Skip to content
Merged
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
35 changes: 35 additions & 0 deletions apps/ligretto-frontend/src/ducks/game/listeners.spec.ts
Original file line number Diff line number Diff line change
@@ -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' }))
})
})
11 changes: 11 additions & 0 deletions apps/ligretto-frontend/src/ducks/game/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
PlayerStatus,
putCardAction,
putCardFromStackOpenDeck,
resumeGameEmitAction,
setPlayerStatusEmitAction,
startGameEmitAction,
takeFromLigrettoDeckAction,
Expand All @@ -25,6 +26,7 @@ import {
tapStackDeckCardAction,
tapLigrettoDeckCardAction,
resetGameStateAction,
resumeGameAction,
} from './slice'
import { gameIdSelector, playerStatusSelector } from './selectors'
import { matchPath } from 'react-router'
Expand Down Expand Up @@ -134,6 +136,15 @@ export function addListeners(startListener: TypedStartListening<All>) {
},
})

startListener({
actionCreator: resumeGameAction,
effect: (_action, listenerApi) => {
const gameId = gameIdSelector(listenerApi.getState())

listenerApi.dispatch(resumeGameEmitAction({ gameId }))
},
})

startListener({
actionCreator: tapCardAction,
effect: ({ payload }, listenerApi) => {
Expand Down
1 change: 1 addition & 0 deletions apps/ligretto-frontend/src/ducks/game/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<Provider store={createMockStore()}>
<GameSettings
gameStatus={GameStatus.Pause}
gameName="Paused game"
canStartGame
onStartClick={() => undefined}
onReadyClick={() => undefined}
onExitClick={() => undefined}
isButtonDisabled={false}
isPlayerReadyToPlay={false}
/>
</Provider>,
)

expect(screen.getByRole('button', { name: 'Resume' })).toBeTruthy()
})

it.each([GameStatus.New, GameStatus.RoundFinished])('labels the host action as Start for %s games', gameStatus => {
render(
<Provider store={createMockStore()}>
<GameSettings
gameStatus={gameStatus}
gameName="Game"
canStartGame
onStartClick={() => undefined}
onReadyClick={() => undefined}
onExitClick={() => undefined}
isButtonDisabled={false}
isPlayerReadyToPlay={false}
/>
</Provider>,
)

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(
<Provider store={store}>
<MemoryRouter>
<GameSettingsContainer />
</MemoryRouter>
</Provider>,
)

fireEvent.click(screen.getByRole('button', { name: 'Resume' }))

expect(dispatch).toHaveBeenCalledWith(resumeGameAction())
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<StyledPaper data-test-id="GameSettings">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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: [] })
})
})
Loading