import { Player, ObfuscatedPlayer } from "./player"; import { CardStack } from "./card"; import { Card, ObfuscatedCardStack } from "./card"; import { Socket } from "socket.io"; import { io } from "../server"; type PlayerSocketSet = Set; type PlayerActionEventName = string; type PlayerActionCallback = (playerSocketId: string, data: any) => void; type ExpectedPlayerActions = Array< [PlayerActionEventName, PlayerActionCallback] >; type PlayerAction = { playerSocketId: string; data: ActionDataType; }; type CardPosition = number; // obfuscated types are used to send only necessary data to the client export type ObfuscatedGame = { sessionId: string; playerCount: number; players: ObfuscatedPlayer[]; cardStack: ObfuscatedCardStack; discardPile: Card[]; phase: string; round: number; }; export const allGames: Game[] = []; const gamePhase = { newRound: "new round", revealTwoCards: "reveal two cards", pickUpCard: "pick up card", placeCard: "place card", revealCard: "reveal card", revealedLastCard: "revealed last card", gameEnded: "game ended", }; export class Game { socket: Socket; sessionId: string; playerCount: number; players: Player[]; cardStack: CardStack; discardPile: Card[]; phase: string; round: number; constructor(socket: Socket, sessionId: string, playerIds: PlayerSocketSet) { this.socket = socket; this.sessionId = sessionId; this.cardStack = new CardStack(); this.cardStack.shuffleCards(); this.playerCount = playerIds.size; this.players = this.initializePlayers(playerIds, this.cardStack); // get the first card from the cardStack and put it in the discard pile this.discardPile = [this.cardStack.cards.pop()!]; this.phase = gamePhase.revealTwoCards; this.round = 1; } initializePlayers( playerIds: PlayerSocketSet, cardStack: CardStack ): Player[] { let players: Player[] = []; let index = 0; playerIds.forEach((socketId) => { index++; const playerCards = cardStack.cards.splice(0, 12); const player = new Player( index, socketId, `Player ${index}`, playerCards ); players.push(player); }); this.cardStack = cardStack; return players; } initializeNewRound(startOver: boolean = false) { this.round = startOver ? 1 : this.round + 1; this.cardStack = new CardStack(); this.cardStack.shuffleCards(); this.players.forEach((player) => { player.cards = this.cardStack.cards.splice(0, 12); player.knownCardPositions = new Array(12).fill(false); player.playersTurn = true; player.cardCache = null; player.tookDispiledCard = false; player.roundPoints = 0; player.totalPoints = startOver ? 0 : player.totalPoints; player.closedRound = false; player.place = null; }); this.discardPile = [this.cardStack.cards.pop()!]; this.phase = gamePhase.revealTwoCards; } async gameLoop() { console.log("Game started!"); this.sendObfuscatedGameUpdate(); while (this.phase !== gamePhase.gameEnded) { this.checkForFullRevealedCards(); switch (this.phase) { case gamePhase.revealTwoCards: console.log("\nGame phase: revealTwoCards"); await this.revealInitialCards(); break; case gamePhase.pickUpCard: console.log("\nGame phase: pickUpCard"); await this.pickUpCard(); break; case gamePhase.placeCard: console.log("\nGame phase: placeCard"); await this.placeCard(); break; case gamePhase.revealCard: console.log("\nGame phase: revealCard"); await this.revealCard(); break; case gamePhase.revealedLastCard: console.log("\nGame phase: revealedLastCard"); await this.revealedLastCard(); break; case gamePhase.newRound: console.log("\nGame phase: newRound"); this.checkIfPointLimitReached(); await this.nextRound(); break; default: console.log("\nGame Ended."); break; } } } // Game Phases async revealInitialCards() { this.sendMessageToAllPlayers("Reveal two cards"); while (!this.allPlayersRevealedInitialCards()) { const playersWithRevealedInitialCards = this.getPlayersWithRevealedInitialCards(); const playersWithUnrevealedInitialCards = this.players.filter( (player) => !playersWithRevealedInitialCards.includes(player) ); const playersSocketIds = playersWithUnrevealedInitialCards.map( (player) => player.socketId ); await this.waitForPlayerActions( [["click-card", this.revealCardAction.bind(this)]], playersSocketIds ); } this.setInitialPlayersTurn(); this.phase = gamePhase.pickUpCard; this.sendObfuscatedGameUpdate(); } async pickUpCard() { const playerOnTurn = this.getPlayersTurn(); if (playerOnTurn.closedRound) { this.phase = gamePhase.revealedLastCard; this.sendObfuscatedGameUpdate(); return; } console.log(`Waiting for ${playerOnTurn.name} to pick up card`); await this.waitForPlayerActions( [ ["draw-from-card-stack", this.drawCardAction.bind(this)], ["click-discard-pile", this.takeDiscardPileAction.bind(this)], ], [playerOnTurn.socketId] ); this.phase = gamePhase.placeCard; this.sendObfuscatedGameUpdate(); } async placeCard() { const playerOnTurn = this.getPlayersTurn(); console.log(`Waiting for ${playerOnTurn.name} to place card`); const expectedActions: ExpectedPlayerActions = [ ["click-card", this.placeCardAction.bind(this)], ]; if (!playerOnTurn.tookDispiledCard) { expectedActions.push([ "click-discard-pile", this.discardCardToPileAction.bind(this), ]); } playerOnTurn.tookDispiledCard = false; await this.waitForPlayerActions(expectedActions, [playerOnTurn.socketId]); this.sendObfuscatedGameUpdate(); } async revealCard() { const playerOnTurn = this.getPlayersTurn(); console.log(`Waiting for ${playerOnTurn.name} to reveal a card`); const numberOfRevealedCards = playerOnTurn.getRevealedCardCount(); // ensures that the player does not select an already revealed card while (playerOnTurn.getRevealedCardCount() <= numberOfRevealedCards) { await this.waitForPlayerActions( [["click-card", this.revealCardAction.bind(this)]], [playerOnTurn.socketId] ); } this.nextPlayersTurn(); this.phase = gamePhase.pickUpCard; this.sendObfuscatedGameUpdate(); } async revealedLastCard() { this.revealAllCards(); this.evaluateAndSavePoints(); this.phase = gamePhase.newRound; this.sendObfuscatedGameUpdate(); console.log("Waiting for next round"); } async nextRound() { const playerSocketIds = this.players.map((player) => player.socketId); await this.waitForPlayerActions( [["next-round", this.nextRoundAction.bind(this)]], playerSocketIds ); } // Player Action Callbacks revealCardAction(playerSocketId: string, cardPosition: number) { const player = this.getPlayerBySocketId(playerSocketId); const revealedCard = player.cards[cardPosition]; console.log(`Revealed card ${revealedCard} at position ${cardPosition}`); const playerIndex = this.players.indexOf(player!); this.players[playerIndex].knownCardPositions[cardPosition] = true; this.sendObfuscatedGameUpdate(); } drawCardAction(playerSocketId: string, data: any) { const player = this.getPlayerBySocketId(playerSocketId); console.log(`Player ${player.name} drawed a card.`); const drawnCard = this.cardStack.cards.pop()!; player.cardCache = drawnCard; this.sendObfuscatedGameUpdate(); } takeDiscardPileAction(playerSocketId: string, data: any) { const player = this.getPlayerBySocketId(playerSocketId); console.log(`Player ${player.name} took the card from discard pile.`); const discardPileCard = this.discardPile.pop()!; player.cardCache = discardPileCard; player.tookDispiledCard = true; this.sendObfuscatedGameUpdate(); } discardCardToPileAction(playerSocketId: string, data: any) { const player = this.getPlayerBySocketId(playerSocketId); console.log(`Player ${player.name} discarded a card to the pile.`); const discardedCard = player.cardCache!; this.discardPile.push(discardedCard); player.cardCache = null; this.phase = gamePhase.revealCard; this.sendObfuscatedGameUpdate(); } placeCardAction(playerSocketId: string, cardPosition: number) { const player = this.getPlayerBySocketId(playerSocketId); console.log(`Player ${player.name} placed a card.`); const placedCard = player.cardCache!; player.cardCache = null; const replacedCard = player.cards[cardPosition]; this.discardPile.push(replacedCard); player.cards[cardPosition] = placedCard; player.knownCardPositions[cardPosition] = true; this.nextPlayersTurn(); this.phase = gamePhase.pickUpCard; this.sendObfuscatedGameUpdate(); } nextRoundAction(playerSocketId: string, data: any) { this.initializeNewRound(); this.sendObfuscatedGameUpdate(); } /** * This function waits for a player to perform one of the expected actions. * When a player performs one of the expected actions, the corresponding callback is called and further processes the player data. * All event listeners are removed after every player defined in expectedFrom performed the expected action. * The function also returns a promise that resolves with the data sent by the player. * @param expectedActions * @param expectedFrom * @returns playerSocketId and data sent by the player */ waitForPlayerActions( expectedActions: ExpectedPlayerActions, expectedFrom: Player["socketId"][] ): Promise> { const eventListeners = new Map void>(); const addPlayerActionListeners = ( resolve: ( value: | PlayerAction | PromiseLike> ) => void ) => { expectedFrom.forEach((playerSocketId) => { const playerSocket = io.sockets.sockets.get(playerSocketId); if (playerSocket) { expectedActions.forEach((expectedAction) => { const [actionName, processAction] = expectedAction; const eventListener = (data: ActionDataType, ackFunction: any) => { console.log(`Received ${actionName} from ${playerSocketId}`); processAction(playerSocketId, data); // remove current and event listeners of alternative expected actions removePlayerActionListeners(); const playerResponse = { playerSocketId, data }; // ackFunction("success"); resolve(playerResponse); }; playerSocket.on(actionName, eventListener); eventListeners.set( `${playerSocketId}-${actionName}`, eventListener ); }); } }); }; const removePlayerActionListeners = () => { expectedFrom.forEach((playerSocketId) => { const playerSocket = io.sockets.sockets.get(playerSocketId); if (playerSocket) { expectedActions.forEach((expectedAction) => { const [actionName] = expectedAction; const eventListener = eventListeners.get( `${playerSocketId}-${actionName}` ); if (eventListener) { playerSocket.off(actionName, eventListener); eventListeners.delete(`${playerSocketId}-${actionName}`); } }); } }); }; return new Promise>((resolve) => { addPlayerActionListeners(resolve); }); } sendObfuscatedGameUpdate() { // console.trace("sendObfuscatedGameUpdate"); this.updatePlayerRoundPoints(); const obfuscatedGame: ObfuscatedGame = { sessionId: this.sessionId, playerCount: this.playerCount, phase: this.phase, round: this.round, discardPile: this.discardPile, players: this.players.map(({ cards, ...player }) => { return { ...player, cards: cards.map((card: Card, index: number) => { // unknown cards are obfuscated to X return { id: player.knownCardPositions[index] ? card.id : 0, value: player.knownCardPositions[index] ? card.value : "X", name: player.knownCardPositions[index] ? card.name : "Facedown Card", color: player.knownCardPositions[index] ? card.color : "black", matchColorToCardValue: card.matchColorToCardValue, }; }), }; }), cardStack: { cards: this.cardStack.cards.map((card: Card) => { // player may not see the value of the facedown cards in the cardStack return { id: 0, value: "X", name: "Facedown Card", color: "black", }; }), }, }; console.log("Sending game update"); io.to(this.sessionId).emit("game-update", obfuscatedGame); } sendNullGameUpdate() { io.to(this.sessionId).emit("game-update", null); } updatePlayerRoundPoints() { this.players.forEach((player) => { const revealedCardValuesSum = player.getRevealedCardsValueSum(); const threeOfAKinds = player.getThreeOfAKinds(); const threeOfAKindPoints = threeOfAKinds.reduce( (points, threeOfAKind) => points + threeOfAKind.value * 3, 0 ); player.roundPoints = revealedCardValuesSum - threeOfAKindPoints; }); } getPlayersWithLowestPoints(): Player[] { this.updatePlayerRoundPoints(); const lowestScore = Math.min( ...this.players.map((player) => player.roundPoints) ); const playersWithLowestPoints = this.players.filter( (player) => player.roundPoints === lowestScore ); return playersWithLowestPoints; } evaluateAndSavePoints() { const playersWithLowestPoints = this.getPlayersWithLowestPoints(); const playerClosedRound = this.getPlayerThatClosedRound(); let playerClosedRoundLostMessage = ""; if ( playersWithLowestPoints.includes(playerClosedRound) && playersWithLowestPoints.length === 1 ) { this.sendMessageToAllPlayers(`${playerClosedRound.name} won the round!`); this.players.forEach((player) => { player.totalPoints += player.roundPoints; }); return; } else if (playersWithLowestPoints.length === 1) { playerClosedRoundLostMessage = playerClosedRoundLostMessage.concat( `${playersWithLowestPoints[0].name} won the round!` ); } else if (playersWithLowestPoints.length > 1) { playerClosedRoundLostMessage = playerClosedRoundLostMessage.concat( `\n ${playersWithLowestPoints .map((player) => player.name) .join(", ")} scored equally the lowest points!` ); } this.players.forEach((player) => { if (player.closedRound) player.totalPoints += player.roundPoints * 2; else player.totalPoints += player.roundPoints; }); playerClosedRoundLostMessage = playerClosedRoundLostMessage.concat( `\n ${playerClosedRound.name} points are doubled!` ); this.sendMessageToAllPlayers(playerClosedRoundLostMessage); } checkForFullRevealedCards() { const alreadyClosedPlayers = this.players.filter( (player) => player.closedRound ); if (alreadyClosedPlayers.length > 0) return; // TODO: check if this is correct with more than 2 players const playerWithAllCardsRevealed = this.players.find((player) => player.knownCardPositions.every( (knownCardPosition) => knownCardPosition === true ) ); if (playerWithAllCardsRevealed) { playerWithAllCardsRevealed.closedRound = true; } } checkIfPointLimitReached() { const highestPoints = Math.max( ...this.players.map((player) => player.totalPoints) ); const lowestPoints = Math.min( ...this.players.map((player) => player.totalPoints) ); const playersWithHighestPoints = this.players.filter( (player) => player.totalPoints === highestPoints ); if (highestPoints >= 100) { if (playersWithHighestPoints.length === 1) { const playerWithHighestPoints = playersWithHighestPoints[0]; this.sendMessageToAllPlayers( `${playerWithHighestPoints.name} lost with ${playerWithHighestPoints.totalPoints}!` ); } else { const playerNames = playersWithHighestPoints .map((player) => player.name) .join(", "); this.sendMessageToAllPlayers( `Multiple players: ${playerNames} lost with ${highestPoints} points!` ); } const playersWithLowestPoints = this.players.filter( (player) => player.totalPoints === lowestPoints ); playersWithLowestPoints.forEach((player) => (player.place = 1)); this.phase = gamePhase.gameEnded; this.sendObfuscatedGameUpdate(); allGames.splice(allGames.indexOf(this), 1); } } checkForPlayerLeave() { const playersInSession = io.sockets.adapter.rooms.get(this.sessionId); if (playersInSession?.size ?? 0 < this.playerCount) { const playerThatLeftSession = this.players.filter( (player) => !playersInSession?.has(player.socketId) ); console.log("players that left session", playerThatLeftSession); if (playerThatLeftSession.length > 0) { this.sendMessageToAllPlayers( `${playerThatLeftSession.map( (player) => player.name + " " )} left the session!` ); this.phase = gamePhase.gameEnded; this.sendNullGameUpdate(); io.to(this.sessionId).emit( "clients-in-session", playersInSession?.size ?? 0 ); allGames.splice(allGames.indexOf(this), 1); } } } revealAllCards() { this.players.forEach((player) => { player.knownCardPositions = player.knownCardPositions.map( (knownCardPosition) => true ); }); } // Helpers getPlayersWithRevealedInitialCards(): Player[] { return this.players.filter((player) => { return player.hasInitialCardsRevealed(); }); } allPlayersRevealedInitialCards() { const playersWithRevealedInitialCards = this.getPlayersWithRevealedInitialCards(); return playersWithRevealedInitialCards.length === this.playerCount; } setInitialPlayersTurn() { const playersWithHighestRevealedCardsValueSum = this.players.reduce( (playersWithHighestRevealedCardsValueSum, player) => { if ( player.getRevealedCardsValueSum() === playersWithHighestRevealedCardsValueSum[0].getRevealedCardsValueSum() ) { if ( player.getHighestRevealedCardValue() > playersWithHighestRevealedCardsValueSum[0].getHighestRevealedCardValue() ) { playersWithHighestRevealedCardsValueSum = [player]; } else if ( player.getHighestRevealedCardValue() === playersWithHighestRevealedCardsValueSum[0].getHighestRevealedCardValue() ) { playersWithHighestRevealedCardsValueSum.push(player); } } else if ( player.getRevealedCardsValueSum() > playersWithHighestRevealedCardsValueSum[0].getRevealedCardsValueSum() ) { playersWithHighestRevealedCardsValueSum = [player]; } return playersWithHighestRevealedCardsValueSum; }, [this.players[0]] ); const playerWithHighestRevealedCardsValueSum = playersWithHighestRevealedCardsValueSum[ Math.floor( Math.random() * playersWithHighestRevealedCardsValueSum.length ) ]; this.players.forEach((player) => { if (player === playerWithHighestRevealedCardsValueSum) { player.playersTurn = true; } else { player.playersTurn = false; } }); } nextPlayersTurn() { const playerOnTurn = this.getPlayersTurn(); const playersTurnIndex = this.players.indexOf(playerOnTurn); const nextPlayersTurnIndex = (playersTurnIndex + 1) % this.playerCount; this.players[playersTurnIndex].playersTurn = false; this.players[nextPlayersTurnIndex].playersTurn = true; } getPlayersTurn(): Player { const playerOnTurn = this.players.find( (player) => player.playersTurn === true ); if (playerOnTurn) return playerOnTurn; else throw new Error("No player on turn found!"); } getPlayerThatClosedRound(): Player { const playerThatClosedRound = this.players.find( (player) => player.closedRound === true ); if (playerThatClosedRound) return playerThatClosedRound; else throw new Error("No player that closed the round found!"); } getPlayerBySocketId(playerSocketId: string): Player { const player = this.players.find( (player) => player.socketId === playerSocketId ); if (player) return player; else throw new Error(`No player with socketId ${playerSocketId} found!`); } listPlayerSocketListeners() { console.log("Player socket listeners:"); io.sockets.sockets.forEach((socket) => { console.log(socket.id); socket.eventNames().forEach((eventName) => { console.log(`${eventName.toString()}`); }); }); } sendMessageToAllPlayers(message: string) { io.to(this.sessionId).emit("message", message); console.log(`Sent Message (Session): ${message}`); } }