mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-07-06 05:23:56 -07:00
gameboard fixes
This commit is contained in:
parent
88489ea2eb
commit
f534cd79b7
86 changed files with 4249 additions and 2145 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
tags
|
tags
|
||||||
build*
|
build*/
|
||||||
*.qm
|
*.qm
|
||||||
.directory
|
.directory
|
||||||
mysql.cnf
|
mysql.cnf
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
}
|
||||||
100
webclient/src/components/Game/Battlefield/AttachmentStack.tsx
Normal file
100
webclient/src/components/Game/Battlefield/AttachmentStack.tsx
Normal 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;
|
||||||
|
|
@ -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 pointer→stack walk aligns with the rendered layout. */
|
||||||
|
.battlefield__stack-placeholder {
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 146 / 204;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
149
webclient/src/components/Game/Battlefield/gridMath.spec.ts
Normal file
149
webclient/src/components/Game/Battlefield/gridMath.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
113
webclient/src/components/Game/Battlefield/gridMath.ts
Normal file
113
webclient/src/components/Game/Battlefield/gridMath.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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} />);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
97
webclient/src/components/Game/CardSlot/useCardSlot.spec.ts
Normal file
97
webclient/src/components/Game/CardSlot/useCardSlot.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
63
webclient/src/components/Game/StackColumn/StackColumn.css
Normal file
63
webclient/src/components/Game/StackColumn/StackColumn.css
Normal 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%);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
57
webclient/src/components/Game/StackColumn/StackColumn.tsx
Normal file
57
webclient/src/components/Game/StackColumn/StackColumn.tsx
Normal 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;
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
328
webclient/src/containers/Game/useGameDnd.spec.ts
Normal file
328
webclient/src/containers/Game/useGameDnd.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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} />,
|
||||||
|
|
|
||||||
|
|
@ -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 20 Mountain ..."
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
26
webclient/src/store/game/game.reducer.arrow.ts
Normal file
26
webclient/src/store/game/game.reducer.arrow.ts
Normal 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 }>>,
|
||||||
|
};
|
||||||
279
webclient/src/store/game/game.reducer.card.ts
Normal file
279
webclient/src/store/game/game.reducer.card.ts
Normal 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 }>>,
|
||||||
|
};
|
||||||
51
webclient/src/store/game/game.reducer.chat.ts
Normal file
51
webclient/src/store/game/game.reducer.chat.ts
Normal 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 }>>,
|
||||||
|
};
|
||||||
39
webclient/src/store/game/game.reducer.counter.ts
Normal file
39
webclient/src/store/game/game.reducer.counter.ts
Normal 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 }>>,
|
||||||
|
};
|
||||||
107
webclient/src/store/game/game.reducer.helpers.ts
Normal file
107
webclient/src/store/game/game.reducer.helpers.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
44
webclient/src/store/game/game.reducer.lifecycle.ts
Normal file
44
webclient/src/store/game/game.reducer.lifecycle.ts
Normal 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 }>>,
|
||||||
|
};
|
||||||
65
webclient/src/store/game/game.reducer.player.ts
Normal file
65
webclient/src/store/game/game.reducer.player.ts
Normal 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 }>>,
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
83
webclient/src/store/game/game.reducer.turn.ts
Normal file
83
webclient/src/store/game/game.reducer.turn.ts
Normal 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 }>>,
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
|
|
||||||
31
webclient/src/store/server/server.reducer.account.ts
Normal file
31
webclient/src/store/server/server.reducer.account.ts
Normal 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> }>>,
|
||||||
|
};
|
||||||
39
webclient/src/store/server/server.reducer.buddies.ts
Normal file
39
webclient/src/store/server/server.reducer.buddies.ts
Normal 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 }>>,
|
||||||
|
};
|
||||||
105
webclient/src/store/server/server.reducer.connection.ts
Normal file
105
webclient/src/store/server/server.reducer.connection.ts
Normal 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 }>>,
|
||||||
|
};
|
||||||
111
webclient/src/store/server/server.reducer.decks.ts
Normal file
111
webclient/src/store/server/server.reducer.decks.ts
Normal 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 }>>,
|
||||||
|
};
|
||||||
58
webclient/src/store/server/server.reducer.moderation.ts
Normal file
58
webclient/src/store/server/server.reducer.moderation.ts
Normal 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>,
|
||||||
|
};
|
||||||
35
webclient/src/store/server/server.reducer.replays.ts
Normal file
35
webclient/src/store/server/server.reducer.replays.ts
Normal 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 }>>,
|
||||||
|
};
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
72
webclient/src/store/server/server.reducer.users.ts
Normal file
72
webclient/src/store/server/server.reducer.users.ts
Normal 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 }>>,
|
||||||
|
};
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
33
webclient/src/websocket/utils/buildWebSocketUrl.spec.ts
Normal file
33
webclient/src/websocket/utils/buildWebSocketUrl.spec.ts
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
webclient/src/websocket/utils/buildWebSocketUrl.ts
Normal file
21
webclient/src/websocket/utils/buildWebSocketUrl.ts
Normal 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}`;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue