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/install-languages.sh b/install-languages.sh old mode 100644 new mode 100755 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..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 as any).role; + const currentRole = user.role; if (!currentRole) { continue; 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/authenticated.ts b/libs/backend/src/plugins/middleware/authenticated.ts index 2ea034b2..12dc75a1 100644 --- a/libs/backend/src/plugins/middleware/authenticated.ts +++ b/libs/backend/src/plugins/middleware/authenticated.ts @@ -1,10 +1,5 @@ import { FastifyReply, FastifyRequest } from "fastify"; -import { - httpResponseCodes, - cookieKeys, - environment, - AuthenticatedInfo -} from "types"; +import { httpResponseCodes, cookieKeys, AuthenticatedInfo } from "types"; export default async function authenticated( request: FastifyRequest, @@ -19,7 +14,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/plugins/middleware/check-user-ban.ts b/libs/backend/src/plugins/middleware/check-user-ban.ts index 1cd6bb47..0414fa3c 100644 --- a/libs/backend/src/plugins/middleware/check-user-ban.ts +++ b/libs/backend/src/plugins/middleware/check-user-ban.ts @@ -1,10 +1,5 @@ import { FastifyReply, FastifyRequest } from "fastify"; -import { - httpResponseCodes, - isAuthenticatedInfo, - banTypeEnum, - environment -} 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/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/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/services/game.service.ts b/libs/backend/src/services/game.service.ts new file mode 100644 index 00000000..8999cbf3 --- /dev/null +++ b/libs/backend/src/services/game.service.ts @@ -0,0 +1,149 @@ +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..0dffb8ad --- /dev/null +++ b/libs/backend/src/services/programming-language.service.ts @@ -0,0 +1,111 @@ +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..995e0887 --- /dev/null +++ b/libs/backend/src/services/puzzle.service.ts @@ -0,0 +1,169 @@ +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..9e791358 --- /dev/null +++ b/libs/backend/src/services/submission.service.ts @@ -0,0 +1,99 @@ +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..26f68a1c --- /dev/null +++ b/libs/backend/src/services/user.service.ts @@ -0,0 +1,80 @@ +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..d4eb22bd --- /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..7f4621bd --- /dev/null +++ b/libs/backend/src/utils/game-mode/game-mode-strategy.ts @@ -0,0 +1,153 @@ +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< + 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, + 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/connection-manager.ts b/libs/backend/src/websocket/connection-manager.ts index 611b2e73..955497de 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,12 @@ 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..7d4178fe 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..d5d3c50a 100644 --- a/libs/backend/src/websocket/game/on-connection.ts +++ b/libs/backend/src/websocket/game/on-connection.ts @@ -1,16 +1,17 @@ -import Game from "@/models/game/game.js"; -import Puzzle from "@/models/puzzle/puzzle.js"; import { AuthenticatedInfo, gameEventEnum, - GameResponse, getUserIdFromUser, isGameDto, isPuzzleDto, - ObjectId + isString, + ObjectId, + websocketCloseCodes } 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 +20,44 @@ 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 = { - event: gameEventEnum.NONEXISTENT_GAME, - message: "Game not found" - }; - socket.send(JSON.stringify(response)); - socket.close(1008, "Game not found"); + socket.send( + JSON.stringify({ + event: gameEventEnum.NONEXISTENT_GAME, + message: "Game not found" + }) + ); + socket.close(websocketCloseCodes.POLICY_VIOLATION, "Game not found"); return; } - const currentPlayerIndex = game.players.findIndex((player) => { - return getUserIdFromUser(player) === user.userId; - }); - - if (currentPlayerIndex === -1) { - const gameOverviewResponse: GameResponse = { - event: gameEventEnum.OVERVIEW_GAME, - game - }; - socket.send(JSON.stringify(gameOverviewResponse)); + const isPlayerInGame = game.players.some( + (player) => getUserIdFromUser(player) === user.userId + ); - const errorResponse: GameResponse = { - event: gameEventEnum.ERROR, - message: `User ${user.userId} hasn't joined this game` - }; - socket.send(JSON.stringify(errorResponse)); + 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.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,7 +65,10 @@ export async function onConnection( return; } - const puzzle = await Puzzle.findById(game.puzzle).populate("author"); + const puzzleId = isString(game.puzzle) + ? game.puzzle + : game.puzzle._id.toString(); + const puzzle = await puzzleService.findByIdPopulated(puzzleId); if (!isPuzzleDto(puzzle)) { userWebSockets.updateUser(user.username, { @@ -85,7 +84,7 @@ export async function onConnection( puzzle }); } catch (error) { - console.error("Error in onConnection:", error); - socket.close(1011, "Internal server error"); + console.error("Error in game websocket connection:", 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 ba3a31da..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,17 +4,16 @@ import { DEFAULT_GAME_LENGTH_IN_MILLISECONDS, frontendUrls, GameEntity, - GameModeEnum, - GameVisibilityEnum, + 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(); @@ -57,7 +56,7 @@ export function waitingRoomSetup( switch (event) { case waitingRoomEventEnum.HOST_ROOM: { - const roomId = waitingRoom.hostRoom(req.user); + const roomId = waitingRoom.hostRoom(req.user, parsedMessage.options); fastify.log.info( { username: req.user.username, roomId }, "User hosted room" @@ -76,22 +75,66 @@ export function waitingRoomSetup( 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; + } + + 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; + } + + 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; + } + + waitingRoom.updateUsersInRoom(parsedMessage.roomId, { + event: waitingRoomEventEnum.CHAT_MESSAGE, + username: req.user.username, + message: parsedMessage.message, + timestamp: new Date() + }); + break; + } + case waitingRoomEventEnum.START_GAME: { try { - fastify.log.info( - { username: req.user.username, roomId: parsedMessage.roomId }, - "START_GAME requested" - ); - - const randomPuzzles = await Puzzle.aggregate([ - { $match: { visibility: puzzleVisibilityEnum.APPROVED } }, - { $sample: { size: 1 } } - ]).exec(); + const randomPuzzles = await puzzleService.findRandomApproved(1); if (randomPuzzles.length < 1) { waitingRoom.updateUser(req.user.username, { @@ -105,13 +148,6 @@ export function waitingRoomSetup( const room = waitingRoom.getRoom(parsedMessage.roomId); if (!room) { - fastify.log.error( - { - roomId: parsedMessage.roomId, - availableRooms: waitingRoom.getAllRoomIds() - }, - "Room not found" - ); waitingRoom.updateUser(req.user.username, { event: waitingRoomEventEnum.ERROR, message: `Room ${parsedMessage.roomId} not found` @@ -131,46 +167,53 @@ 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 + maxGameDurationInSeconds: gameDuration, + mode: gameModeEnum.FASTEST, + visibility: gameVisibilityEnum.PUBLIC, + rated: true, + ...roomOptions }, playerSubmissions: [] }; - const databaseGame = new Game(createGameEntity); - const newlyCreatedGame = await databaseGame.save(); + const newlyCreatedGame = await gameService.create(createGameEntity); + const gameUrl = frontendUrls.multiplayerById(newlyCreatedGame.id); - 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, + startTime }); - // 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" + { + gameId: newlyCreatedGame.id, + playerCount: players.length, + startTime, + countdownSeconds + }, + "Game created with countdown" ); + 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..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,8 +14,14 @@ type Username = string; type RoomId = ObjectId; type Room = Record; +interface RoomConfig { + users: Room; + options?: GameOptions | undefined; + inviteCode?: string | undefined; +} + export class WaitingRoom { - private roomsByRoomId: Record; + private roomsByRoomId: Record; private roomsByUsername: Record; private connectionManager: ConnectionManager; @@ -53,31 +60,51 @@ export class WaitingRoom { this.connectionManager.remove(username); } - hostRoom(user: AuthenticatedInfo): RoomId { + hostRoom(user: AuthenticatedInfo, options?: GameOptions): 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 +117,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 +140,37 @@ export class WaitingRoom { } getRoom(roomId: RoomId): Room | undefined { - return this.roomsByRoomId[roomId]; + const roomConfig = this.roomsByRoomId[roomId]; + return roomConfig?.users; + } + + getRoomOptions(roomId: RoomId): GameOptions | 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 +179,7 @@ export class WaitingRoom { updateUsersOnRoomState(roomId: RoomId): void { const room = this.getRoom(roomId); + const inviteCode = this.getInviteCode(roomId); if (!room) { return; } @@ -138,7 +190,8 @@ export class WaitingRoom { room: { users: usersInRoom, owner: this.findRoomOwner(room), - roomId + roomId, + ...(inviteCode && { inviteCode }) } }); } @@ -176,7 +229,9 @@ 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) => { @@ -189,6 +244,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/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 6a40f069..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,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,18 +81,22 @@ 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 : isObjectId(programmingLanguage) ? "Unknown" : programmingLanguage.language} + {#if isUserDto(user)} {index + 1}. @@ -116,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..df95505a --- /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..94658b84 --- /dev/null +++ b/libs/frontend/src/lib/features/multiplayer/components/waiting-room-chat.svelte @@ -0,0 +1,105 @@ + + + +

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/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/frontend/src/routes/(authenticated)/multiplayer/+page.svelte b/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte index af9653b6..fd7680c9 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,23 @@ 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< + Array<{ username: string; message: string; timestamp: Date }> + >([]); const queryParamKeys = { ROOM_ID: "roomId" @@ -76,9 +89,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 +160,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 +183,38 @@ function sendWaitingRoomMessage(data: WaitingRoomRequest) { wsManager.send(data); } + + function handleHostRoom(options?: GameOptions) { + 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 + }); + } @@ -188,11 +258,12 @@ }); room = undefined; + chatMessages = []; }} + disabled={Boolean(pendingGameStart)} > - Leave room + Leave waiting room - {#if $authenticatedUserInfo?.userId && isAuthor(room?.owner.userId, $authenticatedUserInfo?.userId)} {/if} {:else} + + - {/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 +393,12 @@ {/if} {/if} + + + 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..56a46986 --- /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; 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..032e58b4 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,5 @@ -export const GameModeEnum = { - RATED: "rated", - CASUAL: "casual", +export const gameModeEnum = { + FASTEST: "fastest", + SHORTEST: "shortest", + 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/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..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.RATED), + 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 cd920815..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.RATED); + .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 9de5c7eb..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 @@ -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.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/puzzle/schema/puzzle-dto.schema.ts b/libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts index 8694b612..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,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/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), 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";