diff --git a/apps/ligretto-frontend/src/ducks/game/listeners.spec.ts b/apps/ligretto-frontend/src/ducks/game/listeners.spec.ts new file mode 100644 index 00000000..f239dbc4 --- /dev/null +++ b/apps/ligretto-frontend/src/ducks/game/listeners.spec.ts @@ -0,0 +1,35 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest' +import { resumeGameEmitAction } from '@memebattle/ligretto-shared' + +import { createMockStore } from '#testing/lib/createMockStore' +import { addListeners } from './listeners' +import { initialState, resumeGameAction } from './slice' + +type ListenerConfig = { + actionCreator?: { type: string } + effect?: (action: unknown, listenerApi: { getState: () => unknown; dispatch: (action: unknown) => void }) => void +} + +describe('game listeners', () => { + it('maps the local resume action to the dedicated websocket action', () => { + const listeners: ListenerConfig[] = [] + addListeners(((config: ListenerConfig) => listeners.push(config)) as never) + + const listener = listeners.find(config => config.actionCreator?.type === resumeGameAction.type) + const store = createMockStore({ + preloadedState: { + game: { + ...initialState, + game: { ...initialState.game, id: 'paused-game' }, + }, + }, + }) + const dispatch = vi.fn() + + listener?.effect?.(resumeGameAction(), { getState: store.getState, dispatch }) + + expect(dispatch).toHaveBeenCalledWith(resumeGameEmitAction({ gameId: 'paused-game' })) + }) +}) diff --git a/apps/ligretto-frontend/src/ducks/game/listeners.ts b/apps/ligretto-frontend/src/ducks/game/listeners.ts index f4afb85f..5f34c4ff 100644 --- a/apps/ligretto-frontend/src/ducks/game/listeners.ts +++ b/apps/ligretto-frontend/src/ducks/game/listeners.ts @@ -4,6 +4,7 @@ import { PlayerStatus, putCardAction, putCardFromStackOpenDeck, + resumeGameEmitAction, setPlayerStatusEmitAction, startGameEmitAction, takeFromLigrettoDeckAction, @@ -25,6 +26,7 @@ import { tapStackDeckCardAction, tapLigrettoDeckCardAction, resetGameStateAction, + resumeGameAction, } from './slice' import { gameIdSelector, playerStatusSelector } from './selectors' import { matchPath } from 'react-router' @@ -134,6 +136,15 @@ export function addListeners(startListener: TypedStartListening) { }, }) + startListener({ + actionCreator: resumeGameAction, + effect: (_action, listenerApi) => { + const gameId = gameIdSelector(listenerApi.getState()) + + listenerApi.dispatch(resumeGameEmitAction({ gameId })) + }, + }) + startListener({ actionCreator: tapCardAction, effect: ({ payload }, listenerApi) => { diff --git a/apps/ligretto-frontend/src/ducks/game/slice.ts b/apps/ligretto-frontend/src/ducks/game/slice.ts index 7d266e26..57b52a63 100644 --- a/apps/ligretto-frontend/src/ducks/game/slice.ts +++ b/apps/ligretto-frontend/src/ducks/game/slice.ts @@ -33,6 +33,7 @@ export const initialState: GameState = { export const togglePlayerStatusAction = createAction('@@game/TOGGLE_PLAYER_STATUS') export const startGameAction = createAction('@@game/START_GAME') +export const resumeGameAction = createAction('@@game/RESUME_GAME') export const tapCardAction = createAction<{ cardIndex: number }>('@@game/TapCardAction') export const tapStackOpenDeckCardAction = createAction('@@game/TapStackOpenDeckCardAction') export const tapStackDeckCardAction = createAction('@@game/TapStackDeckCardAction') diff --git a/apps/ligretto-frontend/src/widgets/game-info/ui/GameSettings/GameSettings.spec.tsx b/apps/ligretto-frontend/src/widgets/game-info/ui/GameSettings/GameSettings.spec.tsx new file mode 100644 index 00000000..69b02e23 --- /dev/null +++ b/apps/ligretto-frontend/src/widgets/game-info/ui/GameSettings/GameSettings.spec.tsx @@ -0,0 +1,96 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Provider } from 'react-redux' +import { MemoryRouter } from 'react-router' +import { GameStatus, PlayerStatus } from '@memebattle/ligretto-shared' + +import { createMockStore } from '#testing/lib/createMockStore' +import { initialState, resumeGameAction } from '#ducks/game' +import { GameSettings } from './GameSettings' +import { GameSettingsContainer } from './GameSettingsContainer' + +afterEach(cleanup) + +describe('GameSettings', () => { + it('labels the host action as Resume while the round is paused', () => { + render( + + undefined} + onReadyClick={() => undefined} + onExitClick={() => undefined} + isButtonDisabled={false} + isPlayerReadyToPlay={false} + /> + , + ) + + expect(screen.getByRole('button', { name: 'Resume' })).toBeTruthy() + }) + + it.each([GameStatus.New, GameStatus.RoundFinished])('labels the host action as Start for %s games', gameStatus => { + render( + + undefined} + onReadyClick={() => undefined} + onExitClick={() => undefined} + isButtonDisabled={false} + isPlayerReadyToPlay={false} + /> + , + ) + + expect(screen.getByRole('button', { name: 'Start' })).toBeTruthy() + }) + + it('dispatches the resume flow when the host resumes a paused round', () => { + const player = { + id: 'host', + isHost: true, + status: PlayerStatus.InGame, + cards: [], + ligrettoDeck: { isHidden: true, cards: [] }, + stackOpenDeck: { isHidden: false, cards: [] }, + stackDeck: { isHidden: true, cards: [] }, + } + const store = createMockStore({ + preloadedState: { + auth: { userId: player.id, token: '', isLoading: false }, + game: { + ...initialState, + game: { + ...initialState.game, + id: 'paused-game', + status: GameStatus.Pause, + players: { + [player.id]: player, + opponent: { ...player, id: 'opponent', isHost: false }, + }, + }, + }, + }, + }) + const dispatch = vi.spyOn(store, 'dispatch') + + render( + + + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Resume' })) + + expect(dispatch).toHaveBeenCalledWith(resumeGameAction()) + }) +}) diff --git a/apps/ligretto-frontend/src/widgets/game-info/ui/GameSettings/GameSettings.tsx b/apps/ligretto-frontend/src/widgets/game-info/ui/GameSettings/GameSettings.tsx index e9769985..eac7384e 100644 --- a/apps/ligretto-frontend/src/widgets/game-info/ui/GameSettings/GameSettings.tsx +++ b/apps/ligretto-frontend/src/widgets/game-info/ui/GameSettings/GameSettings.tsx @@ -51,13 +51,13 @@ export const GameSettings = ({ const buttonText: string = useMemo(() => { if (canStartGame) { - return 'Start' + return gameStatus === GameStatus.Pause ? 'Resume' : 'Start' } if (isPlayerReadyToPlay) { return 'Not ready' } return 'Ready' - }, [canStartGame, isPlayerReadyToPlay]) + }, [canStartGame, gameStatus, isPlayerReadyToPlay]) return ( diff --git a/apps/ligretto-frontend/src/widgets/game-info/ui/GameSettings/GameSettingsContainer.tsx b/apps/ligretto-frontend/src/widgets/game-info/ui/GameSettings/GameSettingsContainer.tsx index 89e09942..c0a1956c 100644 --- a/apps/ligretto-frontend/src/widgets/game-info/ui/GameSettings/GameSettingsContainer.tsx +++ b/apps/ligretto-frontend/src/widgets/game-info/ui/GameSettings/GameSettingsContainer.tsx @@ -8,11 +8,12 @@ import { gameStatusSelector, isGameReadyToStartSelector, playerSelector, + resumeGameAction, startGameAction, togglePlayerStatusAction, } from '#ducks/game' import { GameSettings } from '#widgets/game-info' -import { PlayerStatus } from '@memebattle/ligretto-shared' +import { GameStatus, PlayerStatus } from '@memebattle/ligretto-shared' export const GameSettingsContainer = () => { const dispatch = useDispatch() @@ -28,8 +29,8 @@ export const GameSettingsContainer = () => { }, [dispatch]) const handleStartClick = useCallback(() => { - dispatch(startGameAction()) - }, [dispatch]) + dispatch(gameStatus === GameStatus.Pause ? resumeGameAction() : startGameAction()) + }, [dispatch, gameStatus]) const handleExitClick = useCallback(() => { navigate(routes.HOME) diff --git a/apps/ligretto-gameplay-backend/src/controllers/__tests__/gameplay-controller.spec.ts b/apps/ligretto-gameplay-backend/src/controllers/__tests__/gameplay-controller.spec.ts new file mode 100644 index 00000000..ef497e4c --- /dev/null +++ b/apps/ligretto-gameplay-backend/src/controllers/__tests__/gameplay-controller.spec.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + CardColors, + GameStatus, + PlayerStatus, + resumeGameEmitAction, + startGameEmitAction, + updateGameAction, + type Game, +} from '@memebattle/ligretto-shared' + +import type { GameplayController } from '../gameplay-controller' +import { createIOC } from '../../inversify.config' +import { IOC_TYPES } from '../../IOC_TYPES' +import type { Database } from '../../database' +import { createSocketMockImpl } from '../../../test/utils' +import type { AnyAction } from '../../types/any-action' + +const pausedGame: Game = { + id: 'paused-game', + name: 'Paused game', + status: GameStatus.Pause, + players: { + player: { + id: 'player', + isHost: true, + status: PlayerStatus.InGame, + cards: [{ color: CardColors.red, value: 4, playerId: 'player' }], + ligrettoDeck: { + isHidden: true, + cards: [{ color: CardColors.blue, value: 7, playerId: 'player' }], + }, + stackOpenDeck: { + isHidden: false, + cards: [{ color: CardColors.green, value: 3, playerId: 'player' }], + }, + stackDeck: { + isHidden: true, + cards: [{ color: CardColors.yellow, value: 9, playerId: 'player' }], + }, + }, + }, + spectators: { spectator: { id: 'spectator' } }, + playground: { + decks: [{ isHidden: false, cards: [{ color: CardColors.red, value: 1, playerId: 'player' }] }], + droppedDecks: [{ isHidden: false, cards: [{ color: CardColors.blue, value: 2, playerId: 'player' }] }], + }, + config: { + startingDelayInSec: 4, + playersMaxCount: 4, + dndEnabled: false, + maxCardsOnTable: 12, + }, +} + +describe('Gameplay Controller', () => { + let gameplayController: GameplayController + let database: Database + let socket = createSocketMockImpl() + + beforeEach(async () => { + const container = createIOC() + gameplayController = container.get(IOC_TYPES.GameplayController) + database = container.get(IOC_TYPES.Database) + socket = createSocketMockImpl({ data: { user: { id: 'player' } } }) + + await database.set(storage => { + storage.games[pausedGame.id] = structuredClone(pausedGame) + }) + }) + + it('resumes a paused game without changing its in-progress state', async () => { + await gameplayController.handleMessage(socket, resumeGameEmitAction({ gameId: pausedGame.id }) as AnyAction) + + const resumedGame = await database.get(storage => storage.games[pausedGame.id]) + const expectedGame = { ...pausedGame, status: GameStatus.InGame } + + expect(resumedGame).toEqual(expectedGame) + expect(socket.to).toHaveBeenCalledWith(pausedGame.id) + expect(socket.emit).toHaveBeenCalledWith('event', updateGameAction(expectedGame)) + }) + + it('broadcasts the freshest canonical game after resuming', async () => { + const handling = gameplayController.handleMessage(socket, resumeGameEmitAction({ gameId: pausedGame.id }) as AnyAction) + + await Promise.resolve() + const freshestGame = await database.set(storage => { + const game = storage.games[pausedGame.id] + return (storage.games[pausedGame.id] = { ...game, name: 'Concurrently updated game' }) + }) + await handling + + expect(socket.emit).toHaveBeenCalledWith('event', updateGameAction(freshestGame)) + }) + + it('does not allow a non-host socket to resume a paused game', async () => { + const nonHostSocket = createSocketMockImpl({ data: { user: { id: 'spectator' } } }) + + await gameplayController.handleMessage(nonHostSocket, resumeGameEmitAction({ gameId: pausedGame.id }) as AnyAction) + + const game = await database.get(storage => storage.games[pausedGame.id]) + expect(game).toEqual(pausedGame) + expect(nonHostSocket.to).not.toHaveBeenCalled() + expect(nonHostSocket.emit).not.toHaveBeenCalled() + }) + + it('does not allow a stale former host to resume or broadcast', async () => { + const handling = gameplayController.handleMessage(socket, resumeGameEmitAction({ gameId: pausedGame.id }) as AnyAction) + + await database.set(storage => { + const game = storage.games[pausedGame.id] + storage.games[pausedGame.id] = { + ...game, + players: { + ...game.players, + player: { ...game.players.player!, isHost: false }, + }, + } + }) + await handling + + const game = await database.get(storage => storage.games[pausedGame.id]) + expect(game.status).toBe(GameStatus.Pause) + expect(socket.to).not.toHaveBeenCalled() + expect(socket.emit).not.toHaveBeenCalled() + }) + + it('ignores a resume action for an unknown game', async () => { + const gamesBefore = await database.get(storage => structuredClone(storage.games)) + + await gameplayController.handleMessage(socket, resumeGameEmitAction({ gameId: 'unknown-game' }) as AnyAction) + + const gamesAfter = await database.get(storage => storage.games) + expect(gamesAfter).toEqual(gamesBefore) + expect(socket.to).not.toHaveBeenCalled() + expect(socket.emit).not.toHaveBeenCalled() + }) + + it('does not change a game that is not paused', async () => { + const activeGame = { ...pausedGame, status: GameStatus.New } + await database.set(storage => { + storage.games[pausedGame.id] = structuredClone(activeGame) + }) + + await gameplayController.handleMessage(socket, resumeGameEmitAction({ gameId: pausedGame.id }) as AnyAction) + + const game = await database.get(storage => storage.games[pausedGame.id]) + expect(game).toEqual(activeGame) + }) + + it('keeps the start action on the fresh-round initialization path', async () => { + const newGame = { + ...pausedGame, + status: GameStatus.New, + config: { ...pausedGame.config, startingDelayInSec: 0 }, + } + await database.set(storage => { + storage.games[pausedGame.id] = structuredClone(newGame) + }) + + await gameplayController.handleMessage(socket, startGameEmitAction({ gameId: pausedGame.id }) as AnyAction) + + const game = await database.get(storage => storage.games[pausedGame.id]) + expect(game.status).toBe(GameStatus.InGame) + expect(game.players.player?.cards).not.toEqual(pausedGame.players.player?.cards) + expect(game.playground).toEqual({ decks: new Array(pausedGame.config.maxCardsOnTable).fill(null), droppedDecks: [] }) + }) +}) diff --git a/apps/ligretto-gameplay-backend/src/controllers/gameplay-controller.ts b/apps/ligretto-gameplay-backend/src/controllers/gameplay-controller.ts index cad4fc58..51e0aed9 100644 --- a/apps/ligretto-gameplay-backend/src/controllers/gameplay-controller.ts +++ b/apps/ligretto-gameplay-backend/src/controllers/gameplay-controller.ts @@ -7,6 +7,7 @@ import { endRoundAction, putCardAction, putCardFromStackOpenDeck, + resumeGameEmitAction, startGameEmitAction, takeFromLigrettoDeckAction, takeFromStackDeckAction, @@ -23,6 +24,7 @@ export class GameplayController extends Controller { protected handlers: Controller['handlers'] = { [startGameEmitAction.type]: (socket, action: ReturnType) => this.startGame(socket, action), + [resumeGameEmitAction.type]: (socket, action: ReturnType) => this.resumeGame(socket, action), [putCardAction.type]: (socket: Socket, action) => this.putCard(socket, action), [takeFromLigrettoDeckAction.type]: (socket: Socket, action) => this.takeCardFromLigrettoDeck(socket, action), [putCardFromStackOpenDeck.type]: (socket: Socket, action) => this.putCardFromStackOpenDeck(socket, action), @@ -39,6 +41,22 @@ export class GameplayController extends Controller { await this.updateGame(socket, gameId) } + private async resumeGame(socket: Socket, action: ReturnType) { + const gameId = action.payload.gameId + const game = await this.gameService.getGame(gameId) + + if (!game || !game.players[socket.data.user.id]?.isHost) { + return + } + + const resumedGame = await this.gameService.resumeGame(gameId, socket.data.user.id) + if (!resumedGame) { + return + } + + await this.updateGame(socket, gameId) + } + private async updateGame(socket: Socket, gameId: string, gameState?: Game) { const game = gameState || (await this.gameService.getGame(gameId)) diff --git a/apps/ligretto-gameplay-backend/src/entities/game/game.repo.ts b/apps/ligretto-gameplay-backend/src/entities/game/game.repo.ts index 98adce2f..eb5693b1 100644 --- a/apps/ligretto-gameplay-backend/src/entities/game/game.repo.ts +++ b/apps/ligretto-gameplay-backend/src/entities/game/game.repo.ts @@ -16,10 +16,22 @@ export class GameRepository { return this.database.get(storage => storage.games[gameId]) } - async updateGame(gameId: UUID, updater: (game: Game) => Game): Promise { - const game = await this.getGame(gameId) + updateGame(gameId: UUID, updater: (game: Game) => Game): Promise + updateGame(gameId: UUID, updater: (game: Game) => Game | undefined): Promise + async updateGame(gameId: UUID, updater: (game: Game) => Game | undefined): Promise { + return this.database.set(storage => { + const game = storage.games[gameId] + if (!game) { + return undefined + } - return this.database.set(storage => (storage.games[gameId] = updater(game))) + const updatedGame = updater(game) + if (!updatedGame) { + return undefined + } + + return (storage.games[gameId] = updatedGame) + }) } async getGameByName(gameName: string) { diff --git a/apps/ligretto-gameplay-backend/src/entities/game/game.service.ts b/apps/ligretto-gameplay-backend/src/entities/game/game.service.ts index 734f3c19..9de97ece 100644 --- a/apps/ligretto-gameplay-backend/src/entities/game/game.service.ts +++ b/apps/ligretto-gameplay-backend/src/entities/game/game.service.ts @@ -89,6 +89,16 @@ export class GameService { return this.gameRepository.updateGame(gameId, game => ({ ...game, status: GameStatus.Pause })) } + resumeGame(gameId: UUID, userId: Player['id']) { + return this.gameRepository.updateGame(gameId, game => { + if (game.status !== GameStatus.Pause || !game.players[userId]?.isHost) { + return undefined + } + + return { ...game, status: GameStatus.InGame } + }) + } + async addPlayer(gameId: UUID, playerData: Partial & { id: Player['id'] }) { const player = await this.gameRepository.createPlayer({ ...playerData }) return { diff --git a/packages/ligretto-shared/src/actions.ts b/packages/ligretto-shared/src/actions.ts index 90532867..d778b8c3 100644 --- a/packages/ligretto-shared/src/actions.ts +++ b/packages/ligretto-shared/src/actions.ts @@ -31,6 +31,8 @@ export const setPlayerStatusEmitAction = createAction('@ export const startGameEmitAction = createAction('@@gameplay/WEBSOCKET/START_GAME') +export const resumeGameEmitAction = createAction('@@gameplay/WEBSOCKET/RESUME_GAME') + export const takeFromLigrettoDeckAction = createAction('@@gameplay/WEBSOCKET/TAKE_FROM_LIGRETTO_DECK') export const takeFromStackDeckAction = createAction('@@gameplay/WEBSOCKET/TAKE_FROM_STACK_DECK') diff --git a/packages/ligretto-shared/src/dto.ts b/packages/ligretto-shared/src/dto.ts index 4cb2bd3d..dd8598a5 100644 --- a/packages/ligretto-shared/src/dto.ts +++ b/packages/ligretto-shared/src/dto.ts @@ -36,6 +36,10 @@ export interface StartGame { gameId: Game['id'] } +export interface ResumeGame { + gameId: Game['id'] +} + export interface PlayerStatusInGame { gameId: Game['id'] status: PlayerStatus