implement gameboard v1

This commit is contained in:
seavor 2026-04-19 23:21:42 -05:00
parent b103db681b
commit 0d7336edc2
177 changed files with 16995 additions and 139 deletions

View file

@ -0,0 +1,443 @@
// Exercises the full Game container under the real Redux store + real
// reducers + real React chain. We dispatch game lifecycle events via
// GameDispatch (the same path real event handlers take) and assert the
// Game container's UI tracks state transitions.
import { act, fireEvent, waitFor, screen, within } from '@testing-library/react';
import { create } from '@bufbuild/protobuf';
import { useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Data } from '@app/types';
import { Command_GameSay_ext } from '@app/generated';
import { GameDispatch, ServerDispatch, store } from '@app/store';
import { WebsocketTypes } from '@app/websocket/types';
import Game from '../../../src/containers/Game/Game';
import { renderAppScreen } from './helpers';
import { findLastGameCommand } from '../helpers/command-capture';
import { connectRaw } from '../helpers/setup';
// Surfaces the current MemoryRouter pathname so navigate() side-effects
// (e.g. useGameLifecycle → /server on kick) can be asserted. Depends on
// `renderAppScreen` → `renderWithProviders` wrapping its tree in a
// `MemoryRouter`; if that harness ever moves the probe outside a Router,
// `useLocation()` will throw "useLocation() may be used only in the
// context of a <Router> component." — fix by adding an inline
// `<MemoryRouter>` around the probe OR by teaching the harness about it.
function LocationProbe() {
const location = useLocation();
return <span data-testid="app-location">{location.pathname}</span>;
}
function buildEventGameJoined(args: {
gameId: number;
localPlayerId: number;
hostId: number;
}): Data.Event_GameJoined {
return create(Data.Event_GameJoinedSchema, {
gameInfo: create(Data.ServerInfo_GameSchema, {
gameId: args.gameId,
roomId: 1,
description: 'Integration Test Game',
gameTypes: [],
started: false,
}),
hostId: args.hostId,
playerId: args.localPlayerId,
spectator: false,
judge: false,
resuming: false,
});
}
function buildEventGameStateChanged(
playerIds: number[],
localId: number,
): Data.Event_GameStateChanged {
return create(Data.Event_GameStateChangedSchema, {
gameStarted: true,
activePlayerId: localId,
activePhase: 0,
playerList: playerIds.map((pid) =>
create(Data.ServerInfo_PlayerSchema, {
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: pid,
userInfo: create(Data.ServerInfo_UserSchema, { name: `P${pid}` }),
spectator: false,
conceded: false,
readyStart: false,
judge: false,
}),
deckList: '',
zoneList: [
create(Data.ServerInfo_ZoneSchema, {
name: 'table',
type: 1,
withCoords: true,
cardCount: 0,
cardList: [],
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'hand',
type: 0,
withCoords: false,
cardCount: 0,
cardList: [],
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'deck',
type: 2,
withCoords: false,
cardCount: 40,
cardList: [],
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'grave',
type: 1,
withCoords: false,
cardCount: 0,
cardList: [],
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'rfg',
type: 1,
withCoords: false,
cardCount: 0,
cardList: [],
}),
],
counterList: [],
arrowList: [],
}),
),
});
}
function simulateConnected() {
act(() => {
ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.LOGGED_IN, null);
});
}
afterEach(() => {
act(() => {
for (const gameId of Object.keys(store.getState().games.games)) {
GameDispatch.gameLeft(Number(gameId));
}
ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.DISCONNECTED, null);
});
});
beforeEach(() => {
// Integration setup installs fake timers for KeepAliveService control;
// waitFor / React effects need real timers to run between dispatch and assert.
vi.useRealTimers();
simulateConnected();
});
describe('Game board integration', () => {
it('renders the empty-board placeholder until a game is joined', () => {
renderAppScreen(<Game />);
expect(screen.getByTestId('game-empty')).toBeInTheDocument();
expect(screen.getByTestId('phase-bar')).toBeInTheDocument();
expect(screen.getByTestId('right-panel')).toBeInTheDocument();
});
it('transitions from empty → active board when gameJoined + gameStateChanged fire', async () => {
renderAppScreen(<Game />);
expect(screen.getByTestId('game-empty')).toBeInTheDocument();
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.queryByTestId('game-empty')).not.toBeInTheDocument();
});
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
expect(screen.getByTestId('player-board-2')).toBeInTheDocument();
expect(screen.getByTestId('hand-zone')).toBeInTheDocument();
});
it('returns to the empty placeholder when gameLeft fires', async () => {
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
});
act(() => {
GameDispatch.gameLeft(42);
});
await waitFor(() => {
expect(screen.getByTestId('game-empty')).toBeInTheDocument();
});
expect(screen.queryByTestId('player-board-1')).not.toBeInTheDocument();
});
it('hides the opponent selector for 2-player games but shows it for 3+', async () => {
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
});
expect(screen.queryByTestId('opponent-selector')).not.toBeInTheDocument();
act(() => {
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2, 3], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('opponent-selector')).toBeInTheDocument();
});
});
it('mirrors the opponent board and leaves the local board upright', async () => {
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-board-2')).toHaveClass('player-board--mirrored');
});
expect(screen.getByTestId('player-board-1')).not.toHaveClass('player-board--mirrored');
});
it('renders the deck/graveyard/exile rail in desktop order (no stack in rail)', async () => {
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
});
const localBoard = screen.getByTestId('player-board-1');
const rail = within(localBoard).getByTestId('zone-rail');
const labels = Array.from(rail.querySelectorAll('.zone-stack__label')).map(
(n) => n.textContent,
);
expect(labels).toEqual(['Deck', 'Graveyard', 'Exile']);
expect(within(rail).queryByText('Stack')).not.toBeInTheDocument();
});
it('sends a game_say command through the socket when a chat message is submitted', async () => {
// Establish a real mock socket so the outbound CommandContainer is captured.
connectRaw();
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
// buildEventGameStateChanged sets gameStarted: true, suppressing the
// deck-select dialog which would otherwise block focus/interaction.
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByLabelText('game chat input')).not.toBeDisabled();
});
const input = screen.getByLabelText('game chat input') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'gl hf' } });
fireEvent.submit(input.closest('form')!);
const captured = findLastGameCommand(Command_GameSay_ext);
expect(captured.value.message).toBe('gl hf');
expect(captured.gameId).toBe(42);
});
it('navigates to /server when the local user is kicked', async () => {
renderAppScreen(
<>
<Game />
<LocationProbe />
</>,
);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
});
act(() => {
GameDispatch.kicked(42);
});
await waitFor(() => {
expect(screen.getByTestId('app-location')).toHaveTextContent('/server');
});
});
it('navigates to /server when the game is closed by the host', async () => {
renderAppScreen(
<>
<Game />
<LocationProbe />
</>,
);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
});
act(() => {
GameDispatch.gameClosed(42);
});
await waitFor(() => {
expect(screen.getByTestId('app-location')).toHaveTextContent('/server');
});
});
it('reflects a host change through both PlayerList badge and PlayerInfoPanel', async () => {
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-list-item-1')).toBeInTheDocument();
});
// Host starts as 1; badge should be on row 1.
expect(
screen.getByTestId('player-list-item-1').querySelector('.player-list__host-badge'),
).not.toBeNull();
expect(
screen.getByTestId('player-list-item-2').querySelector('.player-list__host-badge'),
).toBeNull();
// Host changes to player 2.
act(() => {
GameDispatch.gameHostChanged(42, 2);
});
await waitFor(() => {
expect(
screen.getByTestId('player-list-item-2').querySelector('.player-list__host-badge'),
).not.toBeNull();
});
expect(
screen.getByTestId('player-list-item-1').querySelector('.player-list__host-badge'),
).toBeNull();
});
it('auto-opens the DeckSelectDialog when a game is joined and not started', async () => {
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
create(Data.Event_GameStateChangedSchema, {
gameStarted: false,
activePlayerId: 1,
activePhase: -1,
playerList: [1, 2].map((pid) =>
create(Data.ServerInfo_PlayerSchema, {
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: pid,
userInfo: create(Data.ServerInfo_UserSchema, { name: `P${pid}` }),
}),
deckList: '',
zoneList: [],
counterList: [],
arrowList: [],
}),
),
}),
);
});
await waitFor(() => {
expect(screen.getByLabelText('deck list')).toBeInTheDocument();
});
});
});

View file

@ -306,10 +306,22 @@ describe('game', () => {
userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }),
}),
zoneList: [
create(Data.ServerInfo_ZoneSchema, { name: 'deck', type: Data.ServerInfo_Zone_ZoneType.HiddenZone, cardList: deckCards, cardCount: 3 }),
create(Data.ServerInfo_ZoneSchema, { name: 'hand', type: Data.ServerInfo_Zone_ZoneType.HiddenZone, cardList: [], cardCount: 0 }),
create(Data.ServerInfo_ZoneSchema, { name: 'table', type: Data.ServerInfo_Zone_ZoneType.PublicZone, withCoords: true, cardList: [], cardCount: 0 }),
create(Data.ServerInfo_ZoneSchema, { name: 'grave', type: Data.ServerInfo_Zone_ZoneType.PublicZone, cardList: [], cardCount: 0 }),
create(Data.ServerInfo_ZoneSchema, {
name: 'deck', type: Data.ServerInfo_Zone_ZoneType.HiddenZone,
cardList: deckCards, cardCount: 3,
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'hand', type: Data.ServerInfo_Zone_ZoneType.HiddenZone,
cardList: [], cardCount: 0,
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'table', type: Data.ServerInfo_Zone_ZoneType.PublicZone,
withCoords: true, cardList: [], cardCount: 0,
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'grave', type: Data.ServerInfo_Zone_ZoneType.PublicZone,
cardList: [], cardCount: 0,
}),
],
counterList: [],
arrowList: [],
@ -356,7 +368,14 @@ describe('game', () => {
ext: Data.Event_GameSay_ext,
value: create(Data.Event_GameSaySchema, { message: 'good luck!' }),
}));
expect(store.getState().games.games[99].messages).toHaveLength(1);
// game.messages is a merged chat + event-log stream (matches desktop's
// MessageLogWidget). Earlier steps in this lifecycle (game-started,
// phase change, draw) also push event entries, so filter to chat.
const chatMessages = store
.getState()
.games.games[99].messages.filter((m) => m.kind === 'chat');
expect(chatMessages).toHaveLength(1);
expect(chatMessages[0].message).toBe('good luck!');
// ── 6. Discard (move card from hand to graveyard) ────────────────────
deliverMessage(buildGameEventMessage({
@ -413,4 +432,4 @@ describe('game', () => {
expect(store.getState().games.games[99].players[1]).toBeUndefined();
});
});
});