From 7ad7a5d590e2528f91582ead90d8094745220f27 Mon Sep 17 00:00:00 2001 From: Floha Date: Fri, 15 May 2026 22:12:37 +0200 Subject: [PATCH 01/13] initial teams refactor --- .../data-migration.ts | 56 ++++ .../migration.sql | 28 ++ api/prisma/schema.prisma | 14 +- api/src/auth/RoomAuth.ts | 1 + api/src/core/Player.ts | 122 +------- api/src/core/Room.ts | 290 +++++++++++++----- api/src/core/RoomServer.ts | 43 ++- api/src/core/Team.ts | 136 ++++++++ api/src/core/integration/races/LocalTimer.ts | 8 +- api/src/database/Rooms.ts | 6 +- api/src/routes/rooms/Rooms.ts | 3 +- schema/index.d.ts | 1 + schema/schemas/Player.json | 7 - schema/schemas/ServerMessage.json | 34 +- schema/schemas/Team.json | 28 ++ schema/types/Player.d.ts | 2 - schema/types/ServerMessage.d.ts | 18 +- 17 files changed, 564 insertions(+), 233 deletions(-) create mode 100644 api/prisma/migrations/20260515200019_refactor_for_teams/data-migration.ts create mode 100644 api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql create mode 100644 api/src/core/Team.ts create mode 100644 schema/schemas/Team.json diff --git a/api/prisma/migrations/20260515200019_refactor_for_teams/data-migration.ts b/api/prisma/migrations/20260515200019_refactor_for_teams/data-migration.ts new file mode 100644 index 00000000..8b9e0901 --- /dev/null +++ b/api/prisma/migrations/20260515200019_refactor_for_teams/data-migration.ts @@ -0,0 +1,56 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + await prisma.$transaction( + async (tx) => { + // We use raw SQL because the 'spectator' column might have been dropped or + // is not available in the current Prisma Client generation. + // We also need to fetch the roomId to create the team in the correct room. + const players: any[] = await tx.$queryRawUnsafe( + 'SELECT id, spectator, "roomId", nickname FROM "Player"' + ); + + for (const player of players) { + // If the player was not a spectator, they need a team. + if (player.spectator === false) { + // Create a new team for this player. + // The 'key' is required in the Team model. We'll use a simple approach for it. + const team = await tx.team.create({ + data: { + name: `${player.nickname}'s Team`, + key: player.id, // Using player id as a key for uniqueness if needed + roomId: player.roomId, + }, + }); + + // Assign the player to the newly created team. + await tx.player.update({ + where: { id: player.id }, + data: { + teamId: team.id, + }, + }); + } else { + // If the player was a spectator, teamId should remain null. + // This is already the default for the new column, but we ensure it. + await tx.player.update({ + where: { id: player.id }, + data: { + teamId: null, + }, + }); + } + } + }, + { timeout: 300000 } // 5 minutes timeout for potentially large data + ); +} + +main() + .catch(async (e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => await prisma.$disconnect()); diff --git a/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql b/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql new file mode 100644 index 00000000..4fc110ef --- /dev/null +++ b/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - You are about to drop the column `spectator` on the `Player` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Player" DROP COLUMN "spectator", +ADD COLUMN "teamId" TEXT; + +-- CreateTable +CREATE TABLE "Team" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "name" TEXT NOT NULL, + "roomId" TEXT NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_id_key" ON "Team"("id"); + +-- AddForeignKey +ALTER TABLE "Team" ADD CONSTRAINT "Team_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Player" ADD CONSTRAINT "Player_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index d3f346e1..e21737f9 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -167,6 +167,7 @@ model Room { bingoMode BingoMode @default(LINES) lineCount Int @default(1) players Player[] + teams Team[] variant Variant? @relation(fields: [variantId], references: [id]) variantId String? exploration Boolean @default(false) @@ -176,14 +177,25 @@ model Room { finishedAt DateTime? } +model Team { + id String @id @unique @default(cuid(2)) + key String + name String + room Room @relation(fields: [roomId], references: [id]) + roomId String + players Player[] +} + model Player { id String @id @unique @default(cuid()) key String user User? @relation(fields: [userId], references: [id]) room Room @relation(fields: [roomId], references: [id]) + // A player either belongs to a team or is a spectator + team Team? @relation(fields: [teamId], references: [id]) + teamId String? nickname String color String @default("blue") - spectator Boolean monitor Boolean @default(false) roomId String userId String? diff --git a/api/src/auth/RoomAuth.ts b/api/src/auth/RoomAuth.ts index 116057ca..9c776f36 100644 --- a/api/src/auth/RoomAuth.ts +++ b/api/src/auth/RoomAuth.ts @@ -13,6 +13,7 @@ export type RoomTokenPayload = { roomSlug: string; uuid: string; playerId: string; + teamId?: string; userId?: string; } & Permissions; diff --git a/api/src/core/Player.ts b/api/src/core/Player.ts index f02b9bac..6177a12b 100644 --- a/api/src/core/Player.ts +++ b/api/src/core/Player.ts @@ -1,14 +1,15 @@ import { - HiddenCell, Player as PlayerClientData, - RevealedCell, ServerMessage, + HiddenCell, + RevealedCell } from '@playbingo/types'; import { OPEN, WebSocket } from 'ws'; import { RoomTokenPayload } from '../auth/RoomAuth'; -import { computeRevealedMask, rowColToMask } from '../util/RoomUtils'; import Room from './Room'; +type BoardViewProvider = () => (RevealedCell | HiddenCell)[][]; + /** * Represents a player connected to a room. While largely just a data class, this * class offers utilities to make keeping track of players, identities, and their @@ -35,49 +36,32 @@ export default class Player { /** The players chosen color */ color: string; userId?: string; - /** If the player is in spectator mode or not */ - spectator: boolean; /** If the player has permission to perform monitor actions in the room */ monitor: boolean; - /** Bitset of the goals the player has marked */ - markedGoals: bigint; - /** The number of goals the player has marked */ - goalCount: number; - /** Whether or not the player has completed the goal of the room */ - goalComplete: boolean; - linesComplete: number; - /** Bitset of goals that are revealed for the player in exploration based - * modes */ - exploredGoals: bigint; - /** Open connections for the player, mapped by the id in the auth token that * is authorized for the connection */ connections: Map; finishedAt?: string; + getBoardView: BoardViewProvider; + constructor( room: Room, id: string, nickname: string, color: string = 'blue', - spectator: boolean, monitor: boolean, - userId?: string, + getBoardView: BoardViewProvider, + userId?: string ) { this.room = room; ((this.id = id), (this.nickname = nickname)); this.color = color; - this.spectator = spectator; this.monitor = monitor; this.userId = userId; - - this.markedGoals = 0n; - this.goalCount = 0; - this.goalComplete = false; - this.linesComplete = 0; - this.exploredGoals = 0n; + this.getBoardView = getBoardView; this.connections = new Map(); } @@ -148,14 +132,12 @@ export default class Player { id: this.id, nickname: this.nickname, color: this.color, - goalCount: this.goalCount, raceStatus: raceUser ? { connected: true, ...raceUser, } : { connected: false }, - spectator: this.spectator, monitor: this.monitor, showInRoom: this.showInRoom(), }; @@ -177,19 +159,19 @@ export default class Player { action: 'syncBoard', board: { hidden: false, - board: this.obfuscateBoard(), + board: this.getBoardView(), width: this.room.board[0].length, height: this.room.board.length, }, }; } else if (message.action === 'syncBoard' && this.room.exploration) { if (!message.board.hidden) { - message.board.board = this.obfuscateBoard(); + message.board.board = this.getBoardView(); } finalMessage = message; } else if (message.action === 'connected') { if (!message.board.hidden) { - message.board.board = this.obfuscateBoard(); + message.board.board = this.getBoardView(); } finalMessage = message; } else { @@ -212,86 +194,6 @@ export default class Player { return this.connections.size > 0; } - //#region Goal Tracking - mark(row: number, col: number) { - const mask = rowColToMask(row, col, this.room.board[0].length); - if ((this.markedGoals & mask) === 0n) { - this.markedGoals |= mask; - this.goalCount++; - if (this.room.exploration) { - this.exploredGoals = this.getRevealedMask(); - } - } - } - - unmark(row: number, col: number) { - const mask = rowColToMask(row, col, this.room.board[0].length); - if ((this.markedGoals & mask) !== 0n) { - this.markedGoals &= ~mask; - this.goalCount--; - if (this.room.exploration) { - this.exploredGoals = this.getRevealedMask(); - } - } - } - - hasMarked(row: number, col: number): boolean { - const mask = rowColToMask(row, col, this.room.board[0].length); - return (this.markedGoals & mask) !== 0n; - } - - hasRevealed(row: number, col: number): boolean { - const mask = rowColToMask(row, col, this.room.board[0].length); - return (this.exploredGoals & mask) !== 0n; - } - - getRevealedMask(): bigint { - return ( - computeRevealedMask( - this.markedGoals, - this.room.board[0].length, - this.room.board.length, - ) | this.room.alwaysRevealedMask - ); - } - - obfuscateBoard() { - if (this.spectator) { - this.exploredGoals = 0n; - this.room.players.forEach((player) => { - if (!player.spectator) { - this.exploredGoals |= player.getRevealedMask(); - } - }); - } else { - this.exploredGoals = this.getRevealedMask(); - } - return this.room.board.map((row, rowIndex) => - row.map((cell, colIndex) => - this.hasRevealed(rowIndex, colIndex) - ? ({ - revealed: true, - goal: cell.goal, - completedPlayers: cell.completedPlayers, - } as RevealedCell) - : ({ - revealed: false, - completedPlayers: cell.completedPlayers, - } as HiddenCell), - ), - ); - } - - /** - * Checks if this player has completed a set of goals on the board - * - * @param mask The bitmask containing the goals to check for - */ - hasCompletedGoals(mask: bigint) { - return (this.markedGoals & mask) === mask; - } - //#endregion - //#region Races async joinRace() { return this.room.raceHandler.joinPlayer(this); diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 0046cb6e..0dc6c344 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -9,6 +9,7 @@ import { MarkAction, NewCardAction, Player as PlayerData, + Team as TeamData, RevealedCell, ServerMessage, UnmarkAction, @@ -62,6 +63,12 @@ import { generateSRLv5 } from './generation/SRLv5'; import LocalTimer from './integration/races/LocalTimer'; import RaceHandler from './integration/races/RaceHandler'; import RacetimeHandler, { RaceData } from './integration/races/RacetimeHandler'; +import Team from './Team'; + +export type HiddenCell = { + revealed: false; + completedPlayers: string[]; +}; export enum BoardGenerationMode { RANDOM = 'Random', @@ -131,7 +138,9 @@ export default class Room { inactivityWarningTimeout?: NodeJS.Timeout; closeTimeout?: NodeJS.Timeout; - players: Map; + // players: Map; + teams: Map; + spectators: Map; constructor( name: string, @@ -190,7 +199,8 @@ export default class Room { roomCleanupInactive, ); - this.players = new Map(); + this.teams = new Map(); + this.spectators = new Map(); this.seed = seed; @@ -237,6 +247,44 @@ export default class Room { } } + getAllPlayers(): Player[] { + const players = this.teams + .values() + .flatMap((team) => team.players.values()); + return [...this.spectators.values(), ...players]; + } + + deleteTeam(teamId: string) { + this.teams.get(teamId)?.destroy(); + this.teams.delete(teamId); + } + + spectatorObfuscateBoard(): (RevealedCell | HiddenCell)[][] { + let exploredGoals = 0n; + this.teams.forEach((team) => { + exploredGoals |= team.getRevealedMask(); + }); + return this.board.map((row, rowIndex) => + row.map((cell, colIndex) => { + const mask = rowColToMask( + rowIndex, + colIndex, + this.board[0].length, + ); + return (exploredGoals & mask) !== 0n + ? ({ + revealed: true, + goal: cell.goal, + completedPlayers: cell.completedPlayers, + } as RevealedCell) + : ({ + revealed: false, + completedPlayers: cell.completedPlayers, + } as HiddenCell); + }), + ); + } + async generateBoard(options: BoardGenerationOptions) { this.lastGenerationMode = options; const { mode, seed } = options; @@ -366,12 +414,23 @@ export default class Room { ); } - getPlayers(): PlayerData[] { - const players: PlayerData[] = []; - this.players.forEach((player) => { - players.push(player.toClientData()); - }); - return players; + getPlayers(): { teams: TeamData[]; spectators: PlayerData[] } { + const teams: TeamData[] = []; + this.teams.forEach((team) => teams.push(team.toClientData())); + const spectators: PlayerData[] = []; + this.spectators.forEach((spectator) => + spectators.push(spectator.toClientData()), + ); + return { teams, spectators }; + } + + getTeamForPlayer(playerId: string): Team | undefined { + for (const team of this.teams.values()) { + if (team.players.has(playerId)) { + return team; + } + } + return undefined; } //#region Handlers @@ -381,9 +440,24 @@ export default class Room { socket: WebSocket, ): ServerMessage { let player: Player | undefined; + let playerTeam: Team | undefined; let newPlayer = false; - if (this.players.has(auth.playerId)) { - player = this.players.get(auth.playerId); + let playerIsAuthed = false; + if (!auth.isSpectating) { + this.teams.forEach((team) => { + if (team.players.has(auth.playerId)) { + playerIsAuthed = true; + playerTeam = team; + player = team.players.get(auth.playerId); + } + }); + } else { + player = this.spectators.get(auth.playerId); + if (player) { + playerIsAuthed = true; + } + } + if (playerIsAuthed) { if (!player) { return { action: 'unauthorized' }; } @@ -393,33 +467,52 @@ export default class Room { auth.playerId, action.payload.nickname, undefined, - auth.isSpectating, auth.isMonitor, + auth.isSpectating + ? () => this.spectatorObfuscateBoard() + : () => playerTeam!.obfuscateBoard(), auth.userId, ); - this.players.set(player.id, player); + if (auth.isSpectating) { + this.spectators.set(auth.playerId, player); + } else { + if (auth.teamId && this.teams.get(auth.teamId)) { + playerTeam = this.teams.get(auth.teamId); + } else { + playerTeam = new Team( + this, + '', + `Team ${action.payload.nickname}`, + ); + } + playerTeam!.addPlayer(player); + } newPlayer = true; } else { - player = this.players.get(auth.playerId); - if (!player) { + if (!playerIsAuthed) { return { action: 'unauthorized' }; } } + // I don't think this is necessary anymore, but I'm mainly putting it here for type safety + if (!player || (!auth.isSpectating && !playerTeam)) { + return { action: 'unauthorized' }; + } + if (newPlayer) { if (auth.isSpectating) { this.sendChat(`${player.nickname} is now spectating`); } else { this.sendChat([ { contents: player.nickname, color: player.color }, - ' has joined.', + ` has joined playing for ${playerTeam!.name}.`, ]); } } player.addConnection(auth.uuid, socket); addJoinAction(this.id, player.nickname, player.color).then(); - createUpdatePlayer(this.id, player).then(); + createUpdatePlayer(this.id, player, auth.isSpectating).then(); return { action: 'connected', board: { @@ -430,7 +523,7 @@ export default class Room { : { hidden: false, board: this.exploration - ? player.obfuscateBoard() + ? player.getBoardView() : this.board, }), }, @@ -471,7 +564,7 @@ export default class Room { token: string, ): ServerMessage { let player: Player | undefined = undefined; - for (const p of this.players.values()) { + for (const p of this.getAllPlayers()) { if (p.closeConnection(auth.uuid)) { player = p; break; @@ -482,12 +575,20 @@ export default class Room { } const hasLeft = !player.hasConnections(); if (hasLeft) { + const playerTeam = this.getTeamForPlayer(player.id); + if (playerTeam) { + playerTeam.removePlayer(player.id); + if (playerTeam.players.size === 0) { + playerTeam.destroy(); + this.teams.delete(playerTeam.id); + } + } this.sendChat([ { contents: player.nickname, color: player.color }, ' has left.', ]); addLeaveAction(this.id, player.nickname, player.color).then(); - if (this.players.size === 0) { + if (this.getAllPlayers().length === 0) { this.close(); } } @@ -499,7 +600,9 @@ export default class Room { action: ChatAction, auth: RoomTokenPayload, ): ServerMessage | undefined { - const player = this.players.get(auth.playerId); + const player = this.getAllPlayers().find( + (p) => p.id === auth.playerId, + ); if (!player) { return { action: 'unauthorized' }; } @@ -518,13 +621,14 @@ export default class Room { action: MarkAction, auth: RoomTokenPayload, ): ServerMessage | undefined { - const player = this.players.get(auth.playerId); - if (!player) { + const team = this.getTeamForPlayer(auth.playerId); + const player = team?.players.get(auth.playerId); + if (!team || !player) { return { action: 'unauthorized' }; } const { row, col } = action.payload; if (row === undefined || col === undefined) return; - if (player.hasMarked(row, col)) return; + if (team.hasMarked(row, col)) return; if ( this.bingoMode === BingoMode.LOCKOUT && @@ -535,11 +639,11 @@ export default class Room { this.board[row][col].completedPlayers.sort((a, b) => a.localeCompare(b), ); - player.mark(row, col); + team.mark(row, col); this.sendCellUpdate(row, col); this.sendChat([ { - contents: player.nickname, + contents: team.players.size > 1 ? team.name : player.nickname, color: player.color, }, ` marked ${this.board[row][col].goal.goal} (${row},${col})`, @@ -552,20 +656,21 @@ export default class Room { action: UnmarkAction, auth: RoomTokenPayload, ): ServerMessage | undefined { - const player = this.players.get(auth.playerId); - if (!player) { + const team = this.getTeamForPlayer(auth.playerId); + const player = team?.players.get(auth.playerId); + if (!team || !player) { return { action: 'unauthorized' }; } const { row: unRow, col: unCol } = action.payload; if (unRow === undefined || unCol === undefined) return; - if (!player.hasMarked(unRow, unCol)) return; + if (!team.hasMarked(unRow, unCol)) return; this.board[unRow][unCol].completedPlayers = this.board[unRow][ unCol ].completedPlayers.filter((playerId) => playerId !== player.id); - player.unmark(unRow, unCol); + team.unmark(unRow, unCol); this.sendCellUpdate(unRow, unCol); this.sendChat([ - { contents: player.nickname, color: player.color }, + { contents: team.players.size > 1 ? team.name : player.nickname, color: player.color }, ` unmarked ${this.board[unRow][unCol].goal.goal} (${unRow},${unCol})`, ]); addUnmarkAction(this.id, player.id, unRow, unCol).then(); @@ -576,7 +681,7 @@ export default class Room { action: ChangeColorAction, auth: RoomTokenPayload, ): ServerMessage | undefined { - const player = this.players.get(auth.playerId); + const player = this.getAllPlayers().find(p => p.id === auth.playerId); if (!player) { return { action: 'unauthorized' }; } @@ -591,7 +696,7 @@ export default class Room { color, ).then(); player.color = color; - createUpdatePlayer(this.id, player).then(); + createUpdatePlayer(this.id, player, false).then(); this.sendChat([ { contents: player.nickname, color: player.color }, ' has changed their color to ', @@ -641,7 +746,7 @@ export default class Room { handleSocketClose(ws: WebSocket) { let player: Player | undefined; - for (const p of this.players.values()) { + for (const p of this.getAllPlayers()) { if (p.handleSocketClose(ws)) { player = p; } @@ -653,7 +758,7 @@ export default class Room { ' has left.', ]); addLeaveAction(this.id, player.nickname, player.color).then(); - if (this.players.size === 0) { + if (this.getAllPlayers().length === 0) { this.close(); } } @@ -707,7 +812,7 @@ export default class Room { } handleRevealCard(payload: RoomTokenPayload) { - const player = this.players.get(payload.playerId); + const player = this.getAllPlayers().find(p => p.id === payload.playerId); if (!player) { return null; } @@ -815,7 +920,7 @@ export default class Room { message: ServerMessage, updateInactivity: boolean = true, ) { - this.players.forEach((player) => { + this.getAllPlayers().forEach((player) => { player.sendMessage({ ...message, players: this.getPlayers() }); }); @@ -828,98 +933,113 @@ export default class Room { } private checkWinConditions() { - this.players.forEach((player) => { + this.teams.forEach((team) => { if (this.bingoMode === BingoMode.LOCKOUT) { const goalsNeeded = Math.ceil( (this.board.length * this.board[0].length) / 2, ); - if (!player.goalComplete && player.goalCount >= goalsNeeded) { + if (!team.goalComplete && team.goalCount >= goalsNeeded) { this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: team.name, + // TODO: Which color should this be? + color: 'white', }, ' has achieved lockout!', ]); - player.goalComplete = true; - this.raceHandler?.playerFinished(player); + team.goalComplete = true; + team.players.values().forEach((player) => { + this.raceHandler?.playerFinished(player); + }) } - if (player.goalComplete && player.goalCount < goalsNeeded) { + if (team.goalComplete && team.goalCount < goalsNeeded) { this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: team.name, + // TODO: Which color should this be? + color: 'white', }, ' no longer has lockout.', ]); - player.goalComplete = false; - this.raceHandler?.playerUnfinshed(player); + team.goalComplete = false; + team.players.values().forEach((player) => { + this.raceHandler?.playerUnfinshed(player); + }) } } else { if (this.bingoMode === BingoMode.LINES) { const linesComplete = this.victoryMasks.reduce( (count, mask) => - count + (player.hasCompletedGoals(mask) ? 1 : 0), + count + (team.hasCompletedGoals(mask) ? 1 : 0), 0, ); - if (linesComplete > player.linesComplete) { + if (linesComplete > team.linesComplete) { this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: team.name, + // TODO: Which color should this be? + color: 'white', }, ' has completed a line!', ]); } if ( linesComplete >= this.lineCount && - !player.goalComplete + !team.goalComplete ) { - player.goalComplete = true; - this.raceHandler?.playerFinished(player).then(); + team.goalComplete = true; + team.players.values().forEach((player) => { + this.raceHandler?.playerFinished(player).then(); + }) this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: team.name, + color: 'white', }, ' has completed the goal!', ]); } else if ( linesComplete < this.lineCount && - player.goalComplete + team.goalComplete ) { - player.goalComplete = false; - this.raceHandler?.playerUnfinshed(player); + team.goalComplete = false; + team.players.values().forEach((player) => { + this.raceHandler?.playerUnfinshed(player).then(); + }) this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: team.name, + color: 'white', }, ' has no longer completed the goal.', ]); } - player.linesComplete = linesComplete; + team.linesComplete = linesComplete; } else { const complete = this.victoryMasks.every((mask) => - player.hasCompletedGoals(mask), + team.hasCompletedGoals(mask), ); - if (complete && !player.goalComplete) { - player.goalComplete = true; - this.raceHandler?.playerFinished(player); + if (complete && !team.goalComplete) { + team.goalComplete = true; + team.players.values().forEach((player) => { + this.raceHandler?.playerFinished(player); + }) this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: team.name, + color: 'white', }, ' has achieved blackout!', ]); - } else if (!complete && player.goalComplete) { - player.goalComplete = false; - this.raceHandler?.playerUnfinshed(player); + } else if (!complete && team.goalComplete) { + team.goalComplete = false; + team.players.values().forEach((player) => { + this.raceHandler?.playerUnfinshed(player); + }) this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: team.name, + color: 'white', }, ' no longer has blackout.', ]); @@ -928,8 +1048,8 @@ export default class Room { } }); let allComplete = true; - this.players.forEach((player) => { - if (!player.spectator && !player.goalComplete) { + this.teams.forEach((team) => { + if (!team.goalComplete) { allComplete = false; } }); @@ -968,13 +1088,13 @@ export default class Room { return false; } - const player = this.players.get( - `${isSession ? 'session' : 'user'}:${user}`, + const player = this.getAllPlayers().find(p => + p.id === `${isSession ? 'session' : 'user'}:${user}`, ); if (player) { return { isMonitor: player.monitor, - isSpectating: player.spectator, + isSpectating: this.spectators.has(player.id), }; } @@ -1001,7 +1121,7 @@ export default class Room { } joinRaceRoom(racetimeId: string, authToken: RoomTokenPayload) { - const player = this.players.get(authToken.playerId); + const player = this.getAllPlayers().find(p => p.id === authToken.playerId); if (!player) { this.logWarn('Unable to find a player for a verified room token'); return false; @@ -1011,7 +1131,7 @@ export default class Room { } leaveRaceRoom(authToken: RoomTokenPayload) { - const player = this.players.get(authToken.playerId); + const player = this.getAllPlayers().find(p => p.id === authToken.playerId); if (!player) { this.logWarn('Unable to find a player for a verified room token'); return false; @@ -1025,7 +1145,7 @@ export default class Room { } readyPlayer(roomAuth: RoomTokenPayload) { - const player = this.players.get(roomAuth.playerId); + const player = this.getAllPlayers().find(p => p.id === roomAuth.playerId); if (!player) { this.logWarn('Unable to find a player for a verified room token'); return false; @@ -1035,7 +1155,7 @@ export default class Room { } unreadyPlayer(roomAuth: RoomTokenPayload) { - const player = this.players.get(roomAuth.playerId); + const player = this.getAllPlayers().find(p => p.id === roomAuth.playerId); if (!player) { this.logWarn( 'Unable to find an identity for a verified room token', @@ -1081,7 +1201,7 @@ export default class Room { */ canClose() { if (Date.now() - this.lastMessage > roomCleanupInactive) { - return this.players.size <= 0; + return this.getAllPlayers().length <= 0; } return false; } @@ -1092,7 +1212,7 @@ export default class Room { close() { this.logInfo('Closing room.'); this.sendSystemMessage('This room has been closed due to inactivity.'); - this.players.forEach((player) => { + this.getAllPlayers().forEach((player) => { player.connections.forEach((connection) => { this.handleSocketClose(connection); connection.close(1001, 'Room is closing.'); @@ -1121,7 +1241,7 @@ export default class Room { } revealCardForAllPlayers() { - this.players.forEach((player) => { + this.getAllPlayers().forEach((player) => { this.revealCardForPlayer(player); }); } diff --git a/api/src/core/RoomServer.ts b/api/src/core/RoomServer.ts index 627eb347..f5ea66ca 100644 --- a/api/src/core/RoomServer.ts +++ b/api/src/core/RoomServer.ts @@ -8,6 +8,8 @@ import { import { roomCleanupInterval } from '../Environment'; import { logInfo, logWarn } from '../Logger'; import Room from './Room'; +import Team from './Team'; +import Player from './Player'; export const roomWebSocketServer: WebSocketServer = new WebSocketServer({ noServer: true, @@ -135,18 +137,41 @@ roomWebSocketServer.on('connection', (ws, req) => { payload.playerId.split(':')[1], payload.userId, ); - const player = room.players.get(payload.playerId); - if (player) { - player.spectator = action.payload.spectate; - player.sendMessage({ - action: 'reauthenticate', - authToken: newToken, + let team: Team | undefined; + let player: Player | undefined; + room.teams.forEach((t) => { + t.players.forEach((p) => { + if (p.id === payload.playerId) { + team = t; + player = p; + } }); - if (player.spectator) { - player.markedGoals = 0n; - player.goalCount = 0; + }); + room.spectators.forEach((p) => { + if (p.id === payload.playerId) { + player = p; + } + }); + if (player) { + if (action.payload.spectate) { + if (team) { + team.removePlayer(player.id); + if (team.players.size === 0) { + team.destroy(); + room.teams.delete(team.id); + } + } + player.sendMessage({ + action: 'reauthenticate', + authToken: newToken, + }); room.sendChat(`${player.nickname} is now spectating`); + break; } else { + player.sendMessage({ + action: 'reauthenticate', + authToken: newToken, + }); room.sendChat(`${player.nickname} is now playing`); } } diff --git a/api/src/core/Team.ts b/api/src/core/Team.ts new file mode 100644 index 00000000..e8c6b980 --- /dev/null +++ b/api/src/core/Team.ts @@ -0,0 +1,136 @@ +import Room from './Room'; +import Player from './Player'; +import { HiddenCell, RevealedCell, Team as TeamData } from '@playbingo/types'; +import { computeRevealedMask, rowColToMask } from '../util/RoomUtils'; + +export default class Team { + room: Room; + /** + * Unique id for the team + */ + id: string; + /** + * The name of the team + */ + name: string; + /** + * The players on the team + */ + players: Map; + + /** Bitset of the goals the player has marked */ + markedGoals: bigint; + /** The number of goals the player has marked */ + goalCount: number; + /** Whether or not the player has completed the goal of the room */ + goalComplete: boolean; + linesComplete: number; + /** Bitset of goals that are revealed for the player in exploration based + * modes */ + exploredGoals: bigint; + + constructor(room: Room, id: string, name: string) { + this.room = room; + ((this.id = id), (this.name = name)); + this.players = new Map(); + this.markedGoals = 0n; + this.goalCount = 0; + this.goalComplete = false; + this.linesComplete = 0; + this.exploredGoals = 0n; + } + + addPlayer(player: Player) { + this.players.set(player.id, player); + } + + removePlayer(id: string) { + this.players.delete(id); + } + + destroy() { + this.players.clear(); + } + + toClientData(): TeamData { + return { + id: this.id, + name: this.name, + players: Array.from(this.players.values()).map((player) => + player.toClientData(), + ), + goalCount: this.goalCount + }; + } + + //#region Goal Tracking + mark(row: number, col: number) { + const mask = rowColToMask(row, col, this.room.board[0].length); + if ((this.markedGoals & mask) === 0n) { + this.markedGoals |= mask; + this.goalCount++; + if (this.room.exploration) { + this.exploredGoals = this.getRevealedMask(); + } + } + } + + unmark(row: number, col: number) { + const mask = rowColToMask(row, col, this.room.board[0].length); + if ((this.markedGoals & mask) !== 0n) { + this.markedGoals &= ~mask; + this.goalCount--; + if (this.room.exploration) { + this.exploredGoals = this.getRevealedMask(); + } + } + } + + hasMarked(row: number, col: number): boolean { + const mask = rowColToMask(row, col, this.room.board[0].length); + return (this.markedGoals & mask) !== 0n; + } + + hasRevealed(row: number, col: number): boolean { + const mask = rowColToMask(row, col, this.room.board[0].length); + return (this.exploredGoals & mask) !== 0n; + } + + getRevealedMask(): bigint { + return ( + computeRevealedMask( + this.markedGoals, + this.room.board[0].length, + this.room.board.length, + ) | this.room.alwaysRevealedMask + ); + } + + obfuscateBoard() { + this.exploredGoals = this.getRevealedMask(); + return this.room.board.map((row, rowIndex) => + row.map((cell, colIndex) => + this.hasRevealed(rowIndex, colIndex) + ? ({ + revealed: true, + goal: cell.goal, + completedPlayers: cell.completedPlayers, + } as RevealedCell) + : ({ + revealed: false, + completedPlayers: cell.completedPlayers, + } as HiddenCell), + ), + ); + } + + /** + * Checks if this player has completed a set of goals on the board + * + * @param mask The bitmask containing the goals to check for + */ + hasCompletedGoals(mask: bigint) { + return (this.markedGoals & mask) === mask; + } + //#endregion +} diff --git a/api/src/core/integration/races/LocalTimer.ts b/api/src/core/integration/races/LocalTimer.ts index e5061d20..777aff08 100644 --- a/api/src/core/integration/races/LocalTimer.ts +++ b/api/src/core/integration/races/LocalTimer.ts @@ -66,9 +66,9 @@ export default class LocalTimer implements RaceHandler { this.finishedAt = undefined; updateStartTime(this.room.id, null).then(); updateFinishTime(this.room.id, null).then(); - this.room.players.forEach((player) => { + this.room.getAllPlayers().forEach((player) => { player.finishedAt = undefined; - createUpdatePlayer(this.room.id, player).then(); + createUpdatePlayer(this.room.id, player, this.room.spectators.has(player.id)).then(); }); } @@ -81,12 +81,12 @@ export default class LocalTimer implements RaceHandler { async playerFinished(player: Player): Promise { player.finishedAt = new Date().toISOString(); - createUpdatePlayer(this.room.id, player).then(); + createUpdatePlayer(this.room.id, player, this.room.spectators.has(player.id)).then(); } async playerUnfinshed(player: Player): Promise { player.finishedAt = undefined; - createUpdatePlayer(this.room.id, player).then(); + createUpdatePlayer(this.room.id, player, this.room.spectators.has(player.id)).then(); } async allPlayersFinished(): Promise { diff --git a/api/src/database/Rooms.ts b/api/src/database/Rooms.ts index e4906519..7dd93dc0 100644 --- a/api/src/database/Rooms.ts +++ b/api/src/database/Rooms.ts @@ -117,7 +117,7 @@ export const disconnectRoomFromRacetime = (slug: string) => { }); }; -export const createUpdatePlayer = async (room: string, player: Player) => { +export const createUpdatePlayer = async (room: string, player: Player, isSpectator: boolean) => { return prisma.player.upsert({ where: { key_roomId: { key: player.id, roomId: room } }, create: { @@ -128,7 +128,7 @@ export const createUpdatePlayer = async (room: string, player: Player) => { user: player.userId ? { connect: { id: player.userId } } : undefined, - spectator: player.spectator, + spectator: isSpectator, monitor: player.monitor, finishedAt: player.finishedAt, }, @@ -138,7 +138,7 @@ export const createUpdatePlayer = async (room: string, player: Player) => { user: player.userId ? { connect: { id: player.userId } } : { disconnect: true }, - spectator: player.spectator, + spectator: isSpectator, monitor: player.monitor, finishedAt: player.finishedAt ?? null, }, diff --git a/api/src/routes/rooms/Rooms.ts b/api/src/routes/rooms/Rooms.ts index 60b189bc..587d7b4e 100644 --- a/api/src/routes/rooms/Rooms.ts +++ b/api/src/routes/rooms/Rooms.ts @@ -270,7 +270,7 @@ rooms.post('/', async (req, res) => { }); async function getOrLoadRoom(slug: string): Promise { - let room = allRooms.get(slug); + const room = allRooms.get(slug); if (room) return room; const dbRoom = await getRoomFromSlug(slug); @@ -353,7 +353,6 @@ async function getOrLoadRoom(slug: string): Promise { dbPlayer.key, dbPlayer.nickname, dbPlayer.color, - dbPlayer.spectator, dbPlayer.monitor, dbPlayer.userId ?? undefined, ); diff --git a/schema/index.d.ts b/schema/index.d.ts index f442ad54..3bc40be4 100644 --- a/schema/index.d.ts +++ b/schema/index.d.ts @@ -10,4 +10,5 @@ export * from './types/Player'; export * from './types/RoomAction'; export * from './types/RoomData'; export * from './types/ServerMessage'; +export * from './types/Team'; export * from './types/User'; diff --git a/schema/schemas/Player.json b/schema/schemas/Player.json index f1c83918..6262ec84 100644 --- a/schema/schemas/Player.json +++ b/schema/schemas/Player.json @@ -6,7 +6,6 @@ "id", "nickname", "color", - "goalCount", "raceStatus", "spectator", "monitor", @@ -22,9 +21,6 @@ "color": { "type": "string" }, - "goalCount": { - "type": "number" - }, "raceStatus": { "oneOf": [ { @@ -35,9 +31,6 @@ } ] }, - "spectator": { - "type": "boolean" - }, "monitor": { "type": "boolean" }, diff --git a/schema/schemas/ServerMessage.json b/schema/schemas/ServerMessage.json index 280b350d..2db7c3f0 100644 --- a/schema/schemas/ServerMessage.json +++ b/schema/schemas/ServerMessage.json @@ -6,9 +6,20 @@ "description": "An incoming websocket message from the server telling the client of a change in room state or instructing it to take an action", "properties": { "players": { - "type": "array", - "items": { - "$ref": "./Player.json" + "type": "object", + "additionalProperties": false, + "required": ["teams", "spectators"], + "properties": { + "teams": { + "items": { + "$ref": "./Team.json" + } + }, + "spectators": { + "items": { + "$ref": "./Player.json" + } + } } }, "connectedPlayer": { @@ -133,9 +144,20 @@ "properties": { "action": "syncRaceData", "players": { - "type": "array", - "items": { - "$ref": "./Player.json" + "type": "object", + "additionalProperties": false, + "required": ["teams", "spectators"], + "properties": { + "teams": { + "items": { + "$ref": "./Team.json" + } + }, + "spectators": { + "items": { + "$ref": "./Player.json" + } + } } }, "racetimeConnection": { diff --git a/schema/schemas/Team.json b/schema/schemas/Team.json new file mode 100644 index 00000000..836441b1 --- /dev/null +++ b/schema/schemas/Team.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "goalCount", + "players" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "goalCount": { + "type": "number" + }, + "players": { + "type": "array", + "items": { + "$ref": "./Player.json" + } + } + } +} \ No newline at end of file diff --git a/schema/types/Player.d.ts b/schema/types/Player.d.ts index e560db70..59f0c62e 100644 --- a/schema/types/Player.d.ts +++ b/schema/types/Player.d.ts @@ -9,9 +9,7 @@ export interface Player { id: string; nickname: string; color: string; - goalCount: number; raceStatus: RaceStatusDisconnected | RaceStatusConnected; - spectator: boolean; monitor: boolean; showInRoom: boolean; } diff --git a/schema/types/ServerMessage.d.ts b/schema/types/ServerMessage.d.ts index 00beadc0..6d57d4cc 100644 --- a/schema/types/ServerMessage.d.ts +++ b/schema/types/ServerMessage.d.ts @@ -42,7 +42,10 @@ export type ServerMessage = ( } | { action: "syncRaceData"; - players: Player[]; + players: { + teams: Team[]; + spectators: Player[]; + }; racetimeConnection: RacetimeConnection; } | { @@ -57,7 +60,10 @@ export type ServerMessage = ( startTime: string; } ) & { - players?: Player[]; + players?: { + teams: Team[]; + spectators: Player[]; + }; connectedPlayer?: Player; }; export type ChatMessage = ( @@ -168,13 +174,17 @@ export interface RacetimeConnection { */ startDelay?: string; } +export interface Team { + id: string; + name: string; + goalCount: number; + players: Player[]; +} export interface Player { id: string; nickname: string; color: string; - goalCount: number; raceStatus: RaceStatusDisconnected | RaceStatusConnected; - spectator: boolean; monitor: boolean; showInRoom: boolean; } From 740ba74fab4ada7d3bc3ccb87ed81569168b7d69 Mon Sep 17 00:00:00 2001 From: Floha Date: Sun, 17 May 2026 21:40:11 +0200 Subject: [PATCH 02/13] types and classes refactor --- api/prisma/schema.prisma | 4 ++- api/src/core/Player.ts | 4 +++ api/src/database/Rooms.ts | 2 +- api/src/routes/rooms/Rooms.ts | 47 +++++++++++++++++++++++++++------ schema/schemas/Player.json | 7 +++-- schema/types/Player.d.ts | 1 + schema/types/ServerMessage.d.ts | 1 + 7 files changed, 54 insertions(+), 12 deletions(-) diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index e21737f9..9ce734df 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -184,6 +184,8 @@ model Team { room Room @relation(fields: [roomId], references: [id]) roomId String players Player[] + + @@unique([id, roomId]) } model Player { @@ -192,7 +194,7 @@ model Player { user User? @relation(fields: [userId], references: [id]) room Room @relation(fields: [roomId], references: [id]) // A player either belongs to a team or is a spectator - team Team? @relation(fields: [teamId], references: [id]) + team Team? @relation(fields: [teamId, roomId], references: [id, roomId]) teamId String? nickname String color String @default("blue") diff --git a/api/src/core/Player.ts b/api/src/core/Player.ts index 6177a12b..0e9a834b 100644 --- a/api/src/core/Player.ts +++ b/api/src/core/Player.ts @@ -38,6 +38,8 @@ export default class Player { userId?: string; /** If the player has permission to perform monitor actions in the room */ monitor: boolean; + /** Parent Team Id, null if spectator */ + teamId: string | null; /** Open connections for the player, mapped by the id in the auth token that * is authorized for the connection */ @@ -51,6 +53,7 @@ export default class Player { room: Room, id: string, nickname: string, + teamId: string | null, color: string = 'blue', monitor: boolean, getBoardView: BoardViewProvider, @@ -58,6 +61,7 @@ export default class Player { ) { this.room = room; ((this.id = id), (this.nickname = nickname)); + this.teamId = teamId; this.color = color; this.monitor = monitor; this.userId = userId; diff --git a/api/src/database/Rooms.ts b/api/src/database/Rooms.ts index 7dd93dc0..69de8ea4 100644 --- a/api/src/database/Rooms.ts +++ b/api/src/database/Rooms.ts @@ -102,7 +102,7 @@ export const getAllRooms = () => { export const getRoomFromSlug = (slug: string) => { return prisma.room.findUnique({ where: { slug }, - include: { history: true, game: true, players: true }, + include: { history: true, game: true, players: true, teams: true }, }); }; diff --git a/api/src/routes/rooms/Rooms.ts b/api/src/routes/rooms/Rooms.ts index 587d7b4e..b922a34b 100644 --- a/api/src/routes/rooms/Rooms.ts +++ b/api/src/routes/rooms/Rooms.ts @@ -32,6 +32,7 @@ import { GenerationFailedError } from '../../core/generation/GenerationFailedErr import RacetimeHandler from '../../core/integration/races/RacetimeHandler'; import LocalTimer from '../../core/integration/races/LocalTimer'; import { error } from 'console'; +import Team from "../../core/Team"; const MIN_ROOM_GOALS_REQUIRED = 25; const rooms = Router(); @@ -347,17 +348,45 @@ async function getOrLoadRoom(slug: string): Promise { } newRoom.computeVictoryMasks(); + dbRoom.teams.forEach((dbTeam) => { + const team = new Team(newRoom, dbTeam.key, dbTeam.name); + newRoom.teams.set(team.id, team); + }) + dbRoom.players.forEach((dbPlayer) => { + // Player is spectator, no need to add a team + if (!dbPlayer.teamId) { + const player = new Player( + newRoom, + dbPlayer.key, + dbPlayer.nickname, + null, + dbPlayer.color, + dbPlayer.monitor, + newRoom.spectatorObfuscateBoard, + dbPlayer.userId ?? undefined, + ); + player.finishedAt = dbPlayer.finishedAt?.toISOString(); + newRoom.spectators.set(player.id, player); + return; + } + // player is not spectator and is on a team + const team = newRoom.teams.get(dbPlayer.teamId); + if (!team) { + // This really shouldn't happen, this is mostly here for type safety + throw new Error(`Team for player ${dbPlayer.nickname} not found, please report this to a developer.`); + } const player = new Player( newRoom, dbPlayer.key, dbPlayer.nickname, + team.id, dbPlayer.color, dbPlayer.monitor, + team.obfuscateBoard, dbPlayer.userId ?? undefined, - ); - player.finishedAt = dbPlayer.finishedAt?.toISOString(); - newRoom.players.set(player.id, player); + ) + team.players.set(player.id, player); }); dbRoom.history.forEach((action) => { @@ -372,7 +401,9 @@ async function getOrLoadRoom(slug: string): Promise { player: playerId, } = action.payload as any; - const player = newRoom.players.get(playerId)!; + const player = newRoom.getAllPlayers().find(p => p.id === playerId)!; + + const team = newRoom.teams.get(player.teamId); switch (action.action) { case 'JOIN': @@ -385,12 +416,12 @@ async function getOrLoadRoom(slug: string): Promise { newRoom.sendChat([{ contents: nickname, color }, ' has left.']); break; case 'MARK': - if (!player.hasMarked(row, col)) { + if (!team.hasMarked(row, col)) { newRoom.board[row][col].completedPlayers.push(playerId); newRoom.board[row][col].completedPlayers.sort((a, b) => a.localeCompare(b), ); - player.mark(row, col); + team.mark(row, col); newRoom.sendCellUpdate(row, col); newRoom.sendChat([ { contents: player.nickname, color: player.color }, @@ -399,11 +430,11 @@ async function getOrLoadRoom(slug: string): Promise { } break; case 'UNMARK': - if (player.hasMarked(row, col)) { + if (team.hasMarked(row, col)) { newRoom.board[row][col].completedPlayers = newRoom.board[ row ][col].completedPlayers.filter((p) => p !== playerId); - player.unmark(row, col); + team.unmark(row, col); newRoom.sendCellUpdate(row, col); newRoom.sendChat([ { contents: player.nickname, color: player.color }, diff --git a/schema/schemas/Player.json b/schema/schemas/Player.json index 6262ec84..1a809c93 100644 --- a/schema/schemas/Player.json +++ b/schema/schemas/Player.json @@ -7,9 +7,9 @@ "nickname", "color", "raceStatus", - "spectator", "monitor", - "showInRoom" + "showInRoom", + "teamId" ], "properties": { "id": { @@ -36,6 +36,9 @@ }, "showInRoom": { "type": "boolean" + }, + "teamId": { + "type": "string" } }, "$defs": { diff --git a/schema/types/Player.d.ts b/schema/types/Player.d.ts index 59f0c62e..e73f2a5c 100644 --- a/schema/types/Player.d.ts +++ b/schema/types/Player.d.ts @@ -12,6 +12,7 @@ export interface Player { raceStatus: RaceStatusDisconnected | RaceStatusConnected; monitor: boolean; showInRoom: boolean; + teamId: string; } export interface RaceStatusDisconnected { connected: false; diff --git a/schema/types/ServerMessage.d.ts b/schema/types/ServerMessage.d.ts index 6d57d4cc..13bd34b1 100644 --- a/schema/types/ServerMessage.d.ts +++ b/schema/types/ServerMessage.d.ts @@ -187,6 +187,7 @@ export interface Player { raceStatus: RaceStatusDisconnected | RaceStatusConnected; monitor: boolean; showInRoom: boolean; + teamId: string; } export interface RaceStatusDisconnected { connected: false; From e3ec37d2cc598bf0048b1e06c6aa64a3ec715096 Mon Sep 17 00:00:00 2001 From: Floha Date: Sun, 12 Jul 2026 22:44:40 +0200 Subject: [PATCH 03/13] fix types and db calls --- api/src/core/Player.ts | 1 + api/src/core/Room.ts | 5 +++-- api/src/core/integration/races/LocalTimer.ts | 6 +++--- api/src/database/Rooms.ts | 10 +++++++--- api/src/routes/rooms/Rooms.ts | 12 ++++++++++-- 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/api/src/core/Player.ts b/api/src/core/Player.ts index 0e9a834b..20a71e41 100644 --- a/api/src/core/Player.ts +++ b/api/src/core/Player.ts @@ -135,6 +135,7 @@ export default class Player { return { id: this.id, nickname: this.nickname, + teamId: this.teamId || '', color: this.color, raceStatus: raceUser ? { diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 0dc6c344..12625873 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -466,6 +466,7 @@ export default class Room { this, auth.playerId, action.payload.nickname, + auth.teamId || null, undefined, auth.isMonitor, auth.isSpectating @@ -512,7 +513,7 @@ export default class Room { player.addConnection(auth.uuid, socket); addJoinAction(this.id, player.nickname, player.color).then(); - createUpdatePlayer(this.id, player, auth.isSpectating).then(); + createUpdatePlayer(this.id, player).then(); return { action: 'connected', board: { @@ -696,7 +697,7 @@ export default class Room { color, ).then(); player.color = color; - createUpdatePlayer(this.id, player, false).then(); + createUpdatePlayer(this.id, player).then(); this.sendChat([ { contents: player.nickname, color: player.color }, ' has changed their color to ', diff --git a/api/src/core/integration/races/LocalTimer.ts b/api/src/core/integration/races/LocalTimer.ts index 777aff08..5077a253 100644 --- a/api/src/core/integration/races/LocalTimer.ts +++ b/api/src/core/integration/races/LocalTimer.ts @@ -68,7 +68,7 @@ export default class LocalTimer implements RaceHandler { updateFinishTime(this.room.id, null).then(); this.room.getAllPlayers().forEach((player) => { player.finishedAt = undefined; - createUpdatePlayer(this.room.id, player, this.room.spectators.has(player.id)).then(); + createUpdatePlayer(this.room.id, player).then(); }); } @@ -81,12 +81,12 @@ export default class LocalTimer implements RaceHandler { async playerFinished(player: Player): Promise { player.finishedAt = new Date().toISOString(); - createUpdatePlayer(this.room.id, player, this.room.spectators.has(player.id)).then(); + createUpdatePlayer(this.room.id, player).then(); } async playerUnfinshed(player: Player): Promise { player.finishedAt = undefined; - createUpdatePlayer(this.room.id, player, this.room.spectators.has(player.id)).then(); + createUpdatePlayer(this.room.id, player).then(); } async allPlayersFinished(): Promise { diff --git a/api/src/database/Rooms.ts b/api/src/database/Rooms.ts index 69de8ea4..677e3f94 100644 --- a/api/src/database/Rooms.ts +++ b/api/src/database/Rooms.ts @@ -117,7 +117,7 @@ export const disconnectRoomFromRacetime = (slug: string) => { }); }; -export const createUpdatePlayer = async (room: string, player: Player, isSpectator: boolean) => { +export const createUpdatePlayer = async (room: string, player: Player) => { return prisma.player.upsert({ where: { key_roomId: { key: player.id, roomId: room } }, create: { @@ -128,7 +128,9 @@ export const createUpdatePlayer = async (room: string, player: Player, isSpectat user: player.userId ? { connect: { id: player.userId } } : undefined, - spectator: isSpectator, + team: player.teamId + ? { connect: { id: player.teamId } } + : undefined, monitor: player.monitor, finishedAt: player.finishedAt, }, @@ -138,8 +140,10 @@ export const createUpdatePlayer = async (room: string, player: Player, isSpectat user: player.userId ? { connect: { id: player.userId } } : { disconnect: true }, - spectator: isSpectator, monitor: player.monitor, + team: player.teamId + ? { connect: { id: player.teamId } } + : { disconnect: true }, finishedAt: player.finishedAt ?? null, }, }); diff --git a/api/src/routes/rooms/Rooms.ts b/api/src/routes/rooms/Rooms.ts index b922a34b..e3a5ffb6 100644 --- a/api/src/routes/rooms/Rooms.ts +++ b/api/src/routes/rooms/Rooms.ts @@ -402,8 +402,10 @@ async function getOrLoadRoom(slug: string): Promise { } = action.payload as any; const player = newRoom.getAllPlayers().find(p => p.id === playerId)!; - - const team = newRoom.teams.get(player.teamId); + let team: Team | undefined; + if (player.teamId) { + team = newRoom.teams.get(player.teamId) + } switch (action.action) { case 'JOIN': @@ -416,6 +418,9 @@ async function getOrLoadRoom(slug: string): Promise { newRoom.sendChat([{ contents: nickname, color }, ' has left.']); break; case 'MARK': + if (!team) { + break; + } if (!team.hasMarked(row, col)) { newRoom.board[row][col].completedPlayers.push(playerId); newRoom.board[row][col].completedPlayers.sort((a, b) => @@ -430,6 +435,9 @@ async function getOrLoadRoom(slug: string): Promise { } break; case 'UNMARK': + if (!team) { + break; + } if (team.hasMarked(row, col)) { newRoom.board[row][col].completedPlayers = newRoom.board[ row From ba311a5f211543cd9febc93fc25e0b8f49df2a17 Mon Sep 17 00:00:00 2001 From: floha258 Date: Mon, 13 Jul 2026 11:40:23 +0200 Subject: [PATCH 04/13] add ws actions for joining team --- api/docker-compose.yml | 2 +- api/package-lock.json | 27 +++++- .../migration.sql | 9 +- api/src/auth/RoomAuth.ts | 1 + api/src/core/Room.ts | 93 ++++++++++++++----- api/src/database/Rooms.ts | 15 +++ schema/package-lock.json | 3 + schema/schemas/RoomAction.json | 15 +++ schema/schemas/ServerMessage.json | 13 +++ schema/types/RoomAction.d.ts | 7 ++ schema/types/ServerMessage.d.ts | 64 +++++++------ schema/types/Team.d.ts | 37 ++++++++ 12 files changed, 219 insertions(+), 67 deletions(-) create mode 100644 schema/types/Team.d.ts diff --git a/api/docker-compose.yml b/api/docker-compose.yml index a2089299..d83a89c3 100644 --- a/api/docker-compose.yml +++ b/api/docker-compose.yml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:13 + image: postgres:14 ports: - '5432:5432' environment: diff --git a/api/package-lock.json b/api/package-lock.json index 79f27613..b332ab89 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -870,6 +870,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -3683,6 +3684,7 @@ "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.2.0", "@jest/expect": "30.2.0", @@ -4013,6 +4015,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -4252,6 +4255,7 @@ "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18.18" }, @@ -5539,6 +5543,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5712,6 +5717,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -6230,6 +6236,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6275,6 +6282,7 @@ "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-EDtsGZS964mf9zAUXAl9Ew16eYbeyAFWhsPr0fX6oaJxgd8rApYlPBf0joyhnUHz88WxrigyFtTaqqzXNzPgqw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6881,6 +6889,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8245,6 +8254,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8431,6 +8441,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8915,7 +8926,6 @@ "resolved": "https://registry.npmjs.org/express-handlebars/-/express-handlebars-8.0.4.tgz", "integrity": "sha512-1mXd9jxLfZgFjpPGamAizVhwukvwLlXRV0dPcsEvW2hqUlYICMtJAQrqFSmgwHFvbVeIA/afPOtmHtNF516pmQ==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "glob": "^12.0.0", "graceful-fs": "^4.2.11", @@ -8930,7 +8940,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", - "peer": true, "engines": { "node": "18 || 20 || >=22" } @@ -8940,7 +8949,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^4.0.2" }, @@ -8953,7 +8961,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz", "integrity": "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==", "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", @@ -8977,7 +8984,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "brace-expansion": "^5.0.2" }, @@ -10641,6 +10647,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12069,6 +12076,7 @@ "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz", "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -12298,6 +12306,7 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", "license": "MIT-0", + "peer": true, "engines": { "node": ">=6.0.0" } @@ -13086,6 +13095,7 @@ "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" @@ -13386,6 +13396,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13395,6 +13406,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14625,6 +14637,7 @@ "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.8.tgz", "integrity": "sha512-Kq/W41AKQloOqKM39zfaMdJ4BcYDw/N5CIq4/GTI0YjU6pKcZ1KKhk6b4du0a+6RA9pIfOP/eu94Ge7cu+PDCA==", "license": "MIT", + "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", @@ -15062,6 +15075,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15329,6 +15343,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15465,6 +15480,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -15721,6 +15737,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", diff --git a/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql b/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql index 4fc110ef..bdd7e714 100644 --- a/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql +++ b/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql @@ -1,12 +1,5 @@ -/* - Warnings: - - - You are about to drop the column `spectator` on the `Player` table. All the data in the column will be lost. - -*/ -- AlterTable -ALTER TABLE "Player" DROP COLUMN "spectator", -ADD COLUMN "teamId" TEXT; +ALTER TABLE "Player" ADD COLUMN "teamId" TEXT; -- CreateTable CREATE TABLE "Team" ( diff --git a/api/src/auth/RoomAuth.ts b/api/src/auth/RoomAuth.ts index 9c776f36..a5c9aa80 100644 --- a/api/src/auth/RoomAuth.ts +++ b/api/src/auth/RoomAuth.ts @@ -31,6 +31,7 @@ export const createRoomToken = ( userId, isSpectating: !!isSpectating, isMonitor: !!isMonitor, + teamId: room.getTeamForPlayer(playerKey)?.id, playerId: `${userId ? 'user' : 'session'}:${playerKey}`, }; const token = sign(payload, roomTokenSecret); diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 12625873..d3c74e92 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -1,4 +1,5 @@ import { GeneratorSettings } from '@playbingo/shared'; +import { randomUUID } from 'crypto'; import { ChangeColorAction, ChangeRaceHandlerAction, @@ -14,6 +15,7 @@ import { ServerMessage, UnmarkAction, SetChatEnabledAction, + JoinTeamAction, } from '@playbingo/types'; import { BingoMode } from '@prisma/client'; import { WebSocket } from 'ws'; @@ -32,6 +34,7 @@ import { addMarkAction, addUnmarkAction, createUpdatePlayer, + createUpdateTeam, setRoomBoard, updateRaceHandler, } from '../database/Rooms'; @@ -482,11 +485,12 @@ export default class Room { } else { playerTeam = new Team( this, - '', + randomUUID(), `Team ${action.payload.nickname}`, ); } playerTeam!.addPlayer(player); + this.teams.set(playerTeam!.id, playerTeam!); } newPlayer = true; } else { @@ -513,6 +517,7 @@ export default class Room { player.addConnection(auth.uuid, socket); addJoinAction(this.id, player.nickname, player.color).then(); + createUpdateTeam(this.id, playerTeam!).then(); createUpdatePlayer(this.id, player).then(); return { action: 'connected', @@ -559,6 +564,40 @@ export default class Room { }; } + handleJoinTeam( + action: JoinTeamAction, + auth: RoomTokenPayload, + ): ServerMessage { + let player = this.getAllPlayers().find((p) => p.id === auth.playerId); + if (!player) { + return { action: 'unauthorized' }; + } + const team = this.teams.get(action.payload.teamId); + if (!team) { + return { action: 'unauthorized' }; + } + const oldTeam = this.getTeamForPlayer(player.id); + if (oldTeam) { + oldTeam.removePlayer(player.id); + if (oldTeam.players.size === 0) { + oldTeam.destroy(); + this.teams.delete(oldTeam.id); + } + } else { + // player was spectator before + this.spectators.delete(player.id); + } + player.teamId = team.id; + team.addPlayer(player); + return { + action: 'joinedTeam', + team: { + ...team, + players: Array.from(team.players, ([_, player]) => player.toClientData()), + }, + }; + } + handleLeave( action: LeaveAction, auth: RoomTokenPayload, @@ -601,9 +640,7 @@ export default class Room { action: ChatAction, auth: RoomTokenPayload, ): ServerMessage | undefined { - const player = this.getAllPlayers().find( - (p) => p.id === auth.playerId, - ); + const player = this.getAllPlayers().find((p) => p.id === auth.playerId); if (!player) { return { action: 'unauthorized' }; } @@ -671,7 +708,10 @@ export default class Room { team.unmark(unRow, unCol); this.sendCellUpdate(unRow, unCol); this.sendChat([ - { contents: team.players.size > 1 ? team.name : player.nickname, color: player.color }, + { + contents: team.players.size > 1 ? team.name : player.nickname, + color: player.color, + }, ` unmarked ${this.board[unRow][unCol].goal.goal} (${unRow},${unCol})`, ]); addUnmarkAction(this.id, player.id, unRow, unCol).then(); @@ -682,7 +722,7 @@ export default class Room { action: ChangeColorAction, auth: RoomTokenPayload, ): ServerMessage | undefined { - const player = this.getAllPlayers().find(p => p.id === auth.playerId); + const player = this.getAllPlayers().find((p) => p.id === auth.playerId); if (!player) { return { action: 'unauthorized' }; } @@ -813,7 +853,9 @@ export default class Room { } handleRevealCard(payload: RoomTokenPayload) { - const player = this.getAllPlayers().find(p => p.id === payload.playerId); + const player = this.getAllPlayers().find( + (p) => p.id === payload.playerId, + ); if (!player) { return null; } @@ -951,7 +993,7 @@ export default class Room { team.goalComplete = true; team.players.values().forEach((player) => { this.raceHandler?.playerFinished(player); - }) + }); } if (team.goalComplete && team.goalCount < goalsNeeded) { this.sendChat([ @@ -965,7 +1007,7 @@ export default class Room { team.goalComplete = false; team.players.values().forEach((player) => { this.raceHandler?.playerUnfinshed(player); - }) + }); } } else { if (this.bingoMode === BingoMode.LINES) { @@ -984,14 +1026,11 @@ export default class Room { ' has completed a line!', ]); } - if ( - linesComplete >= this.lineCount && - !team.goalComplete - ) { + if (linesComplete >= this.lineCount && !team.goalComplete) { team.goalComplete = true; team.players.values().forEach((player) => { this.raceHandler?.playerFinished(player).then(); - }) + }); this.sendChat([ { contents: team.name, @@ -1006,7 +1045,7 @@ export default class Room { team.goalComplete = false; team.players.values().forEach((player) => { this.raceHandler?.playerUnfinshed(player).then(); - }) + }); this.sendChat([ { contents: team.name, @@ -1024,7 +1063,7 @@ export default class Room { team.goalComplete = true; team.players.values().forEach((player) => { this.raceHandler?.playerFinished(player); - }) + }); this.sendChat([ { contents: team.name, @@ -1036,7 +1075,7 @@ export default class Room { team.goalComplete = false; team.players.values().forEach((player) => { this.raceHandler?.playerUnfinshed(player); - }) + }); this.sendChat([ { contents: team.name, @@ -1089,8 +1128,8 @@ export default class Room { return false; } - const player = this.getAllPlayers().find(p => - p.id === `${isSession ? 'session' : 'user'}:${user}`, + const player = this.getAllPlayers().find( + (p) => p.id === `${isSession ? 'session' : 'user'}:${user}`, ); if (player) { return { @@ -1122,7 +1161,9 @@ export default class Room { } joinRaceRoom(racetimeId: string, authToken: RoomTokenPayload) { - const player = this.getAllPlayers().find(p => p.id === authToken.playerId); + const player = this.getAllPlayers().find( + (p) => p.id === authToken.playerId, + ); if (!player) { this.logWarn('Unable to find a player for a verified room token'); return false; @@ -1132,7 +1173,9 @@ export default class Room { } leaveRaceRoom(authToken: RoomTokenPayload) { - const player = this.getAllPlayers().find(p => p.id === authToken.playerId); + const player = this.getAllPlayers().find( + (p) => p.id === authToken.playerId, + ); if (!player) { this.logWarn('Unable to find a player for a verified room token'); return false; @@ -1146,7 +1189,9 @@ export default class Room { } readyPlayer(roomAuth: RoomTokenPayload) { - const player = this.getAllPlayers().find(p => p.id === roomAuth.playerId); + const player = this.getAllPlayers().find( + (p) => p.id === roomAuth.playerId, + ); if (!player) { this.logWarn('Unable to find a player for a verified room token'); return false; @@ -1156,7 +1201,9 @@ export default class Room { } unreadyPlayer(roomAuth: RoomTokenPayload) { - const player = this.getAllPlayers().find(p => p.id === roomAuth.playerId); + const player = this.getAllPlayers().find( + (p) => p.id === roomAuth.playerId, + ); if (!player) { this.logWarn( 'Unable to find an identity for a verified room token', diff --git a/api/src/database/Rooms.ts b/api/src/database/Rooms.ts index 677e3f94..8b8af69a 100644 --- a/api/src/database/Rooms.ts +++ b/api/src/database/Rooms.ts @@ -2,6 +2,7 @@ import { BingoMode, RaceHandler, RoomActionType } from '@prisma/client'; import { prisma } from './Database'; import { JsonObject } from '@prisma/client/runtime/library'; import Player from '../core/Player'; +import Team from '../core/Team'; export const createRoom = ( slug: string, @@ -149,6 +150,20 @@ export const createUpdatePlayer = async (room: string, player: Player) => { }); }; +export const createUpdateTeam = async (room: string, team: Team) => { + return prisma.team.upsert({ + where: { id_roomId: { id: team.id, roomId: room } }, + create: { + key: team.id, + name: team.name, + room: { connect: { id: room } }, + }, + update: { + name: team.name, + }, + }); +}; + export const updateStartTime = async (room: string, startedAt: Date | null) => { return prisma.room.update({ where: { id: room }, diff --git a/schema/package-lock.json b/schema/package-lock.json index a76450c2..4e6456c8 100644 --- a/schema/package-lock.json +++ b/schema/package-lock.json @@ -570,6 +570,7 @@ "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -800,6 +801,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -918,6 +920,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/schema/schemas/RoomAction.json b/schema/schemas/RoomAction.json index b4253362..d2c7cf26 100644 --- a/schema/schemas/RoomAction.json +++ b/schema/schemas/RoomAction.json @@ -10,6 +10,7 @@ "anyOf": [ {"$ref": "#/$defs/JoinAction"}, {"$ref": "#/$defs/LeaveAction"}, + {"$ref": "#/$defs/JoinTeamAction"}, {"$ref": "#/$defs/ChatAction"}, {"$ref": "#/$defs/MarkAction"}, {"$ref": "#/$defs/UnmarkAction"}, @@ -44,6 +45,20 @@ "action": "leave" } }, + "JoinTeamAction": { + "required": ["action", "payload"], + "additionalProperties": false, + "properties": { + "action": "joinTeam", + "payload": { + "required": ["teamId"], + "additionalProperties": false, + "properties": { + "teamId": {"type": "string"} + } + } + } + }, "ChatAction": { "required": ["action", "payload"], "additionalProperties": false, diff --git a/schema/schemas/ServerMessage.json b/schema/schemas/ServerMessage.json index 2db7c3f0..acca85db 100644 --- a/schema/schemas/ServerMessage.json +++ b/schema/schemas/ServerMessage.json @@ -40,6 +40,19 @@ } } }, + { + "required": [ + "action", + "team" + ], + "additionalProperties": false, + "properties": { + "action": "joinedTeam", + "team": { + "$ref": "./Team.json" + } + } + }, { "required": [ "action", diff --git a/schema/types/RoomAction.d.ts b/schema/types/RoomAction.d.ts index 0566e324..d9f598a2 100644 --- a/schema/types/RoomAction.d.ts +++ b/schema/types/RoomAction.d.ts @@ -11,6 +11,7 @@ export type RoomAction = ( | JoinAction | LeaveAction + | JoinTeamAction | ChatAction | MarkAction | UnmarkAction @@ -38,6 +39,12 @@ export interface JoinAction { export interface LeaveAction { action: "leave"; } +export interface JoinTeamAction { + action: "joinTeam"; + payload: { + teamId: string; + }; +} export interface ChatAction { action: "chat"; payload: { diff --git a/schema/types/ServerMessage.d.ts b/schema/types/ServerMessage.d.ts index 13bd34b1..8c71cc54 100644 --- a/schema/types/ServerMessage.d.ts +++ b/schema/types/ServerMessage.d.ts @@ -13,6 +13,10 @@ export type ServerMessage = ( action: "chat"; message: ChatMessage; } + | { + action: "joinedTeam"; + team: Team; + } | { action: "cellUpdate"; row: number; @@ -76,6 +80,36 @@ export type ChatMessage = ( export type Cell = RevealedCell | HiddenCell; export type Board = RevealedBoard | HiddenBoard; +export interface Team { + id: string; + name: string; + goalCount: number; + players: Player[]; +} +export interface Player { + id: string; + nickname: string; + color: string; + raceStatus: RaceStatusDisconnected | RaceStatusConnected; + monitor: boolean; + showInRoom: boolean; + teamId: string; +} +export interface RaceStatusDisconnected { + connected: false; +} +export interface RaceStatusConnected { + connected: true; + /** + * Username connected to this player for the race, if it is separate from PlayBingo + */ + username: string; + ready?: boolean; + /** + * Race finish time (ISO 8601 duration) + */ + finishTime?: string; +} export interface RevealedCell { goal: Goal; completedPlayers: string[]; @@ -174,33 +208,3 @@ export interface RacetimeConnection { */ startDelay?: string; } -export interface Team { - id: string; - name: string; - goalCount: number; - players: Player[]; -} -export interface Player { - id: string; - nickname: string; - color: string; - raceStatus: RaceStatusDisconnected | RaceStatusConnected; - monitor: boolean; - showInRoom: boolean; - teamId: string; -} -export interface RaceStatusDisconnected { - connected: false; -} -export interface RaceStatusConnected { - connected: true; - /** - * Username connected to this player for the race, if it is separate from PlayBingo - */ - username: string; - ready?: boolean; - /** - * Race finish time (ISO 8601 duration) - */ - finishTime?: string; -} diff --git a/schema/types/Team.d.ts b/schema/types/Team.d.ts new file mode 100644 index 00000000..64f8006e --- /dev/null +++ b/schema/types/Team.d.ts @@ -0,0 +1,37 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export interface Team { + id: string; + name: string; + goalCount: number; + players: Player[]; +} +export interface Player { + id: string; + nickname: string; + color: string; + raceStatus: RaceStatusDisconnected | RaceStatusConnected; + monitor: boolean; + showInRoom: boolean; + teamId: string; +} +export interface RaceStatusDisconnected { + connected: false; +} +export interface RaceStatusConnected { + connected: true; + /** + * Username connected to this player for the race, if it is separate from PlayBingo + */ + username: string; + ready?: boolean; + /** + * Race finish time (ISO 8601 duration) + */ + finishTime?: string; +} From 30ddb6bd698f6ee373766a338dfabeb29200b15c Mon Sep 17 00:00:00 2001 From: floha258 Date: Mon, 13 Jul 2026 14:16:28 +0200 Subject: [PATCH 05/13] review and other fixes --- api/src/auth/RoomAuth.ts | 2 - api/src/core/Player.ts | 8 ++-- api/src/core/Room.ts | 83 ++++++++++++++++++++++------------- api/src/core/RoomServer.ts | 6 +++ api/src/routes/rooms/Rooms.ts | 4 +- 5 files changed, 65 insertions(+), 38 deletions(-) diff --git a/api/src/auth/RoomAuth.ts b/api/src/auth/RoomAuth.ts index a5c9aa80..116057ca 100644 --- a/api/src/auth/RoomAuth.ts +++ b/api/src/auth/RoomAuth.ts @@ -13,7 +13,6 @@ export type RoomTokenPayload = { roomSlug: string; uuid: string; playerId: string; - teamId?: string; userId?: string; } & Permissions; @@ -31,7 +30,6 @@ export const createRoomToken = ( userId, isSpectating: !!isSpectating, isMonitor: !!isMonitor, - teamId: room.getTeamForPlayer(playerKey)?.id, playerId: `${userId ? 'user' : 'session'}:${playerKey}`, }; const token = sign(payload, roomTokenSecret); diff --git a/api/src/core/Player.ts b/api/src/core/Player.ts index 20a71e41..af1f6a02 100644 --- a/api/src/core/Player.ts +++ b/api/src/core/Player.ts @@ -8,7 +8,7 @@ import { OPEN, WebSocket } from 'ws'; import { RoomTokenPayload } from '../auth/RoomAuth'; import Room from './Room'; -type BoardViewProvider = () => (RevealedCell | HiddenCell)[][]; +export type BoardViewProvider = () => (RevealedCell | HiddenCell)[][]; /** * Represents a player connected to a room. While largely just a data class, this @@ -38,8 +38,8 @@ export default class Player { userId?: string; /** If the player has permission to perform monitor actions in the room */ monitor: boolean; - /** Parent Team Id, null if spectator */ - teamId: string | null; + /** Parent Team Id, undefined if spectator */ + teamId?: string; /** Open connections for the player, mapped by the id in the auth token that * is authorized for the connection */ @@ -53,10 +53,10 @@ export default class Player { room: Room, id: string, nickname: string, - teamId: string | null, color: string = 'blue', monitor: boolean, getBoardView: BoardViewProvider, + teamId?: string, userId?: string ) { this.room = room; diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index d3c74e92..3f200fe5 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -417,7 +417,7 @@ export default class Room { ); } - getPlayers(): { teams: TeamData[]; spectators: PlayerData[] } { + getPlayerData(): { teams: TeamData[]; spectators: PlayerData[] } { const teams: TeamData[] = []; this.teams.forEach((team) => teams.push(team.toClientData())); const spectators: PlayerData[] = []; @@ -465,32 +465,37 @@ export default class Room { return { action: 'unauthorized' }; } } else if (action.payload) { - player = new Player( - this, - auth.playerId, - action.payload.nickname, - auth.teamId || null, - undefined, - auth.isMonitor, - auth.isSpectating - ? () => this.spectatorObfuscateBoard() - : () => playerTeam!.obfuscateBoard(), - auth.userId, - ); - if (auth.isSpectating) { - this.spectators.set(auth.playerId, player); - } else { - if (auth.teamId && this.teams.get(auth.teamId)) { - playerTeam = this.teams.get(auth.teamId); - } else { - playerTeam = new Team( - this, - randomUUID(), - `Team ${action.payload.nickname}`, - ); - } + const teamId = auth.isSpectating ? undefined : randomUUID(); + if (!auth.isSpectating) { + playerTeam = new Team( + this, + teamId!, + `Team ${action.payload.nickname}`, + ); + player = new Player( + this, + auth.playerId, + action.payload.nickname, + undefined, + auth.isMonitor, + playerTeam.obfuscateBoard, + teamId, + auth.userId, + ); playerTeam!.addPlayer(player); this.teams.set(playerTeam!.id, playerTeam!); + } else { + player = new Player( + this, + auth.playerId, + action.payload.nickname, + undefined, + auth.isMonitor, + this.spectatorObfuscateBoard, + teamId, + auth.userId, + ); + this.spectators.set(auth.playerId, player); } newPlayer = true; } else { @@ -517,7 +522,9 @@ export default class Room { player.addConnection(auth.uuid, socket); addJoinAction(this.id, player.nickname, player.color).then(); - createUpdateTeam(this.id, playerTeam!).then(); + if (playerTeam) { + createUpdateTeam(this.id, playerTeam).then(); + } createUpdatePlayer(this.id, player).then(); return { action: 'connected', @@ -560,7 +567,7 @@ export default class Room { finishedAt: this.raceHandler?.getEndTime(), raceHandler: this.raceHandler?.key(), }, - players: this.getPlayers(), + players: this.getPlayerData(), }; } @@ -589,11 +596,27 @@ export default class Room { } player.teamId = team.id; team.addPlayer(player); + createUpdatePlayer(this.id, player).then(); + if (oldTeam) { + createUpdateTeam(this.id, oldTeam).then(); + } + if (team) { + createUpdateTeam(this.id, team).then(); + } + this.sendChat([ + { + contents: team.players.size > 1 ? team.name : player.nickname, + color: player.color, + }, + ` joined ${team.name}`, + ]); return { action: 'joinedTeam', team: { ...team, - players: Array.from(team.players, ([_, player]) => player.toClientData()), + players: Array.from(team.players, ([_, player]) => + player.toClientData(), + ), }, }; } @@ -916,7 +939,7 @@ export default class Room { this.logInfo('Dispatching race data update'); this.sendServerMessage({ action: 'syncRaceData', - players: this.getPlayers(), + players: this.getPlayerData(), racetimeConnection: { gameActive: this.racetimeEligible, url: (this.raceHandler as RacetimeHandler).url, @@ -964,7 +987,7 @@ export default class Room { updateInactivity: boolean = true, ) { this.getAllPlayers().forEach((player) => { - player.sendMessage({ ...message, players: this.getPlayers() }); + player.sendMessage({ ...message, players: this.getPlayerData() }); }); if (updateInactivity) { diff --git a/api/src/core/RoomServer.ts b/api/src/core/RoomServer.ts index f5ea66ca..a31fe851 100644 --- a/api/src/core/RoomServer.ts +++ b/api/src/core/RoomServer.ts @@ -106,6 +106,12 @@ roomWebSocketServer.on('connection', (ws, req) => { ws.send(JSON.stringify(unmarkResult)); } break; + case 'joinTeam': + const joinTeamResult = room.handleJoinTeam(action, payload); + if (joinTeamResult) { + ws.send(JSON.stringify(joinTeamResult)); + } + break; case 'chat': const chatResult = room.handleChat(action, payload); if (chatResult) { diff --git a/api/src/routes/rooms/Rooms.ts b/api/src/routes/rooms/Rooms.ts index e3a5ffb6..7fc9f1a4 100644 --- a/api/src/routes/rooms/Rooms.ts +++ b/api/src/routes/rooms/Rooms.ts @@ -360,10 +360,10 @@ async function getOrLoadRoom(slug: string): Promise { newRoom, dbPlayer.key, dbPlayer.nickname, - null, dbPlayer.color, dbPlayer.monitor, newRoom.spectatorObfuscateBoard, + undefined, dbPlayer.userId ?? undefined, ); player.finishedAt = dbPlayer.finishedAt?.toISOString(); @@ -380,10 +380,10 @@ async function getOrLoadRoom(slug: string): Promise { newRoom, dbPlayer.key, dbPlayer.nickname, - team.id, dbPlayer.color, dbPlayer.monitor, team.obfuscateBoard, + team.id, dbPlayer.userId ?? undefined, ) team.players.set(player.id, player); From 220440980691ec7607ec7f9a10c373af517fc0b2 Mon Sep 17 00:00:00 2001 From: floha258 Date: Mon, 13 Jul 2026 14:23:47 +0200 Subject: [PATCH 06/13] fix tests --- api/src/tests/core/Player.test.ts | 135 ---------------------- api/src/tests/core/TeamPlayer.test.ts | 154 ++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 135 deletions(-) delete mode 100644 api/src/tests/core/Player.test.ts create mode 100644 api/src/tests/core/TeamPlayer.test.ts diff --git a/api/src/tests/core/Player.test.ts b/api/src/tests/core/Player.test.ts deleted file mode 100644 index 41da1a5a..00000000 --- a/api/src/tests/core/Player.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { mock } from 'jest-mock-extended'; -import Player from '../../core/Player'; -import Room from '../../core/Room'; -import { RevealedCell } from '@playbingo/types'; - -const room = mock(); -room.board = [Array(5).fill(mock()), [], [], [], []]; - -const createPlayer = () => - new Player(room, 'test', 'Test Player', 'blue', false, false); - -describe('Goal Tracking', () => { - beforeEach(() => { - room.exploration = false; - }); - - it('Correctly marks unmarked cells', () => { - const player = createPlayer(); - player.mark(0, 0); - expect(player.markedGoals).toEqual(1n); - expect(player.goalCount).toEqual(1); - player.mark(0, 4); - expect(player.markedGoals).toEqual(BigInt(0b10001)); - expect(player.goalCount).toEqual(2); - player.mark(0, 3); - player.mark(1, 4); - expect(player.markedGoals).toEqual(BigInt(0b1000011001)); - expect(player.goalCount).toEqual(4); - }); - - it("Doesn't change marked cells when marking a cell that is already marked", () => { - const player = createPlayer(); - player.mark(0, 0); - player.mark(1, 2); - const original = player.markedGoals; - player.mark(1, 2); - expect(player.markedGoals).toEqual(original); - expect(player.goalCount).toEqual(2); - }); - - it('Correctly unmarks marked cells', () => { - const player = createPlayer(); - player.mark(0, 0); - player.mark(1, 2); - player.mark(1, 4); - player.mark(2, 2); - player.mark(3, 0); - player.mark(3, 2); - player.unmark(2, 2); - expect(player.markedGoals).toEqual(BigInt(0b101000001010000001)); - expect(player.goalCount).toEqual(5); - player.unmark(3, 2); - player.unmark(1, 4); - expect(player.goalCount).toEqual(3); - expect(player.markedGoals).toEqual(BigInt(0b1000000010000001)); - }); - - it("Doesn't change marked cells when unmarking a cell that is not marked", () => { - const player = createPlayer(); - player.mark(0, 0); - player.mark(1, 3); - const original = player.markedGoals; - player.unmark(3, 0); - expect(player.markedGoals).toEqual(original); - expect(player.goalCount).toEqual(2); - }); - - it('Correctly tells if a cell is marked', () => { - const player = createPlayer(); - const toMark = [3, 7, 9, 16, 21]; - const unmarked = Array.from(Array(25), (_, index) => index).filter( - (index) => !toMark.includes(index), - ); - toMark.forEach((index) => - player.mark(index % 5, Math.floor(index / 5)), - ); - toMark.forEach((index) => - expect( - player.hasMarked(index % 5, Math.floor(index / 5)), - ).toBeTruthy(), - ); - unmarked.forEach((index) => - expect( - player.hasMarked(index % 5, Math.floor(index / 5)), - ).toBeFalsy(), - ); - }); - - it('Correctly determines if a set of goals is marked', () => { - const player = createPlayer(); - player.mark(0, 0); - player.mark(0, 1); - player.mark(0, 2); - player.mark(0, 3); - player.mark(0, 4); - player.mark(1, 0); - player.mark(2, 0); - player.mark(3, 0); - player.mark(4, 0); - const row1Mask = BigInt(0b11111); - const row2Mask = BigInt(0b1111100000); - const col1Mask = BigInt(0b0000100001000010000100001); - expect(player.hasCompletedGoals(row1Mask)).toBeTruthy(); - expect(player.hasCompletedGoals(col1Mask)).toBeTruthy(); - expect(player.hasCompletedGoals(row2Mask)).toBeFalsy(); - }); -}); - -describe('Exploration', () => { - beforeEach(() => { - room.exploration = true; - room.alwaysRevealedMask = 1n; - }); - - it('Correctly reveals cells when marking with exploration enabled', () => { - const player = createPlayer(); - player.room.exploration = true; - player.mark(2, 2); - expect(player.hasRevealed(1, 2)).toBeTruthy(); - expect(player.hasRevealed(3, 2)).toBeTruthy(); - expect(player.hasRevealed(2, 1)).toBeTruthy(); - expect(player.hasRevealed(2, 3)).toBeTruthy(); - }); - - it('Correctly hides cells when marking with exploration enabled', () => { - const player = createPlayer(); - player.room.exploration = true; - player.mark(2, 2); - player.unmark(2, 2); - expect(player.hasRevealed(1, 2)).toBeFalsy(); - expect(player.hasRevealed(3, 2)).toBeFalsy(); - expect(player.hasRevealed(2, 1)).toBeFalsy(); - expect(player.hasRevealed(2, 3)).toBeFalsy(); - }); -}); diff --git a/api/src/tests/core/TeamPlayer.test.ts b/api/src/tests/core/TeamPlayer.test.ts new file mode 100644 index 00000000..3b4b9a20 --- /dev/null +++ b/api/src/tests/core/TeamPlayer.test.ts @@ -0,0 +1,154 @@ +import { mock } from 'jest-mock-extended'; +import Player from '../../core/Player'; +import Room from '../../core/Room'; +import { RevealedCell } from '@playbingo/types'; +import Team from '../../core/Team'; + +const room = mock(); +room.board = [Array(5).fill(mock()), [], [], [], []]; + +const createTeam = () => new Team(room, 'test', 'Test Team'); + +const createPlayer = (team?: Team) => + new Player( + room, + 'test', + 'Test Player', + 'blue', + false, + team ? team.obfuscateBoard : room.spectatorObfuscateBoard, + team?.id, + ); + +describe('Goal Tracking', () => { + beforeEach(() => { + room.exploration = false; + }); + + it('Correctly marks unmarked cells', () => { + const team = createTeam(); + const player = createPlayer(team); + team.mark(0, 0); + expect(team.markedGoals).toEqual(1n); + expect(team.goalCount).toEqual(1); + team.mark(0, 4); + expect(team.markedGoals).toEqual(BigInt(0b10001)); + expect(team.goalCount).toEqual(2); + team.mark(0, 3); + team.mark(1, 4); + expect(team.markedGoals).toEqual(BigInt(0b1000011001)); + expect(team.goalCount).toEqual(4); + }); + + it("Doesn't change marked cells when marking a cell that is already marked", () => { + const team = createTeam(); + const player = createPlayer(team); + team.mark(0, 0); + team.mark(1, 2); + const original = team.markedGoals; + team.mark(1, 2); + expect(team.markedGoals).toEqual(original); + expect(team.goalCount).toEqual(2); + }); + + it('Correctly unmarks marked cells', () => { + const team = createTeam(); + const player = createPlayer(team); + team.mark(0, 0); + team.mark(1, 2); + team.mark(1, 4); + team.mark(2, 2); + team.mark(3, 0); + team.mark(3, 2); + team.unmark(2, 2); + expect(team.markedGoals).toEqual(BigInt(0b101000001010000001)); + expect(team.goalCount).toEqual(5); + team.unmark(3, 2); + team.unmark(1, 4); + expect(team.goalCount).toEqual(3); + expect(team.markedGoals).toEqual(BigInt(0b1000000010000001)); + }); + + it("Doesn't change marked cells when unmarking a cell that is not marked", () => { + const team = createTeam(); + const player = createPlayer(team); + team.mark(0, 0); + team.mark(1, 3); + const original = team.markedGoals; + team.unmark(3, 0); + expect(team.markedGoals).toEqual(original); + expect(team.goalCount).toEqual(2); + }); + + it('Correctly tells if a cell is marked', () => { + const team = createTeam(); + const player = createPlayer(team); + const toMark = [3, 7, 9, 16, 21]; + const unmarked = Array.from(Array(25), (_, index) => index).filter( + (index) => !toMark.includes(index), + ); + toMark.forEach((index) => + team.mark(index % 5, Math.floor(index / 5)), + ); + toMark.forEach((index) => + expect( + team.hasMarked(index % 5, Math.floor(index / 5)), + ).toBeTruthy(), + ); + unmarked.forEach((index) => + expect( + team.hasMarked(index % 5, Math.floor(index / 5)), + ).toBeFalsy(), + ); + }); + + it('Correctly determines if a set of goals is marked', () => { + const team = createTeam(); + const player = createPlayer(team); + team.mark(0, 0); + team.mark(0, 1); + team.mark(0, 2); + team.mark(0, 3); + team.mark(0, 4); + team.mark(1, 0); + team.mark(2, 0); + team.mark(3, 0); + team.mark(4, 0); + const row1Mask = BigInt(0b11111); + const row2Mask = BigInt(0b1111100000); + const col1Mask = BigInt(0b0000100001000010000100001); + expect(team.hasCompletedGoals(row1Mask)).toBeTruthy(); + expect(team.hasCompletedGoals(col1Mask)).toBeTruthy(); + expect(team.hasCompletedGoals(row2Mask)).toBeFalsy(); + }); +}); + +describe('Exploration', () => { + beforeEach(() => { + room.exploration = true; + room.alwaysRevealedMask = 1n; + }); + + it('Correctly reveals cells when marking with exploration enabled', () => { + const team = createTeam(); + const player = createPlayer(team); + team.room.exploration = true; + team.mark(2, 2); + expect(team.hasRevealed(1, 2)).toBeTruthy(); + expect(team.hasRevealed(3, 2)).toBeTruthy(); + expect(team.hasRevealed(2, 1)).toBeTruthy(); + expect(team.hasRevealed(2, 3)).toBeTruthy(); + }); + + it('Correctly hides cells when marking with exploration enabled', () => { + const team = createTeam(); + team.room.exploration = true; + team.mark(2, 2); + team.unmark(2, 2); + expect(team.hasRevealed(1, 2)).toBeFalsy(); + expect(team.hasRevealed(3, 2)).toBeFalsy(); + expect(team.hasRevealed(2, 1)).toBeFalsy(); + expect(team.hasRevealed(2, 3)).toBeFalsy(); + expect(team.hasRevealed(2, 3)).toBeFalsy(); + }); +}); From 3c6bd628cc0e15f58fe3c79a2f62b6b87fe7ac8f Mon Sep 17 00:00:00 2001 From: Floha Date: Tue, 14 Jul 2026 21:03:40 +0200 Subject: [PATCH 07/13] update prisma scripts to hopefully fix ci --- api/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/package.json b/api/package.json index d538a40b..5ea2c496 100644 --- a/api/package.json +++ b/api/package.json @@ -10,8 +10,8 @@ "dev": "tsc-watch --noClear --onSuccess \"node build/src/main.js\"", "build": "tsc --sourceMap false", "db:reset": "prisma migrate reset", - "db:pre-migrate": "npm run db:update-defaults && prisma migrate dev", - "db:migrate": "npm run db:pre-migrate && prisma migrate dev", + "db:pre-migrate": "npm run db:update-defaults && prisma migrate deploy", + "db:migrate": "npm run db:pre-migrate && prisma migrate deploy", "db:seed": "prisma db seed", "db:generate-client": "prisma generate", "db:update-defaults": "tsx scripts/update-prisma-defaults.ts", From 214ef386fce31e93895db23073eb767b5456c44c Mon Sep 17 00:00:00 2001 From: Floha Date: Tue, 14 Jul 2026 21:25:19 +0200 Subject: [PATCH 08/13] remove n22 from ci and add n26 --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 20f81ca7..1ae39b56 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ jobs: strategy: matrix: - node-version: [20.x, 22.x, 24.x] # Define a matrix of Node.js versions to test + node-version: [22.x, 24.x, 26.x] # Define a matrix of Node.js versions to test steps: - name: Checkout code From 7601a9eda7eb2e0f22b6a8eb63b2a2e1cebdee6a Mon Sep 17 00:00:00 2001 From: Floha Date: Mon, 10 Aug 2026 17:13:33 +0200 Subject: [PATCH 09/13] small review fixes --- api/.eslintrc | 10 +++++++++- api/src/core/Room.ts | 11 +++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/api/.eslintrc b/api/.eslintrc index 1f8f0d4b..c2cd7e68 100644 --- a/api/.eslintrc +++ b/api/.eslintrc @@ -34,7 +34,15 @@ "import/extensions": ["off"], "no-shadow": ["off"], "@typescript-eslint/no-shadow": ["error"], - "no-console": "warn" + "no-console": "warn", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ] } } ] diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 3f200fe5..40612048 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -482,7 +482,7 @@ export default class Room { teamId, auth.userId, ); - playerTeam!.addPlayer(player); + playerTeam.addPlayer(player); this.teams.set(playerTeam!.id, playerTeam!); } else { player = new Player( @@ -575,7 +575,7 @@ export default class Room { action: JoinTeamAction, auth: RoomTokenPayload, ): ServerMessage { - let player = this.getAllPlayers().find((p) => p.id === auth.playerId); + const player = this.getAllPlayers().find((p) => p.id === auth.playerId); if (!player) { return { action: 'unauthorized' }; } @@ -612,12 +612,7 @@ export default class Room { ]); return { action: 'joinedTeam', - team: { - ...team, - players: Array.from(team.players, ([_, player]) => - player.toClientData(), - ), - }, + team: team.toClientData(), }; } From 1c86effd1949e8c45464e258ee12711096fb68d4 Mon Sep 17 00:00:00 2001 From: floha258 Date: Fri, 28 Aug 2026 16:49:42 +0200 Subject: [PATCH 10/13] change color to team and refactor Cell.completedPlayers to completedTeams --- api/docs/testing-plan.md | 241 ++++++++++++++++++ .../data-migration.ts | 39 +-- .../migration.sql | 2 + api/prisma/schema.prisma | 4 + api/src/core/Player.ts | 5 - api/src/core/Room.ts | 157 ++++++------ api/src/core/Team.ts | 11 +- api/src/database/Rooms.ts | 21 +- api/src/routes/rooms/Rooms.ts | 28 +- api/src/tests/core/TeamPlayer.test.ts | 1 - api/src/tests/util/WinDetection.test.ts | 174 ++++++------- api/src/util/RoomUtils.ts | 2 +- schema/schemas/Cell.json | 10 +- schema/schemas/Player.json | 4 - schema/schemas/Team.json | 4 + schema/types/Board.d.ts | 4 +- schema/types/Cell.d.ts | 4 +- schema/types/Player.d.ts | 1 - schema/types/ServerMessage.d.ts | 6 +- schema/types/Team.d.ts | 2 +- 20 files changed, 472 insertions(+), 248 deletions(-) create mode 100644 api/docs/testing-plan.md diff --git a/api/docs/testing-plan.md b/api/docs/testing-plan.md new file mode 100644 index 00000000..484d24f0 --- /dev/null +++ b/api/docs/testing-plan.md @@ -0,0 +1,241 @@ +# Test Coverage Analysis & Improvement Plan + +> **Date:** July 2026 +> **Status:** Proposal +> **Location:** tests + +## Summary + +Our existing test suite covers board generation, utility functions, and basic user registration, but large areas of the codebase remain completely untested — particularly API routes, database operations, Room logic (mark/unmark/join/leave/win conditions), authentication, WebSocket handling, and race integration. + +This document outlines what's currently tested, what's missing, and a phased plan to add both unit tests and integration tests backed by a real PostgreSQL database. + +--- + +## What's Currently Tested + +| Test File | What It Tests | +|-----------|---------------| +| `createUser.test.ts` | Registration endpoint — auth token validation, user creation (mocked DB) | +| `GoalValidation.test.ts` | `validateGoalMeta()` — byte limits, prototype pollution, circular refs, depth bombs | +| `core/boardGenerator.test.ts` | Full board generation pipeline — filters, layouts (random/SRLv5/static), restrictions, determinism | +| `core/Cleanup.test.ts` | Room inactivity detection, `canClose()`, cleanup timer | +| `core/TeamPlayer.test.ts` | Team goal marking/unmarking with BigInt bitmasks, exploration cell reveals | +| `util/Array.test.ts` | Seeded `shuffle()` function | +| `util/WinDetection.test.ts` | `computeLineMasks()` and `hasLineCompletion()` for variable board sizes | + +**What works well:** Board generation is thoroughly tested. Utility functions have solid coverage. The test setup provides a reusable auth mock pattern. + +**What's weak:** All DB interactions are mocked — we have no confidence the actual queries work. Only 1 out of ~15 route files has any test coverage. Core Room logic (the largest file) is barely tested beyond cleanup. + +--- + +## Gaps — Unit Tests Needed + +### Priority 1: Core Game Logic + +**`core/Room.ts`** (~1200 lines, barely tested) + +| Method | What to Test | +|--------|-------------| +| `handleMark` / `handleUnmark` | Cell state changes, broadcast to all players, permission enforcement | +| `handleJoin` / `handleSocketClose` | Player tracking, team assignment, reconnection | +| `handleChat` | Message broadcasting, chat-disabled enforcement | +| `handleNewCard` | Board re-generation, state clearing | +| `checkWinConditions` | All three modes: LOCKOUT, LINES, BLACKOUT | +| `canAutoAuthenticate` | Staff/moderator detection | + +**`auth/RoomAuth.ts`** + +| Function | What to Test | +|----------|-------------| +| `createRoomToken()` | Produces valid JWT, correct payload fields (roomSlug, playerId, permissions) | +| `verifyRoomToken()` | Rejects invalid/expired/wrong-room tokens; accepts valid | +| `invalidateToken()` | Token rejected after invalidation | +| `hasPermission()` | Spectators can't mark/unmark, only monitors can newCard, etc. | + +### Priority 2: Authentication & Users + +**`lib/Auth.ts`** +- `validatePassword()` — correct password → true, wrong → false +- `validateUsernamePasswordCombo()` — same, by username +- `hashPassword()` determinism + +**`util/Session.ts`** +- `removeSessionsForUser()` — finds and removes all sessions for a user + +### Priority 3: API Routes (only Registration has a test) + +| Route File | Endpoints to Test | +|-----------|-------------------| +| `auth/Auth.ts` | Login, logout, session validation | +| `games/Games.ts` | CRUD games | +| `games/Variants.ts` | CRUD variants | +| `goals/Goals.ts` | CRUD goals | +| `goals/GoalCategories.ts` | Category management | +| `goals/Upload.ts` | Bulk goal upload/import | +| `rooms/Rooms.ts` | Room creation, listing | +| `rooms/actions/Actions.ts` | Room action dispatching | +| `users/Users.ts` | User profile retrieval/update | +| `oauth/OAuth.ts` | OAuth flow | +| middleware.ts | `requiresApiToken` — valid/invalid/missing token | + +### Priority 4: Supporting Modules + +| Module | What to Test | +|--------|-------------| +| `core/RoomServer.ts` | WebSocket token verification, 60s auth timeout, message routing, ping/keepalive | +| `core/integration/races/LocalTimer.ts` | Timer start/stop/reset | +| `core/integration/races/RacetimeHandler.ts` | Racetime.gg WebSocket integration (mock external WS) | +| `communication/outgoing/Email.ts` | Template rendering, transport mocking | +| `media/MediaServer.ts` | Avatar upload validation, file type/size checks | + +--- + +## Integration Tests — New Test Suite + +### Why? + +All existing tests mock the database. This means: +- **Zero confidence** that Prisma queries actually work against PostgreSQL +- Schema migrations could break queries without any test catching it +- Complex queries with joins, filters, and relations are completely untested + +### Architecture + +``` +┌─────────────────────────────────────────────┐ +│ jest.integration.config.ts │ +│ (separate config, *.integration.test.ts) │ +├─────────────────────────────────────────────┤ +│ Global Setup │ +│ - Create test database (bingogg_test) │ +│ - Run prisma migrate deploy │ +│ - Optionally seed reference data │ +├─────────────────────────────────────────────┤ +│ Test Execution │ +│ - Real Prisma client → real PostgreSQL │ +│ - cleanDatabase() between test files │ +├─────────────────────────────────────────────┤ +│ Global Teardown │ +│ - Drop test database │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Docker Compose (already exists) │ +│ PostgreSQL 14 on port 5432 │ +└─────────────────────────────────────────────┘ +``` + +### Phase 1: Test Infrastructure + +1. **Create `jest.integration.config.ts`** — separate Jest config targeting `**/*.integration.test.ts` with longer timeouts (30s) +2. **Create `src/tests/integration/setup.ts`** — global setup that creates `bingogg_test` database, runs `prisma migrate deploy`, exports `cleanDatabase()` helper +3. **Create `src/tests/integration/teardown.ts`** — drops the test database +4. **Add npm script:** + ```json + "test:integration": "DATABASE_URL=postgresql://postgres:password@localhost:5432/bingogg_test jest --config jest.integration.config.ts --forceExit --runInBand" + ``` + +### Phase 2: Database Layer Tests + +| Test File | Functions to Cover | +|-----------|-------------------| +| `database/Users.integration.test.ts` | `registerUser`, `userByEmail`, `userByUsername`, `emailUsed`, `usernameUsed`, `getUser` | +| `database/Rooms.integration.test.ts` | `createRoom`, `addJoinAction`, `addMarkAction`, `setRoomBoard`, `getFullRoomList` | +| `database/games/Games.integration.test.ts` | Full CRUD for games | +| `database/games/Goals.integration.test.ts` | CRUD goals, category/tag associations, filtering | +| `database/auth/ApiTokens.integration.test.ts` | Token creation, `validateToken`, revocation | + +### Phase 3: Route Integration Tests (HTTP + Real DB) + +Use `supertest` with the real Express app + real database: + +| Test File | Flow to Test | +|-----------|-------------| +| `routes/registration.integration.test.ts` | Full registration → verify user in DB | +| `routes/auth.integration.test.ts` | Register → login → session cookie → authenticated request → logout | +| `routes/games.integration.test.ts` | Create game → list → get → update → delete | +| `routes/goals.integration.test.ts` | Create goal → assign categories/tags → filter → delete | +| `routes/rooms.integration.test.ts` | Create room → list → verify DB entry | + +### Phase 4: WebSocket Integration Tests (can defer) + +- Use the `ws` library as a test client +- Full lifecycle: connect → authenticate → join room → mark cell → verify state → detect win → disconnect + +### Cleanup Strategy + +Truncate all tables between test files: + +```typescript +export async function cleanDatabase() { + const tablenames = await prisma.$queryRaw<{ tablename: string }[]>` + SELECT tablename FROM pg_tables WHERE schemaname='public' + `; + for (const { tablename } of tablenames) { + if (tablename !== '_prisma_migrations') { + await prisma.$executeRawUnsafe(`TRUNCATE TABLE "public"."${tablename}" CASCADE;`); + } + } +} +``` + +--- + +## Suggested First Contributions + +| Task | Difficulty | Impact | +|------|-----------|--------| +| Unit tests for `auth/RoomAuth.ts` | Easy | High — critical auth path | +| Unit tests for `hasPermission()` | Easy | High — security-relevant | +| Integration test infrastructure (Phase 1) | Medium | High — unblocks all integration work | +| Unit tests for `Room.checkWinConditions` | Medium | High — core game logic | +| Database integration tests for Users | Easy | Medium — template for other DB tests | +| Route tests for middleware.ts | Easy | Medium — auth boundary | +| Unit tests for `core/Room.handleMark` | Hard | High — complex state management | + +--- + +## CI Integration + +```yaml +- name: Start test database + run: docker compose up -d + +- name: Wait for PostgreSQL + run: until pg_isready -h localhost -p 5432; do sleep 1; done + +- name: Run unit tests + run: npm test + +- name: Run integration tests + run: npm run test:integration + env: + DATABASE_URL: postgresql://postgres:password@localhost:5432/bingogg_test + +- name: Stop test database + run: docker compose down +``` + +--- + +## Coverage Targets + +| Module | Current (est.) | Target | +|--------|----------------|--------| +| `core/` | ~30% | >70% | +| `database/` | 0% | >80% | +| `routes/` | ~5% | >60% | +| `auth/` | 0% | >90% | +| `util/` | ~60% | >90% | +| `lib/` | 0% | >80% | + +--- + +## Open Questions + +1. **Test DB seeding** — Should integration tests use `prisma db seed` for baseline reference data, or create all needed data in each test? +2. **CI environment** — Does CI already have Docker available, or do we need a service container? +3. **WebSocket tests** — Should we defer Phase 4 until Phases 1-3 are solid? \ No newline at end of file diff --git a/api/prisma/migrations/20260515200019_refactor_for_teams/data-migration.ts b/api/prisma/migrations/20260515200019_refactor_for_teams/data-migration.ts index 8b9e0901..dd4940b7 100644 --- a/api/prisma/migrations/20260515200019_refactor_for_teams/data-migration.ts +++ b/api/prisma/migrations/20260515200019_refactor_for_teams/data-migration.ts @@ -5,46 +5,35 @@ const prisma = new PrismaClient(); async function main() { await prisma.$transaction( async (tx) => { - // We use raw SQL because the 'spectator' column might have been dropped or - // is not available in the current Prisma Client generation. - // We also need to fetch the roomId to create the team in the correct room. - const players: any[] = await tx.$queryRawUnsafe( - 'SELECT id, spectator, "roomId", nickname FROM "Player"' - ); + // Player.spectator is deprecated but retained for this migration. + // Player.color is also retained in the database but is no longer used. + const players = await tx.player.findMany({ + select: { + id: true, + spectator: true, + roomId: true, + nickname: true, + }, + }); for (const player of players) { - // If the player was not a spectator, they need a team. - if (player.spectator === false) { - // Create a new team for this player. - // The 'key' is required in the Team model. We'll use a simple approach for it. + if (!player.spectator) { const team = await tx.team.create({ data: { name: `${player.nickname}'s Team`, - key: player.id, // Using player id as a key for uniqueness if needed + key: player.id, roomId: player.roomId, }, }); - // Assign the player to the newly created team. await tx.player.update({ where: { id: player.id }, - data: { - teamId: team.id, - }, - }); - } else { - // If the player was a spectator, teamId should remain null. - // This is already the default for the new column, but we ensure it. - await tx.player.update({ - where: { id: player.id }, - data: { - teamId: null, - }, + data: { teamId: team.id }, }); } } }, - { timeout: 300000 } // 5 minutes timeout for potentially large data + { timeout: 300000 }, ); } diff --git a/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql b/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql index bdd7e714..fc26ac3f 100644 --- a/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql +++ b/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql @@ -1,4 +1,5 @@ -- AlterTable +-- Legacy Player.color and Player.spectator data are retained for a later migration. ALTER TABLE "Player" ADD COLUMN "teamId" TEXT; -- CreateTable @@ -6,6 +7,7 @@ CREATE TABLE "Team" ( "id" TEXT NOT NULL, "key" TEXT NOT NULL, "name" TEXT NOT NULL, + "color" TEXT NOT NULL DEFAULT 'blue', "roomId" TEXT NOT NULL, CONSTRAINT "Team_pkey" PRIMARY KEY ("id") diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 9ce734df..66a16ab2 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -181,6 +181,7 @@ model Team { id String @id @unique @default(cuid(2)) key String name String + color String @default("blue") room Room @relation(fields: [roomId], references: [id]) roomId String players Player[] @@ -197,7 +198,10 @@ model Player { team Team? @relation(fields: [teamId, roomId], references: [id, roomId]) teamId String? nickname String + /// @deprecated Use Team.color. Retained temporarily to preserve existing data. color String @default("blue") + /// @deprecated Use teamId to distinguish players from spectators. Retained temporarily for data migration compatibility. + spectator Boolean monitor Boolean @default(false) roomId String userId String? diff --git a/api/src/core/Player.ts b/api/src/core/Player.ts index af1f6a02..ad42424f 100644 --- a/api/src/core/Player.ts +++ b/api/src/core/Player.ts @@ -33,8 +33,6 @@ export default class Player { id: string; /** Player display name */ nickname: string; - /** The players chosen color */ - color: string; userId?: string; /** If the player has permission to perform monitor actions in the room */ monitor: boolean; @@ -53,7 +51,6 @@ export default class Player { room: Room, id: string, nickname: string, - color: string = 'blue', monitor: boolean, getBoardView: BoardViewProvider, teamId?: string, @@ -62,7 +59,6 @@ export default class Player { this.room = room; ((this.id = id), (this.nickname = nickname)); this.teamId = teamId; - this.color = color; this.monitor = monitor; this.userId = userId; this.getBoardView = getBoardView; @@ -136,7 +132,6 @@ export default class Player { id: this.id, nickname: this.nickname, teamId: this.teamId || '', - color: this.color, raceStatus: raceUser ? { connected: true, diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 40612048..7681f51e 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -70,7 +70,7 @@ import Team from './Team'; export type HiddenCell = { revealed: false; - completedPlayers: string[]; + completedTeams: string[]; }; export enum BoardGenerationMode { @@ -278,11 +278,11 @@ export default class Room { ? ({ revealed: true, goal: cell.goal, - completedPlayers: cell.completedPlayers, + completedTeams: cell.completedTeams, } as RevealedCell) : ({ revealed: false, - completedPlayers: cell.completedPlayers, + completedTeams: cell.completedTeams, } as HiddenCell); }), ); @@ -315,7 +315,7 @@ export default class Room { this.board = generator.board.map((row) => row.map((goal) => ({ goal: goal, - completedPlayers: [], + completedTeams: [], revealed: true, })), ); @@ -442,41 +442,28 @@ export default class Room { auth: RoomTokenPayload, socket: WebSocket, ): ServerMessage { - let player: Player | undefined; - let playerTeam: Team | undefined; + let player = this.getAllPlayers().find( + (player) => player.id === auth.playerId, + ); + let playerTeam = auth.isSpectating + ? undefined + : player + ? this.getTeamForPlayer(player.id) + : undefined; let newPlayer = false; - let playerIsAuthed = false; - if (!auth.isSpectating) { - this.teams.forEach((team) => { - if (team.players.has(auth.playerId)) { - playerIsAuthed = true; - playerTeam = team; - player = team.players.get(auth.playerId); - } - }); - } else { - player = this.spectators.get(auth.playerId); - if (player) { - playerIsAuthed = true; - } - } - if (playerIsAuthed) { - if (!player) { - return { action: 'unauthorized' }; - } - } else if (action.payload) { + if (!player && action.payload) { const teamId = auth.isSpectating ? undefined : randomUUID(); if (!auth.isSpectating) { playerTeam = new Team( this, teamId!, `Team ${action.payload.nickname}`, + 'blue', ); player = new Player( this, auth.playerId, action.payload.nickname, - undefined, auth.isMonitor, playerTeam.obfuscateBoard, teamId, @@ -489,7 +476,6 @@ export default class Room { this, auth.playerId, action.payload.nickname, - undefined, auth.isMonitor, this.spectatorObfuscateBoard, teamId, @@ -498,13 +484,8 @@ export default class Room { this.spectators.set(auth.playerId, player); } newPlayer = true; - } else { - if (!playerIsAuthed) { - return { action: 'unauthorized' }; - } } - // I don't think this is necessary anymore, but I'm mainly putting it here for type safety if (!player || (!auth.isSpectating && !playerTeam)) { return { action: 'unauthorized' }; } @@ -514,14 +495,14 @@ export default class Room { this.sendChat(`${player.nickname} is now spectating`); } else { this.sendChat([ - { contents: player.nickname, color: player.color }, + { contents: player.nickname, color: playerTeam!.color }, ` has joined playing for ${playerTeam!.name}.`, ]); } } player.addConnection(auth.uuid, socket); - addJoinAction(this.id, player.nickname, player.color).then(); + addJoinAction(this.id, player.nickname).then(); if (playerTeam) { createUpdateTeam(this.id, playerTeam).then(); } @@ -606,7 +587,7 @@ export default class Room { this.sendChat([ { contents: team.players.size > 1 ? team.name : player.nickname, - color: player.color, + color: team.color, }, ` joined ${team.name}`, ]); @@ -641,11 +622,15 @@ export default class Room { this.teams.delete(playerTeam.id); } } - this.sendChat([ - { contents: player.nickname, color: player.color }, - ' has left.', - ]); - addLeaveAction(this.id, player.nickname, player.color).then(); + if (playerTeam) { + this.sendChat([ + { contents: player.nickname, color: playerTeam.color }, + ' has left.', + ]); + } else { + this.sendChat(`${player.nickname} has left.`); + } + addLeaveAction(this.id, player.nickname).then(); if (this.getAllPlayers().length === 0) { this.close(); } @@ -668,7 +653,6 @@ export default class Room { addChatAction( this.id, player.nickname, - player.color, chatMessage, ).then(); } @@ -688,11 +672,11 @@ export default class Room { if ( this.bingoMode === BingoMode.LOCKOUT && - this.board[row][col].completedPlayers.length > 0 + this.board[row][col].completedTeams.length > 0 ) return; - this.board[row][col].completedPlayers.push(player.id); - this.board[row][col].completedPlayers.sort((a, b) => + this.board[row][col].completedTeams.push(team.id); + this.board[row][col].completedTeams.sort((a, b) => a.localeCompare(b), ); team.mark(row, col); @@ -700,7 +684,7 @@ export default class Room { this.sendChat([ { contents: team.players.size > 1 ? team.name : player.nickname, - color: player.color, + color: team.color, }, ` marked ${this.board[row][col].goal.goal} (${row},${col})`, ]); @@ -720,15 +704,15 @@ export default class Room { const { row: unRow, col: unCol } = action.payload; if (unRow === undefined || unCol === undefined) return; if (!team.hasMarked(unRow, unCol)) return; - this.board[unRow][unCol].completedPlayers = this.board[unRow][ + this.board[unRow][unCol].completedTeams = this.board[unRow][ unCol - ].completedPlayers.filter((playerId) => playerId !== player.id); + ].completedTeams.filter((teamId) => teamId !== team.id); team.unmark(unRow, unCol); this.sendCellUpdate(unRow, unCol); this.sendChat([ { contents: team.players.size > 1 ? team.name : player.nickname, - color: player.color, + color: team.color, }, ` unmarked ${this.board[unRow][unCol].goal.goal} (${unRow},${unCol})`, ]); @@ -748,17 +732,16 @@ export default class Room { if (!color) { return; } - addChangeColorAction( - this.id, - player.nickname, - player.color, - color, - ).then(); - player.color = color; - createUpdatePlayer(this.id, player).then(); + const team = this.getTeamForPlayer(player.id); + if (!team) { + return { action: 'unauthorized' }; + } + addChangeColorAction(this.id, team.name, team.color, color).then(); + team.color = color; + createUpdateTeam(this.id, team).then(); this.sendChat([ - { contents: player.nickname, color: player.color }, - ' has changed their color to ', + { contents: team.name, color: team.color }, + ' has changed its color to ', { contents: color, color }, ]); } @@ -805,18 +788,21 @@ export default class Room { handleSocketClose(ws: WebSocket) { let player: Player | undefined; - for (const p of this.getAllPlayers()) { - if (p.handleSocketClose(ws)) { - player = p; - } - } + this.getAllPlayers().forEach((p) => { + if (p.handleSocketClose(ws)) player = p; + }); if (player) { if (!player.hasConnections()) { - this.sendChat([ - { contents: player.nickname, color: player.color }, - ' has left.', - ]); - addLeaveAction(this.id, player.nickname, player.color).then(); + const team = this.getTeamForPlayer(player.id); + if (team) { + this.sendChat([ + { contents: player.nickname, color: team.color }, + ' has left.', + ]); + } else { + this.sendChat(`${player.nickname} has left.`); + } + addLeaveAction(this.id, player.nickname).then(); if (this.getAllPlayers().length === 0) { this.close(); } @@ -1003,8 +989,7 @@ export default class Room { this.sendChat([ { contents: team.name, - // TODO: Which color should this be? - color: 'white', + color: team.color, }, ' has achieved lockout!', ]); @@ -1017,8 +1002,7 @@ export default class Room { this.sendChat([ { contents: team.name, - // TODO: Which color should this be? - color: 'white', + color: team.color, }, ' no longer has lockout.', ]); @@ -1038,8 +1022,7 @@ export default class Room { this.sendChat([ { contents: team.name, - // TODO: Which color should this be? - color: 'white', + color: team.color, }, ' has completed a line!', ]); @@ -1052,7 +1035,7 @@ export default class Room { this.sendChat([ { contents: team.name, - color: 'white', + color: team.color, }, ' has completed the goal!', ]); @@ -1067,7 +1050,7 @@ export default class Room { this.sendChat([ { contents: team.name, - color: 'white', + color: team.color, }, ' has no longer completed the goal.', ]); @@ -1085,7 +1068,7 @@ export default class Room { this.sendChat([ { contents: team.name, - color: 'white', + color: team.color, }, ' has achieved blackout!', ]); @@ -1097,7 +1080,7 @@ export default class Room { this.sendChat([ { contents: team.name, - color: 'white', + color: team.color, }, ' no longer has blackout.', ]); @@ -1288,13 +1271,15 @@ export default class Room { } revealCardForPlayer(player: Player) { - this.sendChat([ - { - contents: player.nickname, - color: player.color, - }, - ' has revealed the card.', - ]); + const team = this.getTeamForPlayer(player.id); + if (team) { + this.sendChat([ + { contents: player.nickname, color: team.color }, + ' has revealed the card.', + ]); + } else { + this.sendChat(`${player.nickname} has revealed the card.`); + } player.sendMessage({ action: 'syncBoard', board: { diff --git a/api/src/core/Team.ts b/api/src/core/Team.ts index e8c6b980..ba7e1f54 100644 --- a/api/src/core/Team.ts +++ b/api/src/core/Team.ts @@ -13,6 +13,8 @@ export default class Team { * The name of the team */ name: string; + /** The color used to display this team's marks */ + color: string; /** * The players on the team */ @@ -29,9 +31,9 @@ export default class Team { * modes */ exploredGoals: bigint; - constructor(room: Room, id: string, name: string) { + constructor(room: Room, id: string, name: string, color: string = 'blue') { this.room = room; - ((this.id = id), (this.name = name)); + ((this.id = id), (this.name = name), (this.color = color)); this.players = new Map(); this.markedGoals = 0n; this.goalCount = 0; @@ -56,6 +58,7 @@ export default class Team { return { id: this.id, name: this.name, + color: this.color, players: Array.from(this.players.values()).map((player) => player.toClientData(), ), @@ -114,11 +117,11 @@ export default class Team { ? ({ revealed: true, goal: cell.goal, - completedPlayers: cell.completedPlayers, + completedTeams: cell.completedTeams, } as RevealedCell) : ({ revealed: false, - completedPlayers: cell.completedPlayers, + completedTeams: cell.completedTeams, } as HiddenCell), ), ); diff --git a/api/src/database/Rooms.ts b/api/src/database/Rooms.ts index 8b8af69a..d0671215 100644 --- a/api/src/database/Rooms.ts +++ b/api/src/database/Rooms.ts @@ -49,11 +49,11 @@ const addRoomAction = ( }); }; -export const addJoinAction = (room: string, nickname: string, color: string) => - addRoomAction(room, RoomActionType.JOIN, { nickname, color }); +export const addJoinAction = (room: string, nickname: string) => + addRoomAction(room, RoomActionType.JOIN, { nickname }); -export const addLeaveAction = (room: string, nickname: string, color: string) => - addRoomAction(room, RoomActionType.LEAVE, { nickname, color }); +export const addLeaveAction = (room: string, nickname: string) => + addRoomAction(room, RoomActionType.LEAVE, { nickname }); export const addMarkAction = ( room: string, @@ -72,18 +72,17 @@ export const addUnmarkAction = ( export const addChatAction = ( room: string, nickname: string, - color: string, message: string, -) => addRoomAction(room, RoomActionType.CHAT, { nickname, color, message }); +) => addRoomAction(room, RoomActionType.CHAT, { nickname, message }); export const addChangeColorAction = ( room: string, - nickname: string, + teamName: string, oldColor: string, newColor: string, ) => addRoomAction(room, RoomActionType.CHANGECOLOR, { - nickname, + teamName, oldColor, newColor, }); @@ -124,7 +123,6 @@ export const createUpdatePlayer = async (room: string, player: Player) => { create: { key: player.id, nickname: player.nickname, - color: player.color, room: { connect: { id: room } }, user: player.userId ? { connect: { id: player.userId } } @@ -132,12 +130,12 @@ export const createUpdatePlayer = async (room: string, player: Player) => { team: player.teamId ? { connect: { id: player.teamId } } : undefined, + spectator: !player.teamId, monitor: player.monitor, finishedAt: player.finishedAt, }, update: { nickname: player.nickname, - color: player.color, user: player.userId ? { connect: { id: player.userId } } : { disconnect: true }, @@ -145,6 +143,7 @@ export const createUpdatePlayer = async (room: string, player: Player) => { team: player.teamId ? { connect: { id: player.teamId } } : { disconnect: true }, + spectator: !player.teamId, finishedAt: player.finishedAt ?? null, }, }); @@ -156,10 +155,12 @@ export const createUpdateTeam = async (room: string, team: Team) => { create: { key: team.id, name: team.name, + color: team.color, room: { connect: { id: room } }, }, update: { name: team.name, + color: team.color, }, }); }; diff --git a/api/src/routes/rooms/Rooms.ts b/api/src/routes/rooms/Rooms.ts index 7fc9f1a4..2f9b8ffa 100644 --- a/api/src/routes/rooms/Rooms.ts +++ b/api/src/routes/rooms/Rooms.ts @@ -331,7 +331,7 @@ async function getOrLoadRoom(slug: string): Promise { newRoom.board = chunk( (await getGoalList(dbRoom.board)).map((goal) => ({ goal: goal, - completedPlayers: [], + completedTeams: [], revealed: true, })), generatorSettings.boardLayout.layout[0].length, @@ -340,7 +340,7 @@ async function getOrLoadRoom(slug: string): Promise { newRoom.board = chunk( (await getGoalList(dbRoom.board)).map((goal) => ({ goal: goal, - completedPlayers: [], + completedTeams: [], revealed: true, })), 5, @@ -349,7 +349,12 @@ async function getOrLoadRoom(slug: string): Promise { newRoom.computeVictoryMasks(); dbRoom.teams.forEach((dbTeam) => { - const team = new Team(newRoom, dbTeam.key, dbTeam.name); + const team = new Team( + newRoom, + dbTeam.key, + dbTeam.name, + dbTeam.color, + ); newRoom.teams.set(team.id, team); }) @@ -360,7 +365,6 @@ async function getOrLoadRoom(slug: string): Promise { newRoom, dbPlayer.key, dbPlayer.nickname, - dbPlayer.color, dbPlayer.monitor, newRoom.spectatorObfuscateBoard, undefined, @@ -380,7 +384,6 @@ async function getOrLoadRoom(slug: string): Promise { newRoom, dbPlayer.key, dbPlayer.nickname, - dbPlayer.color, dbPlayer.monitor, team.obfuscateBoard, team.id, @@ -422,14 +425,14 @@ async function getOrLoadRoom(slug: string): Promise { break; } if (!team.hasMarked(row, col)) { - newRoom.board[row][col].completedPlayers.push(playerId); - newRoom.board[row][col].completedPlayers.sort((a, b) => + newRoom.board[row][col].completedTeams.push(team.id); + newRoom.board[row][col].completedTeams.sort((a, b) => a.localeCompare(b), ); team.mark(row, col); newRoom.sendCellUpdate(row, col); newRoom.sendChat([ - { contents: player.nickname, color: player.color }, + { contents: team.name, color: team.color }, ` marked ${newRoom.board[row][col].goal.goal} (${row},${col})`, ]); } @@ -439,13 +442,13 @@ async function getOrLoadRoom(slug: string): Promise { break; } if (team.hasMarked(row, col)) { - newRoom.board[row][col].completedPlayers = newRoom.board[ + newRoom.board[row][col].completedTeams = newRoom.board[ row - ][col].completedPlayers.filter((p) => p !== playerId); + ][col].completedTeams.filter((teamId) => teamId !== team.id); team.unmark(row, col); newRoom.sendCellUpdate(row, col); newRoom.sendChat([ - { contents: player.nickname, color: player.color }, + { contents: team.name, color: team.color }, ` unmarked ${newRoom.board[row][col].goal.goal} (${row},${col})`, ]); } @@ -454,6 +457,9 @@ async function getOrLoadRoom(slug: string): Promise { newRoom.sendChat(`${nickname}: ${message}`); break; case 'CHANGECOLOR': + if (team) { + team.color = newColor; + } newRoom.sendChat([ { contents: nickname, color: oldColor }, ' has changed their color to ', diff --git a/api/src/tests/core/TeamPlayer.test.ts b/api/src/tests/core/TeamPlayer.test.ts index 3b4b9a20..b1bfedb3 100644 --- a/api/src/tests/core/TeamPlayer.test.ts +++ b/api/src/tests/core/TeamPlayer.test.ts @@ -14,7 +14,6 @@ const createPlayer = (team?: Team) => room, 'test', 'Test Player', - 'blue', false, team ? team.obfuscateBoard : room.spectatorObfuscateBoard, team?.id, diff --git a/api/src/tests/util/WinDetection.test.ts b/api/src/tests/util/WinDetection.test.ts index 92bcc431..ddf3999d 100644 --- a/api/src/tests/util/WinDetection.test.ts +++ b/api/src/tests/util/WinDetection.test.ts @@ -23,7 +23,7 @@ const boardToBitset = (board: Cell[][], color: string) => { let bitset = 0n; board.forEach((row, rowIndex) => row.forEach((cell, colIndex) => { - if (cell.completedPlayers.includes(color)) { + if (cell.completedTeams.includes(color)) { bitset |= 1n << BigInt(rowIndex * board.length + colIndex); } }), @@ -109,27 +109,27 @@ describe('Win Conditions', () => { it('Correctly detects single rows', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue']; - board[0][1].completedPlayers = ['blue']; - board[0][2].completedPlayers = ['blue']; - board[0][3].completedPlayers = ['blue']; - board[0][4].completedPlayers = ['blue']; + board[0][0].completedTeams = ['blue']; + board[0][1].completedTeams = ['blue']; + board[0][2].completedTeams = ['blue']; + board[0][3].completedTeams = ['blue']; + board[0][4].completedTeams = ['blue']; expect(countLines(board, 'blue')).toEqual(1); expect(countLines(board, 'red')).toEqual(0); }); it('Correctly detects single rows with additional values on the board', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue', 'red']; - board[0][1].completedPlayers = ['blue']; - board[0][2].completedPlayers = ['blue']; - board[0][3].completedPlayers = ['red', 'blue', 'green']; - board[0][4].completedPlayers = ['blue']; - board[4][2].completedPlayers = ['red']; - board[4][3].completedPlayers = ['red', 'green']; - board[3][1].completedPlayers = ['blue', 'red', 'green']; - board[1][4].completedPlayers = ['blue', 'green']; - board[2][2].completedPlayers = ['green', 'blue']; + board[0][0].completedTeams = ['blue', 'red']; + board[0][1].completedTeams = ['blue']; + board[0][2].completedTeams = ['blue']; + board[0][3].completedTeams = ['red', 'blue', 'green']; + board[0][4].completedTeams = ['blue']; + board[4][2].completedTeams = ['red']; + board[4][3].completedTeams = ['red', 'green']; + board[3][1].completedTeams = ['blue', 'red', 'green']; + board[1][4].completedTeams = ['blue', 'green']; + board[2][2].completedTeams = ['green', 'blue']; expect(countLines(board, 'blue')).toEqual(1); expect(countLines(board, 'red')).toEqual(0); expect(countLines(board, 'green')).toEqual(0); @@ -137,20 +137,20 @@ describe('Win Conditions', () => { it('Correctly detects multiple rows', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue', 'red']; - board[0][1].completedPlayers = ['blue']; - board[0][2].completedPlayers = ['blue']; - board[0][3].completedPlayers = ['red', 'blue', 'green']; - board[0][4].completedPlayers = ['blue']; - board[1][0].completedPlayers = ['red', 'blue']; - board[1][1].completedPlayers = ['blue', 'red']; - board[1][2].completedPlayers = ['blue']; - board[1][3].completedPlayers = ['blue']; - board[1][4].completedPlayers = ['blue', 'green']; - board[2][2].completedPlayers = ['green', 'blue']; - board[3][1].completedPlayers = ['blue', 'red', 'green']; - board[4][2].completedPlayers = ['red']; - board[4][3].completedPlayers = ['red', 'green']; + board[0][0].completedTeams = ['blue', 'red']; + board[0][1].completedTeams = ['blue']; + board[0][2].completedTeams = ['blue']; + board[0][3].completedTeams = ['red', 'blue', 'green']; + board[0][4].completedTeams = ['blue']; + board[1][0].completedTeams = ['red', 'blue']; + board[1][1].completedTeams = ['blue', 'red']; + board[1][2].completedTeams = ['blue']; + board[1][3].completedTeams = ['blue']; + board[1][4].completedTeams = ['blue', 'green']; + board[2][2].completedTeams = ['green', 'blue']; + board[3][1].completedTeams = ['blue', 'red', 'green']; + board[4][2].completedTeams = ['red']; + board[4][3].completedTeams = ['red', 'green']; expect(countLines(board, 'blue')).toEqual(2); expect(countLines(board, 'red')).toEqual(0); expect(countLines(board, 'green')).toEqual(0); @@ -158,27 +158,27 @@ describe('Win Conditions', () => { it('Correctly detects single columns', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue']; - board[1][0].completedPlayers = ['blue']; - board[2][0].completedPlayers = ['blue']; - board[3][0].completedPlayers = ['blue']; - board[4][0].completedPlayers = ['blue']; + board[0][0].completedTeams = ['blue']; + board[1][0].completedTeams = ['blue']; + board[2][0].completedTeams = ['blue']; + board[3][0].completedTeams = ['blue']; + board[4][0].completedTeams = ['blue']; expect(countLines(board, 'blue')).toEqual(1); expect(countLines(board, 'red')).toEqual(0); }); it('Correctly detects single column with additional values on the board', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue']; - board[1][0].completedPlayers = ['blue', 'red']; - board[2][0].completedPlayers = ['green', 'blue']; - board[3][0].completedPlayers = ['blue']; - board[4][0].completedPlayers = ['blue', 'red', 'green']; - board[4][2].completedPlayers = ['red']; - board[4][3].completedPlayers = ['red', 'green']; - board[3][1].completedPlayers = ['blue', 'red', 'green']; - board[1][4].completedPlayers = ['blue', 'green']; - board[2][2].completedPlayers = ['green', 'blue']; + board[0][0].completedTeams = ['blue']; + board[1][0].completedTeams = ['blue', 'red']; + board[2][0].completedTeams = ['green', 'blue']; + board[3][0].completedTeams = ['blue']; + board[4][0].completedTeams = ['blue', 'red', 'green']; + board[4][2].completedTeams = ['red']; + board[4][3].completedTeams = ['red', 'green']; + board[3][1].completedTeams = ['blue', 'red', 'green']; + board[1][4].completedTeams = ['blue', 'green']; + board[2][2].completedTeams = ['green', 'blue']; expect(countLines(board, 'blue')).toEqual(1); expect(countLines(board, 'red')).toEqual(0); expect(countLines(board, 'green')).toEqual(0); @@ -186,19 +186,19 @@ describe('Win Conditions', () => { it('Correctly detects multiple columns', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue']; - board[1][0].completedPlayers = ['blue', 'red']; - board[2][0].completedPlayers = ['green', 'blue']; - board[3][0].completedPlayers = ['blue']; - board[4][0].completedPlayers = ['blue', 'red', 'green']; - board[0][3].completedPlayers = ['blue']; - board[1][3].completedPlayers = ['blue', 'red']; - board[2][3].completedPlayers = ['green', 'blue']; - board[3][3].completedPlayers = ['blue']; - board[4][3].completedPlayers = ['blue', 'red', 'green']; - board[2][2].completedPlayers = ['green', 'blue']; - board[3][1].completedPlayers = ['blue', 'red', 'green']; - board[4][2].completedPlayers = ['red']; + board[0][0].completedTeams = ['blue']; + board[1][0].completedTeams = ['blue', 'red']; + board[2][0].completedTeams = ['green', 'blue']; + board[3][0].completedTeams = ['blue']; + board[4][0].completedTeams = ['blue', 'red', 'green']; + board[0][3].completedTeams = ['blue']; + board[1][3].completedTeams = ['blue', 'red']; + board[2][3].completedTeams = ['green', 'blue']; + board[3][3].completedTeams = ['blue']; + board[4][3].completedTeams = ['blue', 'red', 'green']; + board[2][2].completedTeams = ['green', 'blue']; + board[3][1].completedTeams = ['blue', 'red', 'green']; + board[4][2].completedTeams = ['red']; expect(countLines(board, 'blue')).toEqual(2); expect(countLines(board, 'red')).toEqual(0); expect(countLines(board, 'green')).toEqual(0); @@ -206,53 +206,53 @@ describe('Win Conditions', () => { it('Correctly detects the main diagonal', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue']; - board[1][1].completedPlayers = ['blue']; - board[2][2].completedPlayers = ['blue']; - board[3][3].completedPlayers = ['blue']; - board[4][4].completedPlayers = ['blue']; + board[0][0].completedTeams = ['blue']; + board[1][1].completedTeams = ['blue']; + board[2][2].completedTeams = ['blue']; + board[3][3].completedTeams = ['blue']; + board[4][4].completedTeams = ['blue']; expect(countLines(board, 'blue')).toEqual(1); expect(countLines(board, 'red')).toEqual(0); }); it('Correctly detects the antiDiagonal', () => { const board = createBoard(); - board[0][4].completedPlayers = ['blue']; - board[1][3].completedPlayers = ['blue']; - board[2][2].completedPlayers = ['blue']; - board[3][1].completedPlayers = ['blue']; - board[4][0].completedPlayers = ['blue']; + board[0][4].completedTeams = ['blue']; + board[1][3].completedTeams = ['blue']; + board[2][2].completedTeams = ['blue']; + board[3][1].completedTeams = ['blue']; + board[4][0].completedTeams = ['blue']; expect(countLines(board, 'blue')).toEqual(1); expect(countLines(board, 'red')).toEqual(0); }); it('Correctly detects mixed lines and colors', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue', 'green']; - board[0][1].completedPlayers = ['blue', 'red', 'green']; - board[0][2].completedPlayers = ['green', 'blue']; - board[0][3].completedPlayers = ['blue', 'green']; - board[0][4].completedPlayers = [ + board[0][0].completedTeams = ['blue', 'green']; + board[0][1].completedTeams = ['blue', 'red', 'green']; + board[0][2].completedTeams = ['green', 'blue']; + board[0][3].completedTeams = ['blue', 'green']; + board[0][4].completedTeams = [ 'blue', 'red', 'green', 'yellow', 'orange', ]; - board[1][0].completedPlayers = ['blue', 'red', 'orange']; - board[2][0].completedPlayers = ['green', 'blue']; - board[3][0].completedPlayers = ['blue']; - board[4][0].completedPlayers = ['blue', 'yellow', 'green', 'orange']; - board[1][1].completedPlayers = ['blue', 'red', 'green']; - board[2][2].completedPlayers = ['green']; - board[3][3].completedPlayers = ['red', 'green']; - board[4][4].completedPlayers = ['blue', 'red', 'green']; - board[1][3].completedPlayers = ['blue', 'red']; - board[2][3].completedPlayers = ['green', 'blue']; - board[4][3].completedPlayers = ['blue', 'red', 'green']; - board[3][1].completedPlayers = ['blue', 'red', 'green']; - board[4][2].completedPlayers = ['red', 'yellow']; - board[4][3].completedPlayers = ['red', 'green', 'orange']; + board[1][0].completedTeams = ['blue', 'red', 'orange']; + board[2][0].completedTeams = ['green', 'blue']; + board[3][0].completedTeams = ['blue']; + board[4][0].completedTeams = ['blue', 'yellow', 'green', 'orange']; + board[1][1].completedTeams = ['blue', 'red', 'green']; + board[2][2].completedTeams = ['green']; + board[3][3].completedTeams = ['red', 'green']; + board[4][4].completedTeams = ['blue', 'red', 'green']; + board[1][3].completedTeams = ['blue', 'red']; + board[2][3].completedTeams = ['green', 'blue']; + board[4][3].completedTeams = ['blue', 'red', 'green']; + board[3][1].completedTeams = ['blue', 'red', 'green']; + board[4][2].completedTeams = ['red', 'yellow']; + board[4][3].completedTeams = ['red', 'green', 'orange']; expect(countLines(board, 'blue')).toEqual(2); expect(countLines(board, 'red')).toEqual(0); expect(countLines(board, 'green')).toEqual(2); diff --git a/api/src/util/RoomUtils.ts b/api/src/util/RoomUtils.ts index 108f3b6b..6166f5f5 100644 --- a/api/src/util/RoomUtils.ts +++ b/api/src/util/RoomUtils.ts @@ -10,7 +10,7 @@ export const listToBoard = ( return chunk( list.map((g) => ({ goal: g, - completedPlayers: [], + completedTeams: [], revealed: true, })), length, diff --git a/schema/schemas/Cell.json b/schema/schemas/Cell.json index b6a94909..5a9ee64f 100644 --- a/schema/schemas/Cell.json +++ b/schema/schemas/Cell.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, - "required": ["goal", "description", "completedPlayers"], + "required": ["goal", "description", "completedTeams"], "anyOf": [ {"$ref": "#/$defs/RevealedCell"}, {"$ref": "#/$defs/HiddenCell"} @@ -10,19 +10,19 @@ "$defs": { "RevealedCell": { "additionalProperties": false, - "required": ["revealed", "goal", "completedPlayers"], + "required": ["revealed", "goal", "completedTeams"], "properties": { "goal": {"$ref": "./Goal.json"}, - "completedPlayers": {"type": "array", "items": {"type": "string"}}, + "completedTeams": {"type": "array", "items": {"type": "string"}}, "revealed": { "enum": [ true ]} } }, "HiddenCell": { "additionalProperties": false, - "required": ["revealed", "completedPlayers"], + "required": ["revealed", "completedTeams"], "properties": { "revealed": { "enum": [ false ]}, - "completedPlayers": {"type": "array", "items": {"type": "string"}} + "completedTeams": {"type": "array", "items": {"type": "string"}} } } } diff --git a/schema/schemas/Player.json b/schema/schemas/Player.json index 1a809c93..9b0bb218 100644 --- a/schema/schemas/Player.json +++ b/schema/schemas/Player.json @@ -5,7 +5,6 @@ "required": [ "id", "nickname", - "color", "raceStatus", "monitor", "showInRoom", @@ -18,9 +17,6 @@ "nickname": { "type": "string" }, - "color": { - "type": "string" - }, "raceStatus": { "oneOf": [ { diff --git a/schema/schemas/Team.json b/schema/schemas/Team.json index 836441b1..03a48144 100644 --- a/schema/schemas/Team.json +++ b/schema/schemas/Team.json @@ -5,6 +5,7 @@ "required": [ "id", "name", + "color", "goalCount", "players" ], @@ -15,6 +16,9 @@ "name": { "type": "string" }, + "color": { + "type": "string" + }, "goalCount": { "type": "number" }, diff --git a/schema/types/Board.d.ts b/schema/types/Board.d.ts index b2fa3bed..31d18c6a 100644 --- a/schema/types/Board.d.ts +++ b/schema/types/Board.d.ts @@ -16,7 +16,7 @@ export interface RevealedBoard { } export interface RevealedCell { goal: Goal; - completedPlayers: string[]; + completedTeams: string[]; revealed: true; } /** @@ -55,7 +55,7 @@ export interface GoalTag { } export interface HiddenCell { revealed: false; - completedPlayers: string[]; + completedTeams: string[]; } export interface HiddenBoard { hidden: true; diff --git a/schema/types/Cell.d.ts b/schema/types/Cell.d.ts index 3a48cf41..8f35052b 100644 --- a/schema/types/Cell.d.ts +++ b/schema/types/Cell.d.ts @@ -9,7 +9,7 @@ export type Cell = RevealedCell | HiddenCell; export interface RevealedCell { goal: Goal; - completedPlayers: string[]; + completedTeams: string[]; revealed: true; } /** @@ -48,5 +48,5 @@ export interface GoalTag { } export interface HiddenCell { revealed: false; - completedPlayers: string[]; + completedTeams: string[]; } diff --git a/schema/types/Player.d.ts b/schema/types/Player.d.ts index e73f2a5c..8530bae0 100644 --- a/schema/types/Player.d.ts +++ b/schema/types/Player.d.ts @@ -8,7 +8,6 @@ export interface Player { id: string; nickname: string; - color: string; raceStatus: RaceStatusDisconnected | RaceStatusConnected; monitor: boolean; showInRoom: boolean; diff --git a/schema/types/ServerMessage.d.ts b/schema/types/ServerMessage.d.ts index 8c71cc54..f237c708 100644 --- a/schema/types/ServerMessage.d.ts +++ b/schema/types/ServerMessage.d.ts @@ -83,13 +83,13 @@ export type Board = RevealedBoard | HiddenBoard; export interface Team { id: string; name: string; + color: string; goalCount: number; players: Player[]; } export interface Player { id: string; nickname: string; - color: string; raceStatus: RaceStatusDisconnected | RaceStatusConnected; monitor: boolean; showInRoom: boolean; @@ -112,7 +112,7 @@ export interface RaceStatusConnected { } export interface RevealedCell { goal: Goal; - completedPlayers: string[]; + completedTeams: string[]; revealed: true; } /** @@ -151,7 +151,7 @@ export interface GoalTag { } export interface HiddenCell { revealed: false; - completedPlayers: string[]; + completedTeams: string[]; } export interface RevealedBoard { board: Cell[][]; diff --git a/schema/types/Team.d.ts b/schema/types/Team.d.ts index 64f8006e..50f82289 100644 --- a/schema/types/Team.d.ts +++ b/schema/types/Team.d.ts @@ -8,13 +8,13 @@ export interface Team { id: string; name: string; + color: string; goalCount: number; players: Player[]; } export interface Player { id: string; nickname: string; - color: string; raceStatus: RaceStatusDisconnected | RaceStatusConnected; monitor: boolean; showInRoom: boolean; From 6ba0e30352d7752e5c822622f9f0acbbd5e58b99 Mon Sep 17 00:00:00 2001 From: floha258 Date: Fri, 28 Aug 2026 17:23:46 +0200 Subject: [PATCH 11/13] add teamsEnabled toggle --- .../migration.sql | 3 + api/prisma/schema.prisma | 1 + api/src/core/Room.ts | 81 +++++++++++-------- api/src/core/RoomServer.ts | 19 +---- api/src/database/Rooms.ts | 2 + api/src/routes/rooms/Rooms.ts | 7 +- schema/schemas/RoomData.json | 6 +- schema/types/RoomData.d.ts | 1 + schema/types/ServerMessage.d.ts | 1 + 9 files changed, 70 insertions(+), 51 deletions(-) diff --git a/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql b/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql index fc26ac3f..9ac5f2c9 100644 --- a/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql +++ b/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql @@ -2,6 +2,9 @@ -- Legacy Player.color and Player.spectator data are retained for a later migration. ALTER TABLE "Player" ADD COLUMN "teamId" TEXT; +-- AlterTable +ALTER TABLE "Room" ADD COLUMN "teamsEnabled" BOOLEAN NOT NULL DEFAULT false; + -- CreateTable CREATE TABLE "Team" ( "id" TEXT NOT NULL, diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 66a16ab2..93b3b46c 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -172,6 +172,7 @@ model Room { variantId String? exploration Boolean @default(false) explorationStart String? + teamsEnabled Boolean @default(false) raceHandler RaceHandler? startedAt DateTime? finishedAt DateTime? diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 7681f51e..4e16d2d3 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -123,6 +123,7 @@ export default class Room { exploration: boolean = false; alwaysRevealedMask: bigint = 0n; seed: number; + teamsEnabled: boolean; chatEnabled: boolean = true; lastGenerationMode: BoardGenerationOptions; @@ -161,6 +162,7 @@ export default class Room { explorationStart?: string, racetimeUrl?: string, generatorSettings?: GeneratorSettings, + teamsEnabled: boolean = false, ) { this.name = name; this.game = game; @@ -206,6 +208,7 @@ export default class Room { this.spectators = new Map(); this.seed = seed; + this.teamsEnabled = teamsEnabled; if (explorationStart) { this.exploration = true; @@ -257,6 +260,21 @@ export default class Room { return [...this.spectators.values(), ...players]; } + getPlayerById(playerId: string): Player | undefined { + return this.getAllPlayers().find((player) => player.id === playerId); + } + + getPlayerDisplayName(player: Player, team?: Team): string { + return this.teamsEnabled && team ? team.name : player.nickname; + } + + getTeamDisplayName(team: Team): string { + if (this.teamsEnabled) { + return team.name; + } + return team.players.values().next().value?.nickname ?? team.name; + } + deleteTeam(teamId: string) { this.teams.get(teamId)?.destroy(); this.teams.delete(teamId); @@ -442,9 +460,7 @@ export default class Room { auth: RoomTokenPayload, socket: WebSocket, ): ServerMessage { - let player = this.getAllPlayers().find( - (player) => player.id === auth.playerId, - ); + let player = this.getPlayerById(auth.playerId); let playerTeam = auth.isSpectating ? undefined : player @@ -544,6 +560,7 @@ export default class Room { : { gameActive: this.racetimeEligible, url: undefined }, mode: getModeString(this.bingoMode, this.lineCount), variant: this.variantName, + teamsEnabled: this.teamsEnabled, startedAt: this.raceHandler?.getStartTime(), finishedAt: this.raceHandler?.getEndTime(), raceHandler: this.raceHandler?.key(), @@ -556,7 +573,10 @@ export default class Room { action: JoinTeamAction, auth: RoomTokenPayload, ): ServerMessage { - const player = this.getAllPlayers().find((p) => p.id === auth.playerId); + if (!this.teamsEnabled) { + return { action: 'forbidden' }; + } + const player = this.getPlayerById(auth.playerId); if (!player) { return { action: 'unauthorized' }; } @@ -586,7 +606,7 @@ export default class Room { } this.sendChat([ { - contents: team.players.size > 1 ? team.name : player.nickname, + contents: player.nickname, color: team.color, }, ` joined ${team.name}`, @@ -643,7 +663,7 @@ export default class Room { action: ChatAction, auth: RoomTokenPayload, ): ServerMessage | undefined { - const player = this.getAllPlayers().find((p) => p.id === auth.playerId); + const player = this.getPlayerById(auth.playerId); if (!player) { return { action: 'unauthorized' }; } @@ -683,7 +703,7 @@ export default class Room { this.sendCellUpdate(row, col); this.sendChat([ { - contents: team.players.size > 1 ? team.name : player.nickname, + contents: this.getPlayerDisplayName(player, team), color: team.color, }, ` marked ${this.board[row][col].goal.goal} (${row},${col})`, @@ -711,7 +731,7 @@ export default class Room { this.sendCellUpdate(unRow, unCol); this.sendChat([ { - contents: team.players.size > 1 ? team.name : player.nickname, + contents: this.getPlayerDisplayName(player, team), color: team.color, }, ` unmarked ${this.board[unRow][unCol].goal.goal} (${unRow},${unCol})`, @@ -724,7 +744,7 @@ export default class Room { action: ChangeColorAction, auth: RoomTokenPayload, ): ServerMessage | undefined { - const player = this.getAllPlayers().find((p) => p.id === auth.playerId); + const player = this.getPlayerById(auth.playerId); if (!player) { return { action: 'unauthorized' }; } @@ -740,7 +760,7 @@ export default class Room { team.color = color; createUpdateTeam(this.id, team).then(); this.sendChat([ - { contents: team.name, color: team.color }, + { contents: this.getTeamDisplayName(team), color: team.color }, ' has changed its color to ', { contents: color, color }, ]); @@ -827,6 +847,7 @@ export default class Room { newGenerator: this.newGenerator, mode: getModeString(this.bingoMode, this.lineCount), variant: this.variantName, + teamsEnabled: this.teamsEnabled, raceHandler: this.raceHandler?.key(), }, }); @@ -851,15 +872,14 @@ export default class Room { newGenerator: this.newGenerator, mode: getModeString(this.bingoMode, this.lineCount), variant: this.variantName, + teamsEnabled: this.teamsEnabled, raceHandler: this.raceHandler?.key(), }, }); } handleRevealCard(payload: RoomTokenPayload) { - const player = this.getAllPlayers().find( - (p) => p.id === payload.playerId, - ); + const player = this.getPlayerById(payload.playerId); if (!player) { return null; } @@ -959,6 +979,7 @@ export default class Room { finishedAt: this.raceHandler?.getEndTime(), raceHandler: this.raceHandler?.key(), chatEnabled: this.chatEnabled, + teamsEnabled: this.teamsEnabled, }, }); } @@ -988,7 +1009,7 @@ export default class Room { if (!team.goalComplete && team.goalCount >= goalsNeeded) { this.sendChat([ { - contents: team.name, + contents: this.getTeamDisplayName(team), color: team.color, }, ' has achieved lockout!', @@ -1001,7 +1022,7 @@ export default class Room { if (team.goalComplete && team.goalCount < goalsNeeded) { this.sendChat([ { - contents: team.name, + contents: this.getTeamDisplayName(team), color: team.color, }, ' no longer has lockout.', @@ -1021,7 +1042,7 @@ export default class Room { if (linesComplete > team.linesComplete) { this.sendChat([ { - contents: team.name, + contents: this.getTeamDisplayName(team), color: team.color, }, ' has completed a line!', @@ -1034,7 +1055,7 @@ export default class Room { }); this.sendChat([ { - contents: team.name, + contents: this.getTeamDisplayName(team), color: team.color, }, ' has completed the goal!', @@ -1049,7 +1070,7 @@ export default class Room { }); this.sendChat([ { - contents: team.name, + contents: this.getTeamDisplayName(team), color: team.color, }, ' has no longer completed the goal.', @@ -1067,7 +1088,7 @@ export default class Room { }); this.sendChat([ { - contents: team.name, + contents: this.getTeamDisplayName(team), color: team.color, }, ' has achieved blackout!', @@ -1079,7 +1100,7 @@ export default class Room { }); this.sendChat([ { - contents: team.name, + contents: this.getTeamDisplayName(team), color: team.color, }, ' no longer has blackout.', @@ -1129,8 +1150,8 @@ export default class Room { return false; } - const player = this.getAllPlayers().find( - (p) => p.id === `${isSession ? 'session' : 'user'}:${user}`, + const player = this.getPlayerById( + `${isSession ? 'session' : 'user'}:${user}`, ); if (player) { return { @@ -1162,9 +1183,7 @@ export default class Room { } joinRaceRoom(racetimeId: string, authToken: RoomTokenPayload) { - const player = this.getAllPlayers().find( - (p) => p.id === authToken.playerId, - ); + const player = this.getPlayerById(authToken.playerId); if (!player) { this.logWarn('Unable to find a player for a verified room token'); return false; @@ -1174,9 +1193,7 @@ export default class Room { } leaveRaceRoom(authToken: RoomTokenPayload) { - const player = this.getAllPlayers().find( - (p) => p.id === authToken.playerId, - ); + const player = this.getPlayerById(authToken.playerId); if (!player) { this.logWarn('Unable to find a player for a verified room token'); return false; @@ -1190,9 +1207,7 @@ export default class Room { } readyPlayer(roomAuth: RoomTokenPayload) { - const player = this.getAllPlayers().find( - (p) => p.id === roomAuth.playerId, - ); + const player = this.getPlayerById(roomAuth.playerId); if (!player) { this.logWarn('Unable to find a player for a verified room token'); return false; @@ -1202,9 +1217,7 @@ export default class Room { } unreadyPlayer(roomAuth: RoomTokenPayload) { - const player = this.getAllPlayers().find( - (p) => p.id === roomAuth.playerId, - ); + const player = this.getPlayerById(roomAuth.playerId); if (!player) { this.logWarn( 'Unable to find an identity for a verified room token', diff --git a/api/src/core/RoomServer.ts b/api/src/core/RoomServer.ts index a31fe851..e146cf47 100644 --- a/api/src/core/RoomServer.ts +++ b/api/src/core/RoomServer.ts @@ -143,21 +143,10 @@ roomWebSocketServer.on('connection', (ws, req) => { payload.playerId.split(':')[1], payload.userId, ); - let team: Team | undefined; - let player: Player | undefined; - room.teams.forEach((t) => { - t.players.forEach((p) => { - if (p.id === payload.playerId) { - team = t; - player = p; - } - }); - }); - room.spectators.forEach((p) => { - if (p.id === payload.playerId) { - player = p; - } - }); + const player = room.getPlayerById(payload.playerId); + const team = player + ? room.getTeamForPlayer(player.id) + : undefined; if (player) { if (action.payload.spectate) { if (team) { diff --git a/api/src/database/Rooms.ts b/api/src/database/Rooms.ts index d0671215..a6605a49 100644 --- a/api/src/database/Rooms.ts +++ b/api/src/database/Rooms.ts @@ -16,6 +16,7 @@ export const createRoom = ( variant?: string, explorationStart?: string, seed?: number, + teamsEnabled: boolean = false, ) => { return prisma.room.create({ data: { @@ -31,6 +32,7 @@ export const createRoom = ( exploration: !!explorationStart, explorationStart, seed, + teamsEnabled, }, }); }; diff --git a/api/src/routes/rooms/Rooms.ts b/api/src/routes/rooms/Rooms.ts index 2f9b8ffa..d452b200 100644 --- a/api/src/routes/rooms/Rooms.ts +++ b/api/src/routes/rooms/Rooms.ts @@ -83,6 +83,7 @@ rooms.post('/', async (req, res) => { exploration, explorationStart, explorationStartCount, + teamsEnabled, } = req.body; const seed = req.body.seed ?? Math.ceil(999999 * Math.random()); @@ -192,6 +193,7 @@ rooms.post('/', async (req, res) => { : explorationStart : undefined, seed, + !!teamsEnabled, ); const room = new Room( name, @@ -215,6 +217,7 @@ rooms.post('/', async (req, res) => { : undefined, '', generatorSettings, + !!teamsEnabled, ); const options: BoardGenerationOptions = { mode: BoardGenerationMode.RANDOM, @@ -325,6 +328,7 @@ async function getOrLoadRoom(slug: string): Promise { dbRoom.explorationStart ?? undefined, dbRoom.racetimeRoom ?? '', generatorSettings, + dbRoom.teamsEnabled, ); if (generatorSettings?.boardLayout.mode === 'custom') { @@ -404,7 +408,7 @@ async function getOrLoadRoom(slug: string): Promise { player: playerId, } = action.payload as any; - const player = newRoom.getAllPlayers().find(p => p.id === playerId)!; + const player = newRoom.getPlayerById(playerId)!; let team: Team | undefined; if (player.teamId) { team = newRoom.teams.get(player.teamId) @@ -514,6 +518,7 @@ rooms.get('/:slug', async (req, res) => { mode: room.bingoMode, variant: room.variantName, chatEnabled: room.chatEnabled, + teamsEnabled: room.teamsEnabled, }; const userKey = req.session.user ?? req.session.id; diff --git a/schema/schemas/RoomData.json b/schema/schemas/RoomData.json index fd7b770b..7812157d 100644 --- a/schema/schemas/RoomData.json +++ b/schema/schemas/RoomData.json @@ -10,7 +10,8 @@ "newGenerator", "variant", "mode", - "seed" + "seed", + "teamsEnabled" ], "description": "Basic information about a room", "properties": { @@ -45,6 +46,9 @@ "seed": { "type": "number" }, + "teamsEnabled": { + "type": "boolean" + }, "startedAt": { "type": "string" }, diff --git a/schema/types/RoomData.d.ts b/schema/types/RoomData.d.ts index 7e4352e4..e1f6a59d 100644 --- a/schema/types/RoomData.d.ts +++ b/schema/types/RoomData.d.ts @@ -22,6 +22,7 @@ export interface RoomData { variant: string; mode: string; seed: number; + teamsEnabled: boolean; startedAt?: string; finishedAt?: string; raceHandler?: "LOCAL" | "RACETIME"; diff --git a/schema/types/ServerMessage.d.ts b/schema/types/ServerMessage.d.ts index f237c708..15395589 100644 --- a/schema/types/ServerMessage.d.ts +++ b/schema/types/ServerMessage.d.ts @@ -181,6 +181,7 @@ export interface RoomData { variant: string; mode: string; seed: number; + teamsEnabled: boolean; startedAt?: string; finishedAt?: string; raceHandler?: "LOCAL" | "RACETIME"; From 49970b8a02973873e78767d0e5ecb61566074f4b Mon Sep 17 00:00:00 2001 From: floha258 Date: Fri, 28 Aug 2026 17:27:06 +0200 Subject: [PATCH 12/13] add teamsEnabled action --- api/src/auth/RoomAuth.ts | 1 + api/src/core/Room.ts | 8 ++++++++ api/src/core/RoomServer.ts | 3 +++ api/src/database/Rooms.ts | 3 +++ schema/schemas/RoomAction.json | 17 ++++++++++++++++- schema/types/RoomAction.d.ts | 7 +++++++ 6 files changed, 38 insertions(+), 1 deletion(-) diff --git a/api/src/auth/RoomAuth.ts b/api/src/auth/RoomAuth.ts index 116057ca..acf46140 100644 --- a/api/src/auth/RoomAuth.ts +++ b/api/src/auth/RoomAuth.ts @@ -80,6 +80,7 @@ export const hasPermission = ( case 'resetTimer': return payload.isMonitor; case 'setChatEnabled': + case 'setTeamsEnabled': return payload.isMonitor; default: return true; diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 4e16d2d3..3b56ba6f 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -16,6 +16,7 @@ import { UnmarkAction, SetChatEnabledAction, JoinTeamAction, + SetTeamsEnabledAction, } from '@playbingo/types'; import { BingoMode } from '@prisma/client'; import { WebSocket } from 'ws'; @@ -37,6 +38,7 @@ import { createUpdateTeam, setRoomBoard, updateRaceHandler, + updateTeamsEnabled, } from '../database/Rooms'; import { isStaff } from '../database/Users'; import { @@ -890,6 +892,12 @@ export default class Room { this.chatEnabled = action.payload.enabled; this.sendRoomData(); } + + handleSetTeamsEnabled(action: SetTeamsEnabledAction) { + this.teamsEnabled = action.payload.enabled; + updateTeamsEnabled(this.id, this.teamsEnabled).then(); + this.sendRoomData(); + } //#endregion //#region Send Messages diff --git a/api/src/core/RoomServer.ts b/api/src/core/RoomServer.ts index e146cf47..a7d2767d 100644 --- a/api/src/core/RoomServer.ts +++ b/api/src/core/RoomServer.ts @@ -183,6 +183,9 @@ roomWebSocketServer.on('connection', (ws, req) => { case 'setChatEnabled': room.handleSetChatEnabled(action); break; + case 'setTeamsEnabled': + room.handleSetTeamsEnabled(action); + break; } }); ws.on('close', (code, reason) => { diff --git a/api/src/database/Rooms.ts b/api/src/database/Rooms.ts index a6605a49..c5e28816 100644 --- a/api/src/database/Rooms.ts +++ b/api/src/database/Rooms.ts @@ -192,3 +192,6 @@ export const updateRaceHandler = async ( data: { raceHandler }, }); }; + +export const updateTeamsEnabled = async (room: string, teamsEnabled: boolean) => + prisma.room.update({ where: { id: room }, data: { teamsEnabled } }); diff --git a/schema/schemas/RoomAction.json b/schema/schemas/RoomAction.json index d2c7cf26..174cdace 100644 --- a/schema/schemas/RoomAction.json +++ b/schema/schemas/RoomAction.json @@ -21,7 +21,8 @@ {"$ref": "#/$defs/StartTimerAction"}, {"$ref": "#/$defs/ChangeRaceHandlerAction"}, {"$ref": "#/$defs/ResetTimerAction"}, - {"$ref": "#/$defs/SetChatEnabledAction"} + {"$ref": "#/$defs/SetChatEnabledAction"}, + {"$ref": "#/$defs/SetTeamsEnabledAction"} ], "$defs": { "JoinAction": { @@ -189,6 +190,20 @@ } } } + }, + "SetTeamsEnabledAction": { + "required": ["action", "payload"], + "additionalProperties": false, + "properties": { + "action": "setTeamsEnabled", + "payload": { + "required": ["enabled"], + "additionalProperties": false, + "properties": { + "enabled": {"type": "boolean"} + } + } + } } } } \ No newline at end of file diff --git a/schema/types/RoomAction.d.ts b/schema/types/RoomAction.d.ts index d9f598a2..5bb6753a 100644 --- a/schema/types/RoomAction.d.ts +++ b/schema/types/RoomAction.d.ts @@ -23,6 +23,7 @@ export type RoomAction = ( | ChangeRaceHandlerAction | ResetTimerAction | SetChatEnabledAction + | SetTeamsEnabledAction ) & { /** * JWT for the room obtained from the server @@ -104,3 +105,9 @@ export interface SetChatEnabledAction { enabled: boolean; }; } +export interface SetTeamsEnabledAction { + action: "setTeamsEnabled"; + payload: { + enabled: boolean; + }; +} From 669c7226160a75be22397326003ab08a5f2d9841 Mon Sep 17 00:00:00 2001 From: floha258 Date: Fri, 28 Aug 2026 17:35:42 +0200 Subject: [PATCH 13/13] documentation and testing --- api/docs/breaking-changes.md | 78 ++++++++++++ api/src/tests/core/RoomTeams.test.ts | 177 +++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 api/docs/breaking-changes.md create mode 100644 api/src/tests/core/RoomTeams.test.ts diff --git a/api/docs/breaking-changes.md b/api/docs/breaking-changes.md new file mode 100644 index 00000000..e9722671 --- /dev/null +++ b/api/docs/breaking-changes.md @@ -0,0 +1,78 @@ +# Teams Refactor Breaking Changes + +This release changes the room WebSocket protocol from player-owned state to +team-owned state. Third-party clients must update their room state models before +connecting to rooms running this API version. + +## Player Lists + +The `players` field on `connected`, `chat`, `cellUpdate`, `syncBoard`, and other +room messages is no longer an array of players. It is now an object with two +arrays: + +```ts +{ + teams: Team[]; + spectators: Player[]; +} +``` + +Players in `teams` are nested under their team. Spectators are listed only in +`spectators`. Each `Player` now has `teamId`, which is an empty string for a +spectator. + +## Teams And Colors + +`Team` is a new protocol type with `id`, `name`, `color`, `goalCount`, and +`players`. `Team.color` is the authoritative mark color. + +`Player.color` has been removed from the API payload. Clients must read colors +from the containing team and must not render a color for spectators. + +The existing `changeColor` action now changes the authenticated player's team +color. Spectators cannot use it. + +## Board Cells + +`completedPlayers` has been renamed to `completedTeams`. The array contains +team IDs rather than player IDs. Use each ID to resolve a team from +`players.teams`, then use that team's `color` when rendering a completed cell. + +## Teams Setting + +`RoomData` now includes required boolean `teamsEnabled`. It is included in the +initial room response and `updateRoomData` messages. + +When `teamsEnabled` is `false`, the API still creates one internal team per +player to own marks, but messages display player names and joining another team +is forbidden. When it is `true`, messages display team names and players may +join existing teams. + +Monitors can update the setting over WebSocket: + +```json +{ + "action": "setTeamsEnabled", + "payload": { "enabled": true }, + "authToken": "" +} +``` + +Clients should update local room state after the resulting `updateRoomData` +message. + +## New Action + +`joinTeam` is available when teams are enabled: + +```json +{ + "action": "joinTeam", + "payload": { "teamId": "" }, + "authToken": "" +} +``` + +Clients must handle `joinedTeam`, which returns the destination `Team`, and +`forbidden`, which is returned when teams are disabled or the token lacks the +necessary permission. \ No newline at end of file diff --git a/api/src/tests/core/RoomTeams.test.ts b/api/src/tests/core/RoomTeams.test.ts new file mode 100644 index 00000000..1413c7fe --- /dev/null +++ b/api/src/tests/core/RoomTeams.test.ts @@ -0,0 +1,177 @@ +import { BingoMode } from '@prisma/client'; +import { + ChangeColorAction, + JoinAction, + JoinTeamAction, + MarkAction, + RevealedCell, + SetTeamsEnabledAction, + UnmarkAction, +} from '@playbingo/types'; +import { WebSocket } from 'ws'; +import { RoomTokenPayload } from '../../auth/RoomAuth'; +import Room from '../../core/Room'; +import { + createUpdatePlayer, + createUpdateTeam, + updateTeamsEnabled, +} from '../../database/Rooms'; + +jest.mock('../../database/Rooms', () => ({ + addChangeColorAction: jest.fn().mockResolvedValue(undefined), + addChatAction: jest.fn().mockResolvedValue(undefined), + addJoinAction: jest.fn().mockResolvedValue(undefined), + addLeaveAction: jest.fn().mockResolvedValue(undefined), + addMarkAction: jest.fn().mockResolvedValue(undefined), + addUnmarkAction: jest.fn().mockResolvedValue(undefined), + createUpdatePlayer: jest.fn().mockResolvedValue(undefined), + createUpdateTeam: jest.fn().mockResolvedValue(undefined), + setRoomBoard: jest.fn().mockResolvedValue(undefined), + updateRaceHandler: jest.fn().mockResolvedValue(undefined), + updateTeamsEnabled: jest.fn().mockResolvedValue(undefined), +})); + +const createRoom = () => { + const room = new Room( + 'Room', + 'Game', + 'game', + 'room', + '', + 'room-id', + false, + BingoMode.LINES, + 1, + false, + 'Normal', + 1, + ); + room.board = Array.from({ length: 5 }, (_, row) => + Array.from( + { length: 5 }, + (_, col) => + ({ + goal: { + id: `${row}-${col}`, + goal: 'Goal', + description: null, + }, + completedTeams: [], + revealed: true, + }) as RevealedCell, + ), + ); + room.computeVictoryMasks(); + return room; +}; + +const auth = (playerId: string, isSpectating = false): RoomTokenPayload => ({ + roomSlug: 'room', + uuid: `${playerId}-connection`, + playerId, + isSpectating, + isMonitor: true, +}); + +const socket = () => ({ readyState: 0, send: jest.fn() }) as unknown as WebSocket; + +const joinAction = (nickname: string) => + ({ action: 'join', payload: { nickname } }) as JoinAction; + +describe('Room team workflows', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('creates a team for a joining player and exposes team-owned state', () => { + const room = createRoom(); + const result = room.handleJoin(joinAction('Alice'), auth('alice'), socket()); + + expect(result.action).toBe('connected'); + expect(room.teams.size).toBe(1); + expect(result).toMatchObject({ + roomData: { teamsEnabled: false }, + }); + const team = room.getTeamForPlayer('alice'); + expect(team).toMatchObject({ name: 'Team Alice', color: 'blue' }); + expect(room.getPlayerById('alice')?.teamId).toBe(team?.id); + expect(createUpdateTeam).toHaveBeenCalledWith('room-id', team); + expect(createUpdatePlayer).toHaveBeenCalledWith( + 'room-id', + room.getPlayerById('alice'), + ); + expect(room.chatHistory).toContainEqual([ + { contents: 'Alice', color: 'blue' }, + ' has joined.', + ]); + }); + + it('tracks marks by team ID and applies the team color', () => { + const room = createRoom(); + room.handleJoin(joinAction('Alice'), auth('alice'), socket()); + const team = room.getTeamForPlayer('alice')!; + + room.handleMark( + { action: 'mark', payload: { row: 0, col: 0 } } as MarkAction, + auth('alice'), + ); + expect(room.board[0][0].completedTeams).toEqual([team.id]); + expect(team.hasMarked(0, 0)).toBe(true); + + room.handleUnmark( + { action: 'unmark', payload: { row: 0, col: 0 } } as UnmarkAction, + auth('alice'), + ); + expect(room.board[0][0].completedTeams).toEqual([]); + expect(team.hasMarked(0, 0)).toBe(false); + + room.handleChangeColor( + { action: 'changeColor', payload: { color: 'red' } } as ChangeColorAction, + auth('alice'), + ); + expect(team.color).toBe('red'); + expect(createUpdateTeam).toHaveBeenLastCalledWith('room-id', team); + }); + + it('permits joining another team only when teams are enabled', () => { + const room = createRoom(); + room.handleJoin(joinAction('Alice'), auth('alice'), socket()); + room.handleJoin(joinAction('Bob'), auth('bob'), socket()); + const aliceTeam = room.getTeamForPlayer('alice')!; + + const joinTeam = { + action: 'joinTeam', + payload: { teamId: aliceTeam.id }, + } as JoinTeamAction; + expect(room.handleJoinTeam(joinTeam, auth('bob'))).toEqual({ + action: 'forbidden', + }); + + room.handleSetTeamsEnabled( + { + action: 'setTeamsEnabled', + payload: { enabled: true }, + } as SetTeamsEnabledAction, + ); + expect(updateTeamsEnabled).toHaveBeenCalledWith('room-id', true); + expect(room.handleJoinTeam(joinTeam, auth('bob'))?.action).toBe( + 'joinedTeam', + ); + expect(room.getTeamForPlayer('bob')).toBe(aliceTeam); + }); + + it('uses player names for single-player rooms and team names when enabled', () => { + const room = createRoom(); + room.handleJoin(joinAction('Alice'), auth('alice'), socket()); + const team = room.getTeamForPlayer('alice')!; + + expect(room.getTeamDisplayName(team)).toBe('Alice'); + room.handleSetTeamsEnabled( + { + action: 'setTeamsEnabled', + payload: { enabled: true }, + } as SetTeamsEnabledAction, + ); + expect(room.getTeamDisplayName(team)).toBe('Team Alice'); + }); +}); \ No newline at end of file