mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-06-10 00:04:48 -07:00
implement gameboard v1
This commit is contained in:
parent
b103db681b
commit
0d7336edc2
177 changed files with 16995 additions and 139 deletions
443
webclient/integration/src/app/game-board.spec.tsx
Normal file
443
webclient/integration/src/app/game-board.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue