mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -07:00
Compare commits
11 commits
b103db681b
...
88489ea2eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88489ea2eb | ||
|
|
a75abe1454 | ||
|
|
6074d9d6e4 | ||
|
|
3aa8c654cc | ||
|
|
515dff6d7b | ||
|
|
2afa2922e9 | ||
|
|
2aeb1542b1 | ||
|
|
db1530c9e9 | ||
|
|
e045f498a8 | ||
|
|
5f28d43dff | ||
|
|
0d7336edc2 |
365 changed files with 23886 additions and 2467 deletions
|
|
@ -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 = [
|
||||
|
|
|
|||
443
webclient/integration/src/app/game-board.spec.tsx
Normal file
443
webclient/integration/src/app/game-board.spec.tsx
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
// Exercises the full Game container under the real Redux store + real
|
||||
// reducers + real React chain. We dispatch game lifecycle events via
|
||||
// GameDispatch (the same path real event handlers take) and assert the
|
||||
// Game container's UI tracks state transitions.
|
||||
|
||||
import { act, fireEvent, waitFor, screen, within } from '@testing-library/react';
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Data } from '@app/types';
|
||||
import { Command_GameSay_ext } from '@app/generated';
|
||||
import { GameDispatch, ServerDispatch, store } from '@app/store';
|
||||
import { WebsocketTypes } from '@app/websocket/types';
|
||||
|
||||
import Game from '../../../src/containers/Game/Game';
|
||||
import { renderAppScreen } from './helpers';
|
||||
import { findLastGameCommand } from '../helpers/command-capture';
|
||||
import { connectRaw } from '../helpers/setup';
|
||||
|
||||
// Surfaces the current MemoryRouter pathname so navigate() side-effects
|
||||
// (e.g. useGameLifecycle → /server on kick) can be asserted. Depends on
|
||||
// `renderAppScreen` → `renderWithProviders` wrapping its tree in a
|
||||
// `MemoryRouter`; if that harness ever moves the probe outside a Router,
|
||||
// `useLocation()` will throw "useLocation() may be used only in the
|
||||
// context of a <Router> component." — fix by adding an inline
|
||||
// `<MemoryRouter>` around the probe OR by teaching the harness about it.
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <span data-testid="app-location">{location.pathname}</span>;
|
||||
}
|
||||
|
||||
function buildEventGameJoined(args: {
|
||||
gameId: number;
|
||||
localPlayerId: number;
|
||||
hostId: number;
|
||||
}): Data.Event_GameJoined {
|
||||
return create(Data.Event_GameJoinedSchema, {
|
||||
gameInfo: create(Data.ServerInfo_GameSchema, {
|
||||
gameId: args.gameId,
|
||||
roomId: 1,
|
||||
description: 'Integration Test Game',
|
||||
gameTypes: [],
|
||||
started: false,
|
||||
}),
|
||||
hostId: args.hostId,
|
||||
playerId: args.localPlayerId,
|
||||
spectator: false,
|
||||
judge: false,
|
||||
resuming: false,
|
||||
});
|
||||
}
|
||||
|
||||
function buildEventGameStateChanged(
|
||||
playerIds: number[],
|
||||
localId: number,
|
||||
): Data.Event_GameStateChanged {
|
||||
return create(Data.Event_GameStateChangedSchema, {
|
||||
gameStarted: true,
|
||||
activePlayerId: localId,
|
||||
activePhase: 0,
|
||||
playerList: playerIds.map((pid) =>
|
||||
create(Data.ServerInfo_PlayerSchema, {
|
||||
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
|
||||
playerId: pid,
|
||||
userInfo: create(Data.ServerInfo_UserSchema, { name: `P${pid}` }),
|
||||
spectator: false,
|
||||
conceded: false,
|
||||
readyStart: false,
|
||||
judge: false,
|
||||
}),
|
||||
deckList: '',
|
||||
zoneList: [
|
||||
create(Data.ServerInfo_ZoneSchema, {
|
||||
name: 'table',
|
||||
type: 1,
|
||||
withCoords: true,
|
||||
cardCount: 0,
|
||||
cardList: [],
|
||||
}),
|
||||
create(Data.ServerInfo_ZoneSchema, {
|
||||
name: 'hand',
|
||||
type: 0,
|
||||
withCoords: false,
|
||||
cardCount: 0,
|
||||
cardList: [],
|
||||
}),
|
||||
create(Data.ServerInfo_ZoneSchema, {
|
||||
name: 'deck',
|
||||
type: 2,
|
||||
withCoords: false,
|
||||
cardCount: 40,
|
||||
cardList: [],
|
||||
}),
|
||||
create(Data.ServerInfo_ZoneSchema, {
|
||||
name: 'grave',
|
||||
type: 1,
|
||||
withCoords: false,
|
||||
cardCount: 0,
|
||||
cardList: [],
|
||||
}),
|
||||
create(Data.ServerInfo_ZoneSchema, {
|
||||
name: 'rfg',
|
||||
type: 1,
|
||||
withCoords: false,
|
||||
cardCount: 0,
|
||||
cardList: [],
|
||||
}),
|
||||
],
|
||||
counterList: [],
|
||||
arrowList: [],
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function simulateConnected() {
|
||||
act(() => {
|
||||
ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.LOGGED_IN, null);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
for (const gameId of Object.keys(store.getState().games.games)) {
|
||||
GameDispatch.gameLeft(Number(gameId));
|
||||
}
|
||||
ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.DISCONNECTED, null);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Integration setup installs fake timers for KeepAliveService control;
|
||||
// waitFor / React effects need real timers to run between dispatch and assert.
|
||||
vi.useRealTimers();
|
||||
simulateConnected();
|
||||
});
|
||||
|
||||
describe('Game board integration', () => {
|
||||
it('renders the empty-board placeholder until a game is joined', () => {
|
||||
renderAppScreen(<Game />);
|
||||
|
||||
expect(screen.getByTestId('game-empty')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('phase-bar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('right-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('transitions from empty → active board when gameJoined + gameStateChanged fire', async () => {
|
||||
renderAppScreen(<Game />);
|
||||
|
||||
expect(screen.getByTestId('game-empty')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
GameDispatch.gameJoined(
|
||||
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
|
||||
);
|
||||
GameDispatch.gameStateChanged(
|
||||
42,
|
||||
buildEventGameStateChanged([1, 2], 1),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('game-empty')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('player-board-2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('hand-zone')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns to the empty placeholder when gameLeft fires', async () => {
|
||||
renderAppScreen(<Game />);
|
||||
|
||||
act(() => {
|
||||
GameDispatch.gameJoined(
|
||||
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
|
||||
);
|
||||
GameDispatch.gameStateChanged(
|
||||
42,
|
||||
buildEventGameStateChanged([1, 2], 1),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
GameDispatch.gameLeft(42);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('game-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('player-board-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the opponent selector for 2-player games but shows it for 3+', async () => {
|
||||
renderAppScreen(<Game />);
|
||||
|
||||
act(() => {
|
||||
GameDispatch.gameJoined(
|
||||
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
|
||||
);
|
||||
GameDispatch.gameStateChanged(
|
||||
42,
|
||||
buildEventGameStateChanged([1, 2], 1),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('opponent-selector')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
GameDispatch.gameStateChanged(
|
||||
42,
|
||||
buildEventGameStateChanged([1, 2, 3], 1),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('opponent-selector')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('mirrors the opponent board and leaves the local board upright', async () => {
|
||||
renderAppScreen(<Game />);
|
||||
|
||||
act(() => {
|
||||
GameDispatch.gameJoined(
|
||||
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
|
||||
);
|
||||
GameDispatch.gameStateChanged(
|
||||
42,
|
||||
buildEventGameStateChanged([1, 2], 1),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('player-board-2')).toHaveClass('player-board--mirrored');
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('player-board-1')).not.toHaveClass('player-board--mirrored');
|
||||
});
|
||||
|
||||
it('renders the deck/graveyard/exile rail in desktop order (no stack in rail)', async () => {
|
||||
renderAppScreen(<Game />);
|
||||
|
||||
act(() => {
|
||||
GameDispatch.gameJoined(
|
||||
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
|
||||
);
|
||||
GameDispatch.gameStateChanged(
|
||||
42,
|
||||
buildEventGameStateChanged([1, 2], 1),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const localBoard = screen.getByTestId('player-board-1');
|
||||
const rail = within(localBoard).getByTestId('zone-rail');
|
||||
const labels = Array.from(rail.querySelectorAll('.zone-stack__label')).map(
|
||||
(n) => n.textContent,
|
||||
);
|
||||
expect(labels).toEqual(['Deck', 'Graveyard', 'Exile']);
|
||||
expect(within(rail).queryByText('Stack')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends a game_say command through the socket when a chat message is submitted', async () => {
|
||||
// Establish a real mock socket so the outbound CommandContainer is captured.
|
||||
connectRaw();
|
||||
|
||||
renderAppScreen(<Game />);
|
||||
|
||||
act(() => {
|
||||
GameDispatch.gameJoined(
|
||||
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
|
||||
);
|
||||
// buildEventGameStateChanged sets gameStarted: true, suppressing the
|
||||
// deck-select dialog which would otherwise block focus/interaction.
|
||||
GameDispatch.gameStateChanged(
|
||||
42,
|
||||
buildEventGameStateChanged([1, 2], 1),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('game chat input')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const input = screen.getByLabelText('game chat input') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'gl hf' } });
|
||||
fireEvent.submit(input.closest('form')!);
|
||||
|
||||
const captured = findLastGameCommand(Command_GameSay_ext);
|
||||
expect(captured.value.message).toBe('gl hf');
|
||||
expect(captured.gameId).toBe(42);
|
||||
});
|
||||
|
||||
it('navigates to /server when the local user is kicked', async () => {
|
||||
renderAppScreen(
|
||||
<>
|
||||
<Game />
|
||||
<LocationProbe />
|
||||
</>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
GameDispatch.gameJoined(
|
||||
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
|
||||
);
|
||||
GameDispatch.gameStateChanged(
|
||||
42,
|
||||
buildEventGameStateChanged([1, 2], 1),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
GameDispatch.kicked(42);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('app-location')).toHaveTextContent('/server');
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to /server when the game is closed by the host', async () => {
|
||||
renderAppScreen(
|
||||
<>
|
||||
<Game />
|
||||
<LocationProbe />
|
||||
</>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
GameDispatch.gameJoined(
|
||||
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
|
||||
);
|
||||
GameDispatch.gameStateChanged(
|
||||
42,
|
||||
buildEventGameStateChanged([1, 2], 1),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
GameDispatch.gameClosed(42);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('app-location')).toHaveTextContent('/server');
|
||||
});
|
||||
});
|
||||
|
||||
it('reflects a host change through both PlayerList badge and PlayerInfoPanel', async () => {
|
||||
renderAppScreen(<Game />);
|
||||
|
||||
act(() => {
|
||||
GameDispatch.gameJoined(
|
||||
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
|
||||
);
|
||||
GameDispatch.gameStateChanged(
|
||||
42,
|
||||
buildEventGameStateChanged([1, 2], 1),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('player-list-item-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Host starts as 1; badge should be on row 1.
|
||||
expect(
|
||||
screen.getByTestId('player-list-item-1').querySelector('.player-list__host-badge'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.getByTestId('player-list-item-2').querySelector('.player-list__host-badge'),
|
||||
).toBeNull();
|
||||
|
||||
// Host changes to player 2.
|
||||
act(() => {
|
||||
GameDispatch.gameHostChanged(42, 2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('player-list-item-2').querySelector('.player-list__host-badge'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId('player-list-item-1').querySelector('.player-list__host-badge'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('auto-opens the DeckSelectDialog when a game is joined and not started', async () => {
|
||||
renderAppScreen(<Game />);
|
||||
|
||||
act(() => {
|
||||
GameDispatch.gameJoined(
|
||||
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
|
||||
);
|
||||
GameDispatch.gameStateChanged(
|
||||
42,
|
||||
create(Data.Event_GameStateChangedSchema, {
|
||||
gameStarted: false,
|
||||
activePlayerId: 1,
|
||||
activePhase: -1,
|
||||
playerList: [1, 2].map((pid) =>
|
||||
create(Data.ServerInfo_PlayerSchema, {
|
||||
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
|
||||
playerId: pid,
|
||||
userInfo: create(Data.ServerInfo_UserSchema, { name: `P${pid}` }),
|
||||
}),
|
||||
deckList: '',
|
||||
zoneList: [],
|
||||
counterList: [],
|
||||
arrowList: [],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('deck list')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
321
webclient/package-lock.json
generated
321
webclient/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
53
webclient/src/__test-utils__/makeHookWrapper.tsx
Normal file
53
webclient/src/__test-utils__/makeHookWrapper.tsx
Normal 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 };
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
25
webclient/src/colors.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
29
webclient/src/components/Game/Battlefield/Battlefield.css
Normal file
29
webclient/src/components/Game/Battlefield/Battlefield.css
Normal 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);
|
||||
}
|
||||
196
webclient/src/components/Game/Battlefield/Battlefield.spec.tsx
Normal file
196
webclient/src/components/Game/Battlefield/Battlefield.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
63
webclient/src/components/Game/Battlefield/Battlefield.tsx
Normal file
63
webclient/src/components/Game/Battlefield/Battlefield.tsx
Normal 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;
|
||||
30
webclient/src/components/Game/Battlefield/BattlefieldRow.tsx
Normal file
30
webclient/src/components/Game/Battlefield/BattlefieldRow.tsx
Normal 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;
|
||||
50
webclient/src/components/Game/Battlefield/useBattlefield.ts
Normal file
50
webclient/src/components/Game/Battlefield/useBattlefield.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.card-context-menu .MuiPaper-root {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
44
webclient/src/components/Game/CardPreview/CardPreview.css
Normal file
44
webclient/src/components/Game/CardPreview/CardPreview.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
49
webclient/src/components/Game/CardPreview/CardPreview.tsx
Normal file
49
webclient/src/components/Game/CardPreview/CardPreview.tsx
Normal 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;
|
||||
|
|
@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
153
webclient/src/components/Game/CardSlot/CardSlot.css
Normal file
153
webclient/src/components/Game/CardSlot/CardSlot.css
Normal 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;
|
||||
}
|
||||
126
webclient/src/components/Game/CardSlot/CardSlot.spec.tsx
Normal file
126
webclient/src/components/Game/CardSlot/CardSlot.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
99
webclient/src/components/Game/CardSlot/CardSlot.tsx
Normal file
99
webclient/src/components/Game/CardSlot/CardSlot.tsx
Normal 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);
|
||||
89
webclient/src/components/Game/CardSlot/useCardSlot.ts
Normal file
89
webclient/src/components/Game/CardSlot/useCardSlot.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
}
|
||||
97
webclient/src/components/Game/GameLog/GameLog.css
Normal file
97
webclient/src/components/Game/GameLog/GameLog.css
Normal 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;
|
||||
}
|
||||
249
webclient/src/components/Game/GameLog/GameLog.spec.tsx
Normal file
249
webclient/src/components/Game/GameLog/GameLog.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
65
webclient/src/components/Game/GameLog/GameLog.tsx
Normal file
65
webclient/src/components/Game/GameLog/GameLog.tsx
Normal 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;
|
||||
111
webclient/src/components/Game/GameLog/useGameLog.ts
Normal file
111
webclient/src/components/Game/GameLog/useGameLog.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.hand-context-menu .MuiMenuItem-root {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
|
@ -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 size−1 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 size−1 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
}
|
||||
33
webclient/src/components/Game/HandZone/HandZone.css
Normal file
33
webclient/src/components/Game/HandZone/HandZone.css
Normal 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;
|
||||
}
|
||||
96
webclient/src/components/Game/HandZone/HandZone.spec.tsx
Normal file
96
webclient/src/components/Game/HandZone/HandZone.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
68
webclient/src/components/Game/HandZone/HandZone.tsx
Normal file
68
webclient/src/components/Game/HandZone/HandZone.tsx
Normal 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;
|
||||
55
webclient/src/components/Game/HandZone/useHandZone.ts
Normal file
55
webclient/src/components/Game/HandZone/useHandZone.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
53
webclient/src/components/Game/PhaseBar/PhaseBar.css
Normal file
53
webclient/src/components/Game/PhaseBar/PhaseBar.css
Normal 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;
|
||||
}
|
||||
258
webclient/src/components/Game/PhaseBar/PhaseBar.spec.tsx
Normal file
258
webclient/src/components/Game/PhaseBar/PhaseBar.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
90
webclient/src/components/Game/PhaseBar/PhaseBar.tsx
Normal file
90
webclient/src/components/Game/PhaseBar/PhaseBar.tsx
Normal 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;
|
||||
76
webclient/src/components/Game/PhaseBar/usePhaseBar.ts
Normal file
76
webclient/src/components/Game/PhaseBar/usePhaseBar.ts
Normal 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 };
|
||||
}
|
||||
13
webclient/src/components/Game/PlayerBoard/PlayerBoard.css
Normal file
13
webclient/src/components/Game/PlayerBoard/PlayerBoard.css
Normal 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;
|
||||
}
|
||||
108
webclient/src/components/Game/PlayerBoard/PlayerBoard.spec.tsx
Normal file
108
webclient/src/components/Game/PlayerBoard/PlayerBoard.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
77
webclient/src/components/Game/PlayerBoard/PlayerBoard.tsx
Normal file
77
webclient/src/components/Game/PlayerBoard/PlayerBoard.tsx
Normal 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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.player-context-menu .MuiMenuItem-root {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
76
webclient/src/components/Game/PlayerList/PlayerList.css
Normal file
76
webclient/src/components/Game/PlayerList/PlayerList.css
Normal 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;
|
||||
}
|
||||
143
webclient/src/components/Game/PlayerList/PlayerList.spec.tsx
Normal file
143
webclient/src/components/Game/PlayerList/PlayerList.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
68
webclient/src/components/Game/PlayerList/PlayerList.tsx
Normal file
68
webclient/src/components/Game/PlayerList/PlayerList.tsx
Normal 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;
|
||||
21
webclient/src/components/Game/RightPanel/RightPanel.css
Normal file
21
webclient/src/components/Game/RightPanel/RightPanel.css
Normal 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;
|
||||
}
|
||||
80
webclient/src/components/Game/RightPanel/RightPanel.spec.tsx
Normal file
80
webclient/src/components/Game/RightPanel/RightPanel.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
59
webclient/src/components/Game/RightPanel/RightPanel.tsx
Normal file
59
webclient/src/components/Game/RightPanel/RightPanel.tsx
Normal 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;
|
||||
54
webclient/src/components/Game/StackStrip/StackStrip.css
Normal file
54
webclient/src/components/Game/StackStrip/StackStrip.css
Normal 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;
|
||||
}
|
||||
107
webclient/src/components/Game/StackStrip/StackStrip.spec.tsx
Normal file
107
webclient/src/components/Game/StackStrip/StackStrip.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
73
webclient/src/components/Game/StackStrip/StackStrip.tsx
Normal file
73
webclient/src/components/Game/StackStrip/StackStrip.tsx
Normal 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;
|
||||
39
webclient/src/components/Game/TurnControls/TurnControls.css
Normal file
39
webclient/src/components/Game/TurnControls/TurnControls.css
Normal 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;
|
||||
}
|
||||
394
webclient/src/components/Game/TurnControls/TurnControls.spec.tsx
Normal file
394
webclient/src/components/Game/TurnControls/TurnControls.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
167
webclient/src/components/Game/TurnControls/TurnControls.tsx
Normal file
167
webclient/src/components/Game/TurnControls/TurnControls.tsx
Normal 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;
|
||||
203
webclient/src/components/Game/TurnControls/useTurnControls.ts
Normal file
203
webclient/src/components/Game/TurnControls/useTurnControls.ts
Normal 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 0–10). 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
11
webclient/src/components/Game/ZoneRail/ZoneRail.css
Normal file
11
webclient/src/components/Game/ZoneRail/ZoneRail.css
Normal 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;
|
||||
}
|
||||
74
webclient/src/components/Game/ZoneRail/ZoneRail.spec.tsx
Normal file
74
webclient/src/components/Game/ZoneRail/ZoneRail.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
50
webclient/src/components/Game/ZoneRail/ZoneRail.tsx
Normal file
50
webclient/src/components/Game/ZoneRail/ZoneRail.tsx
Normal 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;
|
||||
57
webclient/src/components/Game/ZoneStack/ZoneStack.css
Normal file
57
webclient/src/components/Game/ZoneStack/ZoneStack.css
Normal 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;
|
||||
}
|
||||
163
webclient/src/components/Game/ZoneStack/ZoneStack.spec.tsx
Normal file
163
webclient/src/components/Game/ZoneStack/ZoneStack.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
79
webclient/src/components/Game/ZoneStack/ZoneStack.tsx
Normal file
79
webclient/src/components/Game/ZoneStack/ZoneStack.tsx
Normal 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;
|
||||
29
webclient/src/components/Game/index.ts
Normal file
29
webclient/src/components/Game/index.ts
Normal 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';
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue