diff --git a/client/src/components/GameContext.tsx b/client/src/components/GameContext.tsx index 32fc03c..d2b0821 100644 --- a/client/src/components/GameContext.tsx +++ b/client/src/components/GameContext.tsx @@ -37,6 +37,7 @@ interface IGameContextValue { gameConfig: RoomConfig isCurrentUserLeader: boolean isCurrentUserJudge: boolean + player: Player | null joinRoom: (username: string, roomId: string, pictureUrl: string) => void createRoom: (username: string, pictureUrl: string) => void leaveRoom: () => void @@ -88,6 +89,7 @@ const GameContext = createContext({ gameConfig: defaultGameConfig, isCurrentUserLeader: false, isCurrentUserJudge: false, + player: null, joinRoom: () => undefined, createRoom: () => undefined, leaveRoom: () => undefined, @@ -263,6 +265,7 @@ const GameProvider: React.FC = ({ children }) => { gameConfig, isCurrentUserLeader: gameState.leader === myId, isCurrentUserJudge: gameState.judge === myId, + player: player || null, joinRoom, createRoom, leaveRoom, diff --git a/client/src/pages/game.tsx b/client/src/pages/game.tsx index daab736..d0a9339 100644 --- a/client/src/pages/game.tsx +++ b/client/src/pages/game.tsx @@ -13,7 +13,7 @@ import useTranslation from 'next-translate/useTranslation' import { type Card } from '@ccc-cards-game/types' export default function Game() { - const { isCurrentUserJudge, gameState, myId, playerSelectCards } = useGameContext() + const { isCurrentUserJudge, gameState, myId, playerSelectCards, player } = useGameContext() const myCards = gameState.players.get(myId)?.cards || [] const { t } = useTranslation('game') @@ -26,8 +26,8 @@ export default function Game() { const { currentQuestionCard } = gameState - const player = gameState.players.get(myId) const myStatus = player?.status + const isWaiting = player?.isWaitingForNextRound || false const handleCardClick = (card: Card) => { if (myStatus !== 'pending') return @@ -83,6 +83,7 @@ export default function Game() { {isCurrentUserJudge && } + {isWaiting && } {!isCurrentUserJudge && (
{myCards.map((card, index) => { diff --git a/server/src/rooms/MyRoom.ts b/server/src/rooms/MyRoom.ts index 79637d5..5bdf7a5 100644 --- a/server/src/rooms/MyRoom.ts +++ b/server/src/rooms/MyRoom.ts @@ -80,15 +80,22 @@ export class MyRoom extends Room { return; } + logger.info(`${result.data.username} joined!`); const {username, pictureUrl, oldPlayerId} = result.data; + // Create a new player instance and set its properties const newPlayer = new PlayerSchema(); newPlayer.id = client.sessionId; newPlayer.username = username; newPlayer.pictureUrl = pictureUrl; + const isMidGame = this.state.roomStatus !== 'waiting' + if(isMidGame){ + newPlayer.isWaitingForNextRound = true + } + // Add the player to the players list this.state.players.set(client.sessionId, newPlayer); @@ -97,6 +104,8 @@ export class MyRoom extends Room { this.state.leader = newPlayer.id; } + + if (oldPlayerId) { if (this.canReconnect(client, oldPlayerId)) { this.handleReconnect(client, oldPlayerId); @@ -128,12 +137,7 @@ export class MyRoom extends Room { }`, ); - if (consented) { - // this.handleConsentedLeave(client); - this.handleUnintentionalDisconnection(client); - return; - } - this.handleUnintentionalDisconnection(client); + this.state.disconnectPlayer(client.sessionId); } onDispose() { @@ -146,6 +150,13 @@ export class MyRoom extends Room { * Handles the player reconnecting in the room */ private handleReconnect(client: Client, oldPlayerId: string) { + // check if the game already started + // if is in the middle of a round and the player was really offline from the beggining og the round + // the player will need to wait until the next round start to reconnect properly + // if the player has quited in the beguinning of a round, was set as bot, and returned at the same round, it will just reconnect normaly + // the player can only reconnect when the room status is "waiting" | "judging" | "results" | "finished" + // if the room status is "playing" the player will be "on hold" untill the status change to a status that can be connected + logger.info(`Player ${oldPlayerId} is reconnecting!`); // TODO: Implement this in the frontend const newPlayer = this.state.players.get(client.sessionId); @@ -159,9 +170,7 @@ export class MyRoom extends Room { // delete the old player this.state.players.delete(oldPlayerId); - // TODO: Go trough all the rounds and replace the old player's id with the new player's id - - + this.state.updatePlayerIdInRounds(oldPlayerId, client.sessionId); logger.info(`${newPlayer.username} reconnected!`); } @@ -174,87 +183,24 @@ export class MyRoom extends Room { return this.state.config.roomSize; } - private handleConsentedLeave(client: Client) { - const playerLeaving = this.state.players.get(client.sessionId); - - if (!playerLeaving) return; - - // this.state.players.delete(client.sessionId); - this.state.disconnectPlayer(client.sessionId); - - // If the player leaving is not the current leader, nothing else needs to be done - if (playerLeaving.id !== this.state.leader) return; - - // If there are still players left, assign leadership to the first player in the players map - if (this.onlinePlayersIds.length > 0) { - // this.state.leader = this.state.onlinePlayers().values().next().value.id; - this.state.leader = this.getNextLeaderId(); - return; - } - - // If no players are left, disconnect the room - this.disconnect(); - } - private handleUnintentionalDisconnection(client: Client) { - //get the player - const playerLeaving = this.state.players.get(client.sessionId); //set the status to disconnected this.state.disconnectPlayer(client.sessionId); // handle the game when someone disconnects - this.handleGamePlayerDisconnectMidGame(playerLeaving); - - // TODO: handle unintentional disconnection - // this.handleConsentedLeave(client); - return; - } - - private handleGamePlayerDisconnectMidGame(player: PlayerSchema) { - logger.info(`>> player disconnected ${player.username}`); + // this.handleGamePlayerDisconnectMidGame(playerLeaving); - const disconnectActions: Record void> = { - //TODO: change this to the appropriate action - playing: this.autoGoToNextRoundWhenPlayerDisconnects.bind(this, player), - judging: this.autoGoToNextRoundWhenPlayerDisconnects.bind(this, player), - results: this.autoGoToNextRoundWhenPlayerDisconnects.bind(this, player), - waiting: () => {}, - finished: () => {}, - starting: () => {}, - }; - - disconnectActions[this.state.roomStatus](); - } - - private autoGoToNextRoundWhenPlayerDisconnects(player: PlayerSchema) { - logger.info(">> auto going to next round when player disconnects"); - this.sendMessageToAllPlayers(`The player ${player.username} disconnected, skipping round`); - this.skipToNextRound(); - } - - private handlePlayerDisconnectWhenJudging(player: PlayerSchema) { - this.sendMessageToAllPlayers(`The player ${player.username} disconnected, skipping round`); - this.skipToNextRound(); return; - logger.info(">> player disconnected when judging", player.username); - - // verify if player is the judge - if (player.id !== this.state.judge) { - logger.info(">>> player is not the judge"); - return; - } - - //if the player is the judge null the round and go tho the next round - - // send a message to the frontend explaining what happened - this.sendMessageToAllPlayers(`The JUDGE ${player.username} disconnected, skipping round`); } - private autoSelectCardsForThePlayerDisconnecting(player: PlayerSchema) { - logger.info(">> auto selecting cards for the player disconnecting"); - this.sendMessageToAllPlayers(`The player ${player.username} disconnected, skipping round`); - this.skipToNextRound(); + /** + * When the player disconnects mid game, he will become a bot, until the end of the round, so the other players will not fell bored with the round skipping. + * @param player + */ + private setPlayerAsBot(player: PlayerSchema) { + player.isBot = true; + player.isOffline = true; } private throwError(client: Client, message: string) { @@ -278,7 +224,7 @@ export class MyRoom extends Room { this.setStatus("starting"); // this.selectJudge(); - const firstJudge = this.randomOnlinePlayerId(); + const firstJudge = this.state.randomOnlinePlayerId; this.state.judge = firstJudge; const questionCard = await this.getNextQuestionCard(); @@ -300,41 +246,6 @@ export class MyRoom extends Room { this.setStatus("playing"); } - private randomOnlinePlayerId() { - const onlinePlayersIds = this.onlinePlayersIds(); - const randomIndex = Math.floor(Math.random() * onlinePlayersIds.length); - return onlinePlayersIds[randomIndex]; - } - - /** - * I want to remove the onlinePlayers() from the Room class and use the state method instead - */ - private onlinePlayersIds() { - const playersArray = this.state.playersArray; - const onlinePlayers = playersArray.filter(player => !player.isOffline); - return onlinePlayers.map(player => player.id); - } - - /** - * Gets the current judge index and adds 1 to it, if the index is bigger than the online players array length, it resets to 0 - */ - private getNextLeaderId() { - const onlinePlayersIds = this.onlinePlayersIds(); - const currentLeaderIndex = onlinePlayersIds.indexOf(this.state.leader); - const nextLeaderIndex = currentLeaderIndex + 1; - const nextLeaderId = onlinePlayersIds[nextLeaderIndex] || onlinePlayersIds[0]; - return nextLeaderId; - } - - private getNextJudgeId() { - const onlinePlayersIds = this.onlinePlayersIds(); - if (onlinePlayersIds.length === 0) return null; - const currentJudgeIdIndex = onlinePlayersIds.findIndex(id => id === this.state.judge); - const nextJudgeIdIndex = currentJudgeIdIndex + 1; - const nextJudgeId = onlinePlayersIds[nextJudgeIdIndex] || onlinePlayersIds[0]; - return nextJudgeId; - } - @AdminOnly private handleAdmKickPlayer(client: Client, data: AdminKickPlayerPayload) { //TODO: make a button on the client to kick a player @@ -355,20 +266,28 @@ export class MyRoom extends Room { } private async skipToNextRound() { - //first check for a winner - if (this.checkForWinner()) { + + if (this.state.thereIsAWinner) { + this.handleWinner(); return; } + + this.state.clearBotPlayers() + if(this.state.players.size < 2) { + logger.info(">>> not enough players"); + this.state.resetGame(); + this.broadcast("game:notify", "Not enough players, at least 2 players are required"); + return + } + logger.info(">> Starting next round..."); this.setStatus("starting"); - const newJudgeId = this.getNextJudgeId(); - + const newJudgeId = this.state.getNextJudgeId; this.state.judge = newJudgeId; const questionCard = await this.getNextQuestionCard(); - this.state.currentQuestionCard = questionCard; await this.dealAnswerCardsForEveryoneLessTheJudge(newJudgeId, questionCard.spaces); @@ -383,12 +302,15 @@ export class MyRoom extends Room { if (playerData.isOffline) continue; playerData.status = playerData.id === this.state.judge ? "waiting" : "pending"; playerData.hasSubmittedCards = false; + playerData.isWaitingForNextRound = false; } + this.setStatus("playing"); } - private async handleNextRound(_client: Client, _data: null) { + private async handleNextRound(client: Client, _data: null) { + await this.skipToNextRound(); } @@ -403,51 +325,18 @@ export class MyRoom extends Room { logger.info(`>>> winner is ${this.state.players.get(this.state.judge)?.username}`); } - private checkForWinner() { - for (const [_id, playerData] of this.state.players) { - if (playerData.score >= this.state.config.scoreToWin) { - this.handleWinner(); - return true; - } - } - return false; - } - private async handleEndGame(_client: Client, _data: null) { logger.info(">> ending game..."); } private async handleStartNewGame(client: Client, _data: null) { logger.info(">> starting new game..."); - this.resetGame(); - this.setStatus("waiting"); + this.state.resetGame(); this.handleStartGame(client); } private async handleBackToLobby(_client: Client, _data: null) { logger.info(">> returning to lobby..."); - this.resetGame(); - this.setStatus("waiting"); - } - - /** - * Resets the game state to the default values - * The config and status still remains the same - */ - private resetGame() { - //BUG: this is not resetting the game properly, im stuck in the winner screen - logger.info(">> resetting game..."); - const newRoomState = new MyRoomState(); - newRoomState.config = this.state.config; - const playersMap = this.state.players; - - for (const [_id, playerData] of playersMap) { - playerData.score = 0; - playerData.status = "pending"; - } - - newRoomState.players = playersMap; - - this.state = newRoomState; + this.state.resetGame(); } private addRound(questionCard?: QuestionCardSchema, judge?: string): void { @@ -601,12 +490,11 @@ export class MyRoom extends Room { this.setStatus("judging"); logger.info(">>> autoPlayNextCard"); this.goToNextCard(); + return; } logger.info(">>> Select a Winner of the round"); - const judgeClient = this.clients.find(client => client.sessionId === this.state.judge); - const playerIds = Array.from(currentRound.answerCards.keys()); const randomWinnerFromPlayers = playerIds[Math.floor(Math.random() * playerIds.length)]; @@ -615,7 +503,7 @@ export class MyRoom extends Room { winner: randomWinnerFromPlayers, }; - this.handleJudgeDecision(judgeClient, payload); + this.handleJudgeDecisionWithId(this.state.judge, payload); } private autoSelectAnswerCardForAllPlayersLeft() { @@ -641,12 +529,18 @@ export class MyRoom extends Room { text: card.text, })); - const playerClient = this.clients.find(client => client.sessionId === player.id); + // const playerClient = this.clients.find(client => client.sessionId === player.id); + const payload: PlayerSelectionPayload = { selection: cardsPayload, }; - this.handlePlayerSelection(playerClient, payload); + /** + * THe player client is not available if the player is disconnected + * and is playing as a bot + */ + this.handlePlayerSelectionWithId(player.id, payload); + // this.handlePlayerSelectionWithId(playerClient.sessionId, payload); }); } @@ -697,19 +591,32 @@ export class MyRoom extends Room { this.state.config.availableDecks = decksSchema; } + /** + * This comes from the handlers + * @param client + * @param selectedCards + */ private handlePlayerSelection(client: Client, selectedCards: PlayerSelectionPayload) { + this.handlePlayerSelectionWithId(client.sessionId, selectedCards); + } + + /** + * This comes from the autoplay maybe is a bot + */ + private handlePlayerSelectionWithId(clientSessionId: string, selectedCards: PlayerSelectionPayload) { + logger.info(">>> handlePlayerSelectionWithId"); // Exit early if the client is the judge - if (this.state.judge === client.sessionId) { + if (this.state.judge === clientSessionId) { return; } // Exit early if the client is not in the room - if (!this.state.players.has(client.sessionId)) { + if (!this.state.players.has(clientSessionId)) { return; } // Exit early if the player has already selected cards - if (this.state.players.get(client.sessionId).hasSubmittedCards) { + if (this.state.players.get(clientSessionId).hasSubmittedCards) { return; } @@ -718,12 +625,13 @@ export class MyRoom extends Room { // Handle validation failure if (!validationResult.success) { - this.throwError(client, `Invalid selection data: ${validationResult.errorMessage}`); - return; + // TODO: FInd a way to do this in the future + // this.throwError(client, `Invalid selection data: ${validationResult.errorMessage}`); + return ; } // get the player - const player = this.state.players.get(client.sessionId); + const player = this.state.players.get(clientSessionId); // Update the player's hasSubmittedCards to true player.hasSubmittedCards = true; @@ -744,7 +652,7 @@ export class MyRoom extends Room { const currentRound = this.state.rounds[this.state.rounds.length - 1]; // Add this array to the round answers map with the player's sessionId as the key - currentRound.answerCards.set(client.sessionId, answerCardArray); + currentRound.answerCards.set(clientSessionId, answerCardArray); player.status = "done"; const playersList = this.state.playersArray; @@ -757,7 +665,7 @@ export class MyRoom extends Room { private checkAllPlayersDone(playersList: PlayerSchema[]) { const allPlayersDone = playersList.every(player => { - if (player.isOffline) return true; + if (!player.isPlayerPlaying ) return true; if (player.id === this.state.judge) return true; return player.status === "done"; }); @@ -769,6 +677,7 @@ export class MyRoom extends Room { const playersList = this.state.playersArray; playersList.forEach(player => { + console.log(player) if (player.id === this.state.judge) { return; } @@ -776,8 +685,7 @@ export class MyRoom extends Room { // get the cards the player has selected const selectedCards = this.state.rounds[this.state.rounds.length - 1].answerCards.get(player.id); - // const _player = this.state.players.get(player.id); - player.removeCardsFromPlayerHand(selectedCards.cards); + if(selectedCards) player.removeCardsFromPlayerHand(selectedCards.cards); }); this.setStatus("judging"); } @@ -810,6 +718,7 @@ export class MyRoom extends Room { if (this.state.roomStatus !== "judging") { return; } + this.setStatus("judging"); // Get the current round const currentRound = this.state.rounds[this.state.rounds.length - 1]; @@ -836,13 +745,24 @@ export class MyRoom extends Room { } private handleJudgeDecision(client: Client, data: JudgeDecisionPayload) { + try { + this.handleJudgeDecisionWithId(client.sessionId, data); + } catch (error) { + if(!(error instanceof Error)) return + this.throwError(client, error.message || 'Error while handling judge decision'); + } + } + + private handleJudgeDecisionWithId(clientId: string, data: JudgeDecisionPayload) { // Exit early if the client is not the judge - if (this.state.judge !== client.sessionId) { + if (this.state.judge !== clientId) { + throw new Error("Client is not the judge"); return; } // Exit early if the round is not in the judging status if (this.state.roomStatus !== "judging") { + throw new Error("Round is not in the judging status"); return; } @@ -851,7 +771,7 @@ export class MyRoom extends Room { // Handle validation failure if (!validationResult.success) { - this.throwError(client, `Invalid selection data: ${validationResult.errorMessage}`); + throw new Error(`Invalid selection data: ${validationResult.errorMessage}`); return; } @@ -862,19 +782,27 @@ export class MyRoom extends Room { const winnerId = validationResult.data.winner; // Update the score for the winner and set the round winner - this.setWinnerOfRound(winnerId, currentRound); + this.setRoundWinner(winnerId, currentRound); // Set the room status to round end this.setStatus("results"); } - private setWinnerOfRound(winnerId: string, round: RoundSchema) { + private setRoundWinner(winnerId: string, round: RoundSchema) { const winner = this.state.players.get(winnerId); + if(!winner) return; //BUG: when the player selects a card, disconnects and reconnects, the id is not the same anymore winner.addPoint(); round.winner = winnerId; } - ///Util methods + /** + * # Save game state snapshot + * This is a helper method to be used only in development to facilitate testing + * + * @todo Implement it + * @param _client + * @param _data + */ public handleDevSaveSnapshot(_client: Client, _data: null) { return; const snapshot = this.state; @@ -883,46 +811,15 @@ export class MyRoom extends Room { console.log(">> Saved snapshot to snapshot.json"); } + /** + * # Save game state snapshot + * This is a helper method to be used only in development to facilitate testing + * + * @todo Implement it + * @param _client + * @param _data + */ public handleDevLoadSnapshot(_client: Client, _data: null) { return; - if (fs.existsSync("snapshot.json")) { - const rawData = fs.readFileSync("snapshot.json", "utf-8"); - const parsedSnapshot = JSON.parse(rawData); - - console.log("typeof rawData", typeof rawData); - console.log("typeof parsedSnapshot", typeof parsedSnapshot); - - // // Transform parsedSnapshot to include Maps - // const transformedState = transformToOriginalState(parsedSnapshot); - - //get the current players list - const currentPlayers = this.state.players; - const playersInSnapshot = parsedSnapshot.players; - - const currentPlayersIds = [...currentPlayers.values()].map(player => player.id); - const playersInSnapshotIds = Object.keys(playersInSnapshot); - - console.log(">> currentPlayers size", currentPlayersIds); - console.log(">> playersInSnapshot size", playersInSnapshotIds); - // check if the the players size is the same - if (currentPlayers.size !== Object.keys(playersInSnapshot).length) { - throw new Error(">> Players size is not the same"); - } - - console.log(">> Before: ", rawData); - // replace in the raw data the players ids with the new ones - let transformedState = ""; - currentPlayersIds.forEach((playerId, index) => { - console.log(">> playerId", playerId); - console.log(">> playersInSnapshotIds[index]", playersInSnapshotIds[index]); - const regex = new RegExp(playersInSnapshotIds[index], "g"); - transformedState = rawData.replace(regex, playerId); - }); - console.log(">> After: ", transformedState); - - // Now, you can safely set the state - // this.setState(transformedState); - console.log(">> Loaded snapshot from snapshot.json"); - } } } \ No newline at end of file diff --git a/server/src/rooms/schema/MyRoomState.ts b/server/src/rooms/schema/MyRoomState.ts index 5833ddf..e495bb3 100644 --- a/server/src/rooms/schema/MyRoomState.ts +++ b/server/src/rooms/schema/MyRoomState.ts @@ -3,6 +3,7 @@ import {AnswerCardSchema, QuestionCardSchema} from "./Card"; import {RoomConfigSchema} from "./Config"; import {PlayerSchema} from "./Player"; import {RoundSchema} from "./Round"; +import logger from "../../lib/loggerConfig"; type RoomStatus = "waiting" | "starting" | "playing" | "judging" | "results" | "finished"; @@ -19,33 +20,77 @@ export class MyRoomState extends Schema { @type(QuestionCardSchema) currentQuestionCard = new QuestionCardSchema(); - //this has no use on the client side, but is used to keep track of the used cards - @type([QuestionCardSchema]) usedQuestionCards = new ArraySchema(); - @type([AnswerCardSchema]) usedAnswerCards = new ArraySchema(); - @type("string") leader = ""; + + //this has no use on the client side, but is used to keep track of the used cards + public usedQuestionCards:ArraySchema = new ArraySchema(); + public usedAnswerCards:ArraySchema = new ArraySchema(); + // private _DISCONNECT_TIMEOUT = 10000; - private _DISCONNECT_TIMEOUT = 1000 * 60 * 2; + private _DISCONNECT_TIMEOUT = 1000 * 15; + + private handleDisconnectInLobby(player: PlayerSchema) { + player.setAsOffline(); + setTimeout(() => { + this.players.delete(player.id); + logger.info(`Player ${player.id} has been removed from the room`); + }, this._DISCONNECT_TIMEOUT); + } + + private setNewLeader(){ + const newLeader = this.getNextLeaderId; + this.leader = newLeader; + logger.info(`New leader is ${newLeader}`); + } + + private handleDisconnectInGame(player: PlayerSchema) { + player.transformIntoBot(); + } /** - * Disconnects a player from the room + * Updates the player ID across all rounds in the game. + * This method is necessary to handle the scenario where a player reconnects with a new ID, + * ensuring that their participation and contributions in previous rounds are correctly associated with their new ID. + * + * @param oldId The player's old ID. + * @param newId The player's new ID. */ - public disconnectPlayer(playerId: string) { - // set the player to offline and after 10 seconds remove them from the room + public updatePlayerIdInRounds(oldId: string, newId: string): void { + const newPlayer = this.players.get(newId); + if(!newPlayer) return; - // get the player + this.rounds.forEach(round => { + round.replacePlayerId(oldId, newId); + }); + } + + /** + * - When disconnecting mid game the player will became a bot until the end of the round (results/waiting/finished) - `isBot = true` and `isOffline = true` + * - When the end of the round arrives, the player will be set as `isBot = false` and `isOffline = true` + * - If the player returns in the same round it will be set as `isBot = false` and `isOffline = false` + * - If the player returns in the next round it will be set as `isBot = false` and `isOffline = false` and `isWaitingForNextRound = true` - Handle this on reconnect method + */ + public disconnectPlayer(playerId: string) { const player = this.players.get(playerId); if (!player) return; - // set the player to offline - player.isOffline = true; - - // remove the player after 10 seconds - setTimeout(() => { - this.players.delete(playerId); - console.log(`Player ${playerId} has been removed from the room`); - }, this._DISCONNECT_TIMEOUT); + if(player.id === this.leader) { + this.setNewLeader(); + } + + // Define the actions for each room status + const disconnectActions: Record void> = { + waiting: () => this.handleDisconnectInLobby(player), + finished: () => this.handleDisconnectInGame(player), + starting: () => this.handleDisconnectInGame(player), + playing: () => this.handleDisconnectInGame(player), + judging: () => this.handleDisconnectInGame(player), + results: () => this.handleDisconnectInGame(player), + }; + + // Execute the action based on the current room status + disconnectActions[this.roomStatus](); } /** @@ -54,6 +99,84 @@ export class MyRoomState extends Schema { public get playersArray() { return Array.from(this.players.values()); } + + public get onlinePlayersIds() { + const playersArray = this.playersArray; + const onlinePlayers = playersArray.filter(player => !player.isOffline); + return onlinePlayers.map(player => player.id); + } + + public get getNextJudgeId() { + const onlinePlayersIds = this.onlinePlayersIds; + if (onlinePlayersIds.length === 0) return null; + const currentJudgeIdIndex = onlinePlayersIds.findIndex(id => id === this.judge); + const nextJudgeIdIndex = currentJudgeIdIndex + 1; + const nextJudgeId = onlinePlayersIds[nextJudgeIdIndex] || onlinePlayersIds[0]; + return nextJudgeId; + } + + public get getNextLeaderId() { + const onlinePlayersIds = this.onlinePlayersIds; + const currentLeaderIndex = onlinePlayersIds.indexOf(this.leader); + const nextLeaderIndex = currentLeaderIndex + 1; + const nextLeaderId = onlinePlayersIds[nextLeaderIndex] || onlinePlayersIds[0]; + return nextLeaderId; + } + + public get randomOnlinePlayerId() { + const onlinePlayersIds = this.onlinePlayersIds; + const randomIndex = Math.floor(Math.random() * onlinePlayersIds.length); + return onlinePlayersIds[randomIndex]; + } + + public get playersWaitingForNextRound() { + const players = this.playersArray; + return players.filter(player => player.isWaitingForNextRound); + } + + public get thereIsAWinner() { + for (const [_id, playerData] of this.players) { + if (playerData.score >= this.config.scoreToWin) { + return true; + } + } + return false; + } + + public resetGame() { + this.clearBotPlayers(); + // reset players points + this.players.forEach(player => { + player.resetPlayer() + }) + + // reset rounds to empty array + this.rounds.clear(); + + // reset room status + this.roomStatus = "waiting"; + + // reset judge + this.judge = ""; + + // reset currentQuestionCard + this.currentQuestionCard = new QuestionCardSchema(); + + // reset usedQuestionCards + this.usedQuestionCards.clear(); + + // reset usedAnswerCards + this.usedAnswerCards.clear(); + + } + + public clearBotPlayers() { + this.players.forEach(player => { + if(player.isBot && player.isOffline && !player.isWaitingForNextRound) { + this.players.delete(player.id); + } + }) + } } export type TMyRoomState = typeof MyRoomState.prototype; diff --git a/server/src/rooms/schema/Player.ts b/server/src/rooms/schema/Player.ts index 4e5764b..4894586 100644 --- a/server/src/rooms/schema/Player.ts +++ b/server/src/rooms/schema/Player.ts @@ -2,6 +2,7 @@ import {Schema, type, ArraySchema} from "@colyseus/schema"; import {AnswerCard} from "@ccc-cards-game/types"; import {AnswerCardSchema} from "./Card"; +import logger from "../../lib/loggerConfig"; export type TPlayerStatus = "judge" | "pending" | "done" | "none" | "winner" | "waiting"; @@ -14,28 +15,102 @@ export class PlayerSchema extends Schema { @type("boolean") hasSubmittedCards: boolean = false; @type([AnswerCardSchema]) cards = new ArraySchema(); @type("boolean") isOffline: boolean = false; + @type("boolean") isBot: boolean = false; + @type("boolean") isWaitingForNextRound: boolean = false; + /** + * Timeout will only be used when disconnecting in the lobby, not mid game. + */ private _timeout?: NodeJS.Timeout = undefined; public addPoint() { this.score++; } + public resetScore() { + this.score = 0; + } + + public resetPlayer() { + this.resetScore(); + this.cards.clear(); + this.status = "pending"; + this.hasSubmittedCards = false; + this.isOffline = false; + this.isBot = false; + this.isWaitingForNextRound = false; + } + + public setAsOffline() { + this.isOffline = true; + this.isBot = false; + this.isWaitingForNextRound = false; + + logger.info(`Player ${this.username} is offline`); + } + + public transformIntoBot() { + this.isOffline = true; + this.isBot = true; + this.isWaitingForNextRound = false; + + logger.info(`Player ${this.username} is bot`); + } + + public transformFromBot(){ + this.isOffline = false; + this.isBot = false; + this.isWaitingForNextRound = false; + + logger.info(`Player ${this.username} is back online`); + } + + public reconnectAndWait(){ + this.isBot = false; + this.isOffline = false; + this.isWaitingForNextRound = true; + + logger.info(`Player ${this.username} is back online, waiting for next round.`); + } + + public get isPlayerPlaying(): boolean { + return (!this.isOffline || this.isBot) && !this.isWaitingForNextRound; + } + + /** + * Overwrite the cards that the player has in the hands + * @param cards + */ public setCards(cards: ArraySchema) { this.cards = cards; } + /** + * Add new cards to player hands, maintaining the ones that he already has + * @param cardsToAdd + */ public addCardsToPlayerHand(cardsToAdd: AnswerCard[]) { const newCards: ArraySchema = this.cards.concat(cardsToAdd as AnswerCardSchema[]); this.setCards(newCards); } + /** + * Remove specific cards from player hands + * When the player make the choice of setting the cards, those cards should be removed from his hand. + * @param cardsToRemove + */ public removeCardsFromPlayerHand(cardsToRemove: ArraySchema) { const cardsToRemoveIds = cardsToRemove.map(card => card.id); const newCards: ArraySchema = this.cards.filter(card => !cardsToRemoveIds.includes(card.id)); this.setCards(newCards); } + /** + * Used for the autoplay, when the of the round ends and you need to select some cards for the game to proceed. + * Or when the player is in bot mode + * @param count + * @returns + */ public getRandomAnswers(count: number): ArraySchema { const randomAnswers: ArraySchema = new ArraySchema(); const availableIndices: number[] = [...Array(this.cards.length).keys()]; // Create an array of available indices @@ -57,6 +132,9 @@ export class PlayerSchema extends Schema { this.status = status; } + /** + * When the player reconnects, essentially he will be a new player with the same data as the previous player. + */ public cloneFrom(otherPlayer: PlayerSchema) { this.username = otherPlayer.username; // this.pictureUrl = otherPlayer.pictureUrl; @@ -65,6 +143,7 @@ export class PlayerSchema extends Schema { this.hasSubmittedCards = otherPlayer.hasSubmittedCards; this.cards = otherPlayer.cards; this.isOffline = false; + this.isBot = false; if (this._timeout) { clearTimeout(this._timeout); diff --git a/server/src/rooms/schema/Round.ts b/server/src/rooms/schema/Round.ts index 3a66055..2d9464a 100644 --- a/server/src/rooms/schema/Round.ts +++ b/server/src/rooms/schema/Round.ts @@ -13,6 +13,35 @@ export class RoundSchema extends Schema { @type(["string"]) revealedCards = new ArraySchema(); @type("string") currentRevealedId = ""; @type("boolean") allCardsRevealed = false; + + /** + * Replaces a player's ID in the answerCards map with a new ID. + * This method is used to handle the case where a player reconnects to the game and is assigned a new ID, + * ensuring that their previously submitted answer cards are preserved and associated with their new ID. + * + * @param oldId The player's old ID. + * @param newId The player's new ID. + */ + public replacePlayerId(oldId: string, newId: string): void { + // Replace the id on judge + if (this.judge === oldId) { + this.judge = newId; + } + + // Replace the id on winner + if (this.winner === oldId) { + this.winner = newId; + } + + // Replace the id on answerCards + const playerCards = this.answerCards.get(oldId); + if (playerCards) { + // Remove the entry with the old ID + this.answerCards.delete(oldId); + // Add a new entry with the new ID and the previously submitted answer cards + this.answerCards.set(newId, playerCards); + } + } } export type TRound = typeof RoundSchema.prototype; diff --git a/server/test/joinAndLeaveRoom.spec.ts b/server/test/joinAndLeaveRoom.spec.ts index 0b1a26d..458c424 100644 --- a/server/test/joinAndLeaveRoom.spec.ts +++ b/server/test/joinAndLeaveRoom.spec.ts @@ -3,8 +3,12 @@ import { ColyseusTestServer, boot } from "@colyseus/testing"; import appConfig from "../src/app.config"; import { MyRoomState } from "../src/rooms/schema/MyRoomState"; -import { countOnlinePlayers, generateNewInvalidPlayer, generateNewPlayer, generateReconnectingPlayer, generateStringWithLength } from './lib/utils'; +import { countOnlinePlayers, generateNewInvalidPlayer, generateNewPlayer, generateRadomAnswerCard, generateReconnectingPlayer, generateStringWithLength } from './lib/utils'; import { faker } from '@faker-js/faker'; +import { AnswerCardSchema, PlayerSchema } from '../src/rooms/schema'; +import { ArraySchema } from '@colyseus/schema'; +import { AnswerCard } from '@ccc-cards-game/types'; +import { TPlayerStatus } from '../src/rooms/schema/Player'; describe("Join, Leave, Reconnect and Change rooms", () => { let colyseus: ColyseusTestServer; @@ -340,5 +344,246 @@ describe("Join, Leave, Reconnect and Change rooms", () => { const totalPlayersInRoom1 = countOnlinePlayers(room1.state.players); expect(totalPlayersInRoom1).toBe(2); }); - + + describe.only('PlayerSchema', () => { + test('addPoint increases the score by 1', () => { + const player = new PlayerSchema(); + player.score = 0; + player.addPoint(); + expect(player.score).toBe(1); + }); + + test('setCards correctly sets the cards', () => { + const player = new PlayerSchema(); + + const newCard1 = generateRadomAnswerCard() + const newCard2 = generateRadomAnswerCard() + + const newCards = new ArraySchema() + + newCards.push(newCard1); + newCards.push(newCard2); + + expect(player.cards.length).toBe(0) + + player.setCards(newCards); + + expect(player.cards.length).toBe(2); + + expect(player.cards[0].id).toBe(newCard1.id); + expect(player.cards[0].text).toBe(newCard1.text); + + expect(player.cards[1].id).toBe(newCard2.id); + expect(player.cards[1].text).toBe(newCard2.text); + + }); + + test('setCards set other group of cards', () => { + const player = new PlayerSchema(); + + const newCard1 = generateRadomAnswerCard() + + const newCards1 = new ArraySchema() + + newCards1.push(newCard1); + + const newCard2 = generateRadomAnswerCard() + const newCard3 = generateRadomAnswerCard() + const newCard4 = generateRadomAnswerCard() + + const newCards2 = new ArraySchema() + + newCards2.push(newCard2); + newCards2.push(newCard3); + newCards2.push(newCard4); + + expect(player.cards.length).toBe(0) + + player.setCards(newCards1); + expect(player.cards.length).toBe(1) + + player.setCards(newCards2); + expect(player.cards.length).toBe(3) + + player.setCards(newCards1); + expect(player.cards.length).toBe(1) + }); + + test('addCardsToPlayerHand adds a single card to the player hand', () => { + const player = new PlayerSchema(); + const initialCard = generateRadomAnswerCard(); + player.setCards(new ArraySchema(initialCard)); + + const newCard = generateRadomAnswerCard(); + player.addCardsToPlayerHand([newCard]); + + expect(player.cards.length).toBe(2); + expect(player.cards).toContainEqual(newCard); + }); + + test('addCardsToPlayerHand adds multiple cards to the player hand', () => { + const player = new PlayerSchema(); + const initialCards = new ArraySchema( + generateRadomAnswerCard(), + generateRadomAnswerCard() + ); + player.setCards(initialCards); + + const newCards = [generateRadomAnswerCard(), generateRadomAnswerCard()]; + player.addCardsToPlayerHand(newCards); + + expect(player.cards.length).toBe(4); + newCards.forEach(card => { + expect(player.cards).toContainEqual(card); + }); + }); + + test('addCardsToPlayerHand adds cards to an initially empty hand', () => { + const player = new PlayerSchema(); + player.setCards(new ArraySchema()); + + const newCards = [generateRadomAnswerCard(), generateRadomAnswerCard()]; + player.addCardsToPlayerHand(newCards); + + expect(player.cards.length).toBe(2); + newCards.forEach(card => { + expect(player.cards).toContainEqual(card); + }); + }); + + + test('removeCardsFromPlayerHand removes a single specified card', () => { + const player = new PlayerSchema(); + const cardToRemove = generateRadomAnswerCard(); + const otherCard = generateRadomAnswerCard(); + player.setCards(new ArraySchema(cardToRemove, otherCard)); + + player.removeCardsFromPlayerHand(new ArraySchema(cardToRemove)); + + expect(player.cards.length).toBe(1); + expect(player.cards).not.toContainEqual(cardToRemove); + expect(player.cards).toContainEqual(otherCard); + }); + + test('removeCardsFromPlayerHand removes multiple specified cards', () => { + const player = new PlayerSchema(); + const cardsToRemove = new ArraySchema( + generateRadomAnswerCard(), + generateRadomAnswerCard() + ); + const remainingCard = generateRadomAnswerCard(); + player.setCards(new ArraySchema(...cardsToRemove, remainingCard)); + + player.removeCardsFromPlayerHand(cardsToRemove); + + expect(player.cards.length).toBe(1); + cardsToRemove.forEach(card => { + expect(player.cards).not.toContainEqual(card); + }); + expect(player.cards).toContainEqual(remainingCard); + }); + + test('removeCardsFromPlayerHand does not change hand if cards to remove are not present', () => { + const player = new PlayerSchema(); + const existingCards = new ArraySchema( + generateRadomAnswerCard(), + generateRadomAnswerCard() + ); + player.setCards(existingCards); + + const nonExistingCards = new ArraySchema( + generateRadomAnswerCard(), + generateRadomAnswerCard() + ); + player.removeCardsFromPlayerHand(nonExistingCards); + + expect(player.cards.length).toBe(2); + existingCards.forEach(card => { + expect(player.cards).toContainEqual(card); + }); + }); + + test('getRandomAnswers returns random cards when enough cards are available', () => { + const player = new PlayerSchema(); + for (let i = 0; i < 10; i++) { + player.cards.push(generateRadomAnswerCard()); + } + + const randomAnswers1 = player.getRandomAnswers(5); + const randomAnswers2 = player.getRandomAnswers(5); + + expect(randomAnswers1.length).toBe(5); + expect(randomAnswers2.length).toBe(5); + expect(randomAnswers1).not.toEqual(randomAnswers2); // This might fail occasionally due to random chance + }); + test('getRandomAnswers returns all cards when requested more than available', () => { + const player = new PlayerSchema(); + for (let i = 0; i < 3; i++) { + player.cards.push(generateRadomAnswerCard()); + } + + const randomAnswers = player.getRandomAnswers(5); + + expect(randomAnswers.length).toBe(3); + }); + + test('getRandomAnswers returns empty array when no cards are available', () => { + const player = new PlayerSchema(); + const randomAnswers = player.getRandomAnswers(5); + + expect(randomAnswers.length).toBe(0); + }); + + test('getRandomAnswers returns specified number of random cards', () => { + const player = new PlayerSchema(); + for (let i = 0; i < 10; i++) { + player.cards.push(generateRadomAnswerCard()); + } + + const count = 4; + const randomAnswers = player.getRandomAnswers(count); + + expect(randomAnswers.length).toBe(count); + }); + + describe('setStatus Method', () => { + let player: PlayerSchema; + + beforeEach(() => { + player = new PlayerSchema(); + }); + + const statusValues: TPlayerStatus[] = ["judge", "pending", "done", "none", "winner", "waiting"]; + + statusValues.forEach(status => { + test(`setStatus correctly sets status to ${status}`, () => { + player.setStatus(status); + expect(player.status).toBe(status); + }); + }); + }); + + test('cloneFrom correctly clones properties from another PlayerSchema', () => { + const originalPlayer = new PlayerSchema(); + originalPlayer.username = 'OriginalPlayer'; + originalPlayer.score = 10; + originalPlayer.status = 'judge'; + originalPlayer.hasSubmittedCards = true; + originalPlayer.cards = new ArraySchema( + generateRadomAnswerCard(), + generateRadomAnswerCard(), + ); + originalPlayer.isOffline = true; + + const clonedPlayer = new PlayerSchema(); + clonedPlayer.cloneFrom(originalPlayer); + + expect(clonedPlayer.username).toBe(originalPlayer.username); + expect(clonedPlayer.score).toBe(originalPlayer.score); + expect(clonedPlayer.status).toBe(originalPlayer.status); + expect(clonedPlayer.hasSubmittedCards).toBe(originalPlayer.hasSubmittedCards); + expect(clonedPlayer.cards).toEqual(originalPlayer.cards); + expect(clonedPlayer.isOffline).toBe(false); + }); + }) }); diff --git a/server/test/lib/utils.ts b/server/test/lib/utils.ts index 92023b6..f99bf63 100644 --- a/server/test/lib/utils.ts +++ b/server/test/lib/utils.ts @@ -1,6 +1,7 @@ import { MapSchema } from '@colyseus/schema'; import { faker } from '@faker-js/faker'; import { PlayerSchema } from '../../src/rooms/schema/Player'; +import { AnswerCardSchema } from '../../src/rooms/schema'; export function generateUsernameWithMaxChar(maxChar: number) { const username = faker.internet.userName(); @@ -45,3 +46,10 @@ export function countOnlinePlayers(players: MapSchema): number { }); return onlineCount; } + +export function generateRadomAnswerCard():AnswerCardSchema { + return { + id: faker.string.alpha(10), + text: faker.string.alpha(30) + } as AnswerCardSchema; +} diff --git a/types/index.ts b/types/index.ts index 423356c..737cdf1 100644 --- a/types/index.ts +++ b/types/index.ts @@ -117,6 +117,8 @@ export interface Player { hasSubmittedCards: boolean; cards: AnswerCard[]; isOffline: boolean; + isBot: boolean; + isWaitingForNextRound: boolean; } // Round.ts