From f534cd79b77653f3611f6e641dc0c520979a7499 Mon Sep 17 00:00:00 2001 From: seavor Date: Sat, 25 Apr 2026 14:42:41 -0500 Subject: [PATCH] gameboard fixes --- .gitignore | 2 +- .../integration/src/app/game-board.spec.tsx | 10 +- .../Game/Battlefield/AttachmentStack.css | 24 + .../Game/Battlefield/AttachmentStack.tsx | 100 +++ .../Game/Battlefield/Battlefield.css | 37 +- .../Game/Battlefield/Battlefield.spec.tsx | 330 ++++++++- .../Game/Battlefield/Battlefield.tsx | 57 +- .../Game/Battlefield/BattlefieldRow.tsx | 15 +- .../Battlefield/BattlefieldStackColumn.css | 17 + .../Battlefield/BattlefieldStackColumn.tsx | 115 +++ .../Game/Battlefield/gridMath.spec.ts | 149 ++++ .../components/Game/Battlefield/gridMath.ts | 113 +++ .../Game/Battlefield/useBattlefield.ts | 52 +- .../src/components/Game/CardSlot/CardSlot.css | 31 +- .../Game/CardSlot/CardSlot.spec.tsx | 14 - .../src/components/Game/CardSlot/CardSlot.tsx | 3 - .../Game/CardSlot/useCardSlot.spec.ts | 97 +++ .../components/Game/CardSlot/useCardSlot.ts | 17 +- .../components/Game/GameLog/GameLog.spec.tsx | 22 + .../src/components/Game/HandZone/HandZone.css | 9 - .../Game/HandZone/HandZone.spec.tsx | 14 +- .../src/components/Game/HandZone/HandZone.tsx | 1 - .../Game/PlayerBoard/PlayerBoard.css | 2 +- .../Game/PlayerBoard/PlayerBoard.spec.tsx | 14 +- .../Game/PlayerBoard/PlayerBoard.tsx | 18 +- .../Game/PlayerInfoPanel/PlayerInfoPanel.css | 242 ++----- .../PlayerInfoPanel/PlayerInfoPanel.spec.tsx | 255 ++++--- .../Game/PlayerInfoPanel/PlayerInfoPanel.tsx | 245 +++---- .../PlayerInfoPanel/usePlayerInfoPanel.ts | 113 ++- .../components/Game/PlayerList/PlayerList.css | 16 +- .../Game/PlayerList/PlayerList.spec.tsx | 94 ++- .../components/Game/PlayerList/PlayerList.tsx | 33 +- .../Game/StackColumn/StackColumn.css | 63 ++ .../Game/StackColumn/StackColumn.spec.tsx | 59 ++ .../Game/StackColumn/StackColumn.tsx | 57 ++ .../components/Game/StackStrip/StackStrip.css | 54 -- .../Game/StackStrip/StackStrip.spec.tsx | 107 --- .../components/Game/StackStrip/StackStrip.tsx | 73 -- .../src/components/Game/ZoneRail/ZoneRail.css | 11 - .../Game/ZoneRail/ZoneRail.spec.tsx | 74 -- .../src/components/Game/ZoneRail/ZoneRail.tsx | 50 -- .../components/Game/ZoneStack/ZoneStack.css | 38 +- .../Game/ZoneStack/ZoneStack.spec.tsx | 28 + .../components/Game/ZoneStack/ZoneStack.tsx | 7 +- webclient/src/components/Game/index.ts | 3 +- webclient/src/containers/Game/Game.css | 9 +- webclient/src/containers/Game/Game.spec.tsx | 2 +- webclient/src/containers/Game/Game.tsx | 88 +-- webclient/src/containers/Game/useGame.ts | 6 +- .../src/containers/Game/useGameDnd.spec.ts | 328 +++++++++ webclient/src/containers/Game/useGameDnd.ts | 82 ++- webclient/src/containers/Layout/LeftNav.tsx | 26 +- webclient/src/containers/Layout/useLeftNav.ts | 12 +- .../Room/GameSelector/GameSelector.spec.tsx | 8 +- .../Room/GameSelector/GameSelector.tsx | 15 +- .../DeckSelectDialog/DeckSelectDialog.css | 31 + .../DeckSelectDialog.spec.tsx | 111 ++- .../DeckSelectDialog/DeckSelectDialog.tsx | 47 +- .../DeckSelectDialog/useDeckSelectDialog.ts | 67 +- .../dialogs/ZoneViewDialog/ZoneViewDialog.tsx | 14 + .../ZoneViewDialog/useZoneViewDialog.ts | 6 + .../src/store/game/game.reducer.arrow.ts | 26 + webclient/src/store/game/game.reducer.card.ts | 279 ++++++++ webclient/src/store/game/game.reducer.chat.ts | 51 ++ .../src/store/game/game.reducer.counter.ts | 39 ++ .../src/store/game/game.reducer.helpers.ts | 107 +++ .../src/store/game/game.reducer.lifecycle.ts | 44 ++ .../src/store/game/game.reducer.player.ts | 65 ++ webclient/src/store/game/game.reducer.spec.ts | 145 ++++ webclient/src/store/game/game.reducer.ts | 660 +----------------- webclient/src/store/game/game.reducer.turn.ts | 83 +++ .../src/store/game/game.selectors.spec.ts | 130 ++++ webclient/src/store/game/game.selectors.ts | 58 +- webclient/src/store/game/messageLog.spec.ts | 26 + webclient/src/store/game/messageLog.ts | 7 +- .../store/server/server.reducer.account.ts | 31 + .../store/server/server.reducer.buddies.ts | 39 ++ .../store/server/server.reducer.connection.ts | 105 +++ .../src/store/server/server.reducer.decks.ts | 111 +++ .../store/server/server.reducer.moderation.ts | 58 ++ .../store/server/server.reducer.replays.ts | 35 + webclient/src/store/server/server.reducer.ts | 430 +----------- .../src/store/server/server.reducer.users.ts | 72 ++ webclient/src/types/routes.ts | 2 +- .../websocket/utils/buildWebSocketUrl.spec.ts | 33 + .../src/websocket/utils/buildWebSocketUrl.ts | 21 + 86 files changed, 4249 insertions(+), 2145 deletions(-) create mode 100644 webclient/src/components/Game/Battlefield/AttachmentStack.css create mode 100644 webclient/src/components/Game/Battlefield/AttachmentStack.tsx create mode 100644 webclient/src/components/Game/Battlefield/BattlefieldStackColumn.css create mode 100644 webclient/src/components/Game/Battlefield/BattlefieldStackColumn.tsx create mode 100644 webclient/src/components/Game/Battlefield/gridMath.spec.ts create mode 100644 webclient/src/components/Game/Battlefield/gridMath.ts create mode 100644 webclient/src/components/Game/CardSlot/useCardSlot.spec.ts create mode 100644 webclient/src/components/Game/StackColumn/StackColumn.css create mode 100644 webclient/src/components/Game/StackColumn/StackColumn.spec.tsx create mode 100644 webclient/src/components/Game/StackColumn/StackColumn.tsx delete mode 100644 webclient/src/components/Game/StackStrip/StackStrip.css delete mode 100644 webclient/src/components/Game/StackStrip/StackStrip.spec.tsx delete mode 100644 webclient/src/components/Game/StackStrip/StackStrip.tsx delete mode 100644 webclient/src/components/Game/ZoneRail/ZoneRail.css delete mode 100644 webclient/src/components/Game/ZoneRail/ZoneRail.spec.tsx delete mode 100644 webclient/src/components/Game/ZoneRail/ZoneRail.tsx create mode 100644 webclient/src/containers/Game/useGameDnd.spec.ts create mode 100644 webclient/src/store/game/game.reducer.arrow.ts create mode 100644 webclient/src/store/game/game.reducer.card.ts create mode 100644 webclient/src/store/game/game.reducer.chat.ts create mode 100644 webclient/src/store/game/game.reducer.counter.ts create mode 100644 webclient/src/store/game/game.reducer.helpers.ts create mode 100644 webclient/src/store/game/game.reducer.lifecycle.ts create mode 100644 webclient/src/store/game/game.reducer.player.ts create mode 100644 webclient/src/store/game/game.reducer.turn.ts create mode 100644 webclient/src/store/server/server.reducer.account.ts create mode 100644 webclient/src/store/server/server.reducer.buddies.ts create mode 100644 webclient/src/store/server/server.reducer.connection.ts create mode 100644 webclient/src/store/server/server.reducer.decks.ts create mode 100644 webclient/src/store/server/server.reducer.moderation.ts create mode 100644 webclient/src/store/server/server.reducer.replays.ts create mode 100644 webclient/src/store/server/server.reducer.users.ts create mode 100644 webclient/src/websocket/utils/buildWebSocketUrl.spec.ts create mode 100644 webclient/src/websocket/utils/buildWebSocketUrl.ts diff --git a/.gitignore b/.gitignore index 33c1ae31d..881174406 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ tags -build* +build*/ *.qm .directory mysql.cnf diff --git a/webclient/integration/src/app/game-board.spec.tsx b/webclient/integration/src/app/game-board.spec.tsx index efb4b7840..b062fb2ba 100644 --- a/webclient/integration/src/app/game-board.spec.tsx +++ b/webclient/integration/src/app/game-board.spec.tsx @@ -248,7 +248,7 @@ describe('Game board integration', () => { 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(); act(() => { @@ -266,12 +266,14 @@ describe('Game board integration', () => { }); const localBoard = screen.getByTestId('player-board-1'); - const rail = within(localBoard).getByTestId('zone-rail'); - const labels = Array.from(rail.querySelectorAll('.zone-stack__label')).map( + const zones = localBoard.querySelector('.player-info-panel__zones')!; + const labels = Array.from(zones.querySelectorAll('.zone-stack__label')).map( (n) => n.textContent, ); 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 () => { diff --git a/webclient/src/components/Game/Battlefield/AttachmentStack.css b/webclient/src/components/Game/Battlefield/AttachmentStack.css new file mode 100644 index 000000000..6258e2317 --- /dev/null +++ b/webclient/src/components/Game/Battlefield/AttachmentStack.css @@ -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 */ +} diff --git a/webclient/src/components/Game/Battlefield/AttachmentStack.tsx b/webclient/src/components/Game/Battlefield/AttachmentStack.tsx new file mode 100644 index 000000000..8f3d1657b --- /dev/null +++ b/webclient/src/components/Game/Battlefield/AttachmentStack.tsx @@ -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 ( +
+
+ onCardClick?.(ownerPlayerId, App.ZoneName.TABLE, c)} + onContextMenu={onCardContextMenu} + onDoubleClick={onCardDoubleClick} + /> +
+ {attachments.map((child, i) => { + const childKey = makeCardKey(ownerPlayerId, App.ZoneName.TABLE, child.id); + const leftPct = round(((i + 1) * OFFSET_FRACTION * 100) / stackFactor); + return ( +
+ onCardClick?.(ownerPlayerId, App.ZoneName.TABLE, c)} + onContextMenu={onCardContextMenu} + onDoubleClick={onCardDoubleClick} + /> +
+ ); + })} +
+ ); +} + +export default AttachmentStack; diff --git a/webclient/src/components/Game/Battlefield/Battlefield.css b/webclient/src/components/Game/Battlefield/Battlefield.css index d0bd68b36..1d726cadf 100644 --- a/webclient/src/components/Game/Battlefield/Battlefield.css +++ b/webclient/src/components/Game/Battlefield/Battlefield.css @@ -1,29 +1,54 @@ .battlefield { - display: flex; - flex-direction: column; + /* --card-base-width is the preferred width of a single card inside a lane. + * 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%; background: #0f1c38; border: 1px solid #1a2b52; box-sizing: border-box; + overflow: hidden; } .battlefield__row { - flex: 1 1 0; + min-height: 0; + min-width: 0; display: flex; flex-direction: row; 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; box-sizing: border-box; overflow-x: auto; overflow-y: hidden; } -.battlefield__row + .battlefield__row { - border-top: 1px solid rgba(255, 255, 255, 0.08); +/* Inset box-shadow instead of border-top so the divider doesn't consume + * 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 { background: rgba(247, 176, 28, 0.08); 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; +} diff --git a/webclient/src/components/Game/Battlefield/Battlefield.spec.tsx b/webclient/src/components/Game/Battlefield/Battlefield.spec.tsx index 56eccd11d..d7a33402e 100644 --- a/webclient/src/components/Game/Battlefield/Battlefield.spec.tsx +++ b/webclient/src/components/Game/Battlefield/Battlefield.spec.tsx @@ -1,5 +1,6 @@ -import { screen } from '@testing-library/react'; -import { App } from '@app/types'; +import { create } from '@bufbuild/protobuf'; +import { act, screen } from '@testing-library/react'; +import { App, Data } from '@app/types'; vi.mock('../../../hooks/useSettings'); @@ -12,6 +13,7 @@ import { makePlayerEntry, makeZoneEntry, } from '../../../store/game/__mocks__/fixtures'; +import { Actions } from '../../../store/game/game.actions'; import Battlefield from './Battlefield'; function setInvert(invert: boolean) { @@ -127,16 +129,6 @@ describe('Battlefield', () => { 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( - , - { preloadedState: stateWithBattlefield(cards) }, - ); - - expect(container.querySelector('.card-slot--inverted')).not.toBeNull(); - }); - describe('invertVerticalCoordinate user setting', () => { it('renders rows bottom-to-top when the setting is on and not mirrored (local player)', () => { setInvert(true); @@ -171,26 +163,324 @@ describe('Battlefield', () => { 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[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(, { + 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( , { 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)', () => { - setInvert(true); - const cards = [makeCard({ id: 1, name: 'A', x: 0, y: 0 })]; + it('does not flip the stack direction on an inverted (opponent) board', () => { + const cards = [ + makeCard({ id: 1, name: 'Creature', x: 0, y: 0 }), + attachedChild({ id: 2, name: 'Aura', attachCardId: 1 }), + ]; const { container } = renderWithProviders( , { 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( + , + { 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(, { + 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( + , + { 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(, { + 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( + , + { 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( + , + { 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(, { + 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(, { + 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( + , + { 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( + , + { 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); }); }); }); diff --git a/webclient/src/components/Game/Battlefield/Battlefield.tsx b/webclient/src/components/Game/Battlefield/Battlefield.tsx index 4f2231292..83e4e612a 100644 --- a/webclient/src/components/Game/Battlefield/Battlefield.tsx +++ b/webclient/src/components/Game/Battlefield/Battlefield.tsx @@ -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 BattlefieldStackColumn from './BattlefieldStackColumn'; import { useBattlefield } from './useBattlefield'; import './Battlefield.css'; @@ -30,27 +29,51 @@ function Battlefield({ onCardContextMenu, onCardDoubleClick, }: BattlefieldProps) { - const { rows, rowOrder, isInverted } = useBattlefield({ gameId, playerId, mirrored }); + const { rows, stackColumnsByRow, rowOrder, attachmentsByParent } = useBattlefield({ + gameId, + playerId, + mirrored, + }); return (
{rowOrder.map((rowIdx) => ( - - {rows[rowIdx].map((card) => { - const key = makeCardKey(playerId, App.ZoneName.TABLE, card.id); + + {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 ( +