Compare commits

...

11 commits

Author SHA1 Message Date
seavor
88489ea2eb fix tests 2026-04-20 22:17:32 -05:00
seavor
a75abe1454 fix login and known hosts 2026-04-20 21:24:43 -05:00
seavor
6074d9d6e4 Comprehensive review changes 2026-04-20 18:58:40 -05:00
seavor
3aa8c654cc use component hooks 2026-04-20 07:38:28 -05:00
seavor
515dff6d7b fix join game 2026-04-20 00:54:03 -05:00
seavor
2afa2922e9 websocket cleanup 2026-04-20 00:37:23 -05:00
seavor
2aeb1542b1 join game error dialog 2026-04-20 00:25:10 -05:00
seavor
db1530c9e9 fix login inputs 2026-04-20 00:17:58 -05:00
seavor
e045f498a8 fix knownhosts issue 2026-04-20 00:03:29 -05:00
seavor
5f28d43dff improve testing speed 2026-04-20 00:01:25 -05:00
seavor
0d7336edc2 implement gameboard v1 2026-04-19 23:21:42 -05:00
365 changed files with 23886 additions and 2467 deletions

View file

@ -12,6 +12,7 @@ const elements = [
{ type: 'services', pattern: ['src/services/**'] },
{ type: 'store', pattern: ['src/store/**'] },
{ type: 'types', pattern: ['src/types/**'] },
{ type: 'utils', pattern: ['src/utils/**'] },
{ type: 'websocket-types', pattern: ['src/websocket/types/**'] },
{ type: 'websocket', pattern: ['src/websocket/**'] },
];
@ -23,24 +24,25 @@ const rules = [
{ from: { type: 'websocket-types' }, allow: types('generated') },
{ from: { type: 'websocket' }, allow: types('generated', 'websocket-types') },
{ from: { type: 'types' }, allow: types('generated') },
{ from: { type: 'utils' }, allow: types('types') },
{ from: { type: 'store' }, allow: types('types', 'websocket-types') },
{ from: { type: 'api' }, allow: types('store', 'types', 'websocket', 'websocket-types') },
{ from: { type: 'store' }, allow: types('types', 'utils', 'websocket-types') },
{ from: { type: 'api' }, allow: types('store', 'types', 'utils', 'websocket', 'websocket-types') },
{ from: { type: 'images' }, allow: types('types') },
{ from: { type: 'services' }, allow: types('api', 'store', 'types') },
{ from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'websocket', 'websocket-types') },
{ from: { type: 'services' }, allow: types('api', 'store', 'types', 'utils') },
{ from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'utils', 'websocket', 'websocket-types') },
{
from: { type: 'components' },
allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'websocket-types')
allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'utils', 'websocket-types')
},
{
from: { type: 'containers' },
allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'websocket-types')
allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'utils', 'websocket-types')
},
{ from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types', 'websocket-types') },
{ from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types', 'websocket-types') },
{ from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types', 'utils', 'websocket-types') },
{ from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types', 'utils', 'websocket-types') },
];
export const boundariesConfig = [

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

@ -100,7 +100,12 @@ describe('connection lifecycle', () => {
vi.advanceTimersByTime(5000);
// Fire onclose the way a real browser would when the connection-attempt
// timer closes a still-connecting socket.
mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent);
expect(mock.close).toHaveBeenCalled();
// Never-opened sockets bypass reconnect and land on DISCONNECTED directly.
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
});
@ -111,12 +116,15 @@ describe('connection lifecycle', () => {
const mock = getMockWebSocket();
getWebClient().disconnect();
// The transport schedules close() synchronously; onclose follows in the
// browser event loop. Simulate it so the status transition fires.
mock.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent);
expect(mock.close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
});
it('drops pending commands and clears state on unexpected socket close', () => {
it('enters RECONNECTING on unexpected socket close after a successful handshake', () => {
connectAndHandshake();
// A login command is now pending (sent during handshake)
@ -127,6 +135,8 @@ describe('connection lifecycle', () => {
mock.readyState = 3;
mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent);
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
// With reconnect configured, a drop after a successful open enters the
// reconnect state machine rather than going straight to DISCONNECTED.
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.RECONNECTING);
});
});

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();
});
});
});

View file

@ -9,6 +9,8 @@
"version": "1.0.0",
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1",
"@mui/icons-material": "^9.0.0",
@ -53,6 +55,7 @@
"@typescript-eslint/eslint-plugin": "^8.58.2",
"@typescript-eslint/parser": "^8.58.2",
"@vitejs/plugin-react": "^6.0.1",
"@vitejs/plugin-react-swc": "^4.3.0",
"@vitest/coverage-v8": "^4.1.4",
"eslint": "^10.2.0",
"eslint-import-resolver-typescript": "^4.4.4",
@ -634,6 +637,45 @@
"node": ">=20.19.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
@ -1629,6 +1671,268 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/core": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.30.tgz",
"integrity": "sha512-R8VQbQY1BZcbIF2p3gjlTCwAQzx1A194ugWfwld5y+WgVVWqVKm7eURGGOVbQVubgKWzidP2agomBbg96rZilQ==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.30",
"@swc/core-darwin-x64": "1.15.30",
"@swc/core-linux-arm-gnueabihf": "1.15.30",
"@swc/core-linux-arm64-gnu": "1.15.30",
"@swc/core-linux-arm64-musl": "1.15.30",
"@swc/core-linux-ppc64-gnu": "1.15.30",
"@swc/core-linux-s390x-gnu": "1.15.30",
"@swc/core-linux-x64-gnu": "1.15.30",
"@swc/core-linux-x64-musl": "1.15.30",
"@swc/core-win32-arm64-msvc": "1.15.30",
"@swc/core-win32-ia32-msvc": "1.15.30",
"@swc/core-win32-x64-msvc": "1.15.30"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.30.tgz",
"integrity": "sha512-VvpP+vq08HmGYewMWvrdsxh9s2lthz/808zXm8Yu5kaqeR8Yia2b0eYXleHQ3VAjoStUDk6LzTheBW9KXYQdMA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.30.tgz",
"integrity": "sha512-WiJA0hiZI3nwQAO6mu5RqigtWGDtth4Hiq6rbZxAaQyhIcqKIg5IoMRc1Y071lrNJn29eEDMC86Rq58xgUxlDg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.30.tgz",
"integrity": "sha512-YANuFUo48kIT6plJgCD0keae9HFXfjxsbvsgevqc0hr/07X/p7sAWTFOGYEc2SXcASaK7UvuQqzlbW8pr7R79g==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.30.tgz",
"integrity": "sha512-VndG8jaR4ugY6u+iVOT0Q+d2fZd7sLgjPgN8W/Le+3EbZKl+cRfFxV7Eoz4gfLqhmneZPdcIzf9T3LkgkmqNLg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.30.tgz",
"integrity": "sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.30.tgz",
"integrity": "sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.30.tgz",
"integrity": "sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.30.tgz",
"integrity": "sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.30.tgz",
"integrity": "sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.30.tgz",
"integrity": "sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.30.tgz",
"integrity": "sha512-30VdLeGk6fugiUs/kUdJ/pAg7z/zpvVbR11RH60jZ0Z42WIeIniYx0rLEWN7h/pKJ3CopqsQ3RsogCAkRKiA2g==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.30.tgz",
"integrity": "sha512-4iObHPR+Q4oDY110EF5SF5eIaaVJNpMdG9C0q3Q92BsJ5y467uHz7sYQhP60WYlLFsLQ1el2YrIPUItUAQGOKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@swc/types": {
"version": "0.1.26",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
"integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@ -2409,6 +2713,23 @@
}
}
},
"node_modules/@vitejs/plugin-react-swc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.3.0.tgz",
"integrity": "sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-rc.7",
"@swc/core": "^1.15.11"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"vite": "^4 || ^5 || ^6 || ^7 || ^8"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz",

View file

@ -27,6 +27,8 @@
},
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1",
"@mui/icons-material": "^9.0.0",
@ -71,6 +73,7 @@
"@typescript-eslint/eslint-plugin": "^8.58.2",
"@typescript-eslint/parser": "^8.58.2",
"@vitejs/plugin-react": "^6.0.1",
"@vitejs/plugin-react-swc": "^4.3.0",
"@vitest/coverage-v8": "^4.1.4",
"eslint": "^10.2.0",
"eslint-import-resolver-typescript": "^4.4.4",

View file

@ -1,4 +1,10 @@
export { withMockLocation } from './globalGuards';
export { renderWithProviders } from './renderWithProviders';
export { createMockWebClient } from './mockWebClient';
export { disconnectedState, connectedState, connectedWithRoomsState, makeUser } from './storeFixtures';
export {
disconnectedState,
connectedState,
connectedWithRoomsState,
makeStoreState,
makeUser,
} from './storeFixtures';

View file

@ -0,0 +1,53 @@
import { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { configureStore, Reducer } from '@reduxjs/toolkit';
import { WebClientContext } from '../hooks/useWebClient';
import type { WebClient } from '../websocket';
import { createMockWebClient } from './mockWebClient';
// Minimal Provider wrapper for hook-only tests. Use this instead of
// `renderWithProviders` when you need `renderHook` — the full provider tree
// auto-instantiates the singleton store via `@app/store`, which races with
// any test-local store you preload. Deep-import the reducer(s) you need and
// pass them here (see useCurrentGame.spec.tsx for the canonical pattern).
export function makeReduxHookWrapper<S>(
reducer: Reducer<S>,
preloadedState: S,
) {
const store = configureStore({
reducer,
preloadedState: preloadedState as Parameters<typeof configureStore>[0]['preloadedState'],
});
function Wrapper({ children }: { children: ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
return { Wrapper, store };
}
export interface MakeReduxWebClientHookWrapperOptions<S> {
reducer: Reducer<S>;
preloadedState: S;
webClient?: WebClient;
}
export function makeReduxWebClientHookWrapper<S>({
reducer,
preloadedState,
webClient,
}: MakeReduxWebClientHookWrapperOptions<S>) {
const store = configureStore({
reducer,
preloadedState: preloadedState as Parameters<typeof configureStore>[0]['preloadedState'],
});
const client = webClient ?? createMockWebClient();
function Wrapper({ children }: { children: ReactNode }) {
return (
<Provider store={store}>
<WebClientContext value={client}>{children}</WebClientContext>
</Provider>
);
}
return { Wrapper, store, webClient: client };
}

View file

@ -34,10 +34,43 @@ export function createMockWebClient() {
leaveRoom: vi.fn(),
roomSay: vi.fn(),
createGame: vi.fn(),
joinGame: vi.fn(),
},
game: {
joinGame: vi.fn(),
leaveGame: vi.fn(),
kickFromGame: vi.fn(),
gameSay: vi.fn(),
readyStart: vi.fn(),
concede: vi.fn(),
unconcede: vi.fn(),
judge: vi.fn(),
nextTurn: vi.fn(),
setActivePhase: vi.fn(),
reverseTurn: vi.fn(),
moveCard: vi.fn(),
flipCard: vi.fn(),
attachCard: vi.fn(),
createToken: vi.fn(),
setCardAttr: vi.fn(),
setCardCounter: vi.fn(),
incCardCounter: vi.fn(),
drawCards: vi.fn(),
undoDraw: vi.fn(),
createArrow: vi.fn(),
deleteArrow: vi.fn(),
createCounter: vi.fn(),
setCounter: vi.fn(),
incCounter: vi.fn(),
delCounter: vi.fn(),
shuffle: vi.fn(),
dumpZone: vi.fn(),
revealCards: vi.fn(),
changeZoneProperties: vi.fn(),
deckSelect: vi.fn(),
setSideboardPlan: vi.fn(),
setSideboardLock: vi.fn(),
mulligan: vi.fn(),
rollDie: vi.fn(),
},
admin: {
adjustMod: vi.fn(),

View file

@ -6,13 +6,57 @@ import { MemoryRouter } from 'react-router-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { DndContext } from '@dnd-kit/core';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { gamesReducer } from '../store/game';
import { roomsReducer } from '../store/rooms';
import { serverReducer } from '../store/server';
import { actionReducer } from '../store/actions';
// Disables MUI's ripple animation AND all component transitions in tests.
// The ripple fires a deferred state update after clicks/focus that would
// trigger a noisy "update to ForwardRef(TouchRipple) was not wrapped in
// act(...)" warning. Transitions (Grow/Fade/Slide used by Menu, Dialog,
// Popover, Tooltip) default to ~225ms, which is pure wait-time in jsdom
// — every portal open paid this cost before. Zeroing `transitions.duration`
// plus the per-component `transitionDuration: 0` override belt-and-braces
// covers the full v9 surface: styled transitions read the theme; component-
// level Transition props need the defaultProps override.
const testTheme = createTheme({
transitions: {
duration: {
shortest: 0, shorter: 0, short: 0,
standard: 0, complex: 0,
enteringScreen: 0, leavingScreen: 0,
},
create: () => 'none',
},
components: {
MuiButtonBase: { defaultProps: { disableRipple: true } },
MuiDialog: { defaultProps: { transitionDuration: 0 } },
MuiMenu: { defaultProps: { transitionDuration: 0 } },
MuiPopover: { defaultProps: { transitionDuration: 0 } },
MuiTooltip: { defaultProps: { enterDelay: 0, leaveDelay: 0 } },
},
});
import { WebClientContext } from '../hooks/useWebClient';
import type { WebClient } from '../websocket';
import rootReducer from '../store/rootReducer';
import { ToastProvider } from '../components/Toast/ToastContext';
import { storeMiddlewareOptions } from '../store/store';
import type { RootState } from '../store/store';
import { createMockWebClient } from './mockWebClient';
// Lazy-initialized per test file (vitest isolate: true re-evaluates module
// graph per file). Reused by every `renderWithProviders` call that doesn't
// inject its own webClient, so the ~65 vi.fn() allocations happen once per
// file instead of once per render. The global `afterEach` in setupTests.ts
// runs `vi.clearAllMocks()` which resets call history between tests without
// destroying the fn instances — exactly what we want here.
let defaultWebClient: WebClient | undefined;
function getDefaultWebClient(): WebClient {
if (!defaultWebClient) {
defaultWebClient = createMockWebClient();
}
return defaultWebClient;
}
// Non-empty `resources` registers en-US so `resolvedLanguage` is defined;
// without it MUI warns about out-of-range Select values.
@ -24,15 +68,18 @@ testI18n.use(initReactI18next).init({
interpolation: { escapeValue: false },
});
// `configureStore`'s `preloadedState` wants `PreloadedState<CombinedState<…>>`
// which narrows collection types past our slice interfaces. A single cast
// here keeps the test harness loose (each test injects only the slices it
// cares about) while specs themselves stay strict via `makeStoreState`.
function createTestStore(preloadedState?: Partial<RootState>) {
return configureStore({
reducer: {
games: gamesReducer,
rooms: roomsReducer,
server: serverReducer,
action: actionReducer,
},
preloadedState: preloadedState as any,
reducer: rootReducer,
preloadedState: preloadedState as Parameters<typeof configureStore>[0]['preloadedState'],
// Share the production middleware config so the serializableCheck
// tolerates protobuf messages (isMessage) the same way the real store
// does — otherwise every proto-payload dispatch in tests spams stderr.
middleware: (getDefaultMiddleware) => getDefaultMiddleware(storeMiddlewareOptions),
});
}
@ -40,6 +87,7 @@ interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
preloadedState?: Partial<RootState>;
store?: EnhancedStore;
route?: string;
webClient?: WebClient;
}
export function renderWithProviders(
@ -48,6 +96,7 @@ export function renderWithProviders(
preloadedState,
store = createTestStore(preloadedState),
route = '/',
webClient = getDefaultWebClient(),
...renderOptions
}: ExtendedRenderOptions = {},
) {
@ -55,11 +104,21 @@ export function renderWithProviders(
return (
<Provider store={store}>
<I18nextProvider i18n={testI18n}>
<ToastProvider>
<MemoryRouter initialEntries={[route]}>
{children}
</MemoryRouter>
</ToastProvider>
<ThemeProvider theme={testTheme}>
<ToastProvider>
<MemoryRouter initialEntries={[route]}>
<WebClientContext value={webClient}>
<DndContext
accessibility={{
screenReaderInstructions: { draggable: '' },
}}
>
{children}
</DndContext>
</WebClientContext>
</MemoryRouter>
</ToastProvider>
</ThemeProvider>
</I18nextProvider>
</Provider>
);
@ -67,6 +126,7 @@ export function renderWithProviders(
return {
store,
webClient,
...render(ui, { wrapper: Wrapper, ...renderOptions }),
};
}

View file

@ -27,6 +27,7 @@ function makeUser(overrides: Partial<Data.ServerInfo_User> = {}): Data.ServerInf
export const disconnectedState: Partial<RootState> = {
server: {
initialized: false,
testConnectionStatus: null,
buddyList: {},
ignoreList: {},
status: {
@ -63,6 +64,10 @@ export const disconnectedState: Partial<RootState> = {
messages: {},
sortGamesBy: { field: App.GameSortField.START_TIME, order: App.SortDirection.DESC },
sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC },
selectedGameIds: {},
gameFilters: {},
joinGamePending: false,
joinGameError: null,
},
games: { games: {} },
action: { type: null, payload: null, meta: null, error: false, count: 0 },
@ -122,3 +127,27 @@ export const connectedWithRoomsState: Partial<RootState> = {
};
export { makeUser };
/**
* Deep-partial of a root state. Let specs pass partial slice shapes
* (typically just `games: { games: { ... } }`) without the ~60 fields of
* server/rooms that the test doesn't care about.
*/
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
/**
* Wraps a partial root-state literal with a safe single `as`-cast so specs
* don't need to sprinkle `as any` on every `preloadedState` argument. The
* runtime value is the exact same literal; the only thing this helper buys
* is deleting the `as any` cast from call sites.
*
* @example
* renderWithProviders(<MyComponent />, {
* preloadedState: makeStoreState({
* games: { games: { 1: makeGameEntry({ ... }) } },
* }),
* });
*/
export function makeStoreState(partial: DeepPartial<RootState>): Partial<RootState> {
return partial as Partial<RootState>;
}

View file

@ -15,11 +15,20 @@ interface AppAuthRequestOverrides extends WebsocketTypes.AuthRequestMap {
ForgotPasswordResetParams: Omit<WebsocketTypes.PasswordResetConnectOptions, 'reason'>;
}
const CONNECTING_STATUS_LABEL = 'Connecting...';
function beginConnect(
options: { host: string; port: string | number },
reason: WebsocketTypes.WebSocketConnectReason,
): void {
setPendingOptions({ ...options, reason });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, CONNECTING_STATUS_LABEL);
WebClient.instance.connect({ host: options.host, port: options.port });
}
export class AuthenticationRequestImpl implements WebsocketTypes.IAuthenticationRequest<AppAuthRequestOverrides> {
login(options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.LOGIN });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
beginConnect(options, WebsocketTypes.WebSocketConnectReason.LOGIN);
}
testConnection(options: Omit<WebsocketTypes.TestConnectionOptions, 'reason'>): void {
@ -27,33 +36,23 @@ export class AuthenticationRequestImpl implements WebsocketTypes.IAuthentication
}
register(options: Omit<WebsocketTypes.RegisterConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.REGISTER });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
beginConnect(options, WebsocketTypes.WebSocketConnectReason.REGISTER);
}
activateAccount(options: Omit<WebsocketTypes.ActivateConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
beginConnect(options, WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT);
}
resetPasswordRequest(options: Omit<WebsocketTypes.PasswordResetRequestConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST);
}
resetPasswordChallenge(options: Omit<WebsocketTypes.PasswordResetChallengeConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE);
}
resetPassword(options: Omit<WebsocketTypes.PasswordResetConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET);
}
disconnect(): void {

View file

@ -15,6 +15,14 @@ export class ModeratorRequestImpl implements WebsocketTypes.IModeratorRequest {
ModeratorCommands.banFromServer(minutes, userName, address, reason, visibleReason, clientid, removeMessages);
}
forceActivateUser(usernameToActivate: string, moderatorName: string): void {
ModeratorCommands.forceActivateUser(usernameToActivate, moderatorName);
}
getAdminNotes(userName: string): void {
ModeratorCommands.getAdminNotes(userName);
}
getBanHistory(userName: string): void {
ModeratorCommands.getBanHistory(userName);
}
@ -27,6 +35,14 @@ export class ModeratorRequestImpl implements WebsocketTypes.IModeratorRequest {
ModeratorCommands.getWarnList(modName, userName, userClientid);
}
grantReplayAccess(replayId: number, moderatorName: string): void {
ModeratorCommands.grantReplayAccess(replayId, moderatorName);
}
updateAdminNotes(userName: string, notes: string): void {
ModeratorCommands.updateAdminNotes(userName, notes);
}
viewLogHistory(filters: Data.ViewLogHistoryParams): void {
ModeratorCommands.viewLogHistory(filters);
}

View file

@ -1,5 +1,6 @@
import { RoomCommands, SessionCommands } from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types';
import type { App } from '@app/types';
export class RoomsRequestImpl implements WebsocketTypes.IRoomsRequest {
joinRoom(roomId: number): void {
@ -13,4 +14,12 @@ export class RoomsRequestImpl implements WebsocketTypes.IRoomsRequest {
roomSay(roomId: number, message: string): void {
RoomCommands.roomSay(roomId, message);
}
createGame(roomId: number, params: App.CreateGameParams): void {
RoomCommands.createGame(roomId, params);
}
joinGame(roomId: number, params: App.JoinGameParams): void {
RoomCommands.joinGame(roomId, params);
}
}

View file

@ -48,4 +48,12 @@ export class RoomResponseImpl implements WebsocketTypes.IRoomResponse<WebsocketT
joinedGame(roomId: number, gameId: number): void {
RoomsDispatch.joinedGame(roomId, gameId);
}
setJoinGamePending(pending: boolean): void {
RoomsDispatch.setJoinGamePending(pending);
}
setJoinGameError(code: number, message: string): void {
RoomsDispatch.setJoinGameError(code, message);
}
}

View file

@ -30,8 +30,8 @@ export class SessionResponseImpl implements WebsocketTypes.ISessionResponse {
ServerDispatch.connectionFailed();
}
testConnectionSuccessful(): void {
ServerDispatch.testConnectionSuccessful();
testConnectionSuccessful(supportsHashedPassword: boolean): void {
ServerDispatch.testConnectionSuccessful(supportsHashedPassword);
}
testConnectionFailed(): void {

25
webclient/src/colors.css Normal file
View file

@ -0,0 +1,25 @@
/**
* Shared CSS custom properties. Declared at :root so any stylesheet can
* reference them via var(--name). Mirrors the constants in
* src/types/colors.ts keep both in sync when adding new entries.
*
* Loaded once from src/index.tsx alongside the top-level stylesheet.
*/
:root {
/* Arrow / modifier colors — paired with App.ArrowColor.* in TS. */
--color-arrow-red: #e04b3b;
--color-arrow-yellow: #f0c83c;
--color-arrow-blue: #89b8e0;
--color-arrow-green: #3da26b;
--color-arrow-red-glow: rgba(224, 75, 59, 0.55);
--color-arrow-green-glow: rgba(61, 162, 107, 0.55);
/* Highlight yellow: active turn indicator, host crown, focus ring,
chat author, PhaseBar selection border. Kept as a single shade for
visual consistency across the game surface. */
--color-highlight-yellow: #f7b01c;
--color-highlight-yellow-soft: rgba(247, 176, 28, 0.4);
--color-highlight-yellow-soft-alt: rgba(247, 176, 28, 0.55);
}

View file

@ -3,6 +3,7 @@
"language": "English",
"disconnect": "Disconnect",
"label": {
"confirmEmail": "Confirm Email",
"confirmPassword": "Confirm Password",
"confirmSure": "Are you sure?",
"country": "Country",
@ -19,6 +20,7 @@
"username": "Username"
},
"validation": {
"emailsMustMatch": "Emails don't match",
"minChars": "Minimum of {count} {count, plural, one {character} other {characters}} required",
"passwordsMustMatch": "Passwords don't match",
"required": "Required"

View file

@ -1,6 +1,3 @@
// eslint-disable-next-line
import React, { useMemo, useState } from 'react';
import { CardDTO } from '@app/services';
import './Card.css';
@ -10,11 +7,13 @@ interface CardProps {
}
const Card = ({ card }: CardProps) => {
const src = `https://api.scryfall.com/cards/${card?.identifiers?.scryfallId}?format=image`;
if (!card) {
return null;
}
return card && (
<img className="card" src={src} alt={card?.name} />
);
}
const src = `https://api.scryfall.com/cards/${card.identifiers?.scryfallId}?format=image`;
return <img className="card" src={src} alt={card.name} />;
};
export default Card;

View file

@ -1,6 +1,3 @@
// eslint-disable-next-line
import React, { useMemo, useState } from 'react';
import { CardDTO } from '@app/services';
import Card from '../Card/Card';
@ -33,7 +30,7 @@ const CardDetails = ({ card }: CardProps) => {
(!card.power && !card.toughness) ? null : (
<div className='cardDetails-attribute'>
<span className='cardDetails-attribute__label'>P/T:</span>
<span className='cardDetails-attribute__value'>{card.power || 0}/{card.toughness || 0}</span>
<span className='cardDetails-attribute__value'>{card.power ?? 0}/{card.toughness ?? 0}</span>
</div>
)
}

View file

@ -1,21 +1,28 @@
import React from 'react';
import Checkbox from '@mui/material/Checkbox';
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
const CheckboxField = (props) => {
const { input: { value, onChange }, label, ...args } = props;
import type { FinalFormFieldProps } from '../fieldTypes';
type CheckboxFieldProps = FinalFormFieldProps<boolean, HTMLInputElement> & {
label?: string;
} & Omit<CheckboxProps, 'checked' | 'onChange' | 'onBlur' | 'onFocus' | 'name' | 'value'>;
const CheckboxField = ({ input, meta: _meta, label, ...args }: CheckboxFieldProps) => {
const { value, onChange, onBlur, onFocus, name } = input;
// @TODO this isnt unchecking properly
return (
<FormControlLabel
className="checkbox-field"
label={label}
label={label ?? ''}
control={
<Checkbox
{ ...args }
{...args}
className="checkbox-field__box"
checked={!!value}
onChange={(e, checked) => onChange(checked)}
name={name}
checked={Boolean(value)}
onChange={onChange}
onBlur={onBlur}
onFocus={onFocus}
color="primary"
/>
}

View file

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { Select, MenuItem } from '@mui/material';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
@ -8,49 +7,48 @@ import { useLocaleSort } from '@app/hooks';
import { Images } from '@app/images';
import { App } from '@app/types';
import type { FinalFormFieldProps } from '../fieldTypes';
import './CountryDropdown.css';
const CountryDropdown = ({ input: { onChange } }) => {
const [value, setValue] = useState('');
type CountryDropdownProps = FinalFormFieldProps<string, HTMLElement>;
const CountryDropdown = ({ input }: CountryDropdownProps) => {
const { t } = useTranslation();
const currentValue = (input.value as string | undefined) ?? '';
useEffect(() => onChange(value), [value]);
const translateCountry = country => t(`Common.countries.${country}`);
const translateCountry = (country: string) => t(`Common.countries.${country}`);
const sortedCountries = useLocaleSort(App.countryCodes, translateCountry);
return (
<FormControl size='small' variant='outlined' className='CountryDropdown'>
<InputLabel id='CountryDropdown-select'>Country</InputLabel>
<FormControl size="small" variant="outlined" className="CountryDropdown">
<InputLabel id="CountryDropdown-label">Country</InputLabel>
<Select
id='CountryDropdown-select'
labelId='CountryDropdown-label'
label='Country'
margin='dense'
value={value}
fullWidth={true}
onChange={e => setValue(e.target.value as string)}
id="CountryDropdown-select"
labelId="CountryDropdown-label"
label="Country"
margin="dense"
fullWidth
{...input}
value={currentValue}
>
<MenuItem value={''} key={-1}>
<MenuItem value="" key="none">
<div className="CountryDropdown-item">
<span className="CountryDropdown-item__label">None</span>
</div>
</MenuItem>
{
sortedCountries.map((country, index:number) => (
<MenuItem value={country} key={index}>
<div className="CountryDropdown-item">
<img className="CountryDropdown-item__image" src={Images.Countries[country.toLowerCase()]} />
<span className="CountryDropdown-item__label">{translateCountry(country)}</span>
</div>
</MenuItem>
))
}
{sortedCountries.map(country => (
<MenuItem value={country} key={country}>
<div className="CountryDropdown-item">
<img className="CountryDropdown-item__image" src={Images.Countries[country.toLowerCase()]} />
<span className="CountryDropdown-item__label">{translateCountry(country)}</span>
</div>
</MenuItem>
))}
</Select>
</FormControl>
)
);
};
export default CountryDropdown;

View file

@ -0,0 +1,29 @@
.battlefield {
display: flex;
flex-direction: column;
height: 100%;
background: #0f1c38;
border: 1px solid #1a2b52;
box-sizing: border-box;
}
.battlefield__row {
flex: 1 1 0;
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
padding: 4px 8px;
box-sizing: border-box;
overflow-x: auto;
overflow-y: hidden;
}
.battlefield__row + .battlefield__row {
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.battlefield__row--drop-over {
background: rgba(247, 176, 28, 0.08);
box-shadow: inset 0 0 0 2px rgba(247, 176, 28, 0.55);
}

View file

@ -0,0 +1,196 @@
import { screen } from '@testing-library/react';
import { App } from '@app/types';
vi.mock('../../../hooks/useSettings');
import { useSettings } from '../../../hooks/useSettings';
import { makeSettings, makeSettingsHook } from '../../../hooks/__mocks__/useSettings';
import { makeStoreState, renderWithProviders } from '../../../__test-utils__';
import {
makeCard,
makeGameEntry,
makePlayerEntry,
makeZoneEntry,
} from '../../../store/game/__mocks__/fixtures';
import Battlefield from './Battlefield';
function setInvert(invert: boolean) {
vi.mocked(useSettings).mockReturnValue(
makeSettingsHook({ value: makeSettings({ invertVerticalCoordinate: invert }) }),
);
}
function stateWithBattlefield(cards: ReturnType<typeof makeCard>[]) {
const table = makeZoneEntry({
name: App.ZoneName.TABLE,
type: 1,
withCoords: true,
cardCount: cards.length,
cards,
});
const player = makePlayerEntry({
zones: { [App.ZoneName.TABLE]: table },
});
const game = makeGameEntry({
localPlayerId: 1,
players: { 1: player },
});
return makeStoreState({ games: { games: { 1: game } } });
}
describe('Battlefield', () => {
beforeEach(() => {
vi.mocked(useSettings).mockReturnValue(makeSettingsHook());
});
it('renders three rows regardless of card count', () => {
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield([]),
});
expect(screen.getByTestId('battlefield-row-0')).toBeInTheDocument();
expect(screen.getByTestId('battlefield-row-1')).toBeInTheDocument();
expect(screen.getByTestId('battlefield-row-2')).toBeInTheDocument();
});
it('places cards into rows by y coordinate', () => {
const cards = [
makeCard({ id: 1, name: 'Top', x: 0, y: 0 }),
makeCard({ id: 2, name: 'Mid', x: 0, y: 1 }),
makeCard({ id: 3, name: 'Bot', x: 0, y: 2 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
expect(screen.getByTestId('battlefield-row-0').querySelector('img')?.alt).toBe('Top');
expect(screen.getByTestId('battlefield-row-1').querySelector('img')?.alt).toBe('Mid');
expect(screen.getByTestId('battlefield-row-2').querySelector('img')?.alt).toBe('Bot');
});
it('clamps out-of-range y values into the three-row space', () => {
const cards = [
makeCard({ id: 1, name: 'TooHigh', x: 0, y: -5 }),
makeCard({ id: 2, name: 'TooLow', x: 0, y: 99 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
expect(screen.getByTestId('battlefield-row-0').querySelector('img')?.alt).toBe('TooHigh');
expect(screen.getByTestId('battlefield-row-2').querySelector('img')?.alt).toBe('TooLow');
});
it('sorts cards within a row by x coordinate', () => {
const cards = [
makeCard({ id: 1, name: 'Right', x: 10, y: 0 }),
makeCard({ id: 2, name: 'Left', x: 0, y: 0 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
const row0 = screen.getByTestId('battlefield-row-0');
const imgs = Array.from(row0.querySelectorAll('img'));
expect(imgs.map((i) => i.alt)).toEqual(['Left', 'Right']);
});
it('renders rows top-to-bottom as 0,1,2 when not mirrored', () => {
const cards = [
makeCard({ id: 1, name: 'A', x: 0, y: 0 }),
makeCard({ id: 2, name: 'B', x: 0, y: 1 }),
makeCard({ id: 3, name: 'C', x: 0, y: 2 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
const rowsInOrder = Array.from(
screen.getByTestId('battlefield').querySelectorAll('.battlefield__row'),
);
expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['0', '1', '2']);
});
it('renders rows bottom-to-top as 2,1,0 when mirrored (opponent)', () => {
const cards = [
makeCard({ id: 1, name: 'A', x: 0, y: 0 }),
makeCard({ id: 2, name: 'B', x: 0, y: 1 }),
makeCard({ id: 3, name: 'C', x: 0, y: 2 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} mirrored />, {
preloadedState: stateWithBattlefield(cards),
});
const rowsInOrder = Array.from(
screen.getByTestId('battlefield').querySelectorAll('.battlefield__row'),
);
expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['2', '1', '0']);
});
it('passes inverted=true to every CardSlot when mirrored', () => {
const cards = [makeCard({ id: 1, name: 'A', x: 0, y: 0 })];
const { container } = renderWithProviders(
<Battlefield gameId={1} playerId={1} mirrored />,
{ preloadedState: stateWithBattlefield(cards) },
);
expect(container.querySelector('.card-slot--inverted')).not.toBeNull();
});
describe('invertVerticalCoordinate user setting', () => {
it('renders rows bottom-to-top when the setting is on and not mirrored (local player)', () => {
setInvert(true);
const cards = [
makeCard({ id: 1, name: 'A', x: 0, y: 0 }),
makeCard({ id: 2, name: 'B', x: 0, y: 1 }),
makeCard({ id: 3, name: 'C', x: 0, y: 2 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
const rowsInOrder = Array.from(
screen.getByTestId('battlefield').querySelectorAll('.battlefield__row'),
);
expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['2', '1', '0']);
});
it('restores top-to-bottom ordering when setting is on AND mirrored (XOR cancels)', () => {
setInvert(true);
const cards = [
makeCard({ id: 1, name: 'A', x: 0, y: 0 }),
makeCard({ id: 2, name: 'B', x: 0, y: 1 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} mirrored />, {
preloadedState: stateWithBattlefield(cards),
});
const rowsInOrder = Array.from(
screen.getByTestId('battlefield').querySelectorAll('.battlefield__row'),
);
expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['0', '1', '2']);
});
it('passes inverted=true to CardSlots when setting is on and not mirrored', () => {
setInvert(true);
const cards = [makeCard({ id: 1, name: 'A', x: 0, y: 0 })];
const { container } = renderWithProviders(
<Battlefield gameId={1} playerId={1} />,
{ preloadedState: stateWithBattlefield(cards) },
);
expect(container.querySelector('.card-slot--inverted')).not.toBeNull();
});
it('passes inverted=false to CardSlots when setting is on AND mirrored (XOR)', () => {
setInvert(true);
const cards = [makeCard({ id: 1, name: 'A', x: 0, y: 0 })];
const { container } = renderWithProviders(
<Battlefield gameId={1} playerId={1} mirrored />,
{ preloadedState: stateWithBattlefield(cards) },
);
expect(container.querySelector('.card-slot--inverted')).toBeNull();
});
});
});

View file

@ -0,0 +1,63 @@
import { App, Data } from '@app/types';
import CardSlot from '../CardSlot/CardSlot';
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
import BattlefieldRow from './BattlefieldRow';
import { useBattlefield } from './useBattlefield';
import './Battlefield.css';
export interface BattlefieldProps {
gameId: number;
playerId: number;
mirrored?: boolean;
canAct?: boolean;
arrowSourceKey?: string | null;
onCardHover?: (card: Data.ServerInfo_Card) => void;
onCardClick?: (playerId: number, zone: string, card: Data.ServerInfo_Card) => void;
onCardContextMenu?: (card: Data.ServerInfo_Card, event: React.MouseEvent) => void;
onCardDoubleClick?: (card: Data.ServerInfo_Card) => void;
}
function Battlefield({
gameId,
playerId,
mirrored = false,
canAct = false,
arrowSourceKey = null,
onCardHover,
onCardClick,
onCardContextMenu,
onCardDoubleClick,
}: BattlefieldProps) {
const { rows, rowOrder, isInverted } = useBattlefield({ gameId, playerId, mirrored });
return (
<div className="battlefield" data-testid="battlefield">
{rowOrder.map((rowIdx) => (
<BattlefieldRow key={rowIdx} playerId={playerId} row={rowIdx}>
{rows[rowIdx].map((card) => {
const key = makeCardKey(playerId, App.ZoneName.TABLE, card.id);
return (
<CardSlot
key={card.id}
card={card}
inverted={isInverted}
draggable={canAct}
ownerPlayerId={playerId}
zone={App.ZoneName.TABLE}
isArrowSource={arrowSourceKey === key}
onMouseEnter={onCardHover}
onClick={(c) => onCardClick?.(playerId, App.ZoneName.TABLE, c)}
onContextMenu={onCardContextMenu}
onDoubleClick={onCardDoubleClick}
/>
);
})}
</BattlefieldRow>
))}
</div>
);
}
export default Battlefield;

View file

@ -0,0 +1,30 @@
import { ReactNode } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { App } from '@app/types';
import { cx } from '@app/utils';
export interface BattlefieldRowProps {
playerId: number;
row: number;
children: ReactNode;
}
function BattlefieldRow({ playerId, row, children }: BattlefieldRowProps) {
const { setNodeRef, isOver } = useDroppable({
id: `battlefield-${playerId}-${row}`,
data: { targetPlayerId: playerId, targetZone: App.ZoneName.TABLE, row },
});
return (
<div
ref={setNodeRef}
className={cx('battlefield__row', { 'battlefield__row--drop-over': isOver })}
data-row={row}
data-testid={`battlefield-row-${row}`}
>
{children}
</div>
);
}
export default BattlefieldRow;

View file

@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
import { useSettings } from '@app/hooks';
export interface Battlefield {
rows: Data.ServerInfo_Card[][];
rowOrder: number[];
isInverted: boolean;
}
export interface UseBattlefieldArgs {
gameId: number;
playerId: number;
mirrored: boolean;
}
const ROW_COUNT = 3;
function rowIndexFor(card: Data.ServerInfo_Card): number {
const y = card.y ?? 0;
return Math.max(0, Math.min(ROW_COUNT - 1, y));
}
export function useBattlefield({ gameId, playerId, mirrored }: UseBattlefieldArgs): Battlefield {
const cards = useAppSelector((state) =>
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.TABLE),
);
const { value: settings } = useSettings();
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
// Mirrors desktop TableZone::isInverted() — XOR of per-player mirrored and
// the global invertVerticalCoordinate preference.
const isInverted = mirrored !== invertVerticalCoordinate;
const rows = useMemo<Data.ServerInfo_Card[][]>(() => {
const bucketed: Data.ServerInfo_Card[][] = Array.from({ length: ROW_COUNT }, () => []);
for (const card of cards) {
bucketed[rowIndexFor(card)].push(card);
}
for (const row of bucketed) {
row.sort((a, b) => (a.x ?? 0) - (b.x ?? 0));
}
return bucketed;
}, [cards]);
const rowOrder = isInverted ? [2, 1, 0] : [0, 1, 2];
return { rows, rowOrder, isInverted };
}

View file

@ -0,0 +1,3 @@
.card-context-menu .MuiPaper-root {
min-width: 220px;
}

View file

@ -0,0 +1,396 @@
import { screen, fireEvent } from '@testing-library/react';
import { App, Data } from '@app/types';
import { createMockWebClient, renderWithProviders } from '../../../__test-utils__';
import { makeCard } from '../../../store/game/__mocks__/fixtures';
import CardContextMenu from './CardContextMenu';
const defaultProps = {
isOpen: true,
anchorPosition: { top: 100, left: 100 },
gameId: 1,
localPlayerId: 1,
ownerPlayerId: 1,
sourceZone: App.ZoneName.TABLE,
onClose: () => {},
onRequestSetPT: () => {},
onRequestSetAnnotation: () => {},
onRequestSetCounter: () => {},
onRequestDrawArrow: () => {},
onRequestAttach: () => {},
onRequestMoveToLibraryAt: () => {},
};
describe('CardContextMenu', () => {
it('does not render when card is null', () => {
const { container } = renderWithProviders(
<CardContextMenu {...defaultProps} card={null} />,
);
expect(container.querySelector('[data-testid="card-context-menu"]')).toBeNull();
});
it('does not render when closed', () => {
renderWithProviders(
<CardContextMenu {...defaultProps} isOpen={false} card={makeCard()} />,
);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});
it('renders all expected menu items', () => {
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ tapped: false, faceDown: false })} />,
);
expect(screen.getByText('Flip')).toBeInTheDocument();
expect(screen.getByText('Tap')).toBeInTheDocument();
expect(screen.getByText('Face Down')).toBeInTheDocument();
expect(screen.getByText('Doesn\'t Untap')).toBeInTheDocument();
expect(screen.getByText('Set P/T…')).toBeInTheDocument();
expect(screen.getByText('Set Annotation…')).toBeInTheDocument();
expect(screen.getByText('Send to Hand')).toBeInTheDocument();
expect(screen.getByText('Send to Graveyard')).toBeInTheDocument();
expect(screen.getByText('Send to Exile')).toBeInTheDocument();
expect(screen.getByText('Send to Library (top)')).toBeInTheDocument();
expect(screen.getByText('Send to Library (bottom)')).toBeInTheDocument();
});
it('flips the card via flipCard and closes the menu', () => {
const webClient = createMockWebClient();
const onClose = vi.fn();
const card = makeCard({ id: 10, faceDown: false });
renderWithProviders(
<CardContextMenu {...defaultProps} card={card} onClose={onClose} />,
{ webClient },
);
fireEvent.click(screen.getByText('Flip'));
expect(webClient.request.game.flipCard).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 10,
faceDown: true,
});
expect(onClose).toHaveBeenCalled();
});
it('toggles tap via setCardAttr (untapped → tapped)', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 5, tapped: false })} />,
{ webClient },
);
fireEvent.click(screen.getByText('Tap'));
expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 5,
attribute: Data.CardAttribute.AttrTapped,
attrValue: '1',
});
});
it('shows Untap label and sends "0" when the card is already tapped', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 5, tapped: true })} />,
{ webClient },
);
expect(screen.getByText('Untap')).toBeInTheDocument();
fireEvent.click(screen.getByText('Untap'));
expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 5,
attribute: Data.CardAttribute.AttrTapped,
attrValue: '0',
});
});
it('toggles Face Down and shows Face Up when already face-down', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 5, faceDown: true })} />,
{ webClient },
);
expect(screen.getByText('Face Up')).toBeInTheDocument();
fireEvent.click(screen.getByText('Face Up'));
expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 5,
attribute: Data.CardAttribute.AttrFaceDown,
attrValue: '0',
});
});
it('toggles Doesn\'t Untap and shows Allow Untap when already set', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 5, doesntUntap: true })} />,
{ webClient },
);
expect(screen.getByText('Allow Untap')).toBeInTheDocument();
fireEvent.click(screen.getByText('Allow Untap'));
expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 5,
attribute: Data.CardAttribute.AttrDoesntUntap,
attrValue: '0',
});
});
it('requests the PT prompt via parent callback', () => {
const onRequestSetPT = vi.fn();
const onClose = vi.fn();
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard()}
onRequestSetPT={onRequestSetPT}
onClose={onClose}
/>,
);
fireEvent.click(screen.getByText('Set P/T…'));
expect(onRequestSetPT).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('requests the Annotation prompt via parent callback', () => {
const onRequestSetAnnotation = vi.fn();
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard()}
onRequestSetAnnotation={onRequestSetAnnotation}
/>,
);
fireEvent.click(screen.getByText('Set Annotation…'));
expect(onRequestSetAnnotation).toHaveBeenCalled();
});
it('moves to hand via moveCard with x=-1 (append)', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 7 })} />,
{ webClient },
);
fireEvent.click(screen.getByText('Send to Hand'));
expect(webClient.request.game.moveCard).toHaveBeenCalledWith(1, {
startPlayerId: 1,
startZone: App.ZoneName.TABLE,
cardsToMove: { card: [{ cardId: 7 }] },
targetPlayerId: 1,
targetZone: App.ZoneName.HAND,
x: -1,
y: 0,
isReversed: false,
});
});
it('hides mutator items (tap, flip, move, counters, P/T) for opponent-owned cards (desktop parity)', () => {
renderWithProviders(
<CardContextMenu
{...defaultProps}
localPlayerId={1}
ownerPlayerId={2}
card={makeCard({ id: 7 })}
/>,
);
// Mutators gone:
expect(screen.queryByText('Flip')).not.toBeInTheDocument();
expect(screen.queryByText('Tap')).not.toBeInTheDocument();
expect(screen.queryByText('Set P/T…')).not.toBeInTheDocument();
expect(screen.queryByText('Set counter…')).not.toBeInTheDocument();
expect(screen.queryByText('Send to Hand')).not.toBeInTheDocument();
expect(screen.queryByText('Attach to card…')).not.toBeInTheDocument();
// Read-only stays:
expect(screen.getByText('Draw arrow from here')).toBeInTheDocument();
});
it('routes moves through the acting (local) player when invoked on an owned card', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu
{...defaultProps}
localPlayerId={1}
ownerPlayerId={1}
card={makeCard({ id: 7 })}
/>,
{ webClient },
);
fireEvent.click(screen.getByText('Send to Hand'));
expect(webClient.request.game.moveCard).toHaveBeenCalledWith(1, expect.objectContaining({
startPlayerId: 1,
targetPlayerId: 1,
}));
});
it('moves to library top vs bottom with distinct x values', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 7 })} />,
{ webClient },
);
fireEvent.click(screen.getByText('Send to Library (top)'));
expect(webClient.request.game.moveCard).toHaveBeenLastCalledWith(1, expect.objectContaining({
targetZone: App.ZoneName.DECK,
x: 0,
}));
fireEvent.click(screen.getByText('Send to Library (bottom)'));
expect(webClient.request.game.moveCard).toHaveBeenLastCalledWith(1, expect.objectContaining({
targetZone: App.ZoneName.DECK,
x: -1,
}));
});
it('adds a counter via incCardCounter (+1 on id 0)', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 9 })} />,
{ webClient },
);
fireEvent.click(screen.getByText('Add counter'));
expect(webClient.request.game.incCardCounter).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 9,
counterId: 0,
counterDelta: 1,
});
});
it('removes a counter via incCardCounter (-1 on id 0)', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 9 })} />,
{ webClient },
);
fireEvent.click(screen.getByText('Remove counter'));
expect(webClient.request.game.incCardCounter).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 9,
counterId: 0,
counterDelta: -1,
});
});
it('defers "Set counter…" to the parent callback', () => {
const onRequestSetCounter = vi.fn();
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard()}
onRequestSetCounter={onRequestSetCounter}
/>,
);
fireEvent.click(screen.getByText('Set counter…'));
expect(onRequestSetCounter).toHaveBeenCalled();
});
it('defers "Draw arrow from here" to the parent callback', () => {
const onRequestDrawArrow = vi.fn();
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard()}
onRequestDrawArrow={onRequestDrawArrow}
/>,
);
fireEvent.click(screen.getByText('Draw arrow from here'));
expect(onRequestDrawArrow).toHaveBeenCalled();
});
describe('Attach / Unattach', () => {
it('defers "Attach to card…" to the parent callback', () => {
const onRequestAttach = vi.fn();
const onClose = vi.fn();
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard()}
onRequestAttach={onRequestAttach}
onClose={onClose}
/>,
);
fireEvent.click(screen.getByText('Attach to card…'));
expect(onRequestAttach).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('does not show "Unattach" when the card is not attached (attachCardId = -1)', () => {
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard({ attachCardId: -1 })}
/>,
);
expect(screen.queryByText('Unattach')).not.toBeInTheDocument();
});
it('shows "Unattach" and dispatches attachCard with only startZone+cardId (desktop parity)', () => {
const webClient = createMockWebClient();
const onClose = vi.fn();
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard({ id: 11, attachCardId: 99 })}
onClose={onClose}
/>,
{ webClient },
);
fireEvent.click(screen.getByText('Unattach'));
// Target fields are intentionally absent. The server uses proto2
// presence (`has_target_player_id()`) to detect "detach"; passing
// targetPlayerId: -1 would leave presence set and the server would
// treat the message as an attach with a missing player.
expect(webClient.request.game.attachCard).toHaveBeenCalledWith(1, {
startZone: App.ZoneName.TABLE,
cardId: 11,
});
expect(onClose).toHaveBeenCalled();
});
it('hides Attach / Unattach when the source card is not on the table', () => {
renderWithProviders(
<CardContextMenu
{...defaultProps}
sourceZone={App.ZoneName.HAND}
card={makeCard({ attachCardId: 99 })}
/>,
);
expect(screen.queryByText('Attach to card…')).not.toBeInTheDocument();
expect(screen.queryByText('Unattach')).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,107 @@
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import { Data } from '@app/types';
import { useCardContextMenu } from './useCardContextMenu';
import './CardContextMenu.css';
export interface CardContextMenuProps {
isOpen: boolean;
anchorPosition: { top: number; left: number } | null;
gameId: number;
localPlayerId: number | null;
card: Data.ServerInfo_Card | null;
ownerPlayerId: number | null;
sourceZone: string | null;
onClose: () => void;
onRequestSetPT: () => void;
onRequestSetAnnotation: () => void;
onRequestSetCounter: () => void;
onRequestDrawArrow: () => void;
onRequestAttach: () => void;
onRequestMoveToLibraryAt: () => void;
}
function CardContextMenu(props: CardContextMenuProps) {
const { isOpen, anchorPosition, card, onClose } = props;
const {
ready,
isOwnedByLocal,
canAttach,
isAttached,
moveTargets,
handleFlip,
handleTapToggle,
handleFaceDownToggle,
handleDoesntUntapToggle,
handleSetPT,
handleSetAnnotation,
handleCardCounterDelta,
handleSetCardCounter,
handleDrawArrow,
handleAttach,
handleUnattach,
handleMove,
handleMoveToLibraryAt,
} = useCardContextMenu(props);
if (!ready || !card) {
return null;
}
return (
<Menu
open={isOpen}
onClose={onClose}
anchorReference="anchorPosition"
anchorPosition={anchorPosition ?? undefined}
data-testid="card-context-menu"
className="card-context-menu"
>
{isOwnedByLocal && (
<>
<MenuItem onClick={handleFlip}>Flip</MenuItem>
<MenuItem onClick={handleTapToggle}>{card.tapped ? 'Untap' : 'Tap'}</MenuItem>
<MenuItem onClick={handleFaceDownToggle}>
{card.faceDown ? 'Face Up' : 'Face Down'}
</MenuItem>
<MenuItem onClick={handleDoesntUntapToggle}>
{card.doesntUntap ? 'Allow Untap' : 'Doesn\'t Untap'}
</MenuItem>
<MenuItem onClick={handleSetPT}>Set P/T</MenuItem>
<MenuItem onClick={handleSetAnnotation}>Set Annotation</MenuItem>
<Divider />
<MenuItem onClick={() => handleCardCounterDelta(+1)}>Add counter</MenuItem>
<MenuItem onClick={() => handleCardCounterDelta(-1)}>Remove counter</MenuItem>
<MenuItem onClick={handleSetCardCounter}>Set counter</MenuItem>
<Divider />
</>
)}
<MenuItem onClick={handleDrawArrow}>Draw arrow from here</MenuItem>
{isOwnedByLocal && canAttach && (
<MenuItem onClick={handleAttach}>Attach to card</MenuItem>
)}
{isOwnedByLocal && canAttach && isAttached && (
<MenuItem onClick={handleUnattach}>Unattach</MenuItem>
)}
{isOwnedByLocal && (
<>
<Divider />
{moveTargets.map((t) => (
<MenuItem key={t.label} onClick={() => handleMove(t)}>
{t.label}
</MenuItem>
))}
<MenuItem onClick={handleMoveToLibraryAt}>
Move to library at position
</MenuItem>
</>
)}
</Menu>
);
}
export default CardContextMenu;

View file

@ -0,0 +1,239 @@
import { useWebClient } from '@app/hooks';
import { App, Data } from '@app/types';
interface MoveTarget {
label: string;
zone: string;
x: number;
y: number;
}
// Mirrors desktop's cockatrice/src/game/player/menu/move_menu.cpp:32-42 —
// six fixed targets plus one prompt ("Move to library at position…") for the
// 7-entry parity. Note that desktop's "Send to Table" label maps to our
// "Send to Battlefield" (same wire semantics: zone=table, x=0, y=0); the
// label diverges but the command is identical.
export const CARD_MOVE_TARGETS: ReadonlyArray<MoveTarget> = [
{ label: 'Send to Hand', zone: App.ZoneName.HAND, x: -1, y: 0 },
{ label: 'Send to Battlefield', zone: App.ZoneName.TABLE, x: 0, y: 0 },
{ label: 'Send to Graveyard', zone: App.ZoneName.GRAVE, x: 0, y: 0 },
{ label: 'Send to Exile', zone: App.ZoneName.EXILE, x: 0, y: 0 },
{ label: 'Send to Library (top)', zone: App.ZoneName.DECK, x: 0, y: 0 },
{ label: 'Send to Library (bottom)', zone: App.ZoneName.DECK, x: -1, y: 0 },
];
export interface CardContextMenu {
ready: boolean;
isOwnedByLocal: boolean;
canAttach: boolean;
isAttached: boolean;
moveTargets: ReadonlyArray<MoveTarget>;
handleFlip: () => void;
handleTapToggle: () => void;
handleFaceDownToggle: () => void;
handleDoesntUntapToggle: () => void;
handleSetPT: () => void;
handleSetAnnotation: () => void;
handleCardCounterDelta: (delta: number) => void;
handleSetCardCounter: () => void;
handleDrawArrow: () => void;
handleAttach: () => void;
handleUnattach: () => void;
handleMove: (target: MoveTarget) => void;
handleMoveToLibraryAt: () => void;
}
export interface UseCardContextMenuArgs {
gameId: number;
localPlayerId: number | null;
card: Data.ServerInfo_Card | null;
ownerPlayerId: number | null;
sourceZone: string | null;
onClose: () => void;
onRequestSetPT: () => void;
onRequestSetAnnotation: () => void;
onRequestSetCounter: () => void;
onRequestDrawArrow: () => void;
onRequestAttach: () => void;
onRequestMoveToLibraryAt: () => void;
}
export function useCardContextMenu({
gameId,
localPlayerId,
card,
ownerPlayerId,
sourceZone,
onClose,
onRequestSetPT,
onRequestSetAnnotation,
onRequestSetCounter,
onRequestDrawArrow,
onRequestAttach,
onRequestMoveToLibraryAt,
}: UseCardContextMenuArgs): CardContextMenu {
const webClient = useWebClient();
const ready = card != null && ownerPlayerId != null && sourceZone != null && localPlayerId != null;
// Mutating actions (tap, flip, counters, attrs, P/T, annotation, attach,
// move) require ownership of the card — matches desktop's
// `card_menu.cpp:151-161` which drops all mutators when the menu target
// isn't getLocalOrJudge()-modifiable. Read-only actions (Draw arrow)
// stay available for planning/communication.
const isOwnedByLocal = ready && ownerPlayerId === localPlayerId;
const isAttached = ready && (card!.attachCardId ?? -1) >= 0;
// Desktop's actAttach is only available from a table card; other zones
// never expose the attach arrow.
const canAttach = ready && sourceZone === App.ZoneName.TABLE;
const setAttr = (attribute: Data.CardAttribute, value: string) => {
if (!ready) {
return;
}
webClient.request.game.setCardAttr(gameId, {
zone: sourceZone!,
cardId: card!.id,
attribute,
attrValue: value,
});
};
const handleFlip = () => {
if (!ready) {
return;
}
// TODO(card-db): desktop's Player::actCardMenuFlip reads the card's stored
// P/T and forwards it so the revealed side shows the correct stats
// (cockatrice/src/game/player/player_actions.cpp:1805-1810). We can't
// do that without a card-database-by-name lookup, which isn't wired in
// the webclient yet. The server re-derives PT from the card DB for known
// names, so omitting `pt` is harmless for non-custom cards.
webClient.request.game.flipCard(gameId, {
zone: sourceZone!,
cardId: card!.id,
faceDown: !card!.faceDown,
});
onClose();
};
const handleTapToggle = () => {
if (!ready) {
return;
}
setAttr(Data.CardAttribute.AttrTapped, card!.tapped ? '0' : '1');
onClose();
};
const handleFaceDownToggle = () => {
if (!ready) {
return;
}
setAttr(Data.CardAttribute.AttrFaceDown, card!.faceDown ? '0' : '1');
onClose();
};
const handleDoesntUntapToggle = () => {
if (!ready) {
return;
}
setAttr(Data.CardAttribute.AttrDoesntUntap, card!.doesntUntap ? '0' : '1');
onClose();
};
const handleSetPT = () => {
onRequestSetPT();
onClose();
};
const handleSetAnnotation = () => {
onRequestSetAnnotation();
onClose();
};
const handleCardCounterDelta = (delta: number) => {
if (!ready) {
return;
}
webClient.request.game.incCardCounter(gameId, {
zone: sourceZone!,
cardId: card!.id,
counterId: 0,
counterDelta: delta,
});
onClose();
};
const handleSetCardCounter = () => {
onRequestSetCounter();
onClose();
};
const handleDrawArrow = () => {
onRequestDrawArrow();
onClose();
};
const handleAttach = () => {
onRequestAttach();
onClose();
};
const handleUnattach = () => {
if (!ready) {
return;
}
// Desktop's actUnattach sends only start_zone + card_id; the server uses
// proto2 presence (`has_target_player_id()`) to detect "detach". Setting
// targetPlayerId: -1 here would leave presence set and trip the attach
// code path server-side. MessageInitShape makes these fields optional,
// so omitting them produces an unset wire field.
webClient.request.game.attachCard(gameId, { startZone: sourceZone!, cardId: card!.id });
onClose();
};
const handleMove = (target: MoveTarget) => {
if (!ready) {
return;
}
// targetPlayerId is the ACTING player (local), matching desktop's
// Player::actMoveCardTo* which uses playerInfo->getId().
webClient.request.game.moveCard(gameId, {
startPlayerId: ownerPlayerId!,
startZone: sourceZone!,
cardsToMove: { card: [{ cardId: card!.id }] },
targetPlayerId: localPlayerId!,
targetZone: target.zone,
x: target.x,
y: target.y,
isReversed: false,
});
onClose();
};
const handleMoveToLibraryAt = () => {
onRequestMoveToLibraryAt();
onClose();
};
return {
ready,
isOwnedByLocal,
canAttach,
isAttached,
moveTargets: CARD_MOVE_TARGETS,
handleFlip,
handleTapToggle,
handleFaceDownToggle,
handleDoesntUntapToggle,
handleSetPT,
handleSetAnnotation,
handleCardCounterDelta,
handleSetCardCounter,
handleDrawArrow,
handleAttach,
handleUnattach,
handleMove,
handleMoveToLibraryAt,
};
}

View file

@ -0,0 +1,24 @@
.card-drag-overlay {
width: 146px;
height: 204px;
border-radius: 6px;
overflow: hidden;
opacity: 0.85;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
pointer-events: none;
}
.card-drag-overlay__image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-drag-overlay__back {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #2a1f3d 0%, #1a1028 60%, #0d0617 100%);
border: 1px solid #3a2d50;
box-sizing: border-box;
}

View file

@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react';
import { makeCard } from '../../../store/game/__mocks__/fixtures';
import CardDragOverlay from './CardDragOverlay';
describe('CardDragOverlay', () => {
it('renders the Scryfall image for a face-up card', () => {
render(<CardDragOverlay card={makeCard({ name: 'Lightning Bolt' })} />);
const img = screen.getByAltText('Lightning Bolt') as HTMLImageElement;
expect(img.src).toContain('Lightning%20Bolt');
expect(img.src).toContain('version=small');
});
it('renders the face-down placeholder for hidden cards', () => {
render(<CardDragOverlay card={makeCard({ faceDown: true })} />);
expect(screen.getByLabelText('face-down card')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,24 @@
import { useScryfallCard } from '@app/hooks';
import type { Data } from '@app/types';
import './CardDragOverlay.css';
export interface CardDragOverlayProps {
card: Data.ServerInfo_Card;
}
function CardDragOverlay({ card }: CardDragOverlayProps) {
const { smallUrl } = useScryfallCard(card);
return (
<div className="card-drag-overlay" data-testid="card-drag-overlay">
{card.faceDown || !smallUrl ? (
<div className="card-drag-overlay__back" aria-label="face-down card" />
) : (
<img className="card-drag-overlay__image" src={smallUrl} alt={card.name} />
)}
</div>
);
}
export default CardDragOverlay;

View file

@ -0,0 +1,44 @@
.card-preview {
height: 340px;
padding: 12px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
background: #0a1225;
border-bottom: 1px solid #1a2b52;
}
.card-preview__empty {
color: #5a6a8a;
font-size: 12px;
font-style: italic;
text-align: center;
}
.card-preview__frame {
position: relative;
width: 100%;
max-width: 280px;
aspect-ratio: 488 / 680;
border-radius: 10px;
overflow: hidden;
background: #0d1930;
}
.card-preview__image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.card-preview__image--normal {
opacity: 0;
transition: opacity 180ms ease-out;
}
.card-preview__image--normal.card-preview__image--loaded {
opacity: 1;
}

View file

@ -0,0 +1,55 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { makeCard } from '../../../store/game/__mocks__/fixtures';
import CardPreview from './CardPreview';
describe('CardPreview', () => {
it('shows an empty hint when no card is hovered', () => {
render(<CardPreview card={null} />);
expect(screen.getByText(/hover a card/i)).toBeInTheDocument();
});
it('renders the small image immediately on hover', () => {
const card = makeCard({ name: 'Lightning Bolt' });
render(<CardPreview card={card} />);
const small = document.querySelector('.card-preview__image--small') as HTMLImageElement;
expect(small).not.toBeNull();
expect(small.src).toContain('version=small');
expect(small.src).toContain('Lightning%20Bolt');
});
it('renders a normal image that stays transparent until it loads', () => {
const card = makeCard({ name: 'Lightning Bolt' });
render(<CardPreview card={card} />);
const normal = screen.getByTestId('card-preview-normal') as HTMLImageElement;
expect(normal.src).toContain('version=normal');
expect(normal).not.toHaveClass('card-preview__image--loaded');
});
it('reveals the normal image once onLoad fires', () => {
const card = makeCard({ name: 'Lightning Bolt' });
render(<CardPreview card={card} />);
const normal = screen.getByTestId('card-preview-normal');
fireEvent.load(normal);
expect(normal).toHaveClass('card-preview__image--loaded');
});
it('resets the loaded flag when the card changes', () => {
const a = makeCard({ id: 1, name: 'A' });
const b = makeCard({ id: 2, name: 'B' });
const { rerender } = render(<CardPreview card={a} />);
fireEvent.load(screen.getByTestId('card-preview-normal'));
expect(screen.getByTestId('card-preview-normal')).toHaveClass(
'card-preview__image--loaded',
);
rerender(<CardPreview card={b} />);
expect(screen.getByTestId('card-preview-normal')).not.toHaveClass(
'card-preview__image--loaded',
);
});
});

View file

@ -0,0 +1,49 @@
import { useEffect, useState } from 'react';
import type { Data } from '@app/types';
import { useScryfallCard } from '@app/hooks';
import './CardPreview.css';
export interface CardPreviewProps {
card: Data.ServerInfo_Card | null | undefined;
}
function CardPreview({ card }: CardPreviewProps) {
const { smallUrl, normalUrl, ready } = useScryfallCard(card ?? null);
const [normalLoaded, setNormalLoaded] = useState(false);
useEffect(() => {
setNormalLoaded(false);
}, [normalUrl]);
return (
<div className="card-preview" data-testid="card-preview">
{!ready && (
<div className="card-preview__empty">Hover a card to preview</div>
)}
{ready && smallUrl && (
<div className="card-preview__frame">
<img
className="card-preview__image card-preview__image--small"
src={smallUrl}
alt={card?.name ?? ''}
/>
{normalUrl && (
<img
className={
'card-preview__image card-preview__image--normal' +
(normalLoaded ? ' card-preview__image--loaded' : '')
}
src={normalUrl}
alt={card?.name ?? ''}
onLoad={() => setNormalLoaded(true)}
data-testid="card-preview-normal"
/>
)}
</div>
)}
</div>
);
}
export default CardPreview;

View file

@ -0,0 +1,64 @@
import { createContext, useCallback, useContext } from 'react';
export type CardKey = string;
export function makeCardKey(playerId: number, zone: string, cardId: number): CardKey {
return `${playerId}-${zone}-${cardId}`;
}
export interface CardRegistry {
register(key: CardKey, el: HTMLElement): void;
unregister(key: CardKey): void;
get(key: CardKey): HTMLElement | undefined;
subscribe(listener: () => void): () => void;
}
export const CardRegistryContext = createContext<CardRegistry | null>(null);
export function useCardRegistry(): CardRegistry | null {
return useContext(CardRegistryContext);
}
export function useRegisterCardRef(key: CardKey | null) {
const registry = useCardRegistry();
return useCallback(
(el: HTMLElement | null) => {
if (!registry || key == null) {
return;
}
if (el) {
registry.register(key, el);
} else {
registry.unregister(key);
}
},
[registry, key],
);
}
export function createCardRegistry(): CardRegistry {
const map = new Map<CardKey, HTMLElement>();
const listeners = new Set<() => void>();
const notify = () => {
listeners.forEach((l) => l());
};
return {
register(key, el) {
map.set(key, el);
notify();
},
unregister(key) {
map.delete(key);
notify();
},
get(key) {
return map.get(key);
},
subscribe(l) {
listeners.add(l);
return () => {
listeners.delete(l);
};
},
};
}

View file

@ -0,0 +1,153 @@
.card-slot {
position: relative;
width: 146px;
height: 204px;
border-radius: 6px;
overflow: hidden;
user-select: none;
cursor: pointer;
transition: transform 120ms ease-out;
}
.card-slot__image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/*
* Card-back art: layered radial + diamond SVG pattern.
* Best-effort stand-in until an MTG-style asset ships under src/images/
* (tracked in gameboard-deferrables.md M1).
*
* Layers (painted bottom-up via background: comma stack):
* 1. Outer radial gradient deep purple core fading to black edges
* 2. SVG diamond-lattice pattern embossed geometry
* 3. Inner vignette subtle darkening at the border
*/
.card-slot__back {
width: 100%;
height: 100%;
box-sizing: border-box;
border: 1px solid #3a2d50;
border-radius: inherit;
position: relative;
background:
radial-gradient(circle at 50% 45%,
rgba(120, 90, 180, 0.15) 0%,
rgba(30, 18, 48, 0.1) 40%,
rgba(0, 0, 0, 0.55) 100%),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'><path d='M20 0 L40 20 L20 40 L0 20 Z' fill='none' stroke='%236a4e9c' stroke-width='0.8' stroke-opacity='0.45'/><circle cx='20' cy='20' r='3' fill='%23826fb8' fill-opacity='0.25'/></svg>"),
linear-gradient(135deg, #2a1f3d 0%, #1a1028 55%, #0d0617 100%);
background-size: 100% 100%, 40px 40px, 100% 100%;
background-position: center center, center center, 0 0;
}
.card-slot__back::before {
content: '';
position: absolute;
inset: 4px;
border: 1px solid rgba(138, 118, 196, 0.35);
border-radius: 4px;
pointer-events: none;
}
.card-slot__back::after {
content: 'MTG';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #a190d6;
font-weight: 700;
letter-spacing: 6px;
font-size: 20px;
text-shadow: 0 0 8px rgba(162, 144, 214, 0.6);
opacity: 0.7;
pointer-events: none;
}
.card-slot--tapped {
transform: rotate(90deg);
}
.card-slot--inverted {
transform: rotate(180deg);
}
.card-slot--tapped.card-slot--inverted {
transform: rotate(270deg);
}
.card-slot--attacking {
outline: 2px solid var(--color-arrow-red);
outline-offset: -2px;
}
.card-slot--dragging {
opacity: 0.35;
}
.card-slot--arrow-source {
box-shadow: 0 0 0 3px var(--color-arrow-red), 0 0 16px var(--color-arrow-red-glow);
}
.card-slot--attach-over {
box-shadow: 0 0 0 3px var(--color-arrow-green), 0 0 16px var(--color-arrow-green-glow);
}
.card-slot__pt {
position: absolute;
right: 4px;
bottom: 4px;
padding: 2px 6px;
background: rgba(0, 0, 0, 0.72);
color: #fff;
font-size: 13px;
font-weight: 700;
border-radius: 4px;
pointer-events: none;
}
.card-slot__annotation {
position: absolute;
left: 4px;
top: 4px;
padding: 2px 6px;
max-width: 80%;
background: rgba(255, 235, 140, 0.92);
color: #2c2000;
font-size: 11px;
font-weight: 600;
border-radius: 3px;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-slot__counters {
position: absolute;
left: 4px;
bottom: 4px;
display: flex;
flex-direction: column;
gap: 2px;
pointer-events: none;
}
.card-slot__counter {
min-width: 18px;
height: 18px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #d48a00;
color: #000;
font-size: 11px;
font-weight: 700;
border-radius: 9px;
}

View file

@ -0,0 +1,126 @@
import { ReactElement } from 'react';
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
import { create } from '@bufbuild/protobuf';
import { DndContext } from '@dnd-kit/core';
import { Data } from '@app/types';
import { makeCard } from '../../../store/game/__mocks__/fixtures';
import CardSlot from './CardSlot';
// useDraggable requires a DndContext ancestor; keep a lightweight wrapper
// for these leaf tests rather than paying for the full renderWithProviders.
const render = (ui: ReactElement) =>
rtlRender(<DndContext>{ui}</DndContext>);
describe('CardSlot', () => {
it('renders the Scryfall image for a normal card', () => {
const card = makeCard({ name: 'Lightning Bolt', id: 1 });
render(<CardSlot card={card} />);
const img = screen.getByAltText('Lightning Bolt') as HTMLImageElement;
expect(img.src).toContain('/cards/named');
expect(img.src).toContain('Lightning%20Bolt');
expect(img.src).toContain('version=small');
});
it('uses providerId over name when present', () => {
const card = makeCard({ name: 'Anything', providerId: 'abc-123', id: 1 });
render(<CardSlot card={card} />);
const img = screen.getByAltText('Anything') as HTMLImageElement;
expect(img.src).toContain('/cards/abc-123');
});
it('renders a face-down back and suppresses image/P-T/counters when faceDown', () => {
const card = makeCard({
name: 'Hidden',
faceDown: true,
pt: '3/3',
counterList: [create(Data.ServerInfo_CardCounterSchema, { id: 1, value: 2 })],
});
render(<CardSlot card={card} />);
expect(screen.getByLabelText('face-down card')).toBeInTheDocument();
expect(screen.queryByAltText('Hidden')).not.toBeInTheDocument();
expect(screen.queryByText('3/3')).not.toBeInTheDocument();
});
it('adds the tapped modifier when card.tapped is true', () => {
const card = makeCard({ tapped: true });
render(<CardSlot card={card} />);
expect(screen.getByTestId('card-slot')).toHaveClass('card-slot--tapped');
});
it('adds the inverted modifier when prop inverted is true', () => {
const card = makeCard();
render(<CardSlot card={card} inverted />);
expect(screen.getByTestId('card-slot')).toHaveClass('card-slot--inverted');
});
it('combines tapped and inverted classes so CSS can compose rotation', () => {
const card = makeCard({ tapped: true });
render(<CardSlot card={card} inverted />);
const el = screen.getByTestId('card-slot');
expect(el).toHaveClass('card-slot--tapped');
expect(el).toHaveClass('card-slot--inverted');
});
it('renders P/T overlay when pt is set', () => {
const card = makeCard({ pt: '5/5' });
render(<CardSlot card={card} />);
expect(screen.getByText('5/5')).toBeInTheDocument();
});
it('renders annotation overlay when annotation is set', () => {
const card = makeCard({ annotation: 'note' });
render(<CardSlot card={card} />);
expect(screen.getByText('note')).toBeInTheDocument();
});
it('renders a counter badge per card counter', () => {
const card = makeCard({
counterList: [
create(Data.ServerInfo_CardCounterSchema, { id: 1, value: 3 }),
create(Data.ServerInfo_CardCounterSchema, { id: 2, value: 7 }),
],
});
render(<CardSlot card={card} />);
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('7')).toBeInTheDocument();
});
it('adds the attacking modifier when card.attacking is true', () => {
const card = makeCard({ attacking: true });
render(<CardSlot card={card} />);
expect(screen.getByTestId('card-slot')).toHaveClass('card-slot--attacking');
});
it('invokes click handlers with the card payload', () => {
const card = makeCard();
const onClick = vi.fn();
const onDoubleClick = vi.fn();
const onContextMenu = vi.fn();
const onMouseEnter = vi.fn();
render(
<CardSlot
card={card}
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
onMouseEnter={onMouseEnter}
/>,
);
const el = screen.getByTestId('card-slot');
fireEvent.click(el);
fireEvent.doubleClick(el);
fireEvent.contextMenu(el);
fireEvent.mouseEnter(el);
expect(onClick).toHaveBeenCalledWith(card);
expect(onDoubleClick).toHaveBeenCalledWith(card);
expect(onContextMenu).toHaveBeenCalled();
expect(onContextMenu.mock.calls[0][0]).toBe(card);
expect(onMouseEnter).toHaveBeenCalledWith(card);
});
});

View file

@ -0,0 +1,99 @@
import { memo } from 'react';
import type { Data } from '@app/types';
import { cx } from '@app/utils';
import { useCardSlot } from './useCardSlot';
import './CardSlot.css';
export interface CardSlotProps {
card: Data.ServerInfo_Card;
inverted?: boolean;
draggable?: boolean;
isArrowSource?: boolean;
/** The player that owns this card (matches desktop's `getOwner()`). Kept
* as `ownerPlayerId`, not `sourcePlayerId`, because it reflects the card
* in the game state rather than any drag origin. */
ownerPlayerId?: number;
zone?: string;
onClick?: (card: Data.ServerInfo_Card) => void;
onDoubleClick?: (card: Data.ServerInfo_Card) => void;
onContextMenu?: (card: Data.ServerInfo_Card, event: React.MouseEvent) => void;
onMouseEnter?: (card: Data.ServerInfo_Card) => void;
}
function CardSlot({
card,
inverted = false,
draggable = false,
isArrowSource = false,
ownerPlayerId,
zone,
onClick,
onDoubleClick,
onContextMenu,
onMouseEnter,
}: CardSlotProps) {
const { smallUrl, attributes, listeners, isDragging, isOver, rootRef } = useCardSlot({
card,
draggable,
ownerPlayerId,
zone,
});
const className = cx('card-slot', {
'card-slot--tapped': card.tapped,
'card-slot--inverted': inverted,
'card-slot--face-down': card.faceDown,
'card-slot--attacking': card.attacking,
'card-slot--dragging': isDragging,
'card-slot--arrow-source': isArrowSource,
'card-slot--attach-over': isOver,
});
return (
<div
ref={rootRef}
className={className}
onClick={() => onClick?.(card)}
onDoubleClick={() => onDoubleClick?.(card)}
onContextMenu={(e) => onContextMenu?.(card, e)}
onMouseEnter={() => onMouseEnter?.(card)}
data-testid="card-slot"
data-card-id={card.id}
data-card-owner={ownerPlayerId ?? ''}
data-card-zone={zone ?? ''}
{...(draggable ? attributes : {})}
{...(draggable ? listeners : {})}
>
{card.faceDown ? (
<div className="card-slot__back" aria-label="face-down card" />
) : (
smallUrl && (
<img className="card-slot__image" src={smallUrl} alt={card.name} />
)
)}
{card.annotation && !card.faceDown && (
<div className="card-slot__annotation">{card.annotation}</div>
)}
{card.pt && !card.faceDown && (
<div className="card-slot__pt">{card.pt}</div>
)}
{card.counterList.length > 0 && !card.faceDown && (
<div className="card-slot__counters">
{card.counterList.map((c) => (
<span key={c.id} className="card-slot__counter">
{c.value}
</span>
))}
</div>
)}
</div>
);
}
export default memo(CardSlot);

View file

@ -0,0 +1,89 @@
import { useCallback, useId } from 'react';
import {
useDraggable,
useDroppable,
type DraggableAttributes,
type DraggableSyntheticListeners,
} from '@dnd-kit/core';
import { useScryfallCard } from '@app/hooks';
import { App } from '@app/types';
import type { Data } from '@app/types';
import { makeCardKey, useRegisterCardRef } from '../CardRegistry/CardRegistryContext';
export interface CardSlot {
smallUrl: string | null | undefined;
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners;
isDragging: boolean;
isOver: boolean;
rootRef: (el: HTMLElement | null) => void;
}
export interface UseCardSlotArgs {
card: Data.ServerInfo_Card;
draggable: boolean;
ownerPlayerId: number | undefined;
zone: string | undefined;
}
export function useCardSlot({ card, draggable, ownerPlayerId, zone }: UseCardSlotArgs): CardSlot {
const { smallUrl } = useScryfallCard(card);
// React-stable id salts the dnd-kit IDs so even two disabled CardSlots
// rendering the same card (during state transitions / hidden-zone leaks)
// never collide. Without the salt, pre-owner/zone render cycles shared
// `card-x-x-<id>` and dnd-kit warned.
const instanceId = useId();
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `card-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
data: { card, sourcePlayerId: ownerPlayerId, sourceZone: zone },
disabled: !draggable || ownerPlayerId == null || zone == null,
});
// Cards on the battlefield double as drop targets for drag-to-attach.
// Other zones don't support attach (desktop's Player::actAttach rejects
// non-table targets), so the droppable is only live for TABLE.
const droppableEnabled =
ownerPlayerId != null && zone === App.ZoneName.TABLE;
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `card-drop-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
data: {
attachTarget: true,
targetPlayerId: ownerPlayerId,
targetZone: zone,
targetCardId: card.id,
},
disabled: !droppableEnabled,
});
const registryKey =
ownerPlayerId != null && zone != null
? makeCardKey(ownerPlayerId, zone, card.id)
: null;
const registerRef = useRegisterCardRef(registryKey);
const rootRef = useCallback(
(el: HTMLElement | null) => {
registerRef(el);
if (draggable) {
setNodeRef(el);
}
if (droppableEnabled) {
setDropRef(el);
}
},
[registerRef, setNodeRef, setDropRef, draggable, droppableEnabled],
);
return {
smallUrl,
attributes,
listeners,
isDragging,
isOver,
rootRef,
};
}

View file

@ -0,0 +1,24 @@
.game-arrow-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 20;
}
.game-arrow-overlay__line {
stroke-width: 4;
stroke-linecap: round;
cursor: pointer;
pointer-events: stroke;
transition: stroke-width 80ms ease-out;
}
.game-arrow-overlay__line:hover {
stroke-width: 6;
}
.game-arrow-overlay__line--preview {
stroke-dasharray: 8 6;
pointer-events: none;
opacity: 0.85;
}

View file

@ -0,0 +1,129 @@
import { useRef } from 'react';
import { screen, fireEvent } from '@testing-library/react';
import { create } from '@bufbuild/protobuf';
import { Data } from '@app/types';
import { createMockWebClient, makeStoreState, renderWithProviders } from '../../../__test-utils__';
import {
makeArrow,
makeGameEntry,
makePlayerEntry,
makePlayerProperties,
} from '../../../store/game/__mocks__/fixtures';
import GameArrowOverlay from './GameArrowOverlay';
import {
CardRegistryContext,
createCardRegistry,
makeCardKey,
} from '../CardRegistry/CardRegistryContext';
function Harness({ gameId }: { gameId: number }) {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} data-testid="arrow-harness-root" style={{ position: 'relative', width: 600, height: 400 }}>
<GameArrowOverlay gameId={gameId} boardRef={ref} />
</div>
);
}
function setupRegistryWithTwoCards() {
const registry = createCardRegistry();
const elA = document.createElement('div');
elA.getBoundingClientRect = () =>
({ left: 100, top: 100, width: 50, height: 50, right: 150, bottom: 150, x: 100, y: 100, toJSON: () => ({}) } as DOMRect);
const elB = document.createElement('div');
elB.getBoundingClientRect = () =>
({ left: 300, top: 300, width: 50, height: 50, right: 350, bottom: 350, x: 300, y: 300, toJSON: () => ({}) } as DOMRect);
// Must be attached to the DOM for the registry subscribers to fire after mount.
document.body.appendChild(elA);
document.body.appendChild(elB);
registry.register(makeCardKey(1, 'table', 10), elA);
registry.register(makeCardKey(1, 'table', 11), elB);
return { registry, elA, elB };
}
function stateWithOneArrow() {
const arrow = makeArrow({
id: 1,
startPlayerId: 1,
startZone: 'table',
startCardId: 10,
targetPlayerId: 1,
targetZone: 'table',
targetCardId: 11,
arrowColor: create(Data.colorSchema, { r: 224, g: 75, b: 59, a: 255 }),
});
return makeStoreState({
games: {
games: {
1: makeGameEntry({
players: {
1: makePlayerEntry({
properties: makePlayerProperties({ playerId: 1 }),
arrows: { 1: arrow },
}),
},
}),
},
},
});
}
function wrapWithRegistry(children: React.ReactNode, registry: ReturnType<typeof createCardRegistry>) {
return (
<CardRegistryContext.Provider value={registry}>
{children}
</CardRegistryContext.Provider>
);
}
describe('GameArrowOverlay', () => {
it('renders an SVG root when mounted', () => {
const { registry } = setupRegistryWithTwoCards();
renderWithProviders(wrapWithRegistry(<Harness gameId={1} />, registry), {
preloadedState: stateWithOneArrow(),
});
expect(screen.getByTestId('game-arrow-overlay')).toBeInTheDocument();
});
it('renders a line for each arrow with endpoints at card centers relative to the board', () => {
const { registry } = setupRegistryWithTwoCards();
// Pretend the board rect starts at 0,0 for simplicity; card A center is
// (125, 125) and card B center is (325, 325) in viewport coords — same in
// board-relative coords since the harness root is at 0,0.
renderWithProviders(wrapWithRegistry(<Harness gameId={1} />, registry), {
preloadedState: stateWithOneArrow(),
});
const line = screen.getByTestId('arrow-1');
expect(line.getAttribute('x1')).toBe('125');
expect(line.getAttribute('y1')).toBe('125');
expect(line.getAttribute('x2')).toBe('325');
expect(line.getAttribute('y2')).toBe('325');
});
it('skips arrows whose endpoints are not registered yet', () => {
const registry = createCardRegistry();
renderWithProviders(wrapWithRegistry(<Harness gameId={1} />, registry), {
preloadedState: stateWithOneArrow(),
});
expect(screen.queryByTestId('arrow-1')).not.toBeInTheDocument();
});
it('dispatches deleteArrow when an arrow line is clicked', () => {
const webClient = createMockWebClient();
const { registry } = setupRegistryWithTwoCards();
renderWithProviders(wrapWithRegistry(<Harness gameId={1} />, registry), {
preloadedState: stateWithOneArrow(),
webClient,
});
fireEvent.click(screen.getByTestId('arrow-1'));
expect(webClient.request.game.deleteArrow).toHaveBeenCalledWith(1, { arrowId: 1 });
});
});

View file

@ -0,0 +1,68 @@
import { useGameArrowOverlay } from './useGameArrowOverlay';
import './GameArrowOverlay.css';
export interface GameArrowOverlayProps {
gameId: number | undefined;
boardRef: React.RefObject<HTMLElement | null>;
dragPreview?: { x1: number; y1: number; x2: number; y2: number; color: string } | null;
}
function GameArrowOverlay({ gameId, boardRef, dragPreview = null }: GameArrowOverlayProps) {
const { arrows, width, height, handleArrowClick } = useGameArrowOverlay({ gameId, boardRef });
return (
<svg
className="game-arrow-overlay"
data-testid="game-arrow-overlay"
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
>
<defs>
<marker
id="game-arrow-overlay__head"
viewBox="0 0 12 12"
refX="10"
refY="6"
markerWidth="10"
markerHeight="10"
orient="auto-start-reverse"
>
<path d="M0,0 L12,6 L0,12 z" fill="currentColor" />
</marker>
</defs>
{arrows.map((a) => (
<line
key={a.arrowId}
className="game-arrow-overlay__line"
data-testid={`arrow-${a.arrowId}`}
x1={a.x1}
y1={a.y1}
x2={a.x2}
y2={a.y2}
stroke={a.color}
style={{ color: a.color }}
markerEnd="url(#game-arrow-overlay__head)"
onClick={() => handleArrowClick(a.arrowId)}
/>
))}
{dragPreview && (
<line
className="game-arrow-overlay__line game-arrow-overlay__line--preview"
data-testid="arrow-preview"
x1={dragPreview.x1}
y1={dragPreview.y1}
x2={dragPreview.x2}
y2={dragPreview.y2}
stroke={dragPreview.color}
style={{ color: dragPreview.color }}
markerEnd="url(#game-arrow-overlay__head)"
/>
)}
</svg>
);
}
export default GameArrowOverlay;

View file

@ -0,0 +1,129 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
import type { Data, Enriched } from '@app/types';
import { makeCardKey, useCardRegistry } from '../CardRegistry/CardRegistryContext';
export interface ResolvedArrow {
arrowId: number;
ownerPlayerId: number;
x1: number;
y1: number;
x2: number;
y2: number;
color: string;
}
const ARROW_FALLBACK_CSS = App.rgbaToCss(App.ArrowColor.RED);
function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
if (!c) {
return ARROW_FALLBACK_CSS;
}
return App.rgbaToCss({ r: c.r, g: c.g, b: c.b, a: c.a ?? 255 });
}
export interface GameArrowOverlay {
arrows: ResolvedArrow[];
width: number;
height: number;
handleArrowClick: (arrowId: number) => void;
}
export interface UseGameArrowOverlayArgs {
gameId: number | undefined;
boardRef: React.RefObject<HTMLElement | null>;
}
export function useGameArrowOverlay({
gameId,
boardRef,
}: UseGameArrowOverlayArgs): GameArrowOverlay {
const webClient = useWebClient();
const registry = useCardRegistry();
const players = useAppSelector((state) =>
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
);
// Tick is bumped whenever we need to re-query DOM rects (card registry
// mutation, board resize). Keeps the overlay declarative without an external
// layout engine.
const [tick, setTick] = useState(0);
const bump = useCallback(() => {
setTick((t) => t + 1);
}, []);
useEffect(() => {
if (!registry) {
return undefined;
}
return registry.subscribe(bump);
}, [registry, bump]);
// First-paint: the board ref is null during the initial render, so `boardRect`
// is undefined and the arrows memo bails out. Bump once after mount so the
// next render sees a populated ref.
useLayoutEffect(() => {
bump();
}, [bump]);
useLayoutEffect(() => {
const el = boardRef.current;
if (!el || typeof ResizeObserver === 'undefined') {
return undefined;
}
const ro = new ResizeObserver(() => bump());
ro.observe(el);
return () => ro.disconnect();
}, [boardRef, bump]);
const boardRect = boardRef.current?.getBoundingClientRect();
const arrows = useMemo<ResolvedArrow[]>(() => {
if (!players || !registry || !boardRect) {
return [];
}
const out: ResolvedArrow[] = [];
for (const player of Object.values(players) as Enriched.PlayerEntry[]) {
for (const a of Object.values(player.arrows) as Data.ServerInfo_Arrow[]) {
const sourceEl = registry.get(
makeCardKey(a.startPlayerId, a.startZone, a.startCardId),
);
const targetEl = registry.get(
makeCardKey(a.targetPlayerId, a.targetZone, a.targetCardId),
);
if (!sourceEl || !targetEl) {
continue;
}
const s = sourceEl.getBoundingClientRect();
const t = targetEl.getBoundingClientRect();
out.push({
arrowId: a.id,
ownerPlayerId: player.properties.playerId,
x1: s.left + s.width / 2 - boardRect.left,
y1: s.top + s.height / 2 - boardRect.top,
x2: t.left + t.width / 2 - boardRect.left,
y2: t.top + t.height / 2 - boardRect.top,
color: cssColor(a.arrowColor),
});
}
}
// `tick` in deps intentionally re-runs the memo on DOM-layout changes.
return out;
}, [players, registry, boardRect, tick]);
const handleArrowClick = (arrowId: number) => {
if (gameId == null) {
return;
}
webClient.request.game.deleteArrow(gameId, { arrowId });
};
const width = boardRect?.width ?? 0;
const height = boardRect?.height ?? 0;
return { arrows, width, height, handleArrowClick };
}

View file

@ -0,0 +1,97 @@
.game-log {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: #0a1225;
color: #e5ecf7;
font-size: 12px;
}
.game-log__heading {
padding: 6px 12px;
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: #8597bb;
border-bottom: 1px solid #1a2b52;
}
.game-log__timer {
padding: 4px 12px;
text-align: center;
font-variant-numeric: tabular-nums;
font-size: 12px;
color: #b8c7e5;
border-bottom: 1px solid #1a2b52;
}
.game-log__messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 6px 12px;
display: flex;
flex-direction: column;
gap: 3px;
}
.game-log__empty {
color: #5a6a8a;
font-style: italic;
}
.game-log__line {
display: flex;
gap: 6px;
line-height: 1.35;
}
.game-log__line--event {
color: #8597bb;
font-style: italic;
}
.game-log__author {
font-weight: 700;
color: var(--color-highlight-yellow);
flex-shrink: 0;
}
.game-log__text {
flex: 1;
word-break: break-word;
}
.game-log__input-row {
border-top: 1px solid #1a2b52;
padding: 6px 12px;
display: flex;
align-items: center;
gap: 6px;
}
.game-log__input-label {
color: #8597bb;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.game-log__input {
width: 100%;
height: 28px;
padding: 0 8px;
box-sizing: border-box;
background: #17223d;
border: 1px solid #233a68;
color: #e5ecf7;
font-family: inherit;
font-size: 12px;
border-radius: 3px;
}
.game-log__input:disabled {
opacity: 0.5;
}

View file

@ -0,0 +1,249 @@
import { act, screen, fireEvent } from '@testing-library/react';
import type { Enriched } from '@app/types';
import { create } from '@bufbuild/protobuf';
import { Data } from '@app/types';
import { createMockWebClient, makeStoreState, renderWithProviders, makeUser } from '../../../__test-utils__';
import {
makeGameEntry,
makePlayerEntry,
makePlayerProperties,
} from '../../../store/game/__mocks__/fixtures';
import { Actions } from '../../../store/game/game.actions';
import GameLog from './GameLog';
function stateWithMessages(
players: ReturnType<typeof makePlayerEntry>[],
messages: Enriched.GameMessage[],
secondsElapsed = 0,
) {
const byId: Record<number, ReturnType<typeof makePlayerEntry>> = {};
for (const p of players) {
byId[p.properties.playerId] = p;
}
return makeStoreState({
games: {
games: {
1: makeGameEntry({ players: byId, messages, secondsElapsed }),
},
},
});
}
describe('GameLog', () => {
it('shows an empty hint when no messages are present', () => {
renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages([], []),
});
expect(screen.getByText(/no messages/i)).toBeInTheDocument();
});
it('renders each message with the speaking player name', () => {
const alice = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Alice' }),
}),
});
renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages(
[alice],
[
{ playerId: 1, message: 'gl hf', timeReceived: 0 },
{ playerId: 1, message: 'yolo', timeReceived: 0 },
],
),
});
expect(screen.getByText('gl hf')).toBeInTheDocument();
expect(screen.getByText('yolo')).toBeInTheDocument();
expect(screen.getAllByText('Alice:').length).toBe(2);
});
it('renders a fallback author label when the speaker is not in the player list', () => {
renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages(
[],
[{ playerId: 99, message: 'hello', timeReceived: 0 }],
),
});
expect(screen.getByText('p99:')).toBeInTheDocument();
});
it('disables the chat input when gameId is undefined', () => {
renderWithProviders(<GameLog gameId={undefined} />, {
preloadedState: makeStoreState({ games: { games: {} } }),
});
expect(screen.getByLabelText('game chat input')).toBeDisabled();
});
it('enables the chat input when gameId is provided', () => {
renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages([], []),
});
expect(screen.getByLabelText('game chat input')).not.toBeDisabled();
});
it('submits the chat draft and clears the input', () => {
const webClient = createMockWebClient();
renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages([], []),
webClient,
});
const input = screen.getByLabelText('game chat input') as HTMLInputElement;
fireEvent.change(input, { target: { value: ' hello world ' } });
fireEvent.submit(input.closest('form')!);
expect(webClient.request.game.gameSay).toHaveBeenCalledWith(1, { message: 'hello world' });
expect(input.value).toBe('');
});
it('does not dispatch for a whitespace-only message', () => {
const webClient = createMockWebClient();
renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages([], []),
webClient,
});
const input = screen.getByLabelText('game chat input') as HTMLInputElement;
fireEvent.change(input, { target: { value: ' ' } });
fireEvent.submit(input.closest('form')!);
expect(webClient.request.game.gameSay).not.toHaveBeenCalled();
});
describe('event-log rendering (desktop MessageLogWidget parity)', () => {
// Desktop renders game events (card moves, tap, concede, etc.) in the
// same log surface as chat, but without a leading speaker label and in a
// distinct italic style. Regression guard — GameLog was chat-only before
// this milestone.
it('renders event messages without a leading author label', () => {
renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages(
[],
[
{ playerId: 1, message: 'Alice plays Bolt.', timeReceived: 0, kind: 'event' },
],
),
});
expect(screen.getByText('Alice plays Bolt.')).toBeInTheDocument();
expect(screen.queryByText(/^p\d+:$/)).not.toBeInTheDocument();
expect(screen.queryByText('Alice:')).not.toBeInTheDocument();
});
it('tags event lines with the event modifier class', () => {
renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages(
[],
[
{ playerId: 0, message: 'The game has started.', timeReceived: 0, kind: 'event' },
],
),
});
const line = screen.getByText('The game has started.').closest('.game-log__line')!;
expect(line.className).toContain('game-log__line--event');
});
it('interleaves chat and event lines in order', () => {
const alice = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Alice' }),
}),
});
renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages(
[alice],
[
{ playerId: 1, message: 'gl', timeReceived: 0, kind: 'chat' },
{ playerId: 1, message: 'Alice plays Bolt.', timeReceived: 1, kind: 'event' },
{ playerId: 1, message: 'hf', timeReceived: 2, kind: 'chat' },
],
),
});
const lines = Array.from(
document.querySelectorAll<HTMLElement>('.game-log__line'),
);
expect(lines).toHaveLength(3);
expect(lines[0].textContent).toContain('Alice:');
expect(lines[1].className).toContain('game-log__line--event');
expect(lines[2].textContent).toContain('Alice:');
});
});
describe('elapsed game timer', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it('renders the initial secondsElapsed snapshot in HH:MM:SS form', () => {
renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages([], [], 3723),
});
expect(screen.getByTestId('game-log-timer')).toHaveTextContent('01:02:03');
});
it('advances locally once per second between server events', () => {
renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages([], [], 0),
});
expect(screen.getByTestId('game-log-timer')).toHaveTextContent('00:00:00');
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.getByTestId('game-log-timer')).toHaveTextContent('00:00:02');
});
it('does not render the timer when there is no active game', () => {
renderWithProviders(<GameLog gameId={undefined} />, {
preloadedState: makeStoreState({ games: { games: {} } }),
});
expect(screen.queryByTestId('game-log-timer')).not.toBeInTheDocument();
});
// Mirrors desktop's setGameTime resync: the local 1Hz ticker drifts until
// the server pushes a fresh `secondsElapsed`, at which point the display
// snaps to the server value. Regression guard for that snap behavior.
it('resyncs displayed time when Redux pushes a new secondsElapsed', () => {
const { store } = renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages([], [], 10),
});
expect(screen.getByTestId('game-log-timer')).toHaveTextContent('00:00:10');
// Local ticker drifts forward between server events.
act(() => {
vi.advanceTimersByTime(3000);
});
expect(screen.getByTestId('game-log-timer')).toHaveTextContent('00:00:13');
// Server pushes a fresh snapshot (real reducer path).
act(() => {
store.dispatch(Actions.gameStateChanged({
gameId: 1,
data: create(Data.Event_GameStateChangedSchema, { secondsElapsed: 120 }),
}));
});
// Display snaps to the server value, not the drifted local value.
expect(screen.getByTestId('game-log-timer')).toHaveTextContent('00:02:00');
});
});
});

View file

@ -0,0 +1,65 @@
import { useRef } from 'react';
import { formatElapsed, useGameLog } from './useGameLog';
import './GameLog.css';
export interface GameLogProps {
gameId: number | undefined;
}
function GameLog({ gameId }: GameLogProps) {
const listRef = useRef<HTMLDivElement>(null);
const {
messages,
players,
displaySeconds,
draft,
setDraft,
handleMessagesScroll,
handleSubmit,
} = useGameLog({ gameId, listRef });
return (
<div className="game-log" data-testid="game-log">
<div className="game-log__heading">Log</div>
{gameId != null && (
<div className="game-log__timer" data-testid="game-log-timer">
{formatElapsed(displaySeconds)}
</div>
)}
<div className="game-log__messages" ref={listRef} onScroll={handleMessagesScroll}>
{messages.length === 0 && (
<div className="game-log__empty">no messages</div>
)}
{messages.map((m, idx) => {
const isEvent = m.kind === 'event';
const name = players?.[m.playerId]?.properties.userInfo?.name ?? `p${m.playerId}`;
const lineClass = isEvent ? 'game-log__line game-log__line--event' : 'game-log__line';
return (
<div key={`${m.timeReceived}-${idx}`} className={lineClass}>
{!isEvent && <span className="game-log__author">{name}:</span>}
<span className="game-log__text">{m.message}</span>
</div>
);
})}
</div>
<form className="game-log__input-row" onSubmit={handleSubmit}>
<label className="game-log__input-label" htmlFor="game-log-say-input">
Say:
</label>
<input
id="game-log-say-input"
type="text"
className="game-log__input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={gameId == null}
aria-label="game chat input"
/>
</form>
</div>
);
}
export default GameLog;

View file

@ -0,0 +1,111 @@
import { useEffect, useRef, useState, RefObject } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import type { Enriched } from '@app/types';
const EMPTY_MESSAGES: Enriched.GameMessage[] = [];
export function formatElapsed(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const ss = String(s % 60).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
export interface GameLog {
messages: Enriched.GameMessage[];
players: Record<number, Enriched.PlayerEntry> | undefined;
displaySeconds: number;
draft: string;
setDraft: (v: string) => void;
handleMessagesScroll: () => void;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
}
export interface UseGameLogArgs {
gameId: number | undefined;
listRef: RefObject<HTMLDivElement | null>;
}
export function useGameLog({ gameId, listRef }: UseGameLogArgs): GameLog {
const webClient = useWebClient();
// getMessages falls back to a shared EMPTY_ARRAY typed as ServerInfo_Card[]
// (see game.selectors.ts). The runtime array is empty, so the cast is safe;
// fixing the selector's fallback type is out of scope for this refactor.
const messages = useAppSelector((state) =>
gameId != null ? GameSelectors.getMessages(state, gameId) : EMPTY_MESSAGES,
) as Enriched.GameMessage[];
const players = useAppSelector((state) =>
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
);
const secondsElapsed = useAppSelector((state) =>
gameId != null ? GameSelectors.getSecondsElapsed(state, gameId) : 0,
);
// Local 1Hz ticker, resynced from Redux whenever a server event delivers a
// fresh `secondsElapsed`. Mirrors desktop's QTimer(1000) +
// setGameTime(event.seconds_elapsed()) pattern in game_state.cpp.
const [displaySeconds, setDisplaySeconds] = useState(secondsElapsed);
useEffect(() => {
setDisplaySeconds(secondsElapsed);
}, [secondsElapsed]);
useEffect(() => {
if (gameId == null) {
return undefined;
}
const id = window.setInterval(() => {
setDisplaySeconds((prev) => prev + 1);
}, 1000);
return () => window.clearInterval(id);
}, [gameId]);
const [draft, setDraft] = useState('');
// Desktop pins the log to the bottom unless the user has scrolled up to read backlog.
// Capture pin state before the new line renders so auto-scroll only fires when the
// user was already following the tail.
const wasPinnedRef = useRef(true);
useEffect(() => {
const el = listRef.current;
if (!el) {
return;
}
if (wasPinnedRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [messages.length, listRef]);
const handleMessagesScroll = () => {
const el = listRef.current;
if (!el) {
return;
}
wasPinnedRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 2;
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (gameId == null) {
return;
}
const trimmed = draft.trim();
if (trimmed.length === 0) {
return;
}
webClient.request.game.gameSay(gameId, { message: trimmed });
setDraft('');
};
return {
messages,
players,
displaySeconds,
draft,
setDraft,
handleMessagesScroll,
handleSubmit,
};
}

View file

@ -0,0 +1,3 @@
.hand-context-menu .MuiMenuItem-root {
font-size: 13px;
}

View file

@ -0,0 +1,81 @@
import { screen, fireEvent } from '@testing-library/react';
import { createMockWebClient, renderWithProviders } from '../../../__test-utils__';
import HandContextMenu from './HandContextMenu';
function render(overrides: Partial<React.ComponentProps<typeof HandContextMenu>> = {}) {
const props: React.ComponentProps<typeof HandContextMenu> = {
isOpen: true,
anchorPosition: { top: 10, left: 10 },
gameId: 1,
handSize: 7,
onClose: vi.fn(),
onRequestChooseMulligan: vi.fn(),
onRequestRevealHand: vi.fn(),
onRequestRevealRandom: vi.fn(),
...overrides,
};
const webClient = createMockWebClient();
return {
...renderWithProviders(<HandContextMenu {...props} />, { webClient }),
webClient,
props,
};
}
describe('HandContextMenu', () => {
it('fires onRequestChooseMulligan and closes when the choose-size item is clicked', () => {
const onRequestChooseMulligan = vi.fn();
const onClose = vi.fn();
render({ onRequestChooseMulligan, onClose });
fireEvent.click(screen.getByRole('menuitem', { name: /choose size/i }));
expect(onRequestChooseMulligan).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('dispatches mulligan(number=handSize) on the same-size item', () => {
const { webClient } = render({ handSize: 7 });
fireEvent.click(screen.getByRole('menuitem', { name: /same size/i }));
expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 7 });
});
it('dispatches mulligan(number=handSize-1) on the size1 item', () => {
const { webClient } = render({ handSize: 5 });
fireEvent.click(screen.getByRole('menuitem', { name: /size 1/i }));
expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 4 });
});
it('floors size1 at 1, matching desktop actMulliganMinusOne', () => {
const { webClient } = render({ handSize: 1 });
fireEvent.click(screen.getByRole('menuitem', { name: /size 1/i }));
expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 1 });
});
it('disables same-size when handSize is 0', () => {
render({ handSize: 0 });
expect(screen.getByRole('menuitem', { name: /same size/i })).toHaveAttribute(
'aria-disabled',
'true',
);
});
it('fires onRequestRevealHand and closes on reveal-hand item', () => {
const onRequestRevealHand = vi.fn();
const onClose = vi.fn();
render({ onRequestRevealHand, onClose });
fireEvent.click(screen.getByRole('menuitem', { name: /reveal hand/i }));
expect(onRequestRevealHand).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,65 @@
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import { useHandContextMenu } from './useHandContextMenu';
import './HandContextMenu.css';
export interface HandContextMenuProps {
isOpen: boolean;
anchorPosition: { top: number; left: number } | null;
gameId: number;
handSize: number;
onClose: () => void;
onRequestChooseMulligan: () => void;
onRequestRevealHand: () => void;
onRequestRevealRandom: () => void;
}
function HandContextMenu({
isOpen,
anchorPosition,
gameId,
handSize,
onClose,
onRequestChooseMulligan,
onRequestRevealHand,
onRequestRevealRandom,
}: HandContextMenuProps) {
const { handleChoose, handleSameSize, handleMinusOne, handleRevealHand, handleRevealRandom } =
useHandContextMenu({
gameId,
handSize,
onClose,
onRequestChooseMulligan,
onRequestRevealHand,
onRequestRevealRandom,
});
return (
<Menu
open={isOpen}
onClose={onClose}
anchorReference="anchorPosition"
anchorPosition={anchorPosition ?? undefined}
data-testid="hand-context-menu"
className="hand-context-menu"
>
<MenuItem onClick={handleChoose}>Take mulligan (choose size)</MenuItem>
<MenuItem onClick={handleSameSize} disabled={handSize === 0}>
Take mulligan (same size)
</MenuItem>
<MenuItem onClick={handleMinusOne}>
Take mulligan (size 1)
</MenuItem>
<Divider />
<MenuItem onClick={handleRevealHand}>Reveal hand to</MenuItem>
<MenuItem onClick={handleRevealRandom} disabled={handSize === 0}>
Reveal random card to
</MenuItem>
</Menu>
);
}
export default HandContextMenu;

View file

@ -0,0 +1,75 @@
import { useWebClient } from '@app/hooks';
export interface HandContextMenu {
handleChoose: () => void;
handleSameSize: () => void;
handleMinusOne: () => void;
handleRevealHand: () => void;
handleRevealRandom: () => void;
}
export interface UseHandContextMenuArgs {
gameId: number;
handSize: number;
onClose: () => void;
onRequestChooseMulligan: () => void;
onRequestRevealHand: () => void;
onRequestRevealRandom: () => void;
}
export function useHandContextMenu({
gameId,
handSize,
onClose,
onRequestChooseMulligan,
onRequestRevealHand,
onRequestRevealRandom,
}: UseHandContextMenuArgs): HandContextMenu {
const webClient = useWebClient();
const handleChoose = () => {
if (gameId <= 0) {
return;
}
onRequestChooseMulligan();
onClose();
};
const handleSameSize = () => {
if (gameId <= 0) {
return;
}
webClient.request.game.mulligan(gameId, { number: handSize });
onClose();
};
const handleMinusOne = () => {
if (gameId <= 0) {
return;
}
// Desktop's actMulliganMinusOne floors at 1 (see
// cockatrice/src/game/player/player_actions.cpp actMulliganMinusOne);
// the server-side doMulligan rejects number < 1.
const next = Math.max(1, handSize - 1);
webClient.request.game.mulligan(gameId, { number: next });
onClose();
};
const handleRevealHand = () => {
if (gameId <= 0) {
return;
}
onRequestRevealHand();
onClose();
};
const handleRevealRandom = () => {
if (gameId <= 0) {
return;
}
onRequestRevealRandom();
onClose();
};
return { handleChoose, handleSameSize, handleMinusOne, handleRevealHand, handleRevealRandom };
}

View file

@ -0,0 +1,33 @@
.hand-zone {
height: 176px;
display: flex;
flex-direction: column;
background: #0a4a1e;
border-top: 2px solid #1a6a33;
padding: 4px 12px;
box-sizing: border-box;
}
.hand-zone__label {
color: #c4e8ce;
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 2px;
}
.hand-zone__cards {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
}
.hand-zone--drop-over {
background: #126a2d;
box-shadow: inset 0 0 0 2px #4de078;
}

View file

@ -0,0 +1,96 @@
import { screen, fireEvent } from '@testing-library/react';
import { App } from '@app/types';
import { makeStoreState, renderWithProviders } from '../../../__test-utils__';
import {
makeCard,
makeGameEntry,
makePlayerEntry,
makeZoneEntry,
} from '../../../store/game/__mocks__/fixtures';
import HandZone from './HandZone';
function stateWithHand(cards: ReturnType<typeof makeCard>[]) {
const hand = makeZoneEntry({
name: App.ZoneName.HAND,
type: 0,
cardCount: cards.length,
cards,
});
return makeStoreState({
games: {
games: {
1: makeGameEntry({
localPlayerId: 1,
players: {
1: makePlayerEntry({ zones: { [App.ZoneName.HAND]: hand } }),
},
}),
},
},
});
}
describe('HandZone', () => {
it('renders the hand label with the current count', () => {
const cards = [
makeCard({ id: 1, name: 'Island' }),
makeCard({ id: 2, name: 'Swamp' }),
];
renderWithProviders(<HandZone gameId={1} playerId={1} />, {
preloadedState: stateWithHand(cards),
});
expect(screen.getByText(/Hand · 2/)).toBeInTheDocument();
});
it('renders a CardSlot for every card in hand', () => {
const cards = [
makeCard({ id: 1, name: 'Forest' }),
makeCard({ id: 2, name: 'Mountain' }),
];
renderWithProviders(<HandZone gameId={1} playerId={1} />, {
preloadedState: stateWithHand(cards),
});
expect(screen.getAllByTestId('card-slot')).toHaveLength(2);
expect(screen.getByAltText('Forest')).toBeInTheDocument();
expect(screen.getByAltText('Mountain')).toBeInTheDocument();
});
it('renders an empty row when hand is empty', () => {
renderWithProviders(<HandZone gameId={1} playerId={1} />, {
preloadedState: stateWithHand([]),
});
expect(screen.getByText(/Hand · 0/)).toBeInTheDocument();
expect(screen.queryAllByTestId('card-slot')).toHaveLength(0);
});
describe('zone-level context menu', () => {
it('fires onZoneContextMenu when right-clicking the empty hand area', () => {
const onZoneContextMenu = vi.fn();
renderWithProviders(
<HandZone gameId={1} playerId={1} onZoneContextMenu={onZoneContextMenu} />,
{ preloadedState: stateWithHand([]) },
);
fireEvent.contextMenu(screen.getByTestId('hand-zone'));
expect(onZoneContextMenu).toHaveBeenCalled();
});
it('does NOT fire onZoneContextMenu when right-clicking a card slot', () => {
const onZoneContextMenu = vi.fn();
const cards = [makeCard({ id: 1, name: 'Island' })];
renderWithProviders(
<HandZone gameId={1} playerId={1} onZoneContextMenu={onZoneContextMenu} />,
{ preloadedState: stateWithHand(cards) },
);
fireEvent.contextMenu(screen.getByTestId('card-slot'));
expect(onZoneContextMenu).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,68 @@
import { App, Data } from '@app/types';
import { cx } from '@app/utils';
import CardSlot from '../CardSlot/CardSlot';
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
import { useHandZone } from './useHandZone';
import './HandZone.css';
export interface HandZoneProps {
gameId: number;
playerId: number;
canAct?: boolean;
arrowSourceKey?: string | null;
onCardHover?: (card: Data.ServerInfo_Card) => void;
onCardClick?: (playerId: number, zone: string, card: Data.ServerInfo_Card) => void;
onCardContextMenu?: (card: Data.ServerInfo_Card, event: React.MouseEvent) => void;
onZoneContextMenu?: (event: React.MouseEvent) => void;
}
function HandZone({
gameId,
playerId,
canAct = false,
arrowSourceKey = null,
onCardHover,
onCardClick,
onCardContextMenu,
onZoneContextMenu,
}: HandZoneProps) {
const { cards, setNodeRef, isOver, handleZoneContextMenu } = useHandZone({
gameId,
playerId,
canAct,
onZoneContextMenu,
});
return (
<div
ref={setNodeRef}
className={cx('hand-zone', { 'hand-zone--drop-over': isOver })}
data-testid="hand-zone"
onContextMenu={handleZoneContextMenu}
>
<div className="hand-zone__label">Hand · {cards.length}</div>
<div className="hand-zone__cards">
{cards.map((card) => {
const key = makeCardKey(playerId, App.ZoneName.HAND, card.id);
return (
<CardSlot
key={card.id}
card={card}
draggable={canAct}
ownerPlayerId={playerId}
zone={App.ZoneName.HAND}
isArrowSource={arrowSourceKey === key}
onMouseEnter={onCardHover}
onClick={(c) => onCardClick?.(playerId, App.ZoneName.HAND, c)}
onContextMenu={onCardContextMenu}
/>
);
})}
</div>
</div>
);
}
export default HandZone;

View file

@ -0,0 +1,55 @@
import { useDroppable } from '@dnd-kit/core';
import type { Ref } from 'react';
import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
export interface HandZone {
cards: Data.ServerInfo_Card[];
setNodeRef: Ref<HTMLDivElement>;
isOver: boolean;
handleZoneContextMenu: (e: React.MouseEvent<HTMLDivElement>) => void;
}
export interface UseHandZoneArgs {
gameId: number;
playerId: number;
canAct: boolean;
onZoneContextMenu?: (event: React.MouseEvent) => void;
}
export function useHandZone({
gameId,
playerId,
canAct,
onZoneContextMenu,
}: UseHandZoneArgs): HandZone {
const cards = useAppSelector((state) =>
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.HAND),
);
// Match desktop: can't drop into a hand zone that isn't yours (judges
// aside; server enforces the same restriction). Today only the local
// HandZone mounts, but this guard future-proofs opponent-hand mirrors.
const { setNodeRef, isOver } = useDroppable({
id: `hand-${playerId}`,
data: { targetPlayerId: playerId, targetZone: App.ZoneName.HAND },
disabled: !canAct,
});
// Right-click anywhere inside the hand that doesn't land on a card opens
// the hand zone context menu (mulligan / reveal hand). Card-level right-
// click has its own handler on CardSlot.
const handleZoneContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
if (!onZoneContextMenu) {
return;
}
const target = e.target as HTMLElement;
if (target.closest('[data-card-id]')) {
return;
}
onZoneContextMenu(e);
};
return { cards, setNodeRef, isOver, handleZoneContextMenu };
}

View file

@ -0,0 +1,26 @@
.opponent-selector {
position: absolute;
top: 8px;
right: 120px;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: rgba(10, 18, 37, 0.9);
border: 1px solid #1a2b52;
border-radius: 4px;
color: #e5ecf7;
}
.opponent-selector__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
}
.opponent-selector__select {
min-width: 120px;
color: #e5ecf7;
}

View file

@ -0,0 +1,55 @@
import { render, screen, fireEvent, within } from '@testing-library/react';
import OpponentSelector from './OpponentSelector';
describe('OpponentSelector', () => {
it('does not render with fewer than 2 opponents (2-player game)', () => {
const { container } = render(
<OpponentSelector
opponents={[{ playerId: 2, name: 'Solo' }]}
selectedPlayerId={2}
onSelect={vi.fn()}
/>,
);
expect(container.firstChild).toBeNull();
});
it('renders with 2+ opponents', () => {
render(
<OpponentSelector
opponents={[
{ playerId: 2, name: 'Alice' },
{ playerId: 3, name: 'Bob' },
]}
selectedPlayerId={2}
onSelect={vi.fn()}
/>,
);
expect(screen.getByTestId('opponent-selector')).toBeInTheDocument();
expect(screen.getByText('Opponent:')).toBeInTheDocument();
expect(screen.getByText('Alice')).toBeInTheDocument();
});
it('fires onSelect with the chosen opponent playerId', () => {
const onSelect = vi.fn();
render(
<OpponentSelector
opponents={[
{ playerId: 2, name: 'Alice' },
{ playerId: 3, name: 'Bob' },
{ playerId: 4, name: 'Carol' },
]}
selectedPlayerId={2}
onSelect={onSelect}
/>,
);
fireEvent.mouseDown(screen.getByRole('combobox'));
const listbox = within(screen.getByRole('listbox'));
fireEvent.click(listbox.getByText('Carol'));
expect(onSelect).toHaveBeenCalledWith(4);
});
});

View file

@ -0,0 +1,40 @@
import { Select, MenuItem } from '@mui/material';
import './OpponentSelector.css';
export interface OpponentOption {
playerId: number;
name: string;
}
export interface OpponentSelectorProps {
opponents: OpponentOption[];
selectedPlayerId: number | undefined;
onSelect: (playerId: number) => void;
}
function OpponentSelector({ opponents, selectedPlayerId, onSelect }: OpponentSelectorProps) {
if (opponents.length < 2) {
return null;
}
return (
<div className="opponent-selector" data-testid="opponent-selector">
<label className="opponent-selector__label">Opponent:</label>
<Select
className="opponent-selector__select"
size="small"
value={selectedPlayerId ?? ''}
onChange={(e) => onSelect(Number(e.target.value))}
>
{opponents.map((o) => (
<MenuItem key={o.playerId} value={o.playerId}>
{o.name}
</MenuItem>
))}
</Select>
</div>
);
}
export default OpponentSelector;

View file

@ -0,0 +1,53 @@
.phase-bar {
width: 56px;
height: 100%;
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 2px;
box-sizing: border-box;
background: #0a1225;
border-right: 1px solid #1a2b52;
}
.phase-bar__btn-wrap {
flex: 0 0 auto;
display: flex;
}
.phase-bar__btn {
flex: 1;
height: 44px;
padding: 0;
background: #162445;
border: 1px solid #233a68;
color: #c8d4ef;
font-family: inherit;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
cursor: default;
border-radius: 3px;
}
.phase-bar__btn:disabled {
cursor: default;
opacity: 0.85;
}
.phase-bar__btn--active {
background: #d48a00;
border-color: var(--color-highlight-yellow);
color: #1a1100;
box-shadow: 0 0 6px var(--color-highlight-yellow-soft);
}
.phase-bar__btn--pass {
background: #3f1a1a;
border-color: #5e2828;
color: #ffd4d4;
}
.phase-bar__spacer {
flex: 1;
}

View file

@ -0,0 +1,258 @@
import { screen, fireEvent } from '@testing-library/react';
import { App, Data } from '@app/types';
import { createMockWebClient, makeStoreState, renderWithProviders } from '../../../__test-utils__';
import {
makeCard,
makeGameEntry,
makePlayerEntry,
makePlayerProperties,
makeZoneEntry,
} from '../../../store/game/__mocks__/fixtures';
import PhaseBar from './PhaseBar';
function stateWith(opts: {
phase?: number;
localPlayerId?: number;
activePlayerId?: number;
started?: boolean;
judge?: boolean;
} = {}) {
const localId = opts.localPlayerId ?? 1;
return makeStoreState({
games: {
games: {
1: makeGameEntry({
activePhase: opts.phase ?? 0,
localPlayerId: localId,
activePlayerId: opts.activePlayerId ?? localId,
started: opts.started ?? true,
judge: opts.judge ?? false,
players: {
[localId]: makePlayerEntry({
properties: makePlayerProperties({ playerId: localId }),
}),
},
}),
},
},
});
}
describe('PhaseBar', () => {
it('renders 11 phase buttons plus PASS', () => {
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWith(),
});
const buttons = screen.getByTestId('phase-bar').querySelectorAll('button');
expect(buttons).toHaveLength(12);
expect(buttons[11].textContent).toBe('PASS TURN');
});
it('renders phases in desktop-Cockatrice order', () => {
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWith(),
});
const labels = Array.from(
screen.getByTestId('phase-bar').querySelectorAll('button'),
).map((b) => b.textContent);
expect(labels.slice(0, 11)).toEqual([
'UNTAP', 'UPKP', 'DRAW', 'M1', 'CMBT', 'ATTK',
'BLCK', 'DMGE', 'ECMB', 'M2', 'END',
]);
});
it('applies the active modifier only to the button matching activePhase', () => {
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWith({ phase: App.Phase.DeclareAttackers }),
});
const active = document.querySelector('.phase-bar__btn--active')!;
expect(active.getAttribute('data-phase')).toBe(String(App.Phase.DeclareAttackers));
expect(document.querySelectorAll('.phase-bar__btn--active')).toHaveLength(1);
});
it('renders no active button when gameId is undefined', () => {
renderWithProviders(<PhaseBar gameId={undefined} />, {
preloadedState: makeStoreState({}),
});
expect(document.querySelectorAll('.phase-bar__btn--active')).toHaveLength(0);
});
it('enables buttons when the local player is the active player and the game has started', () => {
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWith({ started: true }),
});
const buttons = screen.getByTestId('phase-bar').querySelectorAll('button');
buttons.forEach((b) => expect(b).not.toBeDisabled());
});
it('disables every button when the game has not started', () => {
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWith({ started: false }),
});
const buttons = screen.getByTestId('phase-bar').querySelectorAll('button');
buttons.forEach((b) => expect(b).toBeDisabled());
});
it('disables every button when the local player is not the active player (non-judge)', () => {
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWith({
localPlayerId: 1,
activePlayerId: 2,
}),
});
const buttons = screen.getByTestId('phase-bar').querySelectorAll('button');
buttons.forEach((b) => expect(b).toBeDisabled());
});
it('enables buttons for a judge regardless of active player (matches desktop)', () => {
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWith({
localPlayerId: 1,
activePlayerId: 2,
judge: true,
}),
});
const buttons = screen.getByTestId('phase-bar').querySelectorAll('button');
buttons.forEach((b) => expect(b).not.toBeDisabled());
});
it('dispatches setActivePhase when a phase button is clicked', () => {
const webClient = createMockWebClient();
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWith(),
webClient,
});
fireEvent.click(screen.getByText('ATTK'));
expect(webClient.request.game.setActivePhase).toHaveBeenCalledWith(1, {
phase: App.Phase.DeclareAttackers,
});
});
it('dispatches nextTurn when PASS is clicked', () => {
const webClient = createMockWebClient();
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWith(),
webClient,
});
fireEvent.click(screen.getByText('PASS TURN'));
expect(webClient.request.game.nextTurn).toHaveBeenCalledWith(1);
});
describe('desktop double-click built-ins (phases_toolbar.cpp)', () => {
function stateWithTapped(cards: ReturnType<typeof makeCard>[]) {
return makeStoreState({
games: {
games: {
1: makeGameEntry({
activePhase: App.Phase.Untap,
localPlayerId: 1,
activePlayerId: 1,
started: true,
players: {
1: makePlayerEntry({
properties: makePlayerProperties({ playerId: 1 }),
zones: {
table: makeZoneEntry({ name: 'table', cards, cardCount: cards.length }),
},
}),
},
}),
},
},
});
}
it('double-click on UNTAP dispatches setCardAttr AttrTapped=0 for every tapped card', () => {
const webClient = createMockWebClient();
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWithTapped([
makeCard({ id: 1, tapped: true }),
makeCard({ id: 2, tapped: false }),
makeCard({ id: 3, tapped: true }),
]),
webClient,
});
fireEvent.doubleClick(screen.getByText('UNTAP'));
expect(webClient.request.game.setCardAttr).toHaveBeenCalledTimes(2);
expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, {
zone: 'table',
cardId: 1,
attribute: Data.CardAttribute.AttrTapped,
attrValue: '0',
});
expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, {
zone: 'table',
cardId: 3,
attribute: Data.CardAttribute.AttrTapped,
attrValue: '0',
});
});
it('double-click on UNTAP is a no-op when no cards are tapped', () => {
const webClient = createMockWebClient();
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWithTapped([makeCard({ id: 1, tapped: false })]),
webClient,
});
fireEvent.doubleClick(screen.getByText('UNTAP'));
expect(webClient.request.game.setCardAttr).not.toHaveBeenCalled();
});
it('double-click on DRAW dispatches drawCards({ number: 1 })', () => {
const webClient = createMockWebClient();
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWith(),
webClient,
});
fireEvent.doubleClick(screen.getByText('DRAW'));
expect(webClient.request.game.drawCards).toHaveBeenCalledWith(1, { number: 1 });
});
it('double-click does nothing when the local player is not active', () => {
const webClient = createMockWebClient();
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWith({ localPlayerId: 1, activePlayerId: 2 }),
webClient,
});
fireEvent.doubleClick(screen.getByText('UNTAP'));
fireEvent.doubleClick(screen.getByText('DRAW'));
expect(webClient.request.game.setCardAttr).not.toHaveBeenCalled();
expect(webClient.request.game.drawCards).not.toHaveBeenCalled();
});
it('double-click on other phases (UPKP, M1, etc.) does not fire any built-in', () => {
const webClient = createMockWebClient();
renderWithProviders(<PhaseBar gameId={1} />, {
preloadedState: stateWith(),
webClient,
});
fireEvent.doubleClick(screen.getByText('UPKP'));
fireEvent.doubleClick(screen.getByText('M1'));
fireEvent.doubleClick(screen.getByText('END'));
expect(webClient.request.game.setCardAttr).not.toHaveBeenCalled();
expect(webClient.request.game.drawCards).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,90 @@
import Tooltip from '@mui/material/Tooltip';
import { App } from '@app/types';
import { cx } from '@app/utils';
import { usePhaseBar } from './usePhaseBar';
import './PhaseBar.css';
export interface PhaseBarProps {
gameId: number | undefined;
}
// Abbreviated phase badges plus full tooltip titles. Desktop uses full
// words in the horizontal toolbar; we shorten for the vertical strip but
// keep the full text available on hover per the tooltip deferrable.
const PHASE_LABELS: ReadonlyArray<{
phase: App.Phase;
label: string;
title: string;
/** Desktop phase buttons wire "Untap All" to phase 0 double-click and "Draw a Card" to phase 2 double-click. */
builtInOnDoubleClick?: 'untapAll' | 'drawCard';
}> = [
{ phase: App.Phase.Untap, label: 'UNTAP', title: 'Untap step (double-click: untap all)', builtInOnDoubleClick: 'untapAll' },
{ phase: App.Phase.Upkeep, label: 'UPKP', title: 'Upkeep step' },
{ phase: App.Phase.Draw, label: 'DRAW', title: 'Draw step (double-click: draw a card)', builtInOnDoubleClick: 'drawCard' },
{ phase: App.Phase.FirstMain, label: 'M1', title: 'First main phase' },
{ phase: App.Phase.BeginCombat, label: 'CMBT', title: 'Beginning of combat' },
{ phase: App.Phase.DeclareAttackers, label: 'ATTK', title: 'Declare attackers' },
{ phase: App.Phase.DeclareBlockers, label: 'BLCK', title: 'Declare blockers' },
{ phase: App.Phase.CombatDamage, label: 'DMGE', title: 'Combat damage' },
{ phase: App.Phase.EndCombat, label: 'ECMB', title: 'End of combat' },
{ phase: App.Phase.SecondMain, label: 'M2', title: 'Second main phase' },
{ phase: App.Phase.EndCleanup, label: 'END', title: 'End step / cleanup' },
];
function PhaseBar({ gameId }: PhaseBarProps) {
const { activePhase, canAdvance, handlePhaseClick, handlePass, handleUntapAll, handleDrawOne } =
usePhaseBar(gameId);
const onDoubleClickFor = (kind: 'untapAll' | 'drawCard' | undefined) => {
if (kind === 'untapAll') {
return handleUntapAll;
}
if (kind === 'drawCard') {
return handleDrawOne;
}
return undefined;
};
return (
<nav className="phase-bar" data-testid="phase-bar" aria-label="Turn phases">
{PHASE_LABELS.map(({ phase, label, title, builtInOnDoubleClick }) => {
const isActive = phase === activePhase;
return (
<Tooltip key={phase} title={title} placement="right" enterDelay={500}>
{/* span wrapper: MUI Tooltip can't attach listeners to a disabled button. */}
<span className="phase-bar__btn-wrap">
<button
type="button"
className={cx('phase-bar__btn', { 'phase-bar__btn--active': isActive })}
data-phase={phase}
disabled={!canAdvance}
onClick={() => handlePhaseClick(phase)}
onDoubleClick={onDoubleClickFor(builtInOnDoubleClick)}
>
{label}
</button>
</span>
</Tooltip>
);
})}
<div className="phase-bar__spacer" />
<Tooltip title="Pass to the next turn" placement="right" enterDelay={500}>
<span className="phase-bar__btn-wrap">
<button
type="button"
className="phase-bar__btn phase-bar__btn--pass"
disabled={!canAdvance}
onClick={handlePass}
>
PASS TURN
</button>
</span>
</Tooltip>
</nav>
);
}
export default PhaseBar;

View file

@ -0,0 +1,76 @@
import { useCurrentGame, useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App, Data } from '@app/types';
export interface PhaseBar {
activePhase: App.Phase | undefined;
canAdvance: boolean;
handlePhaseClick: (phase: App.Phase) => void;
handlePass: () => void;
handleUntapAll: () => void;
handleDrawOne: () => void;
}
export function usePhaseBar(gameId: number | undefined): PhaseBar {
const webClient = useWebClient();
const { game, isJudge, isStarted } = useCurrentGame(gameId);
const activePhase = useAppSelector((state) =>
gameId != null ? GameSelectors.getActivePhase(state, gameId) : undefined,
);
const localPlayerId = game?.localPlayerId;
const tableCards = useAppSelector((state) =>
gameId != null && localPlayerId != null
? GameSelectors.getCards(state, gameId, localPlayerId, App.ZoneName.TABLE)
: undefined,
);
// Desktop: only the active player (or a judge) can advance the phase.
const canAdvance =
gameId != null &&
game != null &&
isStarted &&
(isJudge || game.activePlayerId === game.localPlayerId);
const handlePhaseClick = (phase: App.Phase) => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.setActivePhase(gameId, { phase });
};
const handlePass = () => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.nextTurn(gameId);
};
// Desktop's untap-step double-click fires "Untap All" on the local player's
// table zone (cockatrice/src/game/player/player_actions.cpp actUntapAll).
// We replicate by sending one setCardAttr per tapped card; there is no
// batch variant on the wire.
const handleUntapAll = () => {
if (!canAdvance || gameId == null || !tableCards) {
return;
}
for (const card of tableCards) {
if (card.tapped) {
webClient.request.game.setCardAttr(gameId, {
zone: App.ZoneName.TABLE,
cardId: card.id,
attribute: Data.CardAttribute.AttrTapped,
attrValue: '0',
});
}
}
};
const handleDrawOne = () => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.drawCards(gameId, { number: 1 });
};
return { activePhase, canAdvance, handlePhaseClick, handlePass, handleUntapAll, handleDrawOne };
}

View file

@ -0,0 +1,13 @@
.player-board {
display: grid;
grid-template-columns: 160px minmax(0, 1fr) 110px;
height: 100%;
min-height: 0;
background: #0a1225;
border-bottom: 2px solid #1a2b52;
}
.player-board--mirrored {
border-bottom: none;
border-top: 2px solid #1a2b52;
}

View file

@ -0,0 +1,108 @@
import { screen } from '@testing-library/react';
import { App } from '@app/types';
// Block Battlefield's Dexie-backed useSettings from firing an async settle
// after mount (would produce an unwrapped React state update).
vi.mock('../../../hooks/useSettings');
import { makeStoreState, renderWithProviders, makeUser } from '../../../__test-utils__';
import {
makeCard,
makeGameEntry,
makePlayerEntry,
makePlayerProperties,
makeZoneEntry,
} from '../../../store/game/__mocks__/fixtures';
import PlayerBoard from './PlayerBoard';
function buildState() {
const table = makeZoneEntry({
name: App.ZoneName.TABLE,
cards: [
makeCard({ id: 1, name: 'Row0-Card', x: 0, y: 0 }),
makeCard({ id: 2, name: 'Row2-Card', x: 0, y: 2 }),
],
cardCount: 2,
});
const hand = makeZoneEntry({ name: App.ZoneName.HAND, cardCount: 0 });
const deck = makeZoneEntry({ name: App.ZoneName.DECK, cardCount: 60 });
const player = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Trajer' }),
}),
zones: {
[App.ZoneName.TABLE]: table,
[App.ZoneName.HAND]: hand,
[App.ZoneName.DECK]: deck,
},
});
return makeStoreState({
games: {
games: {
1: makeGameEntry({ localPlayerId: 1, players: { 1: player } }),
},
},
});
}
describe('PlayerBoard', () => {
it('renders the info panel, battlefield, and zone rail in order', () => {
renderWithProviders(<PlayerBoard gameId={1} playerId={1} />, {
preloadedState: buildState(),
});
expect(screen.getByText('Trajer')).toBeInTheDocument();
expect(screen.getByTestId('battlefield')).toBeInTheDocument();
expect(screen.getByTestId('zone-rail')).toBeInTheDocument();
});
it('passes mirrored=false by default so the battlefield uses natural row order', () => {
const { container } = renderWithProviders(
<PlayerBoard gameId={1} playerId={1} />,
{ preloadedState: buildState() },
);
const rowsInOrder = Array.from(
container.querySelectorAll('.battlefield__row'),
).map((r) => r.getAttribute('data-row'));
expect(rowsInOrder).toEqual(['0', '1', '2']);
expect(container.querySelector('.card-slot--inverted')).toBeNull();
});
it('propagates mirrored=true → battlefield reverses row order and cards are inverted', () => {
const { container } = renderWithProviders(
<PlayerBoard gameId={1} playerId={1} mirrored />,
{ preloadedState: buildState() },
);
const rowsInOrder = Array.from(
container.querySelectorAll('.battlefield__row'),
).map((r) => r.getAttribute('data-row'));
expect(rowsInOrder).toEqual(['2', '1', '0']);
expect(container.querySelectorAll('.card-slot--inverted').length).toBeGreaterThan(0);
});
it('keeps the info panel on the left and zone rail on the right in mirrored mode', () => {
const { container } = renderWithProviders(
<PlayerBoard gameId={1} playerId={1} mirrored />,
{ preloadedState: buildState() },
);
const children = Array.from(container.querySelector('.player-board')!.children);
expect(children[0]).toHaveClass('player-info-panel');
expect(children[1]).toHaveClass('battlefield');
expect(children[2]).toHaveClass('zone-rail');
});
it('adds the --mirrored CSS modifier only when mirrored', () => {
const { container, rerender } = renderWithProviders(
<PlayerBoard gameId={1} playerId={1} />,
{ preloadedState: buildState() },
);
expect(container.querySelector('.player-board--mirrored')).toBeNull();
rerender(<PlayerBoard gameId={1} playerId={1} mirrored />);
expect(container.querySelector('.player-board--mirrored')).not.toBeNull();
});
});

View file

@ -0,0 +1,77 @@
import type { Data } from '@app/types';
import { cx } from '@app/utils';
import Battlefield from '../Battlefield/Battlefield';
import PlayerInfoPanel from '../PlayerInfoPanel/PlayerInfoPanel';
import ZoneRail from '../ZoneRail/ZoneRail';
import './PlayerBoard.css';
export interface PlayerBoardProps {
gameId: number;
playerId: number;
mirrored?: boolean;
canAct?: boolean;
canEditCounters?: boolean;
arrowSourceKey?: string | null;
onCardHover?: (card: Data.ServerInfo_Card) => void;
onCardClick?: (playerId: number, zone: string, card: Data.ServerInfo_Card) => void;
onCardContextMenu?: (card: Data.ServerInfo_Card, event: React.MouseEvent) => void;
onCardDoubleClick?: (card: Data.ServerInfo_Card) => void;
onZoneClick?: (playerId: number, zoneName: string) => void;
onZoneContextMenu?: (playerId: number, zoneName: string, event: React.MouseEvent) => void;
onRequestCreateCounter?: () => void;
onPlayerContextMenu?: (event: React.MouseEvent) => void;
}
function PlayerBoard({
gameId,
playerId,
mirrored = false,
canAct = false,
canEditCounters = false,
arrowSourceKey = null,
onCardHover,
onCardClick,
onCardContextMenu,
onCardDoubleClick,
onZoneClick,
onZoneContextMenu,
onRequestCreateCounter,
onPlayerContextMenu,
}: PlayerBoardProps) {
return (
<div
className={cx('player-board', { 'player-board--mirrored': mirrored })}
data-testid={`player-board-${playerId}`}
>
<PlayerInfoPanel
gameId={gameId}
playerId={playerId}
canEdit={canEditCounters}
onRequestCreateCounter={onRequestCreateCounter}
onContextMenu={onPlayerContextMenu}
/>
<Battlefield
gameId={gameId}
playerId={playerId}
mirrored={mirrored}
canAct={canAct}
arrowSourceKey={arrowSourceKey}
onCardHover={onCardHover}
onCardClick={onCardClick}
onCardContextMenu={onCardContextMenu}
onCardDoubleClick={onCardDoubleClick}
/>
<ZoneRail
gameId={gameId}
playerId={playerId}
onCardHover={onCardHover}
onZoneClick={onZoneClick}
onZoneContextMenu={onZoneContextMenu}
/>
</div>
);
}
export default PlayerBoard;

View file

@ -0,0 +1,3 @@
.player-context-menu .MuiMenuItem-root {
font-size: 13px;
}

View file

@ -0,0 +1,63 @@
import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '../../../__test-utils__';
import PlayerContextMenu from './PlayerContextMenu';
const NOOP = () => {};
const DEFAULT_PROPS = {
isOpen: true,
anchorPosition: { top: 10, left: 10 },
onClose: NOOP,
onRequestCreateToken: NOOP,
onRequestViewSideboard: NOOP,
};
describe('PlayerContextMenu', () => {
it('fires onRequestCreateToken and closes when "Create token…" is clicked', () => {
const onRequestCreateToken = vi.fn();
const onClose = vi.fn();
renderWithProviders(
<PlayerContextMenu
{...DEFAULT_PROPS}
onClose={onClose}
onRequestCreateToken={onRequestCreateToken}
/>,
);
fireEvent.click(screen.getByRole('menuitem', { name: /create token/i }));
expect(onRequestCreateToken).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('fires onRequestViewSideboard and closes when "View sideboard…" is clicked', () => {
const onRequestViewSideboard = vi.fn();
const onClose = vi.fn();
renderWithProviders(
<PlayerContextMenu
{...DEFAULT_PROPS}
onClose={onClose}
onRequestViewSideboard={onRequestViewSideboard}
/>,
);
fireEvent.click(screen.getByRole('menuitem', { name: /view sideboard/i }));
expect(onRequestViewSideboard).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('does not render menu items when closed', () => {
renderWithProviders(
<PlayerContextMenu
{...DEFAULT_PROPS}
isOpen={false}
anchorPosition={null}
/>,
);
expect(screen.queryByRole('menuitem')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,48 @@
import Divider from '@mui/material/Divider';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import './PlayerContextMenu.css';
export interface PlayerContextMenuProps {
isOpen: boolean;
anchorPosition: { top: number; left: number } | null;
onClose: () => void;
onRequestCreateToken: () => void;
onRequestViewSideboard: () => void;
}
function PlayerContextMenu({
isOpen,
anchorPosition,
onClose,
onRequestCreateToken,
onRequestViewSideboard,
}: PlayerContextMenuProps) {
const handleCreateToken = () => {
onRequestCreateToken();
onClose();
};
const handleViewSideboard = () => {
onRequestViewSideboard();
onClose();
};
return (
<Menu
open={isOpen}
onClose={onClose}
anchorReference="anchorPosition"
anchorPosition={anchorPosition ?? undefined}
data-testid="player-context-menu"
className="player-context-menu"
>
<MenuItem onClick={handleCreateToken}>Create token</MenuItem>
<Divider />
<MenuItem onClick={handleViewSideboard}>View sideboard</MenuItem>
</Menu>
);
}
export default PlayerContextMenu;

View file

@ -0,0 +1,267 @@
.player-info-panel {
width: 160px;
height: 100%;
padding: 8px 10px;
box-sizing: border-box;
display: flex;
flex-direction: column;
background: #0a1225;
border-right: 1px solid #1a2b52;
color: #e5ecf7;
font-size: 12px;
}
.player-info-panel--empty {
opacity: 0.4;
}
.player-info-panel__header {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid #1a2b52;
}
.player-info-panel__host-badge {
color: var(--color-highlight-yellow);
font-size: 13px;
line-height: 1;
flex-shrink: 0;
}
.player-info-panel__name {
flex: 1;
font-weight: 700;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-info-panel__sideboard-lock {
font-size: 11px;
line-height: 1;
flex-shrink: 0;
}
.player-info-panel__ping {
color: #7f90b5;
font-size: 10px;
}
.player-info-panel__flag {
align-self: flex-start;
padding: 1px 6px;
background: #6a2626;
color: #ffdede;
font-size: 10px;
font-weight: 600;
border-radius: 3px;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 4px;
}
.player-info-panel__flag--ready {
background: #22562b;
color: #dfffe3;
}
/* Life display: prominent box above the regular counter list. Mirrors
desktop's PlayerTarget sizing where Life renders at ~2x other counters. */
.player-info-panel__life {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto;
grid-column-gap: 6px;
align-items: center;
margin: 2px 0 10px;
padding: 6px 10px 4px;
background: #0f1a35;
border: 2px solid #4a5d87;
border-radius: 6px;
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.4);
}
.player-info-panel__life-btn {
width: 22px;
height: 22px;
padding: 0;
background: #17223d;
border: 1px solid #355090;
color: #dae3f7;
font: inherit;
font-size: 16px;
line-height: 1;
border-radius: 3px;
cursor: pointer;
}
.player-info-panel__life-btn:hover {
background: #223060;
color: #fff;
}
.player-info-panel__life-value,
.player-info-panel__life-input {
grid-row: 1;
font-size: 28px;
font-weight: 800;
line-height: 1;
color: #ffffff;
text-align: center;
font-variant-numeric: tabular-nums;
}
.player-info-panel__life-input {
width: 72px;
height: 32px;
padding: 0 6px;
background: #17223d;
border: 1px solid #355090;
border-radius: 3px;
}
.player-info-panel__life-value--editable {
cursor: text;
border-bottom: 1px dashed rgba(255, 255, 255, 0.25);
}
.player-info-panel__life-value--editable:hover {
background: rgba(255, 255, 255, 0.06);
}
.player-info-panel__life-label {
grid-row: 2;
grid-column: 1 / -1;
text-align: center;
font-size: 9px;
font-weight: 700;
letter-spacing: 2px;
color: #7f90b5;
margin-top: 4px;
}
.player-info-panel__counters {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.player-info-panel__counter {
display: grid;
grid-template-columns: 12px 1fr auto auto auto auto;
align-items: center;
gap: 3px;
padding: 2px 0;
}
.player-info-panel__counter--empty {
color: #5a6a8a;
font-style: italic;
grid-template-columns: 1fr;
}
.player-info-panel__swatch {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.player-info-panel__counter-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-info-panel__counter-value {
font-weight: 700;
color: #fff;
min-width: 22px;
text-align: right;
padding: 0 4px;
}
.player-info-panel__counter-value--editable {
cursor: text;
border-bottom: 1px dashed rgba(255, 255, 255, 0.2);
}
.player-info-panel__counter-value--editable:hover {
background: rgba(255, 255, 255, 0.06);
}
.player-info-panel__counter-input {
width: 36px;
height: 18px;
padding: 0 4px;
box-sizing: border-box;
background: #17223d;
border: 1px solid #355090;
color: #fff;
font: inherit;
font-weight: 700;
text-align: right;
border-radius: 2px;
}
.player-info-panel__counter-input::-webkit-outer-spin-button,
.player-info-panel__counter-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.player-info-panel__counter-btn {
width: 18px;
height: 18px;
padding: 0;
background: #17223d;
border: 1px solid #233a68;
color: #c8d4ef;
font: inherit;
font-size: 13px;
line-height: 1;
border-radius: 2px;
cursor: pointer;
}
.player-info-panel__counter-btn:hover {
background: #223060;
border-color: #355090;
color: #fff;
}
.player-info-panel__counter-btn--del {
color: #f7a3a3;
border-color: #5e2828;
background: #3f1a1a;
}
.player-info-panel__counter-btn--del:hover {
background: #5e2828;
color: #fff;
}
.player-info-panel__new-counter {
margin-top: 8px;
padding: 3px 6px;
background: transparent;
border: 1px dashed #355090;
color: #8ab0ff;
font: inherit;
font-size: 11px;
border-radius: 3px;
cursor: pointer;
text-align: center;
}
.player-info-panel__new-counter:hover {
background: rgba(138, 176, 255, 0.08);
color: #b8d0ff;
}

View file

@ -0,0 +1,348 @@
import { screen, fireEvent } from '@testing-library/react';
import { create } from '@bufbuild/protobuf';
import { Data } from '@app/types';
import { createMockWebClient, makeStoreState, renderWithProviders, makeUser } from '../../../__test-utils__';
import {
makeCounter,
makeGameEntry,
makePlayerEntry,
makePlayerProperties,
} from '../../../store/game/__mocks__/fixtures';
import PlayerInfoPanel from './PlayerInfoPanel';
function statefulPlayer(
overrides: Partial<Parameters<typeof makePlayerEntry>[0]> = {},
) {
const player = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Pumuky' }),
pingSeconds: 42,
}),
...overrides,
});
return makeStoreState({
games: {
games: {
1: makeGameEntry({
localPlayerId: 1,
players: { 1: player },
}),
},
},
});
}
describe('PlayerInfoPanel', () => {
it('renders the player name and ping', () => {
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: statefulPlayer(),
});
expect(screen.getByText('Pumuky')).toBeInTheDocument();
expect(screen.getByText('42s')).toBeInTheDocument();
});
it('falls back to "(unknown)" when userInfo is absent', () => {
const player = makePlayerEntry({
properties: makePlayerProperties({ playerId: 1 }),
});
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: makeStoreState({
games: { games: { 1: makeGameEntry({ players: { 1: player } }) } },
}),
});
expect(screen.getByText('(unknown)')).toBeInTheDocument();
});
it('renders Life in a prominent block above the rest, with "LIFE" label', () => {
const life = makeCounter({
id: 1,
name: 'Life',
count: 20,
counterColor: create(Data.colorSchema, { r: 255, g: 255, b: 255, a: 255 }),
});
const white = makeCounter({
id: 2,
name: 'W',
count: 3,
counterColor: create(Data.colorSchema, { r: 250, g: 245, b: 220, a: 255 }),
});
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: statefulPlayer({
counters: { 1: life, 2: white },
}),
});
expect(screen.getByTestId('life-1')).toHaveTextContent('20');
expect(screen.getByText('LIFE')).toBeInTheDocument();
// Other counters still render in the list with their name.
expect(screen.getByText('W')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
});
it('shows an empty-state line when no counters exist', () => {
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: statefulPlayer({ counters: {} }),
});
expect(screen.getByText(/no counters/i)).toBeInTheDocument();
});
it('shows the Conceded flag when player has conceded', () => {
const player = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Quitter' }),
conceded: true,
}),
});
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: makeStoreState({
games: { games: { 1: makeGameEntry({ players: { 1: player } }) } },
}),
});
expect(screen.getByText(/conceded/i)).toBeInTheDocument();
});
it('shows the Ready flag when readyStart is true and player has not conceded', () => {
const player = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Waiting' }),
readyStart: true,
}),
});
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: makeStoreState({
games: { games: { 1: makeGameEntry({ players: { 1: player } }) } },
}),
});
expect(screen.getByText(/ready/i)).toBeInTheDocument();
});
it('renders an empty panel when the player is missing', () => {
const { container } = renderWithProviders(
<PlayerInfoPanel gameId={1} playerId={999} />,
{ preloadedState: statefulPlayer() },
);
expect(container.querySelector('.player-info-panel--empty')).not.toBeNull();
expect(screen.queryByText('Pumuky')).not.toBeInTheDocument();
});
it('renders a host badge when the player is the game host', () => {
const player = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Host' }),
}),
});
const { container } = renderWithProviders(
<PlayerInfoPanel gameId={1} playerId={1} />,
{
preloadedState: makeStoreState({
games: {
games: {
1: makeGameEntry({ hostId: 1, players: { 1: player } }),
},
},
}),
},
);
expect(container.querySelector('.player-info-panel__host-badge')).not.toBeNull();
});
it('omits the host badge when the player is not the host', () => {
const player = makePlayerEntry({
properties: makePlayerProperties({
playerId: 2,
userInfo: makeUser({ name: 'Guest' }),
}),
});
const { container } = renderWithProviders(
<PlayerInfoPanel gameId={1} playerId={2} />,
{
preloadedState: makeStoreState({
games: {
games: {
1: makeGameEntry({ hostId: 1, players: { 2: player } }),
},
},
}),
},
);
expect(container.querySelector('.player-info-panel__host-badge')).toBeNull();
});
// Sideboard lock indicator — mirrors desktop's `DeckViewContainer`
// lock UI. The webclient surfaces it on the info panel since we don't
// have a persistent deck view.
it('renders a 🔒 indicator when player.properties.sideboardLocked is true', () => {
const player = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'P1' }),
sideboardLocked: true,
}),
});
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: makeStoreState({
games: { games: { 1: makeGameEntry({ players: { 1: player } }) } },
}),
});
expect(screen.getByLabelText('sideboard locked')).toBeInTheDocument();
});
it('omits the lock indicator when sideboardLocked is false', () => {
const player = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'P1' }),
sideboardLocked: false,
}),
});
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: makeStoreState({
games: { games: { 1: makeGameEntry({ players: { 1: player } }) } },
}),
});
expect(screen.queryByLabelText('sideboard locked')).not.toBeInTheDocument();
});
describe('editable counters', () => {
const life = makeCounter({
id: 1,
name: 'Life',
count: 20,
counterColor: create(Data.colorSchema, { r: 255, g: 255, b: 255, a: 255 }),
});
it('does not render counter controls when canEdit is false (default)', () => {
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: statefulPlayer({ counters: { 1: life } }),
});
expect(screen.queryByLabelText('increment Life')).not.toBeInTheDocument();
expect(screen.queryByLabelText('decrement Life')).not.toBeInTheDocument();
expect(screen.queryByLabelText('delete Life')).not.toBeInTheDocument();
});
it('renders +/ controls on the Life block when canEdit is true (Life has no delete — desktop parity)', () => {
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} canEdit />, {
preloadedState: statefulPlayer({ counters: { 1: life } }),
});
expect(screen.getByLabelText('increment Life')).toBeInTheDocument();
expect(screen.getByLabelText('decrement Life')).toBeInTheDocument();
expect(screen.queryByLabelText('delete Life')).not.toBeInTheDocument();
});
it('dispatches incCounter(+1) when + is clicked', () => {
const webClient = createMockWebClient();
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} canEdit />, {
preloadedState: statefulPlayer({ counters: { 1: life } }),
webClient,
});
fireEvent.click(screen.getByLabelText('increment Life'));
expect(webClient.request.game.incCounter).toHaveBeenCalledWith(1, { counterId: 1, delta: 1 });
});
it('dispatches incCounter(-1) when is clicked', () => {
const webClient = createMockWebClient();
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} canEdit />, {
preloadedState: statefulPlayer({ counters: { 1: life } }),
webClient,
});
fireEvent.click(screen.getByLabelText('decrement Life'));
expect(webClient.request.game.incCounter).toHaveBeenCalledWith(1, { counterId: 1, delta: -1 });
});
it('dispatches delCounter when × is clicked on a non-Life counter', () => {
const webClient = createMockWebClient();
const mana = makeCounter({
id: 2,
name: 'W',
count: 3,
counterColor: create(Data.colorSchema, { r: 255, g: 255, b: 255, a: 255 }),
});
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} canEdit />, {
preloadedState: statefulPlayer({ counters: { 2: mana } }),
webClient,
});
fireEvent.click(screen.getByLabelText('delete W'));
expect(webClient.request.game.delCounter).toHaveBeenCalledWith(1, { counterId: 2 });
});
it('swaps the value into an input on click and dispatches setCounter on Enter', () => {
const webClient = createMockWebClient();
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} canEdit />, {
preloadedState: statefulPlayer({ counters: { 1: life } }),
webClient,
});
fireEvent.click(screen.getByText('20'));
const input = screen.getByLabelText('set Life') as HTMLInputElement;
fireEvent.change(input, { target: { value: '18' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(webClient.request.game.setCounter).toHaveBeenCalledWith(1, { counterId: 1, value: 18 });
});
it('does not dispatch setCounter when Escape is pressed during inline edit', () => {
const webClient = createMockWebClient();
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} canEdit />, {
preloadedState: statefulPlayer({ counters: { 1: life } }),
webClient,
});
fireEvent.click(screen.getByText('20'));
const input = screen.getByLabelText('set Life') as HTMLInputElement;
fireEvent.change(input, { target: { value: '99' } });
fireEvent.keyDown(input, { key: 'Escape' });
expect(webClient.request.game.setCounter).not.toHaveBeenCalled();
});
it('fires onRequestCreateCounter when "+ New counter" is clicked', () => {
const onRequestCreateCounter = vi.fn();
renderWithProviders(
<PlayerInfoPanel
gameId={1}
playerId={1}
canEdit
onRequestCreateCounter={onRequestCreateCounter}
/>,
{ preloadedState: statefulPlayer({ counters: { 1: life } }) },
);
fireEvent.click(screen.getByText('+ New counter'));
expect(onRequestCreateCounter).toHaveBeenCalled();
});
it('does not render the new-counter button when canEdit is false', () => {
renderWithProviders(
<PlayerInfoPanel gameId={1} playerId={1} onRequestCreateCounter={() => {}} />,
{ preloadedState: statefulPlayer({ counters: {} }) },
);
expect(screen.queryByText('+ New counter')).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,239 @@
import { cx } from '@app/utils';
import type { Data } from '@app/types';
import { cssColor, usePlayerInfoPanel } from './usePlayerInfoPanel';
import './PlayerInfoPanel.css';
export interface PlayerInfoPanelProps {
gameId: number;
playerId: number;
canEdit?: boolean;
onRequestCreateCounter?: () => void;
onContextMenu?: (event: React.MouseEvent) => void;
}
function PlayerInfoPanel({
gameId,
playerId,
canEdit = false,
onRequestCreateCounter,
onContextMenu,
}: PlayerInfoPanelProps) {
const {
player,
isHost,
lifeCounter,
otherCounters,
editingId,
editDraft,
setEditDraft,
beginEdit,
commitEdit,
cancelEdit,
handleIncrement,
handleDelete,
} = usePlayerInfoPanel({ gameId, playerId });
if (!player) {
return <div className="player-info-panel player-info-panel--empty" />;
}
const name = player.properties.userInfo?.name ?? '(unknown)';
const ping = player.properties.pingSeconds ?? 0;
const conceded = player.properties.conceded;
const ready = player.properties.readyStart;
const sideboardLocked = player.properties.sideboardLocked ?? false;
const renderCounterRow = (c: Data.ServerInfo_Counter) => (
<li
key={c.id}
className="player-info-panel__counter"
data-testid={`counter-${c.id}`}
>
<span
className="player-info-panel__swatch"
style={{ background: cssColor(c.counterColor) }}
/>
<span className="player-info-panel__counter-name" title={c.name}>{c.name}</span>
{canEdit && (
<button
type="button"
className="player-info-panel__counter-btn"
aria-label={`decrement ${c.name}`}
onClick={() => handleIncrement(c.id, -1)}
>
</button>
)}
{editingId === c.id ? (
<input
type="number"
autoFocus
className="player-info-panel__counter-input"
value={editDraft}
onChange={(e) => setEditDraft(e.target.value)}
onBlur={() => commitEdit(c.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitEdit(c.id);
}
if (e.key === 'Escape') {
cancelEdit();
}
}}
aria-label={`set ${c.name}`}
/>
) : (
<span
className={cx('player-info-panel__counter-value', {
'player-info-panel__counter-value--editable': canEdit,
})}
onClick={canEdit ? () => beginEdit(c.id, c.count) : undefined}
role={canEdit ? 'button' : undefined}
tabIndex={canEdit ? 0 : undefined}
>
{c.count}
</span>
)}
{canEdit && (
<button
type="button"
className="player-info-panel__counter-btn"
aria-label={`increment ${c.name}`}
onClick={() => handleIncrement(c.id, +1)}
>
+
</button>
)}
{canEdit && (
<button
type="button"
className="player-info-panel__counter-btn player-info-panel__counter-btn--del"
aria-label={`delete ${c.name}`}
onClick={() => handleDelete(c.id)}
>
×
</button>
)}
</li>
);
return (
<div
className="player-info-panel"
data-testid={`player-info-${playerId}`}
onContextMenu={onContextMenu}
>
<div className="player-info-panel__header">
{isHost && (
<span
className="player-info-panel__host-badge"
aria-label="host"
title="Host"
>
</span>
)}
<span className="player-info-panel__name">{name}</span>
{sideboardLocked && (
<span
className="player-info-panel__sideboard-lock"
aria-label="sideboard locked"
title="Sideboard locked"
>
🔒
</span>
)}
<span className="player-info-panel__ping" title={`ping ${ping}s`}>
{ping}s
</span>
</div>
{conceded && <div className="player-info-panel__flag">Conceded</div>}
{!conceded && ready && <div className="player-info-panel__flag player-info-panel__flag--ready">Ready</div>}
{lifeCounter && (
<div
className="player-info-panel__life"
data-testid={`life-${playerId}`}
style={{ borderColor: cssColor(lifeCounter.counterColor) }}
>
{canEdit && (
<button
type="button"
className="player-info-panel__life-btn"
aria-label="decrement Life"
onClick={() => handleIncrement(lifeCounter.id, -1)}
>
</button>
)}
{editingId === lifeCounter.id ? (
<input
type="number"
autoFocus
className="player-info-panel__life-input"
value={editDraft}
onChange={(e) => setEditDraft(e.target.value)}
onBlur={() => commitEdit(lifeCounter.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitEdit(lifeCounter.id);
}
if (e.key === 'Escape') {
cancelEdit();
}
}}
aria-label="set Life"
/>
) : (
<span
className={cx('player-info-panel__life-value', {
'player-info-panel__life-value--editable': canEdit,
})}
onClick={canEdit ? () => beginEdit(lifeCounter.id, lifeCounter.count) : undefined}
role={canEdit ? 'button' : undefined}
tabIndex={canEdit ? 0 : undefined}
aria-label={`Life: ${lifeCounter.count}`}
>
{lifeCounter.count}
</span>
)}
{canEdit && (
<button
type="button"
className="player-info-panel__life-btn"
aria-label="increment Life"
onClick={() => handleIncrement(lifeCounter.id, +1)}
>
+
</button>
)}
<div className="player-info-panel__life-label">LIFE</div>
</div>
)}
<ul className="player-info-panel__counters">
{otherCounters.length === 0 && !lifeCounter && (
<li className="player-info-panel__counter player-info-panel__counter--empty">
no counters
</li>
)}
{otherCounters.map(renderCounterRow)}
</ul>
{canEdit && onRequestCreateCounter && (
<button
type="button"
className="player-info-panel__new-counter"
onClick={onRequestCreateCounter}
>
+ New counter
</button>
)}
</div>
);
}
export default PlayerInfoPanel;

View file

@ -0,0 +1,106 @@
import { useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import type { Data, Enriched } from '@app/types';
export function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
if (!c) {
return '#666';
}
return `rgba(${c.r}, ${c.g}, ${c.b}, ${(c.a ?? 255) / 255})`;
}
// Desktop renders Life larger/bolder than other counters (see
// cockatrice/src/game/player/player.cpp PlayerTarget sizing). We special-
// case the counter whose name is exactly 'Life' (case-insensitive) and
// pull it out of the regular counter list into a prominent life block.
export function isLifeCounter(c: { name: string }): boolean {
return c.name.trim().toLowerCase() === 'life';
}
export interface PlayerInfoPanel {
player: Enriched.PlayerEntry | undefined;
isHost: boolean;
lifeCounter: Data.ServerInfo_Counter | undefined;
otherCounters: Data.ServerInfo_Counter[];
editingId: number | null;
editDraft: string;
setEditDraft: (v: string) => void;
beginEdit: (counterId: number, currentValue: number) => void;
commitEdit: (counterId: number) => void;
cancelEdit: () => void;
handleIncrement: (counterId: number, delta: number) => void;
handleDelete: (counterId: number) => void;
}
export interface UsePlayerInfoPanelArgs {
gameId: number;
playerId: number;
}
export function usePlayerInfoPanel({
gameId,
playerId,
}: UsePlayerInfoPanelArgs): PlayerInfoPanel {
const webClient = useWebClient();
const player = useAppSelector((state) => GameSelectors.getPlayer(state, gameId, playerId));
const counters = useAppSelector((state) => GameSelectors.getCounters(state, gameId, playerId));
const hostId = useAppSelector((state) => GameSelectors.getHostId(state, gameId));
const [editingId, setEditingId] = useState<number | null>(null);
const [editDraft, setEditDraft] = useState('');
const isHost = hostId != null && hostId === playerId;
const allCounters = Object.values(counters);
const lifeCounter = allCounters.find(isLifeCounter);
const otherCounters = allCounters.filter((c) => !isLifeCounter(c));
const handleIncrement = (counterId: number, delta: number) => {
webClient.request.game.incCounter(gameId, { counterId, delta });
};
const handleDelete = (counterId: number) => {
webClient.request.game.delCounter(gameId, { counterId });
};
const beginEdit = (counterId: number, currentValue: number) => {
setEditingId(counterId);
setEditDraft(String(currentValue));
};
const commitEdit = (counterId: number) => {
const trimmed = editDraft.trim();
// Empty input cancels the edit (desktop inline edits treat blur-with-
// no-change and blur-with-empty-string identically). Prior behavior
// coerced '' → 0 because `Number('')` is 0 and `Number.isInteger(0)` is
// true, which surprised users expecting cancel-on-blank.
if (trimmed.length === 0) {
setEditingId(null);
return;
}
const value = Number(trimmed);
if (Number.isInteger(value)) {
webClient.request.game.setCounter(gameId, { counterId, value });
}
setEditingId(null);
};
const cancelEdit = () => {
setEditingId(null);
};
return {
player,
isHost,
lifeCounter,
otherCounters,
editingId,
editDraft,
setEditDraft,
beginEdit,
commitEdit,
cancelEdit,
handleIncrement,
handleDelete,
};
}

View file

@ -0,0 +1,76 @@
.player-list {
padding: 8px 12px;
background: #0a1225;
border-bottom: 1px solid #1a2b52;
color: #e5ecf7;
font-size: 12px;
}
.player-list__heading {
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: #8597bb;
margin-bottom: 6px;
}
.player-list__items {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.player-list__empty {
color: #5a6a8a;
font-style: italic;
}
.player-list__item {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
}
.player-list__host-badge {
color: var(--color-highlight-yellow);
font-size: 12px;
line-height: 1;
}
.player-list__indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #3a4b73;
}
.player-list__indicator--active {
background: #d48a00;
box-shadow: 0 0 4px var(--color-highlight-yellow);
}
.player-list__item--active .player-list__name {
font-weight: 700;
}
.player-list__item--conceded {
opacity: 0.5;
text-decoration: line-through;
}
.player-list__name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-list__ping {
color: #7f90b5;
font-size: 10px;
}

View file

@ -0,0 +1,143 @@
import { screen } from '@testing-library/react';
import { makeStoreState, renderWithProviders, makeUser } from '../../../__test-utils__';
import {
makeGameEntry,
makePlayerEntry,
makePlayerProperties,
} from '../../../store/game/__mocks__/fixtures';
import PlayerList from './PlayerList';
function buildState(
players: ReturnType<typeof makePlayerEntry>[],
activePlayerId: number,
hostId?: number,
) {
const byId: Record<number, ReturnType<typeof makePlayerEntry>> = {};
for (const p of players) {
byId[p.properties.playerId] = p;
}
return makeStoreState({
games: {
games: {
1: makeGameEntry({
players: byId,
activePlayerId,
...(hostId != null ? { hostId } : {}),
}),
},
},
});
}
describe('PlayerList', () => {
it('lists every player in the game', () => {
const p1 = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Alice' }),
pingSeconds: 10,
}),
});
const p2 = makePlayerEntry({
properties: makePlayerProperties({
playerId: 2,
userInfo: makeUser({ name: 'Bob' }),
pingSeconds: 20,
}),
});
renderWithProviders(<PlayerList gameId={1} />, {
preloadedState: buildState([p1, p2], 1),
});
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.getByText('10s')).toBeInTheDocument();
expect(screen.getByText('20s')).toBeInTheDocument();
});
it('highlights the active player', () => {
const p1 = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Alice' }),
}),
});
const p2 = makePlayerEntry({
properties: makePlayerProperties({
playerId: 2,
userInfo: makeUser({ name: 'Bob' }),
}),
});
renderWithProviders(<PlayerList gameId={1} />, {
preloadedState: buildState([p1, p2], 2),
});
expect(screen.getByTestId('player-list-item-2')).toHaveClass(
'player-list__item--active',
);
expect(screen.getByTestId('player-list-item-1')).not.toHaveClass(
'player-list__item--active',
);
});
it('dims conceded players', () => {
const p1 = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Alice' }),
conceded: true,
}),
});
renderWithProviders(<PlayerList gameId={1} />, {
preloadedState: buildState([p1], 0),
});
expect(screen.getByTestId('player-list-item-1')).toHaveClass(
'player-list__item--conceded',
);
});
it('shows empty state when there are no players', () => {
renderWithProviders(<PlayerList gameId={1} />, {
preloadedState: buildState([], 0),
});
expect(screen.getByText(/no players/i)).toBeInTheDocument();
});
it('handles missing gameId without throwing', () => {
renderWithProviders(<PlayerList gameId={undefined} />, {
preloadedState: makeStoreState({}),
});
expect(screen.getByText(/no players/i)).toBeInTheDocument();
});
it('renders a host badge on the host row only', () => {
const p1 = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Alice' }),
}),
});
const p2 = makePlayerEntry({
properties: makePlayerProperties({
playerId: 2,
userInfo: makeUser({ name: 'Bob' }),
}),
});
renderWithProviders(<PlayerList gameId={1} />, {
preloadedState: buildState([p1, p2], 1, 2),
});
const bobRow = screen.getByTestId('player-list-item-2');
const aliceRow = screen.getByTestId('player-list-item-1');
expect(bobRow.querySelector('.player-list__host-badge')).not.toBeNull();
expect(aliceRow.querySelector('.player-list__host-badge')).toBeNull();
});
});

View file

@ -0,0 +1,68 @@
import { GameSelectors, useAppSelector } from '@app/store';
import { cx } from '@app/utils';
import './PlayerList.css';
export interface PlayerListProps {
gameId: number | undefined;
}
function PlayerList({ gameId }: PlayerListProps) {
const players = useAppSelector((state) =>
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
);
const activePlayerId = useAppSelector((state) =>
gameId != null ? GameSelectors.getActivePlayerId(state, gameId) : undefined,
);
const hostId = useAppSelector((state) =>
gameId != null ? GameSelectors.getHostId(state, gameId) : undefined,
);
const entries = players ? Object.values(players) : [];
return (
<div className="player-list" data-testid="player-list">
<div className="player-list__heading">Players</div>
<ul className="player-list__items">
{entries.length === 0 && (
<li className="player-list__empty">no players</li>
)}
{entries.map((p) => {
const pid = p.properties.playerId;
const name = p.properties.userInfo?.name ?? '(unknown)';
const isActive = pid === activePlayerId;
const isHost = pid === hostId;
return (
<li
key={pid}
className={cx('player-list__item', {
'player-list__item--active': isActive,
'player-list__item--conceded': p.properties.conceded,
})}
data-testid={`player-list-item-${pid}`}
>
<span
className={cx('player-list__indicator', {
'player-list__indicator--active': isActive,
})}
/>
{isHost && (
<span
className="player-list__host-badge"
aria-label="host"
title="Host"
>
</span>
)}
<span className="player-list__name">{name}</span>
<span className="player-list__ping">{p.properties.pingSeconds}s</span>
</li>
);
})}
</ul>
</div>
);
}
export default PlayerList;

View file

@ -0,0 +1,21 @@
.right-panel {
width: 320px;
height: 100%;
display: flex;
flex-direction: column;
background: #0a1225;
border-left: 1px solid #1a2b52;
overflow: hidden;
}
.right-panel__spectating {
padding: 4px 12px;
background: #23324f;
color: var(--color-highlight-yellow);
font-size: 10px;
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
text-align: center;
border-bottom: 1px solid #1a2b52;
}

View file

@ -0,0 +1,80 @@
import { screen, fireEvent } from '@testing-library/react';
// Block TurnControls' Dexie-backed useSettings from firing an async settle
// after mount (the Dexie mock resolves on a microtask, which would produce
// an unwrapped React state update inside TurnControls).
vi.mock('../../../hooks/useSettings');
import { makeStoreState, renderWithProviders } from '../../../__test-utils__';
import { makeCard, makeGameEntry } from '../../../store/game/__mocks__/fixtures';
import RightPanel from './RightPanel';
function stateWithGame() {
return makeStoreState({ games: { games: { 1: makeGameEntry() } } });
}
const NOOP = () => {};
const DEFAULT_RP_PROPS = {
gameId: 1,
hoveredCard: null,
onRequestRollDie: NOOP,
onRequestConcede: NOOP,
onRequestUnconcede: NOOP,
onRequestGameInfo: NOOP,
onToggleRotate90: NOOP,
isRotated: false,
};
describe('RightPanel', () => {
it('renders CardPreview, PlayerList, GameLog, and TurnControls', () => {
renderWithProviders(<RightPanel {...DEFAULT_RP_PROPS} />, {
preloadedState: stateWithGame(),
});
expect(screen.getByTestId('card-preview')).toBeInTheDocument();
expect(screen.getByTestId('player-list')).toBeInTheDocument();
expect(screen.getByTestId('game-log')).toBeInTheDocument();
expect(screen.getByTestId('turn-controls')).toBeInTheDocument();
});
it('forwards the hovered card into the preview', () => {
const card = makeCard({ name: 'Lightning Bolt' });
renderWithProviders(
<RightPanel {...DEFAULT_RP_PROPS} hoveredCard={card} />,
{ preloadedState: stateWithGame() },
);
const small = document.querySelector('.card-preview__image--small') as HTMLImageElement;
expect(small.src).toContain('Lightning%20Bolt');
});
it('forwards Roll Die clicks through to the parent callback', () => {
const onRequestRollDie = vi.fn();
renderWithProviders(
<RightPanel {...DEFAULT_RP_PROPS} onRequestRollDie={onRequestRollDie} />,
{ preloadedState: stateWithGame() },
);
fireEvent.click(screen.getByRole('button', { name: /roll die/i }));
expect(onRequestRollDie).toHaveBeenCalled();
});
it('shows the Spectating tag when the local user is a spectator', () => {
renderWithProviders(<RightPanel {...DEFAULT_RP_PROPS} />, {
preloadedState: makeStoreState({
games: { games: { 1: makeGameEntry({ spectator: true }) } },
}),
});
expect(screen.getByTestId('spectating-tag')).toBeInTheDocument();
});
it('hides the Spectating tag when the local user is a participant', () => {
renderWithProviders(<RightPanel {...DEFAULT_RP_PROPS} />, {
preloadedState: stateWithGame(),
});
expect(screen.queryByTestId('spectating-tag')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,59 @@
import type { Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
import CardPreview from '../CardPreview/CardPreview';
import GameLog from '../GameLog/GameLog';
import PlayerList from '../PlayerList/PlayerList';
import TurnControls from '../TurnControls/TurnControls';
import './RightPanel.css';
export interface RightPanelProps {
gameId: number | undefined;
hoveredCard: Data.ServerInfo_Card | null | undefined;
onRequestRollDie: () => void;
onRequestConcede: () => void;
onRequestUnconcede: () => void;
onRequestGameInfo: () => void;
onToggleRotate90: () => void;
isRotated: boolean;
}
function RightPanel({
gameId,
hoveredCard,
onRequestRollDie,
onRequestConcede,
onRequestUnconcede,
onRequestGameInfo,
onToggleRotate90,
isRotated,
}: RightPanelProps) {
const isSpectator = useAppSelector((state) =>
gameId != null ? GameSelectors.isSpectator(state, gameId) : false,
);
return (
<aside className="right-panel" data-testid="right-panel">
{isSpectator && (
<div className="right-panel__spectating" data-testid="spectating-tag">
Spectating
</div>
)}
<CardPreview card={hoveredCard} />
<PlayerList gameId={gameId} />
<GameLog gameId={gameId} />
<TurnControls
gameId={gameId}
onRequestRollDie={onRequestRollDie}
onRequestConcede={onRequestConcede}
onRequestUnconcede={onRequestUnconcede}
onRequestGameInfo={onRequestGameInfo}
onToggleRotate90={onToggleRotate90}
isRotated={isRotated}
/>
</aside>
);
}
export default RightPanel;

View file

@ -0,0 +1,54 @@
.stack-strip {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 16px;
background: #0a1225;
border-top: 1px solid #1a2b52;
border-bottom: 1px solid #1a2b52;
font-size: 11px;
color: #c8d4ef;
}
.stack-strip__heading {
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: #8597bb;
flex-shrink: 0;
}
.stack-strip__cell {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border: 1px solid #233a68;
border-radius: 3px;
background: #17223d;
}
.stack-strip__cell[role='button'] {
cursor: pointer;
}
.stack-strip__cell[role='button']:hover {
background: #223060;
border-color: #355090;
}
.stack-strip__label {
font-weight: 600;
color: #c8d4ef;
}
.stack-strip__count {
min-width: 16px;
padding: 0 4px;
background: #050914;
color: var(--color-highlight-yellow);
font-variant-numeric: tabular-nums;
font-weight: 700;
border-radius: 2px;
text-align: center;
}

View file

@ -0,0 +1,107 @@
import { screen, fireEvent } from '@testing-library/react';
import { App } from '@app/types';
import { makeStoreState, renderWithProviders } from '../../../__test-utils__';
import {
makeGameEntry,
makePlayerEntry,
makePlayerProperties,
makeZoneEntry,
} from '../../../store/game/__mocks__/fixtures';
import StackStrip from './StackStrip';
function stateWithStacks(localCount: number, opponentCount: number) {
const local = makePlayerEntry({
properties: makePlayerProperties({ playerId: 1 }),
zones: {
[App.ZoneName.STACK]: makeZoneEntry({
name: App.ZoneName.STACK,
cardCount: localCount,
}),
},
});
const opponent = makePlayerEntry({
properties: makePlayerProperties({ playerId: 2 }),
zones: {
[App.ZoneName.STACK]: makeZoneEntry({
name: App.ZoneName.STACK,
cardCount: opponentCount,
}),
},
});
return makeStoreState({
games: {
games: {
1: makeGameEntry({ localPlayerId: 1, players: { 1: local, 2: opponent } }),
},
},
});
}
describe('StackStrip', () => {
it('renders a cell per entry with the zone cardCount', () => {
renderWithProviders(
<StackStrip
gameId={1}
entries={[
{ playerId: 2, name: 'Opp' },
{ playerId: 1, name: 'Me' },
]}
/>,
{ preloadedState: stateWithStacks(0, 3) },
);
const oppCell = screen.getByTestId('stack-strip-cell-2');
const meCell = screen.getByTestId('stack-strip-cell-1');
expect(oppCell).toHaveTextContent('Opp');
expect(oppCell).toHaveTextContent('3');
expect(meCell).toHaveTextContent('Me');
expect(meCell).toHaveTextContent('0');
});
it('invokes onZoneClick(playerId, "stack") when a cell is clicked', () => {
const onZoneClick = vi.fn();
renderWithProviders(
<StackStrip
gameId={1}
entries={[
{ playerId: 2, name: 'Opp' },
{ playerId: 1, name: 'Me' },
]}
onZoneClick={onZoneClick}
/>,
{ preloadedState: stateWithStacks(1, 2) },
);
fireEvent.click(screen.getByTestId('stack-strip-cell-1'));
expect(onZoneClick).toHaveBeenCalledWith(1, App.ZoneName.STACK);
});
it('activates on Enter/Space when clickable', () => {
const onZoneClick = vi.fn();
renderWithProviders(
<StackStrip
gameId={1}
entries={[{ playerId: 1, name: 'Me' }]}
onZoneClick={onZoneClick}
/>,
{ preloadedState: stateWithStacks(0, 0) },
);
fireEvent.keyDown(screen.getByTestId('stack-strip-cell-1'), { key: 'Enter' });
expect(onZoneClick).toHaveBeenCalledWith(1, App.ZoneName.STACK);
});
it('renders cells as non-interactive when onZoneClick is absent', () => {
renderWithProviders(
<StackStrip gameId={1} entries={[{ playerId: 1, name: 'Me' }]} />,
{ preloadedState: stateWithStacks(0, 0) },
);
const cell = screen.getByTestId('stack-strip-cell-1');
expect(cell).not.toHaveAttribute('role', 'button');
expect(cell).not.toHaveAttribute('tabindex');
});
});

View file

@ -0,0 +1,73 @@
import { GameSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
import './StackStrip.css';
export interface StackStripEntry {
playerId: number;
name: string;
}
export interface StackStripProps {
gameId: number;
entries: StackStripEntry[];
onZoneClick?: (playerId: number, zoneName: string) => void;
}
interface StackCellProps {
gameId: number;
playerId: number;
name: string;
onClick?: (playerId: number, zoneName: string) => void;
}
function StackCell({ gameId, playerId, name, onClick }: StackCellProps) {
const zone = useAppSelector((state) =>
GameSelectors.getZone(state, gameId, playerId, App.ZoneName.STACK),
);
const count = zone?.cardCount ?? 0;
const clickable = onClick != null;
const handleClick = () => {
onClick?.(playerId, App.ZoneName.STACK);
};
return (
<div
className="stack-strip__cell"
data-testid={`stack-strip-cell-${playerId}`}
onClick={clickable ? handleClick : undefined}
onKeyDown={(e) => {
if (clickable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleClick();
}
}}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
aria-label={`${name} stack: ${count} ${count === 1 ? 'card' : 'cards'}`}
>
<span className="stack-strip__label">{name}</span>
<span className="stack-strip__count">{count}</span>
</div>
);
}
function StackStrip({ gameId, entries, onZoneClick }: StackStripProps) {
return (
<div className="stack-strip" data-testid="stack-strip">
<span className="stack-strip__heading">Stack</span>
{entries.map((e) => (
<StackCell
key={e.playerId}
gameId={gameId}
playerId={e.playerId}
name={e.name}
onClick={onZoneClick}
/>
))}
</div>
);
}
export default StackStrip;

View file

@ -0,0 +1,39 @@
.turn-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 6px 12px;
border-top: 1px solid #1a2b52;
}
.turn-controls__btn {
height: 28px;
padding: 0 6px;
background: #17223d;
border: 1px solid #233a68;
color: #c8d4ef;
font-family: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
border-radius: 3px;
cursor: pointer;
}
.turn-controls__btn:hover:not(:disabled) {
background: #223060;
border-color: #355090;
color: #fff;
}
.turn-controls__btn:disabled {
opacity: 0.45;
cursor: default;
}
.turn-controls__btn--active {
background: #2d4583;
border-color: #5a7fcd;
color: #fff;
}

View file

@ -0,0 +1,394 @@
import { screen, fireEvent } from '@testing-library/react';
vi.mock('../../../hooks/useSettings');
import { useSettings } from '../../../hooks/useSettings';
import { makeSettings, makeSettingsHook } from '../../../hooks/__mocks__/useSettings';
import { LoadingState } from '../../../hooks/useSharedStore';
import { createMockWebClient, makeStoreState, renderWithProviders, makeUser } from '../../../__test-utils__';
import {
makeGameEntry,
makePlayerEntry,
makePlayerProperties,
} from '../../../store/game/__mocks__/fixtures';
import TurnControls from './TurnControls';
function stateWith(opts: {
localPlayerId?: number;
activePlayerId?: number;
started?: boolean;
conceded?: boolean;
judge?: boolean;
spectator?: boolean;
hostId?: number;
opponentIds?: number[];
} = {}) {
const localId = opts.localPlayerId ?? 1;
const opponentIds = opts.opponentIds ?? [];
const players: Record<number, ReturnType<typeof makePlayerEntry>> = {
[localId]: makePlayerEntry({
properties: makePlayerProperties({
playerId: localId,
userInfo: makeUser({ name: `P${localId}` }),
conceded: opts.conceded ?? false,
}),
}),
};
for (const id of opponentIds) {
players[id] = makePlayerEntry({
properties: makePlayerProperties({
playerId: id,
userInfo: makeUser({ name: `P${id}` }),
}),
});
}
return makeStoreState({
games: {
games: {
1: makeGameEntry({
localPlayerId: localId,
activePlayerId: opts.activePlayerId ?? localId,
started: opts.started ?? true,
judge: opts.judge ?? false,
spectator: opts.spectator ?? false,
hostId: opts.hostId ?? localId,
players,
}),
},
},
});
}
const NOOP = () => {};
const DEFAULT_TURN_PROPS = {
gameId: 1,
onRequestRollDie: NOOP,
onRequestConcede: NOOP,
onRequestUnconcede: NOOP,
onRequestGameInfo: NOOP,
onToggleRotate90: NOOP,
isRotated: false,
};
describe('TurnControls', () => {
beforeEach(() => {
vi.mocked(useSettings).mockReturnValue(makeSettingsHook());
});
it('renders core buttons', () => {
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith(),
});
expect(screen.getByRole('button', { name: /pass turn/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /reverse turn/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next phase/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^concede$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /roll die/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /remove arrows/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /rotate 90/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /game info/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /leave game/i })).toBeInTheDocument();
});
it('dispatches nextTurn on Pass Turn when the local player is active', () => {
const webClient = createMockWebClient();
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith(),
webClient,
});
fireEvent.click(screen.getByRole('button', { name: /pass turn/i }));
expect(webClient.request.game.nextTurn).toHaveBeenCalledWith(1);
});
it('dispatches reverseTurn on Reverse Turn', () => {
const webClient = createMockWebClient();
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith(),
webClient,
});
fireEvent.click(screen.getByRole('button', { name: /reverse turn/i }));
expect(webClient.request.game.reverseTurn).toHaveBeenCalledWith(1);
});
it('dispatches setActivePhase(current+1 mod 11) on Next Phase', () => {
const webClient = createMockWebClient();
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith(),
webClient,
});
fireEvent.click(screen.getByRole('button', { name: /next phase/i }));
// activePhase defaults to 0 in fixtures → Next Phase goes to 1.
expect(webClient.request.game.setActivePhase).toHaveBeenCalledWith(1, { phase: 1 });
});
it('disables Pass/Reverse/NextPhase when local player is not active and is not a judge', () => {
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith({ localPlayerId: 1, activePlayerId: 2 }),
});
expect(screen.getByRole('button', { name: /pass turn/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /reverse turn/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /next phase/i })).toBeDisabled();
});
it('enables Pass/Reverse/NextPhase for a judge even when not the active player (desktop parity)', () => {
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith({ localPlayerId: 1, activePlayerId: 2, judge: true }),
});
expect(screen.getByRole('button', { name: /pass turn/i })).not.toBeDisabled();
expect(screen.getByRole('button', { name: /next phase/i })).not.toBeDisabled();
});
it('routes Concede through the parent confirm handler (no direct dispatch)', () => {
const webClient = createMockWebClient();
const onRequestConcede = vi.fn();
renderWithProviders(
<TurnControls {...DEFAULT_TURN_PROPS} onRequestConcede={onRequestConcede} />,
{ preloadedState: stateWith(), webClient },
);
fireEvent.click(screen.getByRole('button', { name: /^concede$/i }));
expect(onRequestConcede).toHaveBeenCalled();
// Direct dispatch only fires from the ConfirmDialog "Confirm" path.
expect(webClient.request.game.concede).not.toHaveBeenCalled();
});
it('routes Unconcede through the parent confirm handler when already conceded', () => {
const onRequestUnconcede = vi.fn();
renderWithProviders(
<TurnControls {...DEFAULT_TURN_PROPS} onRequestUnconcede={onRequestUnconcede} />,
{ preloadedState: stateWith({ conceded: true }) },
);
fireEvent.click(screen.getByRole('button', { name: /unconcede/i }));
expect(onRequestUnconcede).toHaveBeenCalled();
});
it('dispatches leaveGame when Leave Game is clicked', () => {
const webClient = createMockWebClient();
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith(),
webClient,
});
fireEvent.click(screen.getByRole('button', { name: /leave game/i }));
expect(webClient.request.game.leaveGame).toHaveBeenCalledWith(1);
});
it('fires onRequestRollDie when Roll Die is clicked', () => {
const onRequestRollDie = vi.fn();
renderWithProviders(
<TurnControls {...DEFAULT_TURN_PROPS} onRequestRollDie={onRequestRollDie} />,
{ preloadedState: stateWith() },
);
fireEvent.click(screen.getByRole('button', { name: /roll die/i }));
expect(onRequestRollDie).toHaveBeenCalled();
});
it('fires onRequestGameInfo when Game Info is clicked', () => {
const onRequestGameInfo = vi.fn();
renderWithProviders(
<TurnControls {...DEFAULT_TURN_PROPS} onRequestGameInfo={onRequestGameInfo} />,
{ preloadedState: stateWith() },
);
fireEvent.click(screen.getByRole('button', { name: /game info/i }));
expect(onRequestGameInfo).toHaveBeenCalled();
});
it('fires onToggleRotate90 when Rotate 90° is clicked and flips label when already rotated', () => {
const onToggleRotate90 = vi.fn();
const { rerender } = renderWithProviders(
<TurnControls {...DEFAULT_TURN_PROPS} onToggleRotate90={onToggleRotate90} />,
{ preloadedState: stateWith() },
);
fireEvent.click(screen.getByRole('button', { name: /rotate 90/i }));
expect(onToggleRotate90).toHaveBeenCalled();
rerender(<TurnControls {...DEFAULT_TURN_PROPS} onToggleRotate90={onToggleRotate90} isRotated />);
expect(screen.getByRole('button', { name: /unrotate view/i })).toBeInTheDocument();
});
it('disables Remove Arrows when the local player has no arrows', () => {
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith(),
});
expect(screen.getByRole('button', { name: /remove arrows/i })).toBeDisabled();
});
it('hides the Kick button for non-hosts', () => {
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith({ localPlayerId: 1, hostId: 2, opponentIds: [2] }),
});
expect(screen.queryByRole('button', { name: /kick/i })).not.toBeInTheDocument();
});
it('shows Kick for hosts and opens an opponent picker', () => {
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith({ localPlayerId: 1, hostId: 1, opponentIds: [2, 3] }),
});
fireEvent.click(screen.getByRole('button', { name: /kick/i }));
expect(screen.getByText('P2')).toBeInTheDocument();
expect(screen.getByText('P3')).toBeInTheDocument();
});
it('dispatches kickFromGame with the chosen opponent', () => {
const webClient = createMockWebClient();
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith({ localPlayerId: 1, hostId: 1, opponentIds: [2, 3] }),
webClient,
});
fireEvent.click(screen.getByRole('button', { name: /kick/i }));
fireEvent.click(screen.getByText('P3'));
expect(webClient.request.game.kickFromGame).toHaveBeenCalledWith(1, { playerId: 3 });
});
describe('spectator gating', () => {
it('disables Concede/Unconcede/RollDie for pure spectators (desktop parity)', () => {
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith({ spectator: true }),
});
expect(screen.getByRole('button', { name: /^concede$/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /roll die/i })).toBeDisabled();
});
it('keeps Leave Game enabled for spectators (they may stop spectating)', () => {
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith({ spectator: true }),
});
expect(screen.getByRole('button', { name: /leave game/i })).not.toBeDisabled();
});
it('lets judges roll dice even though they are flagged as spectators (desktop parity)', () => {
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith({ spectator: true, judge: true }),
});
expect(screen.getByRole('button', { name: /roll die/i })).not.toBeDisabled();
});
// Desktop: judges can't concede — they have no local player to concede as.
// Our `canConcede = !isSpectator && …` gate already excludes judges who
// are flagged spectator; this test pins the behavior.
it('disables Concede for judges flagged as spectators (no local player to concede)', () => {
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith({ spectator: true, judge: true, conceded: false }),
});
expect(screen.getByRole('button', { name: /^concede$/i })).toBeDisabled();
});
it('disables Unconcede for spectators who are already conceded', () => {
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith({ spectator: true, conceded: true }),
});
// Button renders as "Unconcede" when already conceded; stays disabled
// because spectators have no concede state in the first place.
expect(screen.getByRole('button', { name: /unconcede/i })).toBeDisabled();
});
it('disables Reverse Turn for pure spectators (they are never the active player)', () => {
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
// Spectator is typically not the active player; canAdvance gates on
// (isJudge || activePlayerId === localPlayerId) so Reverse is off.
preloadedState: stateWith({ spectator: true, localPlayerId: 1, activePlayerId: 2 }),
});
expect(screen.getByRole('button', { name: /reverse turn/i })).toBeDisabled();
});
});
describe('Invert Rows toggle', () => {
it('calls updateSettings with invertVerticalCoordinate=true when off', () => {
const update = vi.fn().mockResolvedValue(undefined);
vi.mocked(useSettings).mockReturnValue(
makeSettingsHook({
status: LoadingState.READY,
value: makeSettings({ invertVerticalCoordinate: false }),
update,
}),
);
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith(),
});
fireEvent.click(screen.getByRole('button', { name: /invert rows/i }));
expect(update).toHaveBeenCalledWith({ invertVerticalCoordinate: true });
});
it('calls updateSettings with invertVerticalCoordinate=false when on', () => {
const update = vi.fn().mockResolvedValue(undefined);
vi.mocked(useSettings).mockReturnValue(
makeSettingsHook({
status: LoadingState.READY,
value: makeSettings({ invertVerticalCoordinate: true }),
update,
}),
);
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith(),
});
fireEvent.click(screen.getByRole('button', { name: /invert rows/i }));
expect(update).toHaveBeenCalledWith({ invertVerticalCoordinate: false });
});
it('reflects the current value via aria-pressed', () => {
vi.mocked(useSettings).mockReturnValue(
makeSettingsHook({
status: LoadingState.READY,
value: makeSettings({ invertVerticalCoordinate: true }),
}),
);
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith(),
});
expect(screen.getByRole('button', { name: /invert rows/i })).toHaveAttribute(
'aria-pressed',
'true',
);
});
it('is disabled while settings are still loading', () => {
vi.mocked(useSettings).mockReturnValue(
makeSettingsHook({ status: LoadingState.LOADING, value: undefined }),
);
renderWithProviders(<TurnControls {...DEFAULT_TURN_PROPS} />, {
preloadedState: stateWith(),
});
expect(screen.getByRole('button', { name: /invert rows/i })).toBeDisabled();
});
});
});

View file

@ -0,0 +1,167 @@
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useTurnControls } from './useTurnControls';
import './TurnControls.css';
export interface TurnControlsProps {
gameId: number | undefined;
onRequestRollDie: () => void;
onRequestConcede: () => void;
onRequestUnconcede: () => void;
onRequestGameInfo: () => void;
onToggleRotate90: () => void;
isRotated: boolean;
}
function TurnControls({
gameId,
onRequestRollDie,
onRequestConcede,
onRequestUnconcede,
onRequestGameInfo,
onToggleRotate90,
isRotated,
}: TurnControlsProps) {
const {
isHost,
isConceded,
invertVerticalCoordinate,
settingsReady,
canAdvance,
canLeave,
canConcede,
canUnconcede,
canRoll,
canKick,
canRemoveArrows,
hasLiveGame,
opponents,
kickAnchor,
setKickAnchor,
handlePassTurn,
handleReverseTurn,
handleNextPhase,
handleConcedeToggle,
handleRemoveArrows,
handleLeave,
handleToggleInvert,
handleKick,
} = useTurnControls({ gameId, onRequestConcede, onRequestUnconcede });
return (
<div className="turn-controls" data-testid="turn-controls">
<button
type="button"
className="turn-controls__btn"
onClick={handlePassTurn}
disabled={!canAdvance}
>
Pass Turn
</button>
<button
type="button"
className="turn-controls__btn"
onClick={handleReverseTurn}
disabled={!canAdvance}
>
Reverse Turn
</button>
<button
type="button"
className="turn-controls__btn"
onClick={handleNextPhase}
disabled={!canAdvance}
>
Next Phase
</button>
<button
type="button"
className="turn-controls__btn"
onClick={handleConcedeToggle}
disabled={!canConcede && !canUnconcede}
>
{isConceded ? 'Unconcede' : 'Concede'}
</button>
<button
type="button"
className="turn-controls__btn"
onClick={onRequestRollDie}
disabled={!canRoll}
>
Roll Die
</button>
<button
type="button"
className="turn-controls__btn"
onClick={handleRemoveArrows}
disabled={!canRemoveArrows}
title="Remove all arrows you've drawn this turn"
>
Remove Arrows
</button>
<button
type="button"
className={`turn-controls__btn${isRotated ? ' turn-controls__btn--active' : ''}`}
onClick={onToggleRotate90}
aria-pressed={isRotated}
disabled={gameId == null}
title="Rotate your view 90° (view-only; no server call)"
>
{isRotated ? 'Unrotate View' : 'Rotate 90°'}
</button>
<button
type="button"
className={`turn-controls__btn${invertVerticalCoordinate ? ' turn-controls__btn--active' : ''}`}
onClick={handleToggleInvert}
aria-pressed={invertVerticalCoordinate}
disabled={!settingsReady}
title="Flip battlefield row order (saved across sessions)"
>
Invert Rows
</button>
<button
type="button"
className="turn-controls__btn"
onClick={onRequestGameInfo}
disabled={!hasLiveGame}
>
Game Info
</button>
<button
type="button"
className="turn-controls__btn"
onClick={handleLeave}
disabled={!canLeave}
>
Leave Game
</button>
{isHost && (
<>
<button
type="button"
className="turn-controls__btn"
onClick={(e) => setKickAnchor(e.currentTarget)}
disabled={!canKick}
>
Kick
</button>
<Menu
open={kickAnchor != null}
anchorEl={kickAnchor}
onClose={() => setKickAnchor(null)}
>
{opponents.map((o) => (
<MenuItem key={o.playerId} onClick={() => handleKick(o.playerId)}>
{o.name}
</MenuItem>
))}
</Menu>
</>
)}
</div>
);
}
export default TurnControls;

View file

@ -0,0 +1,203 @@
import { useMemo, useState } from 'react';
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
/**
* MTG turn phase count (0..10). Mirrors desktop's wrap-around behavior in
* `GameView::actNextPhase` see `types/game.ts` for the Phase enum.
*/
const PHASE_COUNT = 11;
export interface TurnControlsOpponent {
playerId: number;
name: string;
}
export interface TurnControls {
isHost: boolean;
isConceded: boolean;
invertVerticalCoordinate: boolean;
settingsReady: boolean;
canAdvance: boolean;
canLeave: boolean;
canConcede: boolean;
canUnconcede: boolean;
canRoll: boolean;
canKick: boolean;
canRemoveArrows: boolean;
hasLiveGame: boolean;
opponents: TurnControlsOpponent[];
kickAnchor: HTMLElement | null;
setKickAnchor: (el: HTMLElement | null) => void;
handlePassTurn: () => void;
handleReverseTurn: () => void;
handleNextPhase: () => void;
handleConcedeToggle: () => void;
handleRemoveArrows: () => void;
handleLeave: () => void;
handleToggleInvert: () => void;
handleKick: (playerId: number) => void;
}
export interface UseTurnControlsArgs {
gameId: number | undefined;
onRequestConcede: () => void;
onRequestUnconcede: () => void;
}
export function useTurnControls({
gameId,
onRequestConcede,
onRequestUnconcede,
}: UseTurnControlsArgs): TurnControls {
const webClient = useWebClient();
const { game, localPlayer, isSpectator, isJudge, isHost, isStarted } = useCurrentGame(gameId);
const { status: settingsStatus, value: settings, update: updateSettings } = useSettings();
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
// Post-kick: the reducer has deleted the game from state but the dialog
// may still be mounted for a frame while `useGameLifecycle` navigates to
// /server. Every handler double-checks `game` so a trailing click can't
// fire a command against a game the server no longer has.
const hasLiveGame = gameId != null && game != null;
const [kickAnchor, setKickAnchor] = useState<HTMLElement | null>(null);
const opponents = useMemo<TurnControlsOpponent[]>(() => {
if (!game) {
return [];
}
return Object.values(game.players)
.filter((p) => p.properties.playerId !== game.localPlayerId)
.map((p) => ({
playerId: p.properties.playerId,
name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`,
}));
}, [game]);
// Local arrows belong to `localPlayerId`; Remove Local Arrows iterates
// and deletes each one. Matches desktop's Player::actRemoveLocalArrows.
const localArrows = useAppSelector((state) =>
gameId != null && game != null
? GameSelectors.getArrows(state, gameId, game.localPlayerId)
: undefined,
);
const localArrowIds = useMemo(
() => (localArrows ? Object.keys(localArrows).map(Number) : []),
[localArrows],
);
// Players (judge or not) act as participants; pure spectators don't.
// Matches desktop: aConcede/aNextTurn are disabled when isSpectator() without
// judge privileges (see tab_game.cpp concede enablement + player_menu.cpp
// getLocalOrJudge gates).
const isParticipant = gameId != null && game != null && !isSpectator;
const isConceded = localPlayer?.properties.conceded ?? false;
const canAdvance =
gameId != null && game != null && isStarted &&
(isJudge || game.activePlayerId === game.localPlayerId);
const canLeave = gameId != null && game != null;
const canConcede = isParticipant && !isConceded;
const canUnconcede = isParticipant && isConceded;
// Rolling dice is a player action; judges may also roll. Pure spectators
// cannot (desktop exposes it through the player menu, which spectators
// don't receive).
const canRoll = gameId != null && (isParticipant || isJudge);
const canKick = gameId != null && isHost && opponents.length > 0;
const canRemoveArrows = hasLiveGame && localArrowIds.length > 0;
const handlePassTurn = () => {
if (!canAdvance || !hasLiveGame) {
return;
}
webClient.request.game.nextTurn(gameId);
};
const handleReverseTurn = () => {
if (!canAdvance || !hasLiveGame) {
return;
}
webClient.request.game.reverseTurn(gameId);
};
const handleNextPhase = () => {
if (!canAdvance || !hasLiveGame) {
return;
}
// Desktop wraps at PHASE_COUNT → 0 (the Phase enum is 010). When no phase
// is active yet (activePhase < 0 during the pre-game lobby), advance to
// Untap (0).
const current = game.activePhase;
const next = current >= 0 ? (current + 1) % PHASE_COUNT : 0;
webClient.request.game.setActivePhase(gameId, { phase: next });
};
const handleConcedeToggle = () => {
if (!hasLiveGame || (!canConcede && !canUnconcede)) {
return;
}
if (isConceded) {
onRequestUnconcede();
} else {
onRequestConcede();
}
};
const handleRemoveArrows = () => {
if (!canRemoveArrows) {
return;
}
for (const arrowId of localArrowIds) {
webClient.request.game.deleteArrow(gameId, { arrowId });
}
};
const handleLeave = () => {
if (!canLeave || !hasLiveGame) {
return;
}
webClient.request.game.leaveGame(gameId);
};
const handleToggleInvert = () => {
if (settingsStatus !== LoadingState.READY) {
return;
}
void updateSettings({ invertVerticalCoordinate: !invertVerticalCoordinate });
};
const handleKick = (playerId: number) => {
if (!hasLiveGame) {
return;
}
webClient.request.game.kickFromGame(gameId, { playerId });
setKickAnchor(null);
};
return {
isHost,
isConceded,
invertVerticalCoordinate,
settingsReady: settingsStatus === LoadingState.READY,
canAdvance,
canLeave,
canConcede,
canUnconcede,
canRoll,
canKick,
canRemoveArrows,
hasLiveGame,
opponents,
kickAnchor,
setKickAnchor,
handlePassTurn,
handleReverseTurn,
handleNextPhase,
handleConcedeToggle,
handleRemoveArrows,
handleLeave,
handleToggleInvert,
handleKick,
};
}

View file

@ -0,0 +1,16 @@
.zone-context-menu .MuiPaper-root {
min-width: 220px;
}
.zone-context-menu__toggle {
display: flex;
align-items: center;
gap: 6px;
}
.zone-context-menu__check {
display: inline-flex;
width: 16px;
justify-content: center;
color: var(--color-highlight-yellow);
}

View file

@ -0,0 +1,208 @@
import { screen, fireEvent } from '@testing-library/react';
import { App } from '@app/types';
import { createMockWebClient, makeStoreState, renderWithProviders } from '../../../__test-utils__';
import {
makeGameEntry,
makePlayerEntry,
makeZoneEntry,
} from '../../../store/game/__mocks__/fixtures';
import ZoneContextMenu from './ZoneContextMenu';
const defaultProps = {
isOpen: true,
anchorPosition: { top: 100, left: 100 },
gameId: 1,
playerId: 1,
zoneName: App.ZoneName.DECK,
onClose: () => {},
onRequestDrawN: () => {},
onRequestDumpN: () => {},
onRequestRevealTopN: () => {},
onRequestRevealZone: () => {},
};
function stateWithDeckZone(overrides: Partial<ReturnType<typeof makeZoneEntry>> = {}) {
const player = makePlayerEntry({
zones: {
deck: makeZoneEntry({ name: App.ZoneName.DECK, ...overrides }),
grave: makeZoneEntry({ name: App.ZoneName.GRAVE }),
rfg: makeZoneEntry({ name: App.ZoneName.EXILE }),
},
});
return makeStoreState({
games: {
games: {
1: makeGameEntry({ players: { 1: player } }),
},
},
});
}
describe('ZoneContextMenu', () => {
it('does not render when playerId is null', () => {
renderWithProviders(
<ZoneContextMenu {...defaultProps} playerId={null} />,
);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});
it('does not render for unsupported zones (e.g. hand, stack)', () => {
renderWithProviders(
<ZoneContextMenu {...defaultProps} zoneName={App.ZoneName.HAND} />,
{ preloadedState: stateWithDeckZone() },
);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});
describe('Deck zone', () => {
it('renders every deck action when open', () => {
renderWithProviders(<ZoneContextMenu {...defaultProps} />, {
preloadedState: stateWithDeckZone(),
});
expect(screen.getByText('Draw a card')).toBeInTheDocument();
expect(screen.getByText('Draw N cards…')).toBeInTheDocument();
expect(screen.getByText('Shuffle')).toBeInTheDocument();
expect(screen.getByText('Dump top N…')).toBeInTheDocument();
expect(screen.getByText('Reveal top card to all')).toBeInTheDocument();
expect(screen.getByText('Reveal top N to…')).toBeInTheDocument();
expect(screen.getByText('Always reveal top card')).toBeInTheDocument();
expect(screen.getByText('Always look at top card')).toBeInTheDocument();
});
it('dispatches drawCards(1) on "Draw a card"', () => {
const webClient = createMockWebClient();
const onClose = vi.fn();
renderWithProviders(
<ZoneContextMenu {...defaultProps} onClose={onClose} />,
{ webClient, preloadedState: stateWithDeckZone() },
);
fireEvent.click(screen.getByText('Draw a card'));
expect(webClient.request.game.drawCards).toHaveBeenCalledWith(1, { number: 1 });
expect(onClose).toHaveBeenCalled();
});
it('dispatches shuffle on the deck zone', () => {
const webClient = createMockWebClient();
renderWithProviders(<ZoneContextMenu {...defaultProps} />, {
webClient,
preloadedState: stateWithDeckZone(),
});
fireEvent.click(screen.getByText('Shuffle'));
expect(webClient.request.game.shuffle).toHaveBeenCalledWith(1, {
zoneName: App.ZoneName.DECK,
start: 0,
end: -1,
});
});
it('dispatches revealCards(topCards=1, playerId=-1) on "Reveal top card to all"', () => {
const webClient = createMockWebClient();
renderWithProviders(<ZoneContextMenu {...defaultProps} />, {
webClient,
preloadedState: stateWithDeckZone(),
});
fireEvent.click(screen.getByText('Reveal top card to all'));
expect(webClient.request.game.revealCards).toHaveBeenCalledWith(1, {
zoneName: App.ZoneName.DECK,
playerId: -1,
topCards: 1,
});
});
it('defers prompt-backed items to parent callbacks', () => {
const onRequestDrawN = vi.fn();
const onRequestDumpN = vi.fn();
const onRequestRevealTopN = vi.fn();
renderWithProviders(
<ZoneContextMenu
{...defaultProps}
onRequestDrawN={onRequestDrawN}
onRequestDumpN={onRequestDumpN}
onRequestRevealTopN={onRequestRevealTopN}
/>,
{ preloadedState: stateWithDeckZone() },
);
fireEvent.click(screen.getByText('Draw N cards…'));
expect(onRequestDrawN).toHaveBeenCalled();
fireEvent.click(screen.getByText('Dump top N…'));
expect(onRequestDumpN).toHaveBeenCalled();
fireEvent.click(screen.getByText('Reveal top N to…'));
expect(onRequestRevealTopN).toHaveBeenCalled();
});
it('dispatches changeZoneProperties with the flipped alwaysRevealTopCard', () => {
const webClient = createMockWebClient();
renderWithProviders(<ZoneContextMenu {...defaultProps} />, {
webClient,
preloadedState: stateWithDeckZone({ alwaysRevealTopCard: false }),
});
fireEvent.click(screen.getByText('Always reveal top card'));
expect(webClient.request.game.changeZoneProperties).toHaveBeenCalledWith(1, {
zoneName: App.ZoneName.DECK,
alwaysRevealTopCard: true,
});
});
it('dispatches changeZoneProperties with the flipped alwaysLookAtTopCard', () => {
const webClient = createMockWebClient();
renderWithProviders(<ZoneContextMenu {...defaultProps} />, {
webClient,
preloadedState: stateWithDeckZone({ alwaysLookAtTopCard: true }),
});
fireEvent.click(screen.getByText('Always look at top card'));
expect(webClient.request.game.changeZoneProperties).toHaveBeenCalledWith(1, {
zoneName: App.ZoneName.DECK,
alwaysLookAtTopCard: false,
});
});
});
describe('Graveyard / Exile zones', () => {
it('offers "Reveal graveyard to…" on the grave zone', () => {
const onRequestRevealZone = vi.fn();
renderWithProviders(
<ZoneContextMenu
{...defaultProps}
zoneName={App.ZoneName.GRAVE}
onRequestRevealZone={onRequestRevealZone}
/>,
{ preloadedState: stateWithDeckZone() },
);
fireEvent.click(screen.getByText('Reveal graveyard to…'));
expect(onRequestRevealZone).toHaveBeenCalled();
});
it('offers "Reveal exile to…" on the exile zone', () => {
const onRequestRevealZone = vi.fn();
renderWithProviders(
<ZoneContextMenu
{...defaultProps}
zoneName={App.ZoneName.EXILE}
onRequestRevealZone={onRequestRevealZone}
/>,
{ preloadedState: stateWithDeckZone() },
);
fireEvent.click(screen.getByText('Reveal exile to…'));
expect(onRequestRevealZone).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,119 @@
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import Check from '@mui/icons-material/Check';
import { App } from '@app/types';
import { useZoneContextMenu } from './useZoneContextMenu';
import './ZoneContextMenu.css';
export interface ZoneContextMenuProps {
isOpen: boolean;
anchorPosition: { top: number; left: number } | null;
gameId: number;
playerId: number | null;
zoneName: string | null;
onClose: () => void;
onRequestDrawN: () => void;
onRequestDumpN: () => void;
onRequestRevealTopN: () => void;
onRequestRevealZone: () => void;
}
function ZoneContextMenu(props: ZoneContextMenuProps) {
const {
isOpen,
anchorPosition,
zoneName,
onClose,
onRequestDrawN,
onRequestDumpN,
onRequestRevealTopN,
onRequestRevealZone,
} = props;
const {
ready,
alwaysReveal,
alwaysLook,
handleDrawOne,
handleShuffle,
handleRevealTop,
handleToggleAlwaysReveal,
handleToggleAlwaysLook,
runAndClose,
} = useZoneContextMenu(props);
if (!ready) {
return null;
}
const menuItems: React.ReactNode[] = [];
if (zoneName === App.ZoneName.DECK) {
menuItems.push(
<MenuItem key="draw-one" onClick={runAndClose(handleDrawOne)}>Draw a card</MenuItem>,
<MenuItem key="draw-n" onClick={runAndClose(onRequestDrawN)}>Draw N cards</MenuItem>,
<MenuItem key="shuffle" onClick={runAndClose(handleShuffle)}>Shuffle</MenuItem>,
<MenuItem key="dump-n" onClick={runAndClose(onRequestDumpN)}>Dump top N</MenuItem>,
<Divider key="d1" />,
<MenuItem key="reveal-top" onClick={runAndClose(handleRevealTop)}>
Reveal top card to all
</MenuItem>,
<MenuItem key="reveal-top-n" onClick={runAndClose(onRequestRevealTopN)}>
Reveal top N to
</MenuItem>,
<Divider key="d2" />,
<MenuItem
key="always-reveal"
onClick={runAndClose(handleToggleAlwaysReveal)}
className="zone-context-menu__toggle"
>
<span className="zone-context-menu__check" aria-hidden>
{alwaysReveal ? <Check fontSize="inherit" /> : null}
</span>
Always reveal top card
</MenuItem>,
<MenuItem
key="always-look"
onClick={runAndClose(handleToggleAlwaysLook)}
className="zone-context-menu__toggle"
>
<span className="zone-context-menu__check" aria-hidden>
{alwaysLook ? <Check fontSize="inherit" /> : null}
</span>
Always look at top card
</MenuItem>,
);
} else if (zoneName === App.ZoneName.GRAVE) {
menuItems.push(
<MenuItem key="reveal-grave" onClick={runAndClose(onRequestRevealZone)}>
Reveal graveyard to
</MenuItem>,
);
} else if (zoneName === App.ZoneName.EXILE) {
menuItems.push(
<MenuItem key="reveal-exile" onClick={runAndClose(onRequestRevealZone)}>
Reveal exile to
</MenuItem>,
);
} else {
return null;
}
return (
<Menu
open={isOpen}
onClose={onClose}
anchorReference="anchorPosition"
anchorPosition={anchorPosition ?? undefined}
data-testid="zone-context-menu"
className="zone-context-menu"
>
{menuItems}
</Menu>
);
}
export default ZoneContextMenu;

View file

@ -0,0 +1,89 @@
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
export interface ZoneContextMenu {
ready: boolean;
alwaysReveal: boolean;
alwaysLook: boolean;
handleDrawOne: () => void;
handleShuffle: () => void;
handleRevealTop: () => void;
handleToggleAlwaysReveal: () => void;
handleToggleAlwaysLook: () => void;
runAndClose: (fn: () => void) => () => void;
}
export interface UseZoneContextMenuArgs {
gameId: number;
playerId: number | null;
zoneName: string | null;
onClose: () => void;
}
export function useZoneContextMenu({
gameId,
playerId,
zoneName,
onClose,
}: UseZoneContextMenuArgs): ZoneContextMenu {
const webClient = useWebClient();
const zone = useAppSelector((state) =>
playerId != null && zoneName != null
? GameSelectors.getZone(state, gameId, playerId, zoneName)
: undefined,
);
const ready = playerId != null && zoneName != null;
const alwaysReveal = zone?.alwaysRevealTopCard ?? false;
const alwaysLook = zone?.alwaysLookAtTopCard ?? false;
// Close-then-act helpers (avoid duplicating onClose at every site).
const runAndClose = (fn: () => void) => () => {
fn();
onClose();
};
const handleDrawOne = () => {
webClient.request.game.drawCards(gameId, { number: 1 });
};
const handleShuffle = () => {
webClient.request.game.shuffle(gameId, { zoneName: App.ZoneName.DECK, start: 0, end: -1 });
};
const handleRevealTop = () => {
webClient.request.game.revealCards(gameId, {
zoneName: App.ZoneName.DECK,
playerId: -1,
topCards: 1,
});
};
const handleToggleAlwaysReveal = () => {
webClient.request.game.changeZoneProperties(gameId, {
zoneName: App.ZoneName.DECK,
alwaysRevealTopCard: !alwaysReveal,
});
};
const handleToggleAlwaysLook = () => {
webClient.request.game.changeZoneProperties(gameId, {
zoneName: App.ZoneName.DECK,
alwaysLookAtTopCard: !alwaysLook,
});
};
return {
ready,
alwaysReveal,
alwaysLook,
handleDrawOne,
handleShuffle,
handleRevealTop,
handleToggleAlwaysReveal,
handleToggleAlwaysLook,
runAndClose,
};
}

View file

@ -0,0 +1,11 @@
.zone-rail {
width: 110px;
height: 100%;
padding: 8px 4px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
background: #0a1225;
border-left: 1px solid #1a2b52;
}

View file

@ -0,0 +1,74 @@
import { screen, fireEvent } from '@testing-library/react';
import { App } from '@app/types';
import { makeStoreState, renderWithProviders } from '../../../__test-utils__';
import {
makeGameEntry,
makePlayerEntry,
makeZoneEntry,
} from '../../../store/game/__mocks__/fixtures';
import ZoneRail from './ZoneRail';
const baseState = makeStoreState({
games: {
games: {
1: makeGameEntry({
localPlayerId: 1,
players: {
1: makePlayerEntry({
zones: {
[App.ZoneName.STACK]: makeZoneEntry({ name: App.ZoneName.STACK }),
[App.ZoneName.EXILE]: makeZoneEntry({ name: App.ZoneName.EXILE }),
[App.ZoneName.GRAVE]: makeZoneEntry({ name: App.ZoneName.GRAVE }),
[App.ZoneName.DECK]: makeZoneEntry({ name: App.ZoneName.DECK, cardCount: 60 }),
},
}),
},
}),
},
},
});
describe('ZoneRail', () => {
it('renders deck, graveyard, and exile top-to-bottom (desktop pile order)', () => {
const { container } = renderWithProviders(<ZoneRail gameId={1} playerId={1} />, {
preloadedState: baseState,
});
const labels = Array.from(container.querySelectorAll('.zone-stack__label')).map(
(n) => n.textContent,
);
expect(labels).toEqual(['Deck', 'Graveyard', 'Exile']);
});
it('does not render the stack in the pile rail (desktop parity: stack is not a pile)', () => {
renderWithProviders(<ZoneRail gameId={1} playerId={1} />, {
preloadedState: baseState,
});
expect(screen.queryByText('Stack')).not.toBeInTheDocument();
expect(screen.queryByTestId(`zone-stack-${App.ZoneName.STACK}`)).not.toBeInTheDocument();
});
it('propagates player and game context to each ZoneStack', () => {
renderWithProviders(<ZoneRail gameId={1} playerId={1} />, {
preloadedState: baseState,
});
expect(screen.getByTestId(`zone-stack-${App.ZoneName.DECK}`)).toBeInTheDocument();
expect(screen.getByTestId(`zone-stack-${App.ZoneName.GRAVE}`)).toBeInTheDocument();
expect(screen.getByTestId(`zone-stack-${App.ZoneName.EXILE}`)).toBeInTheDocument();
});
it('forwards zone-rail clicks with the player and zone name when onZoneClick is provided', () => {
const onZoneClick = vi.fn();
renderWithProviders(
<ZoneRail gameId={1} playerId={7} onZoneClick={onZoneClick} />,
{ preloadedState: baseState },
);
fireEvent.click(screen.getByTestId(`zone-stack-${App.ZoneName.GRAVE}`));
expect(onZoneClick).toHaveBeenCalledWith(7, App.ZoneName.GRAVE);
});
});

View file

@ -0,0 +1,50 @@
import { App, Data } from '@app/types';
import ZoneStack from '../ZoneStack/ZoneStack';
import './ZoneRail.css';
export interface ZoneRailProps {
gameId: number;
playerId: number;
onCardHover?: (card: Data.ServerInfo_Card) => void;
onZoneClick?: (playerId: number, zoneName: string) => void;
onZoneContextMenu?: (playerId: number, zoneName: string, event: React.MouseEvent) => void;
}
const ZONES: Array<{ name: string; label: string }> = [
{ name: App.ZoneName.DECK, label: 'Deck' },
{ name: App.ZoneName.GRAVE, label: 'Graveyard' },
{ name: App.ZoneName.EXILE, label: 'Exile' },
];
function ZoneRail({
gameId,
playerId,
onCardHover,
onZoneClick,
onZoneContextMenu,
}: ZoneRailProps) {
return (
<div className="zone-rail" data-testid="zone-rail">
{ZONES.map((z) => (
<ZoneStack
key={z.name}
gameId={gameId}
playerId={playerId}
zoneName={z.name}
label={z.label}
onCardHover={onCardHover}
onClick={onZoneClick ? (name) => onZoneClick(playerId, name) : undefined}
onContextMenu={
onZoneContextMenu
? (name, e) => onZoneContextMenu(playerId, name, e)
: undefined
}
/>
))}
</div>
);
}
export default ZoneRail;

View file

@ -0,0 +1,57 @@
.zone-stack {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
cursor: pointer;
}
.zone-stack--drop-over .zone-stack__thumb {
box-shadow: 0 0 0 2px var(--color-highlight-yellow), 0 0 12px var(--color-highlight-yellow-soft);
}
.zone-stack__thumb {
position: relative;
width: 78px;
height: 108px;
background: #0d1930;
border: 1px solid #1a2b52;
border-radius: 4px;
overflow: hidden;
box-sizing: border-box;
}
.zone-stack__image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.zone-stack__placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #17223d 0%, #0d1930 100%);
}
.zone-stack__count {
position: absolute;
right: 2px;
bottom: 2px;
min-width: 20px;
padding: 1px 5px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 12px;
font-weight: 700;
border-radius: 3px;
text-align: center;
}
.zone-stack__label {
color: #b8c5e0;
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
}

View file

@ -0,0 +1,163 @@
import { screen, fireEvent } from '@testing-library/react';
import { App } from '@app/types';
import { makeStoreState, renderWithProviders } from '../../../__test-utils__';
import {
makeCard,
makeGameEntry,
makePlayerEntry,
makeZoneEntry,
} from '../../../store/game/__mocks__/fixtures';
import ZoneStack from './ZoneStack';
function stateWithZone(zoneName: string, overrides: Parameters<typeof makeZoneEntry>[0]) {
return makeStoreState({
games: {
games: {
1: makeGameEntry({
localPlayerId: 1,
players: {
1: makePlayerEntry({
zones: {
[zoneName]: makeZoneEntry({ name: zoneName, ...overrides }),
},
}),
},
}),
},
},
});
}
describe('ZoneStack', () => {
it('renders the label', () => {
renderWithProviders(
<ZoneStack gameId={1} playerId={1} zoneName={App.ZoneName.GRAVE} label="Graveyard" />,
{ preloadedState: stateWithZone(App.ZoneName.GRAVE, { cardCount: 0 }) },
);
expect(screen.getByText('Graveyard')).toBeInTheDocument();
});
it('shows the authoritative cardCount, even when order is empty (hidden zone)', () => {
renderWithProviders(
<ZoneStack gameId={1} playerId={1} zoneName={App.ZoneName.DECK} label="Deck" />,
{
preloadedState: stateWithZone(App.ZoneName.DECK, {
cardCount: 40,
cards: [],
}),
},
);
expect(screen.getByText('40')).toBeInTheDocument();
});
it('renders the top (last) card image as the thumb', () => {
const a = makeCard({ id: 1, name: 'Bottom Card' });
const b = makeCard({ id: 2, name: 'Top Card' });
renderWithProviders(
<ZoneStack gameId={1} playerId={1} zoneName={App.ZoneName.GRAVE} label="Graveyard" />,
{
preloadedState: stateWithZone(App.ZoneName.GRAVE, {
cardCount: 2,
cards: [a, b],
}),
},
);
expect(screen.getByAltText('Top Card')).toBeInTheDocument();
expect(screen.queryByAltText('Bottom Card')).not.toBeInTheDocument();
});
it('renders a placeholder when the zone has no visible cards', () => {
const { container } = renderWithProviders(
<ZoneStack gameId={1} playerId={1} zoneName={App.ZoneName.EXILE} label="Exile" />,
{ preloadedState: stateWithZone(App.ZoneName.EXILE, { cardCount: 0 }) },
);
expect(container.querySelector('.zone-stack__placeholder')).not.toBeNull();
expect(container.querySelector('.zone-stack__image')).toBeNull();
});
it('hides the image when the top card is face-down', () => {
const hidden = makeCard({ id: 1, name: 'Secret', faceDown: true });
const { container } = renderWithProviders(
<ZoneStack gameId={1} playerId={1} zoneName={App.ZoneName.EXILE} label="Exile" />,
{
preloadedState: stateWithZone(App.ZoneName.EXILE, {
cardCount: 1,
cards: [hidden],
}),
},
);
expect(container.querySelector('.zone-stack__placeholder')).not.toBeNull();
expect(container.querySelector('.zone-stack__image')).toBeNull();
});
it('renders count 0 when the zone entry is missing entirely', () => {
renderWithProviders(
<ZoneStack gameId={1} playerId={1} zoneName="nonexistent" label="Missing" />,
{
preloadedState: makeStoreState({
games: {
games: {
1: makeGameEntry({
players: { 1: makePlayerEntry({ zones: {} }) },
}),
},
},
}),
},
);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('fires onClick with the zone name when clicked', () => {
const onClick = vi.fn();
renderWithProviders(
<ZoneStack
gameId={1}
playerId={1}
zoneName={App.ZoneName.GRAVE}
label="Graveyard"
onClick={onClick}
/>,
{ preloadedState: stateWithZone(App.ZoneName.GRAVE, { cardCount: 0 }) },
);
fireEvent.click(screen.getByTestId(`zone-stack-${App.ZoneName.GRAVE}`));
expect(onClick).toHaveBeenCalledWith(App.ZoneName.GRAVE);
});
it('does not gain button semantics when onClick is omitted', () => {
renderWithProviders(
<ZoneStack gameId={1} playerId={1} zoneName={App.ZoneName.GRAVE} label="Graveyard" />,
{ preloadedState: stateWithZone(App.ZoneName.GRAVE, { cardCount: 0 }) },
);
const el = screen.getByTestId(`zone-stack-${App.ZoneName.GRAVE}`);
expect(el).not.toHaveAttribute('role', 'button');
expect(el).not.toHaveAttribute('tabindex');
});
it.each([['Enter'], [' ']])('fires onClick on %s keypress when focusable', (key) => {
const onClick = vi.fn();
renderWithProviders(
<ZoneStack
gameId={1}
playerId={1}
zoneName={App.ZoneName.GRAVE}
label="Graveyard"
onClick={onClick}
/>,
{ preloadedState: stateWithZone(App.ZoneName.GRAVE, { cardCount: 0 }) },
);
fireEvent.keyDown(screen.getByTestId(`zone-stack-${App.ZoneName.GRAVE}`), { key });
expect(onClick).toHaveBeenCalledWith(App.ZoneName.GRAVE);
});
});

View file

@ -0,0 +1,79 @@
import { useDroppable } from '@dnd-kit/core';
import { useGameAccess, useScryfallCard } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import type { Data } from '@app/types';
import { cx } from '@app/utils';
import './ZoneStack.css';
export interface ZoneStackProps {
gameId: number;
playerId: number;
zoneName: string;
label: string;
onCardHover?: (card: Data.ServerInfo_Card) => void;
onClick?: (zoneName: string) => void;
onContextMenu?: (zoneName: string, event: React.MouseEvent) => void;
}
function ZoneStack({
gameId,
playerId,
zoneName,
label,
onCardHover,
onClick,
onContextMenu,
}: ZoneStackProps) {
const zone = useAppSelector((state) =>
GameSelectors.getZone(state, gameId, playerId, zoneName),
);
const topCard: Data.ServerInfo_Card | undefined = zone
? zone.byId[zone.order[zone.order.length - 1]]
: undefined;
const { smallUrl } = useScryfallCard(topCard ?? null);
const count = zone?.cardCount ?? 0;
// Disable drops onto zones the local user can't act on (opponent zones
// for non-judges, etc.). Server rejects the same moves; this keeps the
// dnd-kit over-feedback honest.
const { canAct } = useGameAccess(gameId, playerId);
const { setNodeRef, isOver } = useDroppable({
id: `zone-${playerId}-${zoneName}`,
data: { targetPlayerId: playerId, targetZone: zoneName },
disabled: !canAct,
});
return (
<div
ref={setNodeRef}
className={cx('zone-stack', { 'zone-stack--drop-over': isOver })}
data-testid={`zone-stack-${zoneName}`}
onMouseEnter={() => topCard && onCardHover?.(topCard)}
onClick={() => onClick?.(zoneName)}
onContextMenu={(e) => onContextMenu?.(zoneName, e)}
onKeyDown={(e) => {
if (onClick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onClick(zoneName);
}
}}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
>
<div className="zone-stack__thumb">
{topCard && smallUrl && !topCard.faceDown ? (
<img className="zone-stack__image" src={smallUrl} alt={topCard.name} />
) : (
<div className="zone-stack__placeholder" />
)}
<div className="zone-stack__count">{count}</div>
</div>
<div className="zone-stack__label">{label}</div>
</div>
);
}
export default ZoneStack;

View file

@ -0,0 +1,29 @@
export { default as Battlefield } from './Battlefield/Battlefield';
export { default as CardContextMenu } from './CardContextMenu/CardContextMenu';
export { default as CardDragOverlay } from './CardDragOverlay/CardDragOverlay';
export { default as CardPreview } from './CardPreview/CardPreview';
export { default as CardSlot } from './CardSlot/CardSlot';
export {
CardRegistryContext,
createCardRegistry,
makeCardKey,
useCardRegistry,
useRegisterCardRef,
} from './CardRegistry/CardRegistryContext';
export type { CardKey, CardRegistry } from './CardRegistry/CardRegistryContext';
export { default as GameArrowOverlay } from './GameArrowOverlay/GameArrowOverlay';
export { default as GameLog } from './GameLog/GameLog';
export { default as HandContextMenu } from './HandContextMenu/HandContextMenu';
export { default as HandZone } from './HandZone/HandZone';
export { default as OpponentSelector } from './OpponentSelector/OpponentSelector';
export { default as PhaseBar } from './PhaseBar/PhaseBar';
export { default as PlayerBoard } from './PlayerBoard/PlayerBoard';
export { default as PlayerContextMenu } from './PlayerContextMenu/PlayerContextMenu';
export { default as PlayerInfoPanel } from './PlayerInfoPanel/PlayerInfoPanel';
export { default as PlayerList } from './PlayerList/PlayerList';
export { default as RightPanel } from './RightPanel/RightPanel';
export { default as StackStrip } from './StackStrip/StackStrip';
export { default as TurnControls } from './TurnControls/TurnControls';
export { default as ZoneContextMenu } from './ZoneContextMenu/ZoneContextMenu';
export { default as ZoneRail } from './ZoneRail/ZoneRail';
export { default as ZoneStack } from './ZoneStack/ZoneStack';

View file

@ -1,4 +1,3 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { ServerSelectors, useAppSelector } from '@app/store';

Some files were not shown because too many files have changed in this diff Show more