1import "dotenv/config";
2import { WebSocketServer } from "ws";
3import {
4 tilesNoEmpty,
5 type GridType,
6 type N2,
7 type Rockets,
8 type TilesNoEmpty,
9} from "../../shared/grid";
10import {
11 compareBid,
12 type CtoSPacket,
13 type CtoSPlayerBidPacket,
14 type CtoSPlayerChatPacket,
15 type CtoSPlayerJoinPacket,
16 type CtoSPlayerVerifyMovePacket,
17 type CtoSPlayerVerifyResetPacket,
18 type CtoSRequestGameEndPacket,
19 type CtoSRequestGameStartPacket,
20 type CtoSUpdateSettingsPacket,
21 type GameInfo,
22 type GameRoom,
23 type StoCEventPacket,
24 type StoCGameCompletedEvent,
25 type StoCGamEndEvent,
26 type StoCGameRevealBoardEvent,
27 type StoCGameStartEvent,
28 type StoCGameStartVerificationEvent,
29 type StoCGameVerifyFailedEvent,
30 type StoCGameVerifyNextEvent,
31 type StoCPacket,
32 type StoCPlayerBidEvent,
33 type StoCPlayerChatEvent,
34 type StoCPlayerJoinEvent,
35 type StoCPlayerJoinResponse,
36 type StoCPlayerVerifyCompletedEvent,
37 type StoCPlayerVerifyMoveEvent,
38 type StoCPlayerVerifyResetEvent,
39 type StoCResponsePacket,
40} from "~/shared/game";
41import { db } from "../db";
42import type WebSocket from "ws";
43import { games, gameUsers, users } from "../db/schema";
44import { and, eq } from "drizzle-orm";
45import { validateSessionToken } from "../auth/validate";
46import { defaultGrid } from "~/app/_components/defaultGrid";
47import { currentTimeInSeconds } from "~/lib/time";
48import {
49 getCompletedMove,
50 getMovementSquaresDown,
51 getMovementSquaresLeft,
52 getMovementSquaresRight,
53 getMovementSquaresUp,
54} from "~/shared/movement";
55
56const wss = new WebSocketServer({ port: 3001 });
57
58// gameId => WebSocket[]
59const gamePlayers = new Map<number, Set<WebSocket>>();
60
61// gameId => non-persistent gamedata
62const gameRooms = new Map<number, GameRoom>();
63
64const broadcastToRoom = (
65 ws: WebSocket,
66 gameId: number,
67 packet: StoCEventPacket,
68) => {
69 if (!gamePlayers.has(gameId)) {
70 return;
71 }
72
73 console.log(`Broadcasting: ${JSON.stringify(packet)}`);
74 gamePlayers.get(gameId)?.forEach((client) => {
75 if (client.readyState === client.OPEN) {
76 client.send(JSON.stringify(packet));
77 }
78 });
79};
80
81wss.on("connection", (ws) => {
82 ws.on("message", (message: string) => {
83 void (async () => {
84 try {
85 console.log("Received:", message.toString());
86 const data = JSON.parse(message.toString()) as CtoSPacket;
87
88 let response: StoCResponsePacket | null = err("No response");
89 switch (data.type) {
90 case "playerJoin":
91 response = await handlePlayerJoinPacket(ws, data);
92 break;
93 case "requestGameStart":
94 response = await handleRequestGameStartPacket(ws, data);
95 break;
96 case "requestGameEnd":
97 response = await handleRequestGameEndPacket(ws, data);
98 break;
99 case "playerChat":
100 response = await handlePlayerChatPacket(ws, data);
101 break;
102 case "playerBid":
103 response = await handlePlayerBidPacket(ws, data);
104 break;
105 case "playerVerifyMove":
106 response = await handlePlayerVerifyMovePacket(ws, data);
107 break;
108 case "playerVerifyReset":
109 response = await handlePlayerVerifyResetPacket(ws, data);
110 break;
111 case "updateSettings":
112 response = await handleUpdateSettingsPacket(ws, data);
113 break;
114 default:
115 console.log(
116 `Unimplemented message ${(data as { type: string }).type}`,
117 );
118 }
119
120 if (response != null) {
121 if ("gameId" in data) {
122 if (gameRooms.has(data.gameId)) {
123 console.log(
124 "Rockets: ",
125 gameRooms.get(data.gameId)!.currentRockets,
126 );
127 }
128 }
129 console.log("Sent:", JSON.stringify(response));
130 ws.send(JSON.stringify(response));
131 }
132 } catch (error) {
133 console.error("Error handling message:", error);
134 }
135 })();
136 });
137});
138
139const err = (message: string): StoCResponsePacket => {
140 return {
141 type: "errorResponse",
142 data: {
143 message,
144 },
145 };
146};
147
148const handlePlayerJoinPacket = async (
149 ws: WebSocket,
150 packet: CtoSPlayerJoinPacket,
151): Promise<StoCResponsePacket> => {
152 const authUser = await validateSessionToken(packet.sessionToken);
153 if (!authUser) {
154 return err("You are not logged in");
155 }
156
157 // Check if game exists
158 const game = (
159 await db.select().from(games).where(eq(games.code, packet.data.gameCode))
160 )[0];
161 if (!game) {
162 return err("No game found with code");
163 }
164
165 // Add connection to a set of connections for the specific game room for broadcasts etc.
166 if (!gamePlayers.has(game.id)) {
167 gamePlayers.set(game.id, new Set());
168 }
169 gamePlayers.get(game.id)?.add(ws);
170
171 if (!gameRooms.has(game.id)) {
172 const board = defaultGrid;
173 const rockets = generateRockets(board);
174
175 gameRooms.set(game.id, {
176 board,
177 restorableRockets: { ...rockets },
178 currentRockets: { ...rockets },
179 currentBids: {},
180 ingameState: "starting",
181 currentVerifyingPlayerId: null,
182 targetTile: null,
183 movesTaken: null,
184 wins: {},
185 usedTiles: [],
186 settings: {
187 startingDelay: 10,
188 biddingCountdownTime: 60,
189 verificationTime: 60,
190 },
191 });
192 }
193
194 // Add player to persistent database of players in game if they aren't there already
195 const existingPlayer = (
196 await db
197 .select()
198 .from(gameUsers)
199 .where(
200 and(eq(gameUsers.gameId, game.id), eq(gameUsers.userId, authUser.id)),
201 )
202 )[0];
203 if (!existingPlayer) {
204 await db.insert(gameUsers).values({
205 gameId: game.id,
206 userId: authUser.id,
207 });
208
209 // Send that a new player has joined to all connected clients
210 broadcastToRoom(ws, game.id, {
211 type: "playerJoinedEvent",
212 data: {
213 id: authUser.id,
214 username: authUser.username,
215 },
216 } satisfies StoCPlayerJoinEvent);
217 }
218 const players = await db
219 .select()
220 .from(gameUsers)
221 .where(eq(gameUsers.gameId, game.id))
222 .leftJoin(users, eq(users.id, gameUsers.userId));
223
224 const gameRoom = gameRooms.get(game.id);
225
226 return {
227 type: "playerJoinResponse",
228 data: {
229 game: {
230 id: game.id,
231 name: game.name,
232 ownerId: game.ownerId,
233 state: game.state,
234 },
235 players: players
236 .map((players) => players.users)
237 .map((user) => ({
238 id: user!.id,
239 username: user!.username,
240 })),
241 room: gameRooms.get(game.id)!,
242 },
243 } satisfies StoCPlayerJoinResponse;
244};
245
246const generateRandomPositionInBounds = (board: GridType): N2 => {
247 const x = Math.floor(Math.random() * board[0]!.length);
248 const y = Math.floor(Math.random() * board.length);
249
250 return {
251 x,
252 y,
253 } satisfies N2;
254};
255
256const generateRockets = (board: GridType): Rockets => {
257 const rocketIds = ["red", "green", "blue", "yellow", "silver"];
258 const rocketPositions: Record<string, N2> = {};
259
260 while (Object.keys(rocketPositions).length !== rocketIds.length) {
261 const position = generateRandomPositionInBounds(board);
262 if (board[position.y]![position.x]!.type !== "empty") {
263 continue;
264 }
265
266 if (
267 Object.values(rocketPositions).find(
268 (pos) => pos.x === position.x && pos.y === position.y,
269 )
270 ) {
271 continue;
272 }
273
274 if ([7, 8].includes(position.x) && [7, 8].includes(position.y)) {
275 continue;
276 }
277
278 const rocketId = rocketIds[Object.keys(rocketPositions).length]!;
279 rocketPositions[rocketId] = position;
280 }
281
282 console.log(rocketPositions);
283 return rocketPositions as Rockets;
284};
285
286const getRandomTile = (usedTiles: TilesNoEmpty[]) =>
287 tilesNoEmpty.filter((e) => !usedTiles.includes(e))[
288 Math.floor(Math.random() * tilesNoEmpty.length)
289 ]!;
290
291const startGame = (ws: WebSocket, game: GameInfo) => {
292 const room = gameRooms.get(game.id)!;
293
294 console.log(room);
295
296 const startingDelay = room.settings.startingDelay;
297
298 room.ingameState = "starting";
299 gameRooms.set(game.id, room);
300
301 broadcastToRoom(ws, game.id, {
302 type: "gameStartEvent",
303 data: {
304 startUnix: currentTimeInSeconds() + startingDelay, // Start game after startingDelay seconds
305 room,
306 },
307 } satisfies StoCGameStartEvent);
308
309 setTimeout(() => {
310 (async () => {
311 const room = gameRooms.get(game.id);
312 if (!room) return;
313
314 const targetTile = getRandomTile(room.usedTiles);
315
316 room.ingameState = "nobid";
317 room.targetTile = targetTile;
318 room.currentBids = {};
319 room.currentRockets = { ...room.restorableRockets };
320 room.currentVerifyingPlayerId = null;
321 room.usedTiles.push(targetTile);
322 gameRooms.set(game.id, room);
323
324 broadcastToRoom(ws, game.id, {
325 type: "gameRevealBoard",
326 data: {
327 room,
328 },
329 } satisfies StoCGameRevealBoardEvent);
330 })();
331 }, startingDelay * 1000);
332};
333
334const handleRequestGameStartPacket = async (
335 ws: WebSocket,
336 packet: CtoSRequestGameStartPacket,
337): Promise<StoCResponsePacket | null> => {
338 const authUser = await validateSessionToken(packet.sessionToken);
339 if (!authUser) {
340 return err("You are not logged in");
341 }
342
343 // Check if game exists
344 const game = (
345 await db.select().from(games).where(eq(games.id, packet.gameId))
346 )[0];
347 if (!game) {
348 return err("No game found with id");
349 }
350
351 if (authUser.id !== game.ownerId) {
352 return err("You are not the owner");
353 }
354
355 await db
356 .update(games)
357 .set({
358 state: "ingame",
359 })
360 .where(eq(games.id, packet.gameId));
361
362 startGame(ws, game);
363
364 return null;
365};
366
367const handleRequestGameEndPacket = async (
368 ws: WebSocket,
369 packet: CtoSRequestGameEndPacket,
370): Promise<StoCResponsePacket | null> => {
371 const authUser = await validateSessionToken(packet.sessionToken);
372 if (!authUser) {
373 return err("You are not logged in");
374 }
375
376 // Check if game exists
377 console.log(packet);
378 const game = (
379 await db.select().from(games).where(eq(games.id, packet.gameId))
380 )[0];
381 if (!game) {
382 return err("No game found with id");
383 }
384
385 if (authUser.id !== game.ownerId) {
386 return err("You are not the owner");
387 }
388
389 await db
390 .update(games)
391 .set({
392 state: "lobby",
393 })
394 .where(eq(games.id, packet.gameId));
395
396 broadcastToRoom(ws, game.id, {
397 type: "gameEndEvent",
398 } satisfies StoCGamEndEvent);
399
400 return null;
401};
402
403const handlePlayerChatPacket = async (
404 ws: WebSocket,
405 packet: CtoSPlayerChatPacket,
406): Promise<StoCResponsePacket | null> => {
407 const authUser = await validateSessionToken(packet.sessionToken);
408 if (!authUser) {
409 return err("You are not logged in");
410 }
411
412 // Check if game exists
413 const game = (
414 await db.select().from(games).where(eq(games.id, packet.gameId))
415 )[0];
416 if (!game) {
417 return err("No game found with code");
418 }
419
420 broadcastToRoom(ws, game.id, {
421 type: "playerChatEvent",
422 data: {
423 playerId: authUser.id,
424 message: packet.data.message,
425 },
426 } satisfies StoCPlayerChatEvent);
427
428 return null;
429};
430
431const handleVerifyNext = (ws: WebSocket, endDelay: number, game: GameInfo) => {
432 const room = gameRooms.get(game.id);
433 if (room?.ingameState !== "verify") {
434 return;
435 }
436
437 console.log("Reached", Object.entries(room.currentBids).length);
438 if (Object.entries(room.currentBids).length === 1) {
439 room.ingameState = "failed";
440 room.currentBids = {};
441 room.currentRockets = { ...room.restorableRockets };
442 room.currentVerifyingPlayerId = null;
443 room.movesTaken = 0;
444 gameRooms.set(game.id, room);
445
446 broadcastToRoom(ws, game.id, {
447 type: "gameVerifyFailed",
448 } satisfies StoCGameVerifyFailedEvent);
449
450 setTimeout(() => {
451 startGame(ws, game);
452 }, endDelay * 1000);
453 return;
454 }
455
456 const sortedBidEntries = Object.entries(room.currentBids).sort((a, b) =>
457 compareBid(a[1], b[1]),
458 );
459 const currentBidsWithoutCurrentVerifier = Object.fromEntries(
460 sortedBidEntries.filter(
461 (e) => e[0] !== room.currentVerifyingPlayerId!.toString(),
462 ),
463 );
464
465 const newCurrentBids = currentBidsWithoutCurrentVerifier;
466 const newVerifyingPlayerId = parseInt(
467 Object.entries(currentBidsWithoutCurrentVerifier)
468 .sort((a, b) => compareBid(a[1], b[1]))[0]![0]
469 .toString(),
470 );
471
472 room.currentBids = newCurrentBids;
473 room.currentVerifyingPlayerId = newVerifyingPlayerId;
474 room.currentRockets = { ...room.restorableRockets };
475 room.movesTaken = 0;
476 gameRooms.set(game.id, room);
477
478 console.log("VerifyNext", room.currentRockets, room.restorableRockets);
479
480 broadcastToRoom(ws, game.id, {
481 type: "gameVerifyNext",
482 data: {
483 newCurrentBids,
484 newVerifyingPlayerId,
485 endUnix: currentTimeInSeconds() + endDelay,
486 },
487 } satisfies StoCGameVerifyNextEvent);
488
489 setTimeout(() => {
490 handleVerifyNext(ws, endDelay, game);
491 }, endDelay * 1000);
492};
493
494const gameStartVerification = async (ws: WebSocket, game: GameInfo) => {
495 const room = gameRooms.get(game.id);
496
497 if (room!.ingameState !== "countdown") {
498 return;
499 }
500
501 const verifyingPlayerId = parseInt(
502 Object.entries(room!.currentBids)
503 .sort((a, b) => compareBid(a[1], b[1]))[0]![0]
504 .toString(),
505 );
506
507 room!.ingameState = "verify";
508 room!.currentVerifyingPlayerId = verifyingPlayerId;
509 gameRooms.set(game.id, room!);
510
511 const endDelay = room!.settings.verificationTime;
512
513 broadcastToRoom(ws, game.id, {
514 type: "gameStartVerification",
515 data: {
516 playerId: verifyingPlayerId,
517 endUnix: currentTimeInSeconds() + endDelay,
518 },
519 } satisfies StoCGameStartVerificationEvent);
520
521 setTimeout(() => {
522 handleVerifyNext(ws, endDelay, game);
523 }, endDelay * 1000);
524};
525
526const handlePlayerBidPacket = async (
527 ws: WebSocket,
528 packet: CtoSPlayerBidPacket,
529): Promise<StoCResponsePacket | null> => {
530 const authUser = await validateSessionToken(packet.sessionToken);
531 if (!authUser) {
532 return err("You are not logged in");
533 }
534
535 // Check if game exists
536 const game = (
537 await db.select().from(games).where(eq(games.id, packet.gameId))
538 )[0];
539 if (!game) {
540 return err("No game found with id");
541 }
542
543 if (game.state !== "ingame") {
544 return err("Gamestate isn't ingame");
545 }
546
547 const gameRoom = gameRooms.get(game.id);
548 if (!gameRoom) {
549 throw new Error("No game room");
550 }
551
552 if (!["nobid", "countdown"].includes(gameRoom.ingameState)) {
553 return err(`Can't bid while ingameState is ${gameRoom.ingameState}`);
554 }
555
556 let updatedBid = false;
557 if (!Object.keys(gameRoom.currentBids).includes(authUser.id.toString())) {
558 gameRoom.currentBids[authUser.id] = packet.data;
559 updatedBid = true;
560 } else {
561 const currentBid = gameRoom.currentBids[authUser.id];
562 if (packet.data.bid < currentBid!.bid) {
563 gameRoom.currentBids[authUser.id] = packet.data;
564 updatedBid = true;
565 }
566 }
567
568 let startedCountdown = false;
569 const endDelay = gameRoom.settings.biddingCountdownTime;
570
571 if (gameRoom.ingameState !== "countdown") {
572 gameRoom.ingameState = "countdown";
573 startedCountdown = true;
574
575 setTimeout(() => {
576 gameStartVerification(ws, game);
577 }, endDelay * 1000); // setTimeout works in ms not seconds so i *1000
578 }
579
580 if (updatedBid) {
581 gameRooms.set(game.id, gameRoom);
582 broadcastToRoom(ws, game.id, {
583 type: "playerBidEvent",
584 data: {
585 playerId: authUser.id,
586 bid: packet.data,
587 endUnix: startedCountdown ? currentTimeInSeconds() + endDelay : null,
588 },
589 } satisfies StoCPlayerBidEvent);
590 }
591
592 return null;
593};
594
595const getTileColor = (tile: TilesNoEmpty): string => {
596 return tile.split("_")[0]!;
597};
598
599const isBoardWon = (
600 board: GridType,
601 rockets: Rockets,
602 targetTile: TilesNoEmpty,
603) => {
604 let targetTilePosition: N2 | null = null;
605
606 board.forEach((row, y) =>
607 row.forEach((cell, x) => {
608 if (cell.type === targetTile) {
609 targetTilePosition = {
610 x,
611 y,
612 };
613 }
614 }),
615 );
616
617 if (targetTilePosition === null) {
618 throw new Error("Target tile was not found on board");
619 }
620
621 const rocketOnGoal = Object.entries(rockets).find(
622 (rocket) =>
623 rocket[1].x === targetTilePosition!.x &&
624 rocket[1].y === targetTilePosition!.y,
625 );
626
627 console.log("a");
628
629 if (!rocketOnGoal) return false;
630
631 const goalColor = getTileColor(targetTile);
632
633 console.log("b", goalColor, rocketOnGoal[0]);
634
635 return goalColor === rocketOnGoal[0] || goalColor === "joker";
636};
637
638const handlePlayerVerifyMovePacket = async (
639 ws: WebSocket,
640 packet: CtoSPlayerVerifyMovePacket,
641): Promise<StoCResponsePacket | null> => {
642 const authUser = await validateSessionToken(packet.sessionToken);
643 if (!authUser) {
644 return err("You are not logged in");
645 }
646
647 // Check if game exists
648 const game = (
649 await db.select().from(games).where(eq(games.id, packet.gameId))
650 )[0];
651 if (!game) {
652 return err("No game found with id");
653 }
654
655 if (game.state !== "ingame") {
656 return err("Gamestate isn't ingame");
657 }
658
659 const gameRoom = gameRooms.get(game.id);
660 if (!gameRoom) {
661 throw new Error("No game room");
662 }
663
664 if (gameRoom.ingameState !== "verify") {
665 return err(`Can't bid while ingameState isn't verify`);
666 }
667
668 if (gameRoom.currentVerifyingPlayerId !== authUser.id) {
669 return err("You are not the current verifying player");
670 }
671
672 console.log("Old", gameRoom.restorableRockets);
673
674 const movedTo = getCompletedMove(
675 gameRoom.board,
676 gameRoom.currentRockets,
677 packet.data.rocket,
678 packet.data.direction,
679 );
680
681 if (!movedTo) {
682 return err("Invalid move: no available space in that direction");
683 }
684
685 gameRoom.currentRockets[packet.data.rocket] = movedTo;
686 gameRoom.movesTaken = gameRoom.movesTaken ? gameRoom.movesTaken + 1 : 1;
687 gameRooms.set(game.id, gameRoom);
688
689 broadcastToRoom(ws, game.id, {
690 type: "playerVerifyMove",
691 data: packet.data,
692 } satisfies StoCPlayerVerifyMoveEvent);
693
694 const bid = gameRoom.currentBids[gameRoom.currentVerifyingPlayerId]?.bid;
695
696 console.log(
697 "Room:",
698 Object.fromEntries(
699 Object.entries(gameRoom).filter(([a, b]) => a !== "board"),
700 ),
701 );
702
703 if (
704 gameRoom.movesTaken === bid &&
705 isBoardWon(gameRoom.board, gameRoom.currentRockets, gameRoom.targetTile!)
706 ) {
707 const { wins = {}, targetTile } = gameRoom;
708 const playerId = gameRoom.currentVerifyingPlayerId;
709
710 const playerWins = wins[playerId] ?? [];
711 const updatedWins = {
712 ...wins,
713 [playerId]: [...playerWins, targetTile!],
714 };
715
716 gameRoom.movesTaken = 0;
717 gameRoom.currentBids = {};
718 gameRoom.wins = updatedWins;
719 gameRoom.restorableRockets = { ...gameRoom.currentRockets };
720 gameRoom.ingameState = "winner";
721 gameRooms.set(game.id, gameRoom);
722
723 const winner = Object.entries(gameRoom.wins).find((e) => e[1].length === 3);
724
725 // Someone won
726 if (winner) {
727 await db
728 .update(games)
729 .set({
730 winnerId: parseInt(winner[0].toString()),
731 })
732 .where(eq(games.id, game.id));
733
734 broadcastToRoom(ws, game.id, {
735 type: "gameCompleted",
736 } satisfies StoCGameCompletedEvent);
737 return null;
738 }
739
740 // No winner this round below
741 const endDelay = 10;
742
743 broadcastToRoom(ws, game.id, {
744 type: "playerVerifyCompleted",
745 data: {
746 wins: updatedWins,
747 endUnix: currentTimeInSeconds() + endDelay,
748 },
749 } satisfies StoCPlayerVerifyCompletedEvent);
750
751 setTimeout(() => {
752 startGame(ws, game);
753 }, endDelay * 1000);
754 }
755
756 return null;
757};
758
759const handlePlayerVerifyResetPacket = async (
760 ws: WebSocket,
761 packet: CtoSPlayerVerifyResetPacket,
762): Promise<StoCResponsePacket | null> => {
763 const authUser = await validateSessionToken(packet.sessionToken);
764 if (!authUser) {
765 return err("You are not logged in");
766 }
767
768 // Check if game exists
769 const game = (
770 await db.select().from(games).where(eq(games.id, packet.gameId))
771 )[0];
772 if (!game) {
773 return err("No game found with id");
774 }
775
776 if (game.state !== "ingame") {
777 return err("Gamestate isn't ingame");
778 }
779
780 const gameRoom = gameRooms.get(game.id);
781 if (!gameRoom) {
782 throw new Error("No game room");
783 }
784
785 if (gameRoom.ingameState !== "verify") {
786 return err(`Can't bid while ingameState isn't verify`);
787 }
788
789 if (gameRoom.currentVerifyingPlayerId !== authUser.id) {
790 return err("You are not the current verifying player");
791 }
792
793 gameRoom.movesTaken = 0;
794 gameRoom.currentRockets = gameRoom.restorableRockets;
795 gameRooms.set(game.id, gameRoom);
796
797 broadcastToRoom(ws, game.id, {
798 type: "playerVerifyReset",
799 } satisfies StoCPlayerVerifyResetEvent);
800
801 return null;
802};
803
804const handleUpdateSettingsPacket = async (
805 ws: WebSocket,
806 packet: CtoSUpdateSettingsPacket,
807): Promise<StoCResponsePacket | null> => {
808 const authUser = await validateSessionToken(packet.sessionToken);
809 if (!authUser) {
810 return err("You are not logged in");
811 }
812
813 // Check if game exists
814 const game = (
815 await db.select().from(games).where(eq(games.id, packet.gameId))
816 )[0];
817 if (!game) {
818 return err("No game found with id");
819 }
820
821 if (authUser.id !== game.ownerId) {
822 return err("You are not the owner");
823 }
824
825 const room = gameRooms.get(game.id);
826 if (!room) return err("No room");
827
828 room.settings = packet.data;
829 gameRooms.set(game.id, room);
830
831 return null;
832};