implement gameboard v1

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

View file

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

View file

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

View file

@ -0,0 +1,93 @@
import { useMemo } from 'react';
import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
import { useSettings } from '@app/hooks';
import CardSlot from '../CardSlot/CardSlot';
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
import BattlefieldRow from './BattlefieldRow';
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;
}
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));
}
function Battlefield({
gameId,
playerId,
mirrored = false,
canAct = false,
arrowSourceKey = null,
onCardHover,
onCardClick,
onCardContextMenu,
onCardDoubleClick,
}: BattlefieldProps) {
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 (
<div className="battlefield" data-testid="battlefield">
{rowOrder.map((rowIdx) => (
<BattlefieldRow key={rowIdx} playerId={playerId} row={rowIdx}>
{rows[rowIdx].map((card) => {
const key = makeCardKey(playerId, App.ZoneName.TABLE, card.id);
return (
<CardSlot
key={card.id}
card={card}
inverted={isInverted}
draggable={canAct}
ownerPlayerId={playerId}
zone={App.ZoneName.TABLE}
isArrowSource={arrowSourceKey === key}
onMouseEnter={onCardHover}
onClick={(c) => onCardClick?.(playerId, App.ZoneName.TABLE, c)}
onContextMenu={onCardContextMenu}
onDoubleClick={onCardDoubleClick}
/>
);
})}
</BattlefieldRow>
))}
</div>
);
}
export default Battlefield;

View file

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

View file

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

View file

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

View file

@ -0,0 +1,234 @@
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import { useWebClient } from '@app/hooks';
import { App, Data } from '@app/types';
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;
}
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.
const 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 },
];
function CardContextMenu({
isOpen,
anchorPosition,
gameId,
localPlayerId,
card,
ownerPlayerId,
sourceZone,
onClose,
onRequestSetPT,
onRequestSetAnnotation,
onRequestSetCounter,
onRequestDrawArrow,
onRequestAttach,
onRequestMoveToLibraryAt,
}: CardContextMenuProps) {
const webClient = useWebClient();
if (!card || ownerPlayerId == null || sourceZone == null || localPlayerId == null) {
return null;
}
const game = webClient.request.game;
const zone = sourceZone;
const cardId = card.id;
const setAttr = (attribute: Data.CardAttribute, value: string) => {
game.setCardAttr(gameId, { zone, cardId, attribute, attrValue: value });
};
const handleFlip = () => {
// 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.
game.flipCard(gameId, { zone, cardId, faceDown: !card.faceDown });
onClose();
};
const handleTapToggle = () => {
setAttr(Data.CardAttribute.AttrTapped, card.tapped ? '0' : '1');
onClose();
};
const handleFaceDownToggle = () => {
setAttr(Data.CardAttribute.AttrFaceDown, card.faceDown ? '0' : '1');
onClose();
};
const handleDoesntUntapToggle = () => {
setAttr(Data.CardAttribute.AttrDoesntUntap, card.doesntUntap ? '0' : '1');
onClose();
};
const handleSetPT = () => {
onRequestSetPT();
onClose();
};
const handleSetAnnotation = () => {
onRequestSetAnnotation();
onClose();
};
const handleCardCounterDelta = (delta: number) => {
game.incCardCounter(gameId, {
zone,
cardId,
counterId: 0,
counterDelta: delta,
});
onClose();
};
const handleSetCardCounter = () => {
onRequestSetCounter();
onClose();
};
const handleDrawArrow = () => {
onRequestDrawArrow();
onClose();
};
const handleAttach = () => {
onRequestAttach();
onClose();
};
const handleUnattach = () => {
// 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.
game.attachCard(gameId, { startZone: zone, cardId });
onClose();
};
const isAttached = card.attachCardId >= 0;
// Desktop's actAttach is only available from a table card; other zones
// never expose the attach arrow.
const canAttach = sourceZone === App.ZoneName.TABLE;
// 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 = ownerPlayerId === localPlayerId;
const handleMove = (target: MoveTarget) => {
// targetPlayerId is the ACTING player (local), matching desktop's
// Player::actMoveCardTo* which uses playerInfo->getId().
game.moveCard(gameId, {
startPlayerId: ownerPlayerId,
startZone: sourceZone,
cardsToMove: { card: [{ cardId }] },
targetPlayerId: localPlayerId,
targetZone: target.zone,
x: target.x,
y: target.y,
isReversed: false,
});
onClose();
};
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 />
{MOVE_TARGETS.map((t) => (
<MenuItem key={t.label} onClick={() => handleMove(t)}>
{t.label}
</MenuItem>
))}
<MenuItem
onClick={() => {
onRequestMoveToLibraryAt();
onClose();
}}
>
Move to library at position
</MenuItem>
</>
)}
</Menu>
);
}
export default CardContextMenu;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,144 @@
import { useCallback, useId } from 'react';
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { useScryfallCard } from '@app/hooks';
import { App } from '@app/types';
import type { Data } from '@app/types';
import { cx } from '@app/utils';
import { makeCardKey, useRegisterCardRef } from '../CardRegistry/CardRegistryContext';
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 } = 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],
);
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 CardSlot;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import type { Enriched } from '@app/types';
import './GameLog.css';
const EMPTY_MESSAGES: Enriched.GameMessage[] = [];
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 GameLogProps {
gameId: number | undefined;
}
function GameLog({ gameId }: GameLogProps) {
const webClient = useWebClient();
const messages = useAppSelector((state) =>
gameId != null ? GameSelectors.getMessages(state, gameId) : EMPTY_MESSAGES,
);
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('');
const listRef = useRef<HTMLDivElement>(null);
// 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]);
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 (
<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={idx} className={lineClass}>
{!isEvent && <span className="game-log__author">{name}:</span>}
<span className="game-log__text">{m.message}</span>
</div>
);
})}
</div>
<form className="game-log__input-row" onSubmit={handleSubmit}>
<label className="game-log__input-label" htmlFor="game-log-say-input">
Say:
</label>
<input
id="game-log-say-input"
type="text"
className="game-log__input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={gameId == null}
aria-label="game chat input"
/>
</form>
</div>
);
}
export default GameLog;

View file

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

View file

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

View file

@ -0,0 +1,101 @@
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import { useWebClient } from '@app/hooks';
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 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 (
<Menu
open={isOpen}
onClose={onClose}
anchorReference="anchorPosition"
anchorPosition={anchorPosition ?? undefined}
data-testid="hand-context-menu"
className="hand-context-menu"
>
<MenuItem onClick={handleChoose}>Take mulligan (choose size)</MenuItem>
<MenuItem onClick={handleSameSize} disabled={handSize === 0}>
Take mulligan (same size)
</MenuItem>
<MenuItem onClick={handleMinusOne}>
Take mulligan (size 1)
</MenuItem>
<Divider />
<MenuItem onClick={handleRevealHand}>Reveal hand to</MenuItem>
<MenuItem onClick={handleRevealRandom} disabled={handSize === 0}>
Reveal random card to
</MenuItem>
</Menu>
);
}
export default HandContextMenu;

View file

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

View file

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

View file

@ -0,0 +1,89 @@
import { useDroppable } from '@dnd-kit/core';
import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
import { cx } from '@app/utils';
import CardSlot from '../CardSlot/CardSlot';
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
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 = 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 (
<div
ref={setNodeRef}
className={cx('hand-zone', { 'hand-zone--drop-over': isOver })}
data-testid="hand-zone"
onContextMenu={handleZoneContextMenu}
>
<div className="hand-zone__label">Hand · {cards.length}</div>
<div className="hand-zone__cards">
{cards.map((card) => {
const key = makeCardKey(playerId, App.ZoneName.HAND, card.id);
return (
<CardSlot
key={card.id}
card={card}
draggable={canAct}
ownerPlayerId={playerId}
zone={App.ZoneName.HAND}
isArrowSource={arrowSourceKey === key}
onMouseEnter={onCardHover}
onClick={(c) => onCardClick?.(playerId, App.ZoneName.HAND, c)}
onContextMenu={onCardContextMenu}
/>
);
})}
</div>
</div>
);
}
export default HandZone;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,147 @@
import Tooltip from '@mui/material/Tooltip';
import { useCurrentGame, useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App, Data } from '@app/types';
import { cx } from '@app/utils';
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 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 });
};
const onDoubleClickFor = (kind: 'untapAll' | 'drawCard' | undefined) => {
if (kind === 'untapAll') {
return handleUntapAll;
}
if (kind === 'drawCard') {
return handleDrawOne;
}
return undefined;
};
return (
<nav className="phase-bar" data-testid="phase-bar" aria-label="Turn phases">
{PHASE_LABELS.map(({ phase, label, title, builtInOnDoubleClick }) => {
const isActive = phase === activePhase;
return (
<Tooltip key={phase} title={title} placement="right" enterDelay={500}>
{/* span wrapper: MUI Tooltip can't attach listeners to a disabled button. */}
<span className="phase-bar__btn-wrap">
<button
type="button"
className={cx('phase-bar__btn', { 'phase-bar__btn--active': isActive })}
data-phase={phase}
disabled={!canAdvance}
onClick={() => handlePhaseClick(phase)}
onDoubleClick={onDoubleClickFor(builtInOnDoubleClick)}
>
{label}
</button>
</span>
</Tooltip>
);
})}
<div className="phase-bar__spacer" />
<Tooltip title="Pass to the next turn" placement="right" enterDelay={500}>
<span className="phase-bar__btn-wrap">
<button
type="button"
className="phase-bar__btn phase-bar__btn--pass"
disabled={!canAdvance}
onClick={handlePass}
>
PASS TURN
</button>
</span>
</Tooltip>
</nav>
);
}
export default PhaseBar;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,267 @@
import { useMemo, useState } from 'react';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
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 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(() => {
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 11 → 0 (the Phase enum is 010). When no phase is
// active yet (activePhase < 0 during the pre-game lobby), advance to
// Untap (0).
const current = game.activePhase;
const next = current >= 0 ? (current + 1) % 11 : 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 (
<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={settingsStatus !== LoadingState.READY}
title="Flip battlefield row order (saved across sessions)"
>
Invert Rows
</button>
<button
type="button"
className="turn-controls__btn"
onClick={onRequestGameInfo}
disabled={!hasLiveGame}
>
Game Info
</button>
<button
type="button"
className="turn-controls__btn"
onClick={handleLeave}
disabled={!canLeave}
>
Leave Game
</button>
{isHost && (
<>
<button
type="button"
className="turn-controls__btn"
onClick={(e) => setKickAnchor(e.currentTarget)}
disabled={!canKick}
>
Kick
</button>
<Menu
open={kickAnchor != null}
anchorEl={kickAnchor}
onClose={() => setKickAnchor(null)}
>
{opponents.map((o) => (
<MenuItem key={o.playerId} onClick={() => handleKick(o.playerId)}>
{o.name}
</MenuItem>
))}
</Menu>
</>
)}
</div>
);
}
export default TurnControls;

View file

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

View file

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

View file

@ -0,0 +1,156 @@
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 { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
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({
isOpen,
anchorPosition,
gameId,
playerId,
zoneName,
onClose,
onRequestDrawN,
onRequestDumpN,
onRequestRevealTopN,
onRequestRevealZone,
}: ZoneContextMenuProps) {
const webClient = useWebClient();
const zone = useAppSelector((state) =>
playerId != null && zoneName != null
? GameSelectors.getZone(state, gameId, playerId, zoneName)
: undefined,
);
if (playerId == null || zoneName == null) {
return null;
}
const game = webClient.request.game;
const alwaysReveal = zone?.alwaysRevealTopCard ?? false;
const alwaysLook = zone?.alwaysLookAtTopCard ?? false;
// Close-then-act helpers (avoid duplicating onClose at every site).
const run = (fn: () => void) => () => {
fn();
onClose();
};
const handleDrawOne = () => {
game.drawCards(gameId, { number: 1 });
};
const handleShuffle = () => {
game.shuffle(gameId, { zoneName: App.ZoneName.DECK, start: 0, end: -1 });
};
const handleRevealTop = () => {
game.revealCards(gameId, {
zoneName: App.ZoneName.DECK,
playerId: -1,
topCards: 1,
});
};
const handleToggleAlwaysReveal = () => {
game.changeZoneProperties(gameId, {
zoneName: App.ZoneName.DECK,
alwaysRevealTopCard: !alwaysReveal,
});
};
const handleToggleAlwaysLook = () => {
game.changeZoneProperties(gameId, {
zoneName: App.ZoneName.DECK,
alwaysLookAtTopCard: !alwaysLook,
});
};
const menuItems: React.ReactNode[] = [];
if (zoneName === App.ZoneName.DECK) {
menuItems.push(
<MenuItem key="draw-one" onClick={run(handleDrawOne)}>Draw a card</MenuItem>,
<MenuItem key="draw-n" onClick={run(onRequestDrawN)}>Draw N cards</MenuItem>,
<MenuItem key="shuffle" onClick={run(handleShuffle)}>Shuffle</MenuItem>,
<MenuItem key="dump-n" onClick={run(onRequestDumpN)}>Dump top N</MenuItem>,
<Divider key="d1" />,
<MenuItem key="reveal-top" onClick={run(handleRevealTop)}>
Reveal top card to all
</MenuItem>,
<MenuItem key="reveal-top-n" onClick={run(onRequestRevealTopN)}>
Reveal top N to
</MenuItem>,
<Divider key="d2" />,
<MenuItem
key="always-reveal"
onClick={run(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={run(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={run(onRequestRevealZone)}>
Reveal graveyard to
</MenuItem>,
);
} else if (zoneName === App.ZoneName.EXILE) {
menuItems.push(
<MenuItem key="reveal-exile" onClick={run(onRequestRevealZone)}>
Reveal exile to
</MenuItem>,
);
} else {
return null;
}
return (
<Menu
open={isOpen}
onClose={onClose}
anchorReference="anchorPosition"
anchorPosition={anchorPosition ?? undefined}
data-testid="zone-context-menu"
className="zone-context-menu"
>
{menuItems}
</Menu>
);
}
export default ZoneContextMenu;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -34,7 +34,7 @@ function Toast(props) {
open={open}
autoHideDuration={autoHideDuration}
onClose={handleClose}
TransitionComponent={TransitionLeft}
slots={{ transition: TransitionLeft }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert onClose={handleClose} severity={severity} iconMapping={iconMapping}

View file

@ -9,7 +9,7 @@ interface ToastEntry {
}
interface ToastState {
toasts: Map<string, ToastEntry>,
toasts: Record<string, ToastEntry>,
addToast: (key, children) => void,
openToast: (key) => void,
closeToast: (key) => void,
@ -17,7 +17,7 @@ interface ToastState {
}
const ToastContext: Context<any> = createContext<ToastState>({
toasts: new Map<string, ToastEntry>(),
toasts: {},
addToast: (_key, _children) => {},
openToast: (_key) => {},
closeToast: (_key) => {},
@ -38,7 +38,7 @@ export const ToastProvider: FC<PropsWithChildren> = (props) => {
<ToastContext.Provider value={providerState}>
{children}
<div>
{Array.from(state.toasts).map(([key, value]) => {
{Object.entries(state.toasts).map(([key, value]: [string, ToastEntry]) => {
const { isOpen, children } = value;
return (
<Toast key={key} open={isOpen} onClose={() => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } })}>

View file

@ -20,3 +20,6 @@ export { default as ModGuard } from './Guard/ModGuard';
// Toast
export { default as Toast, useToast, ToastProvider } from './Toast';
// Game board
export * from './Game';