gameboard fixes

This commit is contained in:
seavor 2026-04-25 14:42:41 -05:00
parent 88489ea2eb
commit f534cd79b7
86 changed files with 4249 additions and 2145 deletions

2
.gitignore vendored
View file

@ -1,5 +1,5 @@
tags tags
build* build*/
*.qm *.qm
.directory .directory
mysql.cnf mysql.cnf

View file

@ -248,7 +248,7 @@ describe('Game board integration', () => {
expect(screen.getByTestId('player-board-1')).not.toHaveClass('player-board--mirrored'); expect(screen.getByTestId('player-board-1')).not.toHaveClass('player-board--mirrored');
}); });
it('renders the deck/graveyard/exile rail in desktop order (no stack in rail)', async () => { it('renders the deck/graveyard/exile zones inside the info panel in desktop order', async () => {
renderAppScreen(<Game />); renderAppScreen(<Game />);
act(() => { act(() => {
@ -266,12 +266,14 @@ describe('Game board integration', () => {
}); });
const localBoard = screen.getByTestId('player-board-1'); const localBoard = screen.getByTestId('player-board-1');
const rail = within(localBoard).getByTestId('zone-rail'); const zones = localBoard.querySelector('.player-info-panel__zones')!;
const labels = Array.from(rail.querySelectorAll('.zone-stack__label')).map( const labels = Array.from(zones.querySelectorAll('.zone-stack__label')).map(
(n) => n.textContent, (n) => n.textContent,
); );
expect(labels).toEqual(['Deck', 'Graveyard', 'Exile']); expect(labels).toEqual(['Deck', 'Graveyard', 'Exile']);
expect(within(rail).queryByText('Stack')).not.toBeInTheDocument(); // Stack is now its own column, not a zone inside the rail.
expect(within(zones as HTMLElement).queryByText('Stack')).not.toBeInTheDocument();
expect(within(localBoard).getByTestId('stack-column-1')).toBeInTheDocument();
}); });
it('sends a game_say command through the socket when a chat message is submitted', async () => { it('sends a game_say command through the socket when a chat message is submitted', async () => {

View file

@ -0,0 +1,24 @@
.attachment-stack {
position: relative;
height: 100%;
width: 100%;
min-width: 0;
display: block;
}
.attachment-stack__parent {
position: relative;
height: 100%;
display: flex;
align-items: center;
/* width set inline per-stack */
}
.attachment-stack__child {
position: absolute;
top: 0;
height: 100%;
display: flex;
align-items: center;
/* left, width, z-index set inline per-child */
}

View file

@ -0,0 +1,100 @@
import { App, Data } from '@app/types';
import CardSlot from '../CardSlot/CardSlot';
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
import './AttachmentStack.css';
/**
* Fraction of a card's width that each attached child peeks past its
* predecessor. 0.3 matches the desktop Cockatrice aura fan roughly 70%
* overlap between adjacent cards.
*/
const OFFSET_FRACTION = 0.3;
export interface AttachmentStackProps {
parent: Data.ServerInfo_Card;
attachments: Data.ServerInfo_Card[];
draggable: boolean;
ownerPlayerId: number;
arrowSourceKey: string | null;
onCardHover?: (card: Data.ServerInfo_Card) => void;
onCardClick?: (playerId: number, zone: string, card: Data.ServerInfo_Card) => void;
onCardContextMenu?: (card: Data.ServerInfo_Card, event: React.MouseEvent) => void;
onCardDoubleClick?: (card: Data.ServerInfo_Card) => void;
}
function AttachmentStack({
parent,
attachments,
draggable,
ownerPlayerId,
arrowSourceKey,
onCardHover,
onCardClick,
onCardContextMenu,
onCardDoubleClick,
}: AttachmentStackProps) {
const parentKey = makeCardKey(ownerPlayerId, App.ZoneName.TABLE, parent.id);
// Stack footprint in units of one card width. N attachments → 1 + 0.3·N
// slots wide; every card shares width = 1 / stackFactor of the stack.
// The parent slot (BattlefieldStackColumn__slot) already sizes itself to
// the full stack footprint as a percentage of the stack column, so here we
// just fill the slot and lay the parent + attachments out as percentages.
const stackFactor = 1 + attachments.length * OFFSET_FRACTION;
// Round to hundredths — otherwise 1/1.6 yields 0.625 and computed offsets
// carry float artifacts into sub-pixel rendering and test assertions.
const round = (n: number) => Math.round(n * 100) / 100;
const cardWidthPct = round(100 / stackFactor);
return (
<div className="attachment-stack">
<div
className="attachment-stack__parent"
style={{ width: `${cardWidthPct}%` }}
>
<CardSlot
card={parent}
draggable={draggable}
ownerPlayerId={ownerPlayerId}
zone={App.ZoneName.TABLE}
isArrowSource={arrowSourceKey === parentKey}
onMouseEnter={onCardHover}
onClick={(c) => onCardClick?.(ownerPlayerId, App.ZoneName.TABLE, c)}
onContextMenu={onCardContextMenu}
onDoubleClick={onCardDoubleClick}
/>
</div>
{attachments.map((child, i) => {
const childKey = makeCardKey(ownerPlayerId, App.ZoneName.TABLE, child.id);
const leftPct = round(((i + 1) * OFFSET_FRACTION * 100) / stackFactor);
return (
<div
key={child.id}
className="attachment-stack__child"
style={{
left: `${leftPct}%`,
width: `${cardWidthPct}%`,
zIndex: i + 1,
}}
>
<CardSlot
card={child}
draggable={draggable}
ownerPlayerId={ownerPlayerId}
zone={App.ZoneName.TABLE}
isArrowSource={arrowSourceKey === childKey}
onMouseEnter={onCardHover}
onClick={(c) => onCardClick?.(ownerPlayerId, App.ZoneName.TABLE, c)}
onContextMenu={onCardContextMenu}
onDoubleClick={onCardDoubleClick}
/>
</div>
);
})}
</div>
);
}
export default AttachmentStack;

View file

@ -1,29 +1,54 @@
.battlefield { .battlefield {
display: flex; /* --card-base-width is the preferred width of a single card inside a lane.
flex-direction: column; * Scoped here (not on :root) so HandZone's own .card-slot rules remain
* unaffected. Consumed by .battlefield .card-slot and AttachmentStack. */
--card-base-width: 146px;
display: grid;
/* minmax(0, 1fr) "1fr" alone is shorthand for minmax(auto, 1fr), which
* lets a track grow past 1/3 when a card's intrinsic height is bigger than
* the track's share. That's what made lanes visibly uneven. Clamping the
* min to 0 forces strictly equal 1/3 splits regardless of content. */
grid-template-rows: repeat(3, minmax(0, 1fr));
height: 100%; height: 100%;
background: #0f1c38; background: #0f1c38;
border: 1px solid #1a2b52; border: 1px solid #1a2b52;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden;
} }
.battlefield__row { .battlefield__row {
flex: 1 1 0; min-height: 0;
min-width: 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 4px; /* Desktop-parity gap between stack columns (PADDING_X port). Stack columns
* are fixed-width blocks positioned via flex order; horizontal overflow
* scrolls inside the row rather than compressing cards (desktop behavior). */
gap: 16px;
padding: 4px 8px; padding: 4px 8px;
box-sizing: border-box; box-sizing: border-box;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
} }
.battlefield__row + .battlefield__row { /* Inset box-shadow instead of border-top so the divider doesn't consume
border-top: 1px solid rgba(255, 255, 255, 0.08); * grid track height otherwise rows 2 and 3 are 1px taller than row 1. */
.battlefield__row:not(:first-child) {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
} }
.battlefield__row--drop-over { .battlefield__row--drop-over {
background: rgba(247, 176, 28, 0.08); background: rgba(247, 176, 28, 0.08);
box-shadow: inset 0 0 0 2px rgba(247, 176, 28, 0.55); box-shadow: inset 0 0 0 2px rgba(247, 176, 28, 0.55);
} }
/* Spacer for empty stack columns so a card stored at x=9 renders at visual
* column 3, not column 0. Matches a 1-card stack's footprint (aspect-ratio
* 146/204) so gridMath's pointerstack walk aligns with the rendered layout. */
.battlefield__stack-placeholder {
height: 100%;
aspect-ratio: 146 / 204;
flex: 0 0 auto;
pointer-events: none;
}

View file

@ -1,5 +1,6 @@
import { screen } from '@testing-library/react'; import { create } from '@bufbuild/protobuf';
import { App } from '@app/types'; import { act, screen } from '@testing-library/react';
import { App, Data } from '@app/types';
vi.mock('../../../hooks/useSettings'); vi.mock('../../../hooks/useSettings');
@ -12,6 +13,7 @@ import {
makePlayerEntry, makePlayerEntry,
makeZoneEntry, makeZoneEntry,
} from '../../../store/game/__mocks__/fixtures'; } from '../../../store/game/__mocks__/fixtures';
import { Actions } from '../../../store/game/game.actions';
import Battlefield from './Battlefield'; import Battlefield from './Battlefield';
function setInvert(invert: boolean) { function setInvert(invert: boolean) {
@ -127,16 +129,6 @@ describe('Battlefield', () => {
expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['2', '1', '0']); 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', () => { describe('invertVerticalCoordinate user setting', () => {
it('renders rows bottom-to-top when the setting is on and not mirrored (local player)', () => { it('renders rows bottom-to-top when the setting is on and not mirrored (local player)', () => {
setInvert(true); setInvert(true);
@ -171,26 +163,324 @@ describe('Battlefield', () => {
expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['0', '1', '2']); 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 })]; describe('attachments', () => {
function attachedChild(overrides: Parameters<typeof makeCard>[0]) {
return makeCard({
attachPlayerId: 1,
attachZone: App.ZoneName.TABLE,
...overrides,
});
}
it('renders attached children inside the parent stack, not as independent lane slots', () => {
const cards = [
makeCard({ id: 10, name: 'Creature', x: 0, y: 0 }),
attachedChild({ id: 11, name: 'Aura', x: 5, y: 2, attachCardId: 10 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
// Parent is in row 0; child is NOT bucketed into its own y=2 lane —
// instead it lives inside the parent's AttachmentStack.
const row0 = screen.getByTestId('battlefield-row-0');
const row2 = screen.getByTestId('battlefield-row-2');
expect(row0.querySelector('img[alt="Creature"]')).not.toBeNull();
expect(row0.querySelector('img[alt="Aura"]')).not.toBeNull();
expect(row2.querySelector('img[alt="Aura"]')).toBeNull();
});
it('lays out attached children with horizontal left/width offsets sized to the stack', () => {
// Two attachments → stackFactor = 1 + 2 × 0.3 = 1.6.
// Card width = 100 / 1.6 = 62.5%. Children left at 18.75% and 37.5%.
const cards = [
makeCard({ id: 1, name: 'Creature', x: 0, y: 0 }),
attachedChild({ id: 2, name: 'AuraA', attachCardId: 1 }),
attachedChild({ id: 3, name: 'AuraB', attachCardId: 1 }),
];
const { container } = renderWithProviders( const { container } = renderWithProviders(
<Battlefield gameId={1} playerId={1} />, <Battlefield gameId={1} playerId={1} />,
{ preloadedState: stateWithBattlefield(cards) }, { preloadedState: stateWithBattlefield(cards) },
); );
expect(container.querySelector('.card-slot--inverted')).not.toBeNull(); const children = Array.from(
container.querySelectorAll('.attachment-stack__child'),
) as HTMLElement[];
expect(children).toHaveLength(2);
expect(children[0].style.left).toBe('18.75%');
expect(children[0].style.width).toBe('62.5%');
expect(children[0].style.zIndex).toBe('1');
expect(children[1].style.left).toBe('37.5%');
expect(children[1].style.width).toBe('62.5%');
expect(children[1].style.zIndex).toBe('2');
}); });
it('passes inverted=false to CardSlots when setting is on AND mirrored (XOR)', () => { it('does not flip the stack direction on an inverted (opponent) board', () => {
setInvert(true); const cards = [
const cards = [makeCard({ id: 1, name: 'A', x: 0, y: 0 })]; makeCard({ id: 1, name: 'Creature', x: 0, y: 0 }),
attachedChild({ id: 2, name: 'Aura', attachCardId: 1 }),
];
const { container } = renderWithProviders( const { container } = renderWithProviders(
<Battlefield gameId={1} playerId={1} mirrored />, <Battlefield gameId={1} playerId={1} mirrored />,
{ preloadedState: stateWithBattlefield(cards) }, { preloadedState: stateWithBattlefield(cards) },
); );
expect(container.querySelector('.card-slot--inverted')).toBeNull(); // One attachment → stackFactor = 1.3. Child left at 0.3 × 100 / 1.3 ≈ 23.08%
// (AttachmentStack rounds to hundredths to avoid float artifacts).
const child = container.querySelector('.attachment-stack__child') as HTMLElement;
expect(child.style.left).toBe('23.08%');
});
it('sizes the outer stack column to accommodate the full attachment footprint', () => {
// Two attachments → stackFactor 1.6; stack column aspect-ratio = 233.6 / 204.
// The outer column scales with lane height via aspect-ratio rather than
// a fixed pixel width, so sibling card slots inside share proportional
// width automatically.
const cards = [
makeCard({ id: 1, name: 'Creature', x: 0, y: 0 }),
attachedChild({ id: 2, name: 'AuraA', attachCardId: 1 }),
attachedChild({ id: 3, name: 'AuraB', attachCardId: 1 }),
];
const { container } = renderWithProviders(
<Battlefield gameId={1} playerId={1} />,
{ preloadedState: stateWithBattlefield(cards) },
);
const stackColumn = container.querySelector(
'.battlefield-stack-column',
) as HTMLElement;
expect(stackColumn.style.aspectRatio).toBe('233.6 / 204');
});
it('attached children render with the card-slot testid so they remain interactive', () => {
const cards = [
makeCard({ id: 1, name: 'Creature', x: 0, y: 0 }),
attachedChild({ id: 2, name: 'Aura', attachCardId: 1 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
const slots = screen.getAllByTestId('card-slot');
expect(slots).toHaveLength(2);
expect(slots.map((s) => s.getAttribute('data-card-id'))).toEqual(['1', '2']);
});
});
describe('stack columns (desktop-parity x packing)', () => {
it('packs 3 cards at x=0,1,2 into a single stack column at sub-positions 0/1/2', () => {
const cards = [
makeCard({ id: 1, name: 'A', x: 0, y: 0 }),
makeCard({ id: 2, name: 'B', x: 1, y: 0 }),
makeCard({ id: 3, name: 'C', x: 2, y: 0 }),
];
const { container } = renderWithProviders(
<Battlefield gameId={1} playerId={1} />,
{ preloadedState: stateWithBattlefield(cards) },
);
const row0 = screen.getByTestId('battlefield-row-0');
const stacks = row0.querySelectorAll('[data-testid="battlefield-stack-column"]');
expect(stacks).toHaveLength(1);
const slots = Array.from(
stacks[0].querySelectorAll('.battlefield-stack-column__slot'),
) as HTMLElement[];
expect(slots).toHaveLength(3);
// Positions are expressed as percentages of the stack column (width 244
// for a 3-card stack) so they scale with lane height. 49/244 ≈ 20.08%;
// 98/244 ≈ 40.16%.
expect(slots[0].style.left).toBe('0%');
expect(slots[1].style.left).toBe('20.08%');
expect(slots[2].style.left).toBe('40.16%');
expect(slots[0].style.zIndex).toBe('1');
expect(slots[2].style.zIndex).toBe('3');
// containerize sanity: the stack column width accommodates 3 cards.
void container;
});
it('splits a 4th card into a new stack column at sub-position 0', () => {
const cards = [
makeCard({ id: 1, name: 'A', x: 0, y: 0 }),
makeCard({ id: 2, name: 'B', x: 1, y: 0 }),
makeCard({ id: 3, name: 'C', x: 2, y: 0 }),
makeCard({ id: 4, name: 'D', x: 3, y: 0 }), // starts a new stack (col 1)
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
const row0 = screen.getByTestId('battlefield-row-0');
const stacks = row0.querySelectorAll('[data-testid="battlefield-stack-column"]');
expect(stacks).toHaveLength(2);
expect(stacks[0].querySelectorAll('img').length).toBe(3);
expect(stacks[1].querySelectorAll('img').length).toBe(1);
});
it('sizes a single-card stack with aspect-ratio 146/204 so it scales with lane height', () => {
const cards = [makeCard({ id: 1, name: 'Alone', x: 0, y: 0 })];
const { container } = renderWithProviders(
<Battlefield gameId={1} playerId={1} />,
{ preloadedState: stateWithBattlefield(cards) },
);
const stack = container.querySelector('.battlefield-stack-column') as HTMLElement;
expect(stack.style.aspectRatio).toBe('146 / 204');
});
it('sizes a 3-card stack to fit the rightmost sub-position (aspect-ratio 244/204)', () => {
const cards = [
makeCard({ id: 1, name: 'A', x: 0, y: 0 }),
makeCard({ id: 2, name: 'B', x: 1, y: 0 }),
makeCard({ id: 3, name: 'C', x: 2, y: 0 }),
];
const { container } = renderWithProviders(
<Battlefield gameId={1} playerId={1} />,
{ preloadedState: stateWithBattlefield(cards) },
);
const stack = container.querySelector('.battlefield-stack-column') as HTMLElement;
expect(stack.style.aspectRatio).toBe('244 / 204');
});
it('preserves empty stack columns so a card at gridX=7 renders at visual column 2', () => {
// Cards at gridX 0 and 7 → stack cols 0 and 2 (col 1 empty). The empty
// col 1 is rendered as a placeholder spacer so the visual positions
// match the stored x values; without it, a card dropped at gridX=7 in
// an otherwise-empty row would pack to visual column 0.
const cards = [
makeCard({ id: 1, name: 'Far-left', x: 0, y: 0 }),
makeCard({ id: 2, name: 'Far-right', x: 7, y: 0 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
const row0 = screen.getByTestId('battlefield-row-0');
const stacks = row0.querySelectorAll('[data-testid="battlefield-stack-column"]');
const placeholders = row0.querySelectorAll(
'[data-testid="battlefield-stack-placeholder"]',
);
expect(stacks).toHaveLength(2);
expect(placeholders).toHaveLength(1);
// Verify rendering order: stack(col0), placeholder(col1), stack(col2).
const items = Array.from(
row0.querySelectorAll(
'[data-testid="battlefield-stack-column"], [data-testid="battlefield-stack-placeholder"]',
),
);
expect(items[0].getAttribute('data-testid')).toBe('battlefield-stack-column');
expect(items[1].getAttribute('data-testid')).toBe('battlefield-stack-placeholder');
expect(items[1].getAttribute('data-col')).toBe('1');
expect(items[2].getAttribute('data-testid')).toBe('battlefield-stack-column');
});
it('renders no placeholders for a row packed from column 0', () => {
const cards = [
makeCard({ id: 1, name: 'A', x: 0, y: 0 }),
makeCard({ id: 2, name: 'B', x: 3, y: 0 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
expect(
screen
.getByTestId('battlefield-row-0')
.querySelectorAll('[data-testid="battlefield-stack-placeholder"]'),
).toHaveLength(0);
});
it('re-renders when a card is moved between columns in the same row', () => {
const mover = makeCard({ id: 50, name: 'Mover', x: 0, y: 0 });
const stationary = makeCard({ id: 51, name: 'Still', x: 6, y: 0 });
const { store } = renderWithProviders(
<Battlefield gameId={1} playerId={1} />,
{ preloadedState: stateWithBattlefield([mover, stationary]) },
);
const row0 = screen.getByTestId('battlefield-row-0');
// Before: Mover at col 0, placeholder at col 1, Still at col 2.
expect(row0.querySelectorAll('[data-testid="battlefield-stack-column"]')).toHaveLength(2);
expect(row0.querySelectorAll('[data-testid="battlefield-stack-placeholder"]')).toHaveLength(1);
act(() => {
store.dispatch(
Actions.cardMoved({
gameId: 1,
playerId: 1,
data: create(Data.Event_MoveCardSchema, {
cardId: 50, cardName: '', startPlayerId: 1,
startZone: App.ZoneName.TABLE, position: -1,
targetPlayerId: 1, targetZone: App.ZoneName.TABLE,
x: 3, y: 0, // Move Mover from col 0 to col 1, same row
newCardId: -1, faceDown: false, newCardProviderId: '',
}),
}),
);
});
// After: placeholder at col 0, Mover at col 1, Still at col 2 — no
// placeholder between them since col 1 is now occupied.
const row0After = screen.getByTestId('battlefield-row-0');
const items = Array.from(row0After.children);
expect(items).toHaveLength(3);
expect(items[0].getAttribute('data-testid')).toBe('battlefield-stack-placeholder');
expect(items[1].getAttribute('data-testid')).toBe('battlefield-stack-column');
expect(items[2].getAttribute('data-testid')).toBe('battlefield-stack-column');
// Confirm identities: Mover in the middle stack, Still in the rightmost.
expect(items[1].querySelector('img[alt="Mover"]')).not.toBeNull();
expect(items[2].querySelector('img[alt="Still"]')).not.toBeNull();
});
it('re-renders when a card is moved intra-battlefield (Event_MoveCard arrives)', () => {
const card = makeCard({ id: 42, name: 'Mover', x: 0, y: 0 });
const { store } = renderWithProviders(
<Battlefield gameId={1} playerId={1} />,
{ preloadedState: stateWithBattlefield([card]) },
);
// Before: card lives in row 0, stack col 0.
expect(
screen.getByTestId('battlefield-row-0').querySelector('img[alt="Mover"]'),
).not.toBeNull();
expect(
screen.getByTestId('battlefield-row-1').querySelector('img[alt="Mover"]'),
).toBeNull();
act(() => {
store.dispatch(
Actions.cardMoved({
gameId: 1,
playerId: 1,
data: create(Data.Event_MoveCardSchema, {
cardId: 42,
cardName: '',
startPlayerId: 1,
startZone: App.ZoneName.TABLE,
position: -1,
targetPlayerId: 1,
targetZone: App.ZoneName.TABLE,
x: 6,
y: 1,
newCardId: -1,
faceDown: false,
newCardProviderId: '',
}),
}),
);
});
// After: row 0 empty; row 1 holds Mover at stack col 2 with two
// placeholder spacers for cols 0/1.
expect(
screen.getByTestId('battlefield-row-0').querySelector('img[alt="Mover"]'),
).toBeNull();
const row1 = screen.getByTestId('battlefield-row-1');
expect(row1.querySelector('img[alt="Mover"]')).not.toBeNull();
expect(
row1.querySelectorAll('[data-testid="battlefield-stack-placeholder"]'),
).toHaveLength(2);
}); });
}); });
}); });

View file

@ -1,8 +1,7 @@
import { App, Data } from '@app/types'; import { Data } from '@app/types';
import CardSlot from '../CardSlot/CardSlot';
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
import BattlefieldRow from './BattlefieldRow'; import BattlefieldRow from './BattlefieldRow';
import BattlefieldStackColumn from './BattlefieldStackColumn';
import { useBattlefield } from './useBattlefield'; import { useBattlefield } from './useBattlefield';
import './Battlefield.css'; import './Battlefield.css';
@ -30,27 +29,51 @@ function Battlefield({
onCardContextMenu, onCardContextMenu,
onCardDoubleClick, onCardDoubleClick,
}: BattlefieldProps) { }: BattlefieldProps) {
const { rows, rowOrder, isInverted } = useBattlefield({ gameId, playerId, mirrored }); const { rows, stackColumnsByRow, rowOrder, attachmentsByParent } = useBattlefield({
gameId,
playerId,
mirrored,
});
return ( return (
<div className="battlefield" data-testid="battlefield"> <div className="battlefield" data-testid="battlefield">
{rowOrder.map((rowIdx) => ( {rowOrder.map((rowIdx) => (
<BattlefieldRow key={rowIdx} playerId={playerId} row={rowIdx}> <BattlefieldRow
{rows[rowIdx].map((card) => { key={rowIdx}
const key = makeCardKey(playerId, App.ZoneName.TABLE, card.id); playerId={playerId}
row={rowIdx}
rowCards={rows[rowIdx]}
>
{stackColumnsByRow[rowIdx].map((stackCards, colIdx) => {
if (stackCards == null) {
// Spacer for an empty stack column so visual position matches
// stored x — e.g. a single card at x=9 still renders at visual
// column 3 with cols 0/1/2 empty.
return (
<div
key={`empty-${rowIdx}-${colIdx}`}
className="battlefield__stack-placeholder"
data-testid="battlefield-stack-placeholder"
data-col={colIdx}
aria-hidden="true"
/>
);
}
return ( return (
<CardSlot // Key on the first (sub-position 0) card's id: React keys must be
key={card.id} // stable per stack across re-renders, and the leftmost card's id
card={card} // uniquely identifies a stack on this row.
inverted={isInverted} <BattlefieldStackColumn
key={stackCards[0].id}
cards={stackCards}
attachmentsByParent={attachmentsByParent}
draggable={canAct} draggable={canAct}
ownerPlayerId={playerId} ownerPlayerId={playerId}
zone={App.ZoneName.TABLE} arrowSourceKey={arrowSourceKey}
isArrowSource={arrowSourceKey === key} onCardHover={onCardHover}
onMouseEnter={onCardHover} onCardClick={onCardClick}
onClick={(c) => onCardClick?.(playerId, App.ZoneName.TABLE, c)} onCardContextMenu={onCardContextMenu}
onContextMenu={onCardContextMenu} onCardDoubleClick={onCardDoubleClick}
onDoubleClick={onCardDoubleClick}
/> />
); );
})} })}

View file

@ -1,18 +1,27 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { useDroppable } from '@dnd-kit/core'; import { useDroppable } from '@dnd-kit/core';
import { App } from '@app/types'; import { App, Data } from '@app/types';
import { cx } from '@app/utils'; import { cx } from '@app/utils';
export interface BattlefieldRowProps { export interface BattlefieldRowProps {
playerId: number; playerId: number;
row: number; row: number;
// Row's current cards (sorted by x, attachments already filtered out). The
// drop handler reads these off `event.over.data.current` to compute an
// insertion gridX via gridMath — see useGameDnd.handleDragEnd.
rowCards: Data.ServerInfo_Card[];
children: ReactNode; children: ReactNode;
} }
function BattlefieldRow({ playerId, row, children }: BattlefieldRowProps) { function BattlefieldRow({ playerId, row, rowCards, children }: BattlefieldRowProps) {
const { setNodeRef, isOver } = useDroppable({ const { setNodeRef, isOver } = useDroppable({
id: `battlefield-${playerId}-${row}`, id: `battlefield-${playerId}-${row}`,
data: { targetPlayerId: playerId, targetZone: App.ZoneName.TABLE, row }, data: {
targetPlayerId: playerId,
targetZone: App.ZoneName.TABLE,
row,
rowCards,
},
}); });
return ( return (

View file

@ -0,0 +1,17 @@
.battlefield-stack-column {
position: relative;
height: 100%;
flex: 0 0 auto;
/* width set inline per-stack (dynamic: card width + sub-position offsets + attachments) */
}
.battlefield-stack-column__slot {
position: absolute;
top: 0;
height: 100%;
/* left + zIndex set inline per sub-position */
/* AttachmentStack fills this slot; its own width is self-managed via flex
basis + max-width inline styles. */
display: flex;
align-items: center;
}

View file

@ -0,0 +1,115 @@
import { Data } from '@app/types';
import AttachmentStack from './AttachmentStack';
import {
CARD_HEIGHT_PX,
CARD_WIDTH_PX,
STACKED_CARD_OFFSET_X_PX,
} from './gridMath';
import './BattlefieldStackColumn.css';
// Keep in sync with AttachmentStack.OFFSET_FRACTION. Attachments peek 30% of a
// card width past their predecessor; a parent with N attachments occupies
// CARD_WIDTH_PX × (1 + N × 0.3) horizontally.
const ATTACH_OFFSET_FRACTION = 0.3;
const EMPTY_ATTACHMENTS: Data.ServerInfo_Card[] = [];
export interface BattlefieldStackColumnProps {
cards: Data.ServerInfo_Card[]; // 1..MAX_SUBPOS cards, sorted by sub-position
attachmentsByParent: ReadonlyMap<number, Data.ServerInfo_Card[]>;
draggable: boolean;
ownerPlayerId: number;
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;
}
// Width of a stack column = right-edge extent of its widest-positioned card.
// Each card sits at left = subPos × OFFSET; a card with K attachments extends
// rightward to (subPos × OFFSET) + CARD_WIDTH × (1 + K × 0.3). We take the max
// across all cards so attachments on an early sub-position don't overlap the
// neighbor stack.
function computeStackWidth(
cards: Data.ServerInfo_Card[],
attachmentsByParent: ReadonlyMap<number, Data.ServerInfo_Card[]>,
): number {
let maxRight = CARD_WIDTH_PX;
cards.forEach((card, subPos) => {
const attachCount = attachmentsByParent.get(card.id)?.length ?? 0;
const cardWidth = CARD_WIDTH_PX * (1 + attachCount * ATTACH_OFFSET_FRACTION);
const leftOffset = subPos * STACKED_CARD_OFFSET_X_PX;
maxRight = Math.max(maxRight, leftOffset + cardWidth);
});
return Math.round(maxRight * 100) / 100;
}
function slotWidthFor(card: Data.ServerInfo_Card, attachmentsByParent: ReadonlyMap<number, Data.ServerInfo_Card[]>): number {
const attachCount = attachmentsByParent.get(card.id)?.length ?? 0;
return CARD_WIDTH_PX * (1 + attachCount * ATTACH_OFFSET_FRACTION);
}
function BattlefieldStackColumn({
cards,
attachmentsByParent,
draggable,
ownerPlayerId,
arrowSourceKey,
onCardHover,
onCardClick,
onCardContextMenu,
onCardDoubleClick,
}: BattlefieldStackColumnProps) {
const widthPx = computeStackWidth(cards, attachmentsByParent);
// Stack column scales with lane height via aspect-ratio. Its rendered
// width = laneHeight × widthPx / CARD_HEIGHT_PX; per-slot left/width are
// expressed as percentages of that, so positions stay proportional at any
// zoom level. widthPx is the nominal (146/49/ratio) footprint.
const round = (n: number) => Math.round(n * 100) / 100;
return (
<div
className="battlefield-stack-column"
data-testid="battlefield-stack-column"
style={{ aspectRatio: `${widthPx} / ${CARD_HEIGHT_PX}` }}
>
{cards.map((card, subPos) => {
const slotWidth = slotWidthFor(card, attachmentsByParent);
const leftPct = round((subPos * STACKED_CARD_OFFSET_X_PX * 100) / widthPx);
const widthPct = round((slotWidth * 100) / widthPx);
return (
<div
key={card.id}
className="battlefield-stack-column__slot"
data-sub-position={subPos}
style={{
left: `${leftPct}%`,
width: `${widthPct}%`,
// Later sub-positions render on top of earlier ones, matching
// desktop's paint order where cards added later overlay neighbors.
zIndex: subPos + 1,
}}
>
<AttachmentStack
parent={card}
attachments={attachmentsByParent.get(card.id) ?? EMPTY_ATTACHMENTS}
draggable={draggable}
ownerPlayerId={ownerPlayerId}
arrowSourceKey={arrowSourceKey}
onCardHover={onCardHover}
onCardClick={onCardClick}
onCardContextMenu={onCardContextMenu}
onCardDoubleClick={onCardDoubleClick}
/>
</div>
);
})}
</div>
);
}
export default BattlefieldStackColumn;

View file

@ -0,0 +1,149 @@
import { makeCard } from '../../../store/game/__mocks__/fixtures';
import {
CARD_WIDTH_PX,
MAX_SUBPOS,
PADDING_X_PX,
STACKED_CARD_OFFSET_X_PX,
applyInvertY,
closestGridPoint,
mapToGridX,
stackColumnWidth,
stackCountsForRow,
} from './gridMath';
describe('gridMath', () => {
describe('stackColumnWidth', () => {
it('returns a single card width for an empty or 1-card stack', () => {
expect(stackColumnWidth(0)).toBe(CARD_WIDTH_PX);
expect(stackColumnWidth(1)).toBe(CARD_WIDTH_PX);
});
it('adds one sub-position offset per extra card, up to MAX_SUBPOS', () => {
expect(stackColumnWidth(2)).toBe(CARD_WIDTH_PX + STACKED_CARD_OFFSET_X_PX);
expect(stackColumnWidth(3)).toBe(CARD_WIDTH_PX + 2 * STACKED_CARD_OFFSET_X_PX);
// A 4th card would overflow to the next stack; width is capped at 3.
expect(stackColumnWidth(4)).toBe(CARD_WIDTH_PX + 2 * STACKED_CARD_OFFSET_X_PX);
});
});
describe('stackCountsForRow', () => {
it('groups cards by Math.floor(x / MAX_SUBPOS)', () => {
const cards = [
makeCard({ id: 1, x: 0 }),
makeCard({ id: 2, x: 1 }),
makeCard({ id: 3, x: 3 }),
makeCard({ id: 4, x: 7 }),
];
const counts = stackCountsForRow(cards);
expect(counts.get(0)).toBe(2); // x=0,1 → col 0
expect(counts.get(1)).toBe(1); // x=3 → col 1
expect(counts.get(2)).toBe(1); // x=7 → col 2
});
it('treats missing x as 0', () => {
const counts = stackCountsForRow([makeCard({ id: 1 })]);
expect(counts.get(0)).toBe(1);
});
});
describe('mapToGridX', () => {
it('returns 0 at the left edge of an empty row', () => {
expect(mapToGridX(0, new Map())).toBe(0);
});
it('returns sub-positions 0, 1, 2 within a single empty stack column', () => {
const counts = new Map<number, number>();
expect(mapToGridX(0, counts)).toBe(0);
expect(mapToGridX(STACKED_CARD_OFFSET_X_PX, counts)).toBe(1);
expect(mapToGridX(2 * STACKED_CARD_OFFSET_X_PX, counts)).toBe(2);
});
it('clamps sub-position to MAX_SUBPOS - 1 inside a stack', () => {
// Far-right of stack 0 (pointer just before stack 1 starts) still returns 2.
const counts = new Map<number, number>();
const pointerJustInsideStack0 = CARD_WIDTH_PX - 1;
expect(mapToGridX(pointerJustInsideStack0, counts)).toBe(2);
});
it('advances to the next stack base when pointer crosses padding', () => {
// One card already in stack 0; width = CARD_WIDTH_PX. Pointer past the
// padding lands at the base of stack 1 → gridX = 3.
const counts = new Map<number, number>([[0, 1]]);
const pointerInStack1 = CARD_WIDTH_PX + PADDING_X_PX;
expect(mapToGridX(pointerInStack1, counts)).toBe(3);
});
it('accounts for variable stack widths when walking past multiple stacks', () => {
// Stack 0 is full (3 cards → width = CARD + 2*OFFSET).
// Stack 1 has 1 card (width = CARD).
// Pointer past both should land in stack 2 at sub-position 0 → gridX = 6.
const counts = new Map<number, number>([
[0, 3],
[1, 1],
]);
const stack0 = stackColumnWidth(3);
const stack1 = stackColumnWidth(1);
const pointerInStack2 = stack0 + PADDING_X_PX + stack1 + PADDING_X_PX;
expect(mapToGridX(pointerInStack2, counts)).toBe(6);
});
it('snaps to the nearer stack via desktop-style PADDING_X / 2 rounding', () => {
// Pointer halfway into the padding between stack 0 and stack 1 should
// round forward to stack 1 (matches desktop line 340: + PADDING_X / 2).
const counts = new Map<number, number>([[0, 1]]);
const pointerInPaddingMiddle = CARD_WIDTH_PX + PADDING_X_PX / 2;
expect(mapToGridX(pointerInPaddingMiddle, counts)).toBe(3);
});
it('treats a pointer left of the content edge as sub-position 0', () => {
expect(mapToGridX(-50, new Map())).toBe(0);
});
});
describe('closestGridPoint', () => {
it('rounds an unoccupied gridX down to its stack base', () => {
expect(closestGridPoint(2, new Set())).toBe(0);
expect(closestGridPoint(5, new Set())).toBe(3);
});
it('increments by 1 when the base is occupied', () => {
expect(closestGridPoint(0, new Set([0]))).toBe(1);
});
it('increments by 2 when base and base+1 are occupied', () => {
expect(closestGridPoint(0, new Set([0, 1]))).toBe(2);
});
it('returns null when all sub-positions in the stack are occupied', () => {
expect(closestGridPoint(0, new Set([0, 1, 2]))).toBeNull();
});
it('resolves within the target stack regardless of the initial sub-position', () => {
// gridX = 4 rounds to base 3; base is free → returns 3.
expect(closestGridPoint(4, new Set())).toBe(3);
// gridX = 4 with base occupied → returns base+1 = 4.
expect(closestGridPoint(4, new Set([3]))).toBe(4);
expect(closestGridPoint(5, new Set([3, 4]))).toBe(5);
});
});
describe('applyInvertY', () => {
it('returns y unchanged when not inverted', () => {
expect(applyInvertY(0, false)).toBe(0);
expect(applyInvertY(1, false)).toBe(1);
expect(applyInvertY(2, false)).toBe(2);
});
it('mirrors y across the 3-row span when inverted', () => {
expect(applyInvertY(0, true)).toBe(2);
expect(applyInvertY(1, true)).toBe(1);
expect(applyInvertY(2, true)).toBe(0);
});
it('clamps out-of-range y before inverting', () => {
expect(applyInvertY(-1, false)).toBe(0);
expect(applyInvertY(99, false)).toBe(2);
expect(applyInvertY(-1, true)).toBe(2);
});
});
});

View file

@ -0,0 +1,113 @@
// Port of desktop Cockatrice's table-zone grid math (cockatrice/src/game/zones/table_zone.cpp).
// The server wire protocol encodes table-zone card positions as integers:
// x = stackColumn * MAX_SUBPOS + subPosition (subPosition ∈ {0, 1, 2})
// y ∈ {0, 1, 2} (pre-inversion row index)
// Desktop packs up to MAX_SUBPOS cards into a single stack column via small
// horizontal offsets; once full, drops overflow to the next stack. We replicate
// those semantics exactly so cards rendered side-by-side on desktop land on the
// same grid points in the webclient.
import { Data } from '@app/types';
// Nominal pixel constants at the reference zoom (146x204 card). Cards scale
// with lane height in CSS via `aspect-ratio: 146/204`; at runtime the drop
// handler derives the effective card width from the row's measured height
// (effectiveCardWidth = laneHeight × 146/204) and passes it to mapToGridX.
// PADDING_X and MARGIN_LEFT match fixed CSS gap/padding so they don't scale.
export const CARD_WIDTH_PX = 146; // desktop CardDimensions::WIDTH = 72
export const CARD_HEIGHT_PX = 204;
export const STACKED_CARD_OFFSET_X_PX = 49; // desktop WIDTH/3 = 24 → 146/3 ≈ 49 preserves ratio
export const PADDING_X_PX = 16; // desktop PADDING_X = 35; smaller so rows fit more stacks at browser widths
export const MARGIN_LEFT_PX = 8; // matches .battlefield__row horizontal padding
export const ROW_COUNT = 3;
export const MAX_SUBPOS = 3; // cards per stack column before overflow
export function clampRow(y: number): number {
if (y < 0) return 0;
if (y >= ROW_COUNT) return ROW_COUNT - 1;
return y;
}
// Width a stack column occupies given how many cards it currently holds.
// A 1-card stack is cardWidth wide; each additional sub-positioned card
// extends the stack by offsetX (up to MAX_SUBPOS - 1 extra). Defaults to the
// nominal 146/49 constants; callers that need to account for lane scaling
// pass the effective width and offset derived from the row's measured height.
export function stackColumnWidth(
cardCount: number,
cardWidth: number = CARD_WIDTH_PX,
offsetX: number = STACKED_CARD_OFFSET_X_PX,
): number {
if (cardCount <= 1) return cardWidth;
const extras = Math.min(cardCount - 1, MAX_SUBPOS - 1);
return cardWidth + extras * offsetX;
}
// Count cards per stack column in a row. `cards` must be the row's cards
// (after filtering attached children, which do not occupy their own slot).
export function stackCountsForRow(cards: Data.ServerInfo_Card[]): Map<number, number> {
const counts = new Map<number, number>();
for (const card of cards) {
const col = Math.floor((card.x ?? 0) / MAX_SUBPOS);
counts.set(col, (counts.get(col) ?? 0) + 1);
}
return counts;
}
// Port of table_zone.cpp:mapToGrid's x-axis walk (lines 336-363).
// `pointerXInRow` is the drop pointer's x-coordinate relative to the row's
// left content edge (i.e. already offset past any CSS padding). We mirror
// desktop's "+ paddingX/2" rounding to snap to the nearest stack.
export function mapToGridX(
pointerXInRow: number,
stackCounts: Map<number, number>,
cardWidth: number = CARD_WIDTH_PX,
offsetX: number = STACKED_CARD_OFFSET_X_PX,
paddingX: number = PADDING_X_PX,
): number {
// Desktop shifts by paddingX/2 so a pointer near a stack boundary rounds
// to the nearer stack. MARGIN_LEFT is the caller's responsibility.
const x = pointerXInRow + paddingX / 2;
let xStack = 0;
let xNextStack = 0;
let nextStackCol = 0;
while (xNextStack <= x) {
xStack = xNextStack;
const w = stackColumnWidth(stackCounts.get(nextStackCol) ?? 0, cardWidth, offsetX);
xNextStack += w + paddingX;
nextStackCol++;
// Safety: the loop always terminates because x is finite and each iter
// grows xNextStack by at least cardWidth + paddingX > 0.
}
const stackCol = Math.max(nextStackCol - 1, 0);
const xDiff = Math.max(0, x - xStack);
const subPos = Math.min(Math.floor(xDiff / offsetX), MAX_SUBPOS - 1);
return stackCol * MAX_SUBPOS + subPos;
}
// Port of table_zone.cpp:closestGridPoint (lines 366-375). Rounds x down to
// its stack base, then bumps by +1 / +2 over occupied sub-slots. Returns null
// when all MAX_SUBPOS slots in the stack are taken — desktop handles this by
// dropping the card out of the drag list (card_drag_item.cpp:115), resulting
// in a silent reject. Callers should skip dispatching moveCard on null.
export function closestGridPoint(
gridX: number,
occupiedXs: ReadonlySet<number>,
): number | null {
const base = Math.floor(gridX / MAX_SUBPOS) * MAX_SUBPOS;
for (let i = 0; i < MAX_SUBPOS; i++) {
if (!occupiedXs.has(base + i)) return base + i;
}
return null;
}
// Port of table_zone.cpp:53-59. Inversion flips y at the grid-math boundary
// only — rendering already reverses rowOrder in useBattlefield, so here we
// invert when sending a move to a mirrored board (or when the user has the
// invertVerticalCoordinate setting on and the board isn't already mirrored).
export function applyInvertY(gridY: number, isInverted: boolean): number {
const clamped = clampRow(gridY);
return isInverted ? ROW_COUNT - 1 - clamped : clamped;
}

View file

@ -3,10 +3,19 @@ import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store'; import { GameSelectors, useAppSelector } from '@app/store';
import { useSettings } from '@app/hooks'; import { useSettings } from '@app/hooks';
import { MAX_SUBPOS, ROW_COUNT, clampRow } from './gridMath';
export interface Battlefield { export interface Battlefield {
// Flat rows (preserved for drop-handler occupancy and legacy consumers).
rows: Data.ServerInfo_Card[][]; rows: Data.ServerInfo_Card[][];
// Row → stack columns → ≤ MAX_SUBPOS cards sorted by sub-position
// (x mod MAX_SUBPOS). Empty stack columns are preserved as `null` so a
// card stored at x=9 (stack col 3) renders at visual column 3, not visual
// column 0 — matching desktop's ability to hold mid-lane positions.
stackColumnsByRow: (Data.ServerInfo_Card[] | null)[][];
rowOrder: number[]; rowOrder: number[];
isInverted: boolean; isInverted: boolean;
attachmentsByParent: ReadonlyMap<number, Data.ServerInfo_Card[]>;
} }
export interface UseBattlefieldArgs { export interface UseBattlefieldArgs {
@ -15,17 +24,21 @@ export interface UseBattlefieldArgs {
mirrored: boolean; mirrored: boolean;
} }
const ROW_COUNT = 3;
function rowIndexFor(card: Data.ServerInfo_Card): number { function rowIndexFor(card: Data.ServerInfo_Card): number {
const y = card.y ?? 0; return clampRow(card.y ?? 0);
return Math.max(0, Math.min(ROW_COUNT - 1, y)); }
function isAttachedChild(card: Data.ServerInfo_Card): boolean {
return card.attachCardId != null && card.attachCardId !== -1;
} }
export function useBattlefield({ gameId, playerId, mirrored }: UseBattlefieldArgs): Battlefield { export function useBattlefield({ gameId, playerId, mirrored }: UseBattlefieldArgs): Battlefield {
const cards = useAppSelector((state) => const cards = useAppSelector((state) =>
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.TABLE), GameSelectors.getCards(state, gameId, playerId, App.ZoneName.TABLE),
); );
const attachmentsByParent = useAppSelector((state) =>
GameSelectors.getAttachmentsByParent(state, gameId, playerId),
);
const { value: settings } = useSettings(); const { value: settings } = useSettings();
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false; const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
@ -36,6 +49,9 @@ export function useBattlefield({ gameId, playerId, mirrored }: UseBattlefieldArg
const rows = useMemo<Data.ServerInfo_Card[][]>(() => { const rows = useMemo<Data.ServerInfo_Card[][]>(() => {
const bucketed: Data.ServerInfo_Card[][] = Array.from({ length: ROW_COUNT }, () => []); const bucketed: Data.ServerInfo_Card[][] = Array.from({ length: ROW_COUNT }, () => []);
for (const card of cards) { for (const card of cards) {
// Children render nested under their parent via AttachmentStack, not as
// their own lane slot.
if (isAttachedChild(card)) continue;
bucketed[rowIndexFor(card)].push(card); bucketed[rowIndexFor(card)].push(card);
} }
for (const row of bucketed) { for (const row of bucketed) {
@ -44,7 +60,33 @@ export function useBattlefield({ gameId, playerId, mirrored }: UseBattlefieldArg
return bucketed; return bucketed;
}, [cards]); }, [cards]);
// Group each row's cards by stack column = floor(x / MAX_SUBPOS). Desktop
// packs up to MAX_SUBPOS cards into a stack at sub-positions 0/1/2; the
// renderer absolutely-positions each card at left = subPos * offset.
//
// Empty columns are preserved as `null` entries in the dense-indexed array
// so the renderer can emit a placeholder spacer for each gap — otherwise a
// card dropped at gridX=9 (stack col 3) in an empty row would render at
// visual column 0, breaking WYSIWYG drop positioning.
const stackColumnsByRow = useMemo<(Data.ServerInfo_Card[] | null)[][]>(() => {
return rows.map((rowCards) => {
const sparse: (Data.ServerInfo_Card[] | null)[] = [];
let maxCol = -1;
for (const card of rowCards) {
const col = Math.floor((card.x ?? 0) / MAX_SUBPOS);
if (!sparse[col]) sparse[col] = [];
(sparse[col] as Data.ServerInfo_Card[]).push(card);
if (col > maxCol) maxCol = col;
}
const filled: (Data.ServerInfo_Card[] | null)[] = [];
for (let i = 0; i <= maxCol; i++) {
filled[i] = sparse[i] ?? null;
}
return filled;
});
}, [rows]);
const rowOrder = isInverted ? [2, 1, 0] : [0, 1, 2]; const rowOrder = isInverted ? [2, 1, 0] : [0, 1, 2];
return { rows, rowOrder, isInverted }; return { rows, stackColumnsByRow, rowOrder, isInverted, attachmentsByParent };
} }

View file

@ -1,7 +1,9 @@
.card-slot { .card-slot {
position: relative; position: relative;
width: 146px; height: 100%;
height: 204px; max-height: 204px;
aspect-ratio: 146 / 204;
flex: 0 0 auto;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
user-select: none; user-select: none;
@ -9,6 +11,23 @@
transition: transform 120ms ease-out; transition: transform 120ms ease-out;
} }
/* Battlefield cards scale to fill the lane height; width follows via aspect-
* ratio. No pixel cap, so tall viewports grow cards rather than leaving empty
* vertical space; short lanes shrink them proportionally. Crowded rows do not
* compress cards .battlefield__row has overflow-x: auto, so packed rows
* scroll horizontally (desktop parity).
*
* HandZone's .card-slot keeps the default height-driven rules above. */
.battlefield .card-slot {
aspect-ratio: 146 / 204;
height: 100%;
width: auto;
max-height: none;
flex: 0 0 auto;
min-width: 0;
min-height: 0;
}
.card-slot__image { .card-slot__image {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -73,14 +92,6 @@
transform: rotate(90deg); transform: rotate(90deg);
} }
.card-slot--inverted {
transform: rotate(180deg);
}
.card-slot--tapped.card-slot--inverted {
transform: rotate(270deg);
}
.card-slot--attacking { .card-slot--attacking {
outline: 2px solid var(--color-arrow-red); outline: 2px solid var(--color-arrow-red);
outline-offset: -2px; outline-offset: -2px;

View file

@ -51,20 +51,6 @@ describe('CardSlot', () => {
expect(screen.getByTestId('card-slot')).toHaveClass('card-slot--tapped'); 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', () => { it('renders P/T overlay when pt is set', () => {
const card = makeCard({ pt: '5/5' }); const card = makeCard({ pt: '5/5' });
render(<CardSlot card={card} />); render(<CardSlot card={card} />);

View file

@ -9,7 +9,6 @@ import './CardSlot.css';
export interface CardSlotProps { export interface CardSlotProps {
card: Data.ServerInfo_Card; card: Data.ServerInfo_Card;
inverted?: boolean;
draggable?: boolean; draggable?: boolean;
isArrowSource?: boolean; isArrowSource?: boolean;
/** The player that owns this card (matches desktop's `getOwner()`). Kept /** The player that owns this card (matches desktop's `getOwner()`). Kept
@ -25,7 +24,6 @@ export interface CardSlotProps {
function CardSlot({ function CardSlot({
card, card,
inverted = false,
draggable = false, draggable = false,
isArrowSource = false, isArrowSource = false,
ownerPlayerId, ownerPlayerId,
@ -44,7 +42,6 @@ function CardSlot({
const className = cx('card-slot', { const className = cx('card-slot', {
'card-slot--tapped': card.tapped, 'card-slot--tapped': card.tapped,
'card-slot--inverted': inverted,
'card-slot--face-down': card.faceDown, 'card-slot--face-down': card.faceDown,
'card-slot--attacking': card.attacking, 'card-slot--attacking': card.attacking,
'card-slot--dragging': isDragging, 'card-slot--dragging': isDragging,

View file

@ -0,0 +1,97 @@
import { renderHook } from '@testing-library/react';
import { App } from '@app/types';
vi.mock('@app/hooks', async (orig) => {
const actual = await orig<typeof import('@app/hooks')>();
return {
...actual,
useScryfallCard: () => ({ smallUrl: null }),
};
});
const useDroppableMock = vi.fn();
const useDraggableMock = vi.fn();
// Per-test override so we can simulate the dnd-kit active-drag state without
// a real DndContext or sensor stack.
let nextIsDragging = false;
vi.mock('@dnd-kit/core', () => ({
useDroppable: (opts: unknown) => {
useDroppableMock(opts);
return { setNodeRef: vi.fn(), isOver: false };
},
useDraggable: (opts: unknown) => {
useDraggableMock(opts);
return { setNodeRef: vi.fn(), attributes: {}, listeners: {}, isDragging: nextIsDragging };
},
}));
vi.mock('../CardRegistry/CardRegistryContext', () => ({
makeCardKey: (p: number, z: string, c: number) => `${p}-${z}-${c}`,
useRegisterCardRef: () => vi.fn(),
}));
import { makeCard } from '../../../store/game/__mocks__/fixtures';
import { useCardSlot } from './useCardSlot';
beforeEach(() => {
useDroppableMock.mockClear();
useDraggableMock.mockClear();
nextIsDragging = false;
});
describe('useCardSlot droppable guard', () => {
it('enables the drop target for an unattached TABLE card', () => {
const card = makeCard({ id: 1 });
renderHook(() =>
useCardSlot({ card, draggable: false, ownerPlayerId: 1, zone: App.ZoneName.TABLE }),
);
const dropCall = useDroppableMock.mock.calls[0][0] as { disabled: boolean };
expect(dropCall.disabled).toBe(false);
});
it('disables the drop target when the card is itself attached (a child)', () => {
const card = makeCard({
id: 2,
attachPlayerId: 1,
attachZone: App.ZoneName.TABLE,
attachCardId: 1,
});
renderHook(() =>
useCardSlot({ card, draggable: false, ownerPlayerId: 1, zone: App.ZoneName.TABLE }),
);
const dropCall = useDroppableMock.mock.calls[0][0] as { disabled: boolean };
expect(dropCall.disabled).toBe(true);
});
it('disables the drop target for non-TABLE zones', () => {
const card = makeCard({ id: 3 });
renderHook(() =>
useCardSlot({ card, draggable: false, ownerPlayerId: 1, zone: App.ZoneName.HAND }),
);
const dropCall = useDroppableMock.mock.calls[0][0] as { disabled: boolean };
expect(dropCall.disabled).toBe(true);
});
it('disables the drop target when the owner is unknown', () => {
const card = makeCard({ id: 4 });
renderHook(() =>
useCardSlot({ card, draggable: false, ownerPlayerId: undefined, zone: App.ZoneName.TABLE }),
);
const dropCall = useDroppableMock.mock.calls[0][0] as { disabled: boolean };
expect(dropCall.disabled).toBe(true);
});
it('disables the drop target while the card itself is being dragged', () => {
// Without this gate, dnd-kit's default rectIntersection collision picks
// the dragged card's own droppable over the BattlefieldRow for short
// drags — the self-drop guard in useGameDnd then silently no-ops the
// move and the UI appears not to update.
nextIsDragging = true;
const card = makeCard({ id: 5 });
renderHook(() =>
useCardSlot({ card, draggable: true, ownerPlayerId: 1, zone: App.ZoneName.TABLE }),
);
const dropCall = useDroppableMock.mock.calls[0][0] as { disabled: boolean };
expect(dropCall.disabled).toBe(true);
});
});

View file

@ -45,9 +45,22 @@ export function useCardSlot({ card, draggable, ownerPlayerId, zone }: UseCardSlo
// Cards on the battlefield double as drop targets for drag-to-attach. // Cards on the battlefield double as drop targets for drag-to-attach.
// Other zones don't support attach (desktop's Player::actAttach rejects // Other zones don't support attach (desktop's Player::actAttach rejects
// non-table targets), so the droppable is only live for TABLE. // non-table targets), so the droppable is only live for TABLE. Already-
// attached cards (children) are also excluded so you can only attach to a
// lead card — matches the server-side rule that auto-unattaches children
// when their parent is re-attached.
//
// `!isDragging` ensures the currently-dragged card cannot be its own drop
// target. Without it, the default rectIntersection collision prefers the
// card's own droppable (smaller, contained in the row) over the battlefield
// row for short drags — self-drop guard then silently no-ops the move and
// the UI appears not to update.
const isAttachedChild = card.attachCardId != null && card.attachCardId !== -1;
const droppableEnabled = const droppableEnabled =
ownerPlayerId != null && zone === App.ZoneName.TABLE; ownerPlayerId != null &&
zone === App.ZoneName.TABLE &&
!isAttachedChild &&
!isDragging;
const { setNodeRef: setDropRef, isOver } = useDroppable({ const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `card-drop-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`, id: `card-drop-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
data: { data: {

View file

@ -61,6 +61,28 @@ describe('GameLog', () => {
expect(screen.getAllByText('Alice:').length).toBe(2); expect(screen.getAllByText('Alice:').length).toBe(2);
}); });
// Regression: server assigns player ids starting at 0 (see server_game.cpp
// nextPlayerId), so the first/host player is usually player 0. The log
// helper used to treat playerId <= 0 as the "system" sentinel and render
// "The server" for that player's chat and events. The sentinel is now -1.
it('renders the real player name for player 0 chat (regression: was "The server")', () => {
const host = makePlayerEntry({
properties: makePlayerProperties({
playerId: 0,
userInfo: makeUser({ name: 'Host' }),
}),
});
renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages(
[host],
[{ playerId: 0, message: 'gl', timeReceived: 0, kind: 'chat' }],
),
});
expect(screen.getByText('Host:')).toBeInTheDocument();
expect(screen.queryByText(/The server:/)).not.toBeInTheDocument();
});
it('renders a fallback author label when the speaker is not in the player list', () => { it('renders a fallback author label when the speaker is not in the player list', () => {
renderWithProviders(<GameLog gameId={1} />, { renderWithProviders(<GameLog gameId={1} />, {
preloadedState: stateWithMessages( preloadedState: stateWithMessages(

View file

@ -8,15 +8,6 @@
box-sizing: border-box; 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 { .hand-zone__cards {
flex: 1; flex: 1;
display: flex; display: flex;

View file

@ -32,18 +32,6 @@ function stateWithHand(cards: ReturnType<typeof makeCard>[]) {
} }
describe('HandZone', () => { 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', () => { it('renders a CardSlot for every card in hand', () => {
const cards = [ const cards = [
makeCard({ id: 1, name: 'Forest' }), makeCard({ id: 1, name: 'Forest' }),
@ -63,8 +51,8 @@ describe('HandZone', () => {
preloadedState: stateWithHand([]), preloadedState: stateWithHand([]),
}); });
expect(screen.getByText(/Hand · 0/)).toBeInTheDocument();
expect(screen.queryAllByTestId('card-slot')).toHaveLength(0); expect(screen.queryAllByTestId('card-slot')).toHaveLength(0);
expect(screen.getByTestId('hand-zone')).toBeInTheDocument();
}); });
describe('zone-level context menu', () => { describe('zone-level context menu', () => {

View file

@ -42,7 +42,6 @@ function HandZone({
data-testid="hand-zone" data-testid="hand-zone"
onContextMenu={handleZoneContextMenu} onContextMenu={handleZoneContextMenu}
> >
<div className="hand-zone__label">Hand · {cards.length}</div>
<div className="hand-zone__cards"> <div className="hand-zone__cards">
{cards.map((card) => { {cards.map((card) => {
const key = makeCardKey(playerId, App.ZoneName.HAND, card.id); const key = makeCardKey(playerId, App.ZoneName.HAND, card.id);

View file

@ -1,6 +1,6 @@
.player-board { .player-board {
display: grid; display: grid;
grid-template-columns: 160px minmax(0, 1fr) 110px; grid-template-columns: 150px 76px minmax(0, 1fr);
height: 100%; height: 100%;
min-height: 0; min-height: 0;
background: #0a1225; background: #0a1225;

View file

@ -47,14 +47,14 @@ function buildState() {
} }
describe('PlayerBoard', () => { describe('PlayerBoard', () => {
it('renders the info panel, battlefield, and zone rail in order', () => { it('renders the info panel, stack column, and battlefield', () => {
renderWithProviders(<PlayerBoard gameId={1} playerId={1} />, { renderWithProviders(<PlayerBoard gameId={1} playerId={1} />, {
preloadedState: buildState(), preloadedState: buildState(),
}); });
expect(screen.getByText('Trajer')).toBeInTheDocument(); expect(screen.getByText('Trajer')).toBeInTheDocument();
expect(screen.getByTestId('battlefield')).toBeInTheDocument(); expect(screen.getByTestId('battlefield')).toBeInTheDocument();
expect(screen.getByTestId('zone-rail')).toBeInTheDocument(); expect(screen.getByTestId('stack-column-1')).toBeInTheDocument();
}); });
it('passes mirrored=false by default so the battlefield uses natural row order', () => { it('passes mirrored=false by default so the battlefield uses natural row order', () => {
@ -67,10 +67,9 @@ describe('PlayerBoard', () => {
container.querySelectorAll('.battlefield__row'), container.querySelectorAll('.battlefield__row'),
).map((r) => r.getAttribute('data-row')); ).map((r) => r.getAttribute('data-row'));
expect(rowsInOrder).toEqual(['0', '1', '2']); 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', () => { it('propagates mirrored=true → battlefield reverses row order', () => {
const { container } = renderWithProviders( const { container } = renderWithProviders(
<PlayerBoard gameId={1} playerId={1} mirrored />, <PlayerBoard gameId={1} playerId={1} mirrored />,
{ preloadedState: buildState() }, { preloadedState: buildState() },
@ -80,10 +79,9 @@ describe('PlayerBoard', () => {
container.querySelectorAll('.battlefield__row'), container.querySelectorAll('.battlefield__row'),
).map((r) => r.getAttribute('data-row')); ).map((r) => r.getAttribute('data-row'));
expect(rowsInOrder).toEqual(['2', '1', '0']); 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', () => { it('renders info panel, stack column, battlefield left-to-right (even when mirrored)', () => {
const { container } = renderWithProviders( const { container } = renderWithProviders(
<PlayerBoard gameId={1} playerId={1} mirrored />, <PlayerBoard gameId={1} playerId={1} mirrored />,
{ preloadedState: buildState() }, { preloadedState: buildState() },
@ -91,8 +89,8 @@ describe('PlayerBoard', () => {
const children = Array.from(container.querySelector('.player-board')!.children); const children = Array.from(container.querySelector('.player-board')!.children);
expect(children[0]).toHaveClass('player-info-panel'); expect(children[0]).toHaveClass('player-info-panel');
expect(children[1]).toHaveClass('battlefield'); expect(children[1]).toHaveClass('stack-column');
expect(children[2]).toHaveClass('zone-rail'); expect(children[2]).toHaveClass('battlefield');
}); });
it('adds the --mirrored CSS modifier only when mirrored', () => { it('adds the --mirrored CSS modifier only when mirrored', () => {

View file

@ -3,7 +3,7 @@ import { cx } from '@app/utils';
import Battlefield from '../Battlefield/Battlefield'; import Battlefield from '../Battlefield/Battlefield';
import PlayerInfoPanel from '../PlayerInfoPanel/PlayerInfoPanel'; import PlayerInfoPanel from '../PlayerInfoPanel/PlayerInfoPanel';
import ZoneRail from '../ZoneRail/ZoneRail'; import StackColumn from '../StackColumn/StackColumn';
import './PlayerBoard.css'; import './PlayerBoard.css';
@ -51,6 +51,15 @@ function PlayerBoard({
canEdit={canEditCounters} canEdit={canEditCounters}
onRequestCreateCounter={onRequestCreateCounter} onRequestCreateCounter={onRequestCreateCounter}
onContextMenu={onPlayerContextMenu} onContextMenu={onPlayerContextMenu}
onCardHover={onCardHover}
onZoneClick={onZoneClick}
onZoneContextMenu={onZoneContextMenu}
/>
<StackColumn
gameId={gameId}
playerId={playerId}
mirrored={mirrored}
onCardHover={onCardHover}
/> />
<Battlefield <Battlefield
gameId={gameId} gameId={gameId}
@ -63,13 +72,6 @@ function PlayerBoard({
onCardContextMenu={onCardContextMenu} onCardContextMenu={onCardContextMenu}
onCardDoubleClick={onCardDoubleClick} onCardDoubleClick={onCardDoubleClick}
/> />
<ZoneRail
gameId={gameId}
playerId={playerId}
onCardHover={onCardHover}
onZoneClick={onZoneClick}
onZoneContextMenu={onZoneContextMenu}
/>
</div> </div>
); );
} }

View file

@ -1,7 +1,7 @@
.player-info-panel { .player-info-panel {
width: 160px; width: 150px;
height: 100%; height: 100%;
padding: 8px 10px; padding: 8px 6px;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -9,6 +9,7 @@
border-right: 1px solid #1a2b52; border-right: 1px solid #1a2b52;
color: #e5ecf7; color: #e5ecf7;
font-size: 12px; font-size: 12px;
overflow: hidden;
} }
.player-info-panel--empty { .player-info-panel--empty {
@ -17,7 +18,7 @@
.player-info-panel__header { .player-info-panel__header {
display: flex; display: flex;
align-items: baseline; align-items: center;
gap: 6px; gap: 6px;
margin-bottom: 6px; margin-bottom: 6px;
padding-bottom: 6px; padding-bottom: 6px;
@ -40,17 +41,15 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.player-info-panel__sideboard-lock { /* Life lives in the header slot where ping used to be. Rendered through
font-size: 11px; the shared counter-circle markup so its UL wrapper is cosmetic-only. */
line-height: 1; .player-info-panel__life-slot {
list-style: none;
margin: 0;
padding: 0;
flex-shrink: 0; flex-shrink: 0;
} }
.player-info-panel__ping {
color: #7f90b5;
font-size: 10px;
}
.player-info-panel__flag { .player-info-panel__flag {
align-self: flex-start; align-self: flex-start;
padding: 1px 6px; padding: 1px 6px;
@ -69,183 +68,96 @@
color: #dfffe3; color: #dfffe3;
} }
/* Life display: prominent box above the regular counter list. Mirrors /* Two columns: counters (narrow, left-aligned) and zones (Deck/Graveyard/
desktop's PlayerTarget sizing where Life renders at ~2x other counters. */ Exile rendered as 3 vertically-stacked thumbnails). */
.player-info-panel__life { .player-info-panel__body {
flex: 1 1 auto;
min-height: 0;
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: 32px minmax(0, 1fr);
grid-template-rows: auto auto; gap: 6px;
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;
} }
/* Counter column runs full panel height; rows of equal share keep the
first counter flush to the top (no drift between opponent and local
panels) and let circles scale to fit or shrink when there are many. */
.player-info-panel__counters { .player-info-panel__counters {
list-style: none; list-style: none;
padding: 0; padding: 2px 0;
margin: 0; margin: 0;
display: flex; min-height: 0;
flex-direction: column; display: grid;
grid-auto-rows: minmax(0, 1fr);
justify-items: start;
align-content: stretch;
gap: 3px; gap: 3px;
} }
.player-info-panel__counter { .player-info-panel__counter {
display: grid; display: flex;
grid-template-columns: 12px 1fr auto auto auto auto;
align-items: center; align-items: center;
gap: 3px; justify-content: center;
padding: 2px 0; aspect-ratio: 1;
} height: 100%;
width: auto;
.player-info-panel__counter--empty { max-width: 30px;
color: #5a6a8a; max-height: 30px;
font-style: italic; min-width: 18px;
grid-template-columns: 1fr; min-height: 18px;
}
.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; box-sizing: border-box;
background: #17223d; border-radius: 50%;
border: 1px solid #355090; border: 1px solid rgba(255, 255, 255, 0.35);
color: #fff; box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.4);
font: inherit; user-select: none;
font-weight: 700;
text-align: right;
border-radius: 2px;
} }
.player-info-panel__counter-input::-webkit-outer-spin-button, /* The Life pill sits in the fixed-height header slot, outside the grid,
.player-info-panel__counter-input::-webkit-inner-spin-button { so it keeps a constant size regardless of counter count. */
-webkit-appearance: none; .player-info-panel__counter--life {
margin: 0; width: 32px;
height: 32px;
max-width: none;
max-height: none;
aspect-ratio: auto;
border-width: 2px;
} }
.player-info-panel__counter-btn { .player-info-panel__counter[role='button'] {
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; cursor: pointer;
} }
.player-info-panel__counter-btn:hover { .player-info-panel__counter[role='button']:hover {
background: #223060; border-color: rgba(255, 255, 255, 0.7);
border-color: #355090;
color: #fff;
} }
.player-info-panel__counter-btn--del { .player-info-panel__counter-value {
color: #f7a3a3; color: #ffffff;
border-color: #5e2828; font-weight: 800;
background: #3f1a1a; font-size: 12px;
line-height: 1;
text-align: center;
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
} }
.player-info-panel__counter-btn--del:hover { .player-info-panel__counter--life .player-info-panel__counter-value {
background: #5e2828; font-size: 13px;
color: #fff; }
/* Zones column: 4 rows (Deck, Hand, Graveyard, Exile), each row gets an
equal share of the remaining body height. minmax(0, 1fr) + min-height on
the column lets each ZoneStack thumb shrink when the rail is short
instead of overflowing the panel. All four zones render as landscape
thumbs, matching desktop's info panel. */
.player-info-panel__zones {
min-height: 0;
min-width: 0;
overflow: hidden;
display: grid;
grid-template-rows: repeat(4, minmax(0, 1fr));
justify-items: center;
align-items: center;
gap: 4px;
} }
.player-info-panel__new-counter { .player-info-panel__new-counter {

View file

@ -35,13 +35,20 @@ function statefulPlayer(
} }
describe('PlayerInfoPanel', () => { describe('PlayerInfoPanel', () => {
it('renders the player name and ping', () => { it('renders the player name', () => {
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, { renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: statefulPlayer(), preloadedState: statefulPlayer(),
}); });
expect(screen.getByText('Pumuky')).toBeInTheDocument(); expect(screen.getByText('Pumuky')).toBeInTheDocument();
expect(screen.getByText('42s')).toBeInTheDocument(); });
it('does not render ping (ping lives in the right-side PlayerList)', () => {
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: statefulPlayer(),
});
expect(screen.queryByText('42s')).not.toBeInTheDocument();
}); });
it('falls back to "(unknown)" when userInfo is absent', () => { it('falls back to "(unknown)" when userInfo is absent', () => {
@ -57,12 +64,12 @@ describe('PlayerInfoPanel', () => {
expect(screen.getByText('(unknown)')).toBeInTheDocument(); expect(screen.getByText('(unknown)')).toBeInTheDocument();
}); });
it('renders Life in a prominent block above the rest, with "LIFE" label', () => { it('renders the Life counter inline in the header and other counters as circles in the body', () => {
const life = makeCounter({ const life = makeCounter({
id: 1, id: 1,
name: 'Life', name: 'Life',
count: 20, count: 20,
counterColor: create(Data.colorSchema, { r: 255, g: 255, b: 255, a: 255 }), counterColor: create(Data.colorSchema, { r: 255, g: 150, b: 0, a: 255 }),
}); });
const white = makeCounter({ const white = makeCounter({
id: 2, id: 2,
@ -71,25 +78,68 @@ describe('PlayerInfoPanel', () => {
counterColor: create(Data.colorSchema, { r: 250, g: 245, b: 220, a: 255 }), counterColor: create(Data.colorSchema, { r: 250, g: 245, b: 220, a: 255 }),
}); });
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, { const { container } = renderWithProviders(
preloadedState: statefulPlayer({ <PlayerInfoPanel gameId={1} playerId={1} />,
counters: { 1: life, 2: white }, {
}), preloadedState: statefulPlayer({ counters: { 2: white, 1: life } }),
}); },
);
expect(screen.getByTestId('life-1')).toHaveTextContent('20'); const lifePill = screen.getByTestId('counter-1');
expect(screen.getByText('LIFE')).toBeInTheDocument(); const whitePill = screen.getByTestId('counter-2');
// Other counters still render in the list with their name.
expect(screen.getByText('W')).toBeInTheDocument(); expect(lifePill).toHaveTextContent('20');
expect(screen.getByText('3')).toBeInTheDocument(); expect(whitePill).toHaveTextContent('3');
expect(lifePill).toHaveClass('player-info-panel__counter--life');
expect(whitePill).not.toHaveClass('player-info-panel__counter--life');
// Life sits inside the header (life-slot); others sit in the body list.
expect(container.querySelector('.player-info-panel__header')?.contains(lifePill)).toBe(true);
expect(container.querySelector('.player-info-panel__counters')?.contains(whitePill)).toBe(true);
expect(container.querySelector('.player-info-panel__counters')?.contains(lifePill)).toBe(false);
}); });
it('shows an empty-state line when no counters exist', () => { it('renders no counter elements when the player has no counters', () => {
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, { renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: statefulPlayer({ counters: {} }), preloadedState: statefulPlayer({ counters: {} }),
}); });
expect(screen.getByText(/no counters/i)).toBeInTheDocument(); expect(screen.queryByTestId(/^counter-/)).not.toBeInTheDocument();
});
it('renders the Deck, Hand, Graveyard, and Exile zones inside the body', () => {
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: statefulPlayer(),
});
expect(screen.getByTestId('zone-stack-deck')).toBeInTheDocument();
expect(screen.getByTestId('zone-stack-hand')).toBeInTheDocument();
expect(screen.getByTestId('zone-stack-grave')).toBeInTheDocument();
expect(screen.getByTestId('zone-stack-rfg')).toBeInTheDocument();
});
it('does not wire left-click onZoneClick for the Hand row', () => {
const onZoneClick = vi.fn();
renderWithProviders(
<PlayerInfoPanel gameId={1} playerId={1} onZoneClick={onZoneClick} />,
{ preloadedState: statefulPlayer() },
);
fireEvent.click(screen.getByTestId('zone-stack-hand'));
expect(onZoneClick).not.toHaveBeenCalled();
});
it('forwards zone clicks with (playerId, zoneName)', () => {
const onZoneClick = vi.fn();
renderWithProviders(
<PlayerInfoPanel gameId={1} playerId={1} onZoneClick={onZoneClick} />,
{ preloadedState: statefulPlayer() },
);
fireEvent.click(screen.getByTestId('zone-stack-deck'));
expect(onZoneClick).toHaveBeenCalledWith(1, 'deck');
}); });
it('shows the Conceded flag when player has conceded', () => { it('shows the Conceded flag when player has conceded', () => {
@ -182,10 +232,9 @@ describe('PlayerInfoPanel', () => {
expect(container.querySelector('.player-info-panel__host-badge')).toBeNull(); expect(container.querySelector('.player-info-panel__host-badge')).toBeNull();
}); });
// Sideboard lock indicator — mirrors desktop's `DeckViewContainer` // Sideboard lock lives in the right-side PlayerList — it must NOT render
// lock UI. The webclient surfaces it on the info panel since we don't // inside the PlayerInfoPanel.
// have a persistent deck view. it('never renders the sideboard-lock indicator in the panel', () => {
it('renders a 🔒 indicator when player.properties.sideboardLocked is true', () => {
const player = makePlayerEntry({ const player = makePlayerEntry({
properties: makePlayerProperties({ properties: makePlayerProperties({
playerId: 1, playerId: 1,
@ -199,24 +248,65 @@ describe('PlayerInfoPanel', () => {
}), }),
}); });
expect(screen.getByLabelText('sideboard locked')).toBeInTheDocument(); expect(screen.queryByLabelText('sideboard locked')).not.toBeInTheDocument();
}); });
it('omits the lock indicator when sideboardLocked is false', () => { describe('counter color fallback', () => {
const player = makePlayerEntry({ it('falls back to the MTG name map when the server omits the color', () => {
properties: makePlayerProperties({ const white = makeCounter({
playerId: 1, id: 1,
userInfo: makeUser({ name: 'P1' }), name: 'W',
sideboardLocked: false, count: 1,
}), counterColor: create(Data.colorSchema, { r: 0, g: 0, b: 0, a: 0 }),
}); });
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, { renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: makeStoreState({ preloadedState: statefulPlayer({ counters: { 1: white } }),
games: { games: { 1: makeGameEntry({ players: { 1: player } }) } }, });
}),
expect(screen.getByTestId('counter-1')).toHaveStyle({
background: 'rgba(245, 245, 220, 1)',
});
}); });
expect(screen.queryByLabelText('sideboard locked')).not.toBeInTheDocument(); it('respects the server color when it is present and non-zero', () => {
const custom = makeCounter({
id: 2,
name: 'W',
count: 1,
counterColor: create(Data.colorSchema, { r: 10, g: 20, b: 30, a: 255 }),
});
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: statefulPlayer({ counters: { 2: custom } }),
});
expect(screen.getByTestId('counter-2')).toHaveStyle({
background: 'rgba(10, 20, 30, 1)',
});
});
it('derives a stable non-black hash color for unknown names', () => {
const poison1 = makeCounter({
id: 3,
name: 'Poison',
count: 1,
counterColor: create(Data.colorSchema, { r: 0, g: 0, b: 0, a: 0 }),
});
const { unmount } = renderWithProviders(
<PlayerInfoPanel gameId={1} playerId={1} />,
{ preloadedState: statefulPlayer({ counters: { 3: poison1 } }) },
);
const first = screen.getByTestId('counter-3').getAttribute('style') ?? '';
unmount();
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: statefulPlayer({ counters: { 3: poison1 } }),
});
const second = screen.getByTestId('counter-3').getAttribute('style') ?? '';
expect(first).toBe(second);
expect(first).toMatch(/rgba\(\d+, \d+, \d+, 1\)/);
expect(first).not.toMatch(/rgba\(0, 0, 0/);
});
}); });
describe('editable counters', () => { describe('editable counters', () => {
@ -227,96 +317,61 @@ describe('PlayerInfoPanel', () => {
counterColor: create(Data.colorSchema, { r: 255, g: 255, b: 255, a: 255 }), counterColor: create(Data.colorSchema, { r: 255, g: 255, b: 255, a: 255 }),
}); });
it('does not render counter controls when canEdit is false (default)', () => { it('does not attach click handlers when canEdit is false (default)', () => {
const webClient = createMockWebClient();
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, { renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} />, {
preloadedState: statefulPlayer({ counters: { 1: life } }), preloadedState: statefulPlayer({ counters: { 1: life } }),
webClient,
}); });
expect(screen.queryByLabelText('increment Life')).not.toBeInTheDocument(); const pill = screen.getByTestId('counter-1');
expect(screen.queryByLabelText('decrement Life')).not.toBeInTheDocument(); expect(pill).not.toHaveAttribute('role', 'button');
expect(screen.queryByLabelText('delete Life')).not.toBeInTheDocument();
fireEvent.click(pill);
fireEvent.contextMenu(pill);
expect(webClient.request.game.incCounter).not.toHaveBeenCalled();
}); });
it('renders +/ controls on the Life block when canEdit is true (Life has no delete — desktop parity)', () => { it('dispatches incCounter(+1) on left-click when canEdit is true', () => {
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(); const webClient = createMockWebClient();
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} canEdit />, { renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} canEdit />, {
preloadedState: statefulPlayer({ counters: { 1: life } }), preloadedState: statefulPlayer({ counters: { 1: life } }),
webClient, webClient,
}); });
fireEvent.click(screen.getByLabelText('increment Life')); fireEvent.click(screen.getByTestId('counter-1'));
expect(webClient.request.game.incCounter).toHaveBeenCalledWith(1, { counterId: 1, delta: 1 }); expect(webClient.request.game.incCounter).toHaveBeenCalledWith(1, { counterId: 1, delta: 1 });
}); });
it('dispatches incCounter(-1) when is clicked', () => { it('dispatches incCounter(-1) on right-click, suppresses browser menu, and does not bubble to the panel', () => {
const webClient = createMockWebClient(); const webClient = createMockWebClient();
renderWithProviders(<PlayerInfoPanel gameId={1} playerId={1} canEdit />, { const onContextMenu = vi.fn();
preloadedState: statefulPlayer({ counters: { 1: life } }), renderWithProviders(
webClient, <PlayerInfoPanel gameId={1} playerId={1} canEdit onContextMenu={onContextMenu} />,
}); {
preloadedState: statefulPlayer({ counters: { 1: life } }),
webClient,
},
);
fireEvent.click(screen.getByLabelText('decrement Life')); const dispatched = fireEvent.contextMenu(screen.getByTestId('counter-1'));
expect(dispatched).toBe(false);
expect(webClient.request.game.incCounter).toHaveBeenCalledWith(1, { counterId: 1, delta: -1 }); expect(webClient.request.game.incCounter).toHaveBeenCalledWith(1, { counterId: 1, delta: -1 });
expect(onContextMenu).not.toHaveBeenCalled();
}); });
it('dispatches delCounter when × is clicked on a non-Life counter', () => { it('still fires the panel-level onContextMenu when right-clicking outside a counter', () => {
const webClient = createMockWebClient(); const onContextMenu = vi.fn();
const mana = makeCounter({ const { container } = renderWithProviders(
id: 2, <PlayerInfoPanel gameId={1} playerId={1} canEdit onContextMenu={onContextMenu} />,
name: 'W', { preloadedState: statefulPlayer({ counters: { 1: life } }) },
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')); fireEvent.contextMenu(container.querySelector('.player-info-panel__name')!);
expect(webClient.request.game.delCounter).toHaveBeenCalledWith(1, { counterId: 2 }); expect(onContextMenu).toHaveBeenCalled();
});
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', () => { it('fires onRequestCreateCounter when "+ New counter" is clicked', () => {

View file

@ -1,16 +1,30 @@
import { cx } from '@app/utils'; import { cx } from '@app/utils';
import type { Data } from '@app/types'; import { App, Data } from '@app/types';
import { cssColor, usePlayerInfoPanel } from './usePlayerInfoPanel'; import ZoneStack from '../ZoneStack/ZoneStack';
import { counterCssColor, usePlayerInfoPanel } from './usePlayerInfoPanel';
import './PlayerInfoPanel.css'; import './PlayerInfoPanel.css';
// All four zones render as landscape thumbs in the info rail. Hand sits
// between Deck and Graveyard to match desktop's hand counter placement.
const ZONE_ROWS: Array<{ name: string; label: string; rotated?: boolean }> = [
{ name: App.ZoneName.DECK, label: 'Deck', rotated: true },
{ name: App.ZoneName.HAND, label: 'Hand', rotated: true },
{ name: App.ZoneName.GRAVE, label: 'Graveyard', rotated: true },
{ name: App.ZoneName.EXILE, label: 'Exile', rotated: true },
];
export interface PlayerInfoPanelProps { export interface PlayerInfoPanelProps {
gameId: number; gameId: number;
playerId: number; playerId: number;
canEdit?: boolean; canEdit?: boolean;
onRequestCreateCounter?: () => void; onRequestCreateCounter?: () => void;
onContextMenu?: (event: React.MouseEvent) => void; onContextMenu?: (event: React.MouseEvent) => void;
onCardHover?: (card: Data.ServerInfo_Card) => void;
onZoneClick?: (playerId: number, zoneName: string) => void;
onZoneContextMenu?: (playerId: number, zoneName: string, event: React.MouseEvent) => void;
} }
function PlayerInfoPanel({ function PlayerInfoPanel({
@ -19,103 +33,52 @@ function PlayerInfoPanel({
canEdit = false, canEdit = false,
onRequestCreateCounter, onRequestCreateCounter,
onContextMenu, onContextMenu,
onCardHover,
onZoneClick,
onZoneContextMenu,
}: PlayerInfoPanelProps) { }: PlayerInfoPanelProps) {
const { const { player, isHost, lifeCounter, otherCounters, handleIncrement } = usePlayerInfoPanel({
player, gameId,
isHost, playerId,
lifeCounter, });
otherCounters,
editingId,
editDraft,
setEditDraft,
beginEdit,
commitEdit,
cancelEdit,
handleIncrement,
handleDelete,
} = usePlayerInfoPanel({ gameId, playerId });
if (!player) { if (!player) {
return <div className="player-info-panel player-info-panel--empty" />; return <div className="player-info-panel player-info-panel--empty" />;
} }
const name = player.properties.userInfo?.name ?? '(unknown)'; const name = player.properties.userInfo?.name ?? '(unknown)';
const ping = player.properties.pingSeconds ?? 0;
const conceded = player.properties.conceded; const conceded = player.properties.conceded;
const ready = player.properties.readyStart; const ready = player.properties.readyStart;
const sideboardLocked = player.properties.sideboardLocked ?? false;
const renderCounterRow = (c: Data.ServerInfo_Counter) => ( const counterHandlers = (c: Data.ServerInfo_Counter) =>
canEdit
? {
role: 'button' as const,
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
handleIncrement(c.id, +1);
},
// stopPropagation prevents the panel's onContextMenu (player menu)
// from firing when the user right-clicks a counter to decrement.
onContextMenu: (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
handleIncrement(c.id, -1);
},
}
: {};
const renderCounterCircle = (c: Data.ServerInfo_Counter, modifier?: string) => (
<li <li
key={c.id} key={c.id}
className="player-info-panel__counter" className={cx('player-info-panel__counter', modifier)}
data-testid={`counter-${c.id}`} data-testid={`counter-${c.id}`}
style={{ background: counterCssColor(c) }}
title={c.name}
aria-label={`${c.name}: ${c.count}`}
{...counterHandlers(c)}
> >
<span <span className="player-info-panel__counter-value">{c.count}</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> </li>
); );
@ -136,92 +99,48 @@ function PlayerInfoPanel({
</span> </span>
)} )}
<span className="player-info-panel__name">{name}</span> <span className="player-info-panel__name">{name}</span>
{sideboardLocked && ( {lifeCounter && (
<span <ul className="player-info-panel__life-slot">
className="player-info-panel__sideboard-lock" {renderCounterCircle(lifeCounter, 'player-info-panel__counter--life')}
aria-label="sideboard locked" </ul>
title="Sideboard locked"
>
🔒
</span>
)} )}
<span className="player-info-panel__ping" title={`ping ${ping}s`}>
{ping}s
</span>
</div> </div>
{conceded && <div className="player-info-panel__flag">Conceded</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>} {!conceded && ready && <div className="player-info-panel__flag player-info-panel__flag--ready">Ready</div>}
{lifeCounter && ( <div className="player-info-panel__body">
<div <ul className="player-info-panel__counters">
className="player-info-panel__life" {otherCounters.map((c) => renderCounterCircle(c))}
data-testid={`life-${playerId}`} </ul>
style={{ borderColor: cssColor(lifeCounter.counterColor) }} <div className="player-info-panel__zones">
> {ZONE_ROWS.map((z) => {
{canEdit && ( // Hand is context-menu only: desktop's hand counter doesn't open
<button // a zone view on left-click, and HandZone already renders the cards.
type="button" const clickHandler =
className="player-info-panel__life-btn" onZoneClick && z.name !== App.ZoneName.HAND
aria-label="decrement Life" ? (name: string) => onZoneClick(playerId, name)
onClick={() => handleIncrement(lifeCounter.id, -1)} : undefined;
> return (
<ZoneStack
</button> key={z.name}
)} gameId={gameId}
{editingId === lifeCounter.id ? ( playerId={playerId}
<input zoneName={z.name}
type="number" label={z.label}
autoFocus rotated={z.rotated}
className="player-info-panel__life-input" onCardHover={onCardHover}
value={editDraft} onClick={clickHandler}
onChange={(e) => setEditDraft(e.target.value)} onContextMenu={
onBlur={() => commitEdit(lifeCounter.id)} onZoneContextMenu
onKeyDown={(e) => { ? (name, e) => onZoneContextMenu(playerId, name, e)
if (e.key === 'Enter') { : undefined
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> </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 && ( {canEdit && onRequestCreateCounter && (
<button <button

View file

@ -1,36 +1,69 @@
import { useState } from 'react';
import { useWebClient } from '@app/hooks'; import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store'; import { GameSelectors, useAppSelector } from '@app/store';
import type { Data, Enriched } from '@app/types'; import type { Data, Enriched } from '@app/types';
export function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string { type ServerColor = { r: number; g: number; b: number; a: number } | undefined;
if (!c) {
return '#666'; // Canonical MTG mana colors, mirroring the dead-code table in desktop's
} // libcockatrice_utility/libcockatrice/utility/color.h colorHelper(). Used
return `rgba(${c.r}, ${c.g}, ${c.b}, ${(c.a ?? 255) / 255})`; // as a fallback when the server leaves counterColor unset or all-zero.
const NAME_COLOR_MAP: Record<string, [number, number, number]> = {
W: [245, 245, 220],
U: [80, 140, 255],
B: [60, 60, 60],
R: [220, 60, 50],
G: [70, 160, 70],
LIFE: [220, 140, 60],
};
function isBlankColor(c: ServerColor): boolean {
if (!c) return true;
if ((c.a ?? 255) === 0) return true;
return c.r === 0 && c.g === 0 && c.b === 0;
} }
// Desktop renders Life larger/bolder than other counters (see // Stable 32-bit FNV-1a hash of a string. Matches the shape of desktop's
// cockatrice/src/game/player/player.cpp PlayerTarget sizing). We special- // qHash-based procedural fallback for unknown counter names.
// case the counter whose name is exactly 'Life' (case-insensitive) and function hashName(name: string): number {
// pull it out of the regular counter list into a prominent life block. let h = 0x811c9dc5;
export function isLifeCounter(c: { name: string }): boolean { for (let i = 0; i < name.length; i++) {
h ^= name.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
function hashColor(name: string): [number, number, number] {
const h = hashName(name);
return [100 + (h % 120), 100 + ((h >> 8) % 120), 100 + ((h >> 16) % 120)];
}
export function counterCssColor(counter: { name: string; counterColor?: ServerColor }): string {
const server = counter.counterColor;
if (!isBlankColor(server)) {
return `rgba(${server!.r}, ${server!.g}, ${server!.b}, ${(server!.a ?? 255) / 255})`;
}
const key = counter.name.trim().toUpperCase();
const mapped = NAME_COLOR_MAP[key];
if (mapped) {
return `rgba(${mapped[0]}, ${mapped[1]}, ${mapped[2]}, 1)`;
}
const [r, g, b] = hashColor(counter.name);
return `rgba(${r}, ${g}, ${b}, 1)`;
}
function isLifeCounter(c: { name: string }): boolean {
return c.name.trim().toLowerCase() === 'life'; return c.name.trim().toLowerCase() === 'life';
} }
export interface PlayerInfoPanel { export interface PlayerInfoPanel {
player: Enriched.PlayerEntry | undefined; player: Enriched.PlayerEntry | undefined;
isHost: boolean; isHost: boolean;
// Life renders in the header alongside the name; all other counters render
// as circles centered in the rail body.
lifeCounter: Data.ServerInfo_Counter | undefined; lifeCounter: Data.ServerInfo_Counter | undefined;
otherCounters: Data.ServerInfo_Counter[]; otherCounters: Data.ServerInfo_Counter[];
editingId: number | null;
editDraft: string;
setEditDraft: (v: string) => void;
beginEdit: (counterId: number, currentValue: number) => void;
commitEdit: (counterId: number) => void;
cancelEdit: () => void;
handleIncrement: (counterId: number, delta: number) => void; handleIncrement: (counterId: number, delta: number) => void;
handleDelete: (counterId: number) => void;
} }
export interface UsePlayerInfoPanelArgs { export interface UsePlayerInfoPanelArgs {
@ -44,14 +77,11 @@ export function usePlayerInfoPanel({
}: UsePlayerInfoPanelArgs): PlayerInfoPanel { }: UsePlayerInfoPanelArgs): PlayerInfoPanel {
const webClient = useWebClient(); const webClient = useWebClient();
const player = useAppSelector((state) => GameSelectors.getPlayer(state, gameId, playerId)); const player = useAppSelector((state) => GameSelectors.getPlayer(state, gameId, playerId));
const counters = useAppSelector((state) => GameSelectors.getCounters(state, gameId, playerId)); const countersMap = useAppSelector((state) => GameSelectors.getCounters(state, gameId, playerId));
const hostId = useAppSelector((state) => GameSelectors.getHostId(state, gameId)); const hostId = useAppSelector((state) => GameSelectors.getHostId(state, gameId));
const [editingId, setEditingId] = useState<number | null>(null);
const [editDraft, setEditDraft] = useState('');
const isHost = hostId != null && hostId === playerId; const isHost = hostId != null && hostId === playerId;
const allCounters = Object.values(counters); const allCounters = Object.values(countersMap);
const lifeCounter = allCounters.find(isLifeCounter); const lifeCounter = allCounters.find(isLifeCounter);
const otherCounters = allCounters.filter((c) => !isLifeCounter(c)); const otherCounters = allCounters.filter((c) => !isLifeCounter(c));
@ -59,48 +89,11 @@ export function usePlayerInfoPanel({
webClient.request.game.incCounter(gameId, { counterId, delta }); webClient.request.game.incCounter(gameId, { counterId, delta });
}; };
const handleDelete = (counterId: number) => {
webClient.request.game.delCounter(gameId, { counterId });
};
const beginEdit = (counterId: number, currentValue: number) => {
setEditingId(counterId);
setEditDraft(String(currentValue));
};
const commitEdit = (counterId: number) => {
const trimmed = editDraft.trim();
// Empty input cancels the edit (desktop inline edits treat blur-with-
// no-change and blur-with-empty-string identically). Prior behavior
// coerced '' → 0 because `Number('')` is 0 and `Number.isInteger(0)` is
// true, which surprised users expecting cancel-on-blank.
if (trimmed.length === 0) {
setEditingId(null);
return;
}
const value = Number(trimmed);
if (Number.isInteger(value)) {
webClient.request.game.setCounter(gameId, { counterId, value });
}
setEditingId(null);
};
const cancelEdit = () => {
setEditingId(null);
};
return { return {
player, player,
isHost, isHost,
lifeCounter, lifeCounter,
otherCounters, otherCounters,
editingId,
editDraft,
setEditDraft,
beginEdit,
commitEdit,
cancelEdit,
handleIncrement, handleIncrement,
handleDelete,
}; };
} }

View file

@ -70,7 +70,17 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.player-list__ping { .player-list__sideboard-lock {
color: #7f90b5; font-size: 11px;
font-size: 10px; line-height: 1;
flex-shrink: 0;
}
.player-list__ping-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.35);
flex-shrink: 0;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
} }

View file

@ -31,7 +31,7 @@ function buildState(
} }
describe('PlayerList', () => { describe('PlayerList', () => {
it('lists every player in the game', () => { it('lists every player in the game with a ping-dot tooltip', () => {
const p1 = makePlayerEntry({ const p1 = makePlayerEntry({
properties: makePlayerProperties({ properties: makePlayerProperties({
playerId: 1, playerId: 1,
@ -53,8 +53,96 @@ describe('PlayerList', () => {
expect(screen.getByText('Alice')).toBeInTheDocument(); expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument(); expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.getByText('10s')).toBeInTheDocument(); expect(screen.getByLabelText('ping 10s')).toBeInTheDocument();
expect(screen.getByText('20s')).toBeInTheDocument(); expect(screen.getByLabelText('ping 20s')).toBeInTheDocument();
// Raw-seconds text no longer renders; the dot carries the info via tooltip.
expect(screen.queryByText('10s')).not.toBeInTheDocument();
expect(screen.queryByText('20s')).not.toBeInTheDocument();
});
describe('ping-dot color', () => {
it('colors a low ping green', () => {
const p = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Alice' }),
pingSeconds: 0,
}),
});
renderWithProviders(<PlayerList gameId={1} />, {
preloadedState: buildState([p], 1),
});
expect(screen.getByTestId('ping-dot-1')).toHaveStyle({
background: 'hsl(120, 100%, 50%)',
});
});
it('colors a saturated ping red (clamped at 10s)', () => {
const p = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Alice' }),
pingSeconds: 15,
}),
});
renderWithProviders(<PlayerList gameId={1} />, {
preloadedState: buildState([p], 1),
});
expect(screen.getByTestId('ping-dot-1')).toHaveStyle({
background: 'hsl(0, 100%, 50%)',
});
});
it('colors a disconnected player black (ping < 0)', () => {
const p = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Alice' }),
pingSeconds: -1,
}),
});
renderWithProviders(<PlayerList gameId={1} />, {
preloadedState: buildState([p], 1),
});
expect(screen.getByTestId('ping-dot-1')).toHaveStyle({
background: '#000',
});
});
});
describe('sideboard lock', () => {
it('shows the 🔒 icon for a player with a locked sideboard', () => {
const p = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Alice' }),
sideboardLocked: true,
}),
});
renderWithProviders(<PlayerList gameId={1} />, {
preloadedState: buildState([p], 1),
});
expect(screen.getByLabelText('sideboard locked')).toBeInTheDocument();
});
it('hides the 🔒 icon for a player with an unlocked sideboard', () => {
const p = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Alice' }),
sideboardLocked: false,
}),
});
renderWithProviders(<PlayerList gameId={1} />, {
preloadedState: buildState([p], 1),
});
expect(screen.queryByLabelText('sideboard locked')).not.toBeInTheDocument();
});
}); });
it('highlights the active player', () => { it('highlights the active player', () => {

View file

@ -7,6 +7,19 @@ export interface PlayerListProps {
gameId: number | undefined; gameId: number | undefined;
} }
// HSV gradient from green (0s) to red (>=10s); black when disconnected
// (ping < 0). Mirrors desktop's PingPixmapGenerator in
// cockatrice/src/interface/pixel_map_generator.cpp.
function pingCssColor(pingSeconds: number | undefined): string {
if (pingSeconds == null || pingSeconds < 0) {
return '#000';
}
const max = 10;
const ratio = Math.min(pingSeconds, max) / max;
const hue = 120 * (1 - ratio);
return `hsl(${hue}, 100%, 50%)`;
}
function PlayerList({ gameId }: PlayerListProps) { function PlayerList({ gameId }: PlayerListProps) {
const players = useAppSelector((state) => const players = useAppSelector((state) =>
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined, gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
@ -32,6 +45,9 @@ function PlayerList({ gameId }: PlayerListProps) {
const name = p.properties.userInfo?.name ?? '(unknown)'; const name = p.properties.userInfo?.name ?? '(unknown)';
const isActive = pid === activePlayerId; const isActive = pid === activePlayerId;
const isHost = pid === hostId; const isHost = pid === hostId;
const sideboardLocked = p.properties.sideboardLocked ?? false;
const pingSeconds = p.properties.pingSeconds;
const pingLabel = `ping ${pingSeconds ?? '?'}s`;
return ( return (
<li <li
key={pid} key={pid}
@ -56,7 +72,22 @@ function PlayerList({ gameId }: PlayerListProps) {
</span> </span>
)} )}
<span className="player-list__name">{name}</span> <span className="player-list__name">{name}</span>
<span className="player-list__ping">{p.properties.pingSeconds}s</span> {sideboardLocked && (
<span
className="player-list__sideboard-lock"
aria-label="sideboard locked"
title="Sideboard locked"
>
🔒
</span>
)}
<span
className="player-list__ping-dot"
style={{ background: pingCssColor(pingSeconds) }}
aria-label={pingLabel}
title={pingLabel}
data-testid={`ping-dot-${pid}`}
/>
</li> </li>
); );
})} })}

View file

@ -0,0 +1,63 @@
/* Permanent vertical strip between the PlayerInfoPanel and the
Battlefield. Mirrors desktop's territorial framing rust/brown
background distinguishes it from the battlefield lanes and the
blue-tinted info rail. */
.stack-column {
width: 76px;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 4px;
background: #5a2020;
border-right: 1px solid #2a0c0c;
border-left: 1px solid #2a0c0c;
color: #f2d7d7;
font-size: 11px;
overflow: hidden;
}
.stack-column--mirrored {
/* Mirror the card stacking order so the top-of-stack (last-cast) card
sits closest to the battlefield for the opponent half. */
flex-direction: column-reverse;
}
.stack-column__cards {
flex: 1 1 auto;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
overflow: hidden;
}
.stack-column--mirrored .stack-column__cards {
flex-direction: column-reverse;
}
.stack-column__thumb {
width: 64px;
height: 88px;
background: #1a0808;
border: 1px solid #2a0c0c;
border-radius: 3px;
overflow: hidden;
flex-shrink: 0;
}
.stack-column__image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.stack-column__placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #3a1414 0%, #1a0808 100%);
}

View file

@ -0,0 +1,59 @@
import { screen } from '@testing-library/react';
import { App } from '@app/types';
vi.mock('../../../hooks/useSettings');
vi.mock('../../../hooks/useScryfallCard', () => ({
useScryfallCard: () => ({ smallUrl: null, normalUrl: null, isLoading: false }),
}));
import { makeStoreState, renderWithProviders, makeUser } from '../../../__test-utils__';
import {
makeCard,
makeGameEntry,
makePlayerEntry,
makePlayerProperties,
makeZoneEntry,
} from '../../../store/game/__mocks__/fixtures';
import StackColumn from './StackColumn';
function stateWithStack(cards: ReturnType<typeof makeCard>[] = []) {
const stack = makeZoneEntry({
name: App.ZoneName.STACK,
cards,
cardCount: cards.length,
});
const player = makePlayerEntry({
properties: makePlayerProperties({
playerId: 1,
userInfo: makeUser({ name: 'Alice' }),
}),
zones: { [App.ZoneName.STACK]: stack },
});
return makeStoreState({
games: { games: { 1: makeGameEntry({ localPlayerId: 1, players: { 1: player } }) } },
});
}
describe('StackColumn', () => {
it('renders an empty cards container when the stack is empty', () => {
renderWithProviders(<StackColumn gameId={1} playerId={1} />, {
preloadedState: stateWithStack([]),
});
expect(screen.getByTestId('stack-column-1')).toBeInTheDocument();
const cards = screen.getByTestId('stack-column-cards-1');
expect(cards.children).toHaveLength(0);
});
it('renders one thumbnail per card on the stack', () => {
renderWithProviders(<StackColumn gameId={1} playerId={1} />, {
preloadedState: stateWithStack([
makeCard({ id: 1, name: 'Lightning Bolt' }),
makeCard({ id: 2, name: 'Counterspell' }),
]),
});
const cards = screen.getByTestId('stack-column-cards-1');
expect(cards.children).toHaveLength(2);
});
});

View file

@ -0,0 +1,57 @@
import { useScryfallCard } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App, Data } from '@app/types';
import { cx } from '@app/utils';
import './StackColumn.css';
export interface StackColumnProps {
gameId: number;
playerId: number;
mirrored?: boolean;
onCardHover?: (card: Data.ServerInfo_Card) => void;
}
interface StackThumbProps {
card: Data.ServerInfo_Card;
onHover?: (card: Data.ServerInfo_Card) => void;
}
function StackThumb({ card, onHover }: StackThumbProps) {
const { smallUrl } = useScryfallCard(card);
return (
<div
className="stack-column__thumb"
onMouseEnter={() => onHover?.(card)}
title={card.name}
>
{smallUrl && !card.faceDown ? (
<img className="stack-column__image" src={smallUrl} alt={card.name} />
) : (
<div className="stack-column__placeholder" />
)}
</div>
);
}
function StackColumn({ gameId, playerId, mirrored = false, onCardHover }: StackColumnProps) {
const zone = useAppSelector((state) =>
GameSelectors.getZone(state, gameId, playerId, App.ZoneName.STACK),
);
const cards = zone ? zone.order.map((id) => zone.byId[id]).filter(Boolean) : [];
return (
<div
className={cx('stack-column', { 'stack-column--mirrored': mirrored })}
data-testid={`stack-column-${playerId}`}
>
<div className="stack-column__cards" data-testid={`stack-column-cards-${playerId}`}>
{cards.map((c) => (
<StackThumb key={c.id} card={c} onHover={onCardHover} />
))}
</div>
</div>
);
}
export default StackColumn;

View file

@ -1,54 +0,0 @@
.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

@ -1,107 +0,0 @@
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

@ -1,73 +0,0 @@
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

@ -1,11 +0,0 @@
.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

@ -1,74 +0,0 @@
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

@ -1,50 +0,0 @@
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

@ -2,7 +2,14 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
gap: 2px; gap: 2px;
min-height: 0;
min-width: 0;
width: 100%;
height: 100%;
align-self: stretch;
justify-self: stretch;
cursor: pointer; cursor: pointer;
} }
@ -10,15 +17,32 @@
box-shadow: 0 0 0 2px var(--color-highlight-yellow), 0 0 12px var(--color-highlight-yellow-soft); box-shadow: 0 0 0 2px var(--color-highlight-yellow), 0 0 12px var(--color-highlight-yellow-soft);
} }
/* Portrait thumb (Deck-style). Scales down if its row is shorter than
108px; never grows above the natural card thumbnail size. */
.zone-stack__thumb { .zone-stack__thumb {
position: relative; position: relative;
width: 78px; aspect-ratio: 78 / 108;
height: 108px; max-width: 78px;
max-height: 108px;
width: auto;
height: 100%;
background: #0d1930; background: #0d1930;
border: 1px solid #1a2b52; border: 1px solid #1a2b52;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
flex: 0 1 auto;
min-height: 0;
}
/* Landscape thumb (Graveyard/Exile). Matches desktop where used-up zones
show their top card rotated 90° so they read as a horizontal bar. */
.zone-stack--rotated .zone-stack__thumb {
aspect-ratio: 108 / 78;
max-width: 108px;
max-height: 78px;
width: 100%;
height: auto;
} }
.zone-stack__image { .zone-stack__image {
@ -28,6 +52,15 @@
display: block; display: block;
} }
/* Rotate the top-card image inside a landscape thumb. The 90° rotation
leaves gaps on a square-ish crop; scale by the thumb aspect ratio so
the rotated image re-fills horizontally. overflow:hidden on the thumb
clips the vertical overspill. */
.zone-stack--rotated .zone-stack__image {
transform: rotate(90deg) scale(calc(108 / 78));
transform-origin: center;
}
.zone-stack__placeholder { .zone-stack__placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -54,4 +87,5 @@
font-weight: 600; font-weight: 600;
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
flex-shrink: 0;
} }

View file

@ -144,6 +144,34 @@ describe('ZoneStack', () => {
expect(el).not.toHaveAttribute('tabindex'); expect(el).not.toHaveAttribute('tabindex');
}); });
it('applies the rotated modifier class when rotated is true', () => {
renderWithProviders(
<ZoneStack
gameId={1}
playerId={1}
zoneName={App.ZoneName.GRAVE}
label="Graveyard"
rotated
/>,
{ preloadedState: stateWithZone(App.ZoneName.GRAVE, { cardCount: 0 }) },
);
expect(screen.getByTestId(`zone-stack-${App.ZoneName.GRAVE}`)).toHaveClass(
'zone-stack--rotated',
);
});
it('omits the rotated modifier by default', () => {
renderWithProviders(
<ZoneStack gameId={1} playerId={1} zoneName={App.ZoneName.DECK} label="Deck" />,
{ preloadedState: stateWithZone(App.ZoneName.DECK, { cardCount: 0 }) },
);
expect(screen.getByTestId(`zone-stack-${App.ZoneName.DECK}`)).not.toHaveClass(
'zone-stack--rotated',
);
});
it.each([['Enter'], [' ']])('fires onClick on %s keypress when focusable', (key) => { it.each([['Enter'], [' ']])('fires onClick on %s keypress when focusable', (key) => {
const onClick = vi.fn(); const onClick = vi.fn();
renderWithProviders( renderWithProviders(

View file

@ -12,6 +12,7 @@ export interface ZoneStackProps {
playerId: number; playerId: number;
zoneName: string; zoneName: string;
label: string; label: string;
rotated?: boolean;
onCardHover?: (card: Data.ServerInfo_Card) => void; onCardHover?: (card: Data.ServerInfo_Card) => void;
onClick?: (zoneName: string) => void; onClick?: (zoneName: string) => void;
onContextMenu?: (zoneName: string, event: React.MouseEvent) => void; onContextMenu?: (zoneName: string, event: React.MouseEvent) => void;
@ -22,6 +23,7 @@ function ZoneStack({
playerId, playerId,
zoneName, zoneName,
label, label,
rotated = false,
onCardHover, onCardHover,
onClick, onClick,
onContextMenu, onContextMenu,
@ -49,7 +51,10 @@ function ZoneStack({
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
className={cx('zone-stack', { 'zone-stack--drop-over': isOver })} className={cx('zone-stack', {
'zone-stack--drop-over': isOver,
'zone-stack--rotated': rotated,
})}
data-testid={`zone-stack-${zoneName}`} data-testid={`zone-stack-${zoneName}`}
onMouseEnter={() => topCard && onCardHover?.(topCard)} onMouseEnter={() => topCard && onCardHover?.(topCard)}
onClick={() => onClick?.(zoneName)} onClick={() => onClick?.(zoneName)}

View file

@ -22,8 +22,7 @@ export { default as PlayerContextMenu } from './PlayerContextMenu/PlayerContextM
export { default as PlayerInfoPanel } from './PlayerInfoPanel/PlayerInfoPanel'; export { default as PlayerInfoPanel } from './PlayerInfoPanel/PlayerInfoPanel';
export { default as PlayerList } from './PlayerList/PlayerList'; export { default as PlayerList } from './PlayerList/PlayerList';
export { default as RightPanel } from './RightPanel/RightPanel'; export { default as RightPanel } from './RightPanel/RightPanel';
export { default as StackStrip } from './StackStrip/StackStrip'; export { default as StackColumn } from './StackColumn/StackColumn';
export { default as TurnControls } from './TurnControls/TurnControls'; export { default as TurnControls } from './TurnControls/TurnControls';
export { default as ZoneContextMenu } from './ZoneContextMenu/ZoneContextMenu'; export { default as ZoneContextMenu } from './ZoneContextMenu/ZoneContextMenu';
export { default as ZoneRail } from './ZoneRail/ZoneRail';
export { default as ZoneStack } from './ZoneStack/ZoneStack'; export { default as ZoneStack } from './ZoneStack/ZoneStack';

View file

@ -22,7 +22,7 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr) 176px; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr) 176px;
} }
/* Rotate 90°: view-only transform on the whole board. Mirrors desktop's /* Rotate 90°: view-only transform on the whole board. Mirrors desktop's
@ -53,12 +53,7 @@
.game-log__input:focus-visible, .game-log__input:focus-visible,
.turn-controls__btn:focus-visible, .turn-controls__btn:focus-visible,
.phase-bar__btn:focus-visible, .phase-bar__btn:focus-visible,
.player-info-panel__counter-value--editable:focus-visible, .player-info-panel__counter[role='button']:focus-visible,
.player-info-panel__counter-input:focus-visible,
.player-info-panel__counter-btn:focus-visible,
.player-info-panel__life-value--editable:focus-visible,
.player-info-panel__life-input:focus-visible,
.player-info-panel__life-btn:focus-visible,
.player-info-panel__new-counter:focus-visible, .player-info-panel__new-counter:focus-visible,
.opponent-selector__button:focus-visible { .opponent-selector__button:focus-visible {
outline: 2px solid var(--color-highlight-yellow); outline: 2px solid var(--color-highlight-yellow);

View file

@ -270,7 +270,7 @@ describe('Game container', () => {
expect(screen.queryByRole('button', { name: /close zone view/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /close zone view/i })).not.toBeInTheDocument();
}); });
it('opens when a zone-rail entry is clicked, showing the cards in that zone', () => { it('opens when a zone entry in the info panel is clicked, showing the cards in that zone', () => {
const graveCard = makeCard({ id: 77, name: 'Final Card' }); const graveCard = makeCard({ id: 77, name: 'Final Card' });
renderWithProviders(<Game />, { renderWithProviders(<Game />, {
preloadedState: buildGame({ preloadedState: buildGame({

View file

@ -13,7 +13,6 @@ import {
PlayerBoard, PlayerBoard,
PlayerContextMenu, PlayerContextMenu,
RightPanel, RightPanel,
StackStrip,
ZoneContextMenu, ZoneContextMenu,
} from '@app/components'; } from '@app/components';
import { import {
@ -43,6 +42,7 @@ function Game() {
gameId, gameId,
game, game,
localPlayer, localPlayer,
isSpectator,
boardRef, boardRef,
cardRegistry, cardRegistry,
sensors, sensors,
@ -114,56 +114,42 @@ function Game() {
onZoneClick={dialogs.handleZoneClick} onZoneClick={dialogs.handleZoneClick}
onZoneContextMenu={dialogs.handleZoneContextMenu} onZoneContextMenu={dialogs.handleZoneContextMenu}
/> />
<StackStrip {!isSpectator && (
gameId={gameId!} <>
entries={[ <PlayerBoard
{ gameId={gameId!}
playerId: opponents.shownOpponentId, playerId={game.localPlayerId}
name: canAct={localAccess.canAct}
opponents.opponents.find((o) => o.playerId === opponents.shownOpponentId)?.name ?? canEditCounters={localAccess.canAct}
`p${opponents.shownOpponentId}`, arrowSourceKey={arrows.arrowSourceKey}
}, onCardHover={setHoveredCard}
{ onCardClick={arrows.handleCardClick}
playerId: game.localPlayerId, onCardContextMenu={(card, e) =>
name: dialogs.handleCardContextMenu(game.localPlayerId, App.ZoneName.TABLE, card, e)
localPlayer?.properties.userInfo?.name ?? }
`p${game.localPlayerId}`, onCardDoubleClick={(card) =>
}, arrows.handleCardDoubleClick(App.ZoneName.TABLE, card)
]} }
onZoneClick={dialogs.handleZoneClick} onZoneClick={dialogs.handleZoneClick}
/> onZoneContextMenu={dialogs.handleZoneContextMenu}
<PlayerBoard onRequestCreateCounter={dialogs.openCreateCounter}
gameId={gameId!} onPlayerContextMenu={dialogs.handlePlayerContextMenu}
playerId={game.localPlayerId} />
canAct={localAccess.canAct} {localPlayer && (
canEditCounters={localAccess.canAct} <HandZone
arrowSourceKey={arrows.arrowSourceKey} gameId={gameId!}
onCardHover={setHoveredCard} playerId={game.localPlayerId}
onCardClick={arrows.handleCardClick} canAct={localAccess.canAct}
onCardContextMenu={(card, e) => arrowSourceKey={arrows.arrowSourceKey}
dialogs.handleCardContextMenu(game.localPlayerId, App.ZoneName.TABLE, card, e) onCardHover={setHoveredCard}
} onCardClick={arrows.handleCardClick}
onCardDoubleClick={(card) => onCardContextMenu={(card, e) =>
arrows.handleCardDoubleClick(App.ZoneName.TABLE, card) dialogs.handleCardContextMenu(game.localPlayerId, App.ZoneName.HAND, card, e)
} }
onZoneClick={dialogs.handleZoneClick} onZoneContextMenu={dialogs.handleHandContextMenu}
onZoneContextMenu={dialogs.handleZoneContextMenu} />
onRequestCreateCounter={dialogs.openCreateCounter} )}
onPlayerContextMenu={dialogs.handlePlayerContextMenu} </>
/>
{localPlayer && (
<HandZone
gameId={gameId!}
playerId={game.localPlayerId}
canAct={localAccess.canAct}
arrowSourceKey={arrows.arrowSourceKey}
onCardHover={setHoveredCard}
onCardClick={arrows.handleCardClick}
onCardContextMenu={(card, e) =>
dialogs.handleCardContextMenu(game.localPlayerId, App.ZoneName.HAND, card, e)
}
onZoneContextMenu={dialogs.handleHandContextMenu}
/>
)} )}
</div> </div>
)} )}

View file

@ -1,4 +1,5 @@
import { RefObject, useCallback, useMemo, useRef, useState } from 'react'; import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { createCardRegistry, type CardRegistry } from '@app/components'; import { createCardRegistry, type CardRegistry } from '@app/components';
@ -29,7 +30,10 @@ export interface Game extends CurrentGame {
} }
export function useGame(): Game { export function useGame(): Game {
const current = useCurrentGame(); const params = useParams<{ gameId?: string }>();
const parsed = params.gameId != null ? Number(params.gameId) : NaN;
const routeGameId = Number.isFinite(parsed) ? parsed : undefined;
const current = useCurrentGame(routeGameId);
const { gameId, game, localPlayer, isSpectator } = current; const { gameId, game, localPlayer, isSpectator } = current;
useGameLifecycleNavigation(gameId); useGameLifecycleNavigation(gameId);

View file

@ -0,0 +1,328 @@
import { renderHook } from '@testing-library/react';
import type { DragEndEvent } from '@dnd-kit/core';
import { App, Data } from '@app/types';
import { makeCard } from '../../store/game/__mocks__/fixtures';
import {
CARD_WIDTH_PX,
MARGIN_LEFT_PX,
PADDING_X_PX,
STACKED_CARD_OFFSET_X_PX,
} from '../../components/Game/Battlefield/gridMath';
const { mockUseWebClient } = vi.hoisted(() => ({ mockUseWebClient: vi.fn() }));
vi.mock('@app/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@app/hooks')>();
return { ...actual, useWebClient: mockUseWebClient };
});
import { useGameDnd } from './useGameDnd';
// Build a minimal DragEndEvent with only the fields useGameDnd reads. The
// real dnd-kit Event type has many more fields; we cast via `unknown` so
// tests stay tied to actual handler inputs, not dnd-kit internals.
type DropShape = {
sourceCard: Data.ServerInfo_Card;
sourcePlayerId: number;
sourceZone: string;
targetPlayerId: number;
targetZone: string;
targetRow?: number;
rowCards?: Data.ServerInfo_Card[];
// Drop-point is expressed as the card center's x relative to the row's
// content edge (same convention as gridMath.mapToGridX).
pointerXInRow?: number;
attachTarget?: boolean;
targetCardId?: number;
};
const ROW_LEFT = 500;
const CARD_WIDTH_ON_SCREEN = CARD_WIDTH_PX;
function buildEvent(d: DropShape): DragEndEvent {
const pointerXInRow = d.pointerXInRow ?? 0;
// Reverse the math computePointerXInRow does: cardCenterX - overRect.left - MARGIN_LEFT.
const cardCenterXAbs = pointerXInRow + ROW_LEFT + MARGIN_LEFT_PX;
const activeLeft = cardCenterXAbs - CARD_WIDTH_ON_SCREEN / 2;
return {
active: {
id: d.sourceCard.id,
data: {
current: {
card: d.sourceCard,
sourcePlayerId: d.sourcePlayerId,
sourceZone: d.sourceZone,
},
},
rect: {
current: {
translated: {
left: activeLeft,
top: 0,
width: CARD_WIDTH_ON_SCREEN,
height: 204,
right: activeLeft + CARD_WIDTH_ON_SCREEN,
bottom: 204,
},
initial: null,
},
},
},
over: {
id: `battlefield-${d.targetPlayerId}-${d.targetRow ?? 0}`,
// Row height = 204 so the effective card width derived by useGameDnd
// (laneHeight × 146/204) equals CARD_WIDTH_PX = 146, keeping the
// pointer-X math aligned with the test's logical gridMath expectations.
rect: {
left: ROW_LEFT,
top: 0,
width: 1000,
height: 204,
right: ROW_LEFT + 1000,
bottom: 204,
} as any,
data: {
current: {
targetPlayerId: d.targetPlayerId,
targetZone: d.targetZone,
row: d.targetRow ?? 0,
rowCards: d.rowCards ?? [],
attachTarget: d.attachTarget,
targetCardId: d.targetCardId,
},
},
disabled: false,
} as any,
delta: { x: 0, y: 0 },
collisions: null,
activatorEvent: new Event('pointerdown'),
} as unknown as DragEndEvent;
}
function makeWebClient() {
return {
request: {
game: {
moveCard: vi.fn(),
attachCard: vi.fn(),
},
},
} as any;
}
function setupHook() {
const webClient = makeWebClient();
mockUseWebClient.mockReturnValue(webClient);
const onDragStart = vi.fn();
const { result } = renderHook(() => useGameDnd({ gameId: 42, onDragStart }));
return { webClient, onDragStart, handleDragEnd: result.current.handleDragEnd };
}
describe('useGameDnd', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('drops on battlefield', () => {
it('sends moveCard with gridX=0 when dropping into an empty row', () => {
const { webClient, handleDragEnd } = setupHook();
const card = makeCard({ id: 10, x: 0, y: 0 });
handleDragEnd(
buildEvent({
sourceCard: card,
sourcePlayerId: 1,
sourceZone: App.ZoneName.HAND,
targetPlayerId: 1,
targetZone: App.ZoneName.TABLE,
targetRow: 1,
rowCards: [],
pointerXInRow: 10,
}),
);
expect(webClient.request.game.moveCard).toHaveBeenCalledTimes(1);
const args = webClient.request.game.moveCard.mock.calls[0][1];
expect(args.x).toBe(0);
expect(args.y).toBe(1);
expect(args.targetZone).toBe(App.ZoneName.TABLE);
});
it('sends moveCard with gridX=3 when dropping into the next stack past a 1-card stack', () => {
const { webClient, handleDragEnd } = setupHook();
const existing = makeCard({ id: 20, x: 0, y: 0 });
const dragging = makeCard({ id: 21, x: 0, y: 2 }); // from a different row
handleDragEnd(
buildEvent({
sourceCard: dragging,
sourcePlayerId: 1,
sourceZone: App.ZoneName.TABLE,
targetPlayerId: 1,
targetZone: App.ZoneName.TABLE,
targetRow: 0,
rowCards: [existing],
// Pointer well inside stack 1's territory.
pointerXInRow: CARD_WIDTH_PX + PADDING_X_PX + 10,
}),
);
const args = webClient.request.game.moveCard.mock.calls[0][1];
expect(args.x).toBe(3);
});
it('allows in-row reorder (same-row drop no longer silently rejected)', () => {
const { webClient, handleDragEnd } = setupHook();
const leftmost = makeCard({ id: 30, x: 0, y: 1 });
const dragged = makeCard({ id: 31, x: 1, y: 1 });
handleDragEnd(
buildEvent({
sourceCard: dragged,
sourcePlayerId: 1,
sourceZone: App.ZoneName.TABLE,
targetPlayerId: 1,
targetZone: App.ZoneName.TABLE,
targetRow: 1,
// Dragged card still appears in rowCards from Redux during the drag.
rowCards: [leftmost, dragged],
// Drop far enough right that the dragged card lands in a new stack.
pointerXInRow: CARD_WIDTH_PX + PADDING_X_PX + 10,
}),
);
expect(webClient.request.game.moveCard).toHaveBeenCalledTimes(1);
// Dragged card excluded from occupancy → stack 1 base (3) is free.
expect(webClient.request.game.moveCard.mock.calls[0][1].x).toBe(3);
});
it('places into sub-position 1 when the stack base is occupied by another card', () => {
const { webClient, handleDragEnd } = setupHook();
const occupying = makeCard({ id: 40, x: 0, y: 0 });
const dragging = makeCard({ id: 41, x: 0, y: 2 }); // different row
handleDragEnd(
buildEvent({
sourceCard: dragging,
sourcePlayerId: 1,
sourceZone: App.ZoneName.TABLE,
targetPlayerId: 1,
targetZone: App.ZoneName.TABLE,
targetRow: 0,
rowCards: [occupying],
// Inside stack 0's territory (mapToGridX returns something in [0,2]).
pointerXInRow: STACKED_CARD_OFFSET_X_PX / 2,
}),
);
// base 0 occupied → closestGridPoint bumps to 1.
expect(webClient.request.game.moveCard.mock.calls[0][1].x).toBe(1);
});
it('rejects a drop silently when the target stack is fully occupied', () => {
const { webClient, handleDragEnd } = setupHook();
const dragging = makeCard({ id: 50, x: 0, y: 2 });
const full = [
makeCard({ id: 51, x: 0, y: 0 }),
makeCard({ id: 52, x: 1, y: 0 }),
makeCard({ id: 53, x: 2, y: 0 }),
];
handleDragEnd(
buildEvent({
sourceCard: dragging,
sourcePlayerId: 1,
sourceZone: App.ZoneName.TABLE,
targetPlayerId: 1,
targetZone: App.ZoneName.TABLE,
targetRow: 0,
rowCards: full,
pointerXInRow: 0, // mapToGridX → 0; all three sub-slots full.
}),
);
expect(webClient.request.game.moveCard).not.toHaveBeenCalled();
});
it('sends the logical y from target.row without re-inverting (mirrored boards)', () => {
// BattlefieldRow receives `row=rowIdx` where rowIdx iterates rowOrder
// (mirrored: [2,1,0]). Visual top of a mirrored opponent yields
// target.row = 2 which IS the correct server-side y. Re-inverting here
// would send y=0 and render the card at the bottom of the opponent's
// area — the bug this test guards against.
const { webClient, handleDragEnd } = setupHook();
const dragging = makeCard({ id: 60, x: 0, y: 0 });
handleDragEnd(
buildEvent({
sourceCard: dragging,
sourcePlayerId: 1,
sourceZone: App.ZoneName.HAND,
targetPlayerId: 2,
targetZone: App.ZoneName.TABLE,
targetRow: 2, // visual top of mirrored opponent = logical y=2
rowCards: [],
pointerXInRow: 0,
}),
);
expect(webClient.request.game.moveCard.mock.calls[0][1].y).toBe(2);
});
});
describe('non-TABLE drops', () => {
it('sends x=0, y=0 when dropping into a pile zone (graveyard)', () => {
const { webClient, handleDragEnd } = setupHook();
const card = makeCard({ id: 70, x: 0, y: 0 });
handleDragEnd(
buildEvent({
sourceCard: card,
sourcePlayerId: 1,
sourceZone: App.ZoneName.TABLE,
targetPlayerId: 1,
targetZone: App.ZoneName.GRAVE,
targetRow: 0,
pointerXInRow: 500, // ignored for non-TABLE
}),
);
const args = webClient.request.game.moveCard.mock.calls[0][1];
expect(args.x).toBe(0);
expect(args.y).toBe(0);
expect(args.targetZone).toBe(App.ZoneName.GRAVE);
});
it('skips dispatch for same-zone drops on non-TABLE zones', () => {
const { webClient, handleDragEnd } = setupHook();
const card = makeCard({ id: 80, x: 0, y: 0 });
handleDragEnd(
buildEvent({
sourceCard: card,
sourcePlayerId: 1,
sourceZone: App.ZoneName.HAND,
targetPlayerId: 1,
targetZone: App.ZoneName.HAND,
targetRow: 0,
}),
);
expect(webClient.request.game.moveCard).not.toHaveBeenCalled();
});
});
describe('attach drops', () => {
it('dispatches attachCard when dropping a table card onto another table card', () => {
const { webClient, handleDragEnd } = setupHook();
const source = makeCard({ id: 90, x: 0, y: 0 });
handleDragEnd(
buildEvent({
sourceCard: source,
sourcePlayerId: 1,
sourceZone: App.ZoneName.TABLE,
targetPlayerId: 1,
targetZone: App.ZoneName.TABLE,
targetRow: 0,
attachTarget: true,
targetCardId: 91,
}),
);
expect(webClient.request.game.attachCard).toHaveBeenCalledTimes(1);
expect(webClient.request.game.moveCard).not.toHaveBeenCalled();
});
});
});

View file

@ -3,6 +3,16 @@ import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
import { useWebClient } from '@app/hooks'; import { useWebClient } from '@app/hooks';
import { App, Data } from '@app/types'; import { App, Data } from '@app/types';
import {
CARD_HEIGHT_PX,
CARD_WIDTH_PX,
MARGIN_LEFT_PX,
PADDING_X_PX,
STACKED_CARD_OFFSET_X_PX,
closestGridPoint,
mapToGridX,
stackCountsForRow,
} from '../../components/Game/Battlefield/gridMath';
export interface GameDnd { export interface GameDnd {
activeCard: Data.ServerInfo_Card | null; activeCard: Data.ServerInfo_Card | null;
@ -16,6 +26,19 @@ export interface UseGameDndArgs {
onDragStart: () => void; onDragStart: () => void;
} }
// Derive the drop pointer's x-coordinate relative to the target row's content
// edge. We use the center of the dragged card's translated rect as the "drop
// point" — this matches desktop's behavior where the drop position is the
// card's scene position at release (card_drag_item.cpp:updatePosition), not a
// raw cursor coordinate.
function computePointerXInRow(event: DragEndEvent): number {
const overRect = event.over?.rect;
const activeRect = event.active.rect.current.translated;
if (!overRect || !activeRect) return 0;
const cardCenterX = activeRect.left + activeRect.width / 2;
return cardCenterX - overRect.left - MARGIN_LEFT_PX;
}
export function useGameDnd({ gameId, onDragStart }: UseGameDndArgs): GameDnd { export function useGameDnd({ gameId, onDragStart }: UseGameDndArgs): GameDnd {
const webClient = useWebClient(); const webClient = useWebClient();
const [activeCard, setActiveCard] = useState<Data.ServerInfo_Card | null>(null); const [activeCard, setActiveCard] = useState<Data.ServerInfo_Card | null>(null);
@ -51,6 +74,7 @@ export function useGameDnd({ gameId, onDragStart }: UseGameDndArgs): GameDnd {
row?: number; row?: number;
attachTarget?: boolean; attachTarget?: boolean;
targetCardId?: number; targetCardId?: number;
rowCards?: Data.ServerInfo_Card[];
}; };
// Drop onto another card on the table → attach source to target. // Drop onto another card on the table → attach source to target.
@ -83,21 +107,69 @@ export function useGameDnd({ gameId, onDragStart }: UseGameDndArgs): GameDnd {
const sameZone = const sameZone =
source.sourcePlayerId === target.targetPlayerId && source.sourcePlayerId === target.targetPlayerId &&
source.sourceZone === target.targetZone; source.sourceZone === target.targetZone;
if (sameZone && source.sourceZone === App.ZoneName.TABLE && (source.card.y ?? 0) === (target.row ?? 0)) { // Non-TABLE same-zone drops aren't meaningful (dragging within the hand
return; // or library isn't a user-facing reorder on desktop either).
}
if (sameZone && source.sourceZone !== App.ZoneName.TABLE) { if (sameZone && source.sourceZone !== App.ZoneName.TABLE) {
return; return;
} }
const targetRow = target.row ?? 0;
const targetIsTable = target.targetZone === App.ZoneName.TABLE;
const sameRowOnTable =
sameZone && source.sourceZone === App.ZoneName.TABLE && (source.card.y ?? 0) === targetRow;
// Compute an integer gridX matching desktop's stack-and-subposition
// packing (table_zone.cpp:mapToGrid + closestGridPoint). Non-TABLE
// targets get x = 0 — the server assigns a position in piles/hand.
let gridX = 0;
if (targetIsTable) {
const rowCards = target.rowCards ?? [];
// When reordering within the same row, exclude the dragged card from
// occupancy / stack width — otherwise closestGridPoint would skip its
// own slot and a same-position no-op drop would bump to base+1.
const neighbors = sameRowOnTable
? rowCards.filter((c) => c.id !== source.card.id)
: rowCards;
const pointerXInRow = computePointerXInRow(event);
const stackCounts = stackCountsForRow(neighbors);
// Cards render at laneHeight × (146/204) via CSS aspect-ratio. Derive
// the same effective width here so gridMath maps the pointer to the
// stack/sub-position the user visually dropped on — at any zoom level.
const laneHeight = event.over.rect.height;
const effectiveCardWidth = laneHeight > 0
? (laneHeight * CARD_WIDTH_PX) / CARD_HEIGHT_PX
: CARD_WIDTH_PX;
const effectiveOffsetX =
(effectiveCardWidth * STACKED_CARD_OFFSET_X_PX) / CARD_WIDTH_PX;
const rawGridX = mapToGridX(
pointerXInRow,
stackCounts,
effectiveCardWidth,
effectiveOffsetX,
PADDING_X_PX,
);
const occupied = new Set(neighbors.map((c) => c.x ?? 0));
const resolved = closestGridPoint(rawGridX, occupied);
// Fully-occupied 3-card stack → desktop silently rejects (see
// card_drag_item.cpp:115); skip dispatch to match.
if (resolved == null) return;
gridX = resolved;
}
// targetRow passed from BattlefieldRow is already the LOGICAL y (cards
// get y=rowIdx where rowIdx iterates rowOrder = [0,1,2] or [2,1,0]). On
// a mirrored board the first iterated rowIdx is 2, so dropping on the
// visual top row yields target.row=2 — which is the correct server-side
// y. Unlike desktop's mapToGrid (which takes pixel-y and inverts), we
// never see a pre-inverted value here, so no re-inversion needed.
webClient.request.game.moveCard(gameId, { webClient.request.game.moveCard(gameId, {
startPlayerId: source.sourcePlayerId, startPlayerId: source.sourcePlayerId,
startZone: source.sourceZone, startZone: source.sourceZone,
cardsToMove: { card: [{ cardId: source.card.id }] }, cardsToMove: { card: [{ cardId: source.card.id }] },
targetPlayerId: target.targetPlayerId, targetPlayerId: target.targetPlayerId,
targetZone: target.targetZone, targetZone: target.targetZone,
x: 0, x: gridX,
y: target.row ?? 0, y: targetIsTable ? targetRow : 0,
isReversed: false, isReversed: false,
}); });
}, },

View file

@ -18,12 +18,14 @@ import './LeftNav.css';
const LeftNav = () => { const LeftNav = () => {
const { const {
joinedRooms, joinedRooms,
joinedGames,
isConnected, isConnected,
state, state,
handleMenuOpen, handleMenuOpen,
handleMenuItemClick, handleMenuItemClick,
handleMenuClose, handleMenuClose,
leaveRoom, leaveRoom,
leaveGame,
openImportCardWizard, openImportCardWizard,
closeImportCardWizard, closeImportCardWizard,
} = useLeftNav(); } = useLeftNav();
@ -72,10 +74,32 @@ const LeftNav = () => {
</div> </div>
</div> </div>
<div className="LeftNav-nav__link"> <div className="LeftNav-nav__link">
<NavLink className="LeftNav-nav__link-btn" to={App.RouteEnum.GAME}> <NavLink
className="LeftNav-nav__link-btn"
to={
joinedGames.length
? generatePath(App.RouteEnum.GAME, { gameId: joinedGames[0].info.gameId.toString() })
: App.RouteEnum.SERVER
}
>
Games Games
<ArrowDropDownIcon className="LeftNav-nav__link-btn__icon" fontSize="small" /> <ArrowDropDownIcon className="LeftNav-nav__link-btn__icon" fontSize="small" />
</NavLink> </NavLink>
<div className="LeftNav-nav__link-menu">
{joinedGames.map((game) => (
<div className="LeftNav-nav__link-menu__item" key={game.info.gameId}>
<NavLink className="LeftNav-nav__link-menu__btn"
to={generatePath(App.RouteEnum.GAME, { gameId: game.info.gameId.toString() })}
>
{game.info.description || `#${game.info.gameId}`}
<IconButton size="small" edge="end" onClick={event => leaveGame(event, game.info.gameId)}>
<CloseIcon style={{ fontSize: 10, color: 'white' }} />
</IconButton>
</NavLink>
</div>
))}
</div>
</div> </div>
<div className="LeftNav-nav__link"> <div className="LeftNav-nav__link">
<NavLink className="LeftNav-nav__link-btn" to={App.RouteEnum.DECKS}> <NavLink className="LeftNav-nav__link-btn" to={App.RouteEnum.DECKS}>

View file

@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useNavigate, generatePath } from 'react-router-dom'; import { useNavigate, generatePath } from 'react-router-dom';
import { useWebClient } from '@app/hooks'; import { useWebClient } from '@app/hooks';
import { RoomsSelectors, ServerSelectors, useAppSelector } from '@app/store'; import { GameSelectors, RoomsSelectors, ServerSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types'; import { App } from '@app/types';
export interface LeftNavOption { export interface LeftNavOption {
@ -18,12 +18,14 @@ interface LeftNavState {
export interface LeftNav { export interface LeftNav {
joinedRooms: ReturnType<typeof RoomsSelectors.getJoinedRooms>; joinedRooms: ReturnType<typeof RoomsSelectors.getJoinedRooms>;
joinedGames: ReturnType<typeof GameSelectors.getActiveGames>;
isConnected: boolean; isConnected: boolean;
state: LeftNavState; state: LeftNavState;
handleMenuOpen: (event: React.MouseEvent) => void; handleMenuOpen: (event: React.MouseEvent) => void;
handleMenuItemClick: (option: LeftNavOption) => void; handleMenuItemClick: (option: LeftNavOption) => void;
handleMenuClose: () => void; handleMenuClose: () => void;
leaveRoom: (event: React.MouseEvent, roomId: number) => void; leaveRoom: (event: React.MouseEvent, roomId: number) => void;
leaveGame: (event: React.MouseEvent, gameId: number) => void;
openImportCardWizard: () => void; openImportCardWizard: () => void;
closeImportCardWizard: () => void; closeImportCardWizard: () => void;
} }
@ -40,6 +42,7 @@ const MODERATOR_OPTIONS: LeftNavOption[] = [
export function useLeftNav(): LeftNav { export function useLeftNav(): LeftNav {
const joinedRooms = useAppSelector((state) => RoomsSelectors.getJoinedRooms(state)); const joinedRooms = useAppSelector((state) => RoomsSelectors.getJoinedRooms(state));
const joinedGames = useAppSelector(GameSelectors.getActiveGames);
const isConnected = useAppSelector(ServerSelectors.getIsConnected); const isConnected = useAppSelector(ServerSelectors.getIsConnected);
const isModerator = useAppSelector(ServerSelectors.getIsUserModerator); const isModerator = useAppSelector(ServerSelectors.getIsUserModerator);
const navigate = useNavigate(); const navigate = useNavigate();
@ -71,6 +74,11 @@ export function useLeftNav(): LeftNav {
webClient.request.rooms.leaveRoom(roomId); webClient.request.rooms.leaveRoom(roomId);
}; };
const leaveGame = (event: React.MouseEvent, gameId: number) => {
event.preventDefault();
webClient.request.game.leaveGame(gameId);
};
const openImportCardWizard = () => { const openImportCardWizard = () => {
setShowCardImportDialog(true); setShowCardImportDialog(true);
handleMenuClose(); handleMenuClose();
@ -82,12 +90,14 @@ export function useLeftNav(): LeftNav {
return { return {
joinedRooms, joinedRooms,
joinedGames,
isConnected, isConnected,
state, state,
handleMenuOpen, handleMenuOpen,
handleMenuItemClick, handleMenuItemClick,
handleMenuClose, handleMenuClose,
leaveRoom, leaveRoom,
leaveGame,
openImportCardWizard, openImportCardWizard,
closeImportCardWizard, closeImportCardWizard,
}; };

View file

@ -218,7 +218,7 @@ describe('GameSelector', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
}); });
it('clicking Join on a game already present in games.games navigates to /game without sending a command', () => { it('clicking Join on a game already present in games.games navigates to /game/<id> without sending a command', () => {
const client = makeWebClient(); const client = makeWebClient();
mockUseWebClient.mockReturnValue(client); mockUseWebClient.mockReturnValue(client);
const game = makeGame({ gameId: 7, withPassword: false }); const game = makeGame({ gameId: 7, withPassword: false });
@ -230,10 +230,10 @@ describe('GameSelector', () => {
fireEvent.click(screen.getByRole('button', { name: /^Join$/ })); fireEvent.click(screen.getByRole('button', { name: /^Join$/ }));
expect(client.request.rooms.joinGame).not.toHaveBeenCalled(); expect(client.request.rooms.joinGame).not.toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith(App.RouteEnum.GAME); expect(mockNavigate).toHaveBeenCalledWith('/game/7');
}); });
it('dispatching GAME_JOINED navigates to /game (mirrors JOIN_ROOM → /room)', async () => { it('dispatching GAME_JOINED navigates to /game/<id> (mirrors JOIN_ROOM → /room)', async () => {
mockUseWebClient.mockReturnValue(makeWebClient()); mockUseWebClient.mockReturnValue(makeWebClient());
const room = makeRoomEntry([]); const room = makeRoomEntry([]);
const { store } = renderWithProviders(<GameSelector room={room as any} />, { const { store } = renderWithProviders(<GameSelector room={room as any} />, {
@ -247,7 +247,7 @@ describe('GameSelector', () => {
payload: { data: { gameInfo: { gameId: 42 }, hostId: 0, playerId: 0, spectator: false } }, payload: { data: { gameInfo: { gameId: 42 }, hostId: 0, playerId: 0, spectator: false } },
}); });
}); });
expect(mockNavigate).toHaveBeenCalledWith(App.RouteEnum.GAME); expect(mockNavigate).toHaveBeenCalledWith('/game/42');
}); });
it('Join button is disabled while joinGamePending is true even when a game is selected', () => { it('Join button is disabled while joinGamePending is true even when a game is selected', () => {

View file

@ -1,5 +1,5 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { generatePath, useNavigate } from 'react-router-dom';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
@ -13,7 +13,7 @@ import {
type GameFilters, type GameFilters,
} from '@app/store'; } from '@app/store';
import { useReduxEffect, useWebClient } from '@app/hooks'; import { useReduxEffect, useWebClient } from '@app/hooks';
import { App, type Enriched } from '@app/types'; import { App, type Data, type Enriched } from '@app/types';
import { AlertDialog, CreateGameDialog, FilterGamesDialog, PromptDialog } from '@app/dialogs'; import { AlertDialog, CreateGameDialog, FilterGamesDialog, PromptDialog } from '@app/dialogs';
import OpenGames from '../OpenGames'; import OpenGames from '../OpenGames';
@ -49,9 +49,12 @@ const GameSelector = ({ room }: GameSelectorProps) => {
const activeGameIds = useAppSelector(GameSelectors.getActiveGameIds); const activeGameIds = useAppSelector(GameSelectors.getActiveGameIds);
// Mirrors Server.tsx's JOIN_ROOM → navigate(ROOM) pattern: when Event_GameJoined // Mirrors Server.tsx's JOIN_ROOM → navigate(ROOM) pattern: when Event_GameJoined
// lands, we're actually in the game — route to /game. // lands, we're actually in the game — route to /game/:gameId so the URL
useReduxEffect(() => { // identifies which joined game to display.
navigate(App.RouteEnum.GAME); useReduxEffect<{ data: Data.Event_GameJoined }>((action) => {
const gameId = action.payload.data.gameInfo?.gameId;
if (gameId == null) return;
navigate(generatePath(App.RouteEnum.GAME, { gameId: gameId.toString() }));
}, GameTypes.GAME_JOINED, [navigate]); }, GameTypes.GAME_JOINED, [navigate]);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@ -65,7 +68,7 @@ const GameSelector = ({ room }: GameSelectorProps) => {
// JoinGame — the server would reject it with RespContextError — and go // JoinGame — the server would reject it with RespContextError — and go
// straight to the game view. // straight to the game view.
if (activeGameIds.includes(gameId)) { if (activeGameIds.includes(gameId)) {
navigate(App.RouteEnum.GAME); navigate(generatePath(App.RouteEnum.GAME, { gameId: gameId.toString() }));
return; return;
} }
const params: App.JoinGameParams = { const params: App.JoinGameParams = {

View file

@ -3,6 +3,37 @@
max-width: 540px; max-width: 540px;
} }
.deck-select-dialog__file-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.deck-select-dialog__file-name {
font-size: 13px;
color: #56607a;
font-family: ui-monospace, Menlo, Consolas, monospace;
word-break: break-all;
}
.deck-select-dialog__divider {
display: block;
margin: 4px 0 6px;
color: #9ea6b8;
text-align: center;
}
.deck-select-dialog__error {
margin-top: 10px;
padding: 8px 12px;
font-size: 13px;
color: #8a1f1f;
background: #fdecec;
border: 1px solid #f2b8b8;
border-radius: 4px;
}
.deck-select-dialog__textarea { .deck-select-dialog__textarea {
width: 100%; width: 100%;
min-height: 220px; min-height: 220px;

View file

@ -1,4 +1,4 @@
import { screen, fireEvent } from '@testing-library/react'; import { screen, fireEvent, waitFor } from '@testing-library/react';
import { createMockWebClient, makeStoreState, renderWithProviders } from '../../__test-utils__'; import { createMockWebClient, makeStoreState, renderWithProviders } from '../../__test-utils__';
import { import {
@ -8,6 +8,9 @@ import {
} from '../../store/game/__mocks__/fixtures'; } from '../../store/game/__mocks__/fixtures';
import DeckSelectDialog from './DeckSelectDialog'; import DeckSelectDialog from './DeckSelectDialog';
const VALID_COD_XML =
'<?xml version="1.0"?><cockatrice_deck version="1"><zone name="main"><card number="4" name="Island"/></zone></cockatrice_deck>';
function stateWith(playerProps: Parameters<typeof makePlayerProperties>[0] = {}) { function stateWith(playerProps: Parameters<typeof makePlayerProperties>[0] = {}) {
return makeStoreState({ return makeStoreState({
games: { games: {
@ -25,6 +28,12 @@ function stateWith(playerProps: Parameters<typeof makePlayerProperties>[0] = {})
}); });
} }
function pickFile(contents: string, name = 'deck.cod') {
const file = new File([contents], name, { type: 'application/xml' });
const input = screen.getByLabelText('deck file') as HTMLInputElement;
fireEvent.change(input, { target: { files: [file] } });
}
describe('DeckSelectDialog', () => { describe('DeckSelectDialog', () => {
it('does not render content when closed', () => { it('does not render content when closed', () => {
renderWithProviders( renderWithProviders(
@ -35,13 +44,15 @@ describe('DeckSelectDialog', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
}); });
it('renders textarea, Submit Deck, and Ready controls when open', () => { it('renders textarea, file picker, Submit Deck, and Ready controls when open', () => {
renderWithProviders( renderWithProviders(
<DeckSelectDialog isOpen gameId={1} />, <DeckSelectDialog isOpen gameId={1} />,
{ preloadedState: stateWith() }, { preloadedState: stateWith() },
); );
expect(screen.getByLabelText('deck list')).toBeInTheDocument(); expect(screen.getByLabelText('deck list')).toBeInTheDocument();
expect(screen.getByLabelText('deck file')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /choose \.cod file/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit deck/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /submit deck/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^ready$/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^ready$/i })).toBeInTheDocument();
}); });
@ -56,12 +67,12 @@ describe('DeckSelectDialog', () => {
expect(submit).toBeDisabled(); expect(submit).toBeDisabled();
fireEvent.change(screen.getByLabelText('deck list'), { fireEvent.change(screen.getByLabelText('deck list'), {
target: { value: '4 Lightning Bolt\n' }, target: { value: VALID_COD_XML },
}); });
expect(submit).not.toBeDisabled(); expect(submit).not.toBeDisabled();
}); });
it('dispatches deckSelect with the textarea content when Submit Deck is clicked', () => { it('dispatches deckSelect with the pasted XML when Submit Deck is clicked', () => {
const webClient = createMockWebClient(); const webClient = createMockWebClient();
renderWithProviders( renderWithProviders(
<DeckSelectDialog isOpen gameId={1} />, <DeckSelectDialog isOpen gameId={1} />,
@ -69,15 +80,103 @@ describe('DeckSelectDialog', () => {
); );
fireEvent.change(screen.getByLabelText('deck list'), { fireEvent.change(screen.getByLabelText('deck list'), {
target: { value: '4 Island\n4 Mountain' }, target: { value: VALID_COD_XML },
}); });
fireEvent.click(screen.getByRole('button', { name: /submit deck/i })); fireEvent.click(screen.getByRole('button', { name: /submit deck/i }));
expect(webClient.request.game.deckSelect).toHaveBeenCalledWith(1, { expect(webClient.request.game.deckSelect).toHaveBeenCalledWith(1, {
deck: '4 Island\n4 Mountain', deck: VALID_COD_XML,
}); });
}); });
it('shows a validation error and does not dispatch when the textarea holds non-XML text', () => {
const webClient = createMockWebClient();
renderWithProviders(
<DeckSelectDialog isOpen gameId={1} />,
{ preloadedState: stateWith(), webClient },
);
fireEvent.change(screen.getByLabelText('deck list'), {
target: { value: '4 Lightning Bolt\n20 Mountain' },
});
fireEvent.click(screen.getByRole('button', { name: /submit deck/i }));
expect(screen.getByRole('alert')).toHaveTextContent(/not a valid cockatrice deck/i);
expect(webClient.request.game.deckSelect).not.toHaveBeenCalled();
});
it('shows a validation error when the XML has a wrong root element', () => {
const webClient = createMockWebClient();
renderWithProviders(
<DeckSelectDialog isOpen gameId={1} />,
{ preloadedState: stateWith(), webClient },
);
fireEvent.change(screen.getByLabelText('deck list'), {
target: { value: '<?xml version="1.0"?><not_a_deck/>' },
});
fireEvent.click(screen.getByRole('button', { name: /submit deck/i }));
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(webClient.request.game.deckSelect).not.toHaveBeenCalled();
});
it('dispatches deckSelect with the file contents when a valid .cod file is picked', async () => {
const webClient = createMockWebClient();
renderWithProviders(
<DeckSelectDialog isOpen gameId={1} />,
{ preloadedState: stateWith(), webClient },
);
pickFile(VALID_COD_XML, 'my-deck.cod');
await waitFor(() => {
expect(screen.getByText('my-deck.cod')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /submit deck/i }));
expect(webClient.request.game.deckSelect).toHaveBeenCalledWith(1, {
deck: VALID_COD_XML,
});
});
it('rejects a picked file whose contents are not valid Cockatrice XML', async () => {
const webClient = createMockWebClient();
renderWithProviders(
<DeckSelectDialog isOpen gameId={1} />,
{ preloadedState: stateWith(), webClient },
);
pickFile('4 Lightning Bolt\n20 Mountain', 'bogus.cod');
await waitFor(() => {
expect(screen.getByText('bogus.cod')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /submit deck/i }));
expect(screen.getByRole('alert')).toHaveTextContent(/not a valid cockatrice deck/i);
expect(webClient.request.game.deckSelect).not.toHaveBeenCalled();
});
it('clears the picked filename when the user starts typing in the textarea', async () => {
renderWithProviders(
<DeckSelectDialog isOpen gameId={1} />,
{ preloadedState: stateWith() },
);
pickFile(VALID_COD_XML, 'my-deck.cod');
await waitFor(() => {
expect(screen.getByText('my-deck.cod')).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText('deck list'), {
target: { value: VALID_COD_XML },
});
expect(screen.queryByText('my-deck.cod')).not.toBeInTheDocument();
expect(screen.getByText('No file selected')).toBeInTheDocument();
});
it('keeps Ready disabled until the player has a deckHash', () => { it('keeps Ready disabled until the player has a deckHash', () => {
const { rerender } = renderWithProviders( const { rerender } = renderWithProviders(
<DeckSelectDialog isOpen gameId={1} />, <DeckSelectDialog isOpen gameId={1} />,

View file

@ -1,3 +1,4 @@
import { useRef } from 'react';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
@ -41,6 +42,9 @@ function DeckSelectDialog({ isOpen, gameId, handleClose }: DeckSelectDialogProps
const { const {
deckText, deckText,
setDeckText, setDeckText,
fileName,
handleFilePicked,
validationError,
deckHash, deckHash,
isReady, isReady,
canSubmit, canSubmit,
@ -49,6 +53,8 @@ function DeckSelectDialog({ isOpen, gameId, handleClose }: DeckSelectDialogProps
handleToggleReady, handleToggleReady,
} = useDeckSelectDialog(gameId); } = useDeckSelectDialog(gameId);
const fileInputRef = useRef<HTMLInputElement | null>(null);
return ( return (
<StyledDialog <StyledDialog
className={'DeckSelectDialog ' + classes.root} className={'DeckSelectDialog ' + classes.root}
@ -63,8 +69,37 @@ function DeckSelectDialog({ isOpen, gameId, handleClose }: DeckSelectDialogProps
</DialogTitle> </DialogTitle>
<DialogContent className="dialog-content"> <DialogContent className="dialog-content">
<Typography className="dialog-content__subtitle" variant="subtitle1"> <Typography className="dialog-content__subtitle" variant="subtitle1">
Paste your deck list below, then click Submit Deck. After the server Pick a .cod file from your computer or paste its XML below, then click
accepts the deck, the Ready button unlocks. Submit Deck. After the server accepts the deck, the Ready button unlocks.
</Typography>
<div className="deck-select-dialog__file-row">
<input
ref={fileInputRef}
type="file"
accept=".cod"
aria-label="deck file"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0] ?? null;
handleFilePicked(file);
e.target.value = '';
}}
/>
<Button
variant="outlined"
size="small"
onClick={() => fileInputRef.current?.click()}
>
Choose .cod file
</Button>
<span className="deck-select-dialog__file-name">
{fileName ?? 'No file selected'}
</span>
</div>
<Typography className="deck-select-dialog__divider" variant="caption">
or paste XML below
</Typography> </Typography>
<textarea <textarea
@ -72,10 +107,16 @@ function DeckSelectDialog({ isOpen, gameId, handleClose }: DeckSelectDialogProps
rows={10} rows={10}
value={deckText} value={deckText}
onChange={(e) => setDeckText(e.target.value)} onChange={(e) => setDeckText(e.target.value)}
placeholder="4 Lightning Bolt&#10;20 Mountain&#10;..." placeholder={'<?xml version="1.0"?>\n<cockatrice_deck version="1">\n ...\n</cockatrice_deck>'}
aria-label="deck list" aria-label="deck list"
/> />
{validationError != null && (
<div className="deck-select-dialog__error" role="alert">
{validationError}
</div>
)}
<div className="deck-select-dialog__hash"> <div className="deck-select-dialog__hash">
Deck hash: {deckHash.length > 0 ? deckHash : <span className="deck-select-dialog__hash--pending"></span>} Deck hash: {deckHash.length > 0 ? deckHash : <span className="deck-select-dialog__hash--pending"></span>}
</div> </div>

View file

@ -6,6 +6,9 @@ import { GameSelectors, useAppSelector } from '@app/store';
export interface DeckSelectDialog { export interface DeckSelectDialog {
deckText: string; deckText: string;
setDeckText: (v: string) => void; setDeckText: (v: string) => void;
fileName: string | null;
handleFilePicked: (file: File | null) => void;
validationError: string | null;
deckHash: string; deckHash: string;
isReady: boolean; isReady: boolean;
canSubmit: boolean; canSubmit: boolean;
@ -14,12 +17,28 @@ export interface DeckSelectDialog {
handleToggleReady: () => void; handleToggleReady: () => void;
} }
const INVALID_COD_MESSAGE = 'Not a valid Cockatrice deck (.cod) file';
function validateCodXml(xml: string): boolean {
if (xml.length === 0) {
return false;
}
const doc = new DOMParser().parseFromString(xml, 'application/xml');
if (doc.getElementsByTagName('parsererror').length > 0) {
return false;
}
return doc.documentElement?.tagName === 'cockatrice_deck';
}
export function useDeckSelectDialog(gameId: number | undefined): DeckSelectDialog { export function useDeckSelectDialog(gameId: number | undefined): DeckSelectDialog {
const webClient = useWebClient(); const webClient = useWebClient();
const localPlayer = useAppSelector((state) => const localPlayer = useAppSelector((state) =>
gameId != null ? GameSelectors.getLocalPlayer(state, gameId) : undefined, gameId != null ? GameSelectors.getLocalPlayer(state, gameId) : undefined,
); );
const [deckText, setDeckText] = useState(''); const [deckText, setDeckTextState] = useState('');
const [fileXml, setFileXml] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const deckHash = localPlayer?.properties.deckHash ?? ''; const deckHash = localPlayer?.properties.deckHash ?? '';
const isReady = localPlayer?.properties.readyStart ?? false; const isReady = localPlayer?.properties.readyStart ?? false;
@ -27,14 +46,53 @@ export function useDeckSelectDialog(gameId: number | undefined): DeckSelectDialo
// Guard Submit/Ready on having a local player — today the deckSelectOpen // Guard Submit/Ready on having a local player — today the deckSelectOpen
// predicate in Game.tsx implies one, but the dialog mounts before the // predicate in Game.tsx implies one, but the dialog mounts before the
// Event_GameJoined echo populates players during reconnect. // Event_GameJoined echo populates players during reconnect.
const canSubmit = hasLocalPlayer && deckText.trim().length > 0; const canSubmit =
hasLocalPlayer && (fileXml != null || deckText.trim().length > 0);
const canToggleReady = hasLocalPlayer && deckHash.length > 0; const canToggleReady = hasLocalPlayer && deckHash.length > 0;
const setDeckText = (value: string) => {
setDeckTextState(value);
if (fileXml != null) {
setFileXml(null);
setFileName(null);
}
if (validationError != null) {
setValidationError(null);
}
};
const handleFilePicked = (file: File | null) => {
if (file == null) {
setFileXml(null);
setFileName(null);
setValidationError(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
const contents = typeof reader.result === 'string' ? reader.result : '';
setFileXml(contents);
setFileName(file.name);
setDeckTextState('');
setValidationError(null);
};
reader.onerror = () => {
setValidationError('Could not read the selected file');
};
reader.readAsText(file);
};
const handleSubmitDeck = () => { const handleSubmitDeck = () => {
if (!canSubmit || gameId == null) { if (!canSubmit || gameId == null) {
return; return;
} }
webClient.request.game.deckSelect(gameId, { deck: deckText.trim() }); const xml = fileXml ?? deckText.trim();
if (!validateCodXml(xml)) {
setValidationError(INVALID_COD_MESSAGE);
return;
}
setValidationError(null);
webClient.request.game.deckSelect(gameId, { deck: xml });
}; };
const handleToggleReady = () => { const handleToggleReady = () => {
@ -47,6 +105,9 @@ export function useDeckSelectDialog(gameId: number | undefined): DeckSelectDialo
return { return {
deckText, deckText,
setDeckText, setDeckText,
fileName,
handleFilePicked,
validationError,
deckHash, deckHash,
isReady, isReady,
canSubmit, canSubmit,

View file

@ -1,3 +1,4 @@
import { useEffect } from 'react';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
@ -45,6 +46,19 @@ function ZoneViewDialog({
const { cards, count, title, position, handlePointerDown, handlePointerMove, handlePointerUp } = const { cards, count, title, position, handlePointerDown, handlePointerMove, handlePointerUp } =
useZoneViewDialog({ gameId, playerId, zoneName, initialPosition }); useZoneViewDialog({ gameId, playerId, zoneName, initialPosition });
useEffect(() => {
if (!isOpen) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isOpen, handleClose]);
if (!isOpen) { if (!isOpen) {
return null; return null;
} }

View file

@ -76,6 +76,12 @@ export function useZoneViewDialog({
if (e.button !== 0) { if (e.button !== 0) {
return; return;
} }
// Skip drag initiation when the pointer lands on an interactive child
// (e.g. the close button). Capturing the pointer on the header would
// otherwise swallow the button's click event.
if ((e.target as HTMLElement).closest('button')) {
return;
}
const target = e.currentTarget; const target = e.currentTarget;
target.setPointerCapture(e.pointerId); target.setPointerCapture(e.pointerId);
dragStateRef.current = { dragStateRef.current = {

View file

@ -0,0 +1,26 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { Data } from '@app/types';
import { GamesState } from './game.interfaces';
import { pushEventMessage } from './game.reducer.helpers';
import { formatArrowCreated } from './messageLog';
export const arrowReducers = {
arrowCreated: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const game = state.games[gameId];
const player = game?.players[playerId];
if (!game || !player || !data.arrowInfo) {
return;
}
player.arrows[data.arrowInfo.id] = { ...data.arrowInfo };
pushEventMessage(game, playerId, formatArrowCreated(game, playerId, data.arrowInfo));
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateArrow }>>,
arrowDeleted: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const player = state.games[gameId]?.players[playerId];
if (player) {
delete player.arrows[data.arrowId];
}
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DeleteArrow }>>,
};

View file

@ -0,0 +1,279 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { create, isFieldSet } from '@bufbuild/protobuf';
import { Data, Enriched } from '@app/types';
import { GamesState } from './game.interfaces';
import { buildEmptyCard, pushEventMessage } from './game.reducer.helpers';
import {
formatCardAttached,
formatCardAttrChanged,
formatCardCounterChanged,
formatCardDestroyed,
formatCardFlipped,
formatCardMoved,
formatCardsDrawn,
formatTokenCreated,
formatZonePropertiesChanged,
} from './messageLog';
export const cardReducers = {
cardMoved: ((state, action) => {
const { gameId, data } = action.payload;
const {
cardId, cardName, startPlayerId, startZone, position,
targetPlayerId, targetZone, x, y, newCardId, faceDown, newCardProviderId,
} = data;
// Server omits target_zone when it equals start_zone (proto3 strips the
// default empty string). Desktop's GameEventHandler applies the same
// fallback; without it, intra-zone moves silently bail at the zone lookup.
const effectiveTargetZone = targetZone || startZone;
const game = state.games[gameId];
if (!game) {
return;
}
const sourcePlayer = game.players[startPlayerId];
const sourceZone = sourcePlayer?.zones[startZone];
if (!sourcePlayer || !sourceZone) {
return;
}
const targetPlayer = game.players[targetPlayerId];
const targetZoneEntry = targetPlayer?.zones[effectiveTargetZone];
if (!targetPlayer || !targetZoneEntry) {
return;
}
let resolvedCardId = -1;
if (cardId >= 0) {
resolvedCardId = cardId;
} else if (position >= 0 && position < sourceZone.order.length) {
resolvedCardId = sourceZone.order[position];
}
// If the card can't be resolved and no newCardId is provided, the event
// is malformed — bail out to avoid creating phantom cards with id -1.
if (resolvedCardId < 0 && newCardId < 0) {
return;
}
// Remove from source zone if the card was resolved to a known entry
let removedCard: Data.ServerInfo_Card | undefined;
if (resolvedCardId >= 0) {
removedCard = sourceZone.byId[resolvedCardId];
const idx = sourceZone.order.indexOf(resolvedCardId);
if (idx >= 0) {
sourceZone.order.splice(idx, 1);
}
delete sourceZone.byId[resolvedCardId];
}
sourceZone.cardCount = Math.max(0, sourceZone.cardCount - 1);
const effectiveNewId = newCardId >= 0 ? newCardId : (removedCard?.id ?? resolvedCardId);
const movedCard: Data.ServerInfo_Card = removedCard
? {
...removedCard, id: effectiveNewId, name: cardName || removedCard.name,
x, y, faceDown, providerId: newCardProviderId || removedCard.providerId,
counterList: [...removedCard.counterList],
}
: buildEmptyCard(effectiveNewId, cardName, x, y, faceDown, newCardProviderId ?? '');
targetZoneEntry.order.push(movedCard.id);
targetZoneEntry.byId[movedCard.id] = movedCard;
targetZoneEntry.cardCount++;
pushEventMessage(
game,
action.payload.playerId,
// Pass the defaulted targetZone through so isSameZoneReorder in the
// formatter correctly suppresses the log line for intra-zone reorders.
formatCardMoved(
game,
action.payload.playerId,
{ ...data, targetZone: effectiveTargetZone },
{ resolvedCardName: removedCard?.name ?? '' },
),
);
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_MoveCard }>>,
cardFlipped: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId, cardName, faceDown, cardProviderId } = data;
const game = state.games[gameId];
const card = game?.players[playerId]?.zones[zoneName]?.byId[cardId];
if (!game || !card) {
return;
}
const previousName = card.name;
card.faceDown = faceDown;
if (cardName) {
card.name = cardName;
}
if (cardProviderId) {
card.providerId = cardProviderId;
}
pushEventMessage(game, playerId, formatCardFlipped(game, playerId, data, previousName));
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_FlipCard }>>,
cardDestroyed: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId } = data;
const game = state.games[gameId];
const zone = game?.players[playerId]?.zones[zoneName];
if (!game || !zone) {
return;
}
const destroyedName = zone.byId[cardId]?.name;
const idx = zone.order.indexOf(cardId);
if (idx >= 0) {
zone.order.splice(idx, 1);
}
delete zone.byId[cardId];
zone.cardCount = Math.max(0, zone.cardCount - 1);
pushEventMessage(game, playerId, formatCardDestroyed(game, playerId, destroyedName));
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DestroyCard }>>,
cardAttached: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const { startZone, cardId, targetPlayerId, targetZone, targetCardId } = data;
const game = state.games[gameId];
const card = game?.players[playerId]?.zones[startZone]?.byId[cardId];
if (!game || !card) {
return;
}
const sourceCardName = card.name;
card.attachPlayerId = targetPlayerId;
card.attachZone = targetZone;
card.attachCardId = targetCardId;
pushEventMessage(game, playerId, formatCardAttached(game, playerId, data, sourceCardName));
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_AttachCard }>>,
tokenCreated: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId, cardName, color, pt, annotation, destroyOnZoneChange, x, y, cardProviderId, faceDown } = data;
const game = state.games[gameId];
const zone = game?.players[playerId]?.zones[zoneName];
if (!game || !zone) {
return;
}
const newCard = create(Data.ServerInfo_CardSchema, {
id: cardId, name: cardName, x, y, faceDown,
tapped: false, attacking: false, color, pt, annotation, destroyOnZoneChange,
doesntUntap: false, counterList: [],
attachPlayerId: -1, attachZone: '', attachCardId: -1, providerId: cardProviderId,
});
zone.order.push(newCard.id);
zone.byId[newCard.id] = newCard;
zone.cardCount++;
pushEventMessage(game, playerId, formatTokenCreated(game, playerId, data));
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateToken }>>,
cardAttrChanged: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId, attribute, attrValue } = data;
const game = state.games[gameId];
const card = game?.players[playerId]?.zones[zoneName]?.byId[cardId];
if (!game || !card) {
return;
}
const cardName = card.name;
switch (attribute as Data.CardAttribute) {
case Data.CardAttribute.AttrTapped: card.tapped = attrValue === '1'; break;
case Data.CardAttribute.AttrAttacking: card.attacking = attrValue === '1'; break;
case Data.CardAttribute.AttrFaceDown: card.faceDown = attrValue === '1'; break;
case Data.CardAttribute.AttrColor: card.color = attrValue; break;
case Data.CardAttribute.AttrPT: card.pt = attrValue; break;
case Data.CardAttribute.AttrAnnotation: card.annotation = attrValue; break;
case Data.CardAttribute.AttrDoesntUntap: card.doesntUntap = attrValue === '1'; break;
}
pushEventMessage(game, playerId, formatCardAttrChanged(game, playerId, data, cardName));
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_SetCardAttr }>>,
cardCounterChanged: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId, counterId, counterValue } = data;
const game = state.games[gameId];
const card = game?.players[playerId]?.zones[zoneName]?.byId[cardId];
if (!game || !card) {
return;
}
const cardName = card.name;
const previousValue = card.counterList.find(c => c.id === counterId)?.value ?? 0;
if (counterValue <= 0) {
card.counterList = card.counterList.filter(c => c.id !== counterId);
} else {
const idx = card.counterList.findIndex(c => c.id === counterId);
if (idx >= 0) {
card.counterList[idx] = { ...card.counterList[idx], value: counterValue };
} else {
card.counterList.push(create(Data.ServerInfo_CardCounterSchema, { id: counterId, value: counterValue }));
}
}
pushEventMessage(
game,
playerId,
formatCardCounterChanged(game, playerId, data, cardName, previousValue),
);
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_SetCardCounter }>>,
cardsDrawn: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const { number: drawCount, cards } = data;
const game = state.games[gameId];
const player = game?.players[playerId];
if (!game || !player) {
return;
}
const deckZone = player.zones[Enriched.ZoneName.DECK];
const handZone = player.zones[Enriched.ZoneName.HAND];
if (!handZone) {
return;
}
if (deckZone) {
deckZone.cardCount = Math.max(0, deckZone.cardCount - drawCount);
}
for (const card of cards) {
handZone.order.push(card.id);
handZone.byId[card.id] = card;
}
handZone.cardCount += drawCount;
pushEventMessage(game, playerId, formatCardsDrawn(game, playerId, drawCount));
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DrawCards }>>,
cardsRevealed: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cards } = data;
const zone = state.games[gameId]?.players[playerId]?.zones[zoneName];
if (!zone) {
return;
}
for (const revealedCard of cards) {
if (!zone.byId[revealedCard.id]) {
zone.order.push(revealedCard.id);
}
zone.byId[revealedCard.id] = { ...revealedCard, counterList: [...revealedCard.counterList] };
}
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_RevealCards }>>,
zonePropertiesChanged: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const game = state.games[gameId];
const zone = game?.players[playerId]?.zones[data.zoneName];
if (!game || !zone) {
return;
}
if (isFieldSet(data, Data.Event_ChangeZonePropertiesSchema.field.alwaysRevealTopCard)) {
zone.alwaysRevealTopCard = data.alwaysRevealTopCard;
}
if (isFieldSet(data, Data.Event_ChangeZonePropertiesSchema.field.alwaysLookAtTopCard)) {
zone.alwaysLookAtTopCard = data.alwaysLookAtTopCard;
}
pushEventMessage(game, playerId, formatZonePropertiesChanged(game, playerId, data));
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_ChangeZoneProperties }>>,
};

View file

@ -0,0 +1,51 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { Data } from '@app/types';
import { GamesState } from './game.interfaces';
import { MAX_GAME_MESSAGES, pushEventMessage } from './game.reducer.helpers';
import {
formatDieRolled,
formatZoneDumped,
formatZoneShuffled,
} from './messageLog';
export const chatReducers = {
gameSay: ((state, action) => {
const { gameId, playerId, message, timeReceived } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
if (game.messages.length >= MAX_GAME_MESSAGES) {
game.messages = game.messages.slice(game.messages.length - MAX_GAME_MESSAGES + 1);
}
game.messages.push({ playerId, message, timeReceived, kind: 'chat' });
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; message: string; timeReceived: number }>>,
// Logged-only actions: no state mutation but an event-log entry.
zoneShuffled: ((state, action) => {
const { gameId, playerId } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
pushEventMessage(game, playerId, formatZoneShuffled(game, playerId));
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_Shuffle }>>,
zoneDumped: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
pushEventMessage(game, playerId, formatZoneDumped(game, playerId, data));
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DumpZone }>>,
dieRolled: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
pushEventMessage(game, playerId, formatDieRolled(game, playerId, data));
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_RollDie }>>,
};

View file

@ -0,0 +1,39 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { Data } from '@app/types';
import { GamesState } from './game.interfaces';
import { pushEventMessage } from './game.reducer.helpers';
import { formatCounterSet } from './messageLog';
export const counterReducers = {
counterCreated: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const player = state.games[gameId]?.players[playerId];
if (player && data.counterInfo) {
player.counters[data.counterInfo.id] = { ...data.counterInfo };
}
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateCounter }>>,
counterSet: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const game = state.games[gameId];
const counter = game?.players[playerId]?.counters[data.counterId];
if (!game || !counter) {
return;
}
const previousValue = counter.count;
counter.count = data.value;
pushEventMessage(
game,
playerId,
formatCounterSet(game, playerId, data, counter.name, previousValue),
);
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_SetCounter }>>,
counterDeleted: ((state, action) => {
const { gameId, playerId, data } = action.payload;
const player = state.games[gameId]?.players[playerId];
if (player) {
delete player.counters[data.counterId];
}
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DelCounter }>>,
};

View file

@ -0,0 +1,107 @@
import { create } from '@bufbuild/protobuf';
import { Data, Enriched } from '@app/types';
export const MAX_GAME_MESSAGES = 1000;
// Mirrors Event_Leave.LeaveReason values (1=OTHER, 2=USER_KICKED,
// 3=USER_LEFT, 4=USER_DISCONNECTED); kept in sync with desktop
// `GameEventHandler::getLeaveReason` in game_event_handler.cpp.
const LEAVE_REASON_MESSAGES: Record<number, string> = {
1: 'reason unknown',
2: 'kicked by game host or moderator',
3: 'player left the game',
4: 'player disconnected from server',
};
export function formatLeaveMessage(playerName: string, reason: number): string {
const reasonText = LEAVE_REASON_MESSAGES[reason] ?? LEAVE_REASON_MESSAGES[1];
return `${playerName} has left the game (${reasonText}).`;
}
/** Timestamp source for event-log entries. Isolated so tests can mock it. */
export function eventTimestamp(): number {
return Date.now();
}
/** Push a formatted event-log message onto the game's message list, truncating to MAX_GAME_MESSAGES. */
export function pushEventMessage(
game: Enriched.GameEntry,
playerId: number,
message: string | null | undefined,
): void {
if (!message) {
return;
}
if (game.messages.length >= MAX_GAME_MESSAGES) {
game.messages = game.messages.slice(game.messages.length - MAX_GAME_MESSAGES + 1);
}
game.messages.push({
playerId,
message,
timeReceived: eventTimestamp(),
kind: 'event',
});
}
/** Converts the proto ServerInfo_Player[] array into the keyed PlayerEntry map. */
export function normalizePlayers(playerList: Data.ServerInfo_Player[]): { [playerId: number]: Enriched.PlayerEntry } {
const players: { [playerId: number]: Enriched.PlayerEntry } = {};
for (const player of playerList) {
const playerId = player.properties.playerId;
const zones: { [zoneName: string]: Enriched.ZoneEntry } = {};
for (const zone of player.zoneList) {
const order: number[] = [];
const byId: { [id: number]: Data.ServerInfo_Card } = {};
for (const card of zone.cardList) {
order.push(card.id);
byId[card.id] = card;
}
zones[zone.name] = {
name: zone.name as Enriched.ZoneNameValue,
type: zone.type,
withCoords: zone.withCoords,
cardCount: zone.cardCount,
order,
byId,
alwaysRevealTopCard: zone.alwaysRevealTopCard,
alwaysLookAtTopCard: zone.alwaysLookAtTopCard,
};
}
const counters: { [counterId: number]: Data.ServerInfo_Counter } = {};
for (const counter of player.counterList) {
counters[counter.id] = counter;
}
const arrows: { [arrowId: number]: Data.ServerInfo_Arrow } = {};
for (const arrow of player.arrowList) {
arrows[arrow.id] = arrow;
}
players[playerId] = {
properties: player.properties,
deckList: player.deckList,
zones,
counters,
arrows,
};
}
return players;
}
export function buildEmptyCard(
id: number,
name: string,
x: number,
y: number,
faceDown: boolean,
providerId: string
): Data.ServerInfo_Card {
return create(Data.ServerInfo_CardSchema, {
id, name, x, y, faceDown,
tapped: false, attacking: false, color: '', pt: '', annotation: '',
destroyOnZoneChange: false, doesntUntap: false, counterList: [],
attachPlayerId: -1, attachZone: '', attachCardId: -1, providerId,
});
}

View file

@ -0,0 +1,44 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { Data } from '@app/types';
import { GamesState } from './game.interfaces';
const initialState: GamesState = { games: {} };
export const lifecycleReducers = {
clearStore: (() => initialState) as CaseReducer<GamesState>,
gameJoined: ((state, action) => {
const { data } = action.payload;
const gameInfo = data.gameInfo;
if (!gameInfo) {
return;
}
state.games[gameInfo.gameId] = {
info: gameInfo,
hostId: data.hostId,
localPlayerId: data.playerId,
spectator: data.spectator,
judge: data.judge,
resuming: data.resuming,
started: gameInfo.started,
activePlayerId: -1,
activePhase: -1,
secondsElapsed: 0,
reversed: false,
players: {},
messages: [],
};
}) as CaseReducer<GamesState, PayloadAction<{ data: Data.Event_GameJoined }>>,
gameLeft: ((state, action) => {
delete state.games[action.payload.gameId];
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number }>>,
gameClosed: ((state, action) => {
delete state.games[action.payload.gameId];
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number }>>,
kicked: ((state, action) => {
delete state.games[action.payload.gameId];
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number }>>,
};

View file

@ -0,0 +1,65 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { Data } from '@app/types';
import { mergeSetFields } from '../common';
import { GamesState } from './game.interfaces';
import { MAX_GAME_MESSAGES, formatLeaveMessage, pushEventMessage } from './game.reducer.helpers';
import {
diffPlayerProperties,
formatPlayerJoined,
formatPropertyDiff,
} from './messageLog';
export const playerReducers = {
playerJoined: ((state, action) => {
const { gameId, playerProperties } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
game.players[playerProperties.playerId] = {
properties: playerProperties,
deckList: '',
zones: {},
counters: {},
arrows: {},
};
pushEventMessage(game, playerProperties.playerId, formatPlayerJoined(game, playerProperties.playerId));
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerProperties: Data.ServerInfo_PlayerProperties }>>,
playerLeft: ((state, action) => {
const { gameId, playerId, reason, timeReceived } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
const player = game.players[playerId];
const playerName = player?.properties.userInfo?.name ?? 'Unknown player';
if (game.messages.length >= MAX_GAME_MESSAGES) {
game.messages = game.messages.slice(game.messages.length - MAX_GAME_MESSAGES + 1);
}
game.messages.push({
playerId,
message: formatLeaveMessage(playerName, reason),
timeReceived,
kind: 'event',
});
delete game.players[playerId];
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; reason: number; timeReceived: number }>>,
playerPropertiesChanged: ((state, action) => {
const { gameId, playerId, properties } = action.payload;
const game = state.games[gameId];
const player = game?.players[playerId];
if (!game || !player) {
return;
}
const previous = { ...player.properties };
mergeSetFields(Data.ServerInfo_PlayerPropertiesSchema, player.properties, properties);
const diff = diffPlayerProperties(previous, player.properties);
for (const msg of formatPropertyDiff(game, playerId, diff)) {
pushEventMessage(game, playerId, msg);
}
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; playerId: number; properties: Data.ServerInfo_PlayerProperties }>>,
};

View file

@ -570,6 +570,151 @@ describe('2C: CARD_MOVED', () => {
expect(movedCard.counterList).toHaveLength(1); expect(movedCard.counterList).toHaveLength(1);
expect(movedCard.counterList).not.toBe(card.counterList); expect(movedCard.counterList).not.toBe(card.counterList);
}); });
it('intra-zone TABLE move: card stays in the zone with updated x/y', () => {
const card = makeCard({ id: 10, x: 0, y: 0, name: 'InPlace' });
const state = makeState({
games: {
1: makeGameEntry({
players: {
1: makePlayerEntry({
zones: {
table: makeZoneEntry({ name: 'table', cards: [card], cardCount: 1 }),
},
}),
},
}),
},
});
const result = gamesReducer(state, Actions.cardMoved({
gameId: 1,
playerId: 1,
data: {
cardId: 10, cardName: '', startPlayerId: 1, startZone: 'table',
position: -1, targetPlayerId: 1, targetZone: 'table',
x: 3, y: 1, newCardId: -1, faceDown: false, newCardProviderId: '',
},
}));
const tableCards = cardsIn(result, 1, 1, 'table');
expect(tableCards).toHaveLength(1);
expect(tableCards[0].id).toBe(10);
expect(tableCards[0].x).toBe(3);
expect(tableCards[0].y).toBe(1);
expect(tableCards[0].name).toBe('InPlace');
expect(result.games[1].players[1].zones['table'].cardCount).toBe(1);
});
it('intra-zone TABLE move produces a new zone reference so selector caches invalidate', () => {
const card = makeCard({ id: 10, x: 0, y: 0 });
const state = makeState({
games: {
1: makeGameEntry({
players: {
1: makePlayerEntry({
zones: {
table: makeZoneEntry({ name: 'table', cards: [card], cardCount: 1 }),
},
}),
},
}),
},
});
const originalZone = state.games[1].players[1].zones['table'];
const result = gamesReducer(state, Actions.cardMoved({
gameId: 1,
playerId: 1,
data: {
cardId: 10, cardName: '', startPlayerId: 1, startZone: 'table',
position: -1, targetPlayerId: 1, targetZone: 'table',
x: 3, y: 1, newCardId: -1, faceDown: false, newCardProviderId: '',
},
}));
const nextZone = result.games[1].players[1].zones['table'];
expect(nextZone).not.toBe(originalZone);
expect(nextZone.byId[10]).not.toBe(originalZone.byId[10]);
});
it('intra-zone TABLE move with an omitted targetZone defaults to startZone', () => {
// Protobuf strips target_zone from the wire when it equals start_zone
// (default empty string). Desktop falls back to start_zone; this reducer
// must too, otherwise `zones[undefined]` misses and the move silently no-ops.
const card = makeCard({ id: 15, x: 0, y: 0, name: 'A Good Day to Pie' });
const state = makeState({
games: {
1: makeGameEntry({
players: {
1: makePlayerEntry({
zones: {
table: makeZoneEntry({ name: 'table', cards: [card], cardCount: 1 }),
},
}),
},
}),
},
});
const result = gamesReducer(state, Actions.cardMoved({
gameId: 1,
playerId: 1,
data: {
cardId: 15, cardName: '', startPlayerId: 1, startZone: 'table',
position: -1, targetPlayerId: 1, targetZone: '', // ← stripped on the wire
x: 18, y: 1, newCardId: 15, faceDown: false, newCardProviderId: '',
},
}));
const tableCards = cardsIn(result, 1, 1, 'table');
expect(tableCards).toHaveLength(1);
expect(tableCards[0].x).toBe(18);
expect(tableCards[0].y).toBe(1);
});
it('intra-zone TABLE move with two cards: moved card updates, other card untouched', () => {
const movingCard = makeCard({ id: 10, x: 0, y: 0, name: 'Mover' });
const stillCard = makeCard({ id: 11, x: 3, y: 0, name: 'Still' });
const state = makeState({
games: {
1: makeGameEntry({
players: {
1: makePlayerEntry({
zones: {
table: makeZoneEntry({
name: 'table',
cards: [movingCard, stillCard],
cardCount: 2,
}),
},
}),
},
}),
},
});
const result = gamesReducer(state, Actions.cardMoved({
gameId: 1,
playerId: 1,
data: {
cardId: 10, cardName: '', startPlayerId: 1, startZone: 'table',
position: -1, targetPlayerId: 1, targetZone: 'table',
x: 6, y: 2, newCardId: -1, faceDown: false, newCardProviderId: '',
},
}));
const tableCards = cardsIn(result, 1, 1, 'table').sort((a, b) => a.id - b.id);
expect(tableCards).toHaveLength(2);
const [c10, c11] = tableCards;
expect(c10.id).toBe(10);
expect(c10.x).toBe(6);
expect(c10.y).toBe(2);
expect(c11.id).toBe(11);
expect(c11.x).toBe(3);
expect(c11.y).toBe(0);
expect(result.games[1].players[1].zones['table'].cardCount).toBe(2);
});
}); });

View file

@ -1,136 +1,14 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { Data, Enriched } from '@app/types';
import { create, isFieldSet } from '@bufbuild/protobuf';
import { mergeSetFields } from '../common';
import { GamesState } from './game.interfaces'; import { GamesState } from './game.interfaces';
import { import { arrowReducers } from './game.reducer.arrow';
diffPlayerProperties, import { cardReducers } from './game.reducer.card';
formatActivePhaseSet, import { chatReducers } from './game.reducer.chat';
formatActivePlayerSet, import { counterReducers } from './game.reducer.counter';
formatArrowCreated, import { lifecycleReducers } from './game.reducer.lifecycle';
formatCardAttached, import { playerReducers } from './game.reducer.player';
formatCardAttrChanged, import { turnReducers } from './game.reducer.turn';
formatCardCounterChanged,
formatCardDestroyed,
formatCardFlipped,
formatCardMoved,
formatCardsDrawn,
formatCounterSet,
formatDieRolled,
formatGameStart,
formatPlayerJoined,
formatPropertyDiff,
formatTokenCreated,
formatTurnReversed,
formatZoneDumped,
formatZonePropertiesChanged,
formatZoneShuffled,
} from './messageLog';
export const MAX_GAME_MESSAGES = 1000; export { MAX_GAME_MESSAGES } from './game.reducer.helpers';
// Mirrors Event_Leave.LeaveReason values (1=OTHER, 2=USER_KICKED,
// 3=USER_LEFT, 4=USER_DISCONNECTED); kept in sync with desktop
// `GameEventHandler::getLeaveReason` in game_event_handler.cpp.
const LEAVE_REASON_MESSAGES: Record<number, string> = {
1: 'reason unknown',
2: 'kicked by game host or moderator',
3: 'player left the game',
4: 'player disconnected from server',
};
function formatLeaveMessage(playerName: string, reason: number): string {
const reasonText = LEAVE_REASON_MESSAGES[reason] ?? LEAVE_REASON_MESSAGES[1];
return `${playerName} has left the game (${reasonText}).`;
}
/** Timestamp source for event-log entries. Isolated so tests can mock it. */
function eventTimestamp(): number {
return Date.now();
}
/** Push a formatted event-log message onto the game's message list, truncating to MAX_GAME_MESSAGES. */
function pushEventMessage(
game: Enriched.GameEntry,
playerId: number,
message: string | null | undefined,
): void {
if (!message) {
return;
}
if (game.messages.length >= MAX_GAME_MESSAGES) {
game.messages = game.messages.slice(game.messages.length - MAX_GAME_MESSAGES + 1);
}
game.messages.push({
playerId,
message,
timeReceived: eventTimestamp(),
kind: 'event',
});
}
/** Converts the proto ServerInfo_Player[] array into the keyed PlayerEntry map. */
function normalizePlayers(playerList: Data.ServerInfo_Player[]): { [playerId: number]: Enriched.PlayerEntry } {
const players: { [playerId: number]: Enriched.PlayerEntry } = {};
for (const player of playerList) {
const playerId = player.properties.playerId;
const zones: { [zoneName: string]: Enriched.ZoneEntry } = {};
for (const zone of player.zoneList) {
const order: number[] = [];
const byId: { [id: number]: Data.ServerInfo_Card } = {};
for (const card of zone.cardList) {
order.push(card.id);
byId[card.id] = card;
}
zones[zone.name] = {
name: zone.name as Enriched.ZoneNameValue,
type: zone.type,
withCoords: zone.withCoords,
cardCount: zone.cardCount,
order,
byId,
alwaysRevealTopCard: zone.alwaysRevealTopCard,
alwaysLookAtTopCard: zone.alwaysLookAtTopCard,
};
}
const counters: { [counterId: number]: Data.ServerInfo_Counter } = {};
for (const counter of player.counterList) {
counters[counter.id] = counter;
}
const arrows: { [arrowId: number]: Data.ServerInfo_Arrow } = {};
for (const arrow of player.arrowList) {
arrows[arrow.id] = arrow;
}
players[playerId] = {
properties: player.properties,
deckList: player.deckList,
zones,
counters,
arrows,
};
}
return players;
}
function buildEmptyCard(
id: number,
name: string,
x: number,
y: number,
faceDown: boolean,
providerId: string
): Data.ServerInfo_Card {
return create(Data.ServerInfo_CardSchema, {
id, name, x, y, faceDown,
tapped: false, attacking: false, color: '', pt: '', annotation: '',
destroyOnZoneChange: false, doesntUntap: false, counterList: [],
attachPlayerId: -1, attachZone: '', attachCardId: -1, providerId,
});
}
const initialState: GamesState = { games: {} }; const initialState: GamesState = { games: {} };
@ -138,519 +16,13 @@ export const gamesSlice = createSlice({
name: 'games', name: 'games',
initialState, initialState,
reducers: { reducers: {
clearStore: () => initialState, ...lifecycleReducers,
...turnReducers,
gameJoined: (state, action: PayloadAction<{ data: Data.Event_GameJoined }>) => { ...playerReducers,
const { data } = action.payload; ...cardReducers,
const gameInfo = data.gameInfo; ...counterReducers,
if (!gameInfo) { ...arrowReducers,
return; ...chatReducers,
}
state.games[gameInfo.gameId] = {
info: gameInfo,
hostId: data.hostId,
localPlayerId: data.playerId,
spectator: data.spectator,
judge: data.judge,
resuming: data.resuming,
started: gameInfo.started,
activePlayerId: -1,
activePhase: -1,
secondsElapsed: 0,
reversed: false,
players: {},
messages: [],
};
},
gameLeft: (state, action: PayloadAction<{ gameId: number }>) => {
delete state.games[action.payload.gameId];
},
gameClosed: (state, action: PayloadAction<{ gameId: number }>) => {
delete state.games[action.payload.gameId];
},
kicked: (state, action: PayloadAction<{ gameId: number }>) => {
delete state.games[action.payload.gameId];
},
gameHostChanged: (state, action: PayloadAction<{ gameId: number; hostId: number }>) => {
const { gameId, hostId } = action.payload;
const game = state.games[gameId];
if (game) {
game.hostId = hostId;
}
},
gameStateChanged: (state, action: PayloadAction<{ gameId: number; data: Data.Event_GameStateChanged }>) => {
const { gameId, data } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
if (data.playerList?.length > 0) {
game.players = normalizePlayers(data.playerList);
}
const wasStarted = game.started;
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.gameStarted)) {
game.started = data.gameStarted;
}
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.activePlayerId)) {
game.activePlayerId = data.activePlayerId;
}
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.activePhase)) {
game.activePhase = data.activePhase;
}
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.secondsElapsed)) {
game.secondsElapsed = data.secondsElapsed;
}
if (!wasStarted && game.started) {
pushEventMessage(game, 0, formatGameStart());
}
},
playerJoined: (state, action: PayloadAction<{ gameId: number; playerProperties: Data.ServerInfo_PlayerProperties }>) => {
const { gameId, playerProperties } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
game.players[playerProperties.playerId] = {
properties: playerProperties,
deckList: '',
zones: {},
counters: {},
arrows: {},
};
pushEventMessage(game, playerProperties.playerId, formatPlayerJoined(game, playerProperties.playerId));
},
playerLeft: (
state,
action: PayloadAction<{ gameId: number; playerId: number; reason: number; timeReceived: number }>,
) => {
const { gameId, playerId, reason, timeReceived } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
const player = game.players[playerId];
const playerName = player?.properties.userInfo?.name ?? 'Unknown player';
if (game.messages.length >= MAX_GAME_MESSAGES) {
game.messages = game.messages.slice(game.messages.length - MAX_GAME_MESSAGES + 1);
}
game.messages.push({
playerId,
message: formatLeaveMessage(playerName, reason),
timeReceived,
kind: 'event',
});
delete game.players[playerId];
},
playerPropertiesChanged: (
state,
action: PayloadAction<{ gameId: number; playerId: number; properties: Data.ServerInfo_PlayerProperties }>,
) => {
const { gameId, playerId, properties } = action.payload;
const game = state.games[gameId];
const player = game?.players[playerId];
if (!game || !player) {
return;
}
const previous = { ...player.properties };
mergeSetFields(Data.ServerInfo_PlayerPropertiesSchema, player.properties, properties);
const diff = diffPlayerProperties(previous, player.properties);
for (const msg of formatPropertyDiff(game, playerId, diff)) {
pushEventMessage(game, playerId, msg);
}
},
cardMoved: (
state,
action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_MoveCard }>,
) => {
const { gameId, data } = action.payload;
const {
cardId, cardName, startPlayerId, startZone, position,
targetPlayerId, targetZone, x, y, newCardId, faceDown, newCardProviderId,
} = data;
const game = state.games[gameId];
if (!game) {
return;
}
const sourcePlayer = game.players[startPlayerId];
const sourceZone = sourcePlayer?.zones[startZone];
if (!sourcePlayer || !sourceZone) {
return;
}
const targetPlayer = game.players[targetPlayerId];
const targetZoneEntry = targetPlayer?.zones[targetZone];
if (!targetPlayer || !targetZoneEntry) {
return;
}
let resolvedCardId = -1;
if (cardId >= 0) {
resolvedCardId = cardId;
} else if (position >= 0 && position < sourceZone.order.length) {
resolvedCardId = sourceZone.order[position];
}
// If the card can't be resolved and no newCardId is provided, the event
// is malformed — bail out to avoid creating phantom cards with id -1.
if (resolvedCardId < 0 && newCardId < 0) {
return;
}
// Remove from source zone if the card was resolved to a known entry
let removedCard: Data.ServerInfo_Card | undefined;
if (resolvedCardId >= 0) {
removedCard = sourceZone.byId[resolvedCardId];
const idx = sourceZone.order.indexOf(resolvedCardId);
if (idx >= 0) {
sourceZone.order.splice(idx, 1);
}
delete sourceZone.byId[resolvedCardId];
}
sourceZone.cardCount = Math.max(0, sourceZone.cardCount - 1);
const effectiveNewId = newCardId >= 0 ? newCardId : (removedCard?.id ?? resolvedCardId);
const movedCard: Data.ServerInfo_Card = removedCard
? {
...removedCard, id: effectiveNewId, name: cardName || removedCard.name,
x, y, faceDown, providerId: newCardProviderId || removedCard.providerId,
counterList: [...removedCard.counterList],
}
: buildEmptyCard(effectiveNewId, cardName, x, y, faceDown, newCardProviderId ?? '');
targetZoneEntry.order.push(movedCard.id);
targetZoneEntry.byId[movedCard.id] = movedCard;
targetZoneEntry.cardCount++;
pushEventMessage(
game,
action.payload.playerId,
formatCardMoved(game, action.payload.playerId, data, {
resolvedCardName: removedCard?.name ?? '',
}),
);
},
cardFlipped: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_FlipCard }>) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId, cardName, faceDown, cardProviderId } = data;
const game = state.games[gameId];
const card = game?.players[playerId]?.zones[zoneName]?.byId[cardId];
if (!game || !card) {
return;
}
const previousName = card.name;
card.faceDown = faceDown;
if (cardName) {
card.name = cardName;
}
if (cardProviderId) {
card.providerId = cardProviderId;
}
pushEventMessage(game, playerId, formatCardFlipped(game, playerId, data, previousName));
},
cardDestroyed: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DestroyCard }>) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId } = data;
const game = state.games[gameId];
const zone = game?.players[playerId]?.zones[zoneName];
if (!game || !zone) {
return;
}
const destroyedName = zone.byId[cardId]?.name;
const idx = zone.order.indexOf(cardId);
if (idx >= 0) {
zone.order.splice(idx, 1);
}
delete zone.byId[cardId];
zone.cardCount = Math.max(0, zone.cardCount - 1);
pushEventMessage(game, playerId, formatCardDestroyed(game, playerId, destroyedName));
},
cardAttached: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_AttachCard }>) => {
const { gameId, playerId, data } = action.payload;
const { startZone, cardId, targetPlayerId, targetZone, targetCardId } = data;
const game = state.games[gameId];
const card = game?.players[playerId]?.zones[startZone]?.byId[cardId];
if (!game || !card) {
return;
}
const sourceCardName = card.name;
card.attachPlayerId = targetPlayerId;
card.attachZone = targetZone;
card.attachCardId = targetCardId;
pushEventMessage(game, playerId, formatCardAttached(game, playerId, data, sourceCardName));
},
tokenCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateToken }>) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId, cardName, color, pt, annotation, destroyOnZoneChange, x, y, cardProviderId, faceDown } = data;
const game = state.games[gameId];
const zone = game?.players[playerId]?.zones[zoneName];
if (!game || !zone) {
return;
}
const newCard = create(Data.ServerInfo_CardSchema, {
id: cardId, name: cardName, x, y, faceDown,
tapped: false, attacking: false, color, pt, annotation, destroyOnZoneChange,
doesntUntap: false, counterList: [],
attachPlayerId: -1, attachZone: '', attachCardId: -1, providerId: cardProviderId,
});
zone.order.push(newCard.id);
zone.byId[newCard.id] = newCard;
zone.cardCount++;
pushEventMessage(game, playerId, formatTokenCreated(game, playerId, data));
},
cardAttrChanged: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_SetCardAttr }>) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId, attribute, attrValue } = data;
const game = state.games[gameId];
const card = game?.players[playerId]?.zones[zoneName]?.byId[cardId];
if (!game || !card) {
return;
}
const cardName = card.name;
switch (attribute as Data.CardAttribute) {
case Data.CardAttribute.AttrTapped: card.tapped = attrValue === '1'; break;
case Data.CardAttribute.AttrAttacking: card.attacking = attrValue === '1'; break;
case Data.CardAttribute.AttrFaceDown: card.faceDown = attrValue === '1'; break;
case Data.CardAttribute.AttrColor: card.color = attrValue; break;
case Data.CardAttribute.AttrPT: card.pt = attrValue; break;
case Data.CardAttribute.AttrAnnotation: card.annotation = attrValue; break;
case Data.CardAttribute.AttrDoesntUntap: card.doesntUntap = attrValue === '1'; break;
}
pushEventMessage(game, playerId, formatCardAttrChanged(game, playerId, data, cardName));
},
cardCounterChanged: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_SetCardCounter }>) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cardId, counterId, counterValue } = data;
const game = state.games[gameId];
const card = game?.players[playerId]?.zones[zoneName]?.byId[cardId];
if (!game || !card) {
return;
}
const cardName = card.name;
const previousValue = card.counterList.find(c => c.id === counterId)?.value ?? 0;
if (counterValue <= 0) {
card.counterList = card.counterList.filter(c => c.id !== counterId);
} else {
const idx = card.counterList.findIndex(c => c.id === counterId);
if (idx >= 0) {
card.counterList[idx] = { ...card.counterList[idx], value: counterValue };
} else {
card.counterList.push(create(Data.ServerInfo_CardCounterSchema, { id: counterId, value: counterValue }));
}
}
pushEventMessage(
game,
playerId,
formatCardCounterChanged(game, playerId, data, cardName, previousValue),
);
},
arrowCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateArrow }>) => {
const { gameId, playerId, data } = action.payload;
const game = state.games[gameId];
const player = game?.players[playerId];
if (!game || !player || !data.arrowInfo) {
return;
}
player.arrows[data.arrowInfo.id] = { ...data.arrowInfo };
pushEventMessage(game, playerId, formatArrowCreated(game, playerId, data.arrowInfo));
},
arrowDeleted: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DeleteArrow }>) => {
const { gameId, playerId, data } = action.payload;
const player = state.games[gameId]?.players[playerId];
if (player) {
delete player.arrows[data.arrowId];
}
},
counterCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateCounter }>) => {
const { gameId, playerId, data } = action.payload;
const player = state.games[gameId]?.players[playerId];
if (player && data.counterInfo) {
player.counters[data.counterInfo.id] = { ...data.counterInfo };
}
},
counterSet: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_SetCounter }>) => {
const { gameId, playerId, data } = action.payload;
const game = state.games[gameId];
const counter = game?.players[playerId]?.counters[data.counterId];
if (!game || !counter) {
return;
}
const previousValue = counter.count;
counter.count = data.value;
pushEventMessage(
game,
playerId,
formatCounterSet(game, playerId, data, counter.name, previousValue),
);
},
counterDeleted: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DelCounter }>) => {
const { gameId, playerId, data } = action.payload;
const player = state.games[gameId]?.players[playerId];
if (player) {
delete player.counters[data.counterId];
}
},
cardsDrawn: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DrawCards }>) => {
const { gameId, playerId, data } = action.payload;
const { number: drawCount, cards } = data;
const game = state.games[gameId];
const player = game?.players[playerId];
if (!game || !player) {
return;
}
const deckZone = player.zones[Enriched.ZoneName.DECK];
const handZone = player.zones[Enriched.ZoneName.HAND];
if (!handZone) {
return;
}
if (deckZone) {
deckZone.cardCount = Math.max(0, deckZone.cardCount - drawCount);
}
for (const card of cards) {
handZone.order.push(card.id);
handZone.byId[card.id] = card;
}
handZone.cardCount += drawCount;
pushEventMessage(game, playerId, formatCardsDrawn(game, playerId, drawCount));
},
cardsRevealed: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_RevealCards }>) => {
const { gameId, playerId, data } = action.payload;
const { zoneName, cards } = data;
const zone = state.games[gameId]?.players[playerId]?.zones[zoneName];
if (!zone) {
return;
}
for (const revealedCard of cards) {
if (!zone.byId[revealedCard.id]) {
zone.order.push(revealedCard.id);
}
zone.byId[revealedCard.id] = { ...revealedCard, counterList: [...revealedCard.counterList] };
}
},
zonePropertiesChanged: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_ChangeZoneProperties }>) => {
const { gameId, playerId, data } = action.payload;
const game = state.games[gameId];
const zone = game?.players[playerId]?.zones[data.zoneName];
if (!game || !zone) {
return;
}
if (isFieldSet(data, Data.Event_ChangeZonePropertiesSchema.field.alwaysRevealTopCard)) {
zone.alwaysRevealTopCard = data.alwaysRevealTopCard;
}
if (isFieldSet(data, Data.Event_ChangeZonePropertiesSchema.field.alwaysLookAtTopCard)) {
zone.alwaysLookAtTopCard = data.alwaysLookAtTopCard;
}
pushEventMessage(game, playerId, formatZonePropertiesChanged(game, playerId, data));
},
activePlayerSet: (state, action: PayloadAction<{ gameId: number; activePlayerId: number }>) => {
const game = state.games[action.payload.gameId];
if (!game) {
return;
}
const previous = game.activePlayerId;
game.activePlayerId = action.payload.activePlayerId;
if (previous !== action.payload.activePlayerId) {
pushEventMessage(game, action.payload.activePlayerId, formatActivePlayerSet(game, action.payload.activePlayerId));
}
},
activePhaseSet: (state, action: PayloadAction<{ gameId: number; phase: number }>) => {
const game = state.games[action.payload.gameId];
if (!game) {
return;
}
const previous = game.activePhase;
game.activePhase = action.payload.phase;
if (previous !== action.payload.phase && game.started) {
pushEventMessage(game, 0, formatActivePhaseSet(action.payload.phase));
}
},
turnReversed: (state, action: PayloadAction<{ gameId: number; reversed: boolean }>) => {
const game = state.games[action.payload.gameId];
if (!game) {
return;
}
game.reversed = action.payload.reversed;
pushEventMessage(game, game.activePlayerId, formatTurnReversed(game, game.activePlayerId, action.payload.reversed));
},
gameSay: (state, action: PayloadAction<{ gameId: number; playerId: number; message: string; timeReceived: number }>) => {
const { gameId, playerId, message, timeReceived } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
if (game.messages.length >= MAX_GAME_MESSAGES) {
game.messages = game.messages.slice(game.messages.length - MAX_GAME_MESSAGES + 1);
}
game.messages.push({ playerId, message, timeReceived, kind: 'chat' });
},
// Logged-only actions: no state mutation but an event-log entry.
zoneShuffled: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_Shuffle }>) => {
const { gameId, playerId } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
pushEventMessage(game, playerId, formatZoneShuffled(game, playerId));
},
zoneDumped: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_DumpZone }>) => {
const { gameId, playerId, data } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
pushEventMessage(game, playerId, formatZoneDumped(game, playerId, data));
},
dieRolled: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_RollDie }>) => {
const { gameId, playerId, data } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
pushEventMessage(game, playerId, formatDieRolled(game, playerId, data));
},
}, },
}); });

View file

@ -0,0 +1,83 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { isFieldSet } from '@bufbuild/protobuf';
import { Data } from '@app/types';
import { GamesState } from './game.interfaces';
import { normalizePlayers, pushEventMessage } from './game.reducer.helpers';
import {
EVENT_PLAYER_ID_SYSTEM,
formatActivePhaseSet,
formatActivePlayerSet,
formatGameStart,
formatTurnReversed,
} from './messageLog';
export const turnReducers = {
gameHostChanged: ((state, action) => {
const { gameId, hostId } = action.payload;
const game = state.games[gameId];
if (game) {
game.hostId = hostId;
}
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; hostId: number }>>,
gameStateChanged: ((state, action) => {
const { gameId, data } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
}
if (data.playerList?.length > 0) {
game.players = normalizePlayers(data.playerList);
}
const wasStarted = game.started;
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.gameStarted)) {
game.started = data.gameStarted;
}
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.activePlayerId)) {
game.activePlayerId = data.activePlayerId;
}
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.activePhase)) {
game.activePhase = data.activePhase;
}
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.secondsElapsed)) {
game.secondsElapsed = data.secondsElapsed;
}
if (!wasStarted && game.started) {
pushEventMessage(game, EVENT_PLAYER_ID_SYSTEM, formatGameStart());
}
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; data: Data.Event_GameStateChanged }>>,
activePlayerSet: ((state, action) => {
const game = state.games[action.payload.gameId];
if (!game) {
return;
}
const previous = game.activePlayerId;
game.activePlayerId = action.payload.activePlayerId;
if (previous !== action.payload.activePlayerId) {
pushEventMessage(game, action.payload.activePlayerId, formatActivePlayerSet(game, action.payload.activePlayerId));
}
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; activePlayerId: number }>>,
activePhaseSet: ((state, action) => {
const game = state.games[action.payload.gameId];
if (!game) {
return;
}
const previous = game.activePhase;
game.activePhase = action.payload.phase;
if (previous !== action.payload.phase && game.started) {
pushEventMessage(game, EVENT_PLAYER_ID_SYSTEM, formatActivePhaseSet(action.payload.phase));
}
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; phase: number }>>,
turnReversed: ((state, action) => {
const game = state.games[action.payload.gameId];
if (!game) {
return;
}
game.reversed = action.payload.reversed;
pushEventMessage(game, game.activePlayerId, formatTurnReversed(game, game.activePlayerId, action.payload.reversed));
}) as CaseReducer<GamesState, PayloadAction<{ gameId: number; reversed: boolean }>>,
};

View file

@ -1,3 +1,5 @@
import { App } from '@app/types';
import { Selectors } from './game.selectors'; import { Selectors } from './game.selectors';
import { makeGameEntry, makePlayerEntry, makeState, import { makeGameEntry, makePlayerEntry, makeState,
makeZoneEntry, makeCard, makeCounter, makeArrow, makeZoneEntry, makeCard, makeCounter, makeArrow,
@ -178,6 +180,120 @@ describe('Selectors', () => {
expect(Selectors.getMessages(rootState(state), 999)).toEqual([]); expect(Selectors.getMessages(rootState(state), 999)).toEqual([]);
}); });
describe('getAttachmentsByParent', () => {
function stateWithTable(cards: ReturnType<typeof makeCard>[]): GamesState {
return makeState({
games: {
1: makeGameEntry({
players: {
1: makePlayerEntry({
zones: {
[App.ZoneName.TABLE]: makeZoneEntry({
name: App.ZoneName.TABLE,
withCoords: true,
cardCount: cards.length,
cards,
}),
},
}),
},
}),
},
});
}
it('returns an empty map when the TABLE zone is absent', () => {
const state = makeState();
const result = Selectors.getAttachmentsByParent(rootState(state), 1, 1);
expect(result.size).toBe(0);
});
it('returns an empty map when no cards are attached', () => {
const cards = [
makeCard({ id: 1, name: 'Creature A' }),
makeCard({ id: 2, name: 'Creature B' }),
];
const state = stateWithTable(cards);
const result = Selectors.getAttachmentsByParent(rootState(state), 1, 1);
expect(result.size).toBe(0);
});
it('buckets a single attached child under its parent id', () => {
const cards = [
makeCard({ id: 10, name: 'Creature' }),
makeCard({
id: 11, name: 'Aura',
attachPlayerId: 1, attachZone: App.ZoneName.TABLE, attachCardId: 10,
}),
];
const state = stateWithTable(cards);
const result = Selectors.getAttachmentsByParent(rootState(state), 1, 1);
expect(result.size).toBe(1);
expect(result.get(10)?.map((c) => c.name)).toEqual(['Aura']);
});
it('buckets multiple children under the same parent, sorted by id', () => {
const cards = [
makeCard({ id: 5, name: 'Creature' }),
makeCard({
id: 30, name: 'Aura C',
attachPlayerId: 1, attachZone: App.ZoneName.TABLE, attachCardId: 5,
}),
makeCard({
id: 10, name: 'Aura A',
attachPlayerId: 1, attachZone: App.ZoneName.TABLE, attachCardId: 5,
}),
makeCard({
id: 20, name: 'Aura B',
attachPlayerId: 1, attachZone: App.ZoneName.TABLE, attachCardId: 5,
}),
];
const state = stateWithTable(cards);
const result = Selectors.getAttachmentsByParent(rootState(state), 1, 1);
expect(result.get(5)?.map((c) => c.name)).toEqual(['Aura A', 'Aura B', 'Aura C']);
});
it('ignores attachments pointing to a different player', () => {
const cards = [
makeCard({ id: 1, name: 'Creature' }),
makeCard({
id: 2, name: 'Cross-player ref',
attachPlayerId: 99, attachZone: App.ZoneName.TABLE, attachCardId: 1,
}),
];
const state = stateWithTable(cards);
const result = Selectors.getAttachmentsByParent(rootState(state), 1, 1);
expect(result.size).toBe(0);
});
it('ignores attachments pointing to a non-TABLE zone', () => {
const cards = [
makeCard({ id: 1, name: 'Creature' }),
makeCard({
id: 2, name: 'Non-table ref',
attachPlayerId: 1, attachZone: App.ZoneName.HAND, attachCardId: 1,
}),
];
const state = stateWithTable(cards);
const result = Selectors.getAttachmentsByParent(rootState(state), 1, 1);
expect(result.size).toBe(0);
});
it('returns a stable Map reference for the same zone object', () => {
const cards = [
makeCard({ id: 1, name: 'Creature' }),
makeCard({
id: 2, name: 'Aura',
attachPlayerId: 1, attachZone: App.ZoneName.TABLE, attachCardId: 1,
}),
];
const state = stateWithTable(cards);
const a = Selectors.getAttachmentsByParent(rootState(state), 1, 1);
const b = Selectors.getAttachmentsByParent(rootState(state), 1, 1);
expect(a).toBe(b);
});
});
it('getActiveGameIds → returns numeric array of gameIds', () => { it('getActiveGameIds → returns numeric array of gameIds', () => {
const state = makeState({ const state = makeState({
games: { games: {
@ -189,4 +305,18 @@ describe('Selectors', () => {
expect(ids).toEqual(expect.arrayContaining([1, 2])); expect(ids).toEqual(expect.arrayContaining([1, 2]));
expect(ids).toHaveLength(2); expect(ids).toHaveLength(2);
}); });
it('getActiveGames → returns the full GameEntry array', () => {
const e1 = makeGameEntry();
const e2 = makeGameEntry();
const state = makeState({ games: { 1: e1, 2: e2 } });
const games = Selectors.getActiveGames(rootState(state));
expect(games).toHaveLength(2);
expect(games).toEqual(expect.arrayContaining([e1, e2]));
});
it('getActiveGames → returns empty array when no games are active', () => {
const state = makeState({ games: {} });
expect(Selectors.getActiveGames(rootState(state))).toHaveLength(0);
});
}); });

View file

@ -1,5 +1,5 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import type { Data, Enriched } from '@app/types'; import { App, type Data, type Enriched } from '@app/types';
import { GamesState } from './game.interfaces'; import { GamesState } from './game.interfaces';
interface State { interface State {
@ -8,6 +8,7 @@ interface State {
const EMPTY_ARRAY: Data.ServerInfo_Card[] = []; const EMPTY_ARRAY: Data.ServerInfo_Card[] = [];
const EMPTY_OBJECT = {} as Record<string, never>; const EMPTY_OBJECT = {} as Record<string, never>;
const EMPTY_ATTACHMENTS: ReadonlyMap<number, Data.ServerInfo_Card[]> = new Map();
/** /**
* Memoized cache for materialized zone card arrays. Keyed by the zone object * Memoized cache for materialized zone card arrays. Keyed by the zone object
@ -27,6 +28,47 @@ function materializeZoneCards(zone: Enriched.ZoneEntry): Data.ServerInfo_Card[]
return arr; return arr;
} }
/**
* Memoized cache for the parent-id attached-children map for a TABLE zone.
* Keyed by the zone identity so re-renders reuse the same Map reference unless
* the reducer has produced a new zone object.
*/
const attachmentsByParentCache = new WeakMap<
Enriched.ZoneEntry,
ReadonlyMap<number, Data.ServerInfo_Card[]>
>();
function materializeAttachmentsByParent(
zone: Enriched.ZoneEntry,
playerId: number,
): ReadonlyMap<number, Data.ServerInfo_Card[]> {
const cached = attachmentsByParentCache.get(zone);
if (cached) {
return cached;
}
const map = new Map<number, Data.ServerInfo_Card[]>();
for (const card of materializeZoneCards(zone)) {
// Only the webclient-owned parent-pointer side of attachment is encoded:
// desktop maintains a child list, but here every child points up via
// attachCardId. Skip unattached cards and any pointer to a different
// player / zone (the protocol allows cross-player attach).
if (card.attachCardId == null || card.attachCardId === -1) continue;
if (card.attachPlayerId !== playerId) continue;
if (card.attachZone !== App.ZoneName.TABLE) continue;
let bucket = map.get(card.attachCardId);
if (!bucket) {
bucket = [];
map.set(card.attachCardId, bucket);
}
bucket.push(card);
}
for (const bucket of map.values()) {
bucket.sort((a, b) => a.id - b.id);
}
attachmentsByParentCache.set(zone, map);
return map;
}
export const Selectors = { export const Selectors = {
getGames: ({ games }: State): { [gameId: number]: Enriched.GameEntry } => games.games, getGames: ({ games }: State): { [gameId: number]: Enriched.GameEntry } => games.games,
@ -68,6 +110,15 @@ export const Selectors = {
return zone ? materializeZoneCards(zone) : EMPTY_ARRAY; return zone ? materializeZoneCards(zone) : EMPTY_ARRAY;
}, },
getAttachmentsByParent: (
{ games }: State,
gameId: number,
playerId: number,
): ReadonlyMap<number, Data.ServerInfo_Card[]> => {
const zone = games.games[gameId]?.players[playerId]?.zones[App.ZoneName.TABLE];
return zone ? materializeAttachmentsByParent(zone, playerId) : EMPTY_ATTACHMENTS;
},
getCounters: ({ games }: State, gameId: number, playerId: number) => getCounters: ({ games }: State, gameId: number, playerId: number) =>
games.games[gameId]?.players[playerId]?.counters ?? EMPTY_OBJECT, games.games[gameId]?.players[playerId]?.counters ?? EMPTY_OBJECT,
@ -108,4 +159,9 @@ export const Selectors = {
[({ games }: State) => games.games], [({ games }: State) => games.games],
(games) => Object.keys(games).map(Number) (games) => Object.keys(games).map(Number)
), ),
getActiveGames: createSelector(
[({ games }: State) => games.games],
(games): Enriched.GameEntry[] => Object.values(games)
),
}; };

View file

@ -266,6 +266,18 @@ describe('formatActivePhaseSet / formatActivePlayerSet / formatTurnReversed', ()
expect(formatTurnReversed(game, 1, true)).toBe('Alice reverses the turn order.'); expect(formatTurnReversed(game, 1, true)).toBe('Alice reverses the turn order.');
expect(formatTurnReversed(game, 1, false)).toBe('Alice restores the turn order.'); expect(formatTurnReversed(game, 1, false)).toBe('Alice restores the turn order.');
}); });
// Regression: player 0 used to render as "The server" because of a
// playerId <= 0 guard in nameOf.
it('attributes player 0 as the active player by name, not "The server"', () => {
const gameWithHost = makeGameEntry({
players: {
0: makePlayerEntry({
properties: makePlayerProperties({ playerId: 0, userInfo: { name: 'Host' } }),
}),
},
});
expect(formatActivePlayerSet(gameWithHost, 0)).toBe('It is now Host\'s turn.');
});
}); });
describe('formatDieRolled', () => { describe('formatDieRolled', () => {
@ -279,6 +291,20 @@ describe('formatDieRolled', () => {
it('no-rolls falls back to bare sides', () => { it('no-rolls falls back to bare sides', () => {
expect(formatDieRolled(game, 1, { sides: 20, value: 0, values: [] })).toBe('Alice rolls a 20-sided die.'); expect(formatDieRolled(game, 1, { sides: 20, value: 0, values: [] })).toBe('Alice rolls a 20-sided die.');
}); });
// Regression: server player ids start at 0, so the host is usually player 0.
// The log helper used to treat playerId <= 0 as a "system" sentinel and
// render "The server rolled a die" for that player's rolls.
it('attributes player 0 by name, not "The server"', () => {
const gameWithHost = makeGameEntry({
players: {
0: makePlayerEntry({
properties: makePlayerProperties({ playerId: 0, userInfo: { name: 'Host' } }),
}),
},
});
expect(formatDieRolled(gameWithHost, 0, { sides: 20, value: 17, values: [17] }))
.toBe('Host rolls a 17 on a 20-sided die.');
});
}); });
describe('formatArrowCreated', () => { describe('formatArrowCreated', () => {

View file

@ -7,10 +7,13 @@ import { App, Data } from '@app/types';
// return a formatted English string, or null when desktop does not log that // return a formatted English string, or null when desktop does not log that
// case (e.g. same-zone table reorders). // case (e.g. same-zone table reorders).
export const EVENT_PLAYER_ID_SYSTEM = 0; // -1 matches the proto2 default for GameEvent.player_id — the on-wire "no
// actor" sentinel. Must NOT be 0, which is a valid player id (the server
// assigns ids starting at 0, see server_game.cpp nextPlayerId).
export const EVENT_PLAYER_ID_SYSTEM = -1;
function nameOf(game: Enriched.GameEntry, playerId: number): string { function nameOf(game: Enriched.GameEntry, playerId: number): string {
if (playerId <= 0) { if (playerId < 0) {
return 'The server'; return 'The server';
} }
return game.players[playerId]?.properties.userInfo?.name ?? `Player ${playerId}`; return game.players[playerId]?.properties.userInfo?.name ?? `Player ${playerId}`;

View file

@ -0,0 +1,31 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { create } from '@bufbuild/protobuf';
import { Data } from '@app/types';
import { normalizeBannedUserError } from '../common';
import { ServerState } from './server.interfaces';
export const accountReducers = {
registrationFailed: ((state, action) => {
const { reason, endTime } = action.payload;
const error = endTime
? normalizeBannedUserError(reason, endTime)
: reason;
state.registrationError = error;
}) as CaseReducer<ServerState, PayloadAction<{ reason: string; endTime?: number }>>,
clearRegistrationErrors: ((state) => {
state.registrationError = null;
}) as CaseReducer<ServerState>,
accountEditChanged: ((state, action) => {
if (state.user) {
state.user = create(Data.ServerInfo_UserSchema, { ...state.user, ...action.payload.user });
}
}) as CaseReducer<ServerState, PayloadAction<{ user: Partial<Data.ServerInfo_User> }>>,
accountImageChanged: ((state, action) => {
if (state.user) {
state.user = create(Data.ServerInfo_UserSchema, { ...state.user, ...action.payload.user });
}
}) as CaseReducer<ServerState, PayloadAction<{ user: Partial<Data.ServerInfo_User> }>>,
};

View file

@ -0,0 +1,39 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { Data } from '@app/types';
import { ServerState } from './server.interfaces';
export const buddyReducers = {
updateBuddyList: ((state, action) => {
const buddyList: { [userName: string]: Data.ServerInfo_User } = {};
for (const user of action.payload.buddyList) {
buddyList[user.name] = user;
}
state.buddyList = buddyList;
}) as CaseReducer<ServerState, PayloadAction<{ buddyList: Data.ServerInfo_User[] }>>,
addToBuddyList: ((state, action) => {
const { user } = action.payload;
state.buddyList[user.name] = user;
}) as CaseReducer<ServerState, PayloadAction<{ user: Data.ServerInfo_User }>>,
removeFromBuddyList: ((state, action) => {
delete state.buddyList[action.payload.userName];
}) as CaseReducer<ServerState, PayloadAction<{ userName: string }>>,
updateIgnoreList: ((state, action) => {
const ignoreList: { [userName: string]: Data.ServerInfo_User } = {};
for (const user of action.payload.ignoreList) {
ignoreList[user.name] = user;
}
state.ignoreList = ignoreList;
}) as CaseReducer<ServerState, PayloadAction<{ ignoreList: Data.ServerInfo_User[] }>>,
addToIgnoreList: ((state, action) => {
const { user } = action.payload;
state.ignoreList[user.name] = user;
}) as CaseReducer<ServerState, PayloadAction<{ user: Data.ServerInfo_User }>>,
removeFromIgnoreList: ((state, action) => {
delete state.ignoreList[action.payload.userName];
}) as CaseReducer<ServerState, PayloadAction<{ userName: string }>>,
};

View file

@ -0,0 +1,105 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { App, Data } from '@app/types';
import { WebsocketTypes } from '@app/websocket/types';
import { ServerState, ServerStateStatus } from './server.interfaces';
export const initialState: ServerState = {
initialized: false,
testConnectionStatus: null,
buddyList: {},
ignoreList: {},
status: {
connectionAttemptMade: false,
state: WebsocketTypes.StatusEnum.DISCONNECTED,
description: null
},
info: {
message: null,
name: null,
version: null
},
logs: {
room: [],
game: [],
chat: []
},
user: null,
users: {},
sortUsersBy: {
field: App.UserSortField.NAME,
order: App.SortDirection.ASC
},
messages: {},
userInfo: {},
notifications: [],
serverShutdown: null,
banUser: '',
banHistory: {},
warnHistory: {},
warnListOptions: [],
warnUser: '',
adminNotes: {},
replays: {},
backendDecks: null,
downloadedDeck: null,
downloadedReplay: null,
gamesOfUser: {},
registrationError: null,
};
export const connectionReducers = {
initialized: (() => ({
...initialState,
initialized: true,
})) as CaseReducer<ServerState>,
connectionAttempted: ((state) => {
state.status.connectionAttemptMade = true;
}) as CaseReducer<ServerState>,
testConnectionStarted: ((state) => {
state.testConnectionStatus = 'testing';
}) as CaseReducer<ServerState>,
// `supportsHashedPassword` is typed on the action so `useReduxEffect`
// subscribers (see useKnownHostsComponent) can persist it to the host
// record in Dexie. It's deliberately not stored in redux state since
// only the lifecycle matters here; per-host capability lives in Dexie.
testConnectionSuccessful: ((state, _action) => {
state.testConnectionStatus = 'success';
}) as CaseReducer<ServerState, PayloadAction<{ supportsHashedPassword: boolean }>>,
testConnectionFailed: ((state) => {
state.testConnectionStatus = 'failed';
}) as CaseReducer<ServerState>,
clearStore: ((state) => ({
...initialState,
status: { ...state.status },
})) as CaseReducer<ServerState>,
serverMessage: ((state, action) => {
state.info.message = action.payload.message;
}) as CaseReducer<ServerState, PayloadAction<{ message: string }>>,
updateInfo: ((state, action) => {
const { name, version } = action.payload.info;
state.info.name = name;
state.info.version = version;
}) as CaseReducer<ServerState, PayloadAction<{ info: { name: string; version: string } }>>,
updateStatus: ((state, action) => {
const { status } = action.payload;
state.status.state = status.state;
state.status.description = status.description;
if (status.state === WebsocketTypes.StatusEnum.DISCONNECTED) {
state.status.connectionAttemptMade = false;
}
}) as CaseReducer<ServerState, PayloadAction<{ status: Pick<ServerStateStatus, 'state' | 'description'> }>>,
serverShutdown: ((state, action) => {
state.serverShutdown = action.payload.data;
}) as CaseReducer<ServerState, PayloadAction<{ data: Data.Event_ServerShutdown }>>,
};

View file

@ -0,0 +1,111 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { create } from '@bufbuild/protobuf';
import { Data } from '@app/types';
import { ServerState } from './server.interfaces';
function splitPath(path: string): string[] {
return path ? path.split('/') : [];
}
function insertAtPath(
folder: Data.ServerInfo_DeckStorage_Folder,
pathSegments: string[],
item: Data.ServerInfo_DeckStorage_TreeItem,
): Data.ServerInfo_DeckStorage_Folder {
if (pathSegments.length === 0 || (pathSegments.length === 1 && pathSegments[0] === '')) {
return create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [...folder.items, item] });
}
const [head, ...tail] = pathSegments;
const match = folder.items.find(child => child.name === head && child.folder);
if (match) {
return create(Data.ServerInfo_DeckStorage_FolderSchema, {
items: folder.items.map(child =>
child === match
? { ...child, folder: insertAtPath(child.folder!, tail, item) }
: child
),
});
}
const created: Data.ServerInfo_DeckStorage_TreeItem = create(Data.ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: head, folder: insertAtPath(create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [] }), tail, item)
});
return create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [...folder.items, created] });
}
function removeById(folder: Data.ServerInfo_DeckStorage_Folder, id: number): Data.ServerInfo_DeckStorage_Folder {
return create(Data.ServerInfo_DeckStorage_FolderSchema, {
items: folder.items
.filter(item => item.id !== id)
.map(item =>
item.folder ? { ...item, folder: removeById(item.folder, id) } : item
),
});
}
function removeByPath(folder: Data.ServerInfo_DeckStorage_Folder, pathSegments: string[]): Data.ServerInfo_DeckStorage_Folder {
if (pathSegments.length === 0 || (pathSegments.length === 1 && pathSegments[0] === '')) {
return folder;
}
const [head, ...tail] = pathSegments;
if (tail.length === 0) {
return create(Data.ServerInfo_DeckStorage_FolderSchema, {
items: folder.items.filter(item => !(item.name === head && item.folder != null))
});
}
return create(Data.ServerInfo_DeckStorage_FolderSchema, {
items: folder.items.map(item =>
item.name === head && item.folder
? { ...item, folder: removeByPath(item.folder, tail) }
: item
),
});
}
export const deckReducers = {
backendDecks: ((state, action) => {
state.backendDecks = action.payload.deckList;
}) as CaseReducer<ServerState, PayloadAction<{ deckList: Data.Response_DeckList }>>,
deckUpload: ((state, action) => {
if (!state.backendDecks?.root) {
return;
}
state.backendDecks = create(Data.Response_DeckListSchema, {
root: insertAtPath(state.backendDecks.root, splitPath(action.payload.path), action.payload.treeItem),
});
}) as CaseReducer<ServerState, PayloadAction<{ path: string; treeItem: Data.ServerInfo_DeckStorage_TreeItem }>>,
deckDelete: ((state, action) => {
if (!state.backendDecks?.root) {
return;
}
state.backendDecks = create(Data.Response_DeckListSchema, {
root: removeById(state.backendDecks.root, action.payload.deckId),
});
}) as CaseReducer<ServerState, PayloadAction<{ deckId: number }>>,
deckNewDir: ((state, action) => {
if (!state.backendDecks?.root) {
return;
}
const newFolder: Data.ServerInfo_DeckStorage_TreeItem = create(Data.ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: action.payload.dirName, folder: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [] })
});
state.backendDecks = create(Data.Response_DeckListSchema, {
root: insertAtPath(state.backendDecks.root, splitPath(action.payload.path), newFolder),
});
}) as CaseReducer<ServerState, PayloadAction<{ path: string; dirName: string }>>,
deckDelDir: ((state, action) => {
if (!state.backendDecks?.root) {
return;
}
state.backendDecks = create(Data.Response_DeckListSchema, {
root: removeByPath(state.backendDecks.root, splitPath(action.payload.path)),
});
}) as CaseReducer<ServerState, PayloadAction<{ path: string }>>,
deckDownloaded: ((state, action) => {
state.downloadedDeck = action.payload;
}) as CaseReducer<ServerState, PayloadAction<{ deckId: number; deck: string }>>,
};

View file

@ -0,0 +1,58 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { Data } from '@app/types';
import { normalizeLogs } from '../common';
import { ServerState } from './server.interfaces';
export const moderationReducers = {
banFromServer: ((state, action) => {
state.banUser = action.payload.userName;
}) as CaseReducer<ServerState, PayloadAction<{ userName: string }>>,
banHistory: ((state, action) => {
state.banHistory[action.payload.userName] = action.payload.banHistory;
}) as CaseReducer<ServerState, PayloadAction<{ userName: string; banHistory: Data.ServerInfo_Ban[] }>>,
warnHistory: ((state, action) => {
state.warnHistory[action.payload.userName] = action.payload.warnHistory;
}) as CaseReducer<ServerState, PayloadAction<{ userName: string; warnHistory: Data.ServerInfo_Warning[] }>>,
warnListOptions: ((state, action) => {
state.warnListOptions = action.payload.warnList;
}) as CaseReducer<ServerState, PayloadAction<{ warnList: Data.Response_WarnList[] }>>,
warnUser: ((state, action) => {
state.warnUser = action.payload.userName;
}) as CaseReducer<ServerState, PayloadAction<{ userName: string }>>,
getAdminNotes: ((state, action) => {
state.adminNotes[action.payload.userName] = action.payload.notes;
}) as CaseReducer<ServerState, PayloadAction<{ userName: string; notes: string }>>,
updateAdminNotes: ((state, action) => {
state.adminNotes[action.payload.userName] = action.payload.notes;
}) as CaseReducer<ServerState, PayloadAction<{ userName: string; notes: string }>>,
adjustMod: ((state, action) => {
const { userName, shouldBeMod, shouldBeJudge } = action.payload;
const user = state.users[userName];
if (!user) {
return;
}
let newLevel = user.userLevel;
newLevel = shouldBeMod
? (newLevel | Data.ServerInfo_User_UserLevelFlag.IsModerator)
: (newLevel & ~Data.ServerInfo_User_UserLevelFlag.IsModerator);
newLevel = shouldBeJudge
? (newLevel | Data.ServerInfo_User_UserLevelFlag.IsJudge)
: (newLevel & ~Data.ServerInfo_User_UserLevelFlag.IsJudge);
user.userLevel = newLevel;
}) as CaseReducer<ServerState, PayloadAction<{ userName: string; shouldBeMod: boolean; shouldBeJudge: boolean }>>,
viewLogs: ((state, action) => {
state.logs = normalizeLogs(action.payload.logs);
}) as CaseReducer<ServerState, PayloadAction<{ logs: Data.ServerInfo_ChatMessage[] }>>,
clearLogs: ((state) => {
state.logs = { room: [], game: [], chat: [] };
}) as CaseReducer<ServerState>,
};

View file

@ -0,0 +1,35 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { Data } from '@app/types';
import { ServerState } from './server.interfaces';
export const replayReducers = {
replayList: ((state, action) => {
const replays: { [gameId: number]: Data.ServerInfo_ReplayMatch } = {};
for (const match of action.payload.matchList) {
replays[match.gameId] = match;
}
state.replays = replays;
}) as CaseReducer<ServerState, PayloadAction<{ matchList: Data.ServerInfo_ReplayMatch[] }>>,
replayAdded: ((state, action) => {
const { matchInfo } = action.payload;
state.replays[matchInfo.gameId] = matchInfo;
}) as CaseReducer<ServerState, PayloadAction<{ matchInfo: Data.ServerInfo_ReplayMatch }>>,
replayModifyMatch: ((state, action) => {
const { gameId, doNotHide } = action.payload;
const existing = state.replays[gameId];
if (!existing) {
return;
}
existing.doNotHide = doNotHide;
}) as CaseReducer<ServerState, PayloadAction<{ gameId: number; doNotHide: boolean }>>,
replayDeleteMatch: ((state, action) => {
delete state.replays[action.payload.gameId];
}) as CaseReducer<ServerState, PayloadAction<{ gameId: number }>>,
replayDownloaded: ((state, action) => {
state.downloadedReplay = action.payload;
}) as CaseReducer<ServerState, PayloadAction<{ replayId: number; replayData: Uint8Array }>>,
};

View file

@ -1,424 +1,26 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { App, Data, Enriched } from '@app/types';
import { WebsocketTypes } from '@app/websocket/types';
import { create } from '@bufbuild/protobuf';
import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, normalizeLogs } from '../common'; import { accountReducers } from './server.reducer.account';
import { buddyReducers } from './server.reducer.buddies';
import { connectionReducers, initialState } from './server.reducer.connection';
import { deckReducers } from './server.reducer.decks';
import { moderationReducers } from './server.reducer.moderation';
import { replayReducers } from './server.reducer.replays';
import { userReducers } from './server.reducer.users';
import { ServerState, ServerStateStatus } from './server.interfaces'; export { MAX_USER_MESSAGES } from './server.reducer.users';
export const MAX_USER_MESSAGES = 1000;
function splitPath(path: string): string[] {
return path ? path.split('/') : [];
}
function insertAtPath(
folder: Data.ServerInfo_DeckStorage_Folder,
pathSegments: string[],
item: Data.ServerInfo_DeckStorage_TreeItem,
): Data.ServerInfo_DeckStorage_Folder {
if (pathSegments.length === 0 || (pathSegments.length === 1 && pathSegments[0] === '')) {
return create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [...folder.items, item] });
}
const [head, ...tail] = pathSegments;
const match = folder.items.find(child => child.name === head && child.folder);
if (match) {
return create(Data.ServerInfo_DeckStorage_FolderSchema, {
items: folder.items.map(child =>
child === match
? { ...child, folder: insertAtPath(child.folder!, tail, item) }
: child
),
});
}
const created: Data.ServerInfo_DeckStorage_TreeItem = create(Data.ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: head, folder: insertAtPath(create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [] }), tail, item)
});
return create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [...folder.items, created] });
}
function removeById(folder: Data.ServerInfo_DeckStorage_Folder, id: number): Data.ServerInfo_DeckStorage_Folder {
return create(Data.ServerInfo_DeckStorage_FolderSchema, {
items: folder.items
.filter(item => item.id !== id)
.map(item =>
item.folder ? { ...item, folder: removeById(item.folder, id) } : item
),
});
}
function removeByPath(folder: Data.ServerInfo_DeckStorage_Folder, pathSegments: string[]): Data.ServerInfo_DeckStorage_Folder {
if (pathSegments.length === 0 || (pathSegments.length === 1 && pathSegments[0] === '')) {
return folder;
}
const [head, ...tail] = pathSegments;
if (tail.length === 0) {
return create(Data.ServerInfo_DeckStorage_FolderSchema, {
items: folder.items.filter(item => !(item.name === head && item.folder != null))
});
}
return create(Data.ServerInfo_DeckStorage_FolderSchema, {
items: folder.items.map(item =>
item.name === head && item.folder
? { ...item, folder: removeByPath(item.folder, tail) }
: item
),
});
}
const initialState: ServerState = {
initialized: false,
testConnectionStatus: null,
buddyList: {},
ignoreList: {},
status: {
connectionAttemptMade: false,
state: WebsocketTypes.StatusEnum.DISCONNECTED,
description: null
},
info: {
message: null,
name: null,
version: null
},
logs: {
room: [],
game: [],
chat: []
},
user: null,
users: {},
sortUsersBy: {
field: App.UserSortField.NAME,
order: App.SortDirection.ASC
},
messages: {},
userInfo: {},
notifications: [],
serverShutdown: null,
banUser: '',
banHistory: {},
warnHistory: {},
warnListOptions: [],
warnUser: '',
adminNotes: {},
replays: {},
backendDecks: null,
downloadedDeck: null,
downloadedReplay: null,
gamesOfUser: {},
registrationError: null,
};
export const serverSlice = createSlice({ export const serverSlice = createSlice({
name: 'server', name: 'server',
initialState, initialState,
reducers: { reducers: {
initialized: () => ({ ...connectionReducers,
...initialState, ...buddyReducers,
initialized: true, ...userReducers,
}), ...moderationReducers,
...replayReducers,
connectionAttempted: (state) => { ...deckReducers,
state.status.connectionAttemptMade = true; ...accountReducers,
},
testConnectionStarted: (state) => {
state.testConnectionStatus = 'testing';
},
// `supportsHashedPassword` is typed on the action so `useReduxEffect`
// subscribers (see useKnownHostsComponent) can persist it to the host
// record in Dexie. It's deliberately not stored in redux state since
// only the lifecycle matters here; per-host capability lives in Dexie.
testConnectionSuccessful: (state, _action: PayloadAction<{ supportsHashedPassword: boolean }>) => {
state.testConnectionStatus = 'success';
},
testConnectionFailed: (state) => {
state.testConnectionStatus = 'failed';
},
clearStore: (state) => ({
...initialState,
status: { ...state.status },
}),
serverMessage: (state, action: PayloadAction<{ message: string }>) => {
state.info.message = action.payload.message;
},
updateBuddyList: (state, action: PayloadAction<{ buddyList: Data.ServerInfo_User[] }>) => {
const buddyList: { [userName: string]: Data.ServerInfo_User } = {};
for (const user of action.payload.buddyList) {
buddyList[user.name] = user;
}
state.buddyList = buddyList;
},
addToBuddyList: (state, action: PayloadAction<{ user: Data.ServerInfo_User }>) => {
const { user } = action.payload;
state.buddyList[user.name] = user;
},
removeFromBuddyList: (state, action: PayloadAction<{ userName: string }>) => {
delete state.buddyList[action.payload.userName];
},
updateIgnoreList: (state, action: PayloadAction<{ ignoreList: Data.ServerInfo_User[] }>) => {
const ignoreList: { [userName: string]: Data.ServerInfo_User } = {};
for (const user of action.payload.ignoreList) {
ignoreList[user.name] = user;
}
state.ignoreList = ignoreList;
},
addToIgnoreList: (state, action: PayloadAction<{ user: Data.ServerInfo_User }>) => {
const { user } = action.payload;
state.ignoreList[user.name] = user;
},
removeFromIgnoreList: (state, action: PayloadAction<{ userName: string }>) => {
delete state.ignoreList[action.payload.userName];
},
updateInfo: (state, action: PayloadAction<{ info: { name: string; version: string } }>) => {
const { name, version } = action.payload.info;
state.info.name = name;
state.info.version = version;
},
updateStatus: (state, action: PayloadAction<{ status: Pick<ServerStateStatus, 'state' | 'description'> }>) => {
const { status } = action.payload;
state.status.state = status.state;
state.status.description = status.description;
if (status.state === WebsocketTypes.StatusEnum.DISCONNECTED) {
state.status.connectionAttemptMade = false;
}
},
updateUser: (state, action: PayloadAction<{ user: Partial<Data.ServerInfo_User> }>) => {
if (state.user) {
state.user = create(Data.ServerInfo_UserSchema, { ...state.user, ...action.payload.user });
} else {
state.user = action.payload.user as Data.ServerInfo_User;
}
},
updateUsers: (state, action: PayloadAction<{ users: Data.ServerInfo_User[] }>) => {
const users: { [userName: string]: Data.ServerInfo_User } = {};
for (const user of action.payload.users) {
users[user.name] = user;
}
state.users = users;
},
userJoined: (state, action: PayloadAction<{ user: Data.ServerInfo_User }>) => {
const { user } = action.payload;
state.users[user.name] = user;
},
userLeft: (state, action: PayloadAction<{ name: string }>) => {
delete state.users[action.payload.name];
},
viewLogs: (state, action: PayloadAction<{ logs: Data.ServerInfo_ChatMessage[] }>) => {
state.logs = normalizeLogs(action.payload.logs);
},
clearLogs: (state) => {
state.logs = { ...initialState.logs };
},
userMessage: (state, action: PayloadAction<{ messageData: Data.Event_UserMessage }>) => {
if (!state.user) {
return;
}
const { senderName, receiverName } = action.payload.messageData;
const userName = state.user.name === senderName ? receiverName : senderName;
if (!state.messages[userName]) {
state.messages[userName] = [];
}
const msgs = state.messages[userName];
if (msgs.length >= MAX_USER_MESSAGES) {
state.messages[userName] = msgs.slice(msgs.length - MAX_USER_MESSAGES + 1);
}
state.messages[userName].push(action.payload.messageData);
},
getUserInfo: (state, action: PayloadAction<{ userInfo: Data.ServerInfo_User }>) => {
const { userInfo } = action.payload;
state.userInfo[userInfo.name] = userInfo;
},
notifyUser: (state, action: PayloadAction<{ notification: Data.Event_NotifyUser }>) => {
state.notifications.push(action.payload.notification);
},
serverShutdown: (state, action: PayloadAction<{ data: Data.Event_ServerShutdown }>) => {
state.serverShutdown = action.payload.data;
},
banFromServer: (state, action: PayloadAction<{ userName: string }>) => {
state.banUser = action.payload.userName;
},
banHistory: (state, action: PayloadAction<{ userName: string; banHistory: Data.ServerInfo_Ban[] }>) => {
state.banHistory[action.payload.userName] = action.payload.banHistory;
},
warnHistory: (state, action: PayloadAction<{ userName: string; warnHistory: Data.ServerInfo_Warning[] }>) => {
state.warnHistory[action.payload.userName] = action.payload.warnHistory;
},
warnListOptions: (state, action: PayloadAction<{ warnList: Data.Response_WarnList[] }>) => {
state.warnListOptions = action.payload.warnList;
},
warnUser: (state, action: PayloadAction<{ userName: string }>) => {
state.warnUser = action.payload.userName;
},
getAdminNotes: (state, action: PayloadAction<{ userName: string; notes: string }>) => {
state.adminNotes[action.payload.userName] = action.payload.notes;
},
updateAdminNotes: (state, action: PayloadAction<{ userName: string; notes: string }>) => {
state.adminNotes[action.payload.userName] = action.payload.notes;
},
adjustMod: (state, action: PayloadAction<{ userName: string; shouldBeMod: boolean; shouldBeJudge: boolean }>) => {
const { userName, shouldBeMod, shouldBeJudge } = action.payload;
const user = state.users[userName];
if (!user) {
return;
}
let newLevel = user.userLevel;
newLevel = shouldBeMod
? (newLevel | Data.ServerInfo_User_UserLevelFlag.IsModerator)
: (newLevel & ~Data.ServerInfo_User_UserLevelFlag.IsModerator);
newLevel = shouldBeJudge
? (newLevel | Data.ServerInfo_User_UserLevelFlag.IsJudge)
: (newLevel & ~Data.ServerInfo_User_UserLevelFlag.IsJudge);
user.userLevel = newLevel;
},
replayList: (state, action: PayloadAction<{ matchList: Data.ServerInfo_ReplayMatch[] }>) => {
const replays: { [gameId: number]: Data.ServerInfo_ReplayMatch } = {};
for (const match of action.payload.matchList) {
replays[match.gameId] = match;
}
state.replays = replays;
},
replayAdded: (state, action: PayloadAction<{ matchInfo: Data.ServerInfo_ReplayMatch }>) => {
const { matchInfo } = action.payload;
state.replays[matchInfo.gameId] = matchInfo;
},
replayModifyMatch: (state, action: PayloadAction<{ gameId: number; doNotHide: boolean }>) => {
const { gameId, doNotHide } = action.payload;
const existing = state.replays[gameId];
if (!existing) {
return;
}
existing.doNotHide = doNotHide;
},
replayDeleteMatch: (state, action: PayloadAction<{ gameId: number }>) => {
delete state.replays[action.payload.gameId];
},
backendDecks: (state, action: PayloadAction<{ deckList: Data.Response_DeckList }>) => {
state.backendDecks = action.payload.deckList;
},
deckUpload: (state, action: PayloadAction<{ path: string; treeItem: Data.ServerInfo_DeckStorage_TreeItem }>) => {
if (!state.backendDecks?.root) {
return;
}
state.backendDecks = create(Data.Response_DeckListSchema, {
root: insertAtPath(state.backendDecks.root, splitPath(action.payload.path), action.payload.treeItem),
});
},
deckDelete: (state, action: PayloadAction<{ deckId: number }>) => {
if (!state.backendDecks?.root) {
return;
}
state.backendDecks = create(Data.Response_DeckListSchema, {
root: removeById(state.backendDecks.root, action.payload.deckId),
});
},
deckNewDir: (state, action: PayloadAction<{ path: string; dirName: string }>) => {
if (!state.backendDecks?.root) {
return;
}
const newFolder: Data.ServerInfo_DeckStorage_TreeItem = create(Data.ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: action.payload.dirName, folder: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [] })
});
state.backendDecks = create(Data.Response_DeckListSchema, {
root: insertAtPath(state.backendDecks.root, splitPath(action.payload.path), newFolder),
});
},
deckDelDir: (state, action: PayloadAction<{ path: string }>) => {
if (!state.backendDecks?.root) {
return;
}
state.backendDecks = create(Data.Response_DeckListSchema, {
root: removeByPath(state.backendDecks.root, splitPath(action.payload.path)),
});
},
deckDownloaded: (state, action: PayloadAction<{ deckId: number; deck: string }>) => {
state.downloadedDeck = action.payload;
},
replayDownloaded: (state, action: PayloadAction<{ replayId: number; replayData: Uint8Array }>) => {
state.downloadedReplay = action.payload;
},
gamesOfUser: (state, action: PayloadAction<{ userName: string; response: Data.Response_GetGamesOfUser }>) => {
const { userName, response } = action.payload;
const gametypeMap = normalizeGametypeMap(
(response.roomList ?? []).flatMap(room => room.gametypeList ?? [])
);
const games: { [gameId: number]: Enriched.Game } = {};
for (const g of response.gameList ?? []) {
const normalized = normalizeGameObject(g, gametypeMap);
games[normalized.info.gameId] = normalized;
}
state.gamesOfUser[userName] = games;
},
registrationFailed: (state, action: PayloadAction<{ reason: string; endTime?: number }>) => {
const { reason, endTime } = action.payload;
const error = endTime
? normalizeBannedUserError(reason, endTime)
: reason;
state.registrationError = error;
},
clearRegistrationErrors: (state) => {
state.registrationError = null;
},
accountEditChanged: (state, action: PayloadAction<{ user: Partial<Data.ServerInfo_User> }>) => {
if (state.user) {
state.user = create(Data.ServerInfo_UserSchema, { ...state.user, ...action.payload.user });
}
},
accountImageChanged: (state, action: PayloadAction<{ user: Partial<Data.ServerInfo_User> }>) => {
if (state.user) {
state.user = create(Data.ServerInfo_UserSchema, { ...state.user, ...action.payload.user });
}
},
}, },
}); });

View file

@ -0,0 +1,72 @@
import { CaseReducer, PayloadAction } from '@reduxjs/toolkit';
import { create } from '@bufbuild/protobuf';
import { Data, Enriched } from '@app/types';
import { normalizeGameObject, normalizeGametypeMap } from '../common';
import { ServerState } from './server.interfaces';
export const MAX_USER_MESSAGES = 1000;
export const userReducers = {
updateUser: ((state, action) => {
if (state.user) {
state.user = create(Data.ServerInfo_UserSchema, { ...state.user, ...action.payload.user });
} else {
state.user = action.payload.user as Data.ServerInfo_User;
}
}) as CaseReducer<ServerState, PayloadAction<{ user: Partial<Data.ServerInfo_User> }>>,
updateUsers: ((state, action) => {
const users: { [userName: string]: Data.ServerInfo_User } = {};
for (const user of action.payload.users) {
users[user.name] = user;
}
state.users = users;
}) as CaseReducer<ServerState, PayloadAction<{ users: Data.ServerInfo_User[] }>>,
userJoined: ((state, action) => {
const { user } = action.payload;
state.users[user.name] = user;
}) as CaseReducer<ServerState, PayloadAction<{ user: Data.ServerInfo_User }>>,
userLeft: ((state, action) => {
delete state.users[action.payload.name];
}) as CaseReducer<ServerState, PayloadAction<{ name: string }>>,
getUserInfo: ((state, action) => {
const { userInfo } = action.payload;
state.userInfo[userInfo.name] = userInfo;
}) as CaseReducer<ServerState, PayloadAction<{ userInfo: Data.ServerInfo_User }>>,
userMessage: ((state, action) => {
if (!state.user) {
return;
}
const { senderName, receiverName } = action.payload.messageData;
const userName = state.user.name === senderName ? receiverName : senderName;
if (!state.messages[userName]) {
state.messages[userName] = [];
}
const msgs = state.messages[userName];
if (msgs.length >= MAX_USER_MESSAGES) {
state.messages[userName] = msgs.slice(msgs.length - MAX_USER_MESSAGES + 1);
}
state.messages[userName].push(action.payload.messageData);
}) as CaseReducer<ServerState, PayloadAction<{ messageData: Data.Event_UserMessage }>>,
notifyUser: ((state, action) => {
state.notifications.push(action.payload.notification);
}) as CaseReducer<ServerState, PayloadAction<{ notification: Data.Event_NotifyUser }>>,
gamesOfUser: ((state, action) => {
const { userName, response } = action.payload;
const gametypeMap = normalizeGametypeMap(
(response.roomList ?? []).flatMap(room => room.gametypeList ?? [])
);
const games: { [gameId: number]: Enriched.Game } = {};
for (const g of response.gameList ?? []) {
const normalized = normalizeGameObject(g, gametypeMap);
games[normalized.info.gameId] = normalized;
}
state.gamesOfUser[userName] = games;
}) as CaseReducer<ServerState, PayloadAction<{ userName: string; response: Data.Response_GetGamesOfUser }>>,
};

View file

@ -4,7 +4,7 @@ export enum RouteEnum {
ROOM = '/room/:roomId', ROOM = '/room/:roomId',
LOGIN = '/login', LOGIN = '/login',
LOGS = '/logs', LOGS = '/logs',
GAME = '/game', GAME = '/game/:gameId',
DECKS = '/decks', DECKS = '/decks',
DECK = '/deck', DECK = '/deck',
ACCOUNT = '/account', ACCOUNT = '/account',

View file

@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { buildWebSocketUrl } from './buildWebSocketUrl';
describe('buildWebSocketUrl', () => {
it('includes the port for direct (pathless) endpoints', () => {
expect(buildWebSocketUrl('wss', 'mtg.chickatrice.net', '443')).toBe(
'wss://mtg.chickatrice.net:443',
);
});
it('drops the port for nginx-proxied hosts that bake a path into the host string', () => {
expect(buildWebSocketUrl('wss', 'server.cockatrice.us/servatrice', '4748')).toBe(
'wss://server.cockatrice.us/servatrice',
);
});
it('preserves multi-segment proxy paths', () => {
expect(buildWebSocketUrl('wss', 'example.com/foo/bar', '443')).toBe(
'wss://example.com/foo/bar',
);
});
it('accepts a numeric port for direct endpoints', () => {
expect(buildWebSocketUrl('ws', 'localhost', 4748)).toBe('ws://localhost:4748');
});
it('honors the ws protocol even for nginx-proxied hosts', () => {
expect(buildWebSocketUrl('ws', 'localhost/servatrice', 4748)).toBe(
'ws://localhost/servatrice',
);
});
});

View file

@ -0,0 +1,21 @@
// Known hosts fall into two shapes:
// 1. Direct endpoint: `mtg.chickatrice.net` + `4748` → ws://mtg.chickatrice.net:4748
// 2. Reverse-proxied endpoint with an nginx route baked into the host:
// `server.cockatrice.us/servatrice` + `4748` → wss://server.cockatrice.us/servatrice
//
// For shape (2) the `port` field is a dev-convenience value for direct
// connections — on the public TLS endpoint the path is routed via nginx on the
// default 443, NOT on `:4748`. A naive `${protocol}://${host}:${port}` produces
// `wss://server.cockatrice.us/servatrice:4748` which browsers parse as
// host=server.cockatrice.us, port=default, path=/servatrice:4748 — bypassing
// the proxy and failing the WS upgrade.
export function buildWebSocketUrl(
protocol: 'ws' | 'wss',
host: string,
port: string | number,
): string {
if (host.includes('/')) {
return `${protocol}://${host}`;
}
return `${protocol}://${host}:${port}`;
}