From e9307a8768f44eaf2d4ea509dc2566c92a502453 Mon Sep 17 00:00:00 2001 From: JMAD Date: Tue, 28 Oct 2025 20:21:25 +0100 Subject: [PATCH 1/6] fix: start game event --- install-languages.sh | 0 .../src/plugins/middleware/authenticated.ts | 5 +- .../src/websocket/connection-manager.ts | 36 ++++---- libs/backend/src/websocket/game/game-setup.ts | 7 +- .../src/websocket/game/on-connection.ts | 2 + .../waiting-room/waiting-room-setup.ts | 19 ++-- .../websocket/waiting-room/waiting-room.ts | 18 ++++ .../src/content/learn/the-basics/README.md | 89 ++++++++++++++++++- .../components/standings-table.svelte | 1 + .../src/lib/websocket/websocket-constants.ts | 71 +++------------ .../lib/websocket/websocket-manager.svelte.ts | 10 +-- .../core/common/enum/websocket-close-codes.ts | 46 ++++++++++ .../core/puzzle/schema/puzzle-dto.schema.ts | 1 + .../puzzle/schema/puzzle-entity.schema.ts | 2 +- libs/types/src/index.ts | 1 + 15 files changed, 205 insertions(+), 103 deletions(-) mode change 100644 => 100755 install-languages.sh create mode 100644 libs/types/src/core/common/enum/websocket-close-codes.ts diff --git a/install-languages.sh b/install-languages.sh old mode 100644 new mode 100755 diff --git a/libs/backend/src/plugins/middleware/authenticated.ts b/libs/backend/src/plugins/middleware/authenticated.ts index 2ea034b2..c17b8b57 100644 --- a/libs/backend/src/plugins/middleware/authenticated.ts +++ b/libs/backend/src/plugins/middleware/authenticated.ts @@ -2,8 +2,7 @@ import { FastifyReply, FastifyRequest } from "fastify"; import { httpResponseCodes, cookieKeys, - environment, - AuthenticatedInfo + AuthenticatedInfo, } from "types"; export default async function authenticated( @@ -19,7 +18,7 @@ export default async function authenticated( .send({ message: "No authentication token provided" }); } - await request.jwtVerify(); + await request.jwtVerify(); } catch (err) { if (err instanceof Error) { return reply diff --git a/libs/backend/src/websocket/connection-manager.ts b/libs/backend/src/websocket/connection-manager.ts index 611b2e73..50b2a0f1 100644 --- a/libs/backend/src/websocket/connection-manager.ts +++ b/libs/backend/src/websocket/connection-manager.ts @@ -1,8 +1,12 @@ import websocket from "@fastify/websocket"; -import { AuthenticatedInfo } from "types"; +import { AuthenticatedInfo, websocketCloseCodes } from "types"; -// WebSocket ready states (from ws library): -// CONNECTING = 0, OPEN = 1, CLOSING = 2, CLOSED = 3 +const websocketState = { + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3 +} as const type Username = string; type ConnectionId = string; @@ -13,7 +17,7 @@ interface Connection { userId: string; heartbeatInterval?: NodeJS.Timeout; lastPong: number; - pongHandler: () => void; // Store for cleanup + pongHandler: () => void; } interface ConnectionCallbacks { @@ -52,8 +56,7 @@ export class ConnectionManager { continue; } - // WebSocket.OPEN = 1 (from ws library) - if (connection.socket.readyState === 1) { + if (connection.socket.readyState === websocketState.OPEN) { try { connection.socket.ping(); } catch (error) { @@ -81,8 +84,7 @@ export class ConnectionManager { const existing = this.connections.get(user.username); if (existing) { socket.removeListener("pong", existing.pongHandler); - // WebSocket.OPEN = 1 (from ws library) - if (existing.socket.readyState === 1) { + if (existing.socket.readyState === websocketState.OPEN) { existing.socket.close(); } } @@ -118,8 +120,7 @@ export class ConnectionManager { connection.socket.removeListener("pong", connection.pongHandler); - // WebSocket.OPEN = 1 (from ws library) - if (connection.socket.readyState === 1) { + if (connection.socket.readyState === websocketState.OPEN) { try { connection.socket.close(); } catch (error) { @@ -137,8 +138,7 @@ export class ConnectionManager { send(username: Username, data: any): boolean { const connection = this.connections.get(username); - // WebSocket.OPEN = 1 (from ws library) - if (!connection || connection.socket.readyState !== 1) { + if (!connection || connection.socket.readyState !== websocketState.OPEN) { return false; } @@ -157,8 +157,7 @@ export class ConnectionManager { this.connections.forEach((connection, username) => { if (excludeUsers.includes(username)) return; - // WebSocket.OPEN = 1 (from ws library) - if (connection.socket.readyState === 1) { + if (connection.socket.readyState === websocketState.OPEN) { try { connection.socket.send(message); } catch (error) { @@ -171,8 +170,7 @@ export class ConnectionManager { isConnected(username: Username): boolean { const connection = this.connections.get(username); - // WebSocket.OPEN = 1 (from ws library) - return connection?.socket.readyState === 1; + return connection?.socket.readyState === websocketState.OPEN; } getConnectionCount(): number { @@ -190,9 +188,9 @@ export class ConnectionManager { for (const [_username, connection] of this.connections.entries()) { connection.socket.removeListener("pong", connection.pongHandler); - // WebSocket.OPEN = 1 (from ws library) - if (connection.socket.readyState === 1) { - connection.socket.close(1001, "Server shutting down"); + + if (connection.socket.readyState === websocketState.OPEN) { + connection.socket.close(websocketCloseCodes.GOING_AWAY, "Server shutting down"); } } diff --git a/libs/backend/src/websocket/game/game-setup.ts b/libs/backend/src/websocket/game/game-setup.ts index 9c26da86..c166b9a8 100644 --- a/libs/backend/src/websocket/game/game-setup.ts +++ b/libs/backend/src/websocket/game/game-setup.ts @@ -9,7 +9,8 @@ import { isGameDto, isPuzzleDto, ObjectId, - banTypeEnum + banTypeEnum, + websocketCloseCodes } from "types"; import { isValidObjectId } from "mongoose"; import { parseRawDataGameRequest } from "@/utils/functions/parse-raw-data-message.js"; @@ -33,7 +34,7 @@ function sendErrorAndClose(socket: WebSocket, message: string): void { message }) ); - socket.close(1008, message); + socket.close(websocketCloseCodes.POLICY_VIOLATION, message); } export async function gameSetup( @@ -125,6 +126,8 @@ export async function gameSetup( return; } + console.log({game}) + const puzzle = await Puzzle.findById(game.puzzle).populate("author"); if (!isPuzzleDto(puzzle)) { diff --git a/libs/backend/src/websocket/game/on-connection.ts b/libs/backend/src/websocket/game/on-connection.ts index 6bbb8b37..e88e550c 100644 --- a/libs/backend/src/websocket/game/on-connection.ts +++ b/libs/backend/src/websocket/game/on-connection.ts @@ -71,6 +71,8 @@ export async function onConnection( const puzzle = await Puzzle.findById(game.puzzle).populate("author"); + console.log({puzzle, game, is: isPuzzleDto(puzzle)}) + if (!isPuzzleDto(puzzle)) { userWebSockets.updateUser(user.username, { event: gameEventEnum.ERROR, diff --git a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts index ba3a31da..408c6541 100644 --- a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts +++ b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts @@ -110,7 +110,7 @@ export function waitingRoomSetup( roomId: parsedMessage.roomId, availableRooms: waitingRoom.getAllRoomIds() }, - "Room not found" + "Room not fwaitingRoomound" ); waitingRoom.updateUser(req.user.username, { event: waitingRoomEventEnum.ERROR, @@ -152,25 +152,16 @@ export function waitingRoomSetup( const databaseGame = new Game(createGameEntity); const newlyCreatedGame = await databaseGame.save(); - const usernamesInRoom = Object.keys(room); - usernamesInRoom.forEach((username) => { - waitingRoom.updateUser(username, { - event: waitingRoomEventEnum.START_GAME, - gameUrl: frontendUrls.multiplayerById(newlyCreatedGame.id) - }); + waitingRoom.updateUsersInRoom(parsedMessage.roomId, { + event: waitingRoomEventEnum.START_GAME, + gameUrl: frontendUrls.multiplayerById(newlyCreatedGame.id) }); - // Give the clients time to receive the START_GAME message before closing connections - setTimeout(() => { - usernamesInRoom.forEach((username) => { - waitingRoom.removeUserFromUsers(username); - }); - }, 100); - fastify.log.info( { gameId: newlyCreatedGame.id, playerCount: players.length }, "Game started" ); + return; } catch (error) { fastify.log.error({ err: error }, "Error starting game"); waitingRoom.updateUser(req.user.username, { diff --git a/libs/backend/src/websocket/waiting-room/waiting-room.ts b/libs/backend/src/websocket/waiting-room/waiting-room.ts index b2a9b8f3..6a8d50b1 100644 --- a/libs/backend/src/websocket/waiting-room/waiting-room.ts +++ b/libs/backend/src/websocket/waiting-room/waiting-room.ts @@ -189,6 +189,24 @@ export class WaitingRoom { } } + dissolveRoom(roomId: RoomId): void { + const room = this.getRoom(roomId); + if (!room) return; + + const usernames = Object.keys(room); + + usernames.forEach(username => { + delete this.roomsByUsername[username]; + }); + delete this.roomsByRoomId[roomId]; + + usernames.forEach(username => { + this.connectionManager.remove(username); + }); + + console.info(`Dissolved room ${roomId} with ${usernames.length} users`); + } + isUserConnected(username: Username): boolean { return this.connectionManager.isConnected(username); } diff --git a/libs/frontend/src/content/learn/the-basics/README.md b/libs/frontend/src/content/learn/the-basics/README.md index af6cbf24..9df221c9 100644 --- a/libs/frontend/src/content/learn/the-basics/README.md +++ b/libs/frontend/src/content/learn/the-basics/README.md @@ -4,24 +4,111 @@ title: Learn the basics # The basics +In order to understand things, analogies are often helpful. + +The analogy we will use here to understand programming, is one from a packer perspective. +You are in a warehouse, packing boxes. +You have different boxes for different things, a small box for a knife sized item, a big box for a fridge, and an envloppe for a bank card. + +These boxes are data types. + ## common data types +Data types, are agreed upon things, that are often present in languages. +You can make your own data types as well, but that's maybe for a different chapter. + ### integer / number +One of the simplest data types to understand is the number one. It allows computer and us, in code to use numbers like we're used to. We can `1 + 1` or `1 - 1`, etc. + +Different languages have different ways of writing them, lets make up our own language and write it as: + +```ruby +number1 = 1 +``` + ### character -### array +Numbers aren't the only things we use, we write things down, like this whole page, and to make sense out of this, or to make sense out of the programs we write, we have added characters to our code over time, it's also handy to display certain messages to people. + +```ruby +h = 'h' +``` + +### array / list + +An array or list is a sequence of a certain data type, for example, a conveyer belt of boxes of a particular size. All the very large boxes for optimization reasons go on the same conveyer belt, whilst the smaller things are grouped together on other conveyer belts. +They don't have to necessarily be grouped together, but it will make sense in a second why it could be handy. + +```ruby +list = [1, 2, 3, 4, 5, 6, 7, 8] +``` ### string +A string is a list / array of characters often internally, the sequence of characters form a word, sentence or even a whole book. + +```ruby +sentence = "Hello world!" +``` + ### boolean +This is probably the easiest to understand box, it's either `true` or `false`, nothing in between. + +```ruby +theAuthorIsHandsome = true +``` + ## control structures +In order to put all these data types to use, we need sometimes some additional help. + ### conditional statements +When we want to control what happens, `A` or `B` then we can use an `if` statement. +Thinking back in our conveyer belts, in order to determine where a box has to go, we can look at the size of the box, and tell it to go left if it exceeds some height. + +```ruby +if theAuthorIsHandsome then + display(sentence) +else + display(anotherSentence) +end +``` + ### loops +Sometimes you don't want to do the same things over and over again. But you don't want to think about it. Or you're busy with other stuff. + #### while +As long as something is true, this will run. If it is true forever, it will run forever. + +```ruby +while theAuthorIsHandsome do + display(sentence) +end +``` + #### for + +If you know for how many steps you have to do something, this loop is a little more useful, you can say for A to B do the following. + +```ruby +for number in 0 to 10 do + display(number) +end +``` + +--- + +That's all there is to programming, the rest is limited only by your creativity. +Every language has their own writing style, and issues and benefits. +There are many discussions about which one is the best or worst, but I won't partake in any of those. + +The easiest language for me to learn, was ruby, so if you don't know what direction you want to go in, that's a solid choice! + +Good luck, may the code be with you! + + diff --git a/libs/frontend/src/lib/features/game/standings/components/standings-table.svelte b/libs/frontend/src/lib/features/game/standings/components/standings-table.svelte index 6a40f069..3f1fec4b 100644 --- a/libs/frontend/src/lib/features/game/standings/components/standings-table.svelte +++ b/libs/frontend/src/lib/features/game/standings/components/standings-table.svelte @@ -88,6 +88,7 @@ : isObjectId(programmingLanguage) ? "Unknown" : programmingLanguage.language} + {#if isUserDto(user)} {index + 1}. diff --git a/libs/frontend/src/lib/websocket/websocket-constants.ts b/libs/frontend/src/lib/websocket/websocket-constants.ts index 4505036a..e9d1ba13 100644 --- a/libs/frontend/src/lib/websocket/websocket-constants.ts +++ b/libs/frontend/src/lib/websocket/websocket-constants.ts @@ -1,3 +1,5 @@ +import { websocketCloseCodes } from "types"; + export const WEBSOCKET_STATES = { CONNECTING: "connecting", CONNECTED: "connected", @@ -16,63 +18,16 @@ export const WEBSOCKET_RECONNECT = { JITTER_RANGE: 1 * 1000 } as const; -export const WEBSOCKET_CLOSE_CODES = { - /** Normal closure; the connection successfully completed */ - NORMAL: 1000, - - /** Going away (e.g., server going down or browser navigating away) */ - GOING_AWAY: 1001, - - /** Protocol error */ - PROTOCOL_ERROR: 1002, - - /** Unsupported data type */ - UNSUPPORTED_DATA: 1003, - - /** Reserved - no status received */ - NO_STATUS: 1005, - - /** Reserved - abnormal closure */ - ABNORMAL_CLOSURE: 1006, - - /** Invalid frame payload data */ - INVALID_PAYLOAD: 1007, - - /** Policy violation (e.g., authentication failure) */ - POLICY_VIOLATION: 1008, - - /** Message too big */ - MESSAGE_TOO_BIG: 1009, - - /** Missing extension */ - MISSING_EXTENSION: 1010, - - /** Internal server error */ - INTERNAL_ERROR: 1011, - - /** Service restart */ - SERVICE_RESTART: 1012, - - /** Try again later */ - TRY_AGAIN_LATER: 1013, - - /** Bad gateway */ - BAD_GATEWAY: 1014, - - /** TLS handshake failure */ - TLS_HANDSHAKE: 1015 -} as const; - export const WEBSOCKET_CLOSE_MESSAGES: Record = { - [WEBSOCKET_CLOSE_CODES.NORMAL]: "Connection closed normally", - [WEBSOCKET_CLOSE_CODES.GOING_AWAY]: "Server going away", - [WEBSOCKET_CLOSE_CODES.PROTOCOL_ERROR]: "Protocol error", - [WEBSOCKET_CLOSE_CODES.UNSUPPORTED_DATA]: "Unsupported data", - [WEBSOCKET_CLOSE_CODES.INVALID_PAYLOAD]: "Invalid payload", - [WEBSOCKET_CLOSE_CODES.POLICY_VIOLATION]: "Authentication failed", - [WEBSOCKET_CLOSE_CODES.MESSAGE_TOO_BIG]: "Message too large", - [WEBSOCKET_CLOSE_CODES.INTERNAL_ERROR]: "Server error", - [WEBSOCKET_CLOSE_CODES.SERVICE_RESTART]: "Service restarting", - [WEBSOCKET_CLOSE_CODES.TRY_AGAIN_LATER]: "Server busy", - [WEBSOCKET_CLOSE_CODES.BAD_GATEWAY]: "Bad gateway" + [websocketCloseCodes.NORMAL]: "Connection closed normally", + [websocketCloseCodes.GOING_AWAY]: "Server going away", + [websocketCloseCodes.PROTOCOL_ERROR]: "Protocol error", + [websocketCloseCodes.UNSUPPORTED_DATA]: "Unsupported data", + [websocketCloseCodes.INVALID_PAYLOAD]: "Invalid payload", + [websocketCloseCodes.POLICY_VIOLATION]: "Authentication failed", + [websocketCloseCodes.MESSAGE_TOO_BIG]: "Message too large", + [websocketCloseCodes.INTERNAL_ERROR]: "Server error", + [websocketCloseCodes.SERVICE_RESTART]: "Service restarting", + [websocketCloseCodes.TRY_AGAIN_LATER]: "Server busy", + [websocketCloseCodes.BAD_GATEWAY]: "Bad gateway" } as const; diff --git a/libs/frontend/src/lib/websocket/websocket-manager.svelte.ts b/libs/frontend/src/lib/websocket/websocket-manager.svelte.ts index 6bdc2cac..c3a05229 100644 --- a/libs/frontend/src/lib/websocket/websocket-manager.svelte.ts +++ b/libs/frontend/src/lib/websocket/websocket-manager.svelte.ts @@ -8,10 +8,10 @@ * - Type-safe message handling */ +import { websocketCloseCodes } from "types"; import { WEBSOCKET_STATES, WEBSOCKET_RECONNECT, - WEBSOCKET_CLOSE_CODES, type WebSocketState } from "./websocket-constants"; @@ -151,7 +151,7 @@ export class WebSocketManager { this.clearReconnectTimer(); if (this.socket) { - this.socket.close(WEBSOCKET_CLOSE_CODES.NORMAL, "Client disconnecting"); + this.socket.close(websocketCloseCodes.NORMAL, "Client disconnecting"); this.socket = null; } @@ -237,13 +237,13 @@ export class WebSocketManager { console.info("WebSocket connection closed:", event.code, event.reason); // Don't reconnect if it was a clean close initiated by client - if (event.code === WEBSOCKET_CLOSE_CODES.NORMAL && !this.shouldReconnect) { + if (event.code === websocketCloseCodes.NORMAL && !this.shouldReconnect) { this.setState(WEBSOCKET_STATES.DISCONNECTED); return; } // Handle authentication errors (code 1008) - if (event.code === WEBSOCKET_CLOSE_CODES.POLICY_VIOLATION) { + if (event.code === websocketCloseCodes.POLICY_VIOLATION) { console.error("WebSocket authentication failed:", event.reason); this.setState(WEBSOCKET_STATES.ERROR); // Don't attempt to reconnect on auth errors - user needs to re-login @@ -256,7 +256,7 @@ export class WebSocketManager { // Handle invalid game/room errors (also code 1008 with specific messages) if ( - event.code === WEBSOCKET_CLOSE_CODES.POLICY_VIOLATION && + event.code === websocketCloseCodes.POLICY_VIOLATION && event.reason && (event.reason.includes("Game not found") || event.reason.includes("Invalid game ID")) diff --git a/libs/types/src/core/common/enum/websocket-close-codes.ts b/libs/types/src/core/common/enum/websocket-close-codes.ts new file mode 100644 index 00000000..74168aad --- /dev/null +++ b/libs/types/src/core/common/enum/websocket-close-codes.ts @@ -0,0 +1,46 @@ +export const websocketCloseCodes = { + /** Normal closure; the connection successfully completed */ + NORMAL: 1000, + + /** Going away (e.g., server going down or browser navigating away) */ + GOING_AWAY: 1001, + + /** Protocol error */ + PROTOCOL_ERROR: 1002, + + /** Unsupported data type */ + UNSUPPORTED_DATA: 1003, + + /** Reserved - no status received */ + NO_STATUS: 1005, + + /** Reserved - abnormal closure */ + ABNORMAL_CLOSURE: 1006, + + /** Invalid frame payload data */ + INVALID_PAYLOAD: 1007, + + /** Policy violation (e.g., authentication failure) */ + POLICY_VIOLATION: 1008, + + /** Message too big */ + MESSAGE_TOO_BIG: 1009, + + /** Missing extension */ + MISSING_EXTENSION: 1010, + + /** Internal server error */ + INTERNAL_ERROR: 1011, + + /** Service restart */ + SERVICE_RESTART: 1012, + + /** Try again later */ + TRY_AGAIN_LATER: 1013, + + /** Bad gateway */ + BAD_GATEWAY: 1014, + + /** TLS handshake failure */ + TLS_HANDSHAKE: 1015 +} as const; \ No newline at end of file diff --git a/libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts b/libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts index 8694b612..42d1da47 100644 --- a/libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts +++ b/libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts @@ -18,5 +18,6 @@ export const puzzleDtoSchema = basePuzzleDtoSchema.extend({ export type PuzzleDto = z.infer; export function isPuzzleDto(data: unknown): data is PuzzleDto { + console.log({result: puzzleDtoSchema.safeParse(data)}) return puzzleDtoSchema.safeParse(data).success; } diff --git a/libs/types/src/core/puzzle/schema/puzzle-entity.schema.ts b/libs/types/src/core/puzzle/schema/puzzle-entity.schema.ts index d9363447..385f488c 100644 --- a/libs/types/src/core/puzzle/schema/puzzle-entity.schema.ts +++ b/libs/types/src/core/puzzle/schema/puzzle-entity.schema.ts @@ -35,7 +35,7 @@ export const puzzleEntitySchema = z.object({ solution: solutionSchema, puzzleMetrics: objectIdSchema.optional(), // TODO: later not now ! tags: z.array(tagSchema).optional(), // TODO: later not now ! - comments: z.array(objectIdSchema).prefault([]), + comments: z.array(objectIdSchema).default([]).optional(), moderationFeedback: z.string().optional(), }); diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index f258e206..086dca1d 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -38,6 +38,7 @@ export * from "./core/common/config/environment.js"; export * from "./core/common/config/default-values-query-params.js"; export * from "./core/common/config/frontend-urls.js"; export * from "./core/common/config/web-socket-urls.js"; +export * from "./core/common/enum/websocket-close-codes.js"; export * from "./core/common/enum/http-response-codes.js"; export * from "./core/common/enum/vote-type-enum.js"; export * from "./core/common/schema/accepted-date.js"; From 71f28bf3f6af2744ddffc2e8c78d20dca12f89fe Mon Sep 17 00:00:00 2001 From: reeveng <36441093+reeveng@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:22:01 +0000 Subject: [PATCH 2/6] Apply automatic changes --- libs/backend/src/plugins/middleware/authenticated.ts | 6 +----- libs/backend/src/websocket/connection-manager.ts | 7 +++++-- libs/backend/src/websocket/game/game-setup.ts | 2 +- libs/backend/src/websocket/game/on-connection.ts | 2 +- libs/backend/src/websocket/waiting-room/waiting-room.ts | 4 ++-- libs/types/src/core/common/enum/websocket-close-codes.ts | 4 ++-- libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts | 2 +- 7 files changed, 13 insertions(+), 14 deletions(-) diff --git a/libs/backend/src/plugins/middleware/authenticated.ts b/libs/backend/src/plugins/middleware/authenticated.ts index c17b8b57..12dc75a1 100644 --- a/libs/backend/src/plugins/middleware/authenticated.ts +++ b/libs/backend/src/plugins/middleware/authenticated.ts @@ -1,9 +1,5 @@ import { FastifyReply, FastifyRequest } from "fastify"; -import { - httpResponseCodes, - cookieKeys, - AuthenticatedInfo, -} from "types"; +import { httpResponseCodes, cookieKeys, AuthenticatedInfo } from "types"; export default async function authenticated( request: FastifyRequest, diff --git a/libs/backend/src/websocket/connection-manager.ts b/libs/backend/src/websocket/connection-manager.ts index 50b2a0f1..955497de 100644 --- a/libs/backend/src/websocket/connection-manager.ts +++ b/libs/backend/src/websocket/connection-manager.ts @@ -6,7 +6,7 @@ const websocketState = { OPEN: 1, CLOSING: 2, CLOSED: 3 -} as const +} as const; type Username = string; type ConnectionId = string; @@ -190,7 +190,10 @@ export class ConnectionManager { connection.socket.removeListener("pong", connection.pongHandler); if (connection.socket.readyState === websocketState.OPEN) { - connection.socket.close(websocketCloseCodes.GOING_AWAY, "Server shutting down"); + connection.socket.close( + websocketCloseCodes.GOING_AWAY, + "Server shutting down" + ); } } diff --git a/libs/backend/src/websocket/game/game-setup.ts b/libs/backend/src/websocket/game/game-setup.ts index c166b9a8..7d4178fe 100644 --- a/libs/backend/src/websocket/game/game-setup.ts +++ b/libs/backend/src/websocket/game/game-setup.ts @@ -126,7 +126,7 @@ export async function gameSetup( return; } - console.log({game}) + console.log({ game }); const puzzle = await Puzzle.findById(game.puzzle).populate("author"); diff --git a/libs/backend/src/websocket/game/on-connection.ts b/libs/backend/src/websocket/game/on-connection.ts index e88e550c..2568c70f 100644 --- a/libs/backend/src/websocket/game/on-connection.ts +++ b/libs/backend/src/websocket/game/on-connection.ts @@ -71,7 +71,7 @@ export async function onConnection( const puzzle = await Puzzle.findById(game.puzzle).populate("author"); - console.log({puzzle, game, is: isPuzzleDto(puzzle)}) + console.log({ puzzle, game, is: isPuzzleDto(puzzle) }); if (!isPuzzleDto(puzzle)) { userWebSockets.updateUser(user.username, { diff --git a/libs/backend/src/websocket/waiting-room/waiting-room.ts b/libs/backend/src/websocket/waiting-room/waiting-room.ts index 6a8d50b1..21d90138 100644 --- a/libs/backend/src/websocket/waiting-room/waiting-room.ts +++ b/libs/backend/src/websocket/waiting-room/waiting-room.ts @@ -195,12 +195,12 @@ export class WaitingRoom { const usernames = Object.keys(room); - usernames.forEach(username => { + usernames.forEach((username) => { delete this.roomsByUsername[username]; }); delete this.roomsByRoomId[roomId]; - usernames.forEach(username => { + usernames.forEach((username) => { this.connectionManager.remove(username); }); diff --git a/libs/types/src/core/common/enum/websocket-close-codes.ts b/libs/types/src/core/common/enum/websocket-close-codes.ts index 74168aad..56a46986 100644 --- a/libs/types/src/core/common/enum/websocket-close-codes.ts +++ b/libs/types/src/core/common/enum/websocket-close-codes.ts @@ -42,5 +42,5 @@ export const websocketCloseCodes = { BAD_GATEWAY: 1014, /** TLS handshake failure */ - TLS_HANDSHAKE: 1015 -} as const; \ No newline at end of file + TLS_HANDSHAKE: 1015, +} as const; diff --git a/libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts b/libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts index 42d1da47..340178d7 100644 --- a/libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts +++ b/libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts @@ -18,6 +18,6 @@ export const puzzleDtoSchema = basePuzzleDtoSchema.extend({ export type PuzzleDto = z.infer; export function isPuzzleDto(data: unknown): data is PuzzleDto { - console.log({result: puzzleDtoSchema.safeParse(data)}) + console.log({ result: puzzleDtoSchema.safeParse(data) }); return puzzleDtoSchema.safeParse(data).success; } From e811e5abab7b984a11b229c5bcb18ed5d9914cea Mon Sep 17 00:00:00 2001 From: J Mad <36441093+reeveng@users.noreply.github.com> Date: Tue, 28 Oct 2025 22:50:10 +0100 Subject: [PATCH 3/6] feat: add some features and fix some stuff --- FEATURE_ROADMAP.md | 278 ++++++++++++++++++ .../src/models/submission/submission.ts | 4 + .../src/plugins/middleware/check-user-ban.ts | 3 +- .../src/routes/puzzle/[id]/solution/index.ts | 1 - .../src/routes/submission/game/index.ts | 6 +- libs/backend/src/routes/submission/index.ts | 1 + libs/backend/src/services/game.service.ts | 152 ++++++++++ .../services/programming-language.service.ts | 101 +++++++ libs/backend/src/services/puzzle.service.ts | 167 +++++++++++ .../src/services/submission.service.ts | 84 ++++++ libs/backend/src/services/user.service.ts | 69 +++++ libs/backend/src/utils/game-mode/README.md | 110 +++++++ .../src/utils/game-mode/game-mode-strategy.ts | 146 +++++++++ .../src/websocket/game/on-connection.ts | 51 ++-- .../waiting-room/waiting-room-setup.ts | 146 +++++---- .../websocket/waiting-room/waiting-room.ts | 105 +++++-- libs/frontend/src/lib/config/test-ids.ts | 15 + .../components/standings-table.svelte | 19 +- .../components/custom-game-dialog.svelte | 252 ++++++++++++++++ .../components/join-by-invite-dialog.svelte | 82 ++++++ .../components/waiting-room-chat.svelte | 97 ++++++ libs/frontend/src/lib/stores/languages.ts | 2 +- .../(authenticated)/multiplayer/+page.svelte | 198 ++++++++++--- .../src/core/game/enum/game-mode-enum.ts | 2 + .../core/game/enum/waiting-room-event-enum.ts | 4 + .../core/game/schema/game-options.schema.ts | 2 +- .../types/src/core/game/schema/mode.schema.ts | 2 +- .../schema/waiting-room-request.schema.ts | 15 + .../schema/waiting-room-response.schema.ts | 18 ++ .../schema/submission-entity.schema.ts | 1 + 30 files changed, 1978 insertions(+), 155 deletions(-) create mode 100644 FEATURE_ROADMAP.md create mode 100644 libs/backend/src/services/game.service.ts create mode 100644 libs/backend/src/services/programming-language.service.ts create mode 100644 libs/backend/src/services/puzzle.service.ts create mode 100644 libs/backend/src/services/submission.service.ts create mode 100644 libs/backend/src/services/user.service.ts create mode 100644 libs/backend/src/utils/game-mode/README.md create mode 100644 libs/backend/src/utils/game-mode/game-mode-strategy.ts create mode 100644 libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte create mode 100644 libs/frontend/src/lib/features/multiplayer/components/join-by-invite-dialog.svelte create mode 100644 libs/frontend/src/lib/features/multiplayer/components/waiting-room-chat.svelte diff --git a/FEATURE_ROADMAP.md b/FEATURE_ROADMAP.md new file mode 100644 index 00000000..16eed2af --- /dev/null +++ b/FEATURE_ROADMAP.md @@ -0,0 +1,278 @@ +# Feature Implementation Roadmap + +## Priority Levels + +- **🔥 HIGH**: Core functionality, user safety, or significant user value +- **🟡 MEDIUM**: Quality of life improvements, engagement features +- **🔵 LOW**: Nice-to-have, future expansion + +--- + +## 🔥 HIGH PRIORITY + +### 1. Custom Game UI (Frontend) +**Status**: Backend ready, needs frontend +**Effort**: Small +**Impact**: High +**Description**: Allow hosts to configure games with custom settings (languages, duration, mode) +**Tasks**: +- Create game options form component +- Add mode selector (FASTEST/SHORTEST/RATED/CASUAL) +- Add language multi-select +- Add duration slider +- Wire up to existing backend API + +**Files to modify**: +- `libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte` + +--- + +### 2. Private Games +**Status**: Not started +**Effort**: Small-Medium +**Impact**: High +**Description**: Allow users to create invite-only games +**Tasks**: +- Add `inviteCode` to Game model +- Generate unique codes for private games +- Add join-by-code UI +- Filter private games from public lobby + +**Implementation**: +```typescript +// Backend +interface GameOptions { + visibility: "public" | "private"; + inviteCode?: string; // Auto-generated for private games +} + +// Frontend + +``` + +--- + +### 3. Reporting System +**Status**: Partially implemented (user bans exist) +**Effort**: Medium +**Impact**: High (user safety) +**Description**: Allow users to report inappropriate content/behavior +**Tasks**: +- Create Report model (reporter, reported, category, description, status) +- Add report button to user profiles, puzzles, comments +- Create admin moderation dashboard +- Implement automated escalation for repeat offenders + +**Existing**: +- User ban system already implemented +- Ban types (temporary, permanent) +- Check user ban middleware + +**New**: +```typescript +interface Report { + reporter: ObjectId; + reported: ObjectId; + category: "spam" | "harassment" | "cheating" | "inappropriate"; + description: string; + status: "pending" | "resolved" | "dismissed"; + evidence?: string[]; +} +``` + +--- + +### 4. User Blocking +**Status**: Not started +**Effort**: Small +**Impact**: Medium (user experience) +**Description**: Allow users to block others +**Tasks**: +- Add `blockedUsers` array to User model +- Create block/unblock endpoints +- Filter blocked users from matchmaking +- Hide blocked users' comments/content + +--- + +## 🟡 MEDIUM PRIORITY + +### 5. Ranked Matchmaking +**Status**: Mode exists, needs matchmaking +**Effort**: Large +**Impact**: High (engagement) +**Description**: Implement ELO/Glicko2 ranking system with skill-based matchmaking +**Tasks**: +- Implement Glicko2 rating algorithm +- Add rating, ratingDeviation, volatility to User model +- Create matchmaking queue system +- Match players by skill level +- Update ratings after games + +**Recommended**: Use existing library like `glicko2` npm package + +--- + +### 6. Community Challenges +**Status**: Puzzle voting exists +**Effort**: Medium +**Impact**: Medium (engagement) +**Description**: User-created challenges with voting +**Tasks**: +- Already have puzzle creation & approval system +- Add challenge categories/tags +- Implement challenge series/folders +- Add difficulty ratings +- Community curation features + +**Leverage existing**: +- Puzzle approval system +- User voting on puzzles +- Comment system + +--- + +### 7. General Chat +**Status**: Not started +**Effort**: Medium +**Impact**: Medium +**Description**: Global chat room for community +**Tasks**: +- Create ChatMessage model +- Implement WebSocket-based chat +- Add message history pagination +- Moderation tools (delete, timeout) +- Rate limiting + +**Reuse**: +- Existing WebSocket infrastructure +- ConnectionManager pattern + +--- + +### 8. Events System +**Status**: Not started +**Effort**: Large +**Impact**: High (engagement, but complex) +**Description**: Scheduled competitions with leaderboards +**Tasks**: +- Create Event model (type, startTime, endTime, puzzles, prizes) +- Event registration system +- Event-specific leaderboards +- Automated event scheduling +- Prize distribution + +**Event Types**: +- Daily challenges +- Weekly competitions +- Monthly tournaments +- Themed events (Python month, etc.) + +--- + +## 🔵 LOW PRIORITY + +### 9. Private Messages +**Status**: Not started +**Effort**: Medium +**Description**: DM system between users +**Tasks**: +- Create Conversation & Message models +- Message thread UI +- Real-time message delivery (WebSocket) +- Read receipts +- Message notifications + +--- + +### 10. Collaborative Puzzles +**Status**: Not started +**Effort**: Large (very complex) +**Description**: Real-time collaborative code editing +**Tasks**: +- Implement operational transformation or CRDT +- Shared code editor state +- Cursor positions for all users +- Team formation system +- Team scoring + +**Note**: Very complex, requires careful architecture + +--- + +### 11. Streaming Integration +**Status**: Not started +**Effort**: Medium +**Description**: Twitch/YouTube integration for streamers +**Tasks**: +- OAuth with streaming platforms +- Overlay widgets for streams +- Streamer mode (hide sensitive info) +- Stream chat integration + +--- + +## Implementation Recommendations + +### Immediate Next Steps (in order): + +1. **Custom Game UI** (1-2 hours) + - Quick win, backend already done + - High user value + +2. **Private Games** (3-4 hours) + - Small backend changes + - Frequently requested feature + +3. **Reporting System** (1-2 days) + - User safety is critical + - Foundation for healthy community + +4. **User Blocking** (3-4 hours) + - Complements reporting system + - Low complexity, high UX value + +5. **Ranked Matchmaking** (3-5 days) + - High engagement potential + - Requires careful testing + +### Technical Debt to Address: + +- Refactor remaining routes to use service layer +- Add comprehensive error handling +- Implement rate limiting on all endpoints +- Add WebSocket reconnection logic +- Create automated tests for game modes + +### Architecture Notes: + +- **Services First**: Always use service layer for DB operations +- **WebSocket Patterns**: Reuse existing ConnectionManager pattern +- **Type Safety**: Leverage Zod schemas from types library +- **Game Modes**: Use strategy pattern for new competitive modes +- **Lean Code**: Avoid over-engineering, iterate quickly + +--- + +## Estimated Timeline + +**Month 1**: +- Custom Game UI +- Private Games +- Reporting System +- User Blocking + +**Month 2**: +- Ranked Matchmaking +- General Chat +- Community Challenges improvements + +**Month 3**: +- Events System (MVP) +- Private Messages +- Platform polish + +**Long-term**: +- Collaborative Puzzles +- Streaming Integration +- Mobile app diff --git a/libs/backend/src/models/submission/submission.ts b/libs/backend/src/models/submission/submission.ts index cc4031c7..4d29b718 100644 --- a/libs/backend/src/models/submission/submission.ts +++ b/libs/backend/src/models/submission/submission.ts @@ -22,6 +22,10 @@ const submissionSchema = new Schema({ type: String, select: false }, + codeLength: { + required: false, + type: Number + }, createdAt: { default: Date.now, type: Date diff --git a/libs/backend/src/plugins/middleware/check-user-ban.ts b/libs/backend/src/plugins/middleware/check-user-ban.ts index 1cd6bb47..60663a6c 100644 --- a/libs/backend/src/plugins/middleware/check-user-ban.ts +++ b/libs/backend/src/plugins/middleware/check-user-ban.ts @@ -2,8 +2,7 @@ import { FastifyReply, FastifyRequest } from "fastify"; import { httpResponseCodes, isAuthenticatedInfo, - banTypeEnum, - environment + banTypeEnum } from "types"; import { checkUserBanStatus } from "../../utils/moderation/escalation.js"; diff --git a/libs/backend/src/routes/puzzle/[id]/solution/index.ts b/libs/backend/src/routes/puzzle/[id]/solution/index.ts index c5e96934..53e4d2d9 100644 --- a/libs/backend/src/routes/puzzle/[id]/solution/index.ts +++ b/libs/backend/src/routes/puzzle/[id]/solution/index.ts @@ -1,6 +1,5 @@ import { FastifyInstance } from "fastify"; import { - environment, ErrorResponse, getUserIdFromUser, httpResponseCodes, diff --git a/libs/backend/src/routes/submission/game/index.ts b/libs/backend/src/routes/submission/game/index.ts index f67fb27c..08cc4f5f 100644 --- a/libs/backend/src/routes/submission/game/index.ts +++ b/libs/backend/src/routes/submission/game/index.ts @@ -9,10 +9,10 @@ import { SubmissionEntity } from "types"; import { isValidationError } from "../../../utils/functions/is-validation-error.js"; -import Game from "@/models/game/game.js"; import Submission from "@/models/submission/submission.js"; import authenticated from "@/plugins/middleware/authenticated.js"; import checkUserBan from "@/plugins/middleware/check-user-ban.js"; +import { gameService } from "@/services/game.service.js"; export default async function submissionGameRoutes(fastify: FastifyInstance) { fastify.post<{ Body: GameSubmissionParams }>( @@ -40,9 +40,7 @@ export default async function submissionGameRoutes(fastify: FastifyInstance) { }); } - const matchingGame = await Game.findById(gameId) - .populate("playerSubmissions") - .exec(); + const matchingGame = await gameService.findByIdPopulated(gameId); if (!matchingGame) { return reply diff --git a/libs/backend/src/routes/submission/index.ts b/libs/backend/src/routes/submission/index.ts index 6e788e44..8b8526ef 100644 --- a/libs/backend/src/routes/submission/index.ts +++ b/libs/backend/src/routes/submission/index.ts @@ -126,6 +126,7 @@ export default async function submissionRoutes(fastify: FastifyInstance) { const submissionData: SubmissionEntity = { code: code, + codeLength: code.length, puzzle: puzzleId, user: userId, createdAt: new Date(), diff --git a/libs/backend/src/services/game.service.ts b/libs/backend/src/services/game.service.ts new file mode 100644 index 00000000..ff5075c4 --- /dev/null +++ b/libs/backend/src/services/game.service.ts @@ -0,0 +1,152 @@ +import Game, { GameDocument } from "../models/game/game.js"; +import { GameEntity, ObjectId } from "types"; + +/** + * Service for Game database operations + * Centralizes all MongoDB queries for games + */ +export class GameService { + /** + * Find a game by ID with all related data populated + */ + async findByIdPopulated(id: string | ObjectId): Promise { + return await Game.findById(id) + .populate("owner") + .populate("players") + .populate({ + path: "playerSubmissions", + populate: [ + { path: "user" }, + { path: "programmingLanguage" } + ] + }) + .exec(); + } + + /** + * Find a game by ID without population + */ + async findById(id: string | ObjectId): Promise { + return await Game.findById(id).exec(); + } + + /** + * Create a new game + */ + async create(gameEntity: GameEntity): Promise { + const game = new Game(gameEntity); + return await game.save(); + } + + /** + * Update a game's player submissions + */ + async addPlayerSubmission( + gameId: string | ObjectId, + submissionId: string | ObjectId + ): Promise { + const game = await Game.findById(gameId); + if (!game) return null; + + const uniqueSubmissions = new Set([ + ...(game.playerSubmissions ?? []), + submissionId.toString() + ]); + game.playerSubmissions = Array.from(uniqueSubmissions); + + return await game.save(); + } + + /** + * Find games by player ID + */ + async findByPlayerId( + playerId: string | ObjectId, + options?: { + limit?: number; + skip?: number; + sort?: Record; + } + ): Promise { + let query = Game.find({ players: playerId }); + + if (options?.sort) { + query = query.sort(options.sort); + } + if (options?.skip) { + query = query.skip(options.skip); + } + if (options?.limit) { + query = query.limit(options.limit); + } + + return await query.exec(); + } + + /** + * Find games by owner ID + */ + async findByOwnerId( + ownerId: string | ObjectId, + options?: { + limit?: number; + skip?: number; + sort?: Record; + } + ): Promise { + let query = Game.find({ owner: ownerId }); + + if (options?.sort) { + query = query.sort(options.sort); + } + if (options?.skip) { + query = query.skip(options.skip); + } + if (options?.limit) { + query = query.limit(options.limit); + } + + return await query.exec(); + } + + /** + * Find all games with optional filters + */ + async findAll(options?: { + filter?: Record; + limit?: number; + skip?: number; + sort?: Record; + }): Promise { + let query = Game.find(options?.filter ?? {}); + + if (options?.sort) { + query = query.sort(options.sort); + } + if (options?.skip) { + query = query.skip(options.skip); + } + if (options?.limit) { + query = query.limit(options.limit); + } + + return await query.exec(); + } + + /** + * Count games matching a filter + */ + async count(filter?: Record): Promise { + return await Game.countDocuments(filter ?? {}); + } + + /** + * Delete a game by ID + */ + async deleteById(id: string | ObjectId): Promise { + return await Game.findByIdAndDelete(id).exec(); + } +} + +// Export a singleton instance +export const gameService = new GameService(); diff --git a/libs/backend/src/services/programming-language.service.ts b/libs/backend/src/services/programming-language.service.ts new file mode 100644 index 00000000..e6fb5752 --- /dev/null +++ b/libs/backend/src/services/programming-language.service.ts @@ -0,0 +1,101 @@ +import ProgrammingLanguage, { + ProgrammingLanguageDocument +} from "../models/programming-language/language.js"; +import { ObjectId, ProgrammingLanguageDto, programmingLanguageDtoSchema } from "types"; + +/** + * Service for ProgrammingLanguage database operations + * Centralizes all MongoDB queries for programming languages + */ +export class ProgrammingLanguageService { + /** + * Find a programming language by ID + */ + async findById(id: string | ObjectId): Promise { + return await ProgrammingLanguage.findById(id).lean() as ProgrammingLanguageDocument | null; + } + + /** + * Find all programming languages + */ + async findAll(): Promise { + return await ProgrammingLanguage.find() + .select("-createdAt -updatedAt -__v") + .sort({ language: 1, version: -1 }) + } + + /** + * Find programming language by language name and version + */ + async findByLanguageAndVersion( + language: string, + version: string + ): Promise { + return await ProgrammingLanguage.findOne({ language, version }) + } + + /** + * Convert a ProgrammingLanguageDocument to DTO + */ + toDto(doc: ProgrammingLanguageDocument): ProgrammingLanguageDto { + return programmingLanguageDtoSchema.parse({ + _id: doc._id.toString(), + language: doc.language, + version: doc.version, + aliases: doc.aliases, + runtime: doc.runtime + }); + } + + /** + * Get all programming languages as DTOs + */ + async findAllAsDto(): Promise { + const languages = await this.findAll(); + return languages.map((lang) => this.toDto(lang)); + } + + /** + * Count all programming languages + */ + async count(): Promise { + return await ProgrammingLanguage.countDocuments({}); + } + + /** + * Create a new programming language + */ + async create(data: { + language: string; + version: string; + aliases?: string[]; + runtime?: string; + }): Promise { + const programmingLanguage = new ProgrammingLanguage(data); + return await programmingLanguage.save(); + } + + /** + * Create multiple programming languages + */ + async createMany( + data: Array<{ + language: string; + version: string; + aliases?: string[]; + runtime?: string; + }> + ): Promise { + return await ProgrammingLanguage.insertMany(data) as ProgrammingLanguageDocument[]; + } + + /** + * Delete all programming languages + */ + async deleteAll(): Promise { + await ProgrammingLanguage.deleteMany({}); + } +} + +// Export a singleton instance +export const programmingLanguageService = new ProgrammingLanguageService(); diff --git a/libs/backend/src/services/puzzle.service.ts b/libs/backend/src/services/puzzle.service.ts new file mode 100644 index 00000000..b7917613 --- /dev/null +++ b/libs/backend/src/services/puzzle.service.ts @@ -0,0 +1,167 @@ +import Puzzle, { PuzzleDocument } from "../models/puzzle/puzzle.js"; +import { ObjectId, PuzzleEntity, puzzleVisibilityEnum } from "types"; +import { PipelineStage } from "mongoose"; + +/** + * Service for Puzzle database operations + * Centralizes all MongoDB queries for puzzles + */ +export class PuzzleService { + /** + * Find a puzzle by ID + */ + async findById(id: string | ObjectId): Promise { + return await Puzzle.findById(id).exec(); + } + + /** + * Find a puzzle by ID with author populated + */ + async findByIdPopulated(id: string | ObjectId): Promise { + return await Puzzle.findById(id).populate("author").exec(); + } + + /** + * Find random approved puzzles + */ + async findRandomApproved(count: number = 1): Promise { + const pipeline: PipelineStage[] = [ + { $match: { visibility: puzzleVisibilityEnum.APPROVED } }, + { $sample: { size: count } } + ]; + return await Puzzle.aggregate(pipeline).exec(); + } + + /** + * Create a new puzzle + */ + async create(puzzleEntity: PuzzleEntity): Promise { + const puzzle = new Puzzle(puzzleEntity); + return await puzzle.save(); + } + + /** + * Update a puzzle by ID + */ + async updateById( + id: string | ObjectId, + update: Partial + ): Promise { + return await Puzzle.findByIdAndUpdate(id, update, { + new: true, + runValidators: true + }).exec(); + } + + /** + * Find puzzles by author ID + */ + async findByAuthorId( + authorId: string | ObjectId, + options?: { + visibility?: string; + limit?: number; + skip?: number; + sort?: Record; + } + ): Promise { + const filter: Record = { author: authorId }; + if (options?.visibility) { + filter.visibility = options.visibility; + } + + let query = Puzzle.find(filter); + + if (options?.sort) { + query = query.sort(options.sort); + } + if (options?.skip) { + query = query.skip(options.skip); + } + if (options?.limit) { + query = query.limit(options.limit); + } + + return await query.exec(); + } + + /** + * Find all puzzles with optional filters + */ + async findAll(options?: { + filter?: Record; + limit?: number; + skip?: number; + sort?: Record; + populate?: string | string[]; + }): Promise { + let query = Puzzle.find(options?.filter ?? {}); + + if (options?.sort) { + query = query.sort(options.sort); + } + if (options?.skip) { + query = query.skip(options.skip); + } + if (options?.limit) { + query = query.limit(options.limit); + } + if (options?.populate) { + query = query.populate(options.populate); + } + + return await query.exec(); + } + + /** + * Count puzzles matching a filter + */ + async count(filter?: Record): Promise { + return await Puzzle.countDocuments(filter ?? {}); + } + + /** + * Delete a puzzle by ID + */ + async deleteById(id: string | ObjectId): Promise { + return await Puzzle.findByIdAndDelete(id).exec(); + } + + /** + * Find puzzles with pagination + */ + async findWithPagination( + page: number, + pageSize: number, + filter?: Record, + sort?: Record + ): Promise<{ + puzzles: PuzzleDocument[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + }> { + const skip = (page - 1) * pageSize; + const [puzzles, total] = await Promise.all([ + this.findAll({ + ...(filter && { filter }), + skip, + limit: pageSize, + ...(sort && { sort }) + }), + this.count(filter) + ]); + + return { + puzzles, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize) + }; + } +} + +// Export a singleton instance +export const puzzleService = new PuzzleService(); diff --git a/libs/backend/src/services/submission.service.ts b/libs/backend/src/services/submission.service.ts new file mode 100644 index 00000000..bffcc0c9 --- /dev/null +++ b/libs/backend/src/services/submission.service.ts @@ -0,0 +1,84 @@ +import Submission, { SubmissionDocument } from "../models/submission/submission.js"; +import { ObjectId, SubmissionEntity } from "types"; + +export class SubmissionService { + async findById(id: string | ObjectId): Promise { + return await Submission.findById(id); + } + + async findByIdWithCode(id: string | ObjectId): Promise { + return await Submission.findById(id).select("+code"); + } + + async findByIdPopulated(id: string | ObjectId): Promise { + return await Submission.findById(id) + .populate("user") + .populate("programmingLanguage") + .populate("puzzle"); + } + + async findByIdWithCodePopulated(id: string | ObjectId): Promise { + return await Submission.findById(id) + .select("+code") + .populate("user") + .populate("programmingLanguage") + .populate("puzzle"); + } + + async findByUser(userId: string | ObjectId, limit?: number): Promise { + const query = Submission.find({ user: userId }) + .sort({ createdAt: -1 }) + .populate("puzzle") + .populate("programmingLanguage"); + + if (limit) { + query.limit(limit); + } + + return await query.exec(); + } + + async findByPuzzle(puzzleId: string | ObjectId): Promise { + return await Submission.find({ puzzle: puzzleId }) + .populate("user") + .populate("programmingLanguage") + .sort({ createdAt: -1 }); + } + + async create(data: SubmissionEntity): Promise { + const submission = new Submission(data); + return await submission.save(); + } + + async countByUser(userId: string | ObjectId): Promise { + return await Submission.countDocuments({ user: userId }); + } + + async countByPuzzle(puzzleId: string | ObjectId): Promise { + return await Submission.countDocuments({ puzzle: puzzleId }); + } + + async findSuccessfulByUser(userId: string | ObjectId): Promise { + return await Submission.find({ + user: userId, + "result.successRate": 1 + }) + .populate("puzzle") + .populate("programmingLanguage") + .sort({ createdAt: -1 }); + } + + async deleteMany(ids: (string | ObjectId)[]): Promise { + await Submission.deleteMany({ _id: { $in: ids } }); + } + + async deleteByPuzzle(puzzleId: string | ObjectId): Promise { + await Submission.deleteMany({ puzzle: puzzleId }); + } + + async deleteByUser(userId: string | ObjectId): Promise { + await Submission.deleteMany({ user: userId }); + } +} + +export const submissionService = new SubmissionService(); diff --git a/libs/backend/src/services/user.service.ts b/libs/backend/src/services/user.service.ts new file mode 100644 index 00000000..42659b88 --- /dev/null +++ b/libs/backend/src/services/user.service.ts @@ -0,0 +1,69 @@ +import User, { UserDocument } from "../models/user/user.js"; +import { ObjectId, UserDto, UserEntity } from "types"; + +export class UserService { + async findById(id: string | ObjectId): Promise { + return await User.findById(id); + } + + async findByIdWithBan(id: string | ObjectId): Promise { + return await User.findById(id).populate("currentBan"); + } + + async findByUsername(username: string): Promise { + return await User.findOne({ username }); + } + + async findByEmail(email: string): Promise { + return await User.findOne({ email }).select("+email"); + } + + async findByUsernameWithPassword(username: string): Promise { + return await User.findOne({ username }).select("+password"); + } + + async create(data: Omit): Promise { + const user = new User(data); + return await user.save(); + } + + async updateProfile( + id: string | ObjectId, + profile: Partial + ): Promise { + return await User.findByIdAndUpdate(id, { $set: { profile } }, { new: true }); + } + + async usernameExists(username: string): Promise { + const count = await User.countDocuments({ username }); + return count > 0; + } + + async emailExists(email: string): Promise { + const count = await User.countDocuments({ email }); + return count > 0; + } + + async updateBan(userId: string | ObjectId, banId: ObjectId | null): Promise { + await User.findByIdAndUpdate(userId, { currentBan: banId }); + } + + async incrementReportCount(userId: string | ObjectId): Promise { + await User.findByIdAndUpdate(userId, { $inc: { reportCount: 1 } }); + } + + async findMany(ids: (string | ObjectId)[]): Promise { + return await User.find({ _id: { $in: ids } }); + } + + toDto(user: UserDocument): UserDto { + return { + _id: (user._id as ObjectId).toString(), + username: user.username, + profile: user.profile, + createdAt: user.createdAt + }; + } +} + +export const userService = new UserService(); diff --git a/libs/backend/src/utils/game-mode/README.md b/libs/backend/src/utils/game-mode/README.md new file mode 100644 index 00000000..f3eacb89 --- /dev/null +++ b/libs/backend/src/utils/game-mode/README.md @@ -0,0 +1,110 @@ +# Game Mode Architecture + +## Overview + +CodinCod uses a **strategy pattern** to handle different game modes. This makes it easy to add new game modes without modifying existing code. + +## Current Game Modes + +- **FASTEST**: Solve the puzzle as quickly as possible (default for competitive play) +- **SHORTEST**: Solve the puzzle with the least amount of characters (code golf) +- **RATED**: Competitive mode with ELO-style ranking (affects player ratings) +- **CASUAL**: Non-competitive mode (doesn't affect ratings) + +## How to Add a New Game Mode + +### 1. Add the Mode to the Enum + +Update `libs/types/src/core/game/enum/game-mode-enum.ts`: + +```typescript +export const GameModeEnum = { + FASTEST: "fastest", + SHORTEST: "shortest", + RATED: "rated", + CASUAL: "casual", + YOUR_NEW_MODE: "your_new_mode", // Add your mode here +} as const; +``` + +### 2. Create a Strategy Class + +In `libs/backend/src/utils/game-mode/game-mode-strategy.ts`, create a new strategy: + +```typescript +class YourNewModeStrategy implements GameModeStrategy { + calculateScore(submission: { + successRate: number; + timeSpent: number; + codeLength?: number; + // Add any custom metrics you need + }): number { + // Return a numeric score for the submission + // Higher is better + return 0; + } + + compareSubmissions( + a: { successRate: number; timeSpent: number; codeLength?: number }, + b: { successRate: number; timeSpent: number; codeLength?: number } + ): number { + // Return negative if a is better, positive if b is better, 0 if equal + // This determines leaderboard order + return 0; + } + + getDisplayMetrics(): string[] { + // Return which metrics should be shown in the UI + return ["score", "yourMetric"]; + } +} +``` + +### 3. Register the Strategy + +Add your strategy to the `strategies` object: + +```typescript +const strategies: Record = { + // ... existing strategies + [GameModeEnum.YOUR_NEW_MODE]: new YourNewModeStrategy(), +}; +``` + +### 4. Add Required Data Fields + +If your mode needs new submission data (like `codeLength` for SHORTEST mode): + +1. Update `libs/types/src/core/submission/schema/submission-entity.schema.ts` +2. Update `libs/backend/src/models/submission/submission.ts` +3. Update submission routes to calculate/store the data + +### 5. Update the Frontend + +Update `libs/frontend/src/lib/features/game/standings/components/standings-table.svelte`: + +```svelte +{#if game.options.mode === GameModeEnum.YOUR_NEW_MODE} + Your Metric +{/if} +``` + +## Architecture Benefits + +✅ **Extensible**: Add new modes without changing existing code +✅ **Type-safe**: TypeScript ensures all modes are handled +✅ **Testable**: Each strategy can be unit tested independently +✅ **Maintainable**: Mode-specific logic is isolated + +## Example: Adding a "Memory Efficient" Mode + +1. Add `MEMORY_EFFICIENT: "memory_efficient"` to GameModeEnum +2. Create `MemoryEfficientModeStrategy` that: + - Tracks peak memory usage during execution + - Scores based on lowest memory usage + success rate + - Breaks ties by execution time +3. Add `peakMemoryUsage` field to SubmissionEntity +4. Update Piston execution to capture memory metrics +5. Update standings table to show memory usage column + +That's it! The game mode system handles the rest automatically. diff --git a/libs/backend/src/utils/game-mode/game-mode-strategy.ts b/libs/backend/src/utils/game-mode/game-mode-strategy.ts new file mode 100644 index 00000000..a4b61d29 --- /dev/null +++ b/libs/backend/src/utils/game-mode/game-mode-strategy.ts @@ -0,0 +1,146 @@ +import { GameModeEnum, type GameMode } from "types"; + +export interface GameModeStrategy { + calculateScore(submission: { + successRate: number; + timeSpent: number; + codeLength?: number; + }): number; + + compareSubmissions( + a: { successRate: number; timeSpent: number; codeLength?: number }, + b: { successRate: number; timeSpent: number; codeLength?: number } + ): number; + + getDisplayMetrics(): string[]; +} + +class FastestModeStrategy implements GameModeStrategy { + calculateScore(submission: { successRate: number; timeSpent: number }): number { + if (submission.successRate < 1) return 0; + return 1000000 / submission.timeSpent; + } + + compareSubmissions( + a: { successRate: number; timeSpent: number }, + b: { successRate: number; timeSpent: number } + ): number { + if (a.successRate !== b.successRate) { + return b.successRate - a.successRate; + } + return a.timeSpent - b.timeSpent; + } + + getDisplayMetrics(): string[] { + return ["score", "time"]; + } +} + +class ShortestModeStrategy implements GameModeStrategy { + calculateScore(submission: { successRate: number; codeLength?: number }): number { + if (submission.successRate < 1 || !submission.codeLength) return 0; + return 1000000 / submission.codeLength; + } + + compareSubmissions( + a: { successRate: number; timeSpent: number; codeLength?: number }, + b: { successRate: number; timeSpent: number; codeLength?: number } + ): number { + if (a.successRate !== b.successRate) { + return b.successRate - a.successRate; + } + const aLength = a.codeLength ?? Number.MAX_SAFE_INTEGER; + const bLength = b.codeLength ?? Number.MAX_SAFE_INTEGER; + if (aLength !== bLength) { + return aLength - bLength; + } + return a.timeSpent - b.timeSpent; + } + + getDisplayMetrics(): string[] { + return ["score", "length", "time"]; + } +} + +class RatedModeStrategy implements GameModeStrategy { + calculateScore(submission: { successRate: number; timeSpent: number }): number { + if (submission.successRate < 1) return 0; + return 1000000 / submission.timeSpent; + } + + compareSubmissions( + a: { successRate: number; timeSpent: number }, + b: { successRate: number; timeSpent: number } + ): number { + if (a.successRate !== b.successRate) { + return b.successRate - a.successRate; + } + return a.timeSpent - b.timeSpent; + } + + getDisplayMetrics(): string[] { + return ["score", "time"]; + } +} + +class CasualModeStrategy implements GameModeStrategy { + calculateScore(submission: { successRate: number }): number { + return submission.successRate; + } + + compareSubmissions( + a: { successRate: number; timeSpent: number }, + b: { successRate: number; timeSpent: number } + ): number { + if (a.successRate !== b.successRate) { + return b.successRate - a.successRate; + } + return a.timeSpent - b.timeSpent; + } + + getDisplayMetrics(): string[] { + return ["score"]; + } +} + +const strategies: Record = { + [GameModeEnum.FASTEST]: new FastestModeStrategy(), + [GameModeEnum.SHORTEST]: new ShortestModeStrategy(), + [GameModeEnum.RATED]: new RatedModeStrategy(), + [GameModeEnum.CASUAL]: new CasualModeStrategy(), +}; + +export function getGameModeStrategy(mode: GameMode): GameModeStrategy { + return strategies[mode]; +} + +export function sortSubmissionsByGameMode( + submissions: T[], + mode: GameMode, + gameStartTime: Date | string +): T[] { + const strategy = getGameModeStrategy(mode); + const startTime = new Date(gameStartTime).getTime(); + + return [...submissions].sort((a, b) => { + const aTime = (new Date(a.createdAt).getTime() - startTime) / 1000; + const bTime = (new Date(b.createdAt).getTime() - startTime) / 1000; + + return strategy.compareSubmissions( + { + successRate: a.result.successRate, + timeSpent: aTime, + ...(a.codeLength !== undefined && { codeLength: a.codeLength }) + }, + { + successRate: b.result.successRate, + timeSpent: bTime, + ...(b.codeLength !== undefined && { codeLength: b.codeLength }) + } + ); + }); +} diff --git a/libs/backend/src/websocket/game/on-connection.ts b/libs/backend/src/websocket/game/on-connection.ts index 2568c70f..dbd8c16c 100644 --- a/libs/backend/src/websocket/game/on-connection.ts +++ b/libs/backend/src/websocket/game/on-connection.ts @@ -1,9 +1,6 @@ -import Game from "@/models/game/game.js"; -import Puzzle from "@/models/puzzle/puzzle.js"; import { AuthenticatedInfo, gameEventEnum, - GameResponse, getUserIdFromUser, isGameDto, isPuzzleDto, @@ -11,6 +8,8 @@ import { } from "types"; import { UserWebSockets } from "./user-web-sockets.js"; import { WebSocket } from "@fastify/websocket"; +import { gameService } from "@/services/game.service.js"; +import { puzzleService } from "@/services/puzzle.service.js"; export async function onConnection( userWebSockets: UserWebSockets, @@ -19,49 +18,38 @@ export async function onConnection( socket: WebSocket ): Promise { try { - const game = await Game.findById(gameId) - .populate("owner") - .populate("players") - .populate({ - path: "playerSubmissions", - populate: { path: "user" } - }) - .exec(); + const game = await gameService.findByIdPopulated(gameId); if (!isGameDto(game)) { - const response: GameResponse = { + socket.send(JSON.stringify({ event: gameEventEnum.NONEXISTENT_GAME, message: "Game not found" - }; - socket.send(JSON.stringify(response)); + })); socket.close(1008, "Game not found"); return; } - const currentPlayerIndex = game.players.findIndex((player) => { - return getUserIdFromUser(player) === user.userId; - }); + const isPlayerInGame = game.players.some(player => + getUserIdFromUser(player) === user.userId + ); - if (currentPlayerIndex === -1) { - const gameOverviewResponse: GameResponse = { + if (!isPlayerInGame) { + socket.send(JSON.stringify({ event: gameEventEnum.OVERVIEW_GAME, game - }; - socket.send(JSON.stringify(gameOverviewResponse)); - - const errorResponse: GameResponse = { + })); + socket.send(JSON.stringify({ event: gameEventEnum.ERROR, - message: `User ${user.userId} hasn't joined this game` - }; - socket.send(JSON.stringify(errorResponse)); + message: `User not in this game` + })); socket.close(1008, "User not in game"); return; } userWebSockets.add(user.username, socket, user); - const currentTime = new Date(); - if (game.endTime < currentTime) { + const isGameFinished = game.endTime < new Date(); + if (isGameFinished) { userWebSockets.updateUser(user.username, { event: gameEventEnum.FINISHED_GAME, game @@ -69,9 +57,8 @@ export async function onConnection( return; } - const puzzle = await Puzzle.findById(game.puzzle).populate("author"); - - console.log({ puzzle, game, is: isPuzzleDto(puzzle) }); + const puzzleId = typeof game.puzzle === 'string' ? game.puzzle : game.puzzle._id.toString(); + const puzzle = await puzzleService.findByIdPopulated(puzzleId); if (!isPuzzleDto(puzzle)) { userWebSockets.updateUser(user.username, { @@ -87,7 +74,7 @@ export async function onConnection( puzzle }); } catch (error) { - console.error("Error in onConnection:", error); + console.error("Error in game websocket connection:", error); socket.close(1011, "Internal server error"); } } diff --git a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts index 408c6541..e1b97eb2 100644 --- a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts +++ b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts @@ -7,14 +7,13 @@ import { GameModeEnum, GameVisibilityEnum, isAuthenticatedInfo, - puzzleVisibilityEnum, waitingRoomEventEnum } from "types"; import { WaitingRoom } from "./waiting-room.js"; import { onConnection as onWaitingRoomConnection } from "./on-connection.js"; import { parseRawDataWaitingRoomRequest } from "@/utils/functions/parse-raw-data-message.js"; -import Puzzle from "@/models/puzzle/puzzle.js"; -import Game from "@/models/game/game.js"; +import { puzzleService } from "@/services/puzzle.service.js"; +import { gameService } from "@/services/game.service.js"; const waitingRoom = new WaitingRoom(); @@ -56,42 +55,81 @@ export function waitingRoomSetup( const { event } = parsedMessage; switch (event) { - case waitingRoomEventEnum.HOST_ROOM: { - const roomId = waitingRoom.hostRoom(req.user); - fastify.log.info( - { username: req.user.username, roomId }, - "User hosted room" - ); + case waitingRoomEventEnum.HOST_ROOM: { + const roomId = waitingRoom.hostRoom(req.user, parsedMessage.options); + fastify.log.info({ username: req.user.username, roomId }, "User hosted room"); + break; + } + + case waitingRoomEventEnum.JOIN_ROOM: { + const success = waitingRoom.joinRoom(req.user, parsedMessage.roomId); + if (!success) { + waitingRoom.updateUser(req.user.username, { + event: waitingRoomEventEnum.ERROR, + message: `Room ${parsedMessage.roomId} not found` + }); + } + break; + } + + case waitingRoomEventEnum.JOIN_BY_INVITE_CODE: { + const roomId = waitingRoom.getRoomByInviteCode(parsedMessage.inviteCode); + if (!roomId) { + waitingRoom.updateUser(req.user.username, { + event: waitingRoomEventEnum.ERROR, + message: `Invalid invite code: ${parsedMessage.inviteCode}` + }); break; } - case waitingRoomEventEnum.JOIN_ROOM: { - const success = waitingRoom.joinRoom(req.user, parsedMessage.roomId); - if (!success) { - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: `Room ${parsedMessage.roomId} not found` - }); - } + const success = waitingRoom.joinRoom(req.user, roomId); + if (!success) { + waitingRoom.updateUser(req.user.username, { + event: waitingRoomEventEnum.ERROR, + message: "Failed to join room" + }); + } + break; + } + + case waitingRoomEventEnum.LEAVE_ROOM: { + waitingRoom.leaveRoom(req.user.username, parsedMessage.roomId); + break; + } + + case waitingRoomEventEnum.CHAT_MESSAGE: { + const room = waitingRoom.getRoom(parsedMessage.roomId); + + if (!room) { + waitingRoom.updateUser(req.user.username, { + event: waitingRoomEventEnum.ERROR, + message: `Room ${parsedMessage.roomId} not found` + }); break; } - case waitingRoomEventEnum.LEAVE_ROOM: { - waitingRoom.leaveRoom(req.user.username, parsedMessage.roomId); + const userInRoom = req.user.username in room; + + if (!userInRoom) { + waitingRoom.updateUser(req.user.username, { + event: waitingRoomEventEnum.ERROR, + message: "You must be in the room to send messages" + }); break; } - case waitingRoomEventEnum.START_GAME: { - try { - fastify.log.info( - { username: req.user.username, roomId: parsedMessage.roomId }, - "START_GAME requested" - ); + waitingRoom.updateUsersInRoom(parsedMessage.roomId, { + event: waitingRoomEventEnum.CHAT_MESSAGE, + username: req.user.username, + message: parsedMessage.message, + timestamp: new Date() + }); + break; + } - const randomPuzzles = await Puzzle.aggregate([ - { $match: { visibility: puzzleVisibilityEnum.APPROVED } }, - { $sample: { size: 1 } } - ]).exec(); + case waitingRoomEventEnum.START_GAME: { + try { + const randomPuzzles = await puzzleService.findRandomApproved(1); if (randomPuzzles.length < 1) { waitingRoom.updateUser(req.user.username, { @@ -101,17 +139,10 @@ export function waitingRoomSetup( return; } - const randomPuzzle = randomPuzzles[0]; + const randomPuzzle = randomPuzzles[0] as any; const room = waitingRoom.getRoom(parsedMessage.roomId); if (!room) { - fastify.log.error( - { - roomId: parsedMessage.roomId, - availableRooms: waitingRoom.getAllRoomIds() - }, - "Room not fwaitingRoomound" - ); waitingRoom.updateUser(req.user.username, { event: waitingRoomEventEnum.ERROR, message: `Room ${parsedMessage.roomId} not found` @@ -131,36 +162,45 @@ export function waitingRoomSetup( } const now = new Date(); + const roomOptions = waitingRoom.getRoomOptions(parsedMessage.roomId); + const gameDuration = roomOptions?.maxGameDurationInSeconds ?? DEFAULT_GAME_LENGTH_IN_MILLISECONDS / 1000; + const gameDurationMs = gameDuration * 1000; + + const countdownSeconds = 15; + const startTime = new Date(now.getTime() + countdownSeconds * 1000); + const endTime = new Date(startTime.getTime() + gameDurationMs); + const createGameEntity: GameEntity = { players, owner: waitingRoom.findRoomOwner(room).userId, - puzzle: randomPuzzle._id.toString(), + puzzle: (randomPuzzle._id as any).toString(), createdAt: now, - startTime: now, - endTime: new Date( - now.getTime() + DEFAULT_GAME_LENGTH_IN_MILLISECONDS - ), + startTime, + endTime, options: { - allowedLanguages: [], - maxGameDurationInSeconds: DEFAULT_GAME_LENGTH_IN_MILLISECONDS, - mode: GameModeEnum.RATED, - visibility: GameVisibilityEnum.PUBLIC + allowedLanguages: roomOptions?.allowedLanguages ?? [], + maxGameDurationInSeconds: gameDuration, + mode: roomOptions?.mode ?? GameModeEnum.FASTEST, + visibility: roomOptions?.visibility ?? GameVisibilityEnum.PUBLIC }, playerSubmissions: [] }; - const databaseGame = new Game(createGameEntity); - const newlyCreatedGame = await databaseGame.save(); + const newlyCreatedGame = await gameService.create(createGameEntity); + const gameUrl = frontendUrls.multiplayerById(newlyCreatedGame.id); waitingRoom.updateUsersInRoom(parsedMessage.roomId, { event: waitingRoomEventEnum.START_GAME, - gameUrl: frontendUrls.multiplayerById(newlyCreatedGame.id) + gameUrl, + startTime }); - fastify.log.info( - { gameId: newlyCreatedGame.id, playerCount: players.length }, - "Game started" - ); + fastify.log.info({ + gameId: newlyCreatedGame.id, + playerCount: players.length, + startTime, + countdownSeconds + }, "Game created with countdown"); return; } catch (error) { fastify.log.error({ err: error }, "Error starting game"); diff --git a/libs/backend/src/websocket/waiting-room/waiting-room.ts b/libs/backend/src/websocket/waiting-room/waiting-room.ts index 21d90138..2f6f89af 100644 --- a/libs/backend/src/websocket/waiting-room/waiting-room.ts +++ b/libs/backend/src/websocket/waiting-room/waiting-room.ts @@ -13,8 +13,28 @@ type Username = string; type RoomId = ObjectId; type Room = Record; +// Custom type for room options to work with exactOptionalPropertyTypes +export type RoomGameOptions = { + allowedLanguages?: Array | undefined; + maxGameDurationInSeconds?: number | undefined; + visibility?: ("private" | "public") | undefined; + mode?: ("fastest" | "shortest" | "rated" | "casual") | undefined; +}; + +interface RoomConfig { + users: Room; + options?: RoomGameOptions | undefined; + inviteCode?: string | undefined; +} + export class WaitingRoom { - private roomsByRoomId: Record; + private roomsByRoomId: Record; private roomsByUsername: Record; private connectionManager: ConnectionManager; @@ -53,31 +73,51 @@ export class WaitingRoom { this.connectionManager.remove(username); } - hostRoom(user: AuthenticatedInfo): RoomId { + hostRoom(user: AuthenticatedInfo, options?: RoomGameOptions): RoomId { const randomId = new mongoose.Types.ObjectId().toString(); + // Generate a 6-character invite code for private rooms + let inviteCode: string | undefined; + if (options?.visibility === "private") { + inviteCode = this.generateInviteCode(); + } + this.roomsByRoomId[randomId] = { - [user.username]: { - joinedAt: new Date(), - userId: user.userId, - username: user.username - } + users: { + [user.username]: { + joinedAt: new Date(), + userId: user.userId, + username: user.username + } + }, + options, + inviteCode }; this.joinRoom(user, randomId); return randomId; } + private generateInviteCode(): string { + // Generate a random 6-character code using uppercase letters and numbers + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let code = ""; + for (let i = 0; i < 6; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return code; + } + joinRoom(user: AuthenticatedInfo, roomId: RoomId): boolean { - const room = this.getRoom(roomId); - if (!room) { + const roomConfig = this.roomsByRoomId[roomId]; + if (!roomConfig) { console.warn( `Room ${roomId} not found when user ${user.username} tried to join` ); return false; } - room[user.username] = { + roomConfig.users[user.username] = { joinedAt: new Date(), userId: user.userId, username: user.username @@ -90,21 +130,21 @@ export class WaitingRoom { } leaveRoom(username: Username, roomId: RoomId): void { - const room = this.getRoom(roomId); - if (!room) { + const roomConfig = this.roomsByRoomId[roomId]; + if (!roomConfig) { console.warn( `Room ${roomId} not found when user ${username} tried to leave` ); return; } - delete room[username]; + delete roomConfig.users[username]; delete this.roomsByUsername[username]; console.info( - `User ${username} left room ${roomId}. Remaining players: ${Object.keys(room).length}` + `User ${username} left room ${roomId}. Remaining players: ${Object.keys(roomConfig.users).length}` ); - if (Object.keys(room).length <= 0) { + if (Object.keys(roomConfig.users).length <= 0) { delete this.roomsByRoomId[roomId]; console.info(`Room ${roomId} is now empty and removed`); } else { @@ -113,13 +153,34 @@ export class WaitingRoom { } getRoom(roomId: RoomId): Room | undefined { - return this.roomsByRoomId[roomId]; + const roomConfig = this.roomsByRoomId[roomId]; + return roomConfig?.users; + } + + getRoomOptions(roomId: RoomId): RoomGameOptions | undefined { + return this.roomsByRoomId[roomId]?.options; } getRooms(): Array<{ roomId: RoomId; amountOfPlayersJoined: number }> { - return Object.entries(this.roomsByRoomId).map(([roomId, room]) => { - return { roomId, amountOfPlayersJoined: Object.keys(room).length }; - }); + // Only return public rooms + return Object.entries(this.roomsByRoomId) + .filter(([_roomId, roomConfig]) => { + return roomConfig.options?.visibility !== "private"; + }) + .map(([roomId, roomConfig]) => { + return { roomId, amountOfPlayersJoined: Object.keys(roomConfig.users).length }; + }); + } + + getRoomByInviteCode(inviteCode: string): RoomId | undefined { + const entry = Object.entries(this.roomsByRoomId).find( + ([_roomId, roomConfig]) => roomConfig.inviteCode === inviteCode + ); + return entry?.[0]; + } + + getInviteCode(roomId: RoomId): string | undefined { + return this.roomsByRoomId[roomId]?.inviteCode; } getAllRoomIds(): RoomId[] { @@ -128,6 +189,7 @@ export class WaitingRoom { updateUsersOnRoomState(roomId: RoomId): void { const room = this.getRoom(roomId); + const inviteCode = this.getInviteCode(roomId); if (!room) { return; } @@ -138,7 +200,8 @@ export class WaitingRoom { room: { users: usersInRoom, owner: this.findRoomOwner(room), - roomId + roomId, + ...(inviteCode && { inviteCode }) } }); } @@ -176,7 +239,7 @@ export class WaitingRoom { removeEmptyRooms(): void { const emptyRoomIds = Object.entries(this.roomsByRoomId) - .filter(([_roomId, room]) => Object.keys(room).length === 0) + .filter(([_roomId, roomConfig]) => Object.keys(roomConfig.users).length === 0) .map(([roomId]) => roomId); emptyRoomIds.forEach((roomId) => { diff --git a/libs/frontend/src/lib/config/test-ids.ts b/libs/frontend/src/lib/config/test-ids.ts index f745be04..eb8e30db 100644 --- a/libs/frontend/src/lib/config/test-ids.ts +++ b/libs/frontend/src/lib/config/test-ids.ts @@ -73,6 +73,21 @@ export const testIds = { MULTIPLAYER_PAGE_BUTTON_JOIN_ROOM: "multiplayer-page-join-room", MULTIPLAYER_PAGE_BUTTON_LEAVE_ROOM: "multiplayer-page-leave-room", MULTIPLAYER_PAGE_BUTTON_START_ROOM: "multiplayer-page-start-room", + MULTIPLAYER_PAGE_BUTTON_CUSTOM_GAME: "multiplayer-page-button-custom-game", + MULTIPLAYER_PAGE_BUTTON_JOIN_BY_INVITE: + "multiplayer-page-button-join-by-invite", + MULTIPLAYER_PAGE_BUTTON_COPY_INVITE: "multiplayer-page-button-copy-invite", + + // custom game dialog + CUSTOM_GAME_DIALOG_BUTTON_CANCEL: "custom-game-dialog-button-cancel", + CUSTOM_GAME_DIALOG_BUTTON_CREATE: "custom-game-dialog-button-create", + + // join by invite dialog + JOIN_BY_INVITE_DIALOG_BUTTON_CANCEL: "join-by-invite-dialog-button-cancel", + JOIN_BY_INVITE_DIALOG_BUTTON_JOIN: "join-by-invite-dialog-button-join", + + // waiting room chat + WAITING_ROOM_CHAT_BUTTON_SEND: "waiting-room-chat-button-send", // main-navigation NAVIGATION_ANCHOR_HOME: "navigation-anchor-home", diff --git a/libs/frontend/src/lib/features/game/standings/components/standings-table.svelte b/libs/frontend/src/lib/features/game/standings/components/standings-table.svelte index 3f1fec4b..1da568ec 100644 --- a/libs/frontend/src/lib/features/game/standings/components/standings-table.svelte +++ b/libs/frontend/src/lib/features/game/standings/components/standings-table.svelte @@ -2,6 +2,7 @@ import * as Table from "$lib/components/ui/table"; import dayjs from "dayjs"; import { + GameModeEnum, isObjectId, isString, isSubmissionDto, @@ -22,6 +23,7 @@ import FishOffIcon from "@lucide/svelte/icons/fish-off"; import Hash from "@lucide/svelte/icons/hash"; import Hourglass from "@lucide/svelte/icons/hourglass"; + import FileCode from "@lucide/svelte/icons/file-code"; import { cn } from "@/utils/cn"; import { calculatePuzzleResultIconColor } from "@/features/puzzles/utils/calculate-puzzle-result-color"; import Codemirror from "../../components/codemirror.svelte"; @@ -34,6 +36,9 @@ }: { game: GameDto; } = $props(); + + let isShortestMode = $derived(game.options.mode === GameModeEnum.SHORTEST); + let submissions: SubmissionDto[] = $derived( game.playerSubmissions.filter((submission) => isSubmissionDto(submission) @@ -76,12 +81,15 @@ Language Score Time + {#if isShortestMode} + Length + {/if} Actions - {#each submissions as { _id, createdAt, programmingLanguage, result, user }, index} + {#each submissions as { _id, codeLength, createdAt, programmingLanguage, result, user }, index} {@const language = isString(programmingLanguage) && programmingLanguage ? programmingLanguage @@ -117,11 +125,18 @@ - + {#if isShortestMode} + + + + + {/if} + + + + diff --git a/libs/frontend/src/lib/features/multiplayer/components/join-by-invite-dialog.svelte b/libs/frontend/src/lib/features/multiplayer/components/join-by-invite-dialog.svelte new file mode 100644 index 00000000..5327aa17 --- /dev/null +++ b/libs/frontend/src/lib/features/multiplayer/components/join-by-invite-dialog.svelte @@ -0,0 +1,82 @@ + + + + + + Join Private Game + + Enter the 6-character invite code to join a private game. + + + +
+
+ + +

+ Code must be exactly 6 characters +

+
+
+ + + + + +
+
diff --git a/libs/frontend/src/lib/features/multiplayer/components/waiting-room-chat.svelte b/libs/frontend/src/lib/features/multiplayer/components/waiting-room-chat.svelte new file mode 100644 index 00000000..2f83217d --- /dev/null +++ b/libs/frontend/src/lib/features/multiplayer/components/waiting-room-chat.svelte @@ -0,0 +1,97 @@ + + + +

Chat

+ + + {#if chatMessages.length > 0} +
    + {#each chatMessages as chatMessage} +
  1. +
    + + {chatMessage.username} + + + {formatTime(chatMessage.timestamp)} + +
    +

    {chatMessage.message}

    +
  2. + {/each} +
+ {:else} +
+

No messages yet. Start chatting!

+
+ {/if} +
+ +
+ + + + +
+
diff --git a/libs/frontend/src/lib/stores/languages.ts b/libs/frontend/src/lib/stores/languages.ts index b3b601cd..177d0343 100644 --- a/libs/frontend/src/lib/stores/languages.ts +++ b/libs/frontend/src/lib/stores/languages.ts @@ -4,7 +4,7 @@ import { writable } from "svelte/store"; import { httpRequestMethod, type ProgrammingLanguageDto } from "types"; const createLanguagesStore = () => { - const { set, subscribe } = writable(null); + const { set, subscribe } = writable([]); return { async loadLanguages() { diff --git a/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte b/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte index af9653b6..dc7aa9e5 100644 --- a/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte +++ b/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte @@ -8,6 +8,10 @@ import Button from "@/components/ui/button/button.svelte"; import Container from "@/components/ui/container/container.svelte"; import LogicalUnit from "@/components/ui/logical-unit/logical-unit.svelte"; + import CountdownTimer from "@/components/ui/countdown-timer/countdown-timer.svelte"; + import CustomGameDialog from "@/features/multiplayer/components/custom-game-dialog.svelte"; + import JoinByInviteDialog from "@/features/multiplayer/components/join-by-invite-dialog.svelte"; + import WaitingRoomChat from "@/features/multiplayer/components/waiting-room-chat.svelte"; import { buildWebSocketUrl } from "@/config/websocket"; import { authenticatedUserInfo } from "@/stores"; import { WebSocketManager } from "@/websocket/websocket-manager.svelte"; @@ -24,14 +28,20 @@ type RoomOverviewResponse, type RoomStateResponse, type WaitingRoomRequest, - type WaitingRoomResponse + type WaitingRoomResponse, + type GameOptions } from "types"; import { testIds } from "@/config/test-ids"; + import { currentTime } from "@/stores/current-time"; let room: RoomStateResponse | undefined = $state(); let rooms: RoomOverviewResponse[] = $state([]); let errorMessage: string | undefined = $state(); let connectionState = $state(WEBSOCKET_STATES.DISCONNECTED); + let pendingGameStart: { gameUrl: string; startTime: Date } | undefined = $state(); + let customGameDialogOpen = $state(false); + let joinByInviteDialogOpen = $state(false); + let chatMessages = $state>([]); const queryParamKeys = { ROOM_ID: "roomId" @@ -76,9 +86,22 @@ room = data.room; } break; + case waitingRoomEventEnum.CHAT_MESSAGE: + { + chatMessages.push({ + username: data.username, + message: data.message, + timestamp: new Date(data.timestamp) + }); + chatMessages = chatMessages; // Trigger reactivity + } + break; case waitingRoomEventEnum.START_GAME: { - goto(data.gameUrl); + pendingGameStart = { + gameUrl: data.gameUrl, + startTime: new Date(data.startTime) + }; } break; case waitingRoomEventEnum.NOT_ENOUGH_PUZZLES: @@ -134,6 +157,18 @@ if (room?.roomId) updateRoomIdInUrl(); }); + // Auto-redirect when countdown reaches zero + $effect(() => { + if (!pendingGameStart) return; + + const now = $currentTime.getTime(); + const startTime = new Date(pendingGameStart.startTime).getTime(); + + if (now >= startTime) { + goto(pendingGameStart.gameUrl); + } + }); + $effect(() => { return () => { wsManager.destroy(); @@ -145,6 +180,38 @@ function sendWaitingRoomMessage(data: WaitingRoomRequest) { wsManager.send(data); } + + function handleHostRoom(options?: Partial) { + sendWaitingRoomMessage({ + event: waitingRoomEventEnum.HOST_ROOM, + ...(options && { options }) + }); + } + + function handleJoinByInvite(inviteCode: string) { + sendWaitingRoomMessage({ + event: waitingRoomEventEnum.JOIN_BY_INVITE_CODE, + inviteCode + }); + } + + async function copyInviteCode(code: string) { + try { + await navigator.clipboard.writeText(code); + } catch (err) { + console.error("Failed to copy invite code:", err); + } + } + + function sendChatMessage(message: string) { + if (!room?.roomId) return; + + sendWaitingRoomMessage({ + event: waitingRoomEventEnum.CHAT_MESSAGE, + roomId: room.roomId, + message + }); + } @@ -175,25 +242,24 @@
{#if room && room.roomId} - + {#if $authenticatedUserInfo?.userId && isAuthor(room?.owner.userId, $authenticatedUserInfo?.userId)} + + - {/if}
- {#if room} -

waiting for the room to start

+ {#if pendingGameStart} +
+

Game Starting Soon!

+

Get ready! The game will begin in:

+ +
+ {:else if room} +
+ {#if room.inviteCode} +
+

🔒 Private Game - Invite Code:

+
+ + {room.inviteCode} + + +
+

+ Share this code with friends to let them join +

+
+ {/if} -
    - {#each room.users as user} -
  • - {user.username}{#if isAuthor(room.owner.userId, user.userId)} - {` - Host!`}{/if} -
  • - {/each} -
+
+
+

Players in Room

+
    + {#each room.users as user} +
  • + {user.username}{#if isAuthor(room.owner.userId, user.userId)} + {` - Host!`}{/if} +
  • + {/each} +
+

+ Waiting for the host to start the game... +

+
+ + {#if $authenticatedUserInfo?.username} + + {/if} +
+
{:else if rooms && rooms.length > 0}
    {#each rooms as joinableRoom} @@ -263,3 +384,6 @@ {/if} {/if} + + + diff --git a/libs/types/src/core/game/enum/game-mode-enum.ts b/libs/types/src/core/game/enum/game-mode-enum.ts index d88344f5..6e7b0df2 100644 --- a/libs/types/src/core/game/enum/game-mode-enum.ts +++ b/libs/types/src/core/game/enum/game-mode-enum.ts @@ -1,4 +1,6 @@ export const GameModeEnum = { + FASTEST: "fastest", + SHORTEST: "shortest", RATED: "rated", CASUAL: "casual", } as const; diff --git a/libs/types/src/core/game/enum/waiting-room-event-enum.ts b/libs/types/src/core/game/enum/waiting-room-event-enum.ts index ce18ecbe..9688b97f 100644 --- a/libs/types/src/core/game/enum/waiting-room-event-enum.ts +++ b/libs/types/src/core/game/enum/waiting-room-event-enum.ts @@ -3,12 +3,16 @@ import { getValues } from "../../../utils/functions/get-values.js"; export const waitingRoomEventEnum = { START_GAME: "game:start", + GAME_STARTING_COUNTDOWN: "game:starting-countdown", HOST_ROOM: "room:host", JOIN_ROOM: "room:join", + JOIN_BY_INVITE_CODE: "room:join-by-invite", LEAVE_ROOM: "room:leave", OVERVIEW_ROOM: "room:overview", + CHAT_MESSAGE: "room:chat-message", + NOT_ENOUGH_PUZZLES: "rooms:not-enough", ERROR: "error", diff --git a/libs/types/src/core/game/schema/game-options.schema.ts b/libs/types/src/core/game/schema/game-options.schema.ts index 0a223aae..4cd65c36 100644 --- a/libs/types/src/core/game/schema/game-options.schema.ts +++ b/libs/types/src/core/game/schema/game-options.schema.ts @@ -13,6 +13,6 @@ export const gameOptionsSchema = z.object({ .prefault([]), maxGameDurationInSeconds: z.number().prefault(DEFAULT_GAME_LENGTH_IN_SECONDS), visibility: gameVisibilitySchema.prefault(GameVisibilityEnum.PUBLIC), - mode: gameModeSchema.prefault(GameModeEnum.RATED), + mode: gameModeSchema.prefault(GameModeEnum.FASTEST), }); export type GameOptions = z.infer; diff --git a/libs/types/src/core/game/schema/mode.schema.ts b/libs/types/src/core/game/schema/mode.schema.ts index cd920815..f809c705 100644 --- a/libs/types/src/core/game/schema/mode.schema.ts +++ b/libs/types/src/core/game/schema/mode.schema.ts @@ -4,5 +4,5 @@ import { GameModeEnum } from "../enum/game-mode-enum.js"; export const gameModeSchema = z .enum(getValues(GameModeEnum)) - .prefault(GameModeEnum.RATED); + .prefault(GameModeEnum.FASTEST); export type GameMode = z.infer; diff --git a/libs/types/src/core/game/schema/waiting-room-request.schema.ts b/libs/types/src/core/game/schema/waiting-room-request.schema.ts index 9de5c7eb..62cd8cd6 100644 --- a/libs/types/src/core/game/schema/waiting-room-request.schema.ts +++ b/libs/types/src/core/game/schema/waiting-room-request.schema.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { waitingRoomEventEnum } from "../enum/waiting-room-event-enum.js"; import { getValues } from "../../../utils/functions/get-values.js"; import { objectIdSchema } from "../../common/schema/object-id.js"; +import { gameOptionsSchema } from "./game-options.schema.js"; const baseMessageSchema = z.object({ event: z.enum(getValues(waitingRoomEventEnum)), @@ -12,6 +13,11 @@ const joinRoomSchema = baseMessageSchema.extend({ roomId: objectIdSchema, }); +const joinByInviteCodeSchema = baseMessageSchema.extend({ + event: z.literal(waitingRoomEventEnum.JOIN_BY_INVITE_CODE), + inviteCode: z.string(), +}); + const leaveRoomSchema = baseMessageSchema.extend({ event: z.literal(waitingRoomEventEnum.LEAVE_ROOM), roomId: objectIdSchema, @@ -19,6 +25,7 @@ const leaveRoomSchema = baseMessageSchema.extend({ const hostRoomSchema = baseMessageSchema.extend({ event: z.literal(waitingRoomEventEnum.HOST_ROOM), + options: gameOptionsSchema.partial().optional(), }); const startGameSchema = baseMessageSchema.extend({ @@ -26,11 +33,19 @@ const startGameSchema = baseMessageSchema.extend({ roomId: objectIdSchema, }); +const chatMessageSchema = baseMessageSchema.extend({ + event: z.literal(waitingRoomEventEnum.CHAT_MESSAGE), + roomId: objectIdSchema, + message: z.string().min(1).max(500), +}); + export const waitingRoomRequestSchema = z.discriminatedUnion("event", [ joinRoomSchema, + joinByInviteCodeSchema, leaveRoomSchema, hostRoomSchema, startGameSchema, + chatMessageSchema, ]); export type WaitingRoomRequest = z.infer; diff --git a/libs/types/src/core/game/schema/waiting-room-response.schema.ts b/libs/types/src/core/game/schema/waiting-room-response.schema.ts index e64ed106..cfbaf40f 100644 --- a/libs/types/src/core/game/schema/waiting-room-response.schema.ts +++ b/libs/types/src/core/game/schema/waiting-room-response.schema.ts @@ -2,16 +2,25 @@ import { z } from "zod"; import { waitingRoomEventEnum } from "../enum/waiting-room-event-enum.js"; import { gameUserInfoSchema } from "./game-user-info.schema.js"; import { objectIdSchema } from "../../common/schema/object-id.js"; +import { acceptedDateSchema } from "../../common/schema/accepted-date.js"; const startGameResponseSchema = z.object({ event: z.literal(waitingRoomEventEnum.START_GAME), gameUrl: z.string(), + startTime: acceptedDateSchema, +}); + +const gameStartingCountdownResponseSchema = z.object({ + event: z.literal(waitingRoomEventEnum.GAME_STARTING_COUNTDOWN), + secondsRemaining: z.number(), + gameUrl: z.string(), }); const roomStateResponseSchema = z.object({ users: z.array(gameUserInfoSchema), owner: gameUserInfoSchema, roomId: objectIdSchema, + inviteCode: z.string().optional(), }); export type RoomStateResponse = z.infer; @@ -39,12 +48,21 @@ const overviewOfRoomsResponseSchema = z.object({ rooms: z.array(roomOverviewResponseSchema), }); +const chatMessageResponseSchema = z.object({ + event: z.literal(waitingRoomEventEnum.CHAT_MESSAGE), + username: z.string(), + message: z.string(), + timestamp: acceptedDateSchema, +}); + export const waitingRoomResponseSchema = z.discriminatedUnion("event", [ startGameResponseSchema, + gameStartingCountdownResponseSchema, overviewRoomResponseSchema, notEnoughPuzzles, waitingRoomErrorResponseSchema, overviewOfRoomsResponseSchema, + chatMessageResponseSchema, ]); export type WaitingRoomResponse = z.infer; diff --git a/libs/types/src/core/submission/schema/submission-entity.schema.ts b/libs/types/src/core/submission/schema/submission-entity.schema.ts index 5273c023..ef9f58b1 100644 --- a/libs/types/src/core/submission/schema/submission-entity.schema.ts +++ b/libs/types/src/core/submission/schema/submission-entity.schema.ts @@ -8,6 +8,7 @@ import { puzzleResultInformationSchema } from "../../piston/schema/puzzle-result export const submissionEntitySchema = z.object({ code: z.string().optional(), + codeLength: z.number().optional(), programmingLanguage: objectIdSchema.or(programmingLanguageDtoSchema), createdAt: acceptedDateSchema.prefault(() => new Date()), puzzle: objectIdSchema.or(puzzleDtoSchema), From bce9821bfb2e2b430c409be465d01689d94da1da Mon Sep 17 00:00:00 2001 From: reeveng <36441093+reeveng@users.noreply.github.com> Date: Tue, 28 Oct 2025 21:53:18 +0000 Subject: [PATCH 4/6] Apply automatic changes --- .../src/plugins/middleware/check-user-ban.ts | 6 +- libs/backend/src/services/game.service.ts | 5 +- .../services/programming-language.service.ts | 22 ++- libs/backend/src/services/puzzle.service.ts | 4 +- .../src/services/submission.service.ts | 29 +++- libs/backend/src/services/user.service.ts | 19 ++- libs/backend/src/utils/game-mode/README.md | 14 +- .../src/utils/game-mode/game-mode-strategy.ts | 57 ++++---- .../src/websocket/game/on-connection.ts | 39 +++-- .../waiting-room/waiting-room-setup.ts | 138 ++++++++++-------- .../websocket/waiting-room/waiting-room.ts | 28 ++-- .../components/custom-game-dialog.svelte | 29 ++-- .../components/join-by-invite-dialog.svelte | 4 +- .../components/waiting-room-chat.svelte | 22 ++- .../(authenticated)/multiplayer/+page.svelte | 69 +++++---- 15 files changed, 285 insertions(+), 200 deletions(-) diff --git a/libs/backend/src/plugins/middleware/check-user-ban.ts b/libs/backend/src/plugins/middleware/check-user-ban.ts index 60663a6c..0414fa3c 100644 --- a/libs/backend/src/plugins/middleware/check-user-ban.ts +++ b/libs/backend/src/plugins/middleware/check-user-ban.ts @@ -1,9 +1,5 @@ import { FastifyReply, FastifyRequest } from "fastify"; -import { - httpResponseCodes, - isAuthenticatedInfo, - banTypeEnum -} from "types"; +import { httpResponseCodes, isAuthenticatedInfo, banTypeEnum } from "types"; import { checkUserBanStatus } from "../../utils/moderation/escalation.js"; export default async function checkUserBan( diff --git a/libs/backend/src/services/game.service.ts b/libs/backend/src/services/game.service.ts index ff5075c4..8999cbf3 100644 --- a/libs/backend/src/services/game.service.ts +++ b/libs/backend/src/services/game.service.ts @@ -15,10 +15,7 @@ export class GameService { .populate("players") .populate({ path: "playerSubmissions", - populate: [ - { path: "user" }, - { path: "programmingLanguage" } - ] + populate: [{ path: "user" }, { path: "programmingLanguage" }] }) .exec(); } diff --git a/libs/backend/src/services/programming-language.service.ts b/libs/backend/src/services/programming-language.service.ts index e6fb5752..0dffb8ad 100644 --- a/libs/backend/src/services/programming-language.service.ts +++ b/libs/backend/src/services/programming-language.service.ts @@ -1,7 +1,11 @@ import ProgrammingLanguage, { ProgrammingLanguageDocument } from "../models/programming-language/language.js"; -import { ObjectId, ProgrammingLanguageDto, programmingLanguageDtoSchema } from "types"; +import { + ObjectId, + ProgrammingLanguageDto, + programmingLanguageDtoSchema +} from "types"; /** * Service for ProgrammingLanguage database operations @@ -11,8 +15,12 @@ export class ProgrammingLanguageService { /** * Find a programming language by ID */ - async findById(id: string | ObjectId): Promise { - return await ProgrammingLanguage.findById(id).lean() as ProgrammingLanguageDocument | null; + async findById( + id: string | ObjectId + ): Promise { + return (await ProgrammingLanguage.findById( + id + ).lean()) as ProgrammingLanguageDocument | null; } /** @@ -21,7 +29,7 @@ export class ProgrammingLanguageService { async findAll(): Promise { return await ProgrammingLanguage.find() .select("-createdAt -updatedAt -__v") - .sort({ language: 1, version: -1 }) + .sort({ language: 1, version: -1 }); } /** @@ -31,7 +39,7 @@ export class ProgrammingLanguageService { language: string, version: string ): Promise { - return await ProgrammingLanguage.findOne({ language, version }) + return await ProgrammingLanguage.findOne({ language, version }); } /** @@ -86,7 +94,9 @@ export class ProgrammingLanguageService { runtime?: string; }> ): Promise { - return await ProgrammingLanguage.insertMany(data) as ProgrammingLanguageDocument[]; + return (await ProgrammingLanguage.insertMany( + data + )) as ProgrammingLanguageDocument[]; } /** diff --git a/libs/backend/src/services/puzzle.service.ts b/libs/backend/src/services/puzzle.service.ts index b7917613..995e0887 100644 --- a/libs/backend/src/services/puzzle.service.ts +++ b/libs/backend/src/services/puzzle.service.ts @@ -17,7 +17,9 @@ export class PuzzleService { /** * Find a puzzle by ID with author populated */ - async findByIdPopulated(id: string | ObjectId): Promise { + async findByIdPopulated( + id: string | ObjectId + ): Promise { return await Puzzle.findById(id).populate("author").exec(); } diff --git a/libs/backend/src/services/submission.service.ts b/libs/backend/src/services/submission.service.ts index bffcc0c9..9e791358 100644 --- a/libs/backend/src/services/submission.service.ts +++ b/libs/backend/src/services/submission.service.ts @@ -1,4 +1,6 @@ -import Submission, { SubmissionDocument } from "../models/submission/submission.js"; +import Submission, { + SubmissionDocument +} from "../models/submission/submission.js"; import { ObjectId, SubmissionEntity } from "types"; export class SubmissionService { @@ -6,18 +8,24 @@ export class SubmissionService { return await Submission.findById(id); } - async findByIdWithCode(id: string | ObjectId): Promise { + async findByIdWithCode( + id: string | ObjectId + ): Promise { return await Submission.findById(id).select("+code"); } - async findByIdPopulated(id: string | ObjectId): Promise { + async findByIdPopulated( + id: string | ObjectId + ): Promise { return await Submission.findById(id) .populate("user") .populate("programmingLanguage") .populate("puzzle"); } - async findByIdWithCodePopulated(id: string | ObjectId): Promise { + async findByIdWithCodePopulated( + id: string | ObjectId + ): Promise { return await Submission.findById(id) .select("+code") .populate("user") @@ -25,7 +33,10 @@ export class SubmissionService { .populate("puzzle"); } - async findByUser(userId: string | ObjectId, limit?: number): Promise { + async findByUser( + userId: string | ObjectId, + limit?: number + ): Promise { const query = Submission.find({ user: userId }) .sort({ createdAt: -1 }) .populate("puzzle") @@ -38,7 +49,9 @@ export class SubmissionService { return await query.exec(); } - async findByPuzzle(puzzleId: string | ObjectId): Promise { + async findByPuzzle( + puzzleId: string | ObjectId + ): Promise { return await Submission.find({ puzzle: puzzleId }) .populate("user") .populate("programmingLanguage") @@ -58,7 +71,9 @@ export class SubmissionService { return await Submission.countDocuments({ puzzle: puzzleId }); } - async findSuccessfulByUser(userId: string | ObjectId): Promise { + async findSuccessfulByUser( + userId: string | ObjectId + ): Promise { return await Submission.find({ user: userId, "result.successRate": 1 diff --git a/libs/backend/src/services/user.service.ts b/libs/backend/src/services/user.service.ts index 42659b88..26f68a1c 100644 --- a/libs/backend/src/services/user.service.ts +++ b/libs/backend/src/services/user.service.ts @@ -18,11 +18,15 @@ export class UserService { return await User.findOne({ email }).select("+email"); } - async findByUsernameWithPassword(username: string): Promise { + async findByUsernameWithPassword( + username: string + ): Promise { return await User.findOne({ username }).select("+password"); } - async create(data: Omit): Promise { + async create( + data: Omit + ): Promise { const user = new User(data); return await user.save(); } @@ -31,7 +35,11 @@ export class UserService { id: string | ObjectId, profile: Partial ): Promise { - return await User.findByIdAndUpdate(id, { $set: { profile } }, { new: true }); + return await User.findByIdAndUpdate( + id, + { $set: { profile } }, + { new: true } + ); } async usernameExists(username: string): Promise { @@ -44,7 +52,10 @@ export class UserService { return count > 0; } - async updateBan(userId: string | ObjectId, banId: ObjectId | null): Promise { + async updateBan( + userId: string | ObjectId, + banId: ObjectId | null + ): Promise { await User.findByIdAndUpdate(userId, { currentBan: banId }); } diff --git a/libs/backend/src/utils/game-mode/README.md b/libs/backend/src/utils/game-mode/README.md index f3eacb89..a57213e7 100644 --- a/libs/backend/src/utils/game-mode/README.md +++ b/libs/backend/src/utils/game-mode/README.md @@ -23,7 +23,7 @@ export const GameModeEnum = { SHORTEST: "shortest", RATED: "rated", CASUAL: "casual", - YOUR_NEW_MODE: "your_new_mode", // Add your mode here + YOUR_NEW_MODE: "your_new_mode" // Add your mode here } as const; ``` @@ -33,9 +33,9 @@ In `libs/backend/src/utils/game-mode/game-mode-strategy.ts`, create a new strate ```typescript class YourNewModeStrategy implements GameModeStrategy { - calculateScore(submission: { - successRate: number; - timeSpent: number; + calculateScore(submission: { + successRate: number; + timeSpent: number; codeLength?: number; // Add any custom metrics you need }): number { @@ -43,7 +43,7 @@ class YourNewModeStrategy implements GameModeStrategy { // Higher is better return 0; } - + compareSubmissions( a: { successRate: number; timeSpent: number; codeLength?: number }, b: { successRate: number; timeSpent: number; codeLength?: number } @@ -52,7 +52,7 @@ class YourNewModeStrategy implements GameModeStrategy { // This determines leaderboard order return 0; } - + getDisplayMetrics(): string[] { // Return which metrics should be shown in the UI return ["score", "yourMetric"]; @@ -67,7 +67,7 @@ Add your strategy to the `strategies` object: ```typescript const strategies: Record = { // ... existing strategies - [GameModeEnum.YOUR_NEW_MODE]: new YourNewModeStrategy(), + [GameModeEnum.YOUR_NEW_MODE]: new YourNewModeStrategy() }; ``` diff --git a/libs/backend/src/utils/game-mode/game-mode-strategy.ts b/libs/backend/src/utils/game-mode/game-mode-strategy.ts index a4b61d29..ab90342c 100644 --- a/libs/backend/src/utils/game-mode/game-mode-strategy.ts +++ b/libs/backend/src/utils/game-mode/game-mode-strategy.ts @@ -6,21 +6,24 @@ export interface GameModeStrategy { timeSpent: number; codeLength?: number; }): number; - + compareSubmissions( a: { successRate: number; timeSpent: number; codeLength?: number }, b: { successRate: number; timeSpent: number; codeLength?: number } ): number; - + getDisplayMetrics(): string[]; } class FastestModeStrategy implements GameModeStrategy { - calculateScore(submission: { successRate: number; timeSpent: number }): number { + calculateScore(submission: { + successRate: number; + timeSpent: number; + }): number { if (submission.successRate < 1) return 0; return 1000000 / submission.timeSpent; } - + compareSubmissions( a: { successRate: number; timeSpent: number }, b: { successRate: number; timeSpent: number } @@ -30,18 +33,21 @@ class FastestModeStrategy implements GameModeStrategy { } return a.timeSpent - b.timeSpent; } - + getDisplayMetrics(): string[] { return ["score", "time"]; } } class ShortestModeStrategy implements GameModeStrategy { - calculateScore(submission: { successRate: number; codeLength?: number }): number { + calculateScore(submission: { + successRate: number; + codeLength?: number; + }): number { if (submission.successRate < 1 || !submission.codeLength) return 0; return 1000000 / submission.codeLength; } - + compareSubmissions( a: { successRate: number; timeSpent: number; codeLength?: number }, b: { successRate: number; timeSpent: number; codeLength?: number } @@ -56,18 +62,21 @@ class ShortestModeStrategy implements GameModeStrategy { } return a.timeSpent - b.timeSpent; } - + getDisplayMetrics(): string[] { return ["score", "length", "time"]; } } class RatedModeStrategy implements GameModeStrategy { - calculateScore(submission: { successRate: number; timeSpent: number }): number { + calculateScore(submission: { + successRate: number; + timeSpent: number; + }): number { if (submission.successRate < 1) return 0; return 1000000 / submission.timeSpent; } - + compareSubmissions( a: { successRate: number; timeSpent: number }, b: { successRate: number; timeSpent: number } @@ -77,7 +86,7 @@ class RatedModeStrategy implements GameModeStrategy { } return a.timeSpent - b.timeSpent; } - + getDisplayMetrics(): string[] { return ["score", "time"]; } @@ -87,7 +96,7 @@ class CasualModeStrategy implements GameModeStrategy { calculateScore(submission: { successRate: number }): number { return submission.successRate; } - + compareSubmissions( a: { successRate: number; timeSpent: number }, b: { successRate: number; timeSpent: number } @@ -97,7 +106,7 @@ class CasualModeStrategy implements GameModeStrategy { } return a.timeSpent - b.timeSpent; } - + getDisplayMetrics(): string[] { return ["score"]; } @@ -107,29 +116,27 @@ const strategies: Record = { [GameModeEnum.FASTEST]: new FastestModeStrategy(), [GameModeEnum.SHORTEST]: new ShortestModeStrategy(), [GameModeEnum.RATED]: new RatedModeStrategy(), - [GameModeEnum.CASUAL]: new CasualModeStrategy(), + [GameModeEnum.CASUAL]: new CasualModeStrategy() }; export function getGameModeStrategy(mode: GameMode): GameModeStrategy { return strategies[mode]; } -export function sortSubmissionsByGameMode( - submissions: T[], - mode: GameMode, - gameStartTime: Date | string -): T[] { +export function sortSubmissionsByGameMode< + T extends { + result: { successRate: number }; + createdAt: Date | string; + codeLength?: number; + } +>(submissions: T[], mode: GameMode, gameStartTime: Date | string): T[] { const strategy = getGameModeStrategy(mode); const startTime = new Date(gameStartTime).getTime(); - + return [...submissions].sort((a, b) => { const aTime = (new Date(a.createdAt).getTime() - startTime) / 1000; const bTime = (new Date(b.createdAt).getTime() - startTime) / 1000; - + return strategy.compareSubmissions( { successRate: a.result.successRate, diff --git a/libs/backend/src/websocket/game/on-connection.ts b/libs/backend/src/websocket/game/on-connection.ts index dbd8c16c..ac21541b 100644 --- a/libs/backend/src/websocket/game/on-connection.ts +++ b/libs/backend/src/websocket/game/on-connection.ts @@ -21,27 +21,33 @@ export async function onConnection( const game = await gameService.findByIdPopulated(gameId); if (!isGameDto(game)) { - socket.send(JSON.stringify({ - event: gameEventEnum.NONEXISTENT_GAME, - message: "Game not found" - })); + socket.send( + JSON.stringify({ + event: gameEventEnum.NONEXISTENT_GAME, + message: "Game not found" + }) + ); socket.close(1008, "Game not found"); return; } - const isPlayerInGame = game.players.some(player => - getUserIdFromUser(player) === user.userId + const isPlayerInGame = game.players.some( + (player) => getUserIdFromUser(player) === user.userId ); if (!isPlayerInGame) { - socket.send(JSON.stringify({ - event: gameEventEnum.OVERVIEW_GAME, - game - })); - socket.send(JSON.stringify({ - event: gameEventEnum.ERROR, - message: `User not in this game` - })); + socket.send( + JSON.stringify({ + event: gameEventEnum.OVERVIEW_GAME, + game + }) + ); + socket.send( + JSON.stringify({ + event: gameEventEnum.ERROR, + message: `User not in this game` + }) + ); socket.close(1008, "User not in game"); return; } @@ -57,7 +63,10 @@ export async function onConnection( return; } - const puzzleId = typeof game.puzzle === 'string' ? game.puzzle : game.puzzle._id.toString(); + const puzzleId = + typeof game.puzzle === "string" + ? game.puzzle + : game.puzzle._id.toString(); const puzzle = await puzzleService.findByIdPopulated(puzzleId); if (!isPuzzleDto(puzzle)) { diff --git a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts index e1b97eb2..5718b2d9 100644 --- a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts +++ b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts @@ -55,79 +55,84 @@ export function waitingRoomSetup( const { event } = parsedMessage; switch (event) { - case waitingRoomEventEnum.HOST_ROOM: { - const roomId = waitingRoom.hostRoom(req.user, parsedMessage.options); - fastify.log.info({ username: req.user.username, roomId }, "User hosted room"); - break; - } + case waitingRoomEventEnum.HOST_ROOM: { + const roomId = waitingRoom.hostRoom(req.user, parsedMessage.options); + fastify.log.info( + { username: req.user.username, roomId }, + "User hosted room" + ); + break; + } - case waitingRoomEventEnum.JOIN_ROOM: { - const success = waitingRoom.joinRoom(req.user, parsedMessage.roomId); - if (!success) { - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: `Room ${parsedMessage.roomId} not found` - }); + case waitingRoomEventEnum.JOIN_ROOM: { + const success = waitingRoom.joinRoom(req.user, parsedMessage.roomId); + if (!success) { + waitingRoom.updateUser(req.user.username, { + event: waitingRoomEventEnum.ERROR, + message: `Room ${parsedMessage.roomId} not found` + }); + } + break; } - break; - } - case waitingRoomEventEnum.JOIN_BY_INVITE_CODE: { - const roomId = waitingRoom.getRoomByInviteCode(parsedMessage.inviteCode); - if (!roomId) { - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: `Invalid invite code: ${parsedMessage.inviteCode}` - }); + case waitingRoomEventEnum.JOIN_BY_INVITE_CODE: { + const roomId = waitingRoom.getRoomByInviteCode( + parsedMessage.inviteCode + ); + if (!roomId) { + waitingRoom.updateUser(req.user.username, { + event: waitingRoomEventEnum.ERROR, + message: `Invalid invite code: ${parsedMessage.inviteCode}` + }); + break; + } + + const success = waitingRoom.joinRoom(req.user, roomId); + if (!success) { + waitingRoom.updateUser(req.user.username, { + event: waitingRoomEventEnum.ERROR, + message: "Failed to join room" + }); + } break; } - const success = waitingRoom.joinRoom(req.user, roomId); - if (!success) { - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: "Failed to join room" - }); + case waitingRoomEventEnum.LEAVE_ROOM: { + waitingRoom.leaveRoom(req.user.username, parsedMessage.roomId); + break; } - break; - } - case waitingRoomEventEnum.LEAVE_ROOM: { - waitingRoom.leaveRoom(req.user.username, parsedMessage.roomId); - break; - } + case waitingRoomEventEnum.CHAT_MESSAGE: { + const room = waitingRoom.getRoom(parsedMessage.roomId); - case waitingRoomEventEnum.CHAT_MESSAGE: { - const room = waitingRoom.getRoom(parsedMessage.roomId); + if (!room) { + waitingRoom.updateUser(req.user.username, { + event: waitingRoomEventEnum.ERROR, + message: `Room ${parsedMessage.roomId} not found` + }); + break; + } - if (!room) { - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: `Room ${parsedMessage.roomId} not found` - }); - break; - } + const userInRoom = req.user.username in room; - const userInRoom = req.user.username in room; + if (!userInRoom) { + waitingRoom.updateUser(req.user.username, { + event: waitingRoomEventEnum.ERROR, + message: "You must be in the room to send messages" + }); + break; + } - if (!userInRoom) { - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: "You must be in the room to send messages" + waitingRoom.updateUsersInRoom(parsedMessage.roomId, { + event: waitingRoomEventEnum.CHAT_MESSAGE, + username: req.user.username, + message: parsedMessage.message, + timestamp: new Date() }); break; } - waitingRoom.updateUsersInRoom(parsedMessage.roomId, { - event: waitingRoomEventEnum.CHAT_MESSAGE, - username: req.user.username, - message: parsedMessage.message, - timestamp: new Date() - }); - break; - } - - case waitingRoomEventEnum.START_GAME: { + case waitingRoomEventEnum.START_GAME: { try { const randomPuzzles = await puzzleService.findRandomApproved(1); @@ -163,7 +168,9 @@ export function waitingRoomSetup( const now = new Date(); const roomOptions = waitingRoom.getRoomOptions(parsedMessage.roomId); - const gameDuration = roomOptions?.maxGameDurationInSeconds ?? DEFAULT_GAME_LENGTH_IN_MILLISECONDS / 1000; + const gameDuration = + roomOptions?.maxGameDurationInSeconds ?? + DEFAULT_GAME_LENGTH_IN_MILLISECONDS / 1000; const gameDurationMs = gameDuration * 1000; const countdownSeconds = 15; @@ -195,12 +202,15 @@ export function waitingRoomSetup( startTime }); - fastify.log.info({ - gameId: newlyCreatedGame.id, - playerCount: players.length, - startTime, - countdownSeconds - }, "Game created with countdown"); + fastify.log.info( + { + gameId: newlyCreatedGame.id, + playerCount: players.length, + startTime, + countdownSeconds + }, + "Game created with countdown" + ); return; } catch (error) { fastify.log.error({ err: error }, "Error starting game"); diff --git a/libs/backend/src/websocket/waiting-room/waiting-room.ts b/libs/backend/src/websocket/waiting-room/waiting-room.ts index 2f6f89af..19b5068f 100644 --- a/libs/backend/src/websocket/waiting-room/waiting-room.ts +++ b/libs/backend/src/websocket/waiting-room/waiting-room.ts @@ -15,13 +15,18 @@ type Room = Record; // Custom type for room options to work with exactOptionalPropertyTypes export type RoomGameOptions = { - allowedLanguages?: Array | undefined; + allowedLanguages?: + | Array< + | string + | { + language: string; + version: string; + aliases: string[]; + _id?: string | undefined; + runtime?: string | undefined; + } + > + | undefined; maxGameDurationInSeconds?: number | undefined; visibility?: ("private" | "public") | undefined; mode?: ("fastest" | "shortest" | "rated" | "casual") | undefined; @@ -168,7 +173,10 @@ export class WaitingRoom { return roomConfig.options?.visibility !== "private"; }) .map(([roomId, roomConfig]) => { - return { roomId, amountOfPlayersJoined: Object.keys(roomConfig.users).length }; + return { + roomId, + amountOfPlayersJoined: Object.keys(roomConfig.users).length + }; }); } @@ -239,7 +247,9 @@ export class WaitingRoom { removeEmptyRooms(): void { const emptyRoomIds = Object.entries(this.roomsByRoomId) - .filter(([_roomId, roomConfig]) => Object.keys(roomConfig.users).length === 0) + .filter( + ([_roomId, roomConfig]) => Object.keys(roomConfig.users).length === 0 + ) .map(([roomId]) => roomId); emptyRoomIds.forEach((roomId) => { diff --git a/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte b/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte index 649d13b3..7b9e489a 100644 --- a/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte +++ b/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte @@ -50,9 +50,10 @@ if (allLanguagesSelected) { selectedLanguageIds.clear(); } else { - const ids = $languages - ?.map((l) => l._id) - .filter((id): id is string => id !== undefined) || []; + const ids = + $languages + ?.map((l) => l._id) + .filter((id): id is string => id !== undefined) || []; selectedLanguageIds = new Set(ids); } } @@ -118,7 +119,7 @@ -

    +

    {#if selectedMode === GameModeEnum.FASTEST} Win by submitting a correct solution first {:else if selectedMode === GameModeEnum.SHORTEST} @@ -150,7 +151,7 @@ -

    +

    {#if selectedVisibility === GameVisibilityEnum.PUBLIC} Anyone can join this game {:else} @@ -173,7 +174,7 @@ bind:value={durationMinutes} class="cursor-pointer" /> -

    +
    5 min 60 min
    @@ -182,9 +183,7 @@
    -
    +
    -
    @@ -215,17 +211,18 @@ for={`lang-${langId}`} class="cursor-pointer text-sm" > - {language.language} {language.version} + {language.language} + {language.version}
    {/if} {/each} {:else} -

    Loading languages...

    +

    Loading languages...

    {/if}
    -

    +

    {selectedLanguageIds.size === 0 ? "All languages allowed" : `${selectedLanguageIds.size} language${selectedLanguageIds.size !== 1 ? "s" : ""} selected`} diff --git a/libs/frontend/src/lib/features/multiplayer/components/join-by-invite-dialog.svelte b/libs/frontend/src/lib/features/multiplayer/components/join-by-invite-dialog.svelte index 5327aa17..df95505a 100644 --- a/libs/frontend/src/lib/features/multiplayer/components/join-by-invite-dialog.svelte +++ b/libs/frontend/src/lib/features/multiplayer/components/join-by-invite-dialog.svelte @@ -51,9 +51,9 @@ value={inviteCode} oninput={handleInputChange} maxlength={6} - class="text-center text-2xl font-mono uppercase tracking-widest" + class="text-center font-mono text-2xl tracking-widest uppercase" /> -

    +

    Code must be exactly 6 characters

    diff --git a/libs/frontend/src/lib/features/multiplayer/components/waiting-room-chat.svelte b/libs/frontend/src/lib/features/multiplayer/components/waiting-room-chat.svelte index 2f83217d..94658b84 100644 --- a/libs/frontend/src/lib/features/multiplayer/components/waiting-room-chat.svelte +++ b/libs/frontend/src/lib/features/multiplayer/components/waiting-room-chat.svelte @@ -28,34 +28,40 @@ function executeSend() { if (composedMessage.trim().length === 0) return; - + sendMessage(composedMessage.trim()); composedMessage = ""; } function formatTime(timestamp: Date): string { const date = new Date(timestamp); - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }

    Chat

    - + {#if chatMessages.length > 0}
      {#each chatMessages as chatMessage}
    1. - + {chatMessage.username} - + {formatTime(chatMessage.timestamp)}
      @@ -65,7 +71,9 @@
    {:else}
    -

    No messages yet. Start chatting!

    +

    + No messages yet. Start chatting! +

    {/if}
    diff --git a/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte b/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte index dc7aa9e5..8867a126 100644 --- a/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte +++ b/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte @@ -38,10 +38,13 @@ let rooms: RoomOverviewResponse[] = $state([]); let errorMessage: string | undefined = $state(); let connectionState = $state(WEBSOCKET_STATES.DISCONNECTED); - let pendingGameStart: { gameUrl: string; startTime: Date } | undefined = $state(); + let pendingGameStart: { gameUrl: string; startTime: Date } | undefined = + $state(); let customGameDialogOpen = $state(false); let joinByInviteDialogOpen = $state(false); - let chatMessages = $state>([]); + let chatMessages = $state< + Array<{ username: string; message: string; timestamp: Date }> + >([]); const queryParamKeys = { ROOM_ID: "roomId" @@ -242,24 +245,25 @@
    {#if room && room.roomId} - {#if $authenticatedUserInfo?.userId && isAuthor(room?.owner.userId, $authenticatedUserInfo?.userId)} + + {#if $authenticatedUserInfo?.userId && isAuthor(room?.owner.userId, $authenticatedUserInfo?.userId)}
    -

    +

    Share this code with friends to let them join

    @@ -344,7 +351,7 @@ {/each}
-

+

Waiting for the host to start the game...

@@ -385,5 +392,11 @@ {/if} - - + + From d49c9714da67dfe4e8835cea0338b1e4d72fde05 Mon Sep 17 00:00:00 2001 From: JMAD Date: Thu, 30 Oct 2025 17:52:24 +0100 Subject: [PATCH 5/6] fix: something --- .../2025-10-26-migrate-user-roles-to-role.ts | 2 +- .../src/seeds/factories/game.factory.ts | 18 +-- libs/backend/src/utils/game-mode/README.md | 8 +- .../src/utils/game-mode/game-mode-strategy.ts | 10 +- .../src/websocket/game/on-connection.ts | 15 ++- .../waiting-room/waiting-room-setup.ts | 14 +- .../websocket/waiting-room/waiting-room.ts | 26 +--- .../components/standings-table.svelte | 4 +- .../components/custom-game-dialog.svelte | 126 ++++++++++-------- .../(authenticated)/multiplayer/+page.svelte | 8 +- .../src/core/game/enum/game-mode-enum.ts | 6 +- .../core/game/enum/game-visibility-enum.ts | 2 +- .../core/game/schema/game-options.schema.ts | 10 +- .../types/src/core/game/schema/mode.schema.ts | 6 +- .../src/core/game/schema/visibility.schema.ts | 6 +- .../schema/waiting-room-request.schema.ts | 2 +- 16 files changed, 137 insertions(+), 126 deletions(-) diff --git a/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts b/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts index 421dee2f..712bd1af 100644 --- a/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts +++ b/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts @@ -67,7 +67,7 @@ export class MigrateUserRolesToRoleMigration implements Migration { let rolledBack = 0; for (const user of users) { - const currentRole = (user as any).role; + const currentRole = (user).role; if (!currentRole) { continue; diff --git a/libs/backend/src/seeds/factories/game.factory.ts b/libs/backend/src/seeds/factories/game.factory.ts index b0f99a83..68ab0553 100644 --- a/libs/backend/src/seeds/factories/game.factory.ts +++ b/libs/backend/src/seeds/factories/game.factory.ts @@ -1,6 +1,6 @@ import { faker } from "@faker-js/faker"; import Game, { GameDocument } from "../../models/game/game.js"; -import { GameModeEnum, GameVisibilityEnum } from "types"; +import { gameModeEnum, gameVisibilityEnum } from "types"; import { randomFromArray, randomMultipleFromArray @@ -8,9 +8,9 @@ import { import { Types } from "mongoose"; import ProgrammingLanguage from "../../models/programming-language/language.js"; -type GameModeValue = (typeof GameModeEnum)[keyof typeof GameModeEnum]; +type GameModeValue = (typeof gameModeEnum)[keyof typeof gameModeEnum]; type GameVisibilityValue = - (typeof GameVisibilityEnum)[keyof typeof GameVisibilityEnum]; + (typeof gameVisibilityEnum)[keyof typeof gameVisibilityEnum]; export interface GameFactoryOptions { ownerId: Types.ObjectId; @@ -49,9 +49,9 @@ async function generateAllowedLanguages(): Promise { export async function createGame( options: GameFactoryOptions ): Promise { - const mode = options.mode || randomFromArray(Object.values(GameModeEnum)); + const mode = options.mode || randomFromArray(Object.values(gameModeEnum)); const visibility = - options.visibility || randomFromArray(Object.values(GameVisibilityEnum)); + options.visibility || randomFromArray(Object.values(gameVisibilityEnum)); // Game duration varies: 5min to 60min const durationInSeconds = faker.number.int({ min: 300, max: 3600 }); @@ -117,13 +117,13 @@ export async function createGames( // Mode distribution: 60% RATED, 40% CASUAL const mode = faker.datatype.boolean({ probability: 0.6 }) - ? GameModeEnum.RATED - : GameModeEnum.CASUAL; + ? gameModeEnum.RATED + : gameModeEnum.CASUAL; // Visibility distribution: 70% PUBLIC, 30% PRIVATE const visibility = faker.datatype.boolean({ probability: 0.7 }) - ? GameVisibilityEnum.PUBLIC - : GameVisibilityEnum.PRIVATE; + ? gameVisibilityEnum.PUBLIC + : gameVisibilityEnum.PRIVATE; // Get random players (ensure we have enough users) const playerCount = Math.min( diff --git a/libs/backend/src/utils/game-mode/README.md b/libs/backend/src/utils/game-mode/README.md index a57213e7..d4eb22bd 100644 --- a/libs/backend/src/utils/game-mode/README.md +++ b/libs/backend/src/utils/game-mode/README.md @@ -18,7 +18,7 @@ CodinCod uses a **strategy pattern** to handle different game modes. This makes Update `libs/types/src/core/game/enum/game-mode-enum.ts`: ```typescript -export const GameModeEnum = { +export const gameModeEnum = { FASTEST: "fastest", SHORTEST: "shortest", RATED: "rated", @@ -67,7 +67,7 @@ Add your strategy to the `strategies` object: ```typescript const strategies: Record = { // ... existing strategies - [GameModeEnum.YOUR_NEW_MODE]: new YourNewModeStrategy() + [gameModeEnum.YOUR_NEW_MODE]: new YourNewModeStrategy() }; ``` @@ -84,7 +84,7 @@ If your mode needs new submission data (like `codeLength` for SHORTEST mode): Update `libs/frontend/src/lib/features/game/standings/components/standings-table.svelte`: ```svelte -{#if game.options.mode === GameModeEnum.YOUR_NEW_MODE} +{#if game.options.mode === gameModeEnum.YOUR_NEW_MODE} Your Metric {/if} ``` @@ -98,7 +98,7 @@ Update `libs/frontend/src/lib/features/game/standings/components/standings-table ## Example: Adding a "Memory Efficient" Mode -1. Add `MEMORY_EFFICIENT: "memory_efficient"` to GameModeEnum +1. Add `MEMORY_EFFICIENT: "memory_efficient"` to gameModeEnum 2. Create `MemoryEfficientModeStrategy` that: - Tracks peak memory usage during execution - Scores based on lowest memory usage + success rate diff --git a/libs/backend/src/utils/game-mode/game-mode-strategy.ts b/libs/backend/src/utils/game-mode/game-mode-strategy.ts index ab90342c..7f4621bd 100644 --- a/libs/backend/src/utils/game-mode/game-mode-strategy.ts +++ b/libs/backend/src/utils/game-mode/game-mode-strategy.ts @@ -1,4 +1,4 @@ -import { GameModeEnum, type GameMode } from "types"; +import { gameModeEnum, type GameMode } from "types"; export interface GameModeStrategy { calculateScore(submission: { @@ -113,10 +113,10 @@ class CasualModeStrategy implements GameModeStrategy { } const strategies: Record = { - [GameModeEnum.FASTEST]: new FastestModeStrategy(), - [GameModeEnum.SHORTEST]: new ShortestModeStrategy(), - [GameModeEnum.RATED]: new RatedModeStrategy(), - [GameModeEnum.CASUAL]: new CasualModeStrategy() + [gameModeEnum.FASTEST]: new FastestModeStrategy(), + [gameModeEnum.SHORTEST]: new ShortestModeStrategy(), + [gameModeEnum.RATED]: new RatedModeStrategy(), + [gameModeEnum.CASUAL]: new CasualModeStrategy() }; export function getGameModeStrategy(mode: GameMode): GameModeStrategy { diff --git a/libs/backend/src/websocket/game/on-connection.ts b/libs/backend/src/websocket/game/on-connection.ts index ac21541b..d5d3c50a 100644 --- a/libs/backend/src/websocket/game/on-connection.ts +++ b/libs/backend/src/websocket/game/on-connection.ts @@ -4,7 +4,9 @@ import { getUserIdFromUser, isGameDto, isPuzzleDto, - ObjectId + isString, + ObjectId, + websocketCloseCodes } from "types"; import { UserWebSockets } from "./user-web-sockets.js"; import { WebSocket } from "@fastify/websocket"; @@ -27,7 +29,7 @@ export async function onConnection( message: "Game not found" }) ); - socket.close(1008, "Game not found"); + socket.close(websocketCloseCodes.POLICY_VIOLATION, "Game not found"); return; } @@ -63,10 +65,9 @@ export async function onConnection( return; } - const puzzleId = - typeof game.puzzle === "string" - ? game.puzzle - : game.puzzle._id.toString(); + const puzzleId = isString(game.puzzle) + ? game.puzzle + : game.puzzle._id.toString(); const puzzle = await puzzleService.findByIdPopulated(puzzleId); if (!isPuzzleDto(puzzle)) { @@ -84,6 +85,6 @@ export async function onConnection( }); } catch (error) { console.error("Error in game websocket connection:", error); - socket.close(1011, "Internal server error"); + socket.close(websocketCloseCodes.INTERNAL_ERROR, "Internal server error"); } } diff --git a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts index 5718b2d9..96515fe7 100644 --- a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts +++ b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts @@ -4,8 +4,8 @@ import { DEFAULT_GAME_LENGTH_IN_MILLISECONDS, frontendUrls, GameEntity, - GameModeEnum, - GameVisibilityEnum, + gameModeEnum, + gameVisibilityEnum, isAuthenticatedInfo, waitingRoomEventEnum } from "types"; @@ -144,7 +144,7 @@ export function waitingRoomSetup( return; } - const randomPuzzle = randomPuzzles[0] as any; + const randomPuzzle = randomPuzzles[0]; const room = waitingRoom.getRoom(parsedMessage.roomId); if (!room) { @@ -185,10 +185,12 @@ export function waitingRoomSetup( startTime, endTime, options: { - allowedLanguages: roomOptions?.allowedLanguages ?? [], + allowedLanguages: [], maxGameDurationInSeconds: gameDuration, - mode: roomOptions?.mode ?? GameModeEnum.FASTEST, - visibility: roomOptions?.visibility ?? GameVisibilityEnum.PUBLIC + mode: gameModeEnum.FASTEST, + visibility: gameVisibilityEnum.PUBLIC, + rated: true, + ...roomOptions }, playerSubmissions: [] }; diff --git a/libs/backend/src/websocket/waiting-room/waiting-room.ts b/libs/backend/src/websocket/waiting-room/waiting-room.ts index 19b5068f..1c37109a 100644 --- a/libs/backend/src/websocket/waiting-room/waiting-room.ts +++ b/libs/backend/src/websocket/waiting-room/waiting-room.ts @@ -2,6 +2,7 @@ import { WebSocket } from "@fastify/websocket"; import mongoose from "mongoose"; import { AuthenticatedInfo, + GameOptions, GameUserInfo, ObjectId, waitingRoomEventEnum, @@ -13,28 +14,9 @@ type Username = string; type RoomId = ObjectId; type Room = Record; -// Custom type for room options to work with exactOptionalPropertyTypes -export type RoomGameOptions = { - allowedLanguages?: - | Array< - | string - | { - language: string; - version: string; - aliases: string[]; - _id?: string | undefined; - runtime?: string | undefined; - } - > - | undefined; - maxGameDurationInSeconds?: number | undefined; - visibility?: ("private" | "public") | undefined; - mode?: ("fastest" | "shortest" | "rated" | "casual") | undefined; -}; - interface RoomConfig { users: Room; - options?: RoomGameOptions | undefined; + options?: GameOptions | undefined; inviteCode?: string | undefined; } @@ -78,7 +60,7 @@ export class WaitingRoom { this.connectionManager.remove(username); } - hostRoom(user: AuthenticatedInfo, options?: RoomGameOptions): RoomId { + hostRoom(user: AuthenticatedInfo, options?: GameOptions): RoomId { const randomId = new mongoose.Types.ObjectId().toString(); // Generate a 6-character invite code for private rooms @@ -162,7 +144,7 @@ export class WaitingRoom { return roomConfig?.users; } - getRoomOptions(roomId: RoomId): RoomGameOptions | undefined { + getRoomOptions(roomId: RoomId): GameOptions | undefined { return this.roomsByRoomId[roomId]?.options; } diff --git a/libs/frontend/src/lib/features/game/standings/components/standings-table.svelte b/libs/frontend/src/lib/features/game/standings/components/standings-table.svelte index 1da568ec..62218364 100644 --- a/libs/frontend/src/lib/features/game/standings/components/standings-table.svelte +++ b/libs/frontend/src/lib/features/game/standings/components/standings-table.svelte @@ -2,7 +2,7 @@ import * as Table from "$lib/components/ui/table"; import dayjs from "dayjs"; import { - GameModeEnum, + gameModeEnum, isObjectId, isString, isSubmissionDto, @@ -37,7 +37,7 @@ game: GameDto; } = $props(); - let isShortestMode = $derived(game.options.mode === GameModeEnum.SHORTEST); + let isShortestMode = $derived(game.options.mode === gameModeEnum.SHORTEST); let submissions: SubmissionDto[] = $derived( game.playerSubmissions.filter((submission) => diff --git a/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte b/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte index 7b9e489a..6a81f9e3 100644 --- a/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte +++ b/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte @@ -7,11 +7,14 @@ import { Checkbox } from "@/components/ui/checkbox"; import { testIds } from "@/config/test-ids"; import { - GameModeEnum, - GameVisibilityEnum, DEFAULT_GAME_LENGTH_IN_SECONDS, - type ProgrammingLanguageDto, - type GameOptions + type GameOptions, + isString, + DEFAULT_GAME_LENGTH_IN_MINUTES, + type GameMode, + type GameVisibility, + gameModeEnum, + gameVisibilityEnum } from "types"; import { languages } from "@/stores/languages"; @@ -20,14 +23,21 @@ onHostRoom }: { open?: boolean; - onHostRoom: (options: Partial) => void; + onHostRoom: (options: GameOptions) => void; } = $props(); // Game options state - let selectedMode = $state(GameModeEnum.FASTEST); - let selectedVisibility = $state(GameVisibilityEnum.PUBLIC); - let durationMinutes = $state(DEFAULT_GAME_LENGTH_IN_SECONDS / 60); - let selectedLanguageIds = $state>(new Set()); + let selectedMode = $state(gameModeEnum.FASTEST); + let selectedVisibility = $state(gameVisibilityEnum.PUBLIC); + let durationMinutes = $state(DEFAULT_GAME_LENGTH_IN_MINUTES); + let rated = $state(String(true)); + let selectedLanguageIds = $state>( + new Set( + $languages + .map((language) => language._id) + .filter((languageId) => isString(languageId)) + ) + ); // Derived states let allLanguagesSelected = $derived( @@ -59,10 +69,12 @@ } function handleCreateRoom() { - const options: Partial = { - mode: selectedMode as any, - visibility: selectedVisibility as any, - maxGameDurationInSeconds: durationMinutes * 60 + const options: GameOptions = { + mode: selectedMode, + visibility: selectedVisibility, + maxGameDurationInSeconds: durationMinutes * 60, + rated: Boolean(rated), + allowedLanguages: [] }; // Only include allowedLanguages if specific languages are selected @@ -74,8 +86,8 @@ open = false; // Reset form - selectedMode = GameModeEnum.FASTEST; - selectedVisibility = GameVisibilityEnum.PUBLIC; + selectedMode = gameModeEnum.FASTEST; + selectedVisibility = gameVisibilityEnum.PUBLIC; durationMinutes = DEFAULT_GAME_LENGTH_IN_SECONDS / 60; selectedLanguageIds.clear(); } @@ -84,11 +96,8 @@ - Create Custom Game - - Configure your game settings. Leave languages empty to allow all - languages. - + Create a custom game + Configure your game settings.
@@ -97,37 +106,25 @@ - {selectedMode === GameModeEnum.FASTEST && "🏃 Fastest"} - {selectedMode === GameModeEnum.SHORTEST && "📏 Shortest"} - {selectedMode === GameModeEnum.RATED && "⭐ Rated"} - {selectedMode === GameModeEnum.CASUAL && "🎮 Casual"} + {selectedMode} - - Fastest - - - Shortest - - - Rated - - - Casual - + {#each Object.values(gameModeEnum) as gameMode} + + {gameMode} + + {/each}

- {#if selectedMode === GameModeEnum.FASTEST} + {#if selectedMode === gameModeEnum.FASTEST} Win by submitting a correct solution first - {:else if selectedMode === GameModeEnum.SHORTEST} + {:else if selectedMode === gameModeEnum.SHORTEST} Win by writing the shortest correct code - {:else if selectedMode === GameModeEnum.RATED} - Ranked matchmaking with ELO rating - {:else} - Casual play without ratings + {:else if selectedMode === gameModeEnum.RANDOM} + A random game mode will be select for you {/if}

@@ -137,25 +134,50 @@ - {selectedVisibility === GameVisibilityEnum.PUBLIC && "🌐 Public"} - {selectedVisibility === GameVisibilityEnum.PRIVATE && "🔒 Private"} + {selectedVisibility} - - 🌐 Public - - - 🔒 Private - + {#each Object.values(gameVisibilityEnum) as gameVisibility} + {gameVisibility} + {/each}

- {#if selectedVisibility === GameVisibilityEnum.PUBLIC} + {#if selectedVisibility === gameVisibilityEnum.PUBLIC} Anyone can join this game + {:else if selectedVisibility === gameVisibilityEnum.PRIVATE} + Only players with an invite link can join + {/if} +

+ + + +
+ + + + {#if Boolean(rated)} + Rated + {:else} + Casual + {/if} + + + + Rated + Casual + + + +

+ {#if rated} + This game will affect your rating and appear on leaderboards {:else} - Only players with invite link can join + Play for fun, this game won't affect your rating {/if}

diff --git a/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte b/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte index 8867a126..fd7680c9 100644 --- a/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte +++ b/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte @@ -184,7 +184,7 @@ wsManager.send(data); } - function handleHostRoom(options?: Partial) { + function handleHostRoom(options?: GameOptions) { sendWaitingRoomMessage({ event: waitingRoomEventEnum.HOST_ROOM, ...(options && { options }) @@ -260,8 +260,9 @@ room = undefined; chatMessages = []; }} + disabled={Boolean(pendingGameStart)} > - Leave room + Leave waiting room {#if $authenticatedUserInfo?.userId && isAuthor(room?.owner.userId, $authenticatedUserInfo?.userId)} {/if} {:else} diff --git a/libs/types/src/core/game/enum/game-mode-enum.ts b/libs/types/src/core/game/enum/game-mode-enum.ts index 6e7b0df2..80e669c1 100644 --- a/libs/types/src/core/game/enum/game-mode-enum.ts +++ b/libs/types/src/core/game/enum/game-mode-enum.ts @@ -1,6 +1,6 @@ -export const GameModeEnum = { +export const gameModeEnum = { FASTEST: "fastest", SHORTEST: "shortest", - RATED: "rated", - CASUAL: "casual", + RANDOM: "random", } as const; + diff --git a/libs/types/src/core/game/enum/game-visibility-enum.ts b/libs/types/src/core/game/enum/game-visibility-enum.ts index fe875c58..a3dbd9a8 100644 --- a/libs/types/src/core/game/enum/game-visibility-enum.ts +++ b/libs/types/src/core/game/enum/game-visibility-enum.ts @@ -1,4 +1,4 @@ -export const GameVisibilityEnum = { +export const gameVisibilityEnum = { PRIVATE: "private", PUBLIC: "public", } as const; diff --git a/libs/types/src/core/game/schema/game-options.schema.ts b/libs/types/src/core/game/schema/game-options.schema.ts index 4cd65c36..8b6180c1 100644 --- a/libs/types/src/core/game/schema/game-options.schema.ts +++ b/libs/types/src/core/game/schema/game-options.schema.ts @@ -3,8 +3,8 @@ import { gameVisibilitySchema } from "./visibility.schema.js"; import { DEFAULT_GAME_LENGTH_IN_SECONDS } from "../config/game-config.js"; import { gameModeSchema } from "./mode.schema.js"; import { programmingLanguageDtoSchema } from "../../programming-language/schema/programming-language-dto.schema.js"; -import { GameVisibilityEnum } from "../enum/game-visibility-enum.js"; -import { GameModeEnum } from "../enum/game-mode-enum.js"; +import { gameVisibilityEnum } from "../enum/game-visibility-enum.js"; +import { gameModeEnum } from "../enum/game-mode-enum.js"; import { objectIdSchema } from "../../common/schema/object-id.js"; export const gameOptionsSchema = z.object({ @@ -12,7 +12,9 @@ export const gameOptionsSchema = z.object({ .array(objectIdSchema.or(programmingLanguageDtoSchema)) .prefault([]), maxGameDurationInSeconds: z.number().prefault(DEFAULT_GAME_LENGTH_IN_SECONDS), - visibility: gameVisibilitySchema.prefault(GameVisibilityEnum.PUBLIC), - mode: gameModeSchema.prefault(GameModeEnum.FASTEST), + visibility: gameVisibilitySchema.prefault(gameVisibilityEnum.PUBLIC), + mode: gameModeSchema.prefault(gameModeEnum.FASTEST), + rated: z.boolean().default(true), }); + export type GameOptions = z.infer; diff --git a/libs/types/src/core/game/schema/mode.schema.ts b/libs/types/src/core/game/schema/mode.schema.ts index f809c705..878f9a01 100644 --- a/libs/types/src/core/game/schema/mode.schema.ts +++ b/libs/types/src/core/game/schema/mode.schema.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import { getValues } from "../../../utils/functions/get-values.js"; -import { GameModeEnum } from "../enum/game-mode-enum.js"; +import { gameModeEnum } from "../enum/game-mode-enum.js"; export const gameModeSchema = z - .enum(getValues(GameModeEnum)) - .prefault(GameModeEnum.FASTEST); + .enum(getValues(gameModeEnum)) + .prefault(gameModeEnum.FASTEST); export type GameMode = z.infer; diff --git a/libs/types/src/core/game/schema/visibility.schema.ts b/libs/types/src/core/game/schema/visibility.schema.ts index 5214b97d..622d40fb 100644 --- a/libs/types/src/core/game/schema/visibility.schema.ts +++ b/libs/types/src/core/game/schema/visibility.schema.ts @@ -1,8 +1,8 @@ import { z } from "zod"; -import { GameVisibilityEnum } from "../enum/game-visibility-enum.js"; +import { gameVisibilityEnum } from "../enum/game-visibility-enum.js"; import { getValues } from "../../../utils/functions/get-values.js"; export const gameVisibilitySchema = z - .enum(getValues(GameVisibilityEnum)) - .prefault(GameVisibilityEnum.PUBLIC); + .enum(getValues(gameVisibilityEnum)) + .prefault(gameVisibilityEnum.PUBLIC); export type GameVisibility = z.infer; diff --git a/libs/types/src/core/game/schema/waiting-room-request.schema.ts b/libs/types/src/core/game/schema/waiting-room-request.schema.ts index 62cd8cd6..c8eaef47 100644 --- a/libs/types/src/core/game/schema/waiting-room-request.schema.ts +++ b/libs/types/src/core/game/schema/waiting-room-request.schema.ts @@ -25,7 +25,7 @@ const leaveRoomSchema = baseMessageSchema.extend({ const hostRoomSchema = baseMessageSchema.extend({ event: z.literal(waitingRoomEventEnum.HOST_ROOM), - options: gameOptionsSchema.partial().optional(), + options: gameOptionsSchema.optional(), }); const startGameSchema = baseMessageSchema.extend({ From 52d83aa60d9a6febd6ff62e1fb122254c404bd59 Mon Sep 17 00:00:00 2001 From: reeveng <36441093+reeveng@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:10:36 +0000 Subject: [PATCH 6/6] Apply automatic changes --- .../migrations/2025-10-26-migrate-user-roles-to-role.ts | 2 +- .../features/multiplayer/components/custom-game-dialog.svelte | 2 +- libs/types/src/core/game/enum/game-mode-enum.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts b/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts index 712bd1af..3995dbf3 100644 --- a/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts +++ b/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts @@ -67,7 +67,7 @@ export class MigrateUserRolesToRoleMigration implements Migration { let rolledBack = 0; for (const user of users) { - const currentRole = (user).role; + const currentRole = user.role; if (!currentRole) { continue; diff --git a/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte b/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte index 6a81f9e3..d67a6da1 100644 --- a/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte +++ b/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte @@ -158,7 +158,7 @@
- + {#if Boolean(rated)} Rated diff --git a/libs/types/src/core/game/enum/game-mode-enum.ts b/libs/types/src/core/game/enum/game-mode-enum.ts index 80e669c1..032e58b4 100644 --- a/libs/types/src/core/game/enum/game-mode-enum.ts +++ b/libs/types/src/core/game/enum/game-mode-enum.ts @@ -3,4 +3,3 @@ export const gameModeEnum = { SHORTEST: "shortest", RANDOM: "random", } as const; -