Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions services/api/src/domain/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
4 changes: 3 additions & 1 deletion services/api/src/domain/messages.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -19,6 +19,7 @@ export enum MessageType {
ErrorMessage = 'ErrorMessage',
ActionMade = 'ActionMade',
AvatarUpdate = 'AvatarUpdate',
UpdateGameState = 'UpdateGameState',
}

export type SendMessagePayloads = {
Expand All @@ -33,6 +34,7 @@ export type SendMessagePayloads = {
[MessageType.PlayerSuccessfullyJoined]: string;
[MessageType.ErrorMessage]: string;
[MessageType.Pong]: void;
[MessageType.UpdateGameState]: GameState;
};

export interface SendMessage<T extends keyof SendMessagePayloads> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -34,6 +35,7 @@ Object {

exports[`gameRoomService should get existing room 1`] = `
Object {
"gameState": "GAME_NOT_STARTED",
"players": Map {},
"roomId": "abcd-1234",
"server": WebSocketServer {
Expand Down
11 changes: 10 additions & 1 deletion services/api/src/game/game-room.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebSocket, Player>;
turns: Turn[];
gameState: GameState;
}

export const rooms = new Map<string, GameRoom>();
Expand All @@ -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<WebSocket> =>
rooms.get(id)?.server.clients;

Expand Down
32 changes: 27 additions & 5 deletions services/api/src/game/game.service.ts
Original file line number Diff line number Diff line change
@@ -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));
Expand All @@ -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 = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('player.service', () => {
it('should create new id and assign voter role to player on connect', () => {
const message: ReceivedMessage<MessageType.PlayerConnected> = {
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);
Expand Down
103 changes: 103 additions & 0 deletions services/api/src/messaging/players.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the logic behind puting methods into services?
Why is this method inside the players service, while getPlayers and getVoterWhoMadeActionCount are in gameService and gameRoomService?

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why isOneVoteMissing not enough?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've got rid of voters.length > 1 condition, but hasPlayerMoved is needed, because, we need to know, if the particular player has not moved. It also reflects in method name, which is checkIfLastToVote

return true;
}
return false;
};

const playerExists = (roomId: string, playerId: string): boolean => {
try {
findPlayerById(roomId, playerId);
Expand All @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems like some sort of workaround. Why is this needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its for a safety reasons. Without this check, even if game state is GameNotStarted and user cannot place figure on the board, it would be possible to send a message for example with a bot

return;
}

const players = getPlayers(ws.roomId);
logger.info(`Player ${players.get(ws)?.name} moved a figure.`);
players.set(ws, {
Expand All @@ -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);
}
};
Expand All @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this condition needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because, without this condition, if one player joins the room and presses restart game button, the game state would become GameInProgress and he would be able to place figures on the board (at least in FE)

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);
Expand All @@ -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);
Expand Down Expand Up @@ -119,6 +168,7 @@ export const moveSkipped: Handler = (
payload: gameRoomService.getTurns(ws.roomId),
});
if (gameService.areAllPlayersDone(ws.roomId)) {
publishGameState(ws);
publishFinalBoard(ws);
}
} catch (err) {
Expand Down Expand Up @@ -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 (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

theres no if condition

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is, its in the next line and wrapped with ( ). It probably got formatted this way, because the line got too large

gameRoomService.getGameState(params.roomId) === GameState.GAME_FINISHED
) {
newPlayer.status = PlayerStatus.MoveSkipped;
}

return newPlayer;
Expand Down Expand Up @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move this call into a single place and it would be executed after every action taken by players?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why should we send so many messages? For example if user makes two turns(for example changes his mind), we would publish game that has not changed state twice. I think we should leave game state publishing, to when its really needed or game state changes

}
}

publishAllPlayers(ws.roomId);

if (gameService.areAllPlayersDone(ws.roomId)) {
Expand Down Expand Up @@ -232,6 +299,42 @@ export const subscribe = (ws: GameWebSocket, newPlayer: Player): void => {

export const unsubscribe = (ws: GameWebSocket): void => {
const players = getPlayers(ws.roomId);
try {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method got too large. Its hard to follow.

const playerId = findPlayerByConnection(ws).id;

if (
gameService.getVoters(ws.roomId).length === 2 &&
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this condition needed?

Copy link
Contributor Author

@AndzejIlj AndzejIlj Mar 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because, when there is only two people in the room and the game is not finished, when one of them logs out, in my opinion the game state should become game not started (we don't allow player to make votes, when he's the only one in the room).

gameRoomService.getGameState(ws.roomId) !== GameState.GAME_FINISHED
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we create a general algorithm that would be applied no matter how many players we have in the game or what the game state is.

) {
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is repeated all over the place. Can we find a better way to determine when final board should be pushed apart from placing it directly in the code?

}
}, 2000);
return;
}
} catch {
return;
}

logger.info(`Unsubscribing player ${players.get(ws)?.name}`);
players.delete(ws);

Expand Down
7 changes: 5 additions & 2 deletions services/app/src/components/gameFooter/GameFooter.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div id="game-footer" className='align-c'>
{
voters.length < 2 ? <GameFooterInactive /> : <GameFooterActive skipCurrentPlayerMove={skipCurrentPlayerMove} />
gameState === GameState.GAME_NOT_STARTED
? <GameFooterInactive />
: <GameFooterActive skipCurrentPlayerMove={skipCurrentPlayerMove} />
}
</div>
)
Expand Down
1 change: 1 addition & 0 deletions services/app/src/constants/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export const MessageType = {
ActionMade: 'ActionMade',
SetMyTurn: 'SetMyTurn',
NewBoardState: 'NewBoardState',
UpdateGameState: 'UpdateGameState',
}
19 changes: 5 additions & 14 deletions services/app/src/contexts/ChessBoardContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -155,6 +142,10 @@ const ChessBoardContextProvider = ({ children }) => {
chessBoard.clearChessBoard();
}

addWsEventListener(MessageType.UpdateGameState, (payload) => {
setGameState(payload);
});

addWsEventListener(MessageType.PlayerSuccessfullyJoined, (payload) => {
userContext.setUserId(payload)
setCurrentPlayerId(payload);
Expand Down