diff --git a/services/api/src/domain/game.ts b/services/api/src/domain/game.ts index da650be9..19708220 100644 --- a/services/api/src/domain/game.ts +++ b/services/api/src/domain/game.ts @@ -12,3 +12,9 @@ export enum TurnType { MoveSkipped = 'MoveSkipped', FigurePlaced = 'FigurePlaced', } + +export enum GameState { + GAME_NOT_STARTED = 'GAME_NOT_STARTED', + GAME_IN_PROGRESS = 'GAME_IN_PROGRESS', + GAME_FINISHED = 'GAME_FINISHED', +} diff --git a/services/api/src/domain/messages.ts b/services/api/src/domain/messages.ts index 63499d6d..ce60c48a 100644 --- a/services/api/src/domain/messages.ts +++ b/services/api/src/domain/messages.ts @@ -1,6 +1,6 @@ import { GameWebSocket } from './GameRoom'; import { Player, PlayerRole } from './player'; -import { Turn } from './game'; +import { Turn, GameState } from './game'; export enum MessageType { PlayerSuccessfullyJoined = 'PlayerSuccessfullyJoined', @@ -19,6 +19,7 @@ export enum MessageType { ErrorMessage = 'ErrorMessage', ActionMade = 'ActionMade', AvatarUpdate = 'AvatarUpdate', + UpdateGameState = 'UpdateGameState', } export type SendMessagePayloads = { @@ -33,6 +34,7 @@ export type SendMessagePayloads = { [MessageType.PlayerSuccessfullyJoined]: string; [MessageType.ErrorMessage]: string; [MessageType.Pong]: void; + [MessageType.UpdateGameState]: GameState; }; export interface SendMessage { diff --git a/services/api/src/game/__tests__/__snapshots__/game-room.service.test.ts.snap b/services/api/src/game/__tests__/__snapshots__/game-room.service.test.ts.snap index fce80ab8..bc1ada1c 100644 --- a/services/api/src/game/__tests__/__snapshots__/game-room.service.test.ts.snap +++ b/services/api/src/game/__tests__/__snapshots__/game-room.service.test.ts.snap @@ -2,6 +2,7 @@ exports[`gameRoomService should create new room 1`] = ` Object { + "gameState": "GAME_NOT_STARTED", "players": Map {}, "roomId": "some-short-v4-uuid-0", "server": WebSocketServer { @@ -34,6 +35,7 @@ Object { exports[`gameRoomService should get existing room 1`] = ` Object { + "gameState": "GAME_NOT_STARTED", "players": Map {}, "roomId": "abcd-1234", "server": WebSocketServer { diff --git a/services/api/src/game/game-room.service.ts b/services/api/src/game/game-room.service.ts index 3e70531c..d493080f 100644 --- a/services/api/src/game/game-room.service.ts +++ b/services/api/src/game/game-room.service.ts @@ -2,13 +2,14 @@ import WebSocket, { WebSocketServer } from 'ws'; import { v4 as uuidv4 } from 'uuid'; import logger from '../logger'; import { Player } from '../domain'; -import { Turn } from '../domain/game'; +import { Turn, GameState } from '../domain/game'; interface GameRoom { roomId: string; server: WebSocketServer; players: Map; turns: Turn[]; + gameState: GameState; } export const rooms = new Map(); @@ -31,12 +32,20 @@ export const getOrCreateRoom = (id?: string): GameRoom => { server, players: new Map(), turns: [], + gameState: GameState.GAME_NOT_STARTED, }; rooms.set(roomId, newRoom); return newRoom; }; +export const setGameState = (roomId: string, gameState: GameState): void => { + getOrCreateRoom(roomId).gameState = gameState; +}; + +export const getGameState = (roomId: string): GameState => + getOrCreateRoom(roomId).gameState; + export const getClients = (id: string): Set => rooms.get(id)?.server.clients; diff --git a/services/api/src/game/game.service.ts b/services/api/src/game/game.service.ts index 2e142204..e1ea674f 100644 --- a/services/api/src/game/game.service.ts +++ b/services/api/src/game/game.service.ts @@ -1,9 +1,22 @@ import { PlaceFigureMessage } from '../domain/messages'; +import { GameState } from '../domain/game'; import { calculateScore } from '../helpers/calculate-score'; import * as gameRoomService from './game-room.service'; import logger from '../logger'; import { Turn, TurnType } from '../domain/game'; -import { PlayerStatus, PlayerRole } from '../domain/player'; +import { PlayerStatus, PlayerRole, Player } from '../domain/player'; + +export const getVoters = (roomId: string): Player[] => { + const players = gameRoomService.getPlayers(roomId); + return Array.from(players.values()).filter( + (p) => p.role === PlayerRole.Voter, + ); +}; + +export const getVoterWhoMadeActionCount = (roomId: string): number => + getVoters(roomId).filter( + (voter) => voter.status !== PlayerStatus.ActionNotTaken, + ).length; export const playerHasMove = (roomId: string, playerId: string): boolean => Boolean(findMoveByPlayerId(roomId, playerId)); @@ -22,10 +35,19 @@ export const playerHasSkipped = (roomId: string, playerId: string): boolean => { }; export const areAllPlayersDone = (roomId: string): boolean => { - const players = gameRoomService.getPlayers(roomId); - return Array.from(players.values()) - .filter((p) => p.role === PlayerRole.Voter) - .every((player) => player.status !== PlayerStatus.ActionNotTaken); + const voters = getVoters(roomId); + if (voters.length < 2) { + return false; + } + + const arePlayersDone = voters.every( + (player) => player.status !== PlayerStatus.ActionNotTaken, + ); + if (arePlayersDone) { + gameRoomService.setGameState(roomId, GameState.GAME_FINISHED); + } + + return arePlayersDone; }; export const figureMoved = ( diff --git a/services/api/src/messaging/__tests__/player.service.test.ts b/services/api/src/messaging/__tests__/player.service.test.ts index 28bb9cd9..c6e7e8e6 100644 --- a/services/api/src/messaging/__tests__/player.service.test.ts +++ b/services/api/src/messaging/__tests__/player.service.test.ts @@ -67,7 +67,7 @@ describe('player.service', () => { it('should create new id and assign voter role to player on connect', () => { const message: ReceivedMessage = { type: MessageType.PlayerConnected, - payload: { playerName: 'player1', id: '', role: null, avatar: null }, + payload: { playerName: 'player1', id: '', role: null, avatar: undefined }, }; const messageSpy = jest.spyOn(playerService, 'subscribe'); playerService.newMessageReceived(ws, message); diff --git a/services/api/src/messaging/players.service.ts b/services/api/src/messaging/players.service.ts index f7c7622d..696dcd34 100644 --- a/services/api/src/messaging/players.service.ts +++ b/services/api/src/messaging/players.service.ts @@ -1,6 +1,7 @@ import WebSocket from 'ws'; import { v4 as uuidv4 } from 'uuid'; import { Player, PlayerStatus, PlayerRole } from '../domain'; +import { GameState } from '../domain/game'; import { getPlayerAvatarColor } from '../helpers/player-avatar-color'; import logger from '../logger'; import { @@ -36,6 +37,33 @@ export const findPlayerById = ( return player; }; +export const findPlayerByConnection = (ws: GameWebSocket): Player => { + const players = gameRoomService.getPlayers(ws.roomId); + const player = players.get(ws); + if (!player) { + throw new Error('player with this ws connection not found'); + } + return player; +}; + +export const checkIfLastToVote = ( + roomId: string, + playerId: string, +): boolean => { + const player = findPlayerById(roomId, playerId); + const voters = gameService.getVoters(roomId); + const votersWhoMadeMoveCount = gameService.getVoterWhoMadeActionCount(roomId); + const playerCount = gameRoomService.getPlayers(roomId).size; + + const isOneVoteMissing = playerCount === votersWhoMadeMoveCount + 1; + const hasPlayerMoved = player[1].status !== PlayerStatus.ActionNotTaken; + + if (isOneVoteMissing && voters.length > 1 && !hasPlayerMoved) { + return true; + } + return false; +}; + const playerExists = (roomId: string, playerId: string): boolean => { try { findPlayerById(roomId, playerId); @@ -54,7 +82,18 @@ const publishFinalBoard = (ws: GameWebSocket): void => { }); }; +const publishGameState = (ws: GameWebSocket): void => { + publish(ws.roomId, { + type: MessageType.UpdateGameState, + payload: gameRoomService.getGameState(ws.roomId), + }); +}; + export const figureMoved: Handler = (ws, payload: PlaceFigureMessage): void => { + if (gameRoomService.getGameState(ws.roomId) !== GameState.GAME_IN_PROGRESS) { + return; + } + const players = getPlayers(ws.roomId); logger.info(`Player ${players.get(ws)?.name} moved a figure.`); players.set(ws, { @@ -67,6 +106,7 @@ export const figureMoved: Handler = (ws, payload: PlaceFigureMessage): void => { publish(ws.roomId, { type: MessageType.ActionMade, payload: newBoardState }); publishAllPlayers(ws.roomId); if (gameService.areAllPlayersDone(ws.roomId)) { + publishGameState(ws); publishFinalBoard(ws); } }; @@ -80,6 +120,12 @@ const setDefaultStatusForPlayers = (ws: GameWebSocket): void => { export const resetGame = (ws: GameWebSocket): void => { gameService.clearBoard(ws.roomId); + if (gameService.getVoters(ws.roomId).length < 2) { + gameRoomService.setGameState(ws.roomId, GameState.GAME_NOT_STARTED); + } else { + gameRoomService.setGameState(ws.roomId, GameState.GAME_IN_PROGRESS); + } + publishGameState(ws); setDefaultStatusForPlayers(ws); publishBoard(ws.roomId); publishAllPlayers(ws.roomId); @@ -90,6 +136,9 @@ export const moveSkipped: Handler = ( { playerId }: MoveSkippedMessage, ): void => { const players = getPlayers(ws.roomId); + if (gameRoomService.getGameState(ws.roomId) !== GameState.GAME_IN_PROGRESS) { + return; + } try { const [playerConnection, player] = findPlayerById(ws.roomId, playerId); @@ -119,6 +168,7 @@ export const moveSkipped: Handler = ( payload: gameRoomService.getTurns(ws.roomId), }); if (gameService.areAllPlayersDone(ws.roomId)) { + publishGameState(ws); publishFinalBoard(ws); } } catch (err) { @@ -146,6 +196,10 @@ const createNewPlayer = (params: { newPlayer.status = PlayerStatus.FigurePlaced; } else if (gameService.playerHasSkipped(params.roomId, params.playerId)) { newPlayer.status = PlayerStatus.MoveSkipped; + } else if ( + gameRoomService.getGameState(params.roomId) === GameState.GAME_FINISHED + ) { + newPlayer.status = PlayerStatus.MoveSkipped; } return newPlayer; @@ -186,6 +240,19 @@ export const playerConnected: Handler = ( sendMessage(ws, MessageType.SetMyTurn, myTurn); } + sendMessage( + ws, + MessageType.UpdateGameState, + gameRoomService.getGameState(ws.roomId), + ); + + if (gameRoomService.getGameState(ws.roomId) === GameState.GAME_NOT_STARTED) { + if (gameService.getVoters(ws.roomId).length > 1) { + gameRoomService.setGameState(ws.roomId, GameState.GAME_IN_PROGRESS); + publishGameState(ws); + } + } + publishAllPlayers(ws.roomId); if (gameService.areAllPlayersDone(ws.roomId)) { @@ -232,6 +299,42 @@ export const subscribe = (ws: GameWebSocket, newPlayer: Player): void => { export const unsubscribe = (ws: GameWebSocket): void => { const players = getPlayers(ws.roomId); + try { + const playerId = findPlayerByConnection(ws).id; + + if ( + gameService.getVoters(ws.roomId).length === 2 && + gameRoomService.getGameState(ws.roomId) !== GameState.GAME_FINISHED + ) { + gameRoomService.setGameState(ws.roomId, GameState.GAME_NOT_STARTED); + publishGameState(ws); + } else if (checkIfLastToVote(ws.roomId, playerId)) { + players.delete(ws); + setTimeout(() => { + try { + const player = findPlayerById(ws.roomId, playerId); + if (player) { + return; + } + } catch (err) { + logger.error(err?.message); + logger.info('Publishing: player disconnected the game.'); + gameRoomService.setGameState(ws.roomId, GameState.GAME_FINISHED); + const allPlayers = Array.from(players.values()); + publish(ws.roomId, { + type: MessageType.PlayerDisconnected, + payload: allPlayers, + }); + publishGameState(ws); + publishFinalBoard(ws); + } + }, 2000); + return; + } + } catch { + return; + } + logger.info(`Unsubscribing player ${players.get(ws)?.name}`); players.delete(ws); diff --git a/services/app/src/components/gameFooter/GameFooter.jsx b/services/app/src/components/gameFooter/GameFooter.jsx index 5d2eebf7..769bf27d 100644 --- a/services/app/src/components/gameFooter/GameFooter.jsx +++ b/services/app/src/components/gameFooter/GameFooter.jsx @@ -3,14 +3,17 @@ import PropTypes from 'prop-types'; import { useChessBoardContext } from '../../contexts/ChessBoardContext'; import GameFooterActive from './GameFooterActive'; import GameFooterInactive from './GameFooterInactive'; +import { GameState } from '../../constants/gameConstants'; const GameFooter = ({ skipCurrentPlayerMove }) => { - const { voters } = useChessBoardContext() + const { gameState } = useChessBoardContext(); return ( ) diff --git a/services/app/src/constants/messages.js b/services/app/src/constants/messages.js index ca88ce4a..4f9ed977 100644 --- a/services/app/src/constants/messages.js +++ b/services/app/src/constants/messages.js @@ -8,4 +8,5 @@ export const MessageType = { ActionMade: 'ActionMade', SetMyTurn: 'SetMyTurn', NewBoardState: 'NewBoardState', + UpdateGameState: 'UpdateGameState', } \ No newline at end of file diff --git a/services/app/src/contexts/ChessBoardContext.jsx b/services/app/src/contexts/ChessBoardContext.jsx index f862db9a..a5eb1db3 100644 --- a/services/app/src/contexts/ChessBoardContext.jsx +++ b/services/app/src/contexts/ChessBoardContext.jsx @@ -28,6 +28,7 @@ const ChessBoardContextProvider = ({ children }) => { const [players, setPlayers] = useState([]); const [turns, setTurns] = useState([]); const [errorMessage, setErrorMessage] = useState(''); + const [gameState, setGameState] = useState(GameState.GAME_NOT_STARTED); const currentPlayer = useMemo( () => players.find((user) => user.id === currentPlayerId), @@ -74,20 +75,6 @@ const ChessBoardContextProvider = ({ children }) => { PlayerStatuses.MoveSkipped ].includes(currentPlayer?.status), [currentPlayer]); - const gameState = useMemo(() => { - const votersWhoDidNotMove = voters - .filter(p => p.status === PlayerStatuses.ActionNotTaken); - - if (voters.length > 1) { - if (votersWhoDidNotMove.length === 0) { - return GameState.GAME_FINISHED; - } - return GameState.GAME_IN_PROGRESS; - } - - return GameState.GAME_NOT_STARTED; - }, [players]); - const votersListWithScores = useMemo(() => { if (gameState === GameState.GAME_FINISHED && turns) { const voterList = voters.map(voter => { @@ -155,6 +142,10 @@ const ChessBoardContextProvider = ({ children }) => { chessBoard.clearChessBoard(); } + addWsEventListener(MessageType.UpdateGameState, (payload) => { + setGameState(payload); + }); + addWsEventListener(MessageType.PlayerSuccessfullyJoined, (payload) => { userContext.setUserId(payload) setCurrentPlayerId(payload);