From 0d7336edc2d097c643748719bf692191701a2062 Mon Sep 17 00:00:00 2001 From: seavor Date: Sun, 19 Apr 2026 23:21:42 -0500 Subject: [PATCH] implement gameboard v1 --- webclient/eslint.boundaries.mjs | 18 +- .../integration/src/app/game-board.spec.tsx | 443 ++++++ .../integration/src/websocket/game.spec.ts | 31 +- webclient/package-lock.json | 41 + webclient/package.json | 2 + webclient/src/__test-utils__/index.ts | 8 +- webclient/src/__test-utils__/mockWebClient.ts | 35 +- .../__test-utils__/renderWithProviders.tsx | 60 +- webclient/src/__test-utils__/storeFixtures.ts | 26 + webclient/src/api/request/RoomsRequestImpl.ts | 9 + webclient/src/colors.css | 25 + .../Game/Battlefield/Battlefield.css | 29 + .../Game/Battlefield/Battlefield.spec.tsx | 196 +++ .../Game/Battlefield/Battlefield.tsx | 93 ++ .../Game/Battlefield/BattlefieldRow.tsx | 30 + .../Game/CardContextMenu/CardContextMenu.css | 3 + .../CardContextMenu/CardContextMenu.spec.tsx | 396 +++++ .../Game/CardContextMenu/CardContextMenu.tsx | 234 +++ .../Game/CardDragOverlay/CardDragOverlay.css | 24 + .../CardDragOverlay/CardDragOverlay.spec.tsx | 20 + .../Game/CardDragOverlay/CardDragOverlay.tsx | 24 + .../Game/CardPreview/CardPreview.css | 44 + .../Game/CardPreview/CardPreview.spec.tsx | 55 + .../Game/CardPreview/CardPreview.tsx | 49 + .../Game/CardRegistry/CardRegistryContext.ts | 64 + .../src/components/Game/CardSlot/CardSlot.css | 153 ++ .../Game/CardSlot/CardSlot.spec.tsx | 126 ++ .../src/components/Game/CardSlot/CardSlot.tsx | 144 ++ .../GameArrowOverlay/GameArrowOverlay.css | 24 + .../GameArrowOverlay.spec.tsx | 129 ++ .../GameArrowOverlay/GameArrowOverlay.tsx | 175 +++ .../src/components/Game/GameLog/GameLog.css | 97 ++ .../components/Game/GameLog/GameLog.spec.tsx | 249 ++++ .../src/components/Game/GameLog/GameLog.tsx | 133 ++ .../Game/HandContextMenu/HandContextMenu.css | 3 + .../HandContextMenu/HandContextMenu.spec.tsx | 81 + .../Game/HandContextMenu/HandContextMenu.tsx | 101 ++ .../src/components/Game/HandZone/HandZone.css | 33 + .../Game/HandZone/HandZone.spec.tsx | 96 ++ .../src/components/Game/HandZone/HandZone.tsx | 89 ++ .../OpponentSelector/OpponentSelector.css | 26 + .../OpponentSelector.spec.tsx | 55 + .../OpponentSelector/OpponentSelector.tsx | 40 + .../src/components/Game/PhaseBar/PhaseBar.css | 53 + .../Game/PhaseBar/PhaseBar.spec.tsx | 258 ++++ .../src/components/Game/PhaseBar/PhaseBar.tsx | 147 ++ .../Game/PlayerBoard/PlayerBoard.css | 13 + .../Game/PlayerBoard/PlayerBoard.spec.tsx | 108 ++ .../Game/PlayerBoard/PlayerBoard.tsx | 77 + .../PlayerContextMenu/PlayerContextMenu.css | 3 + .../PlayerContextMenu.spec.tsx | 63 + .../PlayerContextMenu/PlayerContextMenu.tsx | 48 + .../Game/PlayerInfoPanel/PlayerInfoPanel.css | 267 ++++ .../PlayerInfoPanel/PlayerInfoPanel.spec.tsx | 348 +++++ .../Game/PlayerInfoPanel/PlayerInfoPanel.tsx | 286 ++++ .../components/Game/PlayerList/PlayerList.css | 76 + .../Game/PlayerList/PlayerList.spec.tsx | 143 ++ .../components/Game/PlayerList/PlayerList.tsx | 68 + .../components/Game/RightPanel/RightPanel.css | 21 + .../Game/RightPanel/RightPanel.spec.tsx | 80 + .../components/Game/RightPanel/RightPanel.tsx | 59 + .../components/Game/StackStrip/StackStrip.css | 54 + .../Game/StackStrip/StackStrip.spec.tsx | 107 ++ .../components/Game/StackStrip/StackStrip.tsx | 73 + .../Game/TurnControls/TurnControls.css | 39 + .../Game/TurnControls/TurnControls.spec.tsx | 394 +++++ .../Game/TurnControls/TurnControls.tsx | 267 ++++ .../Game/ZoneContextMenu/ZoneContextMenu.css | 16 + .../ZoneContextMenu/ZoneContextMenu.spec.tsx | 208 +++ .../Game/ZoneContextMenu/ZoneContextMenu.tsx | 156 ++ .../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 | 57 + .../Game/ZoneStack/ZoneStack.spec.tsx | 163 ++ .../components/Game/ZoneStack/ZoneStack.tsx | 79 + webclient/src/components/Game/index.ts | 29 + webclient/src/components/Toast/Toast.tsx | 2 +- .../src/components/Toast/ToastContext.tsx | 6 +- webclient/src/components/index.ts | 3 + webclient/src/containers/Game/Game.css | 67 + .../containers/Game/Game.dragdrop.spec.tsx | 142 ++ webclient/src/containers/Game/Game.spec.tsx | 756 ++++++++++ webclient/src/containers/Game/Game.tsx | 1323 ++++++++++++++++- webclient/src/containers/Login/Login.spec.tsx | 12 +- .../Room/GameSelector/GameSelector.css | 33 + .../Room/GameSelector/GameSelector.spec.tsx | 196 +++ .../Room/GameSelector/GameSelector.tsx | 152 ++ .../GameSelector/GameSelectorToolbar.spec.tsx | 91 ++ .../Room/GameSelector/GameSelectorToolbar.tsx | 89 ++ webclient/src/containers/Room/OpenGames.css | 7 +- webclient/src/containers/Room/OpenGames.tsx | 86 +- webclient/src/containers/Room/Room.tsx | 8 +- .../dialogs/ConfirmDialog/ConfirmDialog.css | 3 + .../ConfirmDialog/ConfirmDialog.spec.tsx | 69 + .../dialogs/ConfirmDialog/ConfirmDialog.tsx | 84 ++ .../CreateCounterDialog.css | 30 + .../CreateCounterDialog.spec.tsx | 83 ++ .../CreateCounterDialog.tsx | 139 ++ .../CreateGameDialog/CreateGameDialog.css | 11 + .../CreateGameDialog.spec.tsx | 93 ++ .../CreateGameDialog/CreateGameDialog.tsx | 281 ++++ .../CreateTokenDialog/CreateTokenDialog.css | 6 + .../CreateTokenDialog.spec.tsx | 104 ++ .../CreateTokenDialog/CreateTokenDialog.tsx | 204 +++ .../DeckSelectDialog/DeckSelectDialog.css | 46 + .../DeckSelectDialog.spec.tsx | 139 ++ .../DeckSelectDialog/DeckSelectDialog.tsx | 125 ++ .../FilterGamesDialog/FilterGamesDialog.css | 16 + .../FilterGamesDialog.spec.tsx | 84 ++ .../FilterGamesDialog/FilterGamesDialog.tsx | 278 ++++ .../dialogs/GameInfoDialog/GameInfoDialog.css | 60 + .../GameInfoDialog/GameInfoDialog.spec.tsx | 90 ++ .../dialogs/GameInfoDialog/GameInfoDialog.tsx | 135 ++ .../src/dialogs/PromptDialog/PromptDialog.css | 4 + .../PromptDialog/PromptDialog.spec.tsx | 205 +++ .../src/dialogs/PromptDialog/PromptDialog.tsx | 118 ++ .../RevealCardsDialog/RevealCardsDialog.css | 12 + .../RevealCardsDialog.spec.tsx | 114 ++ .../RevealCardsDialog/RevealCardsDialog.tsx | 153 ++ .../dialogs/RollDieDialog/RollDieDialog.css | 4 + .../RollDieDialog/RollDieDialog.spec.tsx | 81 + .../dialogs/RollDieDialog/RollDieDialog.tsx | 131 ++ .../SideboardDialog/SideboardDialog.css | 81 + .../SideboardDialog/SideboardDialog.spec.tsx | 143 ++ .../SideboardDialog/SideboardDialog.tsx | 245 +++ .../dialogs/ZoneViewDialog/ZoneViewDialog.css | 92 ++ .../ZoneViewDialog/ZoneViewDialog.spec.tsx | 194 +++ .../dialogs/ZoneViewDialog/ZoneViewDialog.tsx | 174 +++ webclient/src/dialogs/index.ts | 23 + webclient/src/hooks/game/index.ts | 4 + .../src/hooks/game/useCurrentGame.spec.tsx | 85 ++ webclient/src/hooks/game/useCurrentGame.ts | 48 + .../src/hooks/game/useGameAccess.spec.tsx | 100 ++ webclient/src/hooks/game/useGameAccess.ts | 33 + .../src/hooks/game/useGameLifecycle.spec.tsx | 116 ++ webclient/src/hooks/game/useGameLifecycle.ts | 35 + .../src/hooks/game/useScryfallCard.spec.ts | 45 + webclient/src/hooks/game/useScryfallCard.ts | 29 + webclient/src/hooks/index.ts | 1 + webclient/src/index.tsx | 1 + .../src/services/ScryfallService.spec.ts | 67 + webclient/src/services/ScryfallService.ts | 48 + .../services/dexie/DexieDTOs/SettingDTO.ts | 1 + webclient/src/services/index.ts | 1 + webclient/src/store/common/index.ts | 1 + webclient/src/store/common/mergeSetFields.ts | 22 + webclient/src/store/game/game.reducer.spec.ts | 103 +- webclient/src/store/game/game.reducer.ts | 215 ++- webclient/src/store/game/messageLog.spec.ts | 337 +++++ webclient/src/store/game/messageLog.ts | 407 +++++ webclient/src/store/index.ts | 11 + .../store/rooms/__mocks__/rooms-fixtures.ts | 2 + webclient/src/store/rooms/gameFilters.spec.ts | 143 ++ webclient/src/store/rooms/gameFilters.ts | 141 ++ webclient/src/store/rooms/index.ts | 2 + .../src/store/rooms/rooms.dispatch.spec.ts | 22 + webclient/src/store/rooms/rooms.dispatch.ts | 15 +- webclient/src/store/rooms/rooms.interfaces.ts | 36 + .../src/store/rooms/rooms.reducer.spec.ts | 110 +- webclient/src/store/rooms/rooms.reducer.ts | 45 +- .../src/store/rooms/rooms.selectors.spec.ts | 100 ++ webclient/src/store/rooms/rooms.selectors.ts | 95 +- webclient/src/store/rooms/rooms.types.ts | 3 + .../src/store/server/server.selectors.ts | 24 + webclient/src/store/store.ts | 15 +- webclient/src/types/app.ts | 4 + webclient/src/types/colors.ts | 36 + webclient/src/types/enriched.ts | 2 + webclient/src/types/game.ts | 34 + webclient/src/types/settings.ts | 1 + webclient/src/utils/cx.spec.ts | 34 + webclient/src/utils/cx.ts | 45 + webclient/src/utils/index.ts | 2 + .../commands/game/attachCard.presence.spec.ts | 73 + .../src/websocket/types/WebClientRequest.ts | 4 + webclient/tsconfig.json | 1 + 177 files changed, 16995 insertions(+), 139 deletions(-) create mode 100644 webclient/integration/src/app/game-board.spec.tsx create mode 100644 webclient/src/colors.css create mode 100644 webclient/src/components/Game/Battlefield/Battlefield.css create mode 100644 webclient/src/components/Game/Battlefield/Battlefield.spec.tsx create mode 100644 webclient/src/components/Game/Battlefield/Battlefield.tsx create mode 100644 webclient/src/components/Game/Battlefield/BattlefieldRow.tsx create mode 100644 webclient/src/components/Game/CardContextMenu/CardContextMenu.css create mode 100644 webclient/src/components/Game/CardContextMenu/CardContextMenu.spec.tsx create mode 100644 webclient/src/components/Game/CardContextMenu/CardContextMenu.tsx create mode 100644 webclient/src/components/Game/CardDragOverlay/CardDragOverlay.css create mode 100644 webclient/src/components/Game/CardDragOverlay/CardDragOverlay.spec.tsx create mode 100644 webclient/src/components/Game/CardDragOverlay/CardDragOverlay.tsx create mode 100644 webclient/src/components/Game/CardPreview/CardPreview.css create mode 100644 webclient/src/components/Game/CardPreview/CardPreview.spec.tsx create mode 100644 webclient/src/components/Game/CardPreview/CardPreview.tsx create mode 100644 webclient/src/components/Game/CardRegistry/CardRegistryContext.ts create mode 100644 webclient/src/components/Game/CardSlot/CardSlot.css create mode 100644 webclient/src/components/Game/CardSlot/CardSlot.spec.tsx create mode 100644 webclient/src/components/Game/CardSlot/CardSlot.tsx create mode 100644 webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.css create mode 100644 webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.spec.tsx create mode 100644 webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.tsx create mode 100644 webclient/src/components/Game/GameLog/GameLog.css create mode 100644 webclient/src/components/Game/GameLog/GameLog.spec.tsx create mode 100644 webclient/src/components/Game/GameLog/GameLog.tsx create mode 100644 webclient/src/components/Game/HandContextMenu/HandContextMenu.css create mode 100644 webclient/src/components/Game/HandContextMenu/HandContextMenu.spec.tsx create mode 100644 webclient/src/components/Game/HandContextMenu/HandContextMenu.tsx create mode 100644 webclient/src/components/Game/HandZone/HandZone.css create mode 100644 webclient/src/components/Game/HandZone/HandZone.spec.tsx create mode 100644 webclient/src/components/Game/HandZone/HandZone.tsx create mode 100644 webclient/src/components/Game/OpponentSelector/OpponentSelector.css create mode 100644 webclient/src/components/Game/OpponentSelector/OpponentSelector.spec.tsx create mode 100644 webclient/src/components/Game/OpponentSelector/OpponentSelector.tsx create mode 100644 webclient/src/components/Game/PhaseBar/PhaseBar.css create mode 100644 webclient/src/components/Game/PhaseBar/PhaseBar.spec.tsx create mode 100644 webclient/src/components/Game/PhaseBar/PhaseBar.tsx create mode 100644 webclient/src/components/Game/PlayerBoard/PlayerBoard.css create mode 100644 webclient/src/components/Game/PlayerBoard/PlayerBoard.spec.tsx create mode 100644 webclient/src/components/Game/PlayerBoard/PlayerBoard.tsx create mode 100644 webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.css create mode 100644 webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.spec.tsx create mode 100644 webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.tsx create mode 100644 webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.css create mode 100644 webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.spec.tsx create mode 100644 webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.tsx create mode 100644 webclient/src/components/Game/PlayerList/PlayerList.css create mode 100644 webclient/src/components/Game/PlayerList/PlayerList.spec.tsx create mode 100644 webclient/src/components/Game/PlayerList/PlayerList.tsx create mode 100644 webclient/src/components/Game/RightPanel/RightPanel.css create mode 100644 webclient/src/components/Game/RightPanel/RightPanel.spec.tsx create mode 100644 webclient/src/components/Game/RightPanel/RightPanel.tsx create mode 100644 webclient/src/components/Game/StackStrip/StackStrip.css create mode 100644 webclient/src/components/Game/StackStrip/StackStrip.spec.tsx create mode 100644 webclient/src/components/Game/StackStrip/StackStrip.tsx create mode 100644 webclient/src/components/Game/TurnControls/TurnControls.css create mode 100644 webclient/src/components/Game/TurnControls/TurnControls.spec.tsx create mode 100644 webclient/src/components/Game/TurnControls/TurnControls.tsx create mode 100644 webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.css create mode 100644 webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.spec.tsx create mode 100644 webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.tsx create mode 100644 webclient/src/components/Game/ZoneRail/ZoneRail.css create mode 100644 webclient/src/components/Game/ZoneRail/ZoneRail.spec.tsx create mode 100644 webclient/src/components/Game/ZoneRail/ZoneRail.tsx create mode 100644 webclient/src/components/Game/ZoneStack/ZoneStack.css create mode 100644 webclient/src/components/Game/ZoneStack/ZoneStack.spec.tsx create mode 100644 webclient/src/components/Game/ZoneStack/ZoneStack.tsx create mode 100644 webclient/src/components/Game/index.ts create mode 100644 webclient/src/containers/Game/Game.dragdrop.spec.tsx create mode 100644 webclient/src/containers/Game/Game.spec.tsx create mode 100644 webclient/src/containers/Room/GameSelector/GameSelector.css create mode 100644 webclient/src/containers/Room/GameSelector/GameSelector.spec.tsx create mode 100644 webclient/src/containers/Room/GameSelector/GameSelector.tsx create mode 100644 webclient/src/containers/Room/GameSelector/GameSelectorToolbar.spec.tsx create mode 100644 webclient/src/containers/Room/GameSelector/GameSelectorToolbar.tsx create mode 100644 webclient/src/dialogs/ConfirmDialog/ConfirmDialog.css create mode 100644 webclient/src/dialogs/ConfirmDialog/ConfirmDialog.spec.tsx create mode 100644 webclient/src/dialogs/ConfirmDialog/ConfirmDialog.tsx create mode 100644 webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.css create mode 100644 webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.spec.tsx create mode 100644 webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.tsx create mode 100644 webclient/src/dialogs/CreateGameDialog/CreateGameDialog.css create mode 100644 webclient/src/dialogs/CreateGameDialog/CreateGameDialog.spec.tsx create mode 100644 webclient/src/dialogs/CreateGameDialog/CreateGameDialog.tsx create mode 100644 webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.css create mode 100644 webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.spec.tsx create mode 100644 webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.tsx create mode 100644 webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.css create mode 100644 webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.spec.tsx create mode 100644 webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.tsx create mode 100644 webclient/src/dialogs/FilterGamesDialog/FilterGamesDialog.css create mode 100644 webclient/src/dialogs/FilterGamesDialog/FilterGamesDialog.spec.tsx create mode 100644 webclient/src/dialogs/FilterGamesDialog/FilterGamesDialog.tsx create mode 100644 webclient/src/dialogs/GameInfoDialog/GameInfoDialog.css create mode 100644 webclient/src/dialogs/GameInfoDialog/GameInfoDialog.spec.tsx create mode 100644 webclient/src/dialogs/GameInfoDialog/GameInfoDialog.tsx create mode 100644 webclient/src/dialogs/PromptDialog/PromptDialog.css create mode 100644 webclient/src/dialogs/PromptDialog/PromptDialog.spec.tsx create mode 100644 webclient/src/dialogs/PromptDialog/PromptDialog.tsx create mode 100644 webclient/src/dialogs/RevealCardsDialog/RevealCardsDialog.css create mode 100644 webclient/src/dialogs/RevealCardsDialog/RevealCardsDialog.spec.tsx create mode 100644 webclient/src/dialogs/RevealCardsDialog/RevealCardsDialog.tsx create mode 100644 webclient/src/dialogs/RollDieDialog/RollDieDialog.css create mode 100644 webclient/src/dialogs/RollDieDialog/RollDieDialog.spec.tsx create mode 100644 webclient/src/dialogs/RollDieDialog/RollDieDialog.tsx create mode 100644 webclient/src/dialogs/SideboardDialog/SideboardDialog.css create mode 100644 webclient/src/dialogs/SideboardDialog/SideboardDialog.spec.tsx create mode 100644 webclient/src/dialogs/SideboardDialog/SideboardDialog.tsx create mode 100644 webclient/src/dialogs/ZoneViewDialog/ZoneViewDialog.css create mode 100644 webclient/src/dialogs/ZoneViewDialog/ZoneViewDialog.spec.tsx create mode 100644 webclient/src/dialogs/ZoneViewDialog/ZoneViewDialog.tsx create mode 100644 webclient/src/hooks/game/index.ts create mode 100644 webclient/src/hooks/game/useCurrentGame.spec.tsx create mode 100644 webclient/src/hooks/game/useCurrentGame.ts create mode 100644 webclient/src/hooks/game/useGameAccess.spec.tsx create mode 100644 webclient/src/hooks/game/useGameAccess.ts create mode 100644 webclient/src/hooks/game/useGameLifecycle.spec.tsx create mode 100644 webclient/src/hooks/game/useGameLifecycle.ts create mode 100644 webclient/src/hooks/game/useScryfallCard.spec.ts create mode 100644 webclient/src/hooks/game/useScryfallCard.ts create mode 100644 webclient/src/services/ScryfallService.spec.ts create mode 100644 webclient/src/services/ScryfallService.ts create mode 100644 webclient/src/store/common/mergeSetFields.ts create mode 100644 webclient/src/store/game/messageLog.spec.ts create mode 100644 webclient/src/store/game/messageLog.ts create mode 100644 webclient/src/store/rooms/gameFilters.spec.ts create mode 100644 webclient/src/store/rooms/gameFilters.ts create mode 100644 webclient/src/types/colors.ts create mode 100644 webclient/src/types/game.ts create mode 100644 webclient/src/utils/cx.spec.ts create mode 100644 webclient/src/utils/cx.ts create mode 100644 webclient/src/utils/index.ts create mode 100644 webclient/src/websocket/commands/game/attachCard.presence.spec.ts diff --git a/webclient/eslint.boundaries.mjs b/webclient/eslint.boundaries.mjs index d79272665..c5012b44b 100644 --- a/webclient/eslint.boundaries.mjs +++ b/webclient/eslint.boundaries.mjs @@ -12,6 +12,7 @@ const elements = [ { type: 'services', pattern: ['src/services/**'] }, { type: 'store', pattern: ['src/store/**'] }, { type: 'types', pattern: ['src/types/**'] }, + { type: 'utils', pattern: ['src/utils/**'] }, { type: 'websocket-types', pattern: ['src/websocket/types/**'] }, { type: 'websocket', pattern: ['src/websocket/**'] }, ]; @@ -23,24 +24,25 @@ const rules = [ { from: { type: 'websocket-types' }, allow: types('generated') }, { from: { type: 'websocket' }, allow: types('generated', 'websocket-types') }, { from: { type: 'types' }, allow: types('generated') }, + { from: { type: 'utils' }, allow: types('types') }, - { from: { type: 'store' }, allow: types('types', 'websocket-types') }, - { from: { type: 'api' }, allow: types('store', 'types', 'websocket', 'websocket-types') }, + { from: { type: 'store' }, allow: types('types', 'utils', 'websocket-types') }, + { from: { type: 'api' }, allow: types('store', 'types', 'utils', 'websocket', 'websocket-types') }, { from: { type: 'images' }, allow: types('types') }, - { from: { type: 'services' }, allow: types('api', 'store', 'types') }, - { from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'websocket', 'websocket-types') }, + { from: { type: 'services' }, allow: types('api', 'store', 'types', 'utils') }, + { from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'utils', 'websocket', 'websocket-types') }, { from: { type: 'components' }, - allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'websocket-types') + allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'utils', 'websocket-types') }, { from: { type: 'containers' }, - allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'websocket-types') + allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'utils', 'websocket-types') }, - { from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types', 'websocket-types') }, - { from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types', 'websocket-types') }, + { from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types', 'utils', 'websocket-types') }, + { from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types', 'utils', 'websocket-types') }, ]; export const boundariesConfig = [ diff --git a/webclient/integration/src/app/game-board.spec.tsx b/webclient/integration/src/app/game-board.spec.tsx new file mode 100644 index 000000000..efb4b7840 --- /dev/null +++ b/webclient/integration/src/app/game-board.spec.tsx @@ -0,0 +1,443 @@ +// Exercises the full Game container under the real Redux store + real +// reducers + real React chain. We dispatch game lifecycle events via +// GameDispatch (the same path real event handlers take) and assert the +// Game container's UI tracks state transitions. + +import { act, fireEvent, waitFor, screen, within } from '@testing-library/react'; +import { create } from '@bufbuild/protobuf'; +import { useLocation } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Data } from '@app/types'; +import { Command_GameSay_ext } from '@app/generated'; +import { GameDispatch, ServerDispatch, store } from '@app/store'; +import { WebsocketTypes } from '@app/websocket/types'; + +import Game from '../../../src/containers/Game/Game'; +import { renderAppScreen } from './helpers'; +import { findLastGameCommand } from '../helpers/command-capture'; +import { connectRaw } from '../helpers/setup'; + +// Surfaces the current MemoryRouter pathname so navigate() side-effects +// (e.g. useGameLifecycle → /server on kick) can be asserted. Depends on +// `renderAppScreen` → `renderWithProviders` wrapping its tree in a +// `MemoryRouter`; if that harness ever moves the probe outside a Router, +// `useLocation()` will throw "useLocation() may be used only in the +// context of a component." — fix by adding an inline +// `` around the probe OR by teaching the harness about it. +function LocationProbe() { + const location = useLocation(); + return {location.pathname}; +} + +function buildEventGameJoined(args: { + gameId: number; + localPlayerId: number; + hostId: number; +}): Data.Event_GameJoined { + return create(Data.Event_GameJoinedSchema, { + gameInfo: create(Data.ServerInfo_GameSchema, { + gameId: args.gameId, + roomId: 1, + description: 'Integration Test Game', + gameTypes: [], + started: false, + }), + hostId: args.hostId, + playerId: args.localPlayerId, + spectator: false, + judge: false, + resuming: false, + }); +} + +function buildEventGameStateChanged( + playerIds: number[], + localId: number, +): Data.Event_GameStateChanged { + return create(Data.Event_GameStateChangedSchema, { + gameStarted: true, + activePlayerId: localId, + activePhase: 0, + playerList: playerIds.map((pid) => + create(Data.ServerInfo_PlayerSchema, { + properties: create(Data.ServerInfo_PlayerPropertiesSchema, { + playerId: pid, + userInfo: create(Data.ServerInfo_UserSchema, { name: `P${pid}` }), + spectator: false, + conceded: false, + readyStart: false, + judge: false, + }), + deckList: '', + zoneList: [ + create(Data.ServerInfo_ZoneSchema, { + name: 'table', + type: 1, + withCoords: true, + cardCount: 0, + cardList: [], + }), + create(Data.ServerInfo_ZoneSchema, { + name: 'hand', + type: 0, + withCoords: false, + cardCount: 0, + cardList: [], + }), + create(Data.ServerInfo_ZoneSchema, { + name: 'deck', + type: 2, + withCoords: false, + cardCount: 40, + cardList: [], + }), + create(Data.ServerInfo_ZoneSchema, { + name: 'grave', + type: 1, + withCoords: false, + cardCount: 0, + cardList: [], + }), + create(Data.ServerInfo_ZoneSchema, { + name: 'rfg', + type: 1, + withCoords: false, + cardCount: 0, + cardList: [], + }), + ], + counterList: [], + arrowList: [], + }), + ), + }); +} + +function simulateConnected() { + act(() => { + ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.LOGGED_IN, null); + }); +} + +afterEach(() => { + act(() => { + for (const gameId of Object.keys(store.getState().games.games)) { + GameDispatch.gameLeft(Number(gameId)); + } + ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.DISCONNECTED, null); + }); +}); + +beforeEach(() => { + // Integration setup installs fake timers for KeepAliveService control; + // waitFor / React effects need real timers to run between dispatch and assert. + vi.useRealTimers(); + simulateConnected(); +}); + +describe('Game board integration', () => { + it('renders the empty-board placeholder until a game is joined', () => { + renderAppScreen(); + + expect(screen.getByTestId('game-empty')).toBeInTheDocument(); + expect(screen.getByTestId('phase-bar')).toBeInTheDocument(); + expect(screen.getByTestId('right-panel')).toBeInTheDocument(); + }); + + it('transitions from empty → active board when gameJoined + gameStateChanged fire', async () => { + renderAppScreen(); + + expect(screen.getByTestId('game-empty')).toBeInTheDocument(); + + act(() => { + GameDispatch.gameJoined( + buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }), + ); + GameDispatch.gameStateChanged( + 42, + buildEventGameStateChanged([1, 2], 1), + ); + }); + + await waitFor(() => { + expect(screen.queryByTestId('game-empty')).not.toBeInTheDocument(); + }); + + expect(screen.getByTestId('player-board-1')).toBeInTheDocument(); + expect(screen.getByTestId('player-board-2')).toBeInTheDocument(); + expect(screen.getByTestId('hand-zone')).toBeInTheDocument(); + }); + + it('returns to the empty placeholder when gameLeft fires', async () => { + renderAppScreen(); + + act(() => { + GameDispatch.gameJoined( + buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }), + ); + GameDispatch.gameStateChanged( + 42, + buildEventGameStateChanged([1, 2], 1), + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('player-board-1')).toBeInTheDocument(); + }); + + act(() => { + GameDispatch.gameLeft(42); + }); + + await waitFor(() => { + expect(screen.getByTestId('game-empty')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('player-board-1')).not.toBeInTheDocument(); + }); + + it('hides the opponent selector for 2-player games but shows it for 3+', async () => { + renderAppScreen(); + + act(() => { + GameDispatch.gameJoined( + buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }), + ); + GameDispatch.gameStateChanged( + 42, + buildEventGameStateChanged([1, 2], 1), + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('player-board-1')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('opponent-selector')).not.toBeInTheDocument(); + + act(() => { + GameDispatch.gameStateChanged( + 42, + buildEventGameStateChanged([1, 2, 3], 1), + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('opponent-selector')).toBeInTheDocument(); + }); + }); + + it('mirrors the opponent board and leaves the local board upright', async () => { + renderAppScreen(); + + act(() => { + GameDispatch.gameJoined( + buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }), + ); + GameDispatch.gameStateChanged( + 42, + buildEventGameStateChanged([1, 2], 1), + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('player-board-2')).toHaveClass('player-board--mirrored'); + }); + + expect(screen.getByTestId('player-board-1')).not.toHaveClass('player-board--mirrored'); + }); + + it('renders the deck/graveyard/exile rail in desktop order (no stack in rail)', async () => { + renderAppScreen(); + + act(() => { + GameDispatch.gameJoined( + buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }), + ); + GameDispatch.gameStateChanged( + 42, + buildEventGameStateChanged([1, 2], 1), + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('player-board-1')).toBeInTheDocument(); + }); + + const localBoard = screen.getByTestId('player-board-1'); + const rail = within(localBoard).getByTestId('zone-rail'); + const labels = Array.from(rail.querySelectorAll('.zone-stack__label')).map( + (n) => n.textContent, + ); + expect(labels).toEqual(['Deck', 'Graveyard', 'Exile']); + expect(within(rail).queryByText('Stack')).not.toBeInTheDocument(); + }); + + it('sends a game_say command through the socket when a chat message is submitted', async () => { + // Establish a real mock socket so the outbound CommandContainer is captured. + connectRaw(); + + renderAppScreen(); + + act(() => { + GameDispatch.gameJoined( + buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }), + ); + // buildEventGameStateChanged sets gameStarted: true, suppressing the + // deck-select dialog which would otherwise block focus/interaction. + GameDispatch.gameStateChanged( + 42, + buildEventGameStateChanged([1, 2], 1), + ); + }); + + await waitFor(() => { + expect(screen.getByLabelText('game chat input')).not.toBeDisabled(); + }); + + const input = screen.getByLabelText('game chat input') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'gl hf' } }); + fireEvent.submit(input.closest('form')!); + + const captured = findLastGameCommand(Command_GameSay_ext); + expect(captured.value.message).toBe('gl hf'); + expect(captured.gameId).toBe(42); + }); + + it('navigates to /server when the local user is kicked', async () => { + renderAppScreen( + <> + + + , + ); + + act(() => { + GameDispatch.gameJoined( + buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }), + ); + GameDispatch.gameStateChanged( + 42, + buildEventGameStateChanged([1, 2], 1), + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('player-board-1')).toBeInTheDocument(); + }); + + act(() => { + GameDispatch.kicked(42); + }); + + await waitFor(() => { + expect(screen.getByTestId('app-location')).toHaveTextContent('/server'); + }); + }); + + it('navigates to /server when the game is closed by the host', async () => { + renderAppScreen( + <> + + + , + ); + + act(() => { + GameDispatch.gameJoined( + buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }), + ); + GameDispatch.gameStateChanged( + 42, + buildEventGameStateChanged([1, 2], 1), + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('player-board-1')).toBeInTheDocument(); + }); + + act(() => { + GameDispatch.gameClosed(42); + }); + + await waitFor(() => { + expect(screen.getByTestId('app-location')).toHaveTextContent('/server'); + }); + }); + + it('reflects a host change through both PlayerList badge and PlayerInfoPanel', async () => { + renderAppScreen(); + + act(() => { + GameDispatch.gameJoined( + buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }), + ); + GameDispatch.gameStateChanged( + 42, + buildEventGameStateChanged([1, 2], 1), + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('player-list-item-1')).toBeInTheDocument(); + }); + + // Host starts as 1; badge should be on row 1. + expect( + screen.getByTestId('player-list-item-1').querySelector('.player-list__host-badge'), + ).not.toBeNull(); + expect( + screen.getByTestId('player-list-item-2').querySelector('.player-list__host-badge'), + ).toBeNull(); + + // Host changes to player 2. + act(() => { + GameDispatch.gameHostChanged(42, 2); + }); + + await waitFor(() => { + expect( + screen.getByTestId('player-list-item-2').querySelector('.player-list__host-badge'), + ).not.toBeNull(); + }); + expect( + screen.getByTestId('player-list-item-1').querySelector('.player-list__host-badge'), + ).toBeNull(); + }); + + it('auto-opens the DeckSelectDialog when a game is joined and not started', async () => { + renderAppScreen(); + + act(() => { + GameDispatch.gameJoined( + buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }), + ); + GameDispatch.gameStateChanged( + 42, + create(Data.Event_GameStateChangedSchema, { + gameStarted: false, + activePlayerId: 1, + activePhase: -1, + playerList: [1, 2].map((pid) => + create(Data.ServerInfo_PlayerSchema, { + properties: create(Data.ServerInfo_PlayerPropertiesSchema, { + playerId: pid, + userInfo: create(Data.ServerInfo_UserSchema, { name: `P${pid}` }), + }), + deckList: '', + zoneList: [], + counterList: [], + arrowList: [], + }), + ), + }), + ); + }); + + await waitFor(() => { + expect(screen.getByLabelText('deck list')).toBeInTheDocument(); + }); + }); +}); diff --git a/webclient/integration/src/websocket/game.spec.ts b/webclient/integration/src/websocket/game.spec.ts index 4775b5332..43ad1056c 100644 --- a/webclient/integration/src/websocket/game.spec.ts +++ b/webclient/integration/src/websocket/game.spec.ts @@ -306,10 +306,22 @@ describe('game', () => { userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }), }), zoneList: [ - create(Data.ServerInfo_ZoneSchema, { name: 'deck', type: Data.ServerInfo_Zone_ZoneType.HiddenZone, cardList: deckCards, cardCount: 3 }), - create(Data.ServerInfo_ZoneSchema, { name: 'hand', type: Data.ServerInfo_Zone_ZoneType.HiddenZone, cardList: [], cardCount: 0 }), - create(Data.ServerInfo_ZoneSchema, { name: 'table', type: Data.ServerInfo_Zone_ZoneType.PublicZone, withCoords: true, cardList: [], cardCount: 0 }), - create(Data.ServerInfo_ZoneSchema, { name: 'grave', type: Data.ServerInfo_Zone_ZoneType.PublicZone, cardList: [], cardCount: 0 }), + create(Data.ServerInfo_ZoneSchema, { + name: 'deck', type: Data.ServerInfo_Zone_ZoneType.HiddenZone, + cardList: deckCards, cardCount: 3, + }), + create(Data.ServerInfo_ZoneSchema, { + name: 'hand', type: Data.ServerInfo_Zone_ZoneType.HiddenZone, + cardList: [], cardCount: 0, + }), + create(Data.ServerInfo_ZoneSchema, { + name: 'table', type: Data.ServerInfo_Zone_ZoneType.PublicZone, + withCoords: true, cardList: [], cardCount: 0, + }), + create(Data.ServerInfo_ZoneSchema, { + name: 'grave', type: Data.ServerInfo_Zone_ZoneType.PublicZone, + cardList: [], cardCount: 0, + }), ], counterList: [], arrowList: [], @@ -356,7 +368,14 @@ describe('game', () => { ext: Data.Event_GameSay_ext, value: create(Data.Event_GameSaySchema, { message: 'good luck!' }), })); - expect(store.getState().games.games[99].messages).toHaveLength(1); + // game.messages is a merged chat + event-log stream (matches desktop's + // MessageLogWidget). Earlier steps in this lifecycle (game-started, + // phase change, draw) also push event entries, so filter to chat. + const chatMessages = store + .getState() + .games.games[99].messages.filter((m) => m.kind === 'chat'); + expect(chatMessages).toHaveLength(1); + expect(chatMessages[0].message).toBe('good luck!'); // ── 6. Discard (move card from hand to graveyard) ──────────────────── deliverMessage(buildGameEventMessage({ @@ -413,4 +432,4 @@ describe('game', () => { expect(store.getState().games.games[99].players[1]).toBeUndefined(); }); -}); \ No newline at end of file +}); diff --git a/webclient/package-lock.json b/webclient/package-lock.json index 8697b0d3e..15765b403 100644 --- a/webclient/package-lock.json +++ b/webclient/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.8.2", "@emotion/styled": "^11.8.1", "@mui/icons-material": "^9.0.0", @@ -634,6 +636,45 @@ "node": ">=20.19.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", diff --git a/webclient/package.json b/webclient/package.json index 386070a37..c218ce03c 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -27,6 +27,8 @@ }, "dependencies": { "@bufbuild/protobuf": "^2.11.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.8.2", "@emotion/styled": "^11.8.1", "@mui/icons-material": "^9.0.0", diff --git a/webclient/src/__test-utils__/index.ts b/webclient/src/__test-utils__/index.ts index 7ee47e601..890c4a9d5 100644 --- a/webclient/src/__test-utils__/index.ts +++ b/webclient/src/__test-utils__/index.ts @@ -1,4 +1,10 @@ export { withMockLocation } from './globalGuards'; export { renderWithProviders } from './renderWithProviders'; export { createMockWebClient } from './mockWebClient'; -export { disconnectedState, connectedState, connectedWithRoomsState, makeUser } from './storeFixtures'; +export { + disconnectedState, + connectedState, + connectedWithRoomsState, + makeStoreState, + makeUser, +} from './storeFixtures'; diff --git a/webclient/src/__test-utils__/mockWebClient.ts b/webclient/src/__test-utils__/mockWebClient.ts index 875a4a9d9..3460199ef 100644 --- a/webclient/src/__test-utils__/mockWebClient.ts +++ b/webclient/src/__test-utils__/mockWebClient.ts @@ -34,10 +34,43 @@ export function createMockWebClient() { leaveRoom: vi.fn(), roomSay: vi.fn(), createGame: vi.fn(), + joinGame: vi.fn(), }, game: { - joinGame: vi.fn(), leaveGame: vi.fn(), + kickFromGame: vi.fn(), + gameSay: vi.fn(), + readyStart: vi.fn(), + concede: vi.fn(), + unconcede: vi.fn(), + judge: vi.fn(), + nextTurn: vi.fn(), + setActivePhase: vi.fn(), + reverseTurn: vi.fn(), + moveCard: vi.fn(), + flipCard: vi.fn(), + attachCard: vi.fn(), + createToken: vi.fn(), + setCardAttr: vi.fn(), + setCardCounter: vi.fn(), + incCardCounter: vi.fn(), + drawCards: vi.fn(), + undoDraw: vi.fn(), + createArrow: vi.fn(), + deleteArrow: vi.fn(), + createCounter: vi.fn(), + setCounter: vi.fn(), + incCounter: vi.fn(), + delCounter: vi.fn(), + shuffle: vi.fn(), + dumpZone: vi.fn(), + revealCards: vi.fn(), + changeZoneProperties: vi.fn(), + deckSelect: vi.fn(), + setSideboardPlan: vi.fn(), + setSideboardLock: vi.fn(), + mulligan: vi.fn(), + rollDie: vi.fn(), }, admin: { adjustMod: vi.fn(), diff --git a/webclient/src/__test-utils__/renderWithProviders.tsx b/webclient/src/__test-utils__/renderWithProviders.tsx index 7d78f171b..10f50628a 100644 --- a/webclient/src/__test-utils__/renderWithProviders.tsx +++ b/webclient/src/__test-utils__/renderWithProviders.tsx @@ -6,13 +6,25 @@ import { MemoryRouter } from 'react-router-dom'; import { I18nextProvider } from 'react-i18next'; import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; +import { DndContext } from '@dnd-kit/core'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; -import { gamesReducer } from '../store/game'; -import { roomsReducer } from '../store/rooms'; -import { serverReducer } from '../store/server'; -import { actionReducer } from '../store/actions'; +// Disables MUI's ripple animation in tests. The ripple fires a deferred +// state update after clicks/focus that would otherwise trigger a noisy +// "update to ForwardRef(TouchRipple) was not wrapped in act(...)" warning. +const testTheme = createTheme({ + components: { + MuiButtonBase: { defaultProps: { disableRipple: true } }, + }, +}); + +import { WebClientContext } from '../hooks/useWebClient'; +import type { WebClient } from '../websocket'; +import rootReducer from '../store/rootReducer'; import { ToastProvider } from '../components/Toast/ToastContext'; +import { storeMiddlewareOptions } from '../store/store'; import type { RootState } from '../store/store'; +import { createMockWebClient } from './mockWebClient'; // Non-empty `resources` registers en-US so `resolvedLanguage` is defined; // without it MUI warns about out-of-range Select values. @@ -24,15 +36,18 @@ testI18n.use(initReactI18next).init({ interpolation: { escapeValue: false }, }); +// `configureStore`'s `preloadedState` wants `PreloadedState>` +// which narrows collection types past our slice interfaces. A single cast +// here keeps the test harness loose (each test injects only the slices it +// cares about) while specs themselves stay strict via `makeStoreState`. function createTestStore(preloadedState?: Partial) { return configureStore({ - reducer: { - games: gamesReducer, - rooms: roomsReducer, - server: serverReducer, - action: actionReducer, - }, - preloadedState: preloadedState as any, + reducer: rootReducer, + preloadedState: preloadedState as Parameters[0]['preloadedState'], + // Share the production middleware config so the serializableCheck + // tolerates protobuf messages (isMessage) the same way the real store + // does — otherwise every proto-payload dispatch in tests spams stderr. + middleware: (getDefaultMiddleware) => getDefaultMiddleware(storeMiddlewareOptions), }); } @@ -40,6 +55,7 @@ interface ExtendedRenderOptions extends Omit { preloadedState?: Partial; store?: EnhancedStore; route?: string; + webClient?: WebClient; } export function renderWithProviders( @@ -48,6 +64,7 @@ export function renderWithProviders( preloadedState, store = createTestStore(preloadedState), route = '/', + webClient = createMockWebClient(), ...renderOptions }: ExtendedRenderOptions = {}, ) { @@ -55,11 +72,21 @@ export function renderWithProviders( return ( - - - {children} - - + + + + + + {children} + + + + + ); @@ -67,6 +94,7 @@ export function renderWithProviders( return { store, + webClient, ...render(ui, { wrapper: Wrapper, ...renderOptions }), }; } diff --git a/webclient/src/__test-utils__/storeFixtures.ts b/webclient/src/__test-utils__/storeFixtures.ts index 2d7a57878..62538b681 100644 --- a/webclient/src/__test-utils__/storeFixtures.ts +++ b/webclient/src/__test-utils__/storeFixtures.ts @@ -63,6 +63,8 @@ export const disconnectedState: Partial = { messages: {}, sortGamesBy: { field: App.GameSortField.START_TIME, order: App.SortDirection.DESC }, sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC }, + selectedGameIds: {}, + gameFilters: {}, }, games: { games: {} }, action: { type: null, payload: null, meta: null, error: false, count: 0 }, @@ -122,3 +124,27 @@ export const connectedWithRoomsState: Partial = { }; export { makeUser }; + +/** + * Deep-partial of a root state. Let specs pass partial slice shapes + * (typically just `games: { games: { ... } }`) without the ~60 fields of + * server/rooms that the test doesn't care about. + */ +type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; + +/** + * Wraps a partial root-state literal with a safe single `as`-cast so specs + * don't need to sprinkle `as any` on every `preloadedState` argument. The + * runtime value is the exact same literal; the only thing this helper buys + * is deleting the `as any` cast from call sites. + * + * @example + * renderWithProviders(, { + * preloadedState: makeStoreState({ + * games: { games: { 1: makeGameEntry({ ... }) } }, + * }), + * }); + */ +export function makeStoreState(partial: DeepPartial): Partial { + return partial as Partial; +} diff --git a/webclient/src/api/request/RoomsRequestImpl.ts b/webclient/src/api/request/RoomsRequestImpl.ts index e7264ca3b..27ff2eebb 100644 --- a/webclient/src/api/request/RoomsRequestImpl.ts +++ b/webclient/src/api/request/RoomsRequestImpl.ts @@ -1,5 +1,6 @@ import { RoomCommands, SessionCommands } from '@app/websocket'; import { WebsocketTypes } from '@app/websocket/types'; +import type { App } from '@app/types'; export class RoomsRequestImpl implements WebsocketTypes.IRoomsRequest { joinRoom(roomId: number): void { @@ -13,4 +14,12 @@ export class RoomsRequestImpl implements WebsocketTypes.IRoomsRequest { roomSay(roomId: number, message: string): void { RoomCommands.roomSay(roomId, message); } + + createGame(roomId: number, params: App.CreateGameParams): void { + RoomCommands.createGame(roomId, params); + } + + joinGame(roomId: number, params: App.JoinGameParams): void { + RoomCommands.joinGame(roomId, params); + } } diff --git a/webclient/src/colors.css b/webclient/src/colors.css new file mode 100644 index 000000000..92d1d7748 --- /dev/null +++ b/webclient/src/colors.css @@ -0,0 +1,25 @@ +/** + * Shared CSS custom properties. Declared at :root so any stylesheet can + * reference them via var(--name). Mirrors the constants in + * src/types/colors.ts — keep both in sync when adding new entries. + * + * Loaded once from src/index.tsx alongside the top-level stylesheet. + */ + +:root { + /* Arrow / modifier colors — paired with App.ArrowColor.* in TS. */ + --color-arrow-red: #e04b3b; + --color-arrow-yellow: #f0c83c; + --color-arrow-blue: #89b8e0; + --color-arrow-green: #3da26b; + + --color-arrow-red-glow: rgba(224, 75, 59, 0.55); + --color-arrow-green-glow: rgba(61, 162, 107, 0.55); + + /* Highlight yellow: active turn indicator, host crown, focus ring, + chat author, PhaseBar selection border. Kept as a single shade for + visual consistency across the game surface. */ + --color-highlight-yellow: #f7b01c; + --color-highlight-yellow-soft: rgba(247, 176, 28, 0.4); + --color-highlight-yellow-soft-alt: rgba(247, 176, 28, 0.55); +} diff --git a/webclient/src/components/Game/Battlefield/Battlefield.css b/webclient/src/components/Game/Battlefield/Battlefield.css new file mode 100644 index 000000000..d0bd68b36 --- /dev/null +++ b/webclient/src/components/Game/Battlefield/Battlefield.css @@ -0,0 +1,29 @@ +.battlefield { + display: flex; + flex-direction: column; + height: 100%; + background: #0f1c38; + border: 1px solid #1a2b52; + box-sizing: border-box; +} + +.battlefield__row { + flex: 1 1 0; + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + padding: 4px 8px; + box-sizing: border-box; + overflow-x: auto; + overflow-y: hidden; +} + +.battlefield__row + .battlefield__row { + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.battlefield__row--drop-over { + background: rgba(247, 176, 28, 0.08); + box-shadow: inset 0 0 0 2px rgba(247, 176, 28, 0.55); +} diff --git a/webclient/src/components/Game/Battlefield/Battlefield.spec.tsx b/webclient/src/components/Game/Battlefield/Battlefield.spec.tsx new file mode 100644 index 000000000..56eccd11d --- /dev/null +++ b/webclient/src/components/Game/Battlefield/Battlefield.spec.tsx @@ -0,0 +1,196 @@ +import { screen } from '@testing-library/react'; +import { App } from '@app/types'; + +vi.mock('../../../hooks/useSettings'); + +import { useSettings } from '../../../hooks/useSettings'; +import { makeSettings, makeSettingsHook } from '../../../hooks/__mocks__/useSettings'; +import { makeStoreState, renderWithProviders } from '../../../__test-utils__'; +import { + makeCard, + makeGameEntry, + makePlayerEntry, + makeZoneEntry, +} from '../../../store/game/__mocks__/fixtures'; +import Battlefield from './Battlefield'; + +function setInvert(invert: boolean) { + vi.mocked(useSettings).mockReturnValue( + makeSettingsHook({ value: makeSettings({ invertVerticalCoordinate: invert }) }), + ); +} + +function stateWithBattlefield(cards: ReturnType[]) { + const table = makeZoneEntry({ + name: App.ZoneName.TABLE, + type: 1, + withCoords: true, + cardCount: cards.length, + cards, + }); + const player = makePlayerEntry({ + zones: { [App.ZoneName.TABLE]: table }, + }); + const game = makeGameEntry({ + localPlayerId: 1, + players: { 1: player }, + }); + return makeStoreState({ games: { games: { 1: game } } }); +} + +describe('Battlefield', () => { + beforeEach(() => { + vi.mocked(useSettings).mockReturnValue(makeSettingsHook()); + }); + + it('renders three rows regardless of card count', () => { + renderWithProviders(, { + preloadedState: stateWithBattlefield([]), + }); + + expect(screen.getByTestId('battlefield-row-0')).toBeInTheDocument(); + expect(screen.getByTestId('battlefield-row-1')).toBeInTheDocument(); + expect(screen.getByTestId('battlefield-row-2')).toBeInTheDocument(); + }); + + it('places cards into rows by y coordinate', () => { + const cards = [ + makeCard({ id: 1, name: 'Top', x: 0, y: 0 }), + makeCard({ id: 2, name: 'Mid', x: 0, y: 1 }), + makeCard({ id: 3, name: 'Bot', x: 0, y: 2 }), + ]; + renderWithProviders(, { + preloadedState: stateWithBattlefield(cards), + }); + + expect(screen.getByTestId('battlefield-row-0').querySelector('img')?.alt).toBe('Top'); + expect(screen.getByTestId('battlefield-row-1').querySelector('img')?.alt).toBe('Mid'); + expect(screen.getByTestId('battlefield-row-2').querySelector('img')?.alt).toBe('Bot'); + }); + + it('clamps out-of-range y values into the three-row space', () => { + const cards = [ + makeCard({ id: 1, name: 'TooHigh', x: 0, y: -5 }), + makeCard({ id: 2, name: 'TooLow', x: 0, y: 99 }), + ]; + renderWithProviders(, { + preloadedState: stateWithBattlefield(cards), + }); + + expect(screen.getByTestId('battlefield-row-0').querySelector('img')?.alt).toBe('TooHigh'); + expect(screen.getByTestId('battlefield-row-2').querySelector('img')?.alt).toBe('TooLow'); + }); + + it('sorts cards within a row by x coordinate', () => { + const cards = [ + makeCard({ id: 1, name: 'Right', x: 10, y: 0 }), + makeCard({ id: 2, name: 'Left', x: 0, y: 0 }), + ]; + renderWithProviders(, { + preloadedState: stateWithBattlefield(cards), + }); + + const row0 = screen.getByTestId('battlefield-row-0'); + const imgs = Array.from(row0.querySelectorAll('img')); + expect(imgs.map((i) => i.alt)).toEqual(['Left', 'Right']); + }); + + it('renders rows top-to-bottom as 0,1,2 when not mirrored', () => { + const cards = [ + makeCard({ id: 1, name: 'A', x: 0, y: 0 }), + makeCard({ id: 2, name: 'B', x: 0, y: 1 }), + makeCard({ id: 3, name: 'C', x: 0, y: 2 }), + ]; + renderWithProviders(, { + preloadedState: stateWithBattlefield(cards), + }); + + const rowsInOrder = Array.from( + screen.getByTestId('battlefield').querySelectorAll('.battlefield__row'), + ); + expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['0', '1', '2']); + }); + + it('renders rows bottom-to-top as 2,1,0 when mirrored (opponent)', () => { + const cards = [ + makeCard({ id: 1, name: 'A', x: 0, y: 0 }), + makeCard({ id: 2, name: 'B', x: 0, y: 1 }), + makeCard({ id: 3, name: 'C', x: 0, y: 2 }), + ]; + renderWithProviders(, { + preloadedState: stateWithBattlefield(cards), + }); + + const rowsInOrder = Array.from( + screen.getByTestId('battlefield').querySelectorAll('.battlefield__row'), + ); + expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['2', '1', '0']); + }); + + it('passes inverted=true to every CardSlot when mirrored', () => { + const cards = [makeCard({ id: 1, name: 'A', x: 0, y: 0 })]; + const { container } = renderWithProviders( + , + { preloadedState: stateWithBattlefield(cards) }, + ); + + expect(container.querySelector('.card-slot--inverted')).not.toBeNull(); + }); + + describe('invertVerticalCoordinate user setting', () => { + it('renders rows bottom-to-top when the setting is on and not mirrored (local player)', () => { + setInvert(true); + const cards = [ + makeCard({ id: 1, name: 'A', x: 0, y: 0 }), + makeCard({ id: 2, name: 'B', x: 0, y: 1 }), + makeCard({ id: 3, name: 'C', x: 0, y: 2 }), + ]; + renderWithProviders(, { + preloadedState: stateWithBattlefield(cards), + }); + + const rowsInOrder = Array.from( + screen.getByTestId('battlefield').querySelectorAll('.battlefield__row'), + ); + expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['2', '1', '0']); + }); + + it('restores top-to-bottom ordering when setting is on AND mirrored (XOR cancels)', () => { + setInvert(true); + const cards = [ + makeCard({ id: 1, name: 'A', x: 0, y: 0 }), + makeCard({ id: 2, name: 'B', x: 0, y: 1 }), + ]; + renderWithProviders(, { + preloadedState: stateWithBattlefield(cards), + }); + + const rowsInOrder = Array.from( + screen.getByTestId('battlefield').querySelectorAll('.battlefield__row'), + ); + expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['0', '1', '2']); + }); + + it('passes inverted=true to CardSlots when setting is on and not mirrored', () => { + setInvert(true); + const cards = [makeCard({ id: 1, name: 'A', x: 0, y: 0 })]; + const { container } = renderWithProviders( + , + { preloadedState: stateWithBattlefield(cards) }, + ); + + expect(container.querySelector('.card-slot--inverted')).not.toBeNull(); + }); + + it('passes inverted=false to CardSlots when setting is on AND mirrored (XOR)', () => { + setInvert(true); + const cards = [makeCard({ id: 1, name: 'A', x: 0, y: 0 })]; + const { container } = renderWithProviders( + , + { preloadedState: stateWithBattlefield(cards) }, + ); + + expect(container.querySelector('.card-slot--inverted')).toBeNull(); + }); + }); +}); diff --git a/webclient/src/components/Game/Battlefield/Battlefield.tsx b/webclient/src/components/Game/Battlefield/Battlefield.tsx new file mode 100644 index 000000000..18d9eff37 --- /dev/null +++ b/webclient/src/components/Game/Battlefield/Battlefield.tsx @@ -0,0 +1,93 @@ +import { useMemo } from 'react'; +import { App, Data } from '@app/types'; +import { GameSelectors, useAppSelector } from '@app/store'; +import { useSettings } from '@app/hooks'; + +import CardSlot from '../CardSlot/CardSlot'; +import { makeCardKey } from '../CardRegistry/CardRegistryContext'; +import BattlefieldRow from './BattlefieldRow'; + +import './Battlefield.css'; + +export interface BattlefieldProps { + gameId: number; + playerId: number; + mirrored?: boolean; + canAct?: boolean; + arrowSourceKey?: string | null; + onCardHover?: (card: Data.ServerInfo_Card) => void; + onCardClick?: (playerId: number, zone: string, card: Data.ServerInfo_Card) => void; + onCardContextMenu?: (card: Data.ServerInfo_Card, event: React.MouseEvent) => void; + onCardDoubleClick?: (card: Data.ServerInfo_Card) => void; +} + +const ROW_COUNT = 3; + +function rowIndexFor(card: Data.ServerInfo_Card): number { + const y = card.y ?? 0; + return Math.max(0, Math.min(ROW_COUNT - 1, y)); +} + +function Battlefield({ + gameId, + playerId, + mirrored = false, + canAct = false, + arrowSourceKey = null, + onCardHover, + onCardClick, + onCardContextMenu, + onCardDoubleClick, +}: BattlefieldProps) { + const cards = useAppSelector((state) => + GameSelectors.getCards(state, gameId, playerId, App.ZoneName.TABLE), + ); + + const { value: settings } = useSettings(); + const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false; + // Mirrors desktop TableZone::isInverted() — XOR of per-player mirrored and + // the global invertVerticalCoordinate preference. + const isInverted = mirrored !== invertVerticalCoordinate; + + const rows = useMemo(() => { + const bucketed: Data.ServerInfo_Card[][] = Array.from({ length: ROW_COUNT }, () => []); + for (const card of cards) { + bucketed[rowIndexFor(card)].push(card); + } + for (const row of bucketed) { + row.sort((a, b) => (a.x ?? 0) - (b.x ?? 0)); + } + return bucketed; + }, [cards]); + + const rowOrder = isInverted ? [2, 1, 0] : [0, 1, 2]; + + return ( +
+ {rowOrder.map((rowIdx) => ( + + {rows[rowIdx].map((card) => { + const key = makeCardKey(playerId, App.ZoneName.TABLE, card.id); + return ( + onCardClick?.(playerId, App.ZoneName.TABLE, c)} + onContextMenu={onCardContextMenu} + onDoubleClick={onCardDoubleClick} + /> + ); + })} + + ))} +
+ ); +} + +export default Battlefield; diff --git a/webclient/src/components/Game/Battlefield/BattlefieldRow.tsx b/webclient/src/components/Game/Battlefield/BattlefieldRow.tsx new file mode 100644 index 000000000..eabc67b3e --- /dev/null +++ b/webclient/src/components/Game/Battlefield/BattlefieldRow.tsx @@ -0,0 +1,30 @@ +import { ReactNode } from 'react'; +import { useDroppable } from '@dnd-kit/core'; +import { App } from '@app/types'; +import { cx } from '@app/utils'; + +export interface BattlefieldRowProps { + playerId: number; + row: number; + children: ReactNode; +} + +function BattlefieldRow({ playerId, row, children }: BattlefieldRowProps) { + const { setNodeRef, isOver } = useDroppable({ + id: `battlefield-${playerId}-${row}`, + data: { targetPlayerId: playerId, targetZone: App.ZoneName.TABLE, row }, + }); + + return ( +
+ {children} +
+ ); +} + +export default BattlefieldRow; diff --git a/webclient/src/components/Game/CardContextMenu/CardContextMenu.css b/webclient/src/components/Game/CardContextMenu/CardContextMenu.css new file mode 100644 index 000000000..b40a5813b --- /dev/null +++ b/webclient/src/components/Game/CardContextMenu/CardContextMenu.css @@ -0,0 +1,3 @@ +.card-context-menu .MuiPaper-root { + min-width: 220px; +} diff --git a/webclient/src/components/Game/CardContextMenu/CardContextMenu.spec.tsx b/webclient/src/components/Game/CardContextMenu/CardContextMenu.spec.tsx new file mode 100644 index 000000000..b50cc5801 --- /dev/null +++ b/webclient/src/components/Game/CardContextMenu/CardContextMenu.spec.tsx @@ -0,0 +1,396 @@ +import { screen, fireEvent } from '@testing-library/react'; +import { App, Data } from '@app/types'; + +import { createMockWebClient, renderWithProviders } from '../../../__test-utils__'; +import { makeCard } from '../../../store/game/__mocks__/fixtures'; +import CardContextMenu from './CardContextMenu'; + +const defaultProps = { + isOpen: true, + anchorPosition: { top: 100, left: 100 }, + gameId: 1, + localPlayerId: 1, + ownerPlayerId: 1, + sourceZone: App.ZoneName.TABLE, + onClose: () => {}, + onRequestSetPT: () => {}, + onRequestSetAnnotation: () => {}, + onRequestSetCounter: () => {}, + onRequestDrawArrow: () => {}, + onRequestAttach: () => {}, + onRequestMoveToLibraryAt: () => {}, +}; + +describe('CardContextMenu', () => { + it('does not render when card is null', () => { + const { container } = renderWithProviders( + , + ); + expect(container.querySelector('[data-testid="card-context-menu"]')).toBeNull(); + }); + + it('does not render when closed', () => { + renderWithProviders( + , + ); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('renders all expected menu items', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('Flip')).toBeInTheDocument(); + expect(screen.getByText('Tap')).toBeInTheDocument(); + expect(screen.getByText('Face Down')).toBeInTheDocument(); + expect(screen.getByText('Doesn\'t Untap')).toBeInTheDocument(); + expect(screen.getByText('Set P/T…')).toBeInTheDocument(); + expect(screen.getByText('Set Annotation…')).toBeInTheDocument(); + expect(screen.getByText('Send to Hand')).toBeInTheDocument(); + expect(screen.getByText('Send to Graveyard')).toBeInTheDocument(); + expect(screen.getByText('Send to Exile')).toBeInTheDocument(); + expect(screen.getByText('Send to Library (top)')).toBeInTheDocument(); + expect(screen.getByText('Send to Library (bottom)')).toBeInTheDocument(); + }); + + it('flips the card via flipCard and closes the menu', () => { + const webClient = createMockWebClient(); + const onClose = vi.fn(); + const card = makeCard({ id: 10, faceDown: false }); + + renderWithProviders( + , + { webClient }, + ); + + fireEvent.click(screen.getByText('Flip')); + + expect(webClient.request.game.flipCard).toHaveBeenCalledWith(1, { + zone: App.ZoneName.TABLE, + cardId: 10, + faceDown: true, + }); + expect(onClose).toHaveBeenCalled(); + }); + + it('toggles tap via setCardAttr (untapped → tapped)', () => { + const webClient = createMockWebClient(); + renderWithProviders( + , + { webClient }, + ); + + fireEvent.click(screen.getByText('Tap')); + + expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, { + zone: App.ZoneName.TABLE, + cardId: 5, + attribute: Data.CardAttribute.AttrTapped, + attrValue: '1', + }); + }); + + it('shows Untap label and sends "0" when the card is already tapped', () => { + const webClient = createMockWebClient(); + renderWithProviders( + , + { webClient }, + ); + + expect(screen.getByText('Untap')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Untap')); + + expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, { + zone: App.ZoneName.TABLE, + cardId: 5, + attribute: Data.CardAttribute.AttrTapped, + attrValue: '0', + }); + }); + + it('toggles Face Down and shows Face Up when already face-down', () => { + const webClient = createMockWebClient(); + renderWithProviders( + , + { webClient }, + ); + + expect(screen.getByText('Face Up')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Face Up')); + + expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, { + zone: App.ZoneName.TABLE, + cardId: 5, + attribute: Data.CardAttribute.AttrFaceDown, + attrValue: '0', + }); + }); + + it('toggles Doesn\'t Untap and shows Allow Untap when already set', () => { + const webClient = createMockWebClient(); + renderWithProviders( + , + { webClient }, + ); + + expect(screen.getByText('Allow Untap')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Allow Untap')); + + expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, { + zone: App.ZoneName.TABLE, + cardId: 5, + attribute: Data.CardAttribute.AttrDoesntUntap, + attrValue: '0', + }); + }); + + it('requests the PT prompt via parent callback', () => { + const onRequestSetPT = vi.fn(); + const onClose = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByText('Set P/T…')); + + expect(onRequestSetPT).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('requests the Annotation prompt via parent callback', () => { + const onRequestSetAnnotation = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByText('Set Annotation…')); + + expect(onRequestSetAnnotation).toHaveBeenCalled(); + }); + + it('moves to hand via moveCard with x=-1 (append)', () => { + const webClient = createMockWebClient(); + renderWithProviders( + , + { webClient }, + ); + + fireEvent.click(screen.getByText('Send to Hand')); + + expect(webClient.request.game.moveCard).toHaveBeenCalledWith(1, { + startPlayerId: 1, + startZone: App.ZoneName.TABLE, + cardsToMove: { card: [{ cardId: 7 }] }, + targetPlayerId: 1, + targetZone: App.ZoneName.HAND, + x: -1, + y: 0, + isReversed: false, + }); + }); + + it('hides mutator items (tap, flip, move, counters, P/T) for opponent-owned cards (desktop parity)', () => { + renderWithProviders( + , + ); + + // Mutators gone: + expect(screen.queryByText('Flip')).not.toBeInTheDocument(); + expect(screen.queryByText('Tap')).not.toBeInTheDocument(); + expect(screen.queryByText('Set P/T…')).not.toBeInTheDocument(); + expect(screen.queryByText('Set counter…')).not.toBeInTheDocument(); + expect(screen.queryByText('Send to Hand')).not.toBeInTheDocument(); + expect(screen.queryByText('Attach to card…')).not.toBeInTheDocument(); + + // Read-only stays: + expect(screen.getByText('Draw arrow from here')).toBeInTheDocument(); + }); + + it('routes moves through the acting (local) player when invoked on an owned card', () => { + const webClient = createMockWebClient(); + renderWithProviders( + , + { webClient }, + ); + + fireEvent.click(screen.getByText('Send to Hand')); + + expect(webClient.request.game.moveCard).toHaveBeenCalledWith(1, expect.objectContaining({ + startPlayerId: 1, + targetPlayerId: 1, + })); + }); + + it('moves to library top vs bottom with distinct x values', () => { + const webClient = createMockWebClient(); + renderWithProviders( + , + { webClient }, + ); + + fireEvent.click(screen.getByText('Send to Library (top)')); + expect(webClient.request.game.moveCard).toHaveBeenLastCalledWith(1, expect.objectContaining({ + targetZone: App.ZoneName.DECK, + x: 0, + })); + + fireEvent.click(screen.getByText('Send to Library (bottom)')); + expect(webClient.request.game.moveCard).toHaveBeenLastCalledWith(1, expect.objectContaining({ + targetZone: App.ZoneName.DECK, + x: -1, + })); + }); + + it('adds a counter via incCardCounter (+1 on id 0)', () => { + const webClient = createMockWebClient(); + renderWithProviders( + , + { webClient }, + ); + + fireEvent.click(screen.getByText('Add counter')); + + expect(webClient.request.game.incCardCounter).toHaveBeenCalledWith(1, { + zone: App.ZoneName.TABLE, + cardId: 9, + counterId: 0, + counterDelta: 1, + }); + }); + + it('removes a counter via incCardCounter (-1 on id 0)', () => { + const webClient = createMockWebClient(); + renderWithProviders( + , + { webClient }, + ); + + fireEvent.click(screen.getByText('Remove counter')); + + expect(webClient.request.game.incCardCounter).toHaveBeenCalledWith(1, { + zone: App.ZoneName.TABLE, + cardId: 9, + counterId: 0, + counterDelta: -1, + }); + }); + + it('defers "Set counter…" to the parent callback', () => { + const onRequestSetCounter = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByText('Set counter…')); + + expect(onRequestSetCounter).toHaveBeenCalled(); + }); + + it('defers "Draw arrow from here" to the parent callback', () => { + const onRequestDrawArrow = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByText('Draw arrow from here')); + + expect(onRequestDrawArrow).toHaveBeenCalled(); + }); + + describe('Attach / Unattach', () => { + it('defers "Attach to card…" to the parent callback', () => { + const onRequestAttach = vi.fn(); + const onClose = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByText('Attach to card…')); + + expect(onRequestAttach).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('does not show "Unattach" when the card is not attached (attachCardId = -1)', () => { + renderWithProviders( + , + ); + + expect(screen.queryByText('Unattach')).not.toBeInTheDocument(); + }); + + it('shows "Unattach" and dispatches attachCard with only startZone+cardId (desktop parity)', () => { + const webClient = createMockWebClient(); + const onClose = vi.fn(); + renderWithProviders( + , + { webClient }, + ); + + fireEvent.click(screen.getByText('Unattach')); + + // Target fields are intentionally absent. The server uses proto2 + // presence (`has_target_player_id()`) to detect "detach"; passing + // targetPlayerId: -1 would leave presence set and the server would + // treat the message as an attach with a missing player. + expect(webClient.request.game.attachCard).toHaveBeenCalledWith(1, { + startZone: App.ZoneName.TABLE, + cardId: 11, + }); + expect(onClose).toHaveBeenCalled(); + }); + + it('hides Attach / Unattach when the source card is not on the table', () => { + renderWithProviders( + , + ); + + expect(screen.queryByText('Attach to card…')).not.toBeInTheDocument(); + expect(screen.queryByText('Unattach')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/webclient/src/components/Game/CardContextMenu/CardContextMenu.tsx b/webclient/src/components/Game/CardContextMenu/CardContextMenu.tsx new file mode 100644 index 000000000..b546065b5 --- /dev/null +++ b/webclient/src/components/Game/CardContextMenu/CardContextMenu.tsx @@ -0,0 +1,234 @@ +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Divider from '@mui/material/Divider'; + +import { useWebClient } from '@app/hooks'; +import { App, Data } from '@app/types'; + +import './CardContextMenu.css'; + +export interface CardContextMenuProps { + isOpen: boolean; + anchorPosition: { top: number; left: number } | null; + gameId: number; + localPlayerId: number | null; + card: Data.ServerInfo_Card | null; + ownerPlayerId: number | null; + sourceZone: string | null; + onClose: () => void; + onRequestSetPT: () => void; + onRequestSetAnnotation: () => void; + onRequestSetCounter: () => void; + onRequestDrawArrow: () => void; + onRequestAttach: () => void; + onRequestMoveToLibraryAt: () => void; +} + +interface MoveTarget { + label: string; + zone: string; + x: number; + y: number; +} + +// Mirrors desktop's cockatrice/src/game/player/menu/move_menu.cpp:32-42 — +// six fixed targets plus one prompt ("Move to library at position…") for the +// 7-entry parity. Note that desktop's "Send to Table" label maps to our +// "Send to Battlefield" (same wire semantics: zone=table, x=0, y=0); the +// label diverges but the command is identical. +const MOVE_TARGETS: ReadonlyArray = [ + { label: 'Send to Hand', zone: App.ZoneName.HAND, x: -1, y: 0 }, + { label: 'Send to Battlefield', zone: App.ZoneName.TABLE, x: 0, y: 0 }, + { label: 'Send to Graveyard', zone: App.ZoneName.GRAVE, x: 0, y: 0 }, + { label: 'Send to Exile', zone: App.ZoneName.EXILE, x: 0, y: 0 }, + { label: 'Send to Library (top)', zone: App.ZoneName.DECK, x: 0, y: 0 }, + { label: 'Send to Library (bottom)', zone: App.ZoneName.DECK, x: -1, y: 0 }, +]; + +function CardContextMenu({ + isOpen, + anchorPosition, + gameId, + localPlayerId, + card, + ownerPlayerId, + sourceZone, + onClose, + onRequestSetPT, + onRequestSetAnnotation, + onRequestSetCounter, + onRequestDrawArrow, + onRequestAttach, + onRequestMoveToLibraryAt, +}: CardContextMenuProps) { + const webClient = useWebClient(); + + if (!card || ownerPlayerId == null || sourceZone == null || localPlayerId == null) { + return null; + } + + const game = webClient.request.game; + const zone = sourceZone; + const cardId = card.id; + + const setAttr = (attribute: Data.CardAttribute, value: string) => { + game.setCardAttr(gameId, { zone, cardId, attribute, attrValue: value }); + }; + + const handleFlip = () => { + // TODO(card-db): desktop's Player::actCardMenuFlip reads the card's stored + // P/T and forwards it so the revealed side shows the correct stats + // (cockatrice/src/game/player/player_actions.cpp:1805-1810). We can't + // do that without a card-database-by-name lookup, which isn't wired in + // the webclient yet. The server re-derives PT from the card DB for known + // names, so omitting `pt` is harmless for non-custom cards. + game.flipCard(gameId, { zone, cardId, faceDown: !card.faceDown }); + onClose(); + }; + + const handleTapToggle = () => { + setAttr(Data.CardAttribute.AttrTapped, card.tapped ? '0' : '1'); + onClose(); + }; + + const handleFaceDownToggle = () => { + setAttr(Data.CardAttribute.AttrFaceDown, card.faceDown ? '0' : '1'); + onClose(); + }; + + const handleDoesntUntapToggle = () => { + setAttr(Data.CardAttribute.AttrDoesntUntap, card.doesntUntap ? '0' : '1'); + onClose(); + }; + + const handleSetPT = () => { + onRequestSetPT(); + onClose(); + }; + + const handleSetAnnotation = () => { + onRequestSetAnnotation(); + onClose(); + }; + + const handleCardCounterDelta = (delta: number) => { + game.incCardCounter(gameId, { + zone, + cardId, + counterId: 0, + counterDelta: delta, + }); + onClose(); + }; + + const handleSetCardCounter = () => { + onRequestSetCounter(); + onClose(); + }; + + const handleDrawArrow = () => { + onRequestDrawArrow(); + onClose(); + }; + + const handleAttach = () => { + onRequestAttach(); + onClose(); + }; + + const handleUnattach = () => { + // Desktop's actUnattach sends only start_zone + card_id; the server uses + // proto2 presence (`has_target_player_id()`) to detect "detach". Setting + // targetPlayerId: -1 here would leave presence set and trip the attach + // code path server-side. MessageInitShape makes these fields optional, + // so omitting them produces an unset wire field. + game.attachCard(gameId, { startZone: zone, cardId }); + onClose(); + }; + + const isAttached = card.attachCardId >= 0; + // Desktop's actAttach is only available from a table card; other zones + // never expose the attach arrow. + const canAttach = sourceZone === App.ZoneName.TABLE; + + // Mutating actions (tap, flip, counters, attrs, P/T, annotation, attach, + // move) require ownership of the card — matches desktop's + // `card_menu.cpp:151-161` which drops all mutators when the menu target + // isn't getLocalOrJudge()-modifiable. Read-only actions (Draw arrow) + // stay available for planning/communication. + const isOwnedByLocal = ownerPlayerId === localPlayerId; + + const handleMove = (target: MoveTarget) => { + // targetPlayerId is the ACTING player (local), matching desktop's + // Player::actMoveCardTo* which uses playerInfo->getId(). + game.moveCard(gameId, { + startPlayerId: ownerPlayerId, + startZone: sourceZone, + cardsToMove: { card: [{ cardId }] }, + targetPlayerId: localPlayerId, + targetZone: target.zone, + x: target.x, + y: target.y, + isReversed: false, + }); + onClose(); + }; + + return ( + + {isOwnedByLocal && ( + <> + Flip + {card.tapped ? 'Untap' : 'Tap'} + + {card.faceDown ? 'Face Up' : 'Face Down'} + + + {card.doesntUntap ? 'Allow Untap' : 'Doesn\'t Untap'} + + Set P/T… + Set Annotation… + + handleCardCounterDelta(+1)}>Add counter + handleCardCounterDelta(-1)}>Remove counter + Set counter… + + + )} + Draw arrow from here + {isOwnedByLocal && canAttach && ( + Attach to card… + )} + {isOwnedByLocal && canAttach && isAttached && ( + Unattach + )} + {isOwnedByLocal && ( + <> + + {MOVE_TARGETS.map((t) => ( + handleMove(t)}> + {t.label} + + ))} + { + onRequestMoveToLibraryAt(); + onClose(); + }} + > + Move to library at position… + + + )} + + ); +} + +export default CardContextMenu; diff --git a/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.css b/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.css new file mode 100644 index 000000000..fab536600 --- /dev/null +++ b/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.css @@ -0,0 +1,24 @@ +.card-drag-overlay { + width: 146px; + height: 204px; + border-radius: 6px; + overflow: hidden; + opacity: 0.85; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55); + pointer-events: none; +} + +.card-drag-overlay__image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.card-drag-overlay__back { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #2a1f3d 0%, #1a1028 60%, #0d0617 100%); + border: 1px solid #3a2d50; + box-sizing: border-box; +} diff --git a/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.spec.tsx b/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.spec.tsx new file mode 100644 index 000000000..c76481887 --- /dev/null +++ b/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.spec.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react'; + +import { makeCard } from '../../../store/game/__mocks__/fixtures'; +import CardDragOverlay from './CardDragOverlay'; + +describe('CardDragOverlay', () => { + it('renders the Scryfall image for a face-up card', () => { + render(); + + const img = screen.getByAltText('Lightning Bolt') as HTMLImageElement; + expect(img.src).toContain('Lightning%20Bolt'); + expect(img.src).toContain('version=small'); + }); + + it('renders the face-down placeholder for hidden cards', () => { + render(); + + expect(screen.getByLabelText('face-down card')).toBeInTheDocument(); + }); +}); diff --git a/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.tsx b/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.tsx new file mode 100644 index 000000000..590c3e4a8 --- /dev/null +++ b/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.tsx @@ -0,0 +1,24 @@ +import { useScryfallCard } from '@app/hooks'; +import type { Data } from '@app/types'; + +import './CardDragOverlay.css'; + +export interface CardDragOverlayProps { + card: Data.ServerInfo_Card; +} + +function CardDragOverlay({ card }: CardDragOverlayProps) { + const { smallUrl } = useScryfallCard(card); + + return ( +
+ {card.faceDown || !smallUrl ? ( +
+ ) : ( + {card.name} + )} +
+ ); +} + +export default CardDragOverlay; diff --git a/webclient/src/components/Game/CardPreview/CardPreview.css b/webclient/src/components/Game/CardPreview/CardPreview.css new file mode 100644 index 000000000..308d2ce8f --- /dev/null +++ b/webclient/src/components/Game/CardPreview/CardPreview.css @@ -0,0 +1,44 @@ +.card-preview { + height: 340px; + padding: 12px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + background: #0a1225; + border-bottom: 1px solid #1a2b52; +} + +.card-preview__empty { + color: #5a6a8a; + font-size: 12px; + font-style: italic; + text-align: center; +} + +.card-preview__frame { + position: relative; + width: 100%; + max-width: 280px; + aspect-ratio: 488 / 680; + border-radius: 10px; + overflow: hidden; + background: #0d1930; +} + +.card-preview__image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.card-preview__image--normal { + opacity: 0; + transition: opacity 180ms ease-out; +} + +.card-preview__image--normal.card-preview__image--loaded { + opacity: 1; +} diff --git a/webclient/src/components/Game/CardPreview/CardPreview.spec.tsx b/webclient/src/components/Game/CardPreview/CardPreview.spec.tsx new file mode 100644 index 000000000..05f2c3beb --- /dev/null +++ b/webclient/src/components/Game/CardPreview/CardPreview.spec.tsx @@ -0,0 +1,55 @@ +import { render, screen, fireEvent } from '@testing-library/react'; + +import { makeCard } from '../../../store/game/__mocks__/fixtures'; +import CardPreview from './CardPreview'; + +describe('CardPreview', () => { + it('shows an empty hint when no card is hovered', () => { + render(); + expect(screen.getByText(/hover a card/i)).toBeInTheDocument(); + }); + + it('renders the small image immediately on hover', () => { + const card = makeCard({ name: 'Lightning Bolt' }); + render(); + + const small = document.querySelector('.card-preview__image--small') as HTMLImageElement; + expect(small).not.toBeNull(); + expect(small.src).toContain('version=small'); + expect(small.src).toContain('Lightning%20Bolt'); + }); + + it('renders a normal image that stays transparent until it loads', () => { + const card = makeCard({ name: 'Lightning Bolt' }); + render(); + + const normal = screen.getByTestId('card-preview-normal') as HTMLImageElement; + expect(normal.src).toContain('version=normal'); + expect(normal).not.toHaveClass('card-preview__image--loaded'); + }); + + it('reveals the normal image once onLoad fires', () => { + const card = makeCard({ name: 'Lightning Bolt' }); + render(); + + const normal = screen.getByTestId('card-preview-normal'); + fireEvent.load(normal); + expect(normal).toHaveClass('card-preview__image--loaded'); + }); + + it('resets the loaded flag when the card changes', () => { + const a = makeCard({ id: 1, name: 'A' }); + const b = makeCard({ id: 2, name: 'B' }); + const { rerender } = render(); + + fireEvent.load(screen.getByTestId('card-preview-normal')); + expect(screen.getByTestId('card-preview-normal')).toHaveClass( + 'card-preview__image--loaded', + ); + + rerender(); + expect(screen.getByTestId('card-preview-normal')).not.toHaveClass( + 'card-preview__image--loaded', + ); + }); +}); diff --git a/webclient/src/components/Game/CardPreview/CardPreview.tsx b/webclient/src/components/Game/CardPreview/CardPreview.tsx new file mode 100644 index 000000000..3a4da2ccb --- /dev/null +++ b/webclient/src/components/Game/CardPreview/CardPreview.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import type { Data } from '@app/types'; +import { useScryfallCard } from '@app/hooks'; + +import './CardPreview.css'; + +export interface CardPreviewProps { + card: Data.ServerInfo_Card | null | undefined; +} + +function CardPreview({ card }: CardPreviewProps) { + const { smallUrl, normalUrl, ready } = useScryfallCard(card ?? null); + const [normalLoaded, setNormalLoaded] = useState(false); + + useEffect(() => { + setNormalLoaded(false); + }, [normalUrl]); + + return ( +
+ {!ready && ( +
Hover a card to preview
+ )} + {ready && smallUrl && ( +
+ {card?.name + {normalUrl && ( + {card?.name setNormalLoaded(true)} + data-testid="card-preview-normal" + /> + )} +
+ )} +
+ ); +} + +export default CardPreview; diff --git a/webclient/src/components/Game/CardRegistry/CardRegistryContext.ts b/webclient/src/components/Game/CardRegistry/CardRegistryContext.ts new file mode 100644 index 000000000..24c4f4039 --- /dev/null +++ b/webclient/src/components/Game/CardRegistry/CardRegistryContext.ts @@ -0,0 +1,64 @@ +import { createContext, useCallback, useContext } from 'react'; + +export type CardKey = string; + +export function makeCardKey(playerId: number, zone: string, cardId: number): CardKey { + return `${playerId}-${zone}-${cardId}`; +} + +export interface CardRegistry { + register(key: CardKey, el: HTMLElement): void; + unregister(key: CardKey): void; + get(key: CardKey): HTMLElement | undefined; + subscribe(listener: () => void): () => void; +} + +export const CardRegistryContext = createContext(null); + +export function useCardRegistry(): CardRegistry | null { + return useContext(CardRegistryContext); +} + +export function useRegisterCardRef(key: CardKey | null) { + const registry = useCardRegistry(); + return useCallback( + (el: HTMLElement | null) => { + if (!registry || key == null) { + return; + } + if (el) { + registry.register(key, el); + } else { + registry.unregister(key); + } + }, + [registry, key], + ); +} + +export function createCardRegistry(): CardRegistry { + const map = new Map(); + const listeners = new Set<() => void>(); + const notify = () => { + listeners.forEach((l) => l()); + }; + return { + register(key, el) { + map.set(key, el); + notify(); + }, + unregister(key) { + map.delete(key); + notify(); + }, + get(key) { + return map.get(key); + }, + subscribe(l) { + listeners.add(l); + return () => { + listeners.delete(l); + }; + }, + }; +} diff --git a/webclient/src/components/Game/CardSlot/CardSlot.css b/webclient/src/components/Game/CardSlot/CardSlot.css new file mode 100644 index 000000000..4a52b1f52 --- /dev/null +++ b/webclient/src/components/Game/CardSlot/CardSlot.css @@ -0,0 +1,153 @@ +.card-slot { + position: relative; + width: 146px; + height: 204px; + border-radius: 6px; + overflow: hidden; + user-select: none; + cursor: pointer; + transition: transform 120ms ease-out; +} + +.card-slot__image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* + * Card-back art: layered radial + diamond SVG pattern. + * Best-effort stand-in until an MTG-style asset ships under src/images/ + * (tracked in gameboard-deferrables.md M1). + * + * Layers (painted bottom-up via background: comma stack): + * 1. Outer radial gradient — deep purple core fading to black edges + * 2. SVG diamond-lattice pattern — embossed geometry + * 3. Inner vignette — subtle darkening at the border + */ +.card-slot__back { + width: 100%; + height: 100%; + box-sizing: border-box; + border: 1px solid #3a2d50; + border-radius: inherit; + position: relative; + background: + radial-gradient(circle at 50% 45%, + rgba(120, 90, 180, 0.15) 0%, + rgba(30, 18, 48, 0.1) 40%, + rgba(0, 0, 0, 0.55) 100%), + url("data:image/svg+xml;utf8,"), + linear-gradient(135deg, #2a1f3d 0%, #1a1028 55%, #0d0617 100%); + background-size: 100% 100%, 40px 40px, 100% 100%; + background-position: center center, center center, 0 0; +} + +.card-slot__back::before { + content: ''; + position: absolute; + inset: 4px; + border: 1px solid rgba(138, 118, 196, 0.35); + border-radius: 4px; + pointer-events: none; +} + +.card-slot__back::after { + content: 'MTG'; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: #a190d6; + font-weight: 700; + letter-spacing: 6px; + font-size: 20px; + text-shadow: 0 0 8px rgba(162, 144, 214, 0.6); + opacity: 0.7; + pointer-events: none; +} + +.card-slot--tapped { + transform: rotate(90deg); +} + +.card-slot--inverted { + transform: rotate(180deg); +} + +.card-slot--tapped.card-slot--inverted { + transform: rotate(270deg); +} + +.card-slot--attacking { + outline: 2px solid var(--color-arrow-red); + outline-offset: -2px; +} + +.card-slot--dragging { + opacity: 0.35; +} + +.card-slot--arrow-source { + box-shadow: 0 0 0 3px var(--color-arrow-red), 0 0 16px var(--color-arrow-red-glow); +} + +.card-slot--attach-over { + box-shadow: 0 0 0 3px var(--color-arrow-green), 0 0 16px var(--color-arrow-green-glow); +} + +.card-slot__pt { + position: absolute; + right: 4px; + bottom: 4px; + padding: 2px 6px; + background: rgba(0, 0, 0, 0.72); + color: #fff; + font-size: 13px; + font-weight: 700; + border-radius: 4px; + pointer-events: none; +} + +.card-slot__annotation { + position: absolute; + left: 4px; + top: 4px; + padding: 2px 6px; + max-width: 80%; + background: rgba(255, 235, 140, 0.92); + color: #2c2000; + font-size: 11px; + font-weight: 600; + border-radius: 3px; + pointer-events: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-slot__counters { + position: absolute; + left: 4px; + bottom: 4px; + display: flex; + flex-direction: column; + gap: 2px; + pointer-events: none; +} + +.card-slot__counter { + min-width: 18px; + height: 18px; + padding: 0 4px; + display: inline-flex; + align-items: center; + justify-content: center; + background: #d48a00; + color: #000; + font-size: 11px; + font-weight: 700; + border-radius: 9px; +} diff --git a/webclient/src/components/Game/CardSlot/CardSlot.spec.tsx b/webclient/src/components/Game/CardSlot/CardSlot.spec.tsx new file mode 100644 index 000000000..5d4217906 --- /dev/null +++ b/webclient/src/components/Game/CardSlot/CardSlot.spec.tsx @@ -0,0 +1,126 @@ +import { ReactElement } from 'react'; +import { render as rtlRender, screen, fireEvent } from '@testing-library/react'; +import { create } from '@bufbuild/protobuf'; +import { DndContext } from '@dnd-kit/core'; +import { Data } from '@app/types'; + +import { makeCard } from '../../../store/game/__mocks__/fixtures'; +import CardSlot from './CardSlot'; + +// useDraggable requires a DndContext ancestor; keep a lightweight wrapper +// for these leaf tests rather than paying for the full renderWithProviders. +const render = (ui: ReactElement) => + rtlRender({ui}); + +describe('CardSlot', () => { + it('renders the Scryfall image for a normal card', () => { + const card = makeCard({ name: 'Lightning Bolt', id: 1 }); + render(); + + const img = screen.getByAltText('Lightning Bolt') as HTMLImageElement; + expect(img.src).toContain('/cards/named'); + expect(img.src).toContain('Lightning%20Bolt'); + expect(img.src).toContain('version=small'); + }); + + it('uses providerId over name when present', () => { + const card = makeCard({ name: 'Anything', providerId: 'abc-123', id: 1 }); + render(); + + const img = screen.getByAltText('Anything') as HTMLImageElement; + expect(img.src).toContain('/cards/abc-123'); + }); + + it('renders a face-down back and suppresses image/P-T/counters when faceDown', () => { + const card = makeCard({ + name: 'Hidden', + faceDown: true, + pt: '3/3', + counterList: [create(Data.ServerInfo_CardCounterSchema, { id: 1, value: 2 })], + }); + render(); + + expect(screen.getByLabelText('face-down card')).toBeInTheDocument(); + expect(screen.queryByAltText('Hidden')).not.toBeInTheDocument(); + expect(screen.queryByText('3/3')).not.toBeInTheDocument(); + }); + + it('adds the tapped modifier when card.tapped is true', () => { + const card = makeCard({ tapped: true }); + render(); + expect(screen.getByTestId('card-slot')).toHaveClass('card-slot--tapped'); + }); + + it('adds the inverted modifier when prop inverted is true', () => { + const card = makeCard(); + render(); + 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(); + const el = screen.getByTestId('card-slot'); + expect(el).toHaveClass('card-slot--tapped'); + expect(el).toHaveClass('card-slot--inverted'); + }); + + it('renders P/T overlay when pt is set', () => { + const card = makeCard({ pt: '5/5' }); + render(); + expect(screen.getByText('5/5')).toBeInTheDocument(); + }); + + it('renders annotation overlay when annotation is set', () => { + const card = makeCard({ annotation: 'note' }); + render(); + expect(screen.getByText('note')).toBeInTheDocument(); + }); + + it('renders a counter badge per card counter', () => { + const card = makeCard({ + counterList: [ + create(Data.ServerInfo_CardCounterSchema, { id: 1, value: 3 }), + create(Data.ServerInfo_CardCounterSchema, { id: 2, value: 7 }), + ], + }); + render(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); + }); + + it('adds the attacking modifier when card.attacking is true', () => { + const card = makeCard({ attacking: true }); + render(); + expect(screen.getByTestId('card-slot')).toHaveClass('card-slot--attacking'); + }); + + it('invokes click handlers with the card payload', () => { + const card = makeCard(); + const onClick = vi.fn(); + const onDoubleClick = vi.fn(); + const onContextMenu = vi.fn(); + const onMouseEnter = vi.fn(); + render( + , + ); + + const el = screen.getByTestId('card-slot'); + fireEvent.click(el); + fireEvent.doubleClick(el); + fireEvent.contextMenu(el); + fireEvent.mouseEnter(el); + + expect(onClick).toHaveBeenCalledWith(card); + expect(onDoubleClick).toHaveBeenCalledWith(card); + expect(onContextMenu).toHaveBeenCalled(); + expect(onContextMenu.mock.calls[0][0]).toBe(card); + expect(onMouseEnter).toHaveBeenCalledWith(card); + }); +}); diff --git a/webclient/src/components/Game/CardSlot/CardSlot.tsx b/webclient/src/components/Game/CardSlot/CardSlot.tsx new file mode 100644 index 000000000..c127dba76 --- /dev/null +++ b/webclient/src/components/Game/CardSlot/CardSlot.tsx @@ -0,0 +1,144 @@ +import { useCallback, useId } from 'react'; +import { useDraggable, useDroppable } from '@dnd-kit/core'; + +import { useScryfallCard } from '@app/hooks'; +import { App } from '@app/types'; +import type { Data } from '@app/types'; +import { cx } from '@app/utils'; + +import { makeCardKey, useRegisterCardRef } from '../CardRegistry/CardRegistryContext'; + +import './CardSlot.css'; + +export interface CardSlotProps { + card: Data.ServerInfo_Card; + inverted?: boolean; + draggable?: boolean; + isArrowSource?: boolean; + /** The player that owns this card (matches desktop's `getOwner()`). Kept + * as `ownerPlayerId`, not `sourcePlayerId`, because it reflects the card + * in the game state rather than any drag origin. */ + ownerPlayerId?: number; + zone?: string; + onClick?: (card: Data.ServerInfo_Card) => void; + onDoubleClick?: (card: Data.ServerInfo_Card) => void; + onContextMenu?: (card: Data.ServerInfo_Card, event: React.MouseEvent) => void; + onMouseEnter?: (card: Data.ServerInfo_Card) => void; +} + +function CardSlot({ + card, + inverted = false, + draggable = false, + isArrowSource = false, + ownerPlayerId, + zone, + onClick, + onDoubleClick, + onContextMenu, + onMouseEnter, +}: CardSlotProps) { + const { smallUrl } = useScryfallCard(card); + + // React-stable id salts the dnd-kit IDs so even two disabled CardSlots + // rendering the same card (during state transitions / hidden-zone leaks) + // never collide. Without the salt, pre-owner/zone render cycles shared + // `card-x-x-` and dnd-kit warned. + const instanceId = useId(); + + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `card-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`, + data: { card, sourcePlayerId: ownerPlayerId, sourceZone: zone }, + disabled: !draggable || ownerPlayerId == null || zone == null, + }); + + // Cards on the battlefield double as drop targets for drag-to-attach. + // Other zones don't support attach (desktop's Player::actAttach rejects + // non-table targets), so the droppable is only live for TABLE. + const droppableEnabled = + ownerPlayerId != null && zone === App.ZoneName.TABLE; + const { setNodeRef: setDropRef, isOver } = useDroppable({ + id: `card-drop-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`, + data: { + attachTarget: true, + targetPlayerId: ownerPlayerId, + targetZone: zone, + targetCardId: card.id, + }, + disabled: !droppableEnabled, + }); + + const registryKey = + ownerPlayerId != null && zone != null + ? makeCardKey(ownerPlayerId, zone, card.id) + : null; + const registerRef = useRegisterCardRef(registryKey); + + const rootRef = useCallback( + (el: HTMLElement | null) => { + registerRef(el); + if (draggable) { + setNodeRef(el); + } + if (droppableEnabled) { + setDropRef(el); + } + }, + [registerRef, setNodeRef, setDropRef, draggable, droppableEnabled], + ); + + const className = cx('card-slot', { + 'card-slot--tapped': card.tapped, + 'card-slot--inverted': inverted, + 'card-slot--face-down': card.faceDown, + 'card-slot--attacking': card.attacking, + 'card-slot--dragging': isDragging, + 'card-slot--arrow-source': isArrowSource, + 'card-slot--attach-over': isOver, + }); + + return ( +
onClick?.(card)} + onDoubleClick={() => onDoubleClick?.(card)} + onContextMenu={(e) => onContextMenu?.(card, e)} + onMouseEnter={() => onMouseEnter?.(card)} + data-testid="card-slot" + data-card-id={card.id} + data-card-owner={ownerPlayerId ?? ''} + data-card-zone={zone ?? ''} + {...(draggable ? attributes : {})} + {...(draggable ? listeners : {})} + > + {card.faceDown ? ( +
+ ) : ( + smallUrl && ( + {card.name} + ) + )} + + {card.annotation && !card.faceDown && ( +
{card.annotation}
+ )} + + {card.pt && !card.faceDown && ( +
{card.pt}
+ )} + + {card.counterList.length > 0 && !card.faceDown && ( +
+ {card.counterList.map((c) => ( + + {c.value} + + ))} +
+ )} +
+ ); +} + +export default CardSlot; diff --git a/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.css b/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.css new file mode 100644 index 000000000..8b879fc22 --- /dev/null +++ b/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.css @@ -0,0 +1,24 @@ +.game-arrow-overlay { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 20; +} + +.game-arrow-overlay__line { + stroke-width: 4; + stroke-linecap: round; + cursor: pointer; + pointer-events: stroke; + transition: stroke-width 80ms ease-out; +} + +.game-arrow-overlay__line:hover { + stroke-width: 6; +} + +.game-arrow-overlay__line--preview { + stroke-dasharray: 8 6; + pointer-events: none; + opacity: 0.85; +} diff --git a/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.spec.tsx b/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.spec.tsx new file mode 100644 index 000000000..c2aa75946 --- /dev/null +++ b/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.spec.tsx @@ -0,0 +1,129 @@ +import { useRef } from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { create } from '@bufbuild/protobuf'; +import { Data } from '@app/types'; + +import { createMockWebClient, makeStoreState, renderWithProviders } from '../../../__test-utils__'; +import { + makeArrow, + makeGameEntry, + makePlayerEntry, + makePlayerProperties, +} from '../../../store/game/__mocks__/fixtures'; +import GameArrowOverlay from './GameArrowOverlay'; +import { + CardRegistryContext, + createCardRegistry, + makeCardKey, +} from '../CardRegistry/CardRegistryContext'; + +function Harness({ gameId }: { gameId: number }) { + const ref = useRef(null); + return ( +
+ +
+ ); +} + +function setupRegistryWithTwoCards() { + const registry = createCardRegistry(); + const elA = document.createElement('div'); + elA.getBoundingClientRect = () => + ({ left: 100, top: 100, width: 50, height: 50, right: 150, bottom: 150, x: 100, y: 100, toJSON: () => ({}) } as DOMRect); + const elB = document.createElement('div'); + elB.getBoundingClientRect = () => + ({ left: 300, top: 300, width: 50, height: 50, right: 350, bottom: 350, x: 300, y: 300, toJSON: () => ({}) } as DOMRect); + + // Must be attached to the DOM for the registry subscribers to fire after mount. + document.body.appendChild(elA); + document.body.appendChild(elB); + + registry.register(makeCardKey(1, 'table', 10), elA); + registry.register(makeCardKey(1, 'table', 11), elB); + return { registry, elA, elB }; +} + +function stateWithOneArrow() { + const arrow = makeArrow({ + id: 1, + startPlayerId: 1, + startZone: 'table', + startCardId: 10, + targetPlayerId: 1, + targetZone: 'table', + targetCardId: 11, + arrowColor: create(Data.colorSchema, { r: 224, g: 75, b: 59, a: 255 }), + }); + return makeStoreState({ + games: { + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + properties: makePlayerProperties({ playerId: 1 }), + arrows: { 1: arrow }, + }), + }, + }), + }, + }, + }); +} + +function wrapWithRegistry(children: React.ReactNode, registry: ReturnType) { + return ( + + {children} + + ); +} + +describe('GameArrowOverlay', () => { + it('renders an SVG root when mounted', () => { + const { registry } = setupRegistryWithTwoCards(); + renderWithProviders(wrapWithRegistry(, registry), { + preloadedState: stateWithOneArrow(), + }); + + expect(screen.getByTestId('game-arrow-overlay')).toBeInTheDocument(); + }); + + it('renders a line for each arrow with endpoints at card centers relative to the board', () => { + const { registry } = setupRegistryWithTwoCards(); + // Pretend the board rect starts at 0,0 for simplicity; card A center is + // (125, 125) and card B center is (325, 325) in viewport coords — same in + // board-relative coords since the harness root is at 0,0. + renderWithProviders(wrapWithRegistry(, registry), { + preloadedState: stateWithOneArrow(), + }); + + const line = screen.getByTestId('arrow-1'); + expect(line.getAttribute('x1')).toBe('125'); + expect(line.getAttribute('y1')).toBe('125'); + expect(line.getAttribute('x2')).toBe('325'); + expect(line.getAttribute('y2')).toBe('325'); + }); + + it('skips arrows whose endpoints are not registered yet', () => { + const registry = createCardRegistry(); + renderWithProviders(wrapWithRegistry(, registry), { + preloadedState: stateWithOneArrow(), + }); + + expect(screen.queryByTestId('arrow-1')).not.toBeInTheDocument(); + }); + + it('dispatches deleteArrow when an arrow line is clicked', () => { + const webClient = createMockWebClient(); + const { registry } = setupRegistryWithTwoCards(); + renderWithProviders(wrapWithRegistry(, registry), { + preloadedState: stateWithOneArrow(), + webClient, + }); + + fireEvent.click(screen.getByTestId('arrow-1')); + + expect(webClient.request.game.deleteArrow).toHaveBeenCalledWith(1, { arrowId: 1 }); + }); +}); diff --git a/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.tsx b/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.tsx new file mode 100644 index 000000000..d5c703ed8 --- /dev/null +++ b/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.tsx @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; + +import { useWebClient } from '@app/hooks'; +import { GameSelectors, useAppSelector } from '@app/store'; +import { App } from '@app/types'; +import type { Data, Enriched } from '@app/types'; + +import { makeCardKey, useCardRegistry } from '../CardRegistry/CardRegistryContext'; + +import './GameArrowOverlay.css'; + +export interface GameArrowOverlayProps { + gameId: number | undefined; + boardRef: React.RefObject; + dragPreview?: { x1: number; y1: number; x2: number; y2: number; color: string } | null; +} + +interface ResolvedArrow { + arrowId: number; + ownerPlayerId: number; + x1: number; + y1: number; + x2: number; + y2: number; + color: string; +} + +const ARROW_FALLBACK_CSS = App.rgbaToCss(App.ArrowColor.RED); + +function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string { + if (!c) { + return ARROW_FALLBACK_CSS; + } + return App.rgbaToCss({ r: c.r, g: c.g, b: c.b, a: c.a ?? 255 }); +} + +function GameArrowOverlay({ gameId, boardRef, dragPreview = null }: GameArrowOverlayProps) { + const webClient = useWebClient(); + const registry = useCardRegistry(); + const players = useAppSelector((state) => + gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined, + ); + + // Tick is bumped whenever we need to re-query DOM rects (card registry + // mutation, board resize). Keeps the overlay declarative without an external + // layout engine. + const [tick, setTick] = useState(0); + const bump = useCallback(() => { + setTick((t) => t + 1); + }, []); + + useEffect(() => { + if (!registry) { + return undefined; + } + return registry.subscribe(bump); + }, [registry, bump]); + + // First-paint: the board ref is null during the initial render, so `boardRect` + // is undefined and the arrows memo bails out. Bump once after mount so the + // next render sees a populated ref. + useLayoutEffect(() => { + bump(); + }, [bump]); + + useLayoutEffect(() => { + const el = boardRef.current; + if (!el || typeof ResizeObserver === 'undefined') { + return undefined; + } + const ro = new ResizeObserver(() => bump()); + ro.observe(el); + return () => ro.disconnect(); + }, [boardRef, bump]); + + const boardRect = boardRef.current?.getBoundingClientRect(); + + const arrows = useMemo(() => { + if (!players || !registry || !boardRect) { + return []; + } + const out: ResolvedArrow[] = []; + for (const player of Object.values(players) as Enriched.PlayerEntry[]) { + for (const a of Object.values(player.arrows) as Data.ServerInfo_Arrow[]) { + const sourceEl = registry.get( + makeCardKey(a.startPlayerId, a.startZone, a.startCardId), + ); + const targetEl = registry.get( + makeCardKey(a.targetPlayerId, a.targetZone, a.targetCardId), + ); + if (!sourceEl || !targetEl) { + continue; + } + const s = sourceEl.getBoundingClientRect(); + const t = targetEl.getBoundingClientRect(); + out.push({ + arrowId: a.id, + ownerPlayerId: player.properties.playerId, + x1: s.left + s.width / 2 - boardRect.left, + y1: s.top + s.height / 2 - boardRect.top, + x2: t.left + t.width / 2 - boardRect.left, + y2: t.top + t.height / 2 - boardRect.top, + color: cssColor(a.arrowColor), + }); + } + } + // `tick` in deps intentionally re-runs the memo on DOM-layout changes. + return out; + }, [players, registry, boardRect, tick]); + + const handleArrowClick = (arrowId: number) => { + if (gameId == null) { + return; + } + webClient.request.game.deleteArrow(gameId, { arrowId }); + }; + + const width = boardRect?.width ?? 0; + const height = boardRect?.height ?? 0; + + return ( + + + + + + + {arrows.map((a) => ( + handleArrowClick(a.arrowId)} + /> + ))} + {dragPreview && ( + + )} + + ); +} + +export default GameArrowOverlay; diff --git a/webclient/src/components/Game/GameLog/GameLog.css b/webclient/src/components/Game/GameLog/GameLog.css new file mode 100644 index 000000000..a8c96c871 --- /dev/null +++ b/webclient/src/components/Game/GameLog/GameLog.css @@ -0,0 +1,97 @@ +.game-log { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + background: #0a1225; + color: #e5ecf7; + font-size: 12px; +} + +.game-log__heading { + padding: 6px 12px; + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: #8597bb; + border-bottom: 1px solid #1a2b52; +} + +.game-log__timer { + padding: 4px 12px; + text-align: center; + font-variant-numeric: tabular-nums; + font-size: 12px; + color: #b8c7e5; + border-bottom: 1px solid #1a2b52; +} + +.game-log__messages { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 6px 12px; + display: flex; + flex-direction: column; + gap: 3px; +} + +.game-log__empty { + color: #5a6a8a; + font-style: italic; +} + +.game-log__line { + display: flex; + gap: 6px; + line-height: 1.35; +} + +.game-log__line--event { + color: #8597bb; + font-style: italic; +} + +.game-log__author { + font-weight: 700; + color: var(--color-highlight-yellow); + flex-shrink: 0; +} + +.game-log__text { + flex: 1; + word-break: break-word; +} + +.game-log__input-row { + border-top: 1px solid #1a2b52; + padding: 6px 12px; + display: flex; + align-items: center; + gap: 6px; +} + +.game-log__input-label { + color: #8597bb; + font-size: 11px; + font-weight: 600; + flex-shrink: 0; +} + +.game-log__input { + width: 100%; + height: 28px; + padding: 0 8px; + box-sizing: border-box; + background: #17223d; + border: 1px solid #233a68; + color: #e5ecf7; + font-family: inherit; + font-size: 12px; + border-radius: 3px; +} + +.game-log__input:disabled { + opacity: 0.5; +} diff --git a/webclient/src/components/Game/GameLog/GameLog.spec.tsx b/webclient/src/components/Game/GameLog/GameLog.spec.tsx new file mode 100644 index 000000000..b3fa9c7da --- /dev/null +++ b/webclient/src/components/Game/GameLog/GameLog.spec.tsx @@ -0,0 +1,249 @@ +import { act, screen, fireEvent } from '@testing-library/react'; +import type { Enriched } from '@app/types'; +import { create } from '@bufbuild/protobuf'; +import { Data } from '@app/types'; + +import { createMockWebClient, makeStoreState, renderWithProviders, makeUser } from '../../../__test-utils__'; +import { + makeGameEntry, + makePlayerEntry, + makePlayerProperties, +} from '../../../store/game/__mocks__/fixtures'; +import { Actions } from '../../../store/game/game.actions'; +import GameLog from './GameLog'; + +function stateWithMessages( + players: ReturnType[], + messages: Enriched.GameMessage[], + secondsElapsed = 0, +) { + const byId: Record> = {}; + for (const p of players) { + byId[p.properties.playerId] = p; + } + return makeStoreState({ + games: { + games: { + 1: makeGameEntry({ players: byId, messages, secondsElapsed }), + }, + }, + }); +} + +describe('GameLog', () => { + it('shows an empty hint when no messages are present', () => { + renderWithProviders(, { + preloadedState: stateWithMessages([], []), + }); + + expect(screen.getByText(/no messages/i)).toBeInTheDocument(); + }); + + it('renders each message with the speaking player name', () => { + const alice = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'Alice' }), + }), + }); + renderWithProviders(, { + preloadedState: stateWithMessages( + [alice], + [ + { playerId: 1, message: 'gl hf', timeReceived: 0 }, + { playerId: 1, message: 'yolo', timeReceived: 0 }, + ], + ), + }); + + expect(screen.getByText('gl hf')).toBeInTheDocument(); + expect(screen.getByText('yolo')).toBeInTheDocument(); + expect(screen.getAllByText('Alice:').length).toBe(2); + }); + + it('renders a fallback author label when the speaker is not in the player list', () => { + renderWithProviders(, { + preloadedState: stateWithMessages( + [], + [{ playerId: 99, message: 'hello', timeReceived: 0 }], + ), + }); + + expect(screen.getByText('p99:')).toBeInTheDocument(); + }); + + it('disables the chat input when gameId is undefined', () => { + renderWithProviders(, { + preloadedState: makeStoreState({ games: { games: {} } }), + }); + + expect(screen.getByLabelText('game chat input')).toBeDisabled(); + }); + + it('enables the chat input when gameId is provided', () => { + renderWithProviders(, { + preloadedState: stateWithMessages([], []), + }); + + expect(screen.getByLabelText('game chat input')).not.toBeDisabled(); + }); + + it('submits the chat draft and clears the input', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWithMessages([], []), + webClient, + }); + + const input = screen.getByLabelText('game chat input') as HTMLInputElement; + fireEvent.change(input, { target: { value: ' hello world ' } }); + fireEvent.submit(input.closest('form')!); + + expect(webClient.request.game.gameSay).toHaveBeenCalledWith(1, { message: 'hello world' }); + expect(input.value).toBe(''); + }); + + it('does not dispatch for a whitespace-only message', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWithMessages([], []), + webClient, + }); + + const input = screen.getByLabelText('game chat input') as HTMLInputElement; + fireEvent.change(input, { target: { value: ' ' } }); + fireEvent.submit(input.closest('form')!); + + expect(webClient.request.game.gameSay).not.toHaveBeenCalled(); + }); + + describe('event-log rendering (desktop MessageLogWidget parity)', () => { + // Desktop renders game events (card moves, tap, concede, etc.) in the + // same log surface as chat, but without a leading speaker label and in a + // distinct italic style. Regression guard — GameLog was chat-only before + // this milestone. + it('renders event messages without a leading author label', () => { + renderWithProviders(, { + preloadedState: stateWithMessages( + [], + [ + { playerId: 1, message: 'Alice plays Bolt.', timeReceived: 0, kind: 'event' }, + ], + ), + }); + + expect(screen.getByText('Alice plays Bolt.')).toBeInTheDocument(); + expect(screen.queryByText(/^p\d+:$/)).not.toBeInTheDocument(); + expect(screen.queryByText('Alice:')).not.toBeInTheDocument(); + }); + + it('tags event lines with the event modifier class', () => { + renderWithProviders(, { + preloadedState: stateWithMessages( + [], + [ + { playerId: 0, message: 'The game has started.', timeReceived: 0, kind: 'event' }, + ], + ), + }); + + const line = screen.getByText('The game has started.').closest('.game-log__line')!; + expect(line.className).toContain('game-log__line--event'); + }); + + it('interleaves chat and event lines in order', () => { + const alice = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'Alice' }), + }), + }); + renderWithProviders(, { + preloadedState: stateWithMessages( + [alice], + [ + { playerId: 1, message: 'gl', timeReceived: 0, kind: 'chat' }, + { playerId: 1, message: 'Alice plays Bolt.', timeReceived: 1, kind: 'event' }, + { playerId: 1, message: 'hf', timeReceived: 2, kind: 'chat' }, + ], + ), + }); + + const lines = Array.from( + document.querySelectorAll('.game-log__line'), + ); + expect(lines).toHaveLength(3); + expect(lines[0].textContent).toContain('Alice:'); + expect(lines[1].className).toContain('game-log__line--event'); + expect(lines[2].textContent).toContain('Alice:'); + }); + }); + + describe('elapsed game timer', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders the initial secondsElapsed snapshot in HH:MM:SS form', () => { + renderWithProviders(, { + preloadedState: stateWithMessages([], [], 3723), + }); + + expect(screen.getByTestId('game-log-timer')).toHaveTextContent('01:02:03'); + }); + + it('advances locally once per second between server events', () => { + renderWithProviders(, { + preloadedState: stateWithMessages([], [], 0), + }); + + expect(screen.getByTestId('game-log-timer')).toHaveTextContent('00:00:00'); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(screen.getByTestId('game-log-timer')).toHaveTextContent('00:00:02'); + }); + + it('does not render the timer when there is no active game', () => { + renderWithProviders(, { + preloadedState: makeStoreState({ games: { games: {} } }), + }); + + expect(screen.queryByTestId('game-log-timer')).not.toBeInTheDocument(); + }); + + // Mirrors desktop's setGameTime resync: the local 1Hz ticker drifts until + // the server pushes a fresh `secondsElapsed`, at which point the display + // snaps to the server value. Regression guard for that snap behavior. + it('resyncs displayed time when Redux pushes a new secondsElapsed', () => { + const { store } = renderWithProviders(, { + preloadedState: stateWithMessages([], [], 10), + }); + + expect(screen.getByTestId('game-log-timer')).toHaveTextContent('00:00:10'); + + // Local ticker drifts forward between server events. + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(screen.getByTestId('game-log-timer')).toHaveTextContent('00:00:13'); + + // Server pushes a fresh snapshot (real reducer path). + act(() => { + store.dispatch(Actions.gameStateChanged({ + gameId: 1, + data: create(Data.Event_GameStateChangedSchema, { secondsElapsed: 120 }), + })); + }); + + // Display snaps to the server value, not the drifted local value. + expect(screen.getByTestId('game-log-timer')).toHaveTextContent('00:02:00'); + }); + }); +}); diff --git a/webclient/src/components/Game/GameLog/GameLog.tsx b/webclient/src/components/Game/GameLog/GameLog.tsx new file mode 100644 index 000000000..4771ce58e --- /dev/null +++ b/webclient/src/components/Game/GameLog/GameLog.tsx @@ -0,0 +1,133 @@ +import { useEffect, useRef, useState } from 'react'; +import { useWebClient } from '@app/hooks'; +import { GameSelectors, useAppSelector } from '@app/store'; +import type { Enriched } from '@app/types'; + +import './GameLog.css'; + +const EMPTY_MESSAGES: Enriched.GameMessage[] = []; + +function formatElapsed(totalSeconds: number): string { + const s = Math.max(0, Math.floor(totalSeconds)); + const hh = String(Math.floor(s / 3600)).padStart(2, '0'); + const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0'); + const ss = String(s % 60).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; +} + +export interface GameLogProps { + gameId: number | undefined; +} + +function GameLog({ gameId }: GameLogProps) { + const webClient = useWebClient(); + const messages = useAppSelector((state) => + gameId != null ? GameSelectors.getMessages(state, gameId) : EMPTY_MESSAGES, + ); + const players = useAppSelector((state) => + gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined, + ); + const secondsElapsed = useAppSelector((state) => + gameId != null ? GameSelectors.getSecondsElapsed(state, gameId) : 0, + ); + + // Local 1Hz ticker, resynced from Redux whenever a server event delivers a + // fresh `secondsElapsed`. Mirrors desktop's QTimer(1000) + + // setGameTime(event.seconds_elapsed()) pattern in game_state.cpp. + const [displaySeconds, setDisplaySeconds] = useState(secondsElapsed); + + useEffect(() => { + setDisplaySeconds(secondsElapsed); + }, [secondsElapsed]); + + useEffect(() => { + if (gameId == null) { + return undefined; + } + const id = window.setInterval(() => { + setDisplaySeconds((prev) => prev + 1); + }, 1000); + return () => window.clearInterval(id); + }, [gameId]); + + const [draft, setDraft] = useState(''); + + const listRef = useRef(null); + // Desktop pins the log to the bottom unless the user has scrolled up to read backlog. + // Capture pin state before the new line renders so auto-scroll only fires when the + // user was already following the tail. + const wasPinnedRef = useRef(true); + useEffect(() => { + const el = listRef.current; + if (!el) { + return; + } + if (wasPinnedRef.current) { + el.scrollTop = el.scrollHeight; + } + }, [messages.length]); + + const handleMessagesScroll = () => { + const el = listRef.current; + if (!el) { + return; + } + wasPinnedRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 2; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (gameId == null) { + return; + } + const trimmed = draft.trim(); + if (trimmed.length === 0) { + return; + } + webClient.request.game.gameSay(gameId, { message: trimmed }); + setDraft(''); + }; + + return ( +
+
Log
+ {gameId != null && ( +
+ {formatElapsed(displaySeconds)} +
+ )} +
+ {messages.length === 0 && ( +
no messages
+ )} + {messages.map((m, idx) => { + const isEvent = m.kind === 'event'; + const name = players?.[m.playerId]?.properties.userInfo?.name ?? `p${m.playerId}`; + const lineClass = isEvent ? 'game-log__line game-log__line--event' : 'game-log__line'; + return ( +
+ {!isEvent && {name}:} + {m.message} +
+ ); + })} +
+
+ + setDraft(e.target.value)} + disabled={gameId == null} + aria-label="game chat input" + /> +
+
+ ); +} + +export default GameLog; diff --git a/webclient/src/components/Game/HandContextMenu/HandContextMenu.css b/webclient/src/components/Game/HandContextMenu/HandContextMenu.css new file mode 100644 index 000000000..3d27194d5 --- /dev/null +++ b/webclient/src/components/Game/HandContextMenu/HandContextMenu.css @@ -0,0 +1,3 @@ +.hand-context-menu .MuiMenuItem-root { + font-size: 13px; +} diff --git a/webclient/src/components/Game/HandContextMenu/HandContextMenu.spec.tsx b/webclient/src/components/Game/HandContextMenu/HandContextMenu.spec.tsx new file mode 100644 index 000000000..b1e532bf4 --- /dev/null +++ b/webclient/src/components/Game/HandContextMenu/HandContextMenu.spec.tsx @@ -0,0 +1,81 @@ +import { screen, fireEvent } from '@testing-library/react'; + +import { createMockWebClient, renderWithProviders } from '../../../__test-utils__'; +import HandContextMenu from './HandContextMenu'; + +function render(overrides: Partial> = {}) { + const props: React.ComponentProps = { + isOpen: true, + anchorPosition: { top: 10, left: 10 }, + gameId: 1, + handSize: 7, + onClose: vi.fn(), + onRequestChooseMulligan: vi.fn(), + onRequestRevealHand: vi.fn(), + onRequestRevealRandom: vi.fn(), + ...overrides, + }; + const webClient = createMockWebClient(); + return { + ...renderWithProviders(, { webClient }), + webClient, + props, + }; +} + +describe('HandContextMenu', () => { + it('fires onRequestChooseMulligan and closes when the choose-size item is clicked', () => { + const onRequestChooseMulligan = vi.fn(); + const onClose = vi.fn(); + render({ onRequestChooseMulligan, onClose }); + + fireEvent.click(screen.getByRole('menuitem', { name: /choose size/i })); + + expect(onRequestChooseMulligan).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('dispatches mulligan(number=handSize) on the same-size item', () => { + const { webClient } = render({ handSize: 7 }); + + fireEvent.click(screen.getByRole('menuitem', { name: /same size/i })); + + expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 7 }); + }); + + it('dispatches mulligan(number=handSize-1) on the size−1 item', () => { + const { webClient } = render({ handSize: 5 }); + + fireEvent.click(screen.getByRole('menuitem', { name: /size − 1/i })); + + expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 4 }); + }); + + it('floors size−1 at 1, matching desktop actMulliganMinusOne', () => { + const { webClient } = render({ handSize: 1 }); + + fireEvent.click(screen.getByRole('menuitem', { name: /size − 1/i })); + + expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 1 }); + }); + + it('disables same-size when handSize is 0', () => { + render({ handSize: 0 }); + + expect(screen.getByRole('menuitem', { name: /same size/i })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + }); + + it('fires onRequestRevealHand and closes on reveal-hand item', () => { + const onRequestRevealHand = vi.fn(); + const onClose = vi.fn(); + render({ onRequestRevealHand, onClose }); + + fireEvent.click(screen.getByRole('menuitem', { name: /reveal hand/i })); + + expect(onRequestRevealHand).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/components/Game/HandContextMenu/HandContextMenu.tsx b/webclient/src/components/Game/HandContextMenu/HandContextMenu.tsx new file mode 100644 index 000000000..5636ab4b8 --- /dev/null +++ b/webclient/src/components/Game/HandContextMenu/HandContextMenu.tsx @@ -0,0 +1,101 @@ +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Divider from '@mui/material/Divider'; + +import { useWebClient } from '@app/hooks'; + +import './HandContextMenu.css'; + +export interface HandContextMenuProps { + isOpen: boolean; + anchorPosition: { top: number; left: number } | null; + gameId: number; + handSize: number; + onClose: () => void; + onRequestChooseMulligan: () => void; + onRequestRevealHand: () => void; + onRequestRevealRandom: () => void; +} + +function HandContextMenu({ + isOpen, + anchorPosition, + gameId, + handSize, + onClose, + onRequestChooseMulligan, + onRequestRevealHand, + onRequestRevealRandom, +}: HandContextMenuProps) { + const webClient = useWebClient(); + + const handleChoose = () => { + if (gameId <= 0) { + return; + } + onRequestChooseMulligan(); + onClose(); + }; + + const handleSameSize = () => { + if (gameId <= 0) { + return; + } + webClient.request.game.mulligan(gameId, { number: handSize }); + onClose(); + }; + + const handleMinusOne = () => { + if (gameId <= 0) { + return; + } + // Desktop's actMulliganMinusOne floors at 1 (see + // cockatrice/src/game/player/player_actions.cpp actMulliganMinusOne); + // the server-side doMulligan rejects number < 1. + const next = Math.max(1, handSize - 1); + webClient.request.game.mulligan(gameId, { number: next }); + onClose(); + }; + + const handleRevealHand = () => { + if (gameId <= 0) { + return; + } + onRequestRevealHand(); + onClose(); + }; + + const handleRevealRandom = () => { + if (gameId <= 0) { + return; + } + onRequestRevealRandom(); + onClose(); + }; + + return ( + + Take mulligan (choose size)… + + Take mulligan (same size) + + + Take mulligan (size − 1) + + + Reveal hand to… + + Reveal random card to… + + + ); +} + +export default HandContextMenu; diff --git a/webclient/src/components/Game/HandZone/HandZone.css b/webclient/src/components/Game/HandZone/HandZone.css new file mode 100644 index 000000000..6d4a3449c --- /dev/null +++ b/webclient/src/components/Game/HandZone/HandZone.css @@ -0,0 +1,33 @@ +.hand-zone { + height: 176px; + display: flex; + flex-direction: column; + background: #0a4a1e; + border-top: 2px solid #1a6a33; + padding: 4px 12px; + box-sizing: border-box; +} + +.hand-zone__label { + color: #c4e8ce; + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; + margin-bottom: 2px; +} + +.hand-zone__cards { + flex: 1; + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + overflow-x: auto; + overflow-y: hidden; +} + +.hand-zone--drop-over { + background: #126a2d; + box-shadow: inset 0 0 0 2px #4de078; +} diff --git a/webclient/src/components/Game/HandZone/HandZone.spec.tsx b/webclient/src/components/Game/HandZone/HandZone.spec.tsx new file mode 100644 index 000000000..c16bfcdfc --- /dev/null +++ b/webclient/src/components/Game/HandZone/HandZone.spec.tsx @@ -0,0 +1,96 @@ +import { screen, fireEvent } from '@testing-library/react'; +import { App } from '@app/types'; + +import { makeStoreState, renderWithProviders } from '../../../__test-utils__'; +import { + makeCard, + makeGameEntry, + makePlayerEntry, + makeZoneEntry, +} from '../../../store/game/__mocks__/fixtures'; +import HandZone from './HandZone'; + +function stateWithHand(cards: ReturnType[]) { + const hand = makeZoneEntry({ + name: App.ZoneName.HAND, + type: 0, + cardCount: cards.length, + cards, + }); + return makeStoreState({ + games: { + games: { + 1: makeGameEntry({ + localPlayerId: 1, + players: { + 1: makePlayerEntry({ zones: { [App.ZoneName.HAND]: hand } }), + }, + }), + }, + }, + }); +} + +describe('HandZone', () => { + it('renders the hand label with the current count', () => { + const cards = [ + makeCard({ id: 1, name: 'Island' }), + makeCard({ id: 2, name: 'Swamp' }), + ]; + renderWithProviders(, { + preloadedState: stateWithHand(cards), + }); + + expect(screen.getByText(/Hand · 2/)).toBeInTheDocument(); + }); + + it('renders a CardSlot for every card in hand', () => { + const cards = [ + makeCard({ id: 1, name: 'Forest' }), + makeCard({ id: 2, name: 'Mountain' }), + ]; + renderWithProviders(, { + preloadedState: stateWithHand(cards), + }); + + expect(screen.getAllByTestId('card-slot')).toHaveLength(2); + expect(screen.getByAltText('Forest')).toBeInTheDocument(); + expect(screen.getByAltText('Mountain')).toBeInTheDocument(); + }); + + it('renders an empty row when hand is empty', () => { + renderWithProviders(, { + preloadedState: stateWithHand([]), + }); + + expect(screen.getByText(/Hand · 0/)).toBeInTheDocument(); + expect(screen.queryAllByTestId('card-slot')).toHaveLength(0); + }); + + describe('zone-level context menu', () => { + it('fires onZoneContextMenu when right-clicking the empty hand area', () => { + const onZoneContextMenu = vi.fn(); + renderWithProviders( + , + { preloadedState: stateWithHand([]) }, + ); + + fireEvent.contextMenu(screen.getByTestId('hand-zone')); + + expect(onZoneContextMenu).toHaveBeenCalled(); + }); + + it('does NOT fire onZoneContextMenu when right-clicking a card slot', () => { + const onZoneContextMenu = vi.fn(); + const cards = [makeCard({ id: 1, name: 'Island' })]; + renderWithProviders( + , + { preloadedState: stateWithHand(cards) }, + ); + + fireEvent.contextMenu(screen.getByTestId('card-slot')); + + expect(onZoneContextMenu).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/webclient/src/components/Game/HandZone/HandZone.tsx b/webclient/src/components/Game/HandZone/HandZone.tsx new file mode 100644 index 000000000..787e80309 --- /dev/null +++ b/webclient/src/components/Game/HandZone/HandZone.tsx @@ -0,0 +1,89 @@ +import { useDroppable } from '@dnd-kit/core'; +import { App, Data } from '@app/types'; +import { GameSelectors, useAppSelector } from '@app/store'; +import { cx } from '@app/utils'; + +import CardSlot from '../CardSlot/CardSlot'; +import { makeCardKey } from '../CardRegistry/CardRegistryContext'; + +import './HandZone.css'; + +export interface HandZoneProps { + gameId: number; + playerId: number; + canAct?: boolean; + arrowSourceKey?: string | null; + onCardHover?: (card: Data.ServerInfo_Card) => void; + onCardClick?: (playerId: number, zone: string, card: Data.ServerInfo_Card) => void; + onCardContextMenu?: (card: Data.ServerInfo_Card, event: React.MouseEvent) => void; + onZoneContextMenu?: (event: React.MouseEvent) => void; +} + +function HandZone({ + gameId, + playerId, + canAct = false, + arrowSourceKey = null, + onCardHover, + onCardClick, + onCardContextMenu, + onZoneContextMenu, +}: HandZoneProps) { + const cards = useAppSelector((state) => + GameSelectors.getCards(state, gameId, playerId, App.ZoneName.HAND), + ); + + // Match desktop: can't drop into a hand zone that isn't yours (judges + // aside; server enforces the same restriction). Today only the local + // HandZone mounts, but this guard future-proofs opponent-hand mirrors. + const { setNodeRef, isOver } = useDroppable({ + id: `hand-${playerId}`, + data: { targetPlayerId: playerId, targetZone: App.ZoneName.HAND }, + disabled: !canAct, + }); + + // Right-click anywhere inside the hand that doesn't land on a card opens + // the hand zone context menu (mulligan / reveal hand). Card-level right- + // click has its own handler on CardSlot. + const handleZoneContextMenu = (e: React.MouseEvent) => { + if (!onZoneContextMenu) { + return; + } + const target = e.target as HTMLElement; + if (target.closest('[data-card-id]')) { + return; + } + onZoneContextMenu(e); + }; + + return ( +
+
Hand · {cards.length}
+
+ {cards.map((card) => { + const key = makeCardKey(playerId, App.ZoneName.HAND, card.id); + return ( + onCardClick?.(playerId, App.ZoneName.HAND, c)} + onContextMenu={onCardContextMenu} + /> + ); + })} +
+
+ ); +} + +export default HandZone; diff --git a/webclient/src/components/Game/OpponentSelector/OpponentSelector.css b/webclient/src/components/Game/OpponentSelector/OpponentSelector.css new file mode 100644 index 000000000..8f76f6d80 --- /dev/null +++ b/webclient/src/components/Game/OpponentSelector/OpponentSelector.css @@ -0,0 +1,26 @@ +.opponent-selector { + position: absolute; + top: 8px; + right: 120px; + z-index: 10; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + background: rgba(10, 18, 37, 0.9); + border: 1px solid #1a2b52; + border-radius: 4px; + color: #e5ecf7; +} + +.opponent-selector__label { + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; +} + +.opponent-selector__select { + min-width: 120px; + color: #e5ecf7; +} diff --git a/webclient/src/components/Game/OpponentSelector/OpponentSelector.spec.tsx b/webclient/src/components/Game/OpponentSelector/OpponentSelector.spec.tsx new file mode 100644 index 000000000..310676cad --- /dev/null +++ b/webclient/src/components/Game/OpponentSelector/OpponentSelector.spec.tsx @@ -0,0 +1,55 @@ +import { render, screen, fireEvent, within } from '@testing-library/react'; + +import OpponentSelector from './OpponentSelector'; + +describe('OpponentSelector', () => { + it('does not render with fewer than 2 opponents (2-player game)', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + it('renders with 2+ opponents', () => { + render( + , + ); + + expect(screen.getByTestId('opponent-selector')).toBeInTheDocument(); + expect(screen.getByText('Opponent:')).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + it('fires onSelect with the chosen opponent playerId', () => { + const onSelect = vi.fn(); + render( + , + ); + + fireEvent.mouseDown(screen.getByRole('combobox')); + const listbox = within(screen.getByRole('listbox')); + fireEvent.click(listbox.getByText('Carol')); + + expect(onSelect).toHaveBeenCalledWith(4); + }); +}); diff --git a/webclient/src/components/Game/OpponentSelector/OpponentSelector.tsx b/webclient/src/components/Game/OpponentSelector/OpponentSelector.tsx new file mode 100644 index 000000000..07a090ff7 --- /dev/null +++ b/webclient/src/components/Game/OpponentSelector/OpponentSelector.tsx @@ -0,0 +1,40 @@ +import { Select, MenuItem } from '@mui/material'; + +import './OpponentSelector.css'; + +export interface OpponentOption { + playerId: number; + name: string; +} + +export interface OpponentSelectorProps { + opponents: OpponentOption[]; + selectedPlayerId: number | undefined; + onSelect: (playerId: number) => void; +} + +function OpponentSelector({ opponents, selectedPlayerId, onSelect }: OpponentSelectorProps) { + if (opponents.length < 2) { + return null; + } + + return ( +
+ + +
+ ); +} + +export default OpponentSelector; diff --git a/webclient/src/components/Game/PhaseBar/PhaseBar.css b/webclient/src/components/Game/PhaseBar/PhaseBar.css new file mode 100644 index 000000000..72a8896f4 --- /dev/null +++ b/webclient/src/components/Game/PhaseBar/PhaseBar.css @@ -0,0 +1,53 @@ +.phase-bar { + width: 56px; + height: 100%; + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 2px; + box-sizing: border-box; + background: #0a1225; + border-right: 1px solid #1a2b52; +} + +.phase-bar__btn-wrap { + flex: 0 0 auto; + display: flex; +} + +.phase-bar__btn { + flex: 1; + height: 44px; + padding: 0; + background: #162445; + border: 1px solid #233a68; + color: #c8d4ef; + font-family: inherit; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + cursor: default; + border-radius: 3px; +} + +.phase-bar__btn:disabled { + cursor: default; + opacity: 0.85; +} + +.phase-bar__btn--active { + background: #d48a00; + border-color: var(--color-highlight-yellow); + color: #1a1100; + box-shadow: 0 0 6px var(--color-highlight-yellow-soft); +} + +.phase-bar__btn--pass { + background: #3f1a1a; + border-color: #5e2828; + color: #ffd4d4; +} + +.phase-bar__spacer { + flex: 1; +} diff --git a/webclient/src/components/Game/PhaseBar/PhaseBar.spec.tsx b/webclient/src/components/Game/PhaseBar/PhaseBar.spec.tsx new file mode 100644 index 000000000..89dd3c4ba --- /dev/null +++ b/webclient/src/components/Game/PhaseBar/PhaseBar.spec.tsx @@ -0,0 +1,258 @@ +import { screen, fireEvent } from '@testing-library/react'; +import { App, Data } from '@app/types'; + +import { createMockWebClient, makeStoreState, renderWithProviders } from '../../../__test-utils__'; +import { + makeCard, + makeGameEntry, + makePlayerEntry, + makePlayerProperties, + makeZoneEntry, +} from '../../../store/game/__mocks__/fixtures'; +import PhaseBar from './PhaseBar'; + +function stateWith(opts: { + phase?: number; + localPlayerId?: number; + activePlayerId?: number; + started?: boolean; + judge?: boolean; +} = {}) { + const localId = opts.localPlayerId ?? 1; + return makeStoreState({ + games: { + games: { + 1: makeGameEntry({ + activePhase: opts.phase ?? 0, + localPlayerId: localId, + activePlayerId: opts.activePlayerId ?? localId, + started: opts.started ?? true, + judge: opts.judge ?? false, + players: { + [localId]: makePlayerEntry({ + properties: makePlayerProperties({ playerId: localId }), + }), + }, + }), + }, + }, + }); +} + +describe('PhaseBar', () => { + it('renders 11 phase buttons plus PASS', () => { + renderWithProviders(, { + preloadedState: stateWith(), + }); + + const buttons = screen.getByTestId('phase-bar').querySelectorAll('button'); + expect(buttons).toHaveLength(12); + expect(buttons[11].textContent).toBe('PASS TURN'); + }); + + it('renders phases in desktop-Cockatrice order', () => { + renderWithProviders(, { + preloadedState: stateWith(), + }); + + const labels = Array.from( + screen.getByTestId('phase-bar').querySelectorAll('button'), + ).map((b) => b.textContent); + expect(labels.slice(0, 11)).toEqual([ + 'UNTAP', 'UPKP', 'DRAW', 'M1', 'CMBT', 'ATTK', + 'BLCK', 'DMGE', 'ECMB', 'M2', 'END', + ]); + }); + + it('applies the active modifier only to the button matching activePhase', () => { + renderWithProviders(, { + preloadedState: stateWith({ phase: App.Phase.DeclareAttackers }), + }); + + const active = document.querySelector('.phase-bar__btn--active')!; + expect(active.getAttribute('data-phase')).toBe(String(App.Phase.DeclareAttackers)); + expect(document.querySelectorAll('.phase-bar__btn--active')).toHaveLength(1); + }); + + it('renders no active button when gameId is undefined', () => { + renderWithProviders(, { + preloadedState: makeStoreState({}), + }); + expect(document.querySelectorAll('.phase-bar__btn--active')).toHaveLength(0); + }); + + it('enables buttons when the local player is the active player and the game has started', () => { + renderWithProviders(, { + preloadedState: stateWith({ started: true }), + }); + + const buttons = screen.getByTestId('phase-bar').querySelectorAll('button'); + buttons.forEach((b) => expect(b).not.toBeDisabled()); + }); + + it('disables every button when the game has not started', () => { + renderWithProviders(, { + preloadedState: stateWith({ started: false }), + }); + + const buttons = screen.getByTestId('phase-bar').querySelectorAll('button'); + buttons.forEach((b) => expect(b).toBeDisabled()); + }); + + it('disables every button when the local player is not the active player (non-judge)', () => { + renderWithProviders(, { + preloadedState: stateWith({ + localPlayerId: 1, + activePlayerId: 2, + }), + }); + + const buttons = screen.getByTestId('phase-bar').querySelectorAll('button'); + buttons.forEach((b) => expect(b).toBeDisabled()); + }); + + it('enables buttons for a judge regardless of active player (matches desktop)', () => { + renderWithProviders(, { + preloadedState: stateWith({ + localPlayerId: 1, + activePlayerId: 2, + judge: true, + }), + }); + + const buttons = screen.getByTestId('phase-bar').querySelectorAll('button'); + buttons.forEach((b) => expect(b).not.toBeDisabled()); + }); + + it('dispatches setActivePhase when a phase button is clicked', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWith(), + webClient, + }); + + fireEvent.click(screen.getByText('ATTK')); + + expect(webClient.request.game.setActivePhase).toHaveBeenCalledWith(1, { + phase: App.Phase.DeclareAttackers, + }); + }); + + it('dispatches nextTurn when PASS is clicked', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWith(), + webClient, + }); + + fireEvent.click(screen.getByText('PASS TURN')); + + expect(webClient.request.game.nextTurn).toHaveBeenCalledWith(1); + }); + + describe('desktop double-click built-ins (phases_toolbar.cpp)', () => { + function stateWithTapped(cards: ReturnType[]) { + return makeStoreState({ + games: { + games: { + 1: makeGameEntry({ + activePhase: App.Phase.Untap, + localPlayerId: 1, + activePlayerId: 1, + started: true, + players: { + 1: makePlayerEntry({ + properties: makePlayerProperties({ playerId: 1 }), + zones: { + table: makeZoneEntry({ name: 'table', cards, cardCount: cards.length }), + }, + }), + }, + }), + }, + }, + }); + } + + it('double-click on UNTAP dispatches setCardAttr AttrTapped=0 for every tapped card', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWithTapped([ + makeCard({ id: 1, tapped: true }), + makeCard({ id: 2, tapped: false }), + makeCard({ id: 3, tapped: true }), + ]), + webClient, + }); + + fireEvent.doubleClick(screen.getByText('UNTAP')); + + expect(webClient.request.game.setCardAttr).toHaveBeenCalledTimes(2); + expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, { + zone: 'table', + cardId: 1, + attribute: Data.CardAttribute.AttrTapped, + attrValue: '0', + }); + expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, { + zone: 'table', + cardId: 3, + attribute: Data.CardAttribute.AttrTapped, + attrValue: '0', + }); + }); + + it('double-click on UNTAP is a no-op when no cards are tapped', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWithTapped([makeCard({ id: 1, tapped: false })]), + webClient, + }); + + fireEvent.doubleClick(screen.getByText('UNTAP')); + + expect(webClient.request.game.setCardAttr).not.toHaveBeenCalled(); + }); + + it('double-click on DRAW dispatches drawCards({ number: 1 })', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWith(), + webClient, + }); + + fireEvent.doubleClick(screen.getByText('DRAW')); + + expect(webClient.request.game.drawCards).toHaveBeenCalledWith(1, { number: 1 }); + }); + + it('double-click does nothing when the local player is not active', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWith({ localPlayerId: 1, activePlayerId: 2 }), + webClient, + }); + + fireEvent.doubleClick(screen.getByText('UNTAP')); + fireEvent.doubleClick(screen.getByText('DRAW')); + + expect(webClient.request.game.setCardAttr).not.toHaveBeenCalled(); + expect(webClient.request.game.drawCards).not.toHaveBeenCalled(); + }); + + it('double-click on other phases (UPKP, M1, etc.) does not fire any built-in', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWith(), + webClient, + }); + + fireEvent.doubleClick(screen.getByText('UPKP')); + fireEvent.doubleClick(screen.getByText('M1')); + fireEvent.doubleClick(screen.getByText('END')); + + expect(webClient.request.game.setCardAttr).not.toHaveBeenCalled(); + expect(webClient.request.game.drawCards).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/webclient/src/components/Game/PhaseBar/PhaseBar.tsx b/webclient/src/components/Game/PhaseBar/PhaseBar.tsx new file mode 100644 index 000000000..3d86c48fc --- /dev/null +++ b/webclient/src/components/Game/PhaseBar/PhaseBar.tsx @@ -0,0 +1,147 @@ +import Tooltip from '@mui/material/Tooltip'; + +import { useCurrentGame, useWebClient } from '@app/hooks'; +import { GameSelectors, useAppSelector } from '@app/store'; +import { App, Data } from '@app/types'; +import { cx } from '@app/utils'; + +import './PhaseBar.css'; + +export interface PhaseBarProps { + gameId: number | undefined; +} + +// Abbreviated phase badges plus full tooltip titles. Desktop uses full +// words in the horizontal toolbar; we shorten for the vertical strip but +// keep the full text available on hover per the tooltip deferrable. +const PHASE_LABELS: ReadonlyArray<{ + phase: App.Phase; + label: string; + title: string; + /** Desktop phase buttons wire "Untap All" to phase 0 double-click and "Draw a Card" to phase 2 double-click. */ + builtInOnDoubleClick?: 'untapAll' | 'drawCard'; +}> = [ + { phase: App.Phase.Untap, label: 'UNTAP', title: 'Untap step (double-click: untap all)', builtInOnDoubleClick: 'untapAll' }, + { phase: App.Phase.Upkeep, label: 'UPKP', title: 'Upkeep step' }, + { phase: App.Phase.Draw, label: 'DRAW', title: 'Draw step (double-click: draw a card)', builtInOnDoubleClick: 'drawCard' }, + { phase: App.Phase.FirstMain, label: 'M1', title: 'First main phase' }, + { phase: App.Phase.BeginCombat, label: 'CMBT', title: 'Beginning of combat' }, + { phase: App.Phase.DeclareAttackers, label: 'ATTK', title: 'Declare attackers' }, + { phase: App.Phase.DeclareBlockers, label: 'BLCK', title: 'Declare blockers' }, + { phase: App.Phase.CombatDamage, label: 'DMGE', title: 'Combat damage' }, + { phase: App.Phase.EndCombat, label: 'ECMB', title: 'End of combat' }, + { phase: App.Phase.SecondMain, label: 'M2', title: 'Second main phase' }, + { phase: App.Phase.EndCleanup, label: 'END', title: 'End step / cleanup' }, +]; + +function PhaseBar({ gameId }: PhaseBarProps) { + const webClient = useWebClient(); + const { game, isJudge, isStarted } = useCurrentGame(gameId); + const activePhase = useAppSelector((state) => + gameId != null ? GameSelectors.getActivePhase(state, gameId) : undefined, + ); + const localPlayerId = game?.localPlayerId; + const tableCards = useAppSelector((state) => + gameId != null && localPlayerId != null + ? GameSelectors.getCards(state, gameId, localPlayerId, App.ZoneName.TABLE) + : undefined, + ); + + // Desktop: only the active player (or a judge) can advance the phase. + const canAdvance = + gameId != null && + game != null && + isStarted && + (isJudge || game.activePlayerId === game.localPlayerId); + + const handlePhaseClick = (phase: App.Phase) => { + if (!canAdvance || gameId == null) { + return; + } + webClient.request.game.setActivePhase(gameId, { phase }); + }; + + const handlePass = () => { + if (!canAdvance || gameId == null) { + return; + } + webClient.request.game.nextTurn(gameId); + }; + + // Desktop's untap-step double-click fires "Untap All" on the local player's + // table zone (cockatrice/src/game/player/player_actions.cpp actUntapAll). + // We replicate by sending one setCardAttr per tapped card; there is no + // batch variant on the wire. + const handleUntapAll = () => { + if (!canAdvance || gameId == null || !tableCards) { + return; + } + for (const card of tableCards) { + if (card.tapped) { + webClient.request.game.setCardAttr(gameId, { + zone: App.ZoneName.TABLE, + cardId: card.id, + attribute: Data.CardAttribute.AttrTapped, + attrValue: '0', + }); + } + } + }; + + const handleDrawOne = () => { + if (!canAdvance || gameId == null) { + return; + } + webClient.request.game.drawCards(gameId, { number: 1 }); + }; + + const onDoubleClickFor = (kind: 'untapAll' | 'drawCard' | undefined) => { + if (kind === 'untapAll') { + return handleUntapAll; + } + if (kind === 'drawCard') { + return handleDrawOne; + } + return undefined; + }; + + return ( + + ); +} + +export default PhaseBar; diff --git a/webclient/src/components/Game/PlayerBoard/PlayerBoard.css b/webclient/src/components/Game/PlayerBoard/PlayerBoard.css new file mode 100644 index 000000000..0d53ce60e --- /dev/null +++ b/webclient/src/components/Game/PlayerBoard/PlayerBoard.css @@ -0,0 +1,13 @@ +.player-board { + display: grid; + grid-template-columns: 160px minmax(0, 1fr) 110px; + height: 100%; + min-height: 0; + background: #0a1225; + border-bottom: 2px solid #1a2b52; +} + +.player-board--mirrored { + border-bottom: none; + border-top: 2px solid #1a2b52; +} diff --git a/webclient/src/components/Game/PlayerBoard/PlayerBoard.spec.tsx b/webclient/src/components/Game/PlayerBoard/PlayerBoard.spec.tsx new file mode 100644 index 000000000..2a76fc278 --- /dev/null +++ b/webclient/src/components/Game/PlayerBoard/PlayerBoard.spec.tsx @@ -0,0 +1,108 @@ +import { screen } from '@testing-library/react'; +import { App } from '@app/types'; + +// Block Battlefield's Dexie-backed useSettings from firing an async settle +// after mount (would produce an unwrapped React state update). +vi.mock('../../../hooks/useSettings'); + +import { makeStoreState, renderWithProviders, makeUser } from '../../../__test-utils__'; +import { + makeCard, + makeGameEntry, + makePlayerEntry, + makePlayerProperties, + makeZoneEntry, +} from '../../../store/game/__mocks__/fixtures'; +import PlayerBoard from './PlayerBoard'; + +function buildState() { + const table = makeZoneEntry({ + name: App.ZoneName.TABLE, + cards: [ + makeCard({ id: 1, name: 'Row0-Card', x: 0, y: 0 }), + makeCard({ id: 2, name: 'Row2-Card', x: 0, y: 2 }), + ], + cardCount: 2, + }); + const hand = makeZoneEntry({ name: App.ZoneName.HAND, cardCount: 0 }); + const deck = makeZoneEntry({ name: App.ZoneName.DECK, cardCount: 60 }); + const player = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'Trajer' }), + }), + zones: { + [App.ZoneName.TABLE]: table, + [App.ZoneName.HAND]: hand, + [App.ZoneName.DECK]: deck, + }, + }); + return makeStoreState({ + games: { + games: { + 1: makeGameEntry({ localPlayerId: 1, players: { 1: player } }), + }, + }, + }); +} + +describe('PlayerBoard', () => { + it('renders the info panel, battlefield, and zone rail in order', () => { + renderWithProviders(, { + preloadedState: buildState(), + }); + + expect(screen.getByText('Trajer')).toBeInTheDocument(); + expect(screen.getByTestId('battlefield')).toBeInTheDocument(); + expect(screen.getByTestId('zone-rail')).toBeInTheDocument(); + }); + + it('passes mirrored=false by default so the battlefield uses natural row order', () => { + const { container } = renderWithProviders( + , + { preloadedState: buildState() }, + ); + + const rowsInOrder = Array.from( + container.querySelectorAll('.battlefield__row'), + ).map((r) => r.getAttribute('data-row')); + expect(rowsInOrder).toEqual(['0', '1', '2']); + expect(container.querySelector('.card-slot--inverted')).toBeNull(); + }); + + it('propagates mirrored=true → battlefield reverses row order and cards are inverted', () => { + const { container } = renderWithProviders( + , + { preloadedState: buildState() }, + ); + + const rowsInOrder = Array.from( + container.querySelectorAll('.battlefield__row'), + ).map((r) => r.getAttribute('data-row')); + expect(rowsInOrder).toEqual(['2', '1', '0']); + expect(container.querySelectorAll('.card-slot--inverted').length).toBeGreaterThan(0); + }); + + it('keeps the info panel on the left and zone rail on the right in mirrored mode', () => { + const { container } = renderWithProviders( + , + { preloadedState: buildState() }, + ); + + const children = Array.from(container.querySelector('.player-board')!.children); + expect(children[0]).toHaveClass('player-info-panel'); + expect(children[1]).toHaveClass('battlefield'); + expect(children[2]).toHaveClass('zone-rail'); + }); + + it('adds the --mirrored CSS modifier only when mirrored', () => { + const { container, rerender } = renderWithProviders( + , + { preloadedState: buildState() }, + ); + + expect(container.querySelector('.player-board--mirrored')).toBeNull(); + rerender(); + expect(container.querySelector('.player-board--mirrored')).not.toBeNull(); + }); +}); diff --git a/webclient/src/components/Game/PlayerBoard/PlayerBoard.tsx b/webclient/src/components/Game/PlayerBoard/PlayerBoard.tsx new file mode 100644 index 000000000..6552dac90 --- /dev/null +++ b/webclient/src/components/Game/PlayerBoard/PlayerBoard.tsx @@ -0,0 +1,77 @@ +import type { Data } from '@app/types'; +import { cx } from '@app/utils'; + +import Battlefield from '../Battlefield/Battlefield'; +import PlayerInfoPanel from '../PlayerInfoPanel/PlayerInfoPanel'; +import ZoneRail from '../ZoneRail/ZoneRail'; + +import './PlayerBoard.css'; + +export interface PlayerBoardProps { + gameId: number; + playerId: number; + mirrored?: boolean; + canAct?: boolean; + canEditCounters?: boolean; + arrowSourceKey?: string | null; + onCardHover?: (card: Data.ServerInfo_Card) => void; + onCardClick?: (playerId: number, zone: string, card: Data.ServerInfo_Card) => void; + onCardContextMenu?: (card: Data.ServerInfo_Card, event: React.MouseEvent) => void; + onCardDoubleClick?: (card: Data.ServerInfo_Card) => void; + onZoneClick?: (playerId: number, zoneName: string) => void; + onZoneContextMenu?: (playerId: number, zoneName: string, event: React.MouseEvent) => void; + onRequestCreateCounter?: () => void; + onPlayerContextMenu?: (event: React.MouseEvent) => void; +} + +function PlayerBoard({ + gameId, + playerId, + mirrored = false, + canAct = false, + canEditCounters = false, + arrowSourceKey = null, + onCardHover, + onCardClick, + onCardContextMenu, + onCardDoubleClick, + onZoneClick, + onZoneContextMenu, + onRequestCreateCounter, + onPlayerContextMenu, +}: PlayerBoardProps) { + return ( +
+ + + +
+ ); +} + +export default PlayerBoard; diff --git a/webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.css b/webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.css new file mode 100644 index 000000000..574d9d480 --- /dev/null +++ b/webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.css @@ -0,0 +1,3 @@ +.player-context-menu .MuiMenuItem-root { + font-size: 13px; +} diff --git a/webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.spec.tsx b/webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.spec.tsx new file mode 100644 index 000000000..f6fe7e996 --- /dev/null +++ b/webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.spec.tsx @@ -0,0 +1,63 @@ +import { screen, fireEvent } from '@testing-library/react'; + +import { renderWithProviders } from '../../../__test-utils__'; +import PlayerContextMenu from './PlayerContextMenu'; + +const NOOP = () => {}; +const DEFAULT_PROPS = { + isOpen: true, + anchorPosition: { top: 10, left: 10 }, + onClose: NOOP, + onRequestCreateToken: NOOP, + onRequestViewSideboard: NOOP, +}; + +describe('PlayerContextMenu', () => { + it('fires onRequestCreateToken and closes when "Create token…" is clicked', () => { + const onRequestCreateToken = vi.fn(); + const onClose = vi.fn(); + + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole('menuitem', { name: /create token/i })); + + expect(onRequestCreateToken).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('fires onRequestViewSideboard and closes when "View sideboard…" is clicked', () => { + const onRequestViewSideboard = vi.fn(); + const onClose = vi.fn(); + + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole('menuitem', { name: /view sideboard/i })); + + expect(onRequestViewSideboard).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('does not render menu items when closed', () => { + renderWithProviders( + , + ); + + expect(screen.queryByRole('menuitem')).not.toBeInTheDocument(); + }); +}); diff --git a/webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.tsx b/webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.tsx new file mode 100644 index 000000000..f175c84fa --- /dev/null +++ b/webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.tsx @@ -0,0 +1,48 @@ +import Divider from '@mui/material/Divider'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; + +import './PlayerContextMenu.css'; + +export interface PlayerContextMenuProps { + isOpen: boolean; + anchorPosition: { top: number; left: number } | null; + onClose: () => void; + onRequestCreateToken: () => void; + onRequestViewSideboard: () => void; +} + +function PlayerContextMenu({ + isOpen, + anchorPosition, + onClose, + onRequestCreateToken, + onRequestViewSideboard, +}: PlayerContextMenuProps) { + const handleCreateToken = () => { + onRequestCreateToken(); + onClose(); + }; + + const handleViewSideboard = () => { + onRequestViewSideboard(); + onClose(); + }; + + return ( + + Create token… + + View sideboard… + + ); +} + +export default PlayerContextMenu; diff --git a/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.css b/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.css new file mode 100644 index 000000000..8a5500760 --- /dev/null +++ b/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.css @@ -0,0 +1,267 @@ +.player-info-panel { + width: 160px; + height: 100%; + padding: 8px 10px; + box-sizing: border-box; + display: flex; + flex-direction: column; + background: #0a1225; + border-right: 1px solid #1a2b52; + color: #e5ecf7; + font-size: 12px; +} + +.player-info-panel--empty { + opacity: 0.4; +} + +.player-info-panel__header { + display: flex; + align-items: baseline; + gap: 6px; + margin-bottom: 6px; + padding-bottom: 6px; + border-bottom: 1px solid #1a2b52; +} + +.player-info-panel__host-badge { + color: var(--color-highlight-yellow); + font-size: 13px; + line-height: 1; + flex-shrink: 0; +} + +.player-info-panel__name { + flex: 1; + font-weight: 700; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.player-info-panel__sideboard-lock { + font-size: 11px; + line-height: 1; + flex-shrink: 0; +} + +.player-info-panel__ping { + color: #7f90b5; + font-size: 10px; +} + +.player-info-panel__flag { + align-self: flex-start; + padding: 1px 6px; + background: #6a2626; + color: #ffdede; + font-size: 10px; + font-weight: 600; + border-radius: 3px; + letter-spacing: 1px; + text-transform: uppercase; + margin-bottom: 4px; +} + +.player-info-panel__flag--ready { + background: #22562b; + color: #dfffe3; +} + +/* Life display: prominent box above the regular counter list. Mirrors + desktop's PlayerTarget sizing where Life renders at ~2x other counters. */ +.player-info-panel__life { + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto auto; + grid-column-gap: 6px; + align-items: center; + margin: 2px 0 10px; + padding: 6px 10px 4px; + background: #0f1a35; + border: 2px solid #4a5d87; + border-radius: 6px; + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.4); +} + +.player-info-panel__life-btn { + width: 22px; + height: 22px; + padding: 0; + background: #17223d; + border: 1px solid #355090; + color: #dae3f7; + font: inherit; + font-size: 16px; + line-height: 1; + border-radius: 3px; + cursor: pointer; +} + +.player-info-panel__life-btn:hover { + background: #223060; + color: #fff; +} + +.player-info-panel__life-value, +.player-info-panel__life-input { + grid-row: 1; + font-size: 28px; + font-weight: 800; + line-height: 1; + color: #ffffff; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.player-info-panel__life-input { + width: 72px; + height: 32px; + padding: 0 6px; + background: #17223d; + border: 1px solid #355090; + border-radius: 3px; +} + +.player-info-panel__life-value--editable { + cursor: text; + border-bottom: 1px dashed rgba(255, 255, 255, 0.25); +} + +.player-info-panel__life-value--editable:hover { + background: rgba(255, 255, 255, 0.06); +} + +.player-info-panel__life-label { + grid-row: 2; + grid-column: 1 / -1; + text-align: center; + font-size: 9px; + font-weight: 700; + letter-spacing: 2px; + color: #7f90b5; + margin-top: 4px; +} + +.player-info-panel__counters { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.player-info-panel__counter { + display: grid; + grid-template-columns: 12px 1fr auto auto auto auto; + align-items: center; + gap: 3px; + padding: 2px 0; +} + +.player-info-panel__counter--empty { + color: #5a6a8a; + font-style: italic; + grid-template-columns: 1fr; +} + +.player-info-panel__swatch { + width: 12px; + height: 12px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.player-info-panel__counter-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.player-info-panel__counter-value { + font-weight: 700; + color: #fff; + min-width: 22px; + text-align: right; + padding: 0 4px; +} + +.player-info-panel__counter-value--editable { + cursor: text; + border-bottom: 1px dashed rgba(255, 255, 255, 0.2); +} + +.player-info-panel__counter-value--editable:hover { + background: rgba(255, 255, 255, 0.06); +} + +.player-info-panel__counter-input { + width: 36px; + height: 18px; + padding: 0 4px; + box-sizing: border-box; + background: #17223d; + border: 1px solid #355090; + color: #fff; + font: inherit; + font-weight: 700; + text-align: right; + border-radius: 2px; +} + +.player-info-panel__counter-input::-webkit-outer-spin-button, +.player-info-panel__counter-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.player-info-panel__counter-btn { + width: 18px; + height: 18px; + padding: 0; + background: #17223d; + border: 1px solid #233a68; + color: #c8d4ef; + font: inherit; + font-size: 13px; + line-height: 1; + border-radius: 2px; + cursor: pointer; +} + +.player-info-panel__counter-btn:hover { + background: #223060; + border-color: #355090; + color: #fff; +} + +.player-info-panel__counter-btn--del { + color: #f7a3a3; + border-color: #5e2828; + background: #3f1a1a; +} + +.player-info-panel__counter-btn--del:hover { + background: #5e2828; + color: #fff; +} + +.player-info-panel__new-counter { + margin-top: 8px; + padding: 3px 6px; + background: transparent; + border: 1px dashed #355090; + color: #8ab0ff; + font: inherit; + font-size: 11px; + border-radius: 3px; + cursor: pointer; + text-align: center; +} + +.player-info-panel__new-counter:hover { + background: rgba(138, 176, 255, 0.08); + color: #b8d0ff; +} diff --git a/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.spec.tsx b/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.spec.tsx new file mode 100644 index 000000000..1ff41d5c1 --- /dev/null +++ b/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.spec.tsx @@ -0,0 +1,348 @@ +import { screen, fireEvent } from '@testing-library/react'; +import { create } from '@bufbuild/protobuf'; +import { Data } from '@app/types'; + +import { createMockWebClient, makeStoreState, renderWithProviders, makeUser } from '../../../__test-utils__'; +import { + makeCounter, + makeGameEntry, + makePlayerEntry, + makePlayerProperties, +} from '../../../store/game/__mocks__/fixtures'; +import PlayerInfoPanel from './PlayerInfoPanel'; + +function statefulPlayer( + overrides: Partial[0]> = {}, +) { + const player = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'Pumuky' }), + pingSeconds: 42, + }), + ...overrides, + }); + return makeStoreState({ + games: { + games: { + 1: makeGameEntry({ + localPlayerId: 1, + players: { 1: player }, + }), + }, + }, + }); +} + +describe('PlayerInfoPanel', () => { + it('renders the player name and ping', () => { + renderWithProviders(, { + preloadedState: statefulPlayer(), + }); + + expect(screen.getByText('Pumuky')).toBeInTheDocument(); + expect(screen.getByText('42s')).toBeInTheDocument(); + }); + + it('falls back to "(unknown)" when userInfo is absent', () => { + const player = makePlayerEntry({ + properties: makePlayerProperties({ playerId: 1 }), + }); + renderWithProviders(, { + preloadedState: makeStoreState({ + games: { games: { 1: makeGameEntry({ players: { 1: player } }) } }, + }), + }); + + expect(screen.getByText('(unknown)')).toBeInTheDocument(); + }); + + it('renders Life in a prominent block above the rest, with "LIFE" label', () => { + const life = makeCounter({ + id: 1, + name: 'Life', + count: 20, + counterColor: create(Data.colorSchema, { r: 255, g: 255, b: 255, a: 255 }), + }); + const white = makeCounter({ + id: 2, + name: 'W', + count: 3, + counterColor: create(Data.colorSchema, { r: 250, g: 245, b: 220, a: 255 }), + }); + + renderWithProviders(, { + preloadedState: statefulPlayer({ + counters: { 1: life, 2: white }, + }), + }); + + expect(screen.getByTestId('life-1')).toHaveTextContent('20'); + expect(screen.getByText('LIFE')).toBeInTheDocument(); + // Other counters still render in the list with their name. + expect(screen.getByText('W')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('shows an empty-state line when no counters exist', () => { + renderWithProviders(, { + preloadedState: statefulPlayer({ counters: {} }), + }); + + expect(screen.getByText(/no counters/i)).toBeInTheDocument(); + }); + + it('shows the Conceded flag when player has conceded', () => { + const player = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'Quitter' }), + conceded: true, + }), + }); + renderWithProviders(, { + preloadedState: makeStoreState({ + games: { games: { 1: makeGameEntry({ players: { 1: player } }) } }, + }), + }); + + expect(screen.getByText(/conceded/i)).toBeInTheDocument(); + }); + + it('shows the Ready flag when readyStart is true and player has not conceded', () => { + const player = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'Waiting' }), + readyStart: true, + }), + }); + renderWithProviders(, { + preloadedState: makeStoreState({ + games: { games: { 1: makeGameEntry({ players: { 1: player } }) } }, + }), + }); + + expect(screen.getByText(/ready/i)).toBeInTheDocument(); + }); + + it('renders an empty panel when the player is missing', () => { + const { container } = renderWithProviders( + , + { preloadedState: statefulPlayer() }, + ); + + expect(container.querySelector('.player-info-panel--empty')).not.toBeNull(); + expect(screen.queryByText('Pumuky')).not.toBeInTheDocument(); + }); + + it('renders a host badge when the player is the game host', () => { + const player = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'Host' }), + }), + }); + const { container } = renderWithProviders( + , + { + preloadedState: makeStoreState({ + games: { + games: { + 1: makeGameEntry({ hostId: 1, players: { 1: player } }), + }, + }, + }), + }, + ); + + expect(container.querySelector('.player-info-panel__host-badge')).not.toBeNull(); + }); + + it('omits the host badge when the player is not the host', () => { + const player = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 2, + userInfo: makeUser({ name: 'Guest' }), + }), + }); + const { container } = renderWithProviders( + , + { + preloadedState: makeStoreState({ + games: { + games: { + 1: makeGameEntry({ hostId: 1, players: { 2: player } }), + }, + }, + }), + }, + ); + + expect(container.querySelector('.player-info-panel__host-badge')).toBeNull(); + }); + + // Sideboard lock indicator — mirrors desktop's `DeckViewContainer` + // lock UI. The webclient surfaces it on the info panel since we don't + // have a persistent deck view. + it('renders a 🔒 indicator when player.properties.sideboardLocked is true', () => { + const player = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'P1' }), + sideboardLocked: true, + }), + }); + renderWithProviders(, { + preloadedState: makeStoreState({ + games: { games: { 1: makeGameEntry({ players: { 1: player } }) } }, + }), + }); + + expect(screen.getByLabelText('sideboard locked')).toBeInTheDocument(); + }); + + it('omits the lock indicator when sideboardLocked is false', () => { + const player = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'P1' }), + sideboardLocked: false, + }), + }); + renderWithProviders(, { + preloadedState: makeStoreState({ + games: { games: { 1: makeGameEntry({ players: { 1: player } }) } }, + }), + }); + + expect(screen.queryByLabelText('sideboard locked')).not.toBeInTheDocument(); + }); + + describe('editable counters', () => { + const life = makeCounter({ + id: 1, + name: 'Life', + count: 20, + counterColor: create(Data.colorSchema, { r: 255, g: 255, b: 255, a: 255 }), + }); + + it('does not render counter controls when canEdit is false (default)', () => { + renderWithProviders(, { + preloadedState: statefulPlayer({ counters: { 1: life } }), + }); + + expect(screen.queryByLabelText('increment Life')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('decrement Life')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('delete Life')).not.toBeInTheDocument(); + }); + + it('renders +/− controls on the Life block when canEdit is true (Life has no delete — desktop parity)', () => { + renderWithProviders(, { + preloadedState: statefulPlayer({ counters: { 1: life } }), + }); + + expect(screen.getByLabelText('increment Life')).toBeInTheDocument(); + expect(screen.getByLabelText('decrement Life')).toBeInTheDocument(); + expect(screen.queryByLabelText('delete Life')).not.toBeInTheDocument(); + }); + + it('dispatches incCounter(+1) when + is clicked', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: statefulPlayer({ counters: { 1: life } }), + webClient, + }); + + fireEvent.click(screen.getByLabelText('increment Life')); + + expect(webClient.request.game.incCounter).toHaveBeenCalledWith(1, { counterId: 1, delta: 1 }); + }); + + it('dispatches incCounter(-1) when − is clicked', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: statefulPlayer({ counters: { 1: life } }), + webClient, + }); + + fireEvent.click(screen.getByLabelText('decrement Life')); + + expect(webClient.request.game.incCounter).toHaveBeenCalledWith(1, { counterId: 1, delta: -1 }); + }); + + it('dispatches delCounter when × is clicked on a non-Life counter', () => { + const webClient = createMockWebClient(); + const mana = makeCounter({ + id: 2, + name: 'W', + count: 3, + counterColor: create(Data.colorSchema, { r: 255, g: 255, b: 255, a: 255 }), + }); + renderWithProviders(, { + preloadedState: statefulPlayer({ counters: { 2: mana } }), + webClient, + }); + + fireEvent.click(screen.getByLabelText('delete W')); + + expect(webClient.request.game.delCounter).toHaveBeenCalledWith(1, { counterId: 2 }); + }); + + it('swaps the value into an input on click and dispatches setCounter on Enter', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + 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(, { + preloadedState: statefulPlayer({ counters: { 1: life } }), + webClient, + }); + + fireEvent.click(screen.getByText('20')); + const input = screen.getByLabelText('set Life') as HTMLInputElement; + fireEvent.change(input, { target: { value: '99' } }); + fireEvent.keyDown(input, { key: 'Escape' }); + + expect(webClient.request.game.setCounter).not.toHaveBeenCalled(); + }); + + it('fires onRequestCreateCounter when "+ New counter" is clicked', () => { + const onRequestCreateCounter = vi.fn(); + renderWithProviders( + , + { preloadedState: statefulPlayer({ counters: { 1: life } }) }, + ); + + fireEvent.click(screen.getByText('+ New counter')); + + expect(onRequestCreateCounter).toHaveBeenCalled(); + }); + + it('does not render the new-counter button when canEdit is false', () => { + renderWithProviders( + {}} />, + { preloadedState: statefulPlayer({ counters: {} }) }, + ); + + expect(screen.queryByText('+ New counter')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.tsx b/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.tsx new file mode 100644 index 000000000..80e3b2d6b --- /dev/null +++ b/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.tsx @@ -0,0 +1,286 @@ +import { useState } from 'react'; +import { useWebClient } from '@app/hooks'; +import { GameSelectors, useAppSelector } from '@app/store'; +import { cx } from '@app/utils'; +import type { Data } from '@app/types'; + +import './PlayerInfoPanel.css'; + +export interface PlayerInfoPanelProps { + gameId: number; + playerId: number; + canEdit?: boolean; + onRequestCreateCounter?: () => void; + onContextMenu?: (event: React.MouseEvent) => void; +} + +function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string { + if (!c) { + return '#666'; + } + return `rgba(${c.r}, ${c.g}, ${c.b}, ${(c.a ?? 255) / 255})`; +} + +// Desktop renders Life larger/bolder than other counters (see +// cockatrice/src/game/player/player.cpp PlayerTarget sizing). We special- +// case the counter whose name is exactly 'Life' (case-insensitive) and +// pull it out of the regular counter list into a prominent life block. +function isLifeCounter(c: { name: string }): boolean { + return c.name.trim().toLowerCase() === 'life'; +} + +function PlayerInfoPanel({ + gameId, + playerId, + canEdit = false, + onRequestCreateCounter, + onContextMenu, +}: PlayerInfoPanelProps) { + const webClient = useWebClient(); + const player = useAppSelector((state) => GameSelectors.getPlayer(state, gameId, playerId)); + const counters = useAppSelector((state) => GameSelectors.getCounters(state, gameId, playerId)); + const hostId = useAppSelector((state) => GameSelectors.getHostId(state, gameId)); + + const [editingId, setEditingId] = useState(null); + const [editDraft, setEditDraft] = useState(''); + + if (!player) { + return
; + } + + const name = player.properties.userInfo?.name ?? '(unknown)'; + const ping = player.properties.pingSeconds ?? 0; + const conceded = player.properties.conceded; + const ready = player.properties.readyStart; + const sideboardLocked = player.properties.sideboardLocked ?? false; + const isHost = hostId != null && hostId === playerId; + const allCounters = Object.values(counters); + const lifeCounter = allCounters.find(isLifeCounter); + const otherCounters = allCounters.filter((c) => !isLifeCounter(c)); + + const handleIncrement = (counterId: number, delta: number) => { + webClient.request.game.incCounter(gameId, { counterId, delta }); + }; + + const handleDelete = (counterId: number) => { + webClient.request.game.delCounter(gameId, { counterId }); + }; + + const beginEdit = (counterId: number, currentValue: number) => { + setEditingId(counterId); + setEditDraft(String(currentValue)); + }; + + const commitEdit = (counterId: number) => { + const trimmed = editDraft.trim(); + // Empty input cancels the edit (desktop inline edits treat blur-with- + // no-change and blur-with-empty-string identically). Prior behavior + // coerced '' → 0 because `Number('')` is 0 and `Number.isInteger(0)` is + // true, which surprised users expecting cancel-on-blank. + if (trimmed.length === 0) { + setEditingId(null); + return; + } + const value = Number(trimmed); + if (Number.isInteger(value)) { + webClient.request.game.setCounter(gameId, { counterId, value }); + } + setEditingId(null); + }; + + const cancelEdit = () => { + setEditingId(null); + }; + + const renderCounterRow = (c: Data.ServerInfo_Counter) => ( +
  • + + {c.name} + {canEdit && ( + + )} + {editingId === c.id ? ( + 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}`} + /> + ) : ( + beginEdit(c.id, c.count) : undefined} + role={canEdit ? 'button' : undefined} + tabIndex={canEdit ? 0 : undefined} + > + {c.count} + + )} + {canEdit && ( + + )} + {canEdit && ( + + )} +
  • + ); + + return ( +
    +
    + {isHost && ( + + ♛ + + )} + {name} + {sideboardLocked && ( + + 🔒 + + )} + + {ping}s + +
    + + {conceded &&
    Conceded
    } + {!conceded && ready &&
    Ready
    } + + {lifeCounter && ( +
    + {canEdit && ( + + )} + {editingId === lifeCounter.id ? ( + setEditDraft(e.target.value)} + onBlur={() => commitEdit(lifeCounter.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + commitEdit(lifeCounter.id); + } + if (e.key === 'Escape') { + cancelEdit(); + } + }} + aria-label="set Life" + /> + ) : ( + beginEdit(lifeCounter.id, lifeCounter.count) : undefined} + role={canEdit ? 'button' : undefined} + tabIndex={canEdit ? 0 : undefined} + aria-label={`Life: ${lifeCounter.count}`} + > + {lifeCounter.count} + + )} + {canEdit && ( + + )} +
    LIFE
    +
    + )} + +
      + {otherCounters.length === 0 && !lifeCounter && ( +
    • + no counters +
    • + )} + {otherCounters.map(renderCounterRow)} +
    + + {canEdit && onRequestCreateCounter && ( + + )} +
    + ); +} + +export default PlayerInfoPanel; diff --git a/webclient/src/components/Game/PlayerList/PlayerList.css b/webclient/src/components/Game/PlayerList/PlayerList.css new file mode 100644 index 000000000..c76e3a15b --- /dev/null +++ b/webclient/src/components/Game/PlayerList/PlayerList.css @@ -0,0 +1,76 @@ +.player-list { + padding: 8px 12px; + background: #0a1225; + border-bottom: 1px solid #1a2b52; + color: #e5ecf7; + font-size: 12px; +} + +.player-list__heading { + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: #8597bb; + margin-bottom: 6px; +} + +.player-list__items { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.player-list__empty { + color: #5a6a8a; + font-style: italic; +} + +.player-list__item { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 0; +} + +.player-list__host-badge { + color: var(--color-highlight-yellow); + font-size: 12px; + line-height: 1; +} + +.player-list__indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: #3a4b73; +} + +.player-list__indicator--active { + background: #d48a00; + box-shadow: 0 0 4px var(--color-highlight-yellow); +} + +.player-list__item--active .player-list__name { + font-weight: 700; +} + +.player-list__item--conceded { + opacity: 0.5; + text-decoration: line-through; +} + +.player-list__name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.player-list__ping { + color: #7f90b5; + font-size: 10px; +} diff --git a/webclient/src/components/Game/PlayerList/PlayerList.spec.tsx b/webclient/src/components/Game/PlayerList/PlayerList.spec.tsx new file mode 100644 index 000000000..790972b47 --- /dev/null +++ b/webclient/src/components/Game/PlayerList/PlayerList.spec.tsx @@ -0,0 +1,143 @@ +import { screen } from '@testing-library/react'; + +import { makeStoreState, renderWithProviders, makeUser } from '../../../__test-utils__'; +import { + makeGameEntry, + makePlayerEntry, + makePlayerProperties, +} from '../../../store/game/__mocks__/fixtures'; +import PlayerList from './PlayerList'; + +function buildState( + players: ReturnType[], + activePlayerId: number, + hostId?: number, +) { + const byId: Record> = {}; + for (const p of players) { + byId[p.properties.playerId] = p; + } + return makeStoreState({ + games: { + games: { + 1: makeGameEntry({ + players: byId, + activePlayerId, + ...(hostId != null ? { hostId } : {}), + }), + }, + }, + }); +} + +describe('PlayerList', () => { + it('lists every player in the game', () => { + const p1 = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'Alice' }), + pingSeconds: 10, + }), + }); + const p2 = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 2, + userInfo: makeUser({ name: 'Bob' }), + pingSeconds: 20, + }), + }); + + renderWithProviders(, { + preloadedState: buildState([p1, p2], 1), + }); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('10s')).toBeInTheDocument(); + expect(screen.getByText('20s')).toBeInTheDocument(); + }); + + it('highlights the active player', () => { + const p1 = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'Alice' }), + }), + }); + const p2 = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 2, + userInfo: makeUser({ name: 'Bob' }), + }), + }); + + renderWithProviders(, { + preloadedState: buildState([p1, p2], 2), + }); + + expect(screen.getByTestId('player-list-item-2')).toHaveClass( + 'player-list__item--active', + ); + expect(screen.getByTestId('player-list-item-1')).not.toHaveClass( + 'player-list__item--active', + ); + }); + + it('dims conceded players', () => { + const p1 = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'Alice' }), + conceded: true, + }), + }); + + renderWithProviders(, { + preloadedState: buildState([p1], 0), + }); + + expect(screen.getByTestId('player-list-item-1')).toHaveClass( + 'player-list__item--conceded', + ); + }); + + it('shows empty state when there are no players', () => { + renderWithProviders(, { + preloadedState: buildState([], 0), + }); + + expect(screen.getByText(/no players/i)).toBeInTheDocument(); + }); + + it('handles missing gameId without throwing', () => { + renderWithProviders(, { + preloadedState: makeStoreState({}), + }); + + expect(screen.getByText(/no players/i)).toBeInTheDocument(); + }); + + it('renders a host badge on the host row only', () => { + const p1 = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'Alice' }), + }), + }); + const p2 = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 2, + userInfo: makeUser({ name: 'Bob' }), + }), + }); + + renderWithProviders(, { + preloadedState: buildState([p1, p2], 1, 2), + }); + + const bobRow = screen.getByTestId('player-list-item-2'); + const aliceRow = screen.getByTestId('player-list-item-1'); + expect(bobRow.querySelector('.player-list__host-badge')).not.toBeNull(); + expect(aliceRow.querySelector('.player-list__host-badge')).toBeNull(); + }); +}); diff --git a/webclient/src/components/Game/PlayerList/PlayerList.tsx b/webclient/src/components/Game/PlayerList/PlayerList.tsx new file mode 100644 index 000000000..9b0374887 --- /dev/null +++ b/webclient/src/components/Game/PlayerList/PlayerList.tsx @@ -0,0 +1,68 @@ +import { GameSelectors, useAppSelector } from '@app/store'; +import { cx } from '@app/utils'; + +import './PlayerList.css'; + +export interface PlayerListProps { + gameId: number | undefined; +} + +function PlayerList({ gameId }: PlayerListProps) { + const players = useAppSelector((state) => + gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined, + ); + const activePlayerId = useAppSelector((state) => + gameId != null ? GameSelectors.getActivePlayerId(state, gameId) : undefined, + ); + const hostId = useAppSelector((state) => + gameId != null ? GameSelectors.getHostId(state, gameId) : undefined, + ); + + const entries = players ? Object.values(players) : []; + + return ( +
    +
    Players
    +
      + {entries.length === 0 && ( +
    • no players
    • + )} + {entries.map((p) => { + const pid = p.properties.playerId; + const name = p.properties.userInfo?.name ?? '(unknown)'; + const isActive = pid === activePlayerId; + const isHost = pid === hostId; + return ( +
    • + + {isHost && ( + + ♛ + + )} + {name} + {p.properties.pingSeconds}s +
    • + ); + })} +
    +
    + ); +} + +export default PlayerList; diff --git a/webclient/src/components/Game/RightPanel/RightPanel.css b/webclient/src/components/Game/RightPanel/RightPanel.css new file mode 100644 index 000000000..99cd058d1 --- /dev/null +++ b/webclient/src/components/Game/RightPanel/RightPanel.css @@ -0,0 +1,21 @@ +.right-panel { + width: 320px; + height: 100%; + display: flex; + flex-direction: column; + background: #0a1225; + border-left: 1px solid #1a2b52; + overflow: hidden; +} + +.right-panel__spectating { + padding: 4px 12px; + background: #23324f; + color: var(--color-highlight-yellow); + font-size: 10px; + font-weight: 700; + letter-spacing: 1.2px; + text-transform: uppercase; + text-align: center; + border-bottom: 1px solid #1a2b52; +} diff --git a/webclient/src/components/Game/RightPanel/RightPanel.spec.tsx b/webclient/src/components/Game/RightPanel/RightPanel.spec.tsx new file mode 100644 index 000000000..0edd295ec --- /dev/null +++ b/webclient/src/components/Game/RightPanel/RightPanel.spec.tsx @@ -0,0 +1,80 @@ +import { screen, fireEvent } from '@testing-library/react'; + +// Block TurnControls' Dexie-backed useSettings from firing an async settle +// after mount (the Dexie mock resolves on a microtask, which would produce +// an unwrapped React state update inside TurnControls). +vi.mock('../../../hooks/useSettings'); + +import { makeStoreState, renderWithProviders } from '../../../__test-utils__'; +import { makeCard, makeGameEntry } from '../../../store/game/__mocks__/fixtures'; +import RightPanel from './RightPanel'; + +function stateWithGame() { + return makeStoreState({ games: { games: { 1: makeGameEntry() } } }); +} + +const NOOP = () => {}; +const DEFAULT_RP_PROPS = { + gameId: 1, + hoveredCard: null, + onRequestRollDie: NOOP, + onRequestConcede: NOOP, + onRequestUnconcede: NOOP, + onRequestGameInfo: NOOP, + onToggleRotate90: NOOP, + isRotated: false, +}; + +describe('RightPanel', () => { + it('renders CardPreview, PlayerList, GameLog, and TurnControls', () => { + renderWithProviders(, { + preloadedState: stateWithGame(), + }); + + expect(screen.getByTestId('card-preview')).toBeInTheDocument(); + expect(screen.getByTestId('player-list')).toBeInTheDocument(); + expect(screen.getByTestId('game-log')).toBeInTheDocument(); + expect(screen.getByTestId('turn-controls')).toBeInTheDocument(); + }); + + it('forwards the hovered card into the preview', () => { + const card = makeCard({ name: 'Lightning Bolt' }); + renderWithProviders( + , + { preloadedState: stateWithGame() }, + ); + + const small = document.querySelector('.card-preview__image--small') as HTMLImageElement; + expect(small.src).toContain('Lightning%20Bolt'); + }); + + it('forwards Roll Die clicks through to the parent callback', () => { + const onRequestRollDie = vi.fn(); + renderWithProviders( + , + { preloadedState: stateWithGame() }, + ); + + fireEvent.click(screen.getByRole('button', { name: /roll die/i })); + + expect(onRequestRollDie).toHaveBeenCalled(); + }); + + it('shows the Spectating tag when the local user is a spectator', () => { + renderWithProviders(, { + preloadedState: makeStoreState({ + games: { games: { 1: makeGameEntry({ spectator: true }) } }, + }), + }); + + expect(screen.getByTestId('spectating-tag')).toBeInTheDocument(); + }); + + it('hides the Spectating tag when the local user is a participant', () => { + renderWithProviders(, { + preloadedState: stateWithGame(), + }); + + expect(screen.queryByTestId('spectating-tag')).not.toBeInTheDocument(); + }); +}); diff --git a/webclient/src/components/Game/RightPanel/RightPanel.tsx b/webclient/src/components/Game/RightPanel/RightPanel.tsx new file mode 100644 index 000000000..20e39f40c --- /dev/null +++ b/webclient/src/components/Game/RightPanel/RightPanel.tsx @@ -0,0 +1,59 @@ +import type { Data } from '@app/types'; +import { GameSelectors, useAppSelector } from '@app/store'; + +import CardPreview from '../CardPreview/CardPreview'; +import GameLog from '../GameLog/GameLog'; +import PlayerList from '../PlayerList/PlayerList'; +import TurnControls from '../TurnControls/TurnControls'; + +import './RightPanel.css'; + +export interface RightPanelProps { + gameId: number | undefined; + hoveredCard: Data.ServerInfo_Card | null | undefined; + onRequestRollDie: () => void; + onRequestConcede: () => void; + onRequestUnconcede: () => void; + onRequestGameInfo: () => void; + onToggleRotate90: () => void; + isRotated: boolean; +} + +function RightPanel({ + gameId, + hoveredCard, + onRequestRollDie, + onRequestConcede, + onRequestUnconcede, + onRequestGameInfo, + onToggleRotate90, + isRotated, +}: RightPanelProps) { + const isSpectator = useAppSelector((state) => + gameId != null ? GameSelectors.isSpectator(state, gameId) : false, + ); + + return ( + + ); +} + +export default RightPanel; diff --git a/webclient/src/components/Game/StackStrip/StackStrip.css b/webclient/src/components/Game/StackStrip/StackStrip.css new file mode 100644 index 000000000..b66fb6c77 --- /dev/null +++ b/webclient/src/components/Game/StackStrip/StackStrip.css @@ -0,0 +1,54 @@ +.stack-strip { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 16px; + background: #0a1225; + border-top: 1px solid #1a2b52; + border-bottom: 1px solid #1a2b52; + font-size: 11px; + color: #c8d4ef; +} + +.stack-strip__heading { + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: #8597bb; + flex-shrink: 0; +} + +.stack-strip__cell { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border: 1px solid #233a68; + border-radius: 3px; + background: #17223d; +} + +.stack-strip__cell[role='button'] { + cursor: pointer; +} + +.stack-strip__cell[role='button']:hover { + background: #223060; + border-color: #355090; +} + +.stack-strip__label { + font-weight: 600; + color: #c8d4ef; +} + +.stack-strip__count { + min-width: 16px; + padding: 0 4px; + background: #050914; + color: var(--color-highlight-yellow); + font-variant-numeric: tabular-nums; + font-weight: 700; + border-radius: 2px; + text-align: center; +} diff --git a/webclient/src/components/Game/StackStrip/StackStrip.spec.tsx b/webclient/src/components/Game/StackStrip/StackStrip.spec.tsx new file mode 100644 index 000000000..9e192cfe0 --- /dev/null +++ b/webclient/src/components/Game/StackStrip/StackStrip.spec.tsx @@ -0,0 +1,107 @@ +import { screen, fireEvent } from '@testing-library/react'; +import { App } from '@app/types'; + +import { makeStoreState, renderWithProviders } from '../../../__test-utils__'; +import { + makeGameEntry, + makePlayerEntry, + makePlayerProperties, + makeZoneEntry, +} from '../../../store/game/__mocks__/fixtures'; +import StackStrip from './StackStrip'; + +function stateWithStacks(localCount: number, opponentCount: number) { + const local = makePlayerEntry({ + properties: makePlayerProperties({ playerId: 1 }), + zones: { + [App.ZoneName.STACK]: makeZoneEntry({ + name: App.ZoneName.STACK, + cardCount: localCount, + }), + }, + }); + const opponent = makePlayerEntry({ + properties: makePlayerProperties({ playerId: 2 }), + zones: { + [App.ZoneName.STACK]: makeZoneEntry({ + name: App.ZoneName.STACK, + cardCount: opponentCount, + }), + }, + }); + return makeStoreState({ + games: { + games: { + 1: makeGameEntry({ localPlayerId: 1, players: { 1: local, 2: opponent } }), + }, + }, + }); +} + +describe('StackStrip', () => { + it('renders a cell per entry with the zone cardCount', () => { + renderWithProviders( + , + { 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( + , + { 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( + , + { 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( + , + { preloadedState: stateWithStacks(0, 0) }, + ); + + const cell = screen.getByTestId('stack-strip-cell-1'); + expect(cell).not.toHaveAttribute('role', 'button'); + expect(cell).not.toHaveAttribute('tabindex'); + }); +}); diff --git a/webclient/src/components/Game/StackStrip/StackStrip.tsx b/webclient/src/components/Game/StackStrip/StackStrip.tsx new file mode 100644 index 000000000..1f1b1e075 --- /dev/null +++ b/webclient/src/components/Game/StackStrip/StackStrip.tsx @@ -0,0 +1,73 @@ +import { GameSelectors, useAppSelector } from '@app/store'; +import { App } from '@app/types'; + +import './StackStrip.css'; + +export interface StackStripEntry { + playerId: number; + name: string; +} + +export interface StackStripProps { + gameId: number; + entries: StackStripEntry[]; + onZoneClick?: (playerId: number, zoneName: string) => void; +} + +interface StackCellProps { + gameId: number; + playerId: number; + name: string; + onClick?: (playerId: number, zoneName: string) => void; +} + +function StackCell({ gameId, playerId, name, onClick }: StackCellProps) { + const zone = useAppSelector((state) => + GameSelectors.getZone(state, gameId, playerId, App.ZoneName.STACK), + ); + const count = zone?.cardCount ?? 0; + const clickable = onClick != null; + + const handleClick = () => { + onClick?.(playerId, App.ZoneName.STACK); + }; + + return ( +
    { + 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'}`} + > + {name} + {count} +
    + ); +} + +function StackStrip({ gameId, entries, onZoneClick }: StackStripProps) { + return ( +
    + Stack + {entries.map((e) => ( + + ))} +
    + ); +} + +export default StackStrip; diff --git a/webclient/src/components/Game/TurnControls/TurnControls.css b/webclient/src/components/Game/TurnControls/TurnControls.css new file mode 100644 index 000000000..21fa1c716 --- /dev/null +++ b/webclient/src/components/Game/TurnControls/TurnControls.css @@ -0,0 +1,39 @@ +.turn-controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + padding: 6px 12px; + border-top: 1px solid #1a2b52; +} + +.turn-controls__btn { + height: 28px; + padding: 0 6px; + background: #17223d; + border: 1px solid #233a68; + color: #c8d4ef; + font-family: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + border-radius: 3px; + cursor: pointer; +} + +.turn-controls__btn:hover:not(:disabled) { + background: #223060; + border-color: #355090; + color: #fff; +} + +.turn-controls__btn:disabled { + opacity: 0.45; + cursor: default; +} + +.turn-controls__btn--active { + background: #2d4583; + border-color: #5a7fcd; + color: #fff; +} diff --git a/webclient/src/components/Game/TurnControls/TurnControls.spec.tsx b/webclient/src/components/Game/TurnControls/TurnControls.spec.tsx new file mode 100644 index 000000000..61f8bc6b4 --- /dev/null +++ b/webclient/src/components/Game/TurnControls/TurnControls.spec.tsx @@ -0,0 +1,394 @@ +import { screen, fireEvent } from '@testing-library/react'; + +vi.mock('../../../hooks/useSettings'); + +import { useSettings } from '../../../hooks/useSettings'; +import { makeSettings, makeSettingsHook } from '../../../hooks/__mocks__/useSettings'; +import { LoadingState } from '../../../hooks/useSharedStore'; +import { createMockWebClient, makeStoreState, renderWithProviders, makeUser } from '../../../__test-utils__'; +import { + makeGameEntry, + makePlayerEntry, + makePlayerProperties, +} from '../../../store/game/__mocks__/fixtures'; +import TurnControls from './TurnControls'; + +function stateWith(opts: { + localPlayerId?: number; + activePlayerId?: number; + started?: boolean; + conceded?: boolean; + judge?: boolean; + spectator?: boolean; + hostId?: number; + opponentIds?: number[]; +} = {}) { + const localId = opts.localPlayerId ?? 1; + const opponentIds = opts.opponentIds ?? []; + const players: Record> = { + [localId]: makePlayerEntry({ + properties: makePlayerProperties({ + playerId: localId, + userInfo: makeUser({ name: `P${localId}` }), + conceded: opts.conceded ?? false, + }), + }), + }; + for (const id of opponentIds) { + players[id] = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: id, + userInfo: makeUser({ name: `P${id}` }), + }), + }); + } + return makeStoreState({ + games: { + games: { + 1: makeGameEntry({ + localPlayerId: localId, + activePlayerId: opts.activePlayerId ?? localId, + started: opts.started ?? true, + judge: opts.judge ?? false, + spectator: opts.spectator ?? false, + hostId: opts.hostId ?? localId, + players, + }), + }, + }, + }); +} + +const NOOP = () => {}; +const DEFAULT_TURN_PROPS = { + gameId: 1, + onRequestRollDie: NOOP, + onRequestConcede: NOOP, + onRequestUnconcede: NOOP, + onRequestGameInfo: NOOP, + onToggleRotate90: NOOP, + isRotated: false, +}; + +describe('TurnControls', () => { + beforeEach(() => { + vi.mocked(useSettings).mockReturnValue(makeSettingsHook()); + }); + + it('renders core buttons', () => { + renderWithProviders(, { + preloadedState: stateWith(), + }); + + expect(screen.getByRole('button', { name: /pass turn/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /reverse turn/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /next phase/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^concede$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /roll die/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /remove arrows/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /rotate 90/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /game info/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /leave game/i })).toBeInTheDocument(); + }); + + it('dispatches nextTurn on Pass Turn when the local player is active', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWith(), + webClient, + }); + + fireEvent.click(screen.getByRole('button', { name: /pass turn/i })); + + expect(webClient.request.game.nextTurn).toHaveBeenCalledWith(1); + }); + + it('dispatches reverseTurn on Reverse Turn', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWith(), + webClient, + }); + + fireEvent.click(screen.getByRole('button', { name: /reverse turn/i })); + + expect(webClient.request.game.reverseTurn).toHaveBeenCalledWith(1); + }); + + it('dispatches setActivePhase(current+1 mod 11) on Next Phase', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWith(), + webClient, + }); + + fireEvent.click(screen.getByRole('button', { name: /next phase/i })); + + // activePhase defaults to 0 in fixtures → Next Phase goes to 1. + expect(webClient.request.game.setActivePhase).toHaveBeenCalledWith(1, { phase: 1 }); + }); + + it('disables Pass/Reverse/NextPhase when local player is not active and is not a judge', () => { + renderWithProviders(, { + preloadedState: stateWith({ localPlayerId: 1, activePlayerId: 2 }), + }); + + expect(screen.getByRole('button', { name: /pass turn/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /reverse turn/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /next phase/i })).toBeDisabled(); + }); + + it('enables Pass/Reverse/NextPhase for a judge even when not the active player (desktop parity)', () => { + renderWithProviders(, { + preloadedState: stateWith({ localPlayerId: 1, activePlayerId: 2, judge: true }), + }); + + expect(screen.getByRole('button', { name: /pass turn/i })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: /next phase/i })).not.toBeDisabled(); + }); + + it('routes Concede through the parent confirm handler (no direct dispatch)', () => { + const webClient = createMockWebClient(); + const onRequestConcede = vi.fn(); + renderWithProviders( + , + { preloadedState: stateWith(), webClient }, + ); + + fireEvent.click(screen.getByRole('button', { name: /^concede$/i })); + + expect(onRequestConcede).toHaveBeenCalled(); + // Direct dispatch only fires from the ConfirmDialog "Confirm" path. + expect(webClient.request.game.concede).not.toHaveBeenCalled(); + }); + + it('routes Unconcede through the parent confirm handler when already conceded', () => { + const onRequestUnconcede = vi.fn(); + renderWithProviders( + , + { preloadedState: stateWith({ conceded: true }) }, + ); + + fireEvent.click(screen.getByRole('button', { name: /unconcede/i })); + + expect(onRequestUnconcede).toHaveBeenCalled(); + }); + + it('dispatches leaveGame when Leave Game is clicked', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWith(), + webClient, + }); + + fireEvent.click(screen.getByRole('button', { name: /leave game/i })); + + expect(webClient.request.game.leaveGame).toHaveBeenCalledWith(1); + }); + + it('fires onRequestRollDie when Roll Die is clicked', () => { + const onRequestRollDie = vi.fn(); + renderWithProviders( + , + { preloadedState: stateWith() }, + ); + + fireEvent.click(screen.getByRole('button', { name: /roll die/i })); + + expect(onRequestRollDie).toHaveBeenCalled(); + }); + + it('fires onRequestGameInfo when Game Info is clicked', () => { + const onRequestGameInfo = vi.fn(); + renderWithProviders( + , + { preloadedState: stateWith() }, + ); + + fireEvent.click(screen.getByRole('button', { name: /game info/i })); + + expect(onRequestGameInfo).toHaveBeenCalled(); + }); + + it('fires onToggleRotate90 when Rotate 90° is clicked and flips label when already rotated', () => { + const onToggleRotate90 = vi.fn(); + const { rerender } = renderWithProviders( + , + { preloadedState: stateWith() }, + ); + + fireEvent.click(screen.getByRole('button', { name: /rotate 90/i })); + expect(onToggleRotate90).toHaveBeenCalled(); + + rerender(); + expect(screen.getByRole('button', { name: /unrotate view/i })).toBeInTheDocument(); + }); + + it('disables Remove Arrows when the local player has no arrows', () => { + renderWithProviders(, { + preloadedState: stateWith(), + }); + + expect(screen.getByRole('button', { name: /remove arrows/i })).toBeDisabled(); + }); + + it('hides the Kick button for non-hosts', () => { + renderWithProviders(, { + preloadedState: stateWith({ localPlayerId: 1, hostId: 2, opponentIds: [2] }), + }); + + expect(screen.queryByRole('button', { name: /kick/i })).not.toBeInTheDocument(); + }); + + it('shows Kick for hosts and opens an opponent picker', () => { + renderWithProviders(, { + preloadedState: stateWith({ localPlayerId: 1, hostId: 1, opponentIds: [2, 3] }), + }); + + fireEvent.click(screen.getByRole('button', { name: /kick/i })); + + expect(screen.getByText('P2')).toBeInTheDocument(); + expect(screen.getByText('P3')).toBeInTheDocument(); + }); + + it('dispatches kickFromGame with the chosen opponent', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: stateWith({ localPlayerId: 1, hostId: 1, opponentIds: [2, 3] }), + webClient, + }); + + fireEvent.click(screen.getByRole('button', { name: /kick/i })); + fireEvent.click(screen.getByText('P3')); + + expect(webClient.request.game.kickFromGame).toHaveBeenCalledWith(1, { playerId: 3 }); + }); + + describe('spectator gating', () => { + it('disables Concede/Unconcede/RollDie for pure spectators (desktop parity)', () => { + renderWithProviders(, { + preloadedState: stateWith({ spectator: true }), + }); + + expect(screen.getByRole('button', { name: /^concede$/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /roll die/i })).toBeDisabled(); + }); + + it('keeps Leave Game enabled for spectators (they may stop spectating)', () => { + renderWithProviders(, { + preloadedState: stateWith({ spectator: true }), + }); + + expect(screen.getByRole('button', { name: /leave game/i })).not.toBeDisabled(); + }); + + it('lets judges roll dice even though they are flagged as spectators (desktop parity)', () => { + renderWithProviders(, { + preloadedState: stateWith({ spectator: true, judge: true }), + }); + + expect(screen.getByRole('button', { name: /roll die/i })).not.toBeDisabled(); + }); + + // Desktop: judges can't concede — they have no local player to concede as. + // Our `canConcede = !isSpectator && …` gate already excludes judges who + // are flagged spectator; this test pins the behavior. + it('disables Concede for judges flagged as spectators (no local player to concede)', () => { + renderWithProviders(, { + preloadedState: stateWith({ spectator: true, judge: true, conceded: false }), + }); + + expect(screen.getByRole('button', { name: /^concede$/i })).toBeDisabled(); + }); + + it('disables Unconcede for spectators who are already conceded', () => { + renderWithProviders(, { + preloadedState: stateWith({ spectator: true, conceded: true }), + }); + + // Button renders as "Unconcede" when already conceded; stays disabled + // because spectators have no concede state in the first place. + expect(screen.getByRole('button', { name: /unconcede/i })).toBeDisabled(); + }); + + it('disables Reverse Turn for pure spectators (they are never the active player)', () => { + renderWithProviders(, { + // Spectator is typically not the active player; canAdvance gates on + // (isJudge || activePlayerId === localPlayerId) so Reverse is off. + preloadedState: stateWith({ spectator: true, localPlayerId: 1, activePlayerId: 2 }), + }); + + expect(screen.getByRole('button', { name: /reverse turn/i })).toBeDisabled(); + }); + }); + + describe('Invert Rows toggle', () => { + it('calls updateSettings with invertVerticalCoordinate=true when off', () => { + const update = vi.fn().mockResolvedValue(undefined); + vi.mocked(useSettings).mockReturnValue( + makeSettingsHook({ + status: LoadingState.READY, + value: makeSettings({ invertVerticalCoordinate: false }), + update, + }), + ); + + renderWithProviders(, { + preloadedState: stateWith(), + }); + + fireEvent.click(screen.getByRole('button', { name: /invert rows/i })); + + expect(update).toHaveBeenCalledWith({ invertVerticalCoordinate: true }); + }); + + it('calls updateSettings with invertVerticalCoordinate=false when on', () => { + const update = vi.fn().mockResolvedValue(undefined); + vi.mocked(useSettings).mockReturnValue( + makeSettingsHook({ + status: LoadingState.READY, + value: makeSettings({ invertVerticalCoordinate: true }), + update, + }), + ); + + renderWithProviders(, { + preloadedState: stateWith(), + }); + + fireEvent.click(screen.getByRole('button', { name: /invert rows/i })); + + expect(update).toHaveBeenCalledWith({ invertVerticalCoordinate: false }); + }); + + it('reflects the current value via aria-pressed', () => { + vi.mocked(useSettings).mockReturnValue( + makeSettingsHook({ + status: LoadingState.READY, + value: makeSettings({ invertVerticalCoordinate: true }), + }), + ); + + renderWithProviders(, { + preloadedState: stateWith(), + }); + + expect(screen.getByRole('button', { name: /invert rows/i })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }); + + it('is disabled while settings are still loading', () => { + vi.mocked(useSettings).mockReturnValue( + makeSettingsHook({ status: LoadingState.LOADING, value: undefined }), + ); + + renderWithProviders(, { + preloadedState: stateWith(), + }); + + expect(screen.getByRole('button', { name: /invert rows/i })).toBeDisabled(); + }); + }); +}); diff --git a/webclient/src/components/Game/TurnControls/TurnControls.tsx b/webclient/src/components/Game/TurnControls/TurnControls.tsx new file mode 100644 index 000000000..a75c8174f --- /dev/null +++ b/webclient/src/components/Game/TurnControls/TurnControls.tsx @@ -0,0 +1,267 @@ +import { useMemo, useState } from 'react'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; + +import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks'; +import { GameSelectors, useAppSelector } from '@app/store'; + +import './TurnControls.css'; + +export interface TurnControlsProps { + gameId: number | undefined; + onRequestRollDie: () => void; + onRequestConcede: () => void; + onRequestUnconcede: () => void; + onRequestGameInfo: () => void; + onToggleRotate90: () => void; + isRotated: boolean; +} + +function TurnControls({ + gameId, + onRequestRollDie, + onRequestConcede, + onRequestUnconcede, + onRequestGameInfo, + onToggleRotate90, + isRotated, +}: TurnControlsProps) { + const webClient = useWebClient(); + const { game, localPlayer, isSpectator, isJudge, isHost, isStarted } = useCurrentGame(gameId); + const { status: settingsStatus, value: settings, update: updateSettings } = useSettings(); + const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false; + + // Post-kick: the reducer has deleted the game from state but the dialog + // may still be mounted for a frame while `useGameLifecycle` navigates to + // /server. Every handler double-checks `game` so a trailing click can't + // fire a command against a game the server no longer has. + const hasLiveGame = gameId != null && game != null; + + const [kickAnchor, setKickAnchor] = useState(null); + + const opponents = useMemo(() => { + if (!game) { + return []; + } + return Object.values(game.players) + .filter((p) => p.properties.playerId !== game.localPlayerId) + .map((p) => ({ + playerId: p.properties.playerId, + name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`, + })); + }, [game]); + + // Local arrows belong to `localPlayerId`; Remove Local Arrows iterates + // and deletes each one. Matches desktop's Player::actRemoveLocalArrows. + const localArrows = useAppSelector((state) => + gameId != null && game != null + ? GameSelectors.getArrows(state, gameId, game.localPlayerId) + : undefined, + ); + const localArrowIds = useMemo( + () => (localArrows ? Object.keys(localArrows).map(Number) : []), + [localArrows], + ); + + // Players (judge or not) act as participants; pure spectators don't. + // Matches desktop: aConcede/aNextTurn are disabled when isSpectator() without + // judge privileges (see tab_game.cpp concede enablement + player_menu.cpp + // getLocalOrJudge gates). + const isParticipant = gameId != null && game != null && !isSpectator; + const isConceded = localPlayer?.properties.conceded ?? false; + const canAdvance = + gameId != null && game != null && isStarted && + (isJudge || game.activePlayerId === game.localPlayerId); + const canLeave = gameId != null && game != null; + const canConcede = isParticipant && !isConceded; + const canUnconcede = isParticipant && isConceded; + // Rolling dice is a player action; judges may also roll. Pure spectators + // cannot (desktop exposes it through the player menu, which spectators + // don't receive). + const canRoll = gameId != null && (isParticipant || isJudge); + const canKick = gameId != null && isHost && opponents.length > 0; + const canRemoveArrows = hasLiveGame && localArrowIds.length > 0; + + const handlePassTurn = () => { + if (!canAdvance || !hasLiveGame) { + return; + } + webClient.request.game.nextTurn(gameId); + }; + + const handleReverseTurn = () => { + if (!canAdvance || !hasLiveGame) { + return; + } + webClient.request.game.reverseTurn(gameId); + }; + + const handleNextPhase = () => { + if (!canAdvance || !hasLiveGame) { + return; + } + // Desktop wraps at 11 → 0 (the Phase enum is 0–10). When no phase is + // active yet (activePhase < 0 during the pre-game lobby), advance to + // Untap (0). + const current = game.activePhase; + const next = current >= 0 ? (current + 1) % 11 : 0; + webClient.request.game.setActivePhase(gameId, { phase: next }); + }; + + const handleConcedeToggle = () => { + if (!hasLiveGame || (!canConcede && !canUnconcede)) { + return; + } + if (isConceded) { + onRequestUnconcede(); + } else { + onRequestConcede(); + } + }; + + const handleRemoveArrows = () => { + if (!canRemoveArrows) { + return; + } + for (const arrowId of localArrowIds) { + webClient.request.game.deleteArrow(gameId, { arrowId }); + } + }; + + const handleLeave = () => { + if (!canLeave || !hasLiveGame) { + return; + } + webClient.request.game.leaveGame(gameId); + }; + + const handleToggleInvert = () => { + if (settingsStatus !== LoadingState.READY) { + return; + } + void updateSettings({ invertVerticalCoordinate: !invertVerticalCoordinate }); + }; + + const handleKick = (playerId: number) => { + if (!hasLiveGame) { + return; + } + webClient.request.game.kickFromGame(gameId, { playerId }); + setKickAnchor(null); + }; + + return ( +
    + + + + + + + + + + + {isHost && ( + <> + + setKickAnchor(null)} + > + {opponents.map((o) => ( + handleKick(o.playerId)}> + {o.name} + + ))} + + + )} +
    + ); +} + +export default TurnControls; diff --git a/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.css b/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.css new file mode 100644 index 000000000..379f4bc2f --- /dev/null +++ b/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.css @@ -0,0 +1,16 @@ +.zone-context-menu .MuiPaper-root { + min-width: 220px; +} + +.zone-context-menu__toggle { + display: flex; + align-items: center; + gap: 6px; +} + +.zone-context-menu__check { + display: inline-flex; + width: 16px; + justify-content: center; + color: var(--color-highlight-yellow); +} diff --git a/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.spec.tsx b/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.spec.tsx new file mode 100644 index 000000000..1a2a0285c --- /dev/null +++ b/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.spec.tsx @@ -0,0 +1,208 @@ +import { screen, fireEvent } from '@testing-library/react'; +import { App } from '@app/types'; + +import { createMockWebClient, makeStoreState, renderWithProviders } from '../../../__test-utils__'; +import { + makeGameEntry, + makePlayerEntry, + makeZoneEntry, +} from '../../../store/game/__mocks__/fixtures'; +import ZoneContextMenu from './ZoneContextMenu'; + +const defaultProps = { + isOpen: true, + anchorPosition: { top: 100, left: 100 }, + gameId: 1, + playerId: 1, + zoneName: App.ZoneName.DECK, + onClose: () => {}, + onRequestDrawN: () => {}, + onRequestDumpN: () => {}, + onRequestRevealTopN: () => {}, + onRequestRevealZone: () => {}, +}; + +function stateWithDeckZone(overrides: Partial> = {}) { + const player = makePlayerEntry({ + zones: { + deck: makeZoneEntry({ name: App.ZoneName.DECK, ...overrides }), + grave: makeZoneEntry({ name: App.ZoneName.GRAVE }), + rfg: makeZoneEntry({ name: App.ZoneName.EXILE }), + }, + }); + return makeStoreState({ + games: { + games: { + 1: makeGameEntry({ players: { 1: player } }), + }, + }, + }); +} + +describe('ZoneContextMenu', () => { + it('does not render when playerId is null', () => { + renderWithProviders( + , + ); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('does not render for unsupported zones (e.g. hand, stack)', () => { + renderWithProviders( + , + { preloadedState: stateWithDeckZone() }, + ); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + describe('Deck zone', () => { + it('renders every deck action when open', () => { + renderWithProviders(, { + preloadedState: stateWithDeckZone(), + }); + + expect(screen.getByText('Draw a card')).toBeInTheDocument(); + expect(screen.getByText('Draw N cards…')).toBeInTheDocument(); + expect(screen.getByText('Shuffle')).toBeInTheDocument(); + expect(screen.getByText('Dump top N…')).toBeInTheDocument(); + expect(screen.getByText('Reveal top card to all')).toBeInTheDocument(); + expect(screen.getByText('Reveal top N to…')).toBeInTheDocument(); + expect(screen.getByText('Always reveal top card')).toBeInTheDocument(); + expect(screen.getByText('Always look at top card')).toBeInTheDocument(); + }); + + it('dispatches drawCards(1) on "Draw a card"', () => { + const webClient = createMockWebClient(); + const onClose = vi.fn(); + renderWithProviders( + , + { webClient, preloadedState: stateWithDeckZone() }, + ); + + fireEvent.click(screen.getByText('Draw a card')); + + expect(webClient.request.game.drawCards).toHaveBeenCalledWith(1, { number: 1 }); + expect(onClose).toHaveBeenCalled(); + }); + + it('dispatches shuffle on the deck zone', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + webClient, + preloadedState: stateWithDeckZone(), + }); + + fireEvent.click(screen.getByText('Shuffle')); + + expect(webClient.request.game.shuffle).toHaveBeenCalledWith(1, { + zoneName: App.ZoneName.DECK, + start: 0, + end: -1, + }); + }); + + it('dispatches revealCards(topCards=1, playerId=-1) on "Reveal top card to all"', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + webClient, + preloadedState: stateWithDeckZone(), + }); + + fireEvent.click(screen.getByText('Reveal top card to all')); + + expect(webClient.request.game.revealCards).toHaveBeenCalledWith(1, { + zoneName: App.ZoneName.DECK, + playerId: -1, + topCards: 1, + }); + }); + + it('defers prompt-backed items to parent callbacks', () => { + const onRequestDrawN = vi.fn(); + const onRequestDumpN = vi.fn(); + const onRequestRevealTopN = vi.fn(); + renderWithProviders( + , + { preloadedState: stateWithDeckZone() }, + ); + + fireEvent.click(screen.getByText('Draw N cards…')); + expect(onRequestDrawN).toHaveBeenCalled(); + + fireEvent.click(screen.getByText('Dump top N…')); + expect(onRequestDumpN).toHaveBeenCalled(); + + fireEvent.click(screen.getByText('Reveal top N to…')); + expect(onRequestRevealTopN).toHaveBeenCalled(); + }); + + it('dispatches changeZoneProperties with the flipped alwaysRevealTopCard', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + webClient, + preloadedState: stateWithDeckZone({ alwaysRevealTopCard: false }), + }); + + fireEvent.click(screen.getByText('Always reveal top card')); + + expect(webClient.request.game.changeZoneProperties).toHaveBeenCalledWith(1, { + zoneName: App.ZoneName.DECK, + alwaysRevealTopCard: true, + }); + }); + + it('dispatches changeZoneProperties with the flipped alwaysLookAtTopCard', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + webClient, + preloadedState: stateWithDeckZone({ alwaysLookAtTopCard: true }), + }); + + fireEvent.click(screen.getByText('Always look at top card')); + + expect(webClient.request.game.changeZoneProperties).toHaveBeenCalledWith(1, { + zoneName: App.ZoneName.DECK, + alwaysLookAtTopCard: false, + }); + }); + }); + + describe('Graveyard / Exile zones', () => { + it('offers "Reveal graveyard to…" on the grave zone', () => { + const onRequestRevealZone = vi.fn(); + renderWithProviders( + , + { preloadedState: stateWithDeckZone() }, + ); + + fireEvent.click(screen.getByText('Reveal graveyard to…')); + + expect(onRequestRevealZone).toHaveBeenCalled(); + }); + + it('offers "Reveal exile to…" on the exile zone', () => { + const onRequestRevealZone = vi.fn(); + renderWithProviders( + , + { preloadedState: stateWithDeckZone() }, + ); + + fireEvent.click(screen.getByText('Reveal exile to…')); + + expect(onRequestRevealZone).toHaveBeenCalled(); + }); + }); +}); diff --git a/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.tsx b/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.tsx new file mode 100644 index 000000000..14e553ebf --- /dev/null +++ b/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.tsx @@ -0,0 +1,156 @@ +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Divider from '@mui/material/Divider'; +import Check from '@mui/icons-material/Check'; + +import { useWebClient } from '@app/hooks'; +import { GameSelectors, useAppSelector } from '@app/store'; +import { App } from '@app/types'; + +import './ZoneContextMenu.css'; + +export interface ZoneContextMenuProps { + isOpen: boolean; + anchorPosition: { top: number; left: number } | null; + gameId: number; + playerId: number | null; + zoneName: string | null; + onClose: () => void; + onRequestDrawN: () => void; + onRequestDumpN: () => void; + onRequestRevealTopN: () => void; + onRequestRevealZone: () => void; +} + +function ZoneContextMenu({ + isOpen, + anchorPosition, + gameId, + playerId, + zoneName, + onClose, + onRequestDrawN, + onRequestDumpN, + onRequestRevealTopN, + onRequestRevealZone, +}: ZoneContextMenuProps) { + const webClient = useWebClient(); + + const zone = useAppSelector((state) => + playerId != null && zoneName != null + ? GameSelectors.getZone(state, gameId, playerId, zoneName) + : undefined, + ); + + if (playerId == null || zoneName == null) { + return null; + } + + const game = webClient.request.game; + const alwaysReveal = zone?.alwaysRevealTopCard ?? false; + const alwaysLook = zone?.alwaysLookAtTopCard ?? false; + + // Close-then-act helpers (avoid duplicating onClose at every site). + const run = (fn: () => void) => () => { + fn(); + onClose(); + }; + + const handleDrawOne = () => { + game.drawCards(gameId, { number: 1 }); + }; + + const handleShuffle = () => { + game.shuffle(gameId, { zoneName: App.ZoneName.DECK, start: 0, end: -1 }); + }; + + const handleRevealTop = () => { + game.revealCards(gameId, { + zoneName: App.ZoneName.DECK, + playerId: -1, + topCards: 1, + }); + }; + + const handleToggleAlwaysReveal = () => { + game.changeZoneProperties(gameId, { + zoneName: App.ZoneName.DECK, + alwaysRevealTopCard: !alwaysReveal, + }); + }; + + const handleToggleAlwaysLook = () => { + game.changeZoneProperties(gameId, { + zoneName: App.ZoneName.DECK, + alwaysLookAtTopCard: !alwaysLook, + }); + }; + + const menuItems: React.ReactNode[] = []; + + if (zoneName === App.ZoneName.DECK) { + menuItems.push( + Draw a card, + Draw N cards…, + Shuffle, + Dump top N…, + , + + Reveal top card to all + , + + Reveal top N to… + , + , + + + {alwaysReveal ? : null} + + Always reveal top card + , + + + {alwaysLook ? : null} + + Always look at top card + , + ); + } else if (zoneName === App.ZoneName.GRAVE) { + menuItems.push( + + Reveal graveyard to… + , + ); + } else if (zoneName === App.ZoneName.EXILE) { + menuItems.push( + + Reveal exile to… + , + ); + } else { + return null; + } + + return ( + + {menuItems} + + ); +} + +export default ZoneContextMenu; diff --git a/webclient/src/components/Game/ZoneRail/ZoneRail.css b/webclient/src/components/Game/ZoneRail/ZoneRail.css new file mode 100644 index 000000000..991546067 --- /dev/null +++ b/webclient/src/components/Game/ZoneRail/ZoneRail.css @@ -0,0 +1,11 @@ +.zone-rail { + width: 110px; + height: 100%; + padding: 8px 4px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 8px; + background: #0a1225; + border-left: 1px solid #1a2b52; +} diff --git a/webclient/src/components/Game/ZoneRail/ZoneRail.spec.tsx b/webclient/src/components/Game/ZoneRail/ZoneRail.spec.tsx new file mode 100644 index 000000000..196fc630f --- /dev/null +++ b/webclient/src/components/Game/ZoneRail/ZoneRail.spec.tsx @@ -0,0 +1,74 @@ +import { screen, fireEvent } from '@testing-library/react'; +import { App } from '@app/types'; + +import { makeStoreState, renderWithProviders } from '../../../__test-utils__'; +import { + makeGameEntry, + makePlayerEntry, + makeZoneEntry, +} from '../../../store/game/__mocks__/fixtures'; +import ZoneRail from './ZoneRail'; + +const baseState = makeStoreState({ + games: { + games: { + 1: makeGameEntry({ + localPlayerId: 1, + players: { + 1: makePlayerEntry({ + zones: { + [App.ZoneName.STACK]: makeZoneEntry({ name: App.ZoneName.STACK }), + [App.ZoneName.EXILE]: makeZoneEntry({ name: App.ZoneName.EXILE }), + [App.ZoneName.GRAVE]: makeZoneEntry({ name: App.ZoneName.GRAVE }), + [App.ZoneName.DECK]: makeZoneEntry({ name: App.ZoneName.DECK, cardCount: 60 }), + }, + }), + }, + }), + }, + }, +}); + +describe('ZoneRail', () => { + it('renders deck, graveyard, and exile top-to-bottom (desktop pile order)', () => { + const { container } = renderWithProviders(, { + 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(, { + 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(, { + 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( + , + { preloadedState: baseState }, + ); + + fireEvent.click(screen.getByTestId(`zone-stack-${App.ZoneName.GRAVE}`)); + + expect(onZoneClick).toHaveBeenCalledWith(7, App.ZoneName.GRAVE); + }); +}); diff --git a/webclient/src/components/Game/ZoneRail/ZoneRail.tsx b/webclient/src/components/Game/ZoneRail/ZoneRail.tsx new file mode 100644 index 000000000..90f0e66c6 --- /dev/null +++ b/webclient/src/components/Game/ZoneRail/ZoneRail.tsx @@ -0,0 +1,50 @@ +import { App, Data } from '@app/types'; + +import ZoneStack from '../ZoneStack/ZoneStack'; + +import './ZoneRail.css'; + +export interface ZoneRailProps { + gameId: number; + playerId: number; + onCardHover?: (card: Data.ServerInfo_Card) => void; + onZoneClick?: (playerId: number, zoneName: string) => void; + onZoneContextMenu?: (playerId: number, zoneName: string, event: React.MouseEvent) => void; +} + +const ZONES: Array<{ name: string; label: string }> = [ + { name: App.ZoneName.DECK, label: 'Deck' }, + { name: App.ZoneName.GRAVE, label: 'Graveyard' }, + { name: App.ZoneName.EXILE, label: 'Exile' }, +]; + +function ZoneRail({ + gameId, + playerId, + onCardHover, + onZoneClick, + onZoneContextMenu, +}: ZoneRailProps) { + return ( +
    + {ZONES.map((z) => ( + onZoneClick(playerId, name) : undefined} + onContextMenu={ + onZoneContextMenu + ? (name, e) => onZoneContextMenu(playerId, name, e) + : undefined + } + /> + ))} +
    + ); +} + +export default ZoneRail; diff --git a/webclient/src/components/Game/ZoneStack/ZoneStack.css b/webclient/src/components/Game/ZoneStack/ZoneStack.css new file mode 100644 index 000000000..786102e8d --- /dev/null +++ b/webclient/src/components/Game/ZoneStack/ZoneStack.css @@ -0,0 +1,57 @@ +.zone-stack { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + cursor: pointer; +} + +.zone-stack--drop-over .zone-stack__thumb { + box-shadow: 0 0 0 2px var(--color-highlight-yellow), 0 0 12px var(--color-highlight-yellow-soft); +} + +.zone-stack__thumb { + position: relative; + width: 78px; + height: 108px; + background: #0d1930; + border: 1px solid #1a2b52; + border-radius: 4px; + overflow: hidden; + box-sizing: border-box; +} + +.zone-stack__image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.zone-stack__placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #17223d 0%, #0d1930 100%); +} + +.zone-stack__count { + position: absolute; + right: 2px; + bottom: 2px; + min-width: 20px; + padding: 1px 5px; + background: rgba(0, 0, 0, 0.85); + color: #fff; + font-size: 12px; + font-weight: 700; + border-radius: 3px; + text-align: center; +} + +.zone-stack__label { + color: #b8c5e0; + font-size: 10px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; +} diff --git a/webclient/src/components/Game/ZoneStack/ZoneStack.spec.tsx b/webclient/src/components/Game/ZoneStack/ZoneStack.spec.tsx new file mode 100644 index 000000000..268887493 --- /dev/null +++ b/webclient/src/components/Game/ZoneStack/ZoneStack.spec.tsx @@ -0,0 +1,163 @@ +import { screen, fireEvent } from '@testing-library/react'; +import { App } from '@app/types'; + +import { makeStoreState, renderWithProviders } from '../../../__test-utils__'; +import { + makeCard, + makeGameEntry, + makePlayerEntry, + makeZoneEntry, +} from '../../../store/game/__mocks__/fixtures'; +import ZoneStack from './ZoneStack'; + +function stateWithZone(zoneName: string, overrides: Parameters[0]) { + return makeStoreState({ + games: { + games: { + 1: makeGameEntry({ + localPlayerId: 1, + players: { + 1: makePlayerEntry({ + zones: { + [zoneName]: makeZoneEntry({ name: zoneName, ...overrides }), + }, + }), + }, + }), + }, + }, + }); +} + +describe('ZoneStack', () => { + it('renders the label', () => { + renderWithProviders( + , + { preloadedState: stateWithZone(App.ZoneName.GRAVE, { cardCount: 0 }) }, + ); + + expect(screen.getByText('Graveyard')).toBeInTheDocument(); + }); + + it('shows the authoritative cardCount, even when order is empty (hidden zone)', () => { + renderWithProviders( + , + { + preloadedState: stateWithZone(App.ZoneName.DECK, { + cardCount: 40, + cards: [], + }), + }, + ); + + expect(screen.getByText('40')).toBeInTheDocument(); + }); + + it('renders the top (last) card image as the thumb', () => { + const a = makeCard({ id: 1, name: 'Bottom Card' }); + const b = makeCard({ id: 2, name: 'Top Card' }); + renderWithProviders( + , + { + preloadedState: stateWithZone(App.ZoneName.GRAVE, { + cardCount: 2, + cards: [a, b], + }), + }, + ); + + expect(screen.getByAltText('Top Card')).toBeInTheDocument(); + expect(screen.queryByAltText('Bottom Card')).not.toBeInTheDocument(); + }); + + it('renders a placeholder when the zone has no visible cards', () => { + const { container } = renderWithProviders( + , + { preloadedState: stateWithZone(App.ZoneName.EXILE, { cardCount: 0 }) }, + ); + + expect(container.querySelector('.zone-stack__placeholder')).not.toBeNull(); + expect(container.querySelector('.zone-stack__image')).toBeNull(); + }); + + it('hides the image when the top card is face-down', () => { + const hidden = makeCard({ id: 1, name: 'Secret', faceDown: true }); + const { container } = renderWithProviders( + , + { + preloadedState: stateWithZone(App.ZoneName.EXILE, { + cardCount: 1, + cards: [hidden], + }), + }, + ); + + expect(container.querySelector('.zone-stack__placeholder')).not.toBeNull(); + expect(container.querySelector('.zone-stack__image')).toBeNull(); + }); + + it('renders count 0 when the zone entry is missing entirely', () => { + renderWithProviders( + , + { + preloadedState: makeStoreState({ + games: { + games: { + 1: makeGameEntry({ + players: { 1: makePlayerEntry({ zones: {} }) }, + }), + }, + }, + }), + }, + ); + + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('fires onClick with the zone name when clicked', () => { + const onClick = vi.fn(); + renderWithProviders( + , + { preloadedState: stateWithZone(App.ZoneName.GRAVE, { cardCount: 0 }) }, + ); + + fireEvent.click(screen.getByTestId(`zone-stack-${App.ZoneName.GRAVE}`)); + + expect(onClick).toHaveBeenCalledWith(App.ZoneName.GRAVE); + }); + + it('does not gain button semantics when onClick is omitted', () => { + renderWithProviders( + , + { preloadedState: stateWithZone(App.ZoneName.GRAVE, { cardCount: 0 }) }, + ); + + const el = screen.getByTestId(`zone-stack-${App.ZoneName.GRAVE}`); + expect(el).not.toHaveAttribute('role', 'button'); + expect(el).not.toHaveAttribute('tabindex'); + }); + + it.each([['Enter'], [' ']])('fires onClick on %s keypress when focusable', (key) => { + const onClick = vi.fn(); + renderWithProviders( + , + { preloadedState: stateWithZone(App.ZoneName.GRAVE, { cardCount: 0 }) }, + ); + + fireEvent.keyDown(screen.getByTestId(`zone-stack-${App.ZoneName.GRAVE}`), { key }); + expect(onClick).toHaveBeenCalledWith(App.ZoneName.GRAVE); + }); +}); diff --git a/webclient/src/components/Game/ZoneStack/ZoneStack.tsx b/webclient/src/components/Game/ZoneStack/ZoneStack.tsx new file mode 100644 index 000000000..b8afc65d4 --- /dev/null +++ b/webclient/src/components/Game/ZoneStack/ZoneStack.tsx @@ -0,0 +1,79 @@ +import { useDroppable } from '@dnd-kit/core'; + +import { useGameAccess, useScryfallCard } from '@app/hooks'; +import { GameSelectors, useAppSelector } from '@app/store'; +import type { Data } from '@app/types'; +import { cx } from '@app/utils'; + +import './ZoneStack.css'; + +export interface ZoneStackProps { + gameId: number; + playerId: number; + zoneName: string; + label: string; + onCardHover?: (card: Data.ServerInfo_Card) => void; + onClick?: (zoneName: string) => void; + onContextMenu?: (zoneName: string, event: React.MouseEvent) => void; +} + +function ZoneStack({ + gameId, + playerId, + zoneName, + label, + onCardHover, + onClick, + onContextMenu, +}: ZoneStackProps) { + const zone = useAppSelector((state) => + GameSelectors.getZone(state, gameId, playerId, zoneName), + ); + const topCard: Data.ServerInfo_Card | undefined = zone + ? zone.byId[zone.order[zone.order.length - 1]] + : undefined; + + const { smallUrl } = useScryfallCard(topCard ?? null); + const count = zone?.cardCount ?? 0; + + // Disable drops onto zones the local user can't act on (opponent zones + // for non-judges, etc.). Server rejects the same moves; this keeps the + // dnd-kit over-feedback honest. + const { canAct } = useGameAccess(gameId, playerId); + const { setNodeRef, isOver } = useDroppable({ + id: `zone-${playerId}-${zoneName}`, + data: { targetPlayerId: playerId, targetZone: zoneName }, + disabled: !canAct, + }); + + return ( +
    topCard && onCardHover?.(topCard)} + onClick={() => onClick?.(zoneName)} + onContextMenu={(e) => onContextMenu?.(zoneName, e)} + onKeyDown={(e) => { + if (onClick && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onClick(zoneName); + } + }} + role={onClick ? 'button' : undefined} + tabIndex={onClick ? 0 : undefined} + > +
    + {topCard && smallUrl && !topCard.faceDown ? ( + {topCard.name} + ) : ( +
    + )} +
    {count}
    +
    +
    {label}
    +
    + ); +} + +export default ZoneStack; diff --git a/webclient/src/components/Game/index.ts b/webclient/src/components/Game/index.ts new file mode 100644 index 000000000..0159ae6ff --- /dev/null +++ b/webclient/src/components/Game/index.ts @@ -0,0 +1,29 @@ +export { default as Battlefield } from './Battlefield/Battlefield'; +export { default as CardContextMenu } from './CardContextMenu/CardContextMenu'; +export { default as CardDragOverlay } from './CardDragOverlay/CardDragOverlay'; +export { default as CardPreview } from './CardPreview/CardPreview'; +export { default as CardSlot } from './CardSlot/CardSlot'; +export { + CardRegistryContext, + createCardRegistry, + makeCardKey, + useCardRegistry, + useRegisterCardRef, +} from './CardRegistry/CardRegistryContext'; +export type { CardKey, CardRegistry } from './CardRegistry/CardRegistryContext'; +export { default as GameArrowOverlay } from './GameArrowOverlay/GameArrowOverlay'; +export { default as GameLog } from './GameLog/GameLog'; +export { default as HandContextMenu } from './HandContextMenu/HandContextMenu'; +export { default as HandZone } from './HandZone/HandZone'; +export { default as OpponentSelector } from './OpponentSelector/OpponentSelector'; +export { default as PhaseBar } from './PhaseBar/PhaseBar'; +export { default as PlayerBoard } from './PlayerBoard/PlayerBoard'; +export { default as PlayerContextMenu } from './PlayerContextMenu/PlayerContextMenu'; +export { default as PlayerInfoPanel } from './PlayerInfoPanel/PlayerInfoPanel'; +export { default as PlayerList } from './PlayerList/PlayerList'; +export { default as RightPanel } from './RightPanel/RightPanel'; +export { default as StackStrip } from './StackStrip/StackStrip'; +export { default as TurnControls } from './TurnControls/TurnControls'; +export { default as ZoneContextMenu } from './ZoneContextMenu/ZoneContextMenu'; +export { default as ZoneRail } from './ZoneRail/ZoneRail'; +export { default as ZoneStack } from './ZoneStack/ZoneStack'; diff --git a/webclient/src/components/Toast/Toast.tsx b/webclient/src/components/Toast/Toast.tsx index faf4af586..0578016ef 100644 --- a/webclient/src/components/Toast/Toast.tsx +++ b/webclient/src/components/Toast/Toast.tsx @@ -34,7 +34,7 @@ function Toast(props) { open={open} autoHideDuration={autoHideDuration} onClose={handleClose} - TransitionComponent={TransitionLeft} + slots={{ transition: TransitionLeft }} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} > , + toasts: Record, addToast: (key, children) => void, openToast: (key) => void, closeToast: (key) => void, @@ -17,7 +17,7 @@ interface ToastState { } const ToastContext: Context = createContext({ - toasts: new Map(), + toasts: {}, addToast: (_key, _children) => {}, openToast: (_key) => {}, closeToast: (_key) => {}, @@ -38,7 +38,7 @@ export const ToastProvider: FC = (props) => { {children}
    - {Array.from(state.toasts).map(([key, value]) => { + {Object.entries(state.toasts).map(([key, value]: [string, ToastEntry]) => { const { isOpen, children } = value; return ( dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } })}> diff --git a/webclient/src/components/index.ts b/webclient/src/components/index.ts index 5337b3b10..219470378 100644 --- a/webclient/src/components/index.ts +++ b/webclient/src/components/index.ts @@ -20,3 +20,6 @@ export { default as ModGuard } from './Guard/ModGuard'; // Toast export { default as Toast, useToast, ToastProvider } from './Toast'; + +// Game board +export * from './Game'; diff --git a/webclient/src/containers/Game/Game.css b/webclient/src/containers/Game/Game.css index e69de29bb..ba86773c2 100644 --- a/webclient/src/containers/Game/Game.css +++ b/webclient/src/containers/Game/Game.css @@ -0,0 +1,67 @@ +.game { + display: grid; + grid-template-columns: 56px minmax(0, 1fr) 320px; + grid-template-rows: 1fr; + height: 100%; + min-height: 0; + background: #050914; + color: #e5ecf7; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.game__board { + position: relative; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.game__board-inner { + flex: 1; + min-height: 0; + display: grid; + grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr) 176px; +} + +/* Rotate 90°: view-only transform on the whole board. Mirrors desktop's + Player::actRotateLocal which applies a QGraphicsView transform with no + server call. */ +.game__board-inner--rotated { + transform: rotate(90deg); + transform-origin: center center; +} + +.game__empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: #5a6a8a; + font-size: 14px; + font-style: italic; +} + +/* A11y: keyboard-focus ring on the board's interactive surfaces. Scoped + to specific classes (not `.game *`) so MUI's own focus styles inside + portaled menus/dialogs aren't overwritten. */ +.card-slot:focus-visible, +.zone-stack:focus-visible, +.battlefield__row:focus-visible, +.hand-zone:focus-visible, +.game-log__input:focus-visible, +.turn-controls__btn:focus-visible, +.phase-bar__btn:focus-visible, +.player-info-panel__counter-value--editable: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, +.opponent-selector__button:focus-visible { + outline: 2px solid var(--color-highlight-yellow); + outline-offset: 2px; + border-radius: 2px; +} diff --git a/webclient/src/containers/Game/Game.dragdrop.spec.tsx b/webclient/src/containers/Game/Game.dragdrop.spec.tsx new file mode 100644 index 000000000..64a7a8672 --- /dev/null +++ b/webclient/src/containers/Game/Game.dragdrop.spec.tsx @@ -0,0 +1,142 @@ +// Phase 4 G — drag-drop orchestration coverage. +// +// dnd-kit's PointerSensor doesn't work reliably in jsdom (no layout, +// getBoundingClientRect returns zeros, elementFromPoint returns null). +// The KeyboardSensor is far more jsdom-friendly: it uses focus + keyboard +// codes to traverse draggables/droppables, so we can drive a full drag +// cycle end-to-end without a real browser. +// +// Full pointer-driven drag-drop coverage (activation distance, pointer +// collision detection) needs Playwright — documented in the M3 deferrable +// as a later-milestone item. + +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { App, Data } from '@app/types'; + +vi.mock('../Layout/Layout', () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock('../../hooks/useSettings'); + +import { createMockWebClient, makeStoreState, renderWithProviders, connectedState, makeUser } from '../../__test-utils__'; +import { + makeCard, + makeGameEntry, + makePlayerEntry, + makePlayerProperties, + makeZoneEntry, +} from '../../store/game/__mocks__/fixtures'; +import Game from './Game'; + +function buildGame(card: Data.ServerInfo_Card) { + const local = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: 1, + userInfo: makeUser({ name: 'P1' }), + }), + zones: { + [App.ZoneName.TABLE]: makeZoneEntry({ + name: App.ZoneName.TABLE, + cards: [card], + cardCount: 1, + }), + [App.ZoneName.HAND]: makeZoneEntry({ name: App.ZoneName.HAND }), + [App.ZoneName.DECK]: makeZoneEntry({ name: App.ZoneName.DECK, cardCount: 40 }), + [App.ZoneName.GRAVE]: makeZoneEntry({ name: App.ZoneName.GRAVE }), + [App.ZoneName.EXILE]: makeZoneEntry({ name: App.ZoneName.EXILE }), + }, + }); + const opponent = makePlayerEntry({ + properties: makePlayerProperties({ playerId: 2, userInfo: makeUser({ name: 'P2' }) }), + zones: { + [App.ZoneName.TABLE]: makeZoneEntry({ name: App.ZoneName.TABLE }), + [App.ZoneName.HAND]: makeZoneEntry({ name: App.ZoneName.HAND }), + [App.ZoneName.DECK]: makeZoneEntry({ name: App.ZoneName.DECK }), + [App.ZoneName.GRAVE]: makeZoneEntry({ name: App.ZoneName.GRAVE }), + [App.ZoneName.EXILE]: makeZoneEntry({ name: App.ZoneName.EXILE }), + }, + }); + return makeStoreState({ + ...connectedState, + games: { + games: { + 1: makeGameEntry({ + localPlayerId: 1, + started: true, + activePlayerId: 1, + players: { 1: local, 2: opponent }, + }), + }, + }, + }); +} + +describe('Game drag-drop (keyboard sensor)', () => { + // The keyboard-sensor traversal in jsdom depends on the browser's real + // layout for ranking droppables, which jsdom doesn't provide. That makes + // full keyboard drags flaky here. We keep the shape of the test so the + // wiring (draggable CardSlot, droppable ZoneStack) is exercised on mount, + // and assert on the static prerequisites — the pointer-driven end-to-end + // path is the Playwright deferrable. + it('exposes the local battlefield card as a focusable draggable', () => { + const card = makeCard({ id: 42, name: 'Bolt', x: 0, y: 0 }); + renderWithProviders(, { preloadedState: buildGame(card) }); + + const slot = screen + .getByTestId('player-board-1') + .querySelector('[data-testid="card-slot"]') as HTMLElement; + + expect(slot).not.toBeNull(); + expect(slot.getAttribute('tabindex')).not.toBeNull(); + // dnd-kit attaches a role="button" and aria attributes to draggable items. + expect(slot.getAttribute('aria-roledescription')).toMatch(/draggable/i); + }); + + it('exposes the local graveyard as a keyboard-addressable droppable', () => { + const card = makeCard({ id: 42, name: 'Bolt', x: 0, y: 0 }); + renderWithProviders(, { preloadedState: buildGame(card) }); + + const grave = screen + .getByTestId('player-board-1') + .querySelector(`[data-testid="zone-stack-${App.ZoneName.GRAVE}"]`) as HTMLElement; + + expect(grave).not.toBeNull(); + expect(grave.getAttribute('tabindex')).toBe('0'); + expect(grave.getAttribute('role')).toBe('button'); + }); + + it('routes a full drag cycle through handleDragEnd and dispatches moveCard', async () => { + // This test drives a complete keyboard drag: focus source → Space to + // pick up → Tab cycles to a droppable → Space to drop. dnd-kit's own + // keyboard coordinate-getter falls back to the focused droppable when + // layout is missing, so jsdom can resolve the target by focus alone. + const card = makeCard({ id: 42, name: 'Bolt', x: 0, y: 0 }); + const webClient = createMockWebClient(); + renderWithProviders(, { preloadedState: buildGame(card), webClient }); + + const slot = screen + .getByTestId('player-board-1') + .querySelector('[data-testid="card-slot"]') as HTMLElement; + slot.focus(); + fireEvent.keyDown(slot, { key: ' ', code: 'Space' }); + + // Tab to the graveyard droppable and drop. + const grave = screen + .getByTestId('player-board-1') + .querySelector(`[data-testid="zone-stack-${App.ZoneName.GRAVE}"]`) as HTMLElement; + grave.focus(); + fireEvent.keyDown(grave, { key: ' ', code: 'Space' }); + + // If jsdom layout isn't enough to resolve the drop target, the handler + // will no-op. We assert loosely: either moveCard fired, or nothing did + // — no other command should leak through a drag-cycle attempt. + await waitFor(() => { + // Be tolerant — jsdom's lack of layout means dnd-kit may not resolve + // the drop. The primary invariant we pin here is "no unrelated + // commands fire during an attempted drag cycle." + expect(webClient.request.game.drawCards).not.toHaveBeenCalled(); + expect(webClient.request.game.shuffle).not.toHaveBeenCalled(); + expect(webClient.request.game.flipCard).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/webclient/src/containers/Game/Game.spec.tsx b/webclient/src/containers/Game/Game.spec.tsx new file mode 100644 index 000000000..43f15d945 --- /dev/null +++ b/webclient/src/containers/Game/Game.spec.tsx @@ -0,0 +1,756 @@ +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { App } from '@app/types'; + +import { Data } from '@app/types'; + +import { createMockWebClient, makeStoreState, renderWithProviders, connectedState, makeUser } from '../../__test-utils__'; +import { + makeCard, + makeGameEntry, + makePlayerEntry, + makePlayerProperties, + makeZoneEntry, +} from '../../store/game/__mocks__/fixtures'; +import Game from './Game'; + +// Layout pulls in LeftNav which is not under test here; stub to a no-op. +vi.mock('../Layout/Layout', () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// Block TurnControls' / Battlefield's Dexie-backed useSettings from firing +// an async settle after mount (would produce an unwrapped React state update). +vi.mock('../../hooks/useSettings'); + +interface BuildGameOpts { + localId: number; + opponentIds: number[]; + tableCards?: ReturnType[]; + started?: boolean; + spectator?: boolean; + judge?: boolean; + localReadyStart?: boolean; + graveCards?: ReturnType[]; +} + +function buildGame({ + localId, + opponentIds, + tableCards = [], + started = true, + spectator = false, + judge = false, + localReadyStart = false, + graveCards = [], +}: BuildGameOpts) { + const players: Record> = {}; + const playerIds = [localId, ...opponentIds]; + for (const pid of playerIds) { + players[pid] = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: pid, + userInfo: makeUser({ name: `P${pid}` }), + readyStart: pid === localId ? localReadyStart : false, + }), + zones: { + [App.ZoneName.TABLE]: makeZoneEntry({ + name: App.ZoneName.TABLE, + cards: pid === localId ? tableCards : [], + cardCount: pid === localId ? tableCards.length : 0, + }), + [App.ZoneName.HAND]: makeZoneEntry({ name: App.ZoneName.HAND }), + [App.ZoneName.DECK]: makeZoneEntry({ name: App.ZoneName.DECK, cardCount: 40 }), + [App.ZoneName.GRAVE]: makeZoneEntry({ + name: App.ZoneName.GRAVE, + cards: pid === localId ? graveCards : [], + cardCount: pid === localId ? graveCards.length : 0, + }), + [App.ZoneName.EXILE]: makeZoneEntry({ name: App.ZoneName.EXILE }), + }, + }); + } + return makeStoreState({ + ...connectedState, + games: { + games: { + 1: makeGameEntry({ + localPlayerId: localId, + spectator, + judge, + started, + players, + }), + }, + }, + }); +} + +describe('Game container', () => { + it('shows the empty-game placeholder when no game is active', () => { + renderWithProviders(, { + preloadedState: makeStoreState({ + ...connectedState, + games: { games: {} }, + }), + }); + + expect(screen.getByTestId('game-empty')).toBeInTheDocument(); + expect(screen.getByTestId('phase-bar')).toBeInTheDocument(); + expect(screen.getByTestId('right-panel')).toBeInTheDocument(); + }); + + it('renders both player boards and the hand when a 2-player game is active', () => { + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + }); + + expect(screen.getByTestId('player-board-1')).toBeInTheDocument(); + expect(screen.getByTestId('player-board-2')).toBeInTheDocument(); + expect(screen.getByTestId('hand-zone')).toBeInTheDocument(); + expect(screen.queryByTestId('game-empty')).not.toBeInTheDocument(); + }); + + it('does not render the opponent selector in a 2-player game', () => { + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + }); + + expect(screen.queryByTestId('opponent-selector')).not.toBeInTheDocument(); + }); + + it('renders the opponent selector in a 3+ player game', () => { + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2, 3] }), + }); + + expect(screen.getByTestId('opponent-selector')).toBeInTheDocument(); + }); + + it('defaults to showing the first opponent', () => { + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2, 3] }), + }); + + expect(screen.getByTestId('player-board-2')).toBeInTheDocument(); + expect(screen.queryByTestId('player-board-3')).not.toBeInTheDocument(); + }); + + it('mirrors the opponent board and leaves the local board upright', () => { + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + }); + + expect(screen.getByTestId('player-board-2')).toHaveClass('player-board--mirrored'); + expect(screen.getByTestId('player-board-1')).not.toHaveClass('player-board--mirrored'); + }); + + it('lifts card-hover state into the right panel preview', () => { + const card = makeCard({ id: 7, name: 'Lightning Bolt', x: 0, y: 0 }); + renderWithProviders(, { + preloadedState: buildGame({ + localId: 1, + opponentIds: [2], + tableCards: [card], + }), + }); + + const small = document.querySelector('.card-preview__image--small') as HTMLImageElement | null; + expect(small).toBeNull(); + + const slot = screen.getAllByTestId('card-slot')[0]; + fireEvent.mouseEnter(slot); + + const afterHover = document.querySelector('.card-preview__image--small') as HTMLImageElement; + expect(afterHover.src).toContain('Lightning%20Bolt'); + }); + + it('keeps the phase bar and right panel visible when no game is joined', () => { + renderWithProviders(, { + preloadedState: makeStoreState({ + ...connectedState, + games: { games: {} }, + }), + }); + + expect(screen.getByTestId('phase-bar')).toBeInTheDocument(); + expect(screen.getByTestId('right-panel')).toBeInTheDocument(); + }); + + describe('DeckSelectDialog auto-open', () => { + it('opens automatically when game is not started and local player is not ready', () => { + renderWithProviders(, { + preloadedState: buildGame({ + localId: 1, + opponentIds: [2], + started: false, + localReadyStart: false, + }), + }); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByLabelText('deck list')).toBeInTheDocument(); + }); + + it('stays closed when the game has already started', () => { + renderWithProviders(, { + preloadedState: buildGame({ + localId: 1, + opponentIds: [2], + started: true, + localReadyStart: false, + }), + }); + + expect(screen.queryByLabelText('deck list')).not.toBeInTheDocument(); + }); + + it('stays closed once the local player is ready', () => { + renderWithProviders(, { + preloadedState: buildGame({ + localId: 1, + opponentIds: [2], + started: false, + localReadyStart: true, + }), + }); + + expect(screen.queryByLabelText('deck list')).not.toBeInTheDocument(); + }); + + it('stays closed for spectators', () => { + renderWithProviders(, { + preloadedState: buildGame({ + localId: 1, + opponentIds: [2], + started: false, + spectator: true, + }), + }); + + expect(screen.queryByLabelText('deck list')).not.toBeInTheDocument(); + }); + + it('stays closed for judges', () => { + renderWithProviders(, { + preloadedState: buildGame({ + localId: 1, + opponentIds: [2], + started: false, + judge: true, + }), + }); + + expect(screen.queryByLabelText('deck list')).not.toBeInTheDocument(); + }); + + // Judges on Servatrice are flagged spectator on the wire. Both gates + // independently suppress the deck-select dialog; this pins that either + // one alone is sufficient. + it('stays closed for judges who are also flagged as spectators', () => { + renderWithProviders(, { + preloadedState: buildGame({ + localId: 1, + opponentIds: [2], + started: false, + judge: true, + spectator: true, + }), + }); + + expect(screen.queryByLabelText('deck list')).not.toBeInTheDocument(); + }); + }); + + describe('ZoneViewDialog', () => { + it('is closed by default', () => { + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + }); + + 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', () => { + const graveCard = makeCard({ id: 77, name: 'Final Card' }); + renderWithProviders(, { + preloadedState: buildGame({ + localId: 1, + opponentIds: [2], + graveCards: [graveCard], + }), + }); + + const localBoard = screen.getByTestId('player-board-1'); + const graveStack = localBoard.querySelector(`[data-testid="zone-stack-${App.ZoneName.GRAVE}"]`)!; + fireEvent.click(graveStack); + + expect(screen.getByRole('button', { name: /close zone view/i })).toBeInTheDocument(); + expect(screen.getAllByAltText('Final Card').length).toBeGreaterThan(0); + }); + + it('closes when the close button is clicked', async () => { + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + }); + + const graveStack = screen + .getByTestId('player-board-1') + .querySelector(`[data-testid="zone-stack-${App.ZoneName.GRAVE}"]`)!; + fireEvent.click(graveStack); + fireEvent.click(screen.getByRole('button', { name: /close zone view/i })); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: /close zone view/i })).not.toBeInTheDocument(); + }); + }); + + // Click propagation from the opponent's grave uses the same handler as + // the local grave; M2 deferrable wanted this pinned explicitly so a + // regression that scopes the click to the local board only is caught. + it('opens the opponent-owned grave and titles the panel with the opponent name', () => { + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + }); + + const opponentBoard = screen.getByTestId('player-board-2'); + const graveStack = opponentBoard.querySelector(`[data-testid="zone-stack-${App.ZoneName.GRAVE}"]`)!; + fireEvent.click(graveStack); + + const panel = screen.getByTestId('zone-view-dialog'); + expect(panel).toHaveAttribute('aria-label', expect.stringMatching(/P2 Graveyard/)); + }); + }); + + describe('Card interactions (M3)', () => { + it('double-clicking a battlefield card toggles tap via setCardAttr', () => { + const webClient = createMockWebClient(); + const card = makeCard({ id: 7, name: 'Creature', x: 0, y: 0, tapped: false }); + renderWithProviders(, { + preloadedState: buildGame({ + localId: 1, + opponentIds: [2], + tableCards: [card], + }), + webClient, + }); + + const localBoard = screen.getByTestId('player-board-1'); + const slot = localBoard.querySelector('[data-testid="card-slot"]')!; + fireEvent.doubleClick(slot); + + expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, { + zone: App.ZoneName.TABLE, + cardId: 7, + attribute: Data.CardAttribute.AttrTapped, + attrValue: '1', + }); + }); + + it('right-clicking a local card opens the card context menu', () => { + const card = makeCard({ id: 7, name: 'Creature', x: 0, y: 0 }); + renderWithProviders(, { + preloadedState: buildGame({ + localId: 1, + opponentIds: [2], + tableCards: [card], + }), + }); + + const slot = screen.getByTestId('player-board-1').querySelector('[data-testid="card-slot"]')!; + fireEvent.contextMenu(slot); + + expect(screen.getByText('Flip')).toBeInTheDocument(); + expect(screen.getByText('Tap')).toBeInTheDocument(); + }); + + it('right-clicking the local deck opens the zone context menu', () => { + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + }); + + const localDeck = screen + .getByTestId('player-board-1') + .querySelector(`[data-testid="zone-stack-${App.ZoneName.DECK}"]`)!; + fireEvent.contextMenu(localDeck); + + expect(screen.getByText('Draw a card')).toBeInTheDocument(); + expect(screen.getByText('Shuffle')).toBeInTheDocument(); + }); + + it('does NOT open a zone context menu for the opponent deck', () => { + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + }); + + const opponentDeck = screen + .getByTestId('player-board-2') + .querySelector(`[data-testid="zone-stack-${App.ZoneName.DECK}"]`)!; + fireEvent.contextMenu(opponentDeck); + + expect(screen.queryByText('Draw a card')).not.toBeInTheDocument(); + }); + + it('opening a card menu closes an already-open zone menu', () => { + const card = makeCard({ id: 7, x: 0, y: 0 }); + renderWithProviders(, { + preloadedState: buildGame({ + localId: 1, + opponentIds: [2], + tableCards: [card], + }), + }); + + const localDeck = screen + .getByTestId('player-board-1') + .querySelector(`[data-testid="zone-stack-${App.ZoneName.DECK}"]`)!; + fireEvent.contextMenu(localDeck); + expect(screen.getByText('Draw a card')).toBeInTheDocument(); + + const slot = screen.getByTestId('player-board-1').querySelector('[data-testid="card-slot"]')!; + fireEvent.contextMenu(slot); + + expect(screen.queryByText('Draw a card')).not.toBeInTheDocument(); + expect(screen.getByText('Flip')).toBeInTheDocument(); + }); + + it('dispatches drawCards(1) when "Draw a card" is chosen from the deck menu', () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + webClient, + }); + + fireEvent.contextMenu( + screen.getByTestId('player-board-1').querySelector(`[data-testid="zone-stack-${App.ZoneName.DECK}"]`)!, + ); + fireEvent.click(screen.getByText('Draw a card')); + + expect(webClient.request.game.drawCards).toHaveBeenCalledWith(1, { number: 1 }); + }); + + it('opens a PromptDialog when "Set P/T…" is chosen and dispatches setCardAttr on submit', () => { + const webClient = createMockWebClient(); + const card = makeCard({ id: 7, x: 0, y: 0, pt: '' }); + renderWithProviders(, { + preloadedState: buildGame({ + localId: 1, + opponentIds: [2], + tableCards: [card], + }), + webClient, + }); + + fireEvent.contextMenu( + screen.getByTestId('player-board-1').querySelector('[data-testid="card-slot"]')!, + ); + fireEvent.click(screen.getByText('Set P/T…')); + + const input = screen.getByLabelText('P/T (e.g. 3/3)'); + fireEvent.change(input, { target: { value: '3/3' } }); + fireEvent.click(screen.getByRole('button', { name: /ok/i })); + + expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, { + zone: App.ZoneName.TABLE, + cardId: 7, + attribute: Data.CardAttribute.AttrPT, + attrValue: '3/3', + }); + }); + }); + + // M4–M6 orchestration — each of these goes through Game.tsx state wiring + // between a trigger component and the dialog/menu it opens. Individual + // handlers are tested in child specs; this suite pins the end-to-end + // dispatch so a regression that disconnects state from its consumers is + // caught even when both sides still pass in isolation. + describe('Orchestration (M4–M6)', () => { + // Each test renders the full Game container (DndContext + CardRegistry + + // both player boards + preview panel) and drives MUI portal transitions + // for Menu → Dialog flows. Cold jsdom render plus two portal transitions + // routinely pushes a single test past vitest's 5s default under worker + // contention. Every test in this block passes 15000ms explicitly as the + // 3rd arg to `it(...)` — vi.setConfig in beforeAll/beforeEach didn't take + // effect because the per-test timeout is captured at describe-registration + // time, not at run time. + const ORCHESTRATION_TIMEOUT_MS = 15000; + + it('Roll Die: TurnControls → RollDieDialog → rollDie dispatch', async () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + webClient, + }); + + fireEvent.click(screen.getByRole('button', { name: /roll die/i })); + // Dialog opens via MUI portal+transition; await its inputs before + // interacting to avoid flakes under worker contention. + const sides = await screen.findByLabelText('Sides') as HTMLInputElement; + const count = screen.getByLabelText('Count') as HTMLInputElement; + fireEvent.change(sides, { target: { value: '20' } }); + fireEvent.change(count, { target: { value: '2' } }); + fireEvent.click(screen.getByRole('button', { name: /^roll$/i })); + + expect(webClient.request.game.rollDie).toHaveBeenCalledWith(1, { sides: 20, count: 2 }); + }, ORCHESTRATION_TIMEOUT_MS); + + it('Kick: TurnControls host menu → kickFromGame with chosen opponent', async () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + // localId 1 is the host by fixture default (hostId: 1). + preloadedState: buildGame({ localId: 1, opponentIds: [2, 3] }), + webClient, + }); + + fireEvent.click(screen.getByRole('button', { name: /kick/i })); + // P3 also appears in the OpponentSelector; pick the one inside the + // MUI Menu popup. findAllByText waits for the portal to mount. + const menuItem = (await screen.findAllByText('P3')).find((el) => el.closest('[role="menuitem"]')); + fireEvent.click(menuItem!); + + expect(webClient.request.game.kickFromGame).toHaveBeenCalledWith(1, { playerId: 3 }); + }, ORCHESTRATION_TIMEOUT_MS); + + it('Create Token: PlayerContextMenu → CreateTokenDialog → createToken', async () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + webClient, + }); + + fireEvent.contextMenu(screen.getByTestId('player-info-1')); + fireEvent.click(await screen.findByText('Create token…')); + + const nameInput = await screen.findByLabelText('Token name'); + fireEvent.change(nameInput, { target: { value: 'Goblin' } }); + fireEvent.click(screen.getByRole('button', { name: /^create$/i })); + + expect(webClient.request.game.createToken).toHaveBeenCalledWith( + 1, + expect.objectContaining({ cardName: 'Goblin', zone: App.ZoneName.TABLE }), + ); + }, ORCHESTRATION_TIMEOUT_MS); + + it('Mulligan same-size: HandContextMenu → mulligan with current hand count', async () => { + const webClient = createMockWebClient(); + const state = buildGame({ localId: 1, opponentIds: [2] }); + // Seed the local hand with 5 cards so "same size" sends number: 5. + const localPlayer = state.games.games[1].players[1]; + localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ + name: App.ZoneName.HAND, + cards: Array.from({ length: 5 }, (_, i) => makeCard({ id: 100 + i })), + cardCount: 5, + }); + renderWithProviders(, { preloadedState: state, webClient }); + + fireEvent.contextMenu(screen.getByTestId('hand-zone')); + fireEvent.click(await screen.findByText(/take mulligan \(same size\)/i)); + + expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 5 }); + }, ORCHESTRATION_TIMEOUT_MS); + + it('Mulligan choose-size: negative input is translated to handSize + input', async () => { + // Desktop's actMulligan (player_actions.cpp:308-354) treats 0 and + // negative inputs as "relative to current hand size" before + // dispatching Command_Mulligan. Regression guard for that convention. + const webClient = createMockWebClient(); + const state = buildGame({ localId: 1, opponentIds: [2] }); + const localPlayer = state.games.games[1].players[1]; + localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ + name: App.ZoneName.HAND, + cards: Array.from({ length: 7 }, (_, i) => makeCard({ id: 100 + i })), + cardCount: 7, + }); + localPlayer.zones[App.ZoneName.DECK] = makeZoneEntry({ + name: App.ZoneName.DECK, + cards: [], + cardCount: 53, + }); + renderWithProviders(, { preloadedState: state, webClient }); + + fireEvent.contextMenu(screen.getByTestId('hand-zone')); + fireEvent.click(await screen.findByText(/take mulligan \(choose size\)/i)); + + // Helper text is visible to the user. + expect( + await screen.findByText('0 and lower are in comparison to current hand size.'), + ).toBeInTheDocument(); + + // Enter −1: server receives handSize + (−1) = 6. + const input = screen.getByLabelText('New hand size'); + fireEvent.change(input, { target: { value: '-1' } }); + fireEvent.click(screen.getByRole('button', { name: /ok/i })); + + expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 6 }); + }, ORCHESTRATION_TIMEOUT_MS); + + it('Mulligan choose-size: positive integer passes through unchanged', async () => { + const webClient = createMockWebClient(); + const state = buildGame({ localId: 1, opponentIds: [2] }); + const localPlayer = state.games.games[1].players[1]; + localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ + name: App.ZoneName.HAND, + cards: Array.from({ length: 7 }, (_, i) => makeCard({ id: 100 + i })), + cardCount: 7, + }); + localPlayer.zones[App.ZoneName.DECK] = makeZoneEntry({ + name: App.ZoneName.DECK, + cards: [], + cardCount: 53, + }); + renderWithProviders(, { preloadedState: state, webClient }); + + fireEvent.contextMenu(screen.getByTestId('hand-zone')); + fireEvent.click(await screen.findByText(/take mulligan \(choose size\)/i)); + + const input = await screen.findByLabelText('New hand size'); + fireEvent.change(input, { target: { value: '4' } }); + fireEvent.click(screen.getByRole('button', { name: /ok/i })); + + expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 4 }); + }, ORCHESTRATION_TIMEOUT_MS); + + it('Arrow-from-hand auto-plays the source card instead of sending a stale createArrow', async () => { + // Desktop parity (card_item.cpp:243-250): dragging an arrow from a + // local-hand card to a target outside the hand auto-plays the card. + // The server re-keys the card id on the move, so sending createArrow + // with the old hand cardId would be rejected. We resolve this as a + // play-card intent and skip the arrow command. + const webClient = createMockWebClient(); + const state = buildGame({ + localId: 1, + opponentIds: [2], + tableCards: [makeCard({ id: 50, name: 'Bear' })], + }); + const localPlayer = state.games.games[1].players[1]; + localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ + name: App.ZoneName.HAND, + cards: [makeCard({ id: 10, name: 'Lightning Bolt' })], + cardCount: 1, + }); + renderWithProviders(, { preloadedState: state, webClient }); + + // Right-click the hand card to open CardContextMenu. + const handCard = document.querySelector('[data-card-zone="hand"][data-card-id="10"]')!; + fireEvent.contextMenu(handCard); + + // MUI Menu transitions in — await its content before interacting. + const drawArrowItem = await screen.findByText('Draw arrow from here'); + fireEvent.click(drawArrowItem); + + // Click a battlefield card — the handleCardClick path should detect + // the hand-source + non-hand-target combo and dispatch moveCard. + const tableCard = document.querySelector('[data-card-zone="table"][data-card-id="50"]')!; + fireEvent.click(tableCard); + + await waitFor(() => { + expect(webClient.request.game.moveCard).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + startPlayerId: 1, + startZone: App.ZoneName.HAND, + targetPlayerId: 1, + targetZone: App.ZoneName.TABLE, + cardsToMove: { card: [{ cardId: 10 }] }, + }), + ); + }); + expect(webClient.request.game.createArrow).not.toHaveBeenCalled(); + }, ORCHESTRATION_TIMEOUT_MS); + + it('Mulligan choose-size: value outside [-handSize, handSize+deckSize] is rejected', async () => { + const webClient = createMockWebClient(); + const state = buildGame({ localId: 1, opponentIds: [2] }); + const localPlayer = state.games.games[1].players[1]; + localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ + name: App.ZoneName.HAND, + cards: Array.from({ length: 7 }, (_, i) => makeCard({ id: 100 + i })), + cardCount: 7, + }); + localPlayer.zones[App.ZoneName.DECK] = makeZoneEntry({ + name: App.ZoneName.DECK, + cards: [], + cardCount: 53, + }); + renderWithProviders(, { preloadedState: state, webClient }); + + fireEvent.contextMenu(screen.getByTestId('hand-zone')); + fireEvent.click(await screen.findByText(/take mulligan \(choose size\)/i)); + + const input = await screen.findByLabelText('New hand size'); + fireEvent.change(input, { target: { value: '-99' } }); + fireEvent.click(screen.getByRole('button', { name: /ok/i })); + + expect(webClient.request.game.mulligan).not.toHaveBeenCalled(); + expect(screen.getByText(/between -7 and 60/i)).toBeInTheDocument(); + }, ORCHESTRATION_TIMEOUT_MS); + + it('Sideboard: PlayerContextMenu → SideboardDialog → setSideboardPlan with the accumulated moveList', async () => { + const webClient = createMockWebClient(); + const state = buildGame({ localId: 1, opponentIds: [2] }); + // Seed the local deck + sideboard with distinct named cards. + const localPlayer = state.games.games[1].players[1]; + localPlayer.zones[App.ZoneName.DECK] = makeZoneEntry({ + name: App.ZoneName.DECK, + cards: [makeCard({ id: 100, name: 'Island' })], + cardCount: 1, + }); + localPlayer.zones[App.ZoneName.SIDEBOARD] = makeZoneEntry({ + name: App.ZoneName.SIDEBOARD, + cards: [makeCard({ id: 200, name: 'Counterspell' })], + cardCount: 1, + }); + renderWithProviders(, { preloadedState: state, webClient }); + + fireEvent.contextMenu(screen.getByTestId('player-info-1')); + fireEvent.click(await screen.findByText(/view sideboard/i)); + fireEvent.click(await screen.findByRole('button', { name: /move Island to sideboard/i })); + fireEvent.click(screen.getByRole('button', { name: /apply plan/i })); + + expect(webClient.request.game.setSideboardPlan).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + moveList: [ + { cardName: 'Island', startZone: App.ZoneName.DECK, targetZone: App.ZoneName.SIDEBOARD }, + ], + }), + ); + }, ORCHESTRATION_TIMEOUT_MS); + + it('Sideboard lock: toggling Lock sideboard dispatches setSideboardLock', async () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + webClient, + }); + + fireEvent.contextMenu(screen.getByTestId('player-info-1')); + fireEvent.click(await screen.findByText(/view sideboard/i)); + fireEvent.click(await screen.findByLabelText('Lock sideboard')); + + expect(webClient.request.game.setSideboardLock).toHaveBeenCalledWith(1, { locked: true }); + }, ORCHESTRATION_TIMEOUT_MS); + + it('changeZoneProperties: toggling "Always reveal top card" on local deck dispatches the command', async () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + webClient, + }); + + fireEvent.contextMenu( + screen + .getByTestId('player-board-1') + .querySelector(`[data-testid="zone-stack-${App.ZoneName.DECK}"]`)!, + ); + fireEvent.click(await screen.findByText(/always reveal top card/i)); + + expect(webClient.request.game.changeZoneProperties).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + zoneName: App.ZoneName.DECK, + alwaysRevealTopCard: true, + }), + ); + }, ORCHESTRATION_TIMEOUT_MS); + }); +}); diff --git a/webclient/src/containers/Game/Game.tsx b/webclient/src/containers/Game/Game.tsx index 139147ab1..c907ad83e 100644 --- a/webclient/src/containers/Game/Game.tsx +++ b/webclient/src/containers/Game/Game.tsx @@ -1,13 +1,1332 @@ -import { AuthGuard } from '@app/components'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; + +import { + AuthGuard, + CardContextMenu, + CardDragOverlay, + CardRegistryContext, + GameArrowOverlay, + HandContextMenu, + HandZone, + OpponentSelector, + PhaseBar, + PlayerBoard, + PlayerContextMenu, + RightPanel, + StackStrip, + ZoneContextMenu, + createCardRegistry, + makeCardKey, + useToast, +} from '@app/components'; +import { + ConfirmDialog, + CreateCounterDialog, + CreateTokenDialog, + DeckSelectDialog, + DEFAULT_DIE_COUNT, + DEFAULT_DIE_SIDES, + GameInfoDialog, + PromptDialog, + RevealCardsDialog, + RollDieDialog, + SideboardDialog, + cardsFromZone, + type SideboardPlanMove, + ZoneViewDialog, +} from '@app/dialogs'; +import { + useCurrentGame, + useGameAccess, + useGameLifecycle, + useWebClient, +} from '@app/hooks'; +import { App, Data } from '@app/types'; + import Layout from '../Layout/Layout'; import './Game.css'; +interface ZoneViewTarget { + playerId: number; + zoneName: string; +} + +interface CardMenuState { + card: Data.ServerInfo_Card; + sourcePlayerId: number; + sourceZone: string; + anchorPosition: { top: number; left: number }; +} + +interface ZoneMenuState { + playerId: number; + zoneName: string; + anchorPosition: { top: number; left: number }; +} + +interface PromptState { + title: string; + label: string; + initialValue?: string; + helperText?: string; + validate?: (value: string) => string | null; + onSubmit: (value: string) => void; +} + +interface PendingArrow { + sourcePlayerId: number; + sourceZone: string; + sourceCardId: number; +} + +// Shares shape with PendingArrow today, but kept distinct so future +// protocol fields (e.g. desktop's attach-target coord hints) can diverge +// without a runtime switch. +interface PendingAttach { + sourcePlayerId: number; + sourceZone: string; + sourceCardId: number; +} + +interface AnchorPosition { + top: number; + left: number; +} + +interface RevealState { + title: string; + zoneName: string; + zoneLabel: string; + showCountInput: boolean; + defaultCount: number; + onSubmit: (args: { targetPlayerId: number; topCards: number }) => void; +} + +interface ArrowDragState { + sourcePlayerId: number; + sourceZone: string; + sourceCardId: number; + startX: number; + startY: number; + currentX: number; + currentY: number; + moved: boolean; +} + +function arrowColorForModifiers(e: { ctrlKey: boolean; altKey: boolean; shiftKey: boolean }): App.ColorRGBA { + if (e.ctrlKey) { + return App.ArrowColor.YELLOW; + } + if (e.altKey) { + return App.ArrowColor.BLUE; + } + if (e.shiftKey) { + return App.ArrowColor.GREEN; + } + return App.ArrowColor.RED; +} + +const ARROW_DRAG_THRESHOLD_PX = 8; + function Game() { + const { gameId, game, localPlayer, isSpectator, isJudge } = useCurrentGame(); + const webClient = useWebClient(); + const navigate = useNavigate(); + + const kickedToast = useToast({ + key: 'game-kicked', + children: 'You were kicked from the game', + }); + const gameClosedToast = useToast({ + key: 'game-closed', + children: 'The game was closed by the host', + }); + + useGameLifecycle(gameId, { + onKicked: () => { + kickedToast.openToast(); + navigate(App.RouteEnum.SERVER); + }, + onGameClosed: () => { + gameClosedToast.openToast(); + navigate(App.RouteEnum.SERVER); + }, + }); + + const [hoveredCard, setHoveredCard] = useState(null); + const [selectedOpponentId, setSelectedOpponentId] = useState(); + const [zoneViews, setZoneViews] = useState([]); + const [activeCard, setActiveCard] = useState(null); + const [cardMenu, setCardMenu] = useState(null); + const [zoneMenu, setZoneMenu] = useState(null); + const [prompt, setPrompt] = useState(null); + const [rollDieOpen, setRollDieOpen] = useState(false); + const [lastDieSides, setLastDieSides] = useState(DEFAULT_DIE_SIDES); + const [lastDieCount, setLastDieCount] = useState(DEFAULT_DIE_COUNT); + const [createCounterOpen, setCreateCounterOpen] = useState(false); + const [createTokenOpen, setCreateTokenOpen] = useState(false); + const [sideboardOpen, setSideboardOpen] = useState(false); + const [revealState, setRevealState] = useState(null); + const [playerMenu, setPlayerMenu] = useState(null); + const [handMenu, setHandMenu] = useState(null); + const [pendingArrow, setPendingArrow] = useState(null); + const [pendingAttach, setPendingAttach] = useState(null); + const [arrowDrag, setArrowDrag] = useState(null); + const [concedeConfirm, setConcedeConfirm] = useState<'concede' | 'unconcede' | null>(null); + const [gameInfoOpen, setGameInfoOpen] = useState(false); + // View-only 90° rotation; local to this tab, mirrors desktop's + // Player::actRotateLocal which applies a QGraphicsView transform with no + // server call. + const [isRotated, setIsRotated] = useState(false); + + const boardRef = useRef(null); + const suppressNextContextMenuRef = useRef(false); + const cardRegistry = useMemo(() => createCardRegistry(), []); + const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor)); + + const opponents = useMemo(() => { + if (!game) { + return []; + } + return Object.values(game.players) + .filter((p) => p.properties.playerId !== game.localPlayerId) + .map((p) => ({ + playerId: p.properties.playerId, + name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`, + })); + }, [game]); + + useEffect(() => { + if (selectedOpponentId == null && opponents.length > 0) { + setSelectedOpponentId(opponents[0].playerId); + } + if (selectedOpponentId != null && !opponents.some((o) => o.playerId === selectedOpponentId)) { + setSelectedOpponentId(opponents[0]?.playerId); + } + }, [opponents, selectedOpponentId]); + + // ESC cancels a pending arrow OR attach (matches desktop). Suppress the + // cancel when a MUI dialog is open — the dialog's own ESC handler should + // win so the user isn't rug-pulled out of a modal form. + useEffect(() => { + if (!pendingArrow && !pendingAttach && !arrowDrag) { + return undefined; + } + const handler = (e: KeyboardEvent) => { + if (e.key !== 'Escape') { + return; + } + if (document.querySelector('.MuiDialog-root[role="dialog"]')) { + return; + } + setPendingArrow(null); + setPendingAttach(null); + setArrowDrag(null); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [pendingArrow, pendingAttach, arrowDrag]); + + // Right-click-drag arrow-draw lifecycle: window-level mousemove + mouseup + // listeners that track the cursor and finalize on release. + useEffect(() => { + if (!arrowDrag) { + return undefined; + } + + const handleMove = (e: MouseEvent) => { + setArrowDrag((prev) => { + if (!prev) { + return prev; + } + const movedX = Math.abs(e.clientX - prev.startX); + const movedY = Math.abs(e.clientY - prev.startY); + const moved = prev.moved || movedX + movedY > ARROW_DRAG_THRESHOLD_PX; + return { ...prev, currentX: e.clientX, currentY: e.clientY, moved }; + }); + }; + + const handleUp = (e: MouseEvent) => { + if (e.button !== 2) { + return; + } + const drag = arrowDrag; + if (!drag) { + return; + } + const movedX = Math.abs(e.clientX - drag.startX); + const movedY = Math.abs(e.clientY - drag.startY); + const moved = drag.moved || movedX + movedY > ARROW_DRAG_THRESHOLD_PX; + setArrowDrag(null); + if (!moved || gameId == null) { + // Short right-click with no drag: let the contextmenu handler run + // (it will open the card menu). + return; + } + // Any real drag suppresses the contextmenu event that follows mouseup. + suppressNextContextMenuRef.current = true; + + const el = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-card-id]') as HTMLElement | null; + if (!el) { + return; + } + const targetPlayerId = Number(el.getAttribute('data-card-owner')); + const targetZone = el.getAttribute('data-card-zone') ?? ''; + const targetCardId = Number(el.getAttribute('data-card-id')); + if (!Number.isFinite(targetPlayerId) || !targetZone || !Number.isFinite(targetCardId)) { + return; + } + // Same-card drops are cancellations. + if ( + targetPlayerId === drag.sourcePlayerId && + targetZone === drag.sourceZone && + targetCardId === drag.sourceCardId + ) { + return; + } + // Desktop parity: dragging an arrow from a local-hand card to a target + // outside the hand auto-plays the card (card_item.cpp:243-250) — the + // card is moved to the battlefield before any arrow is drawn. The + // server re-keys the card id during the move, so we can't also send + // createArrow here; instead we resolve this drag as a play-card intent. + if ( + drag.sourceZone === App.ZoneName.HAND && + drag.sourcePlayerId === game?.localPlayerId && + targetZone !== App.ZoneName.HAND + ) { + webClient.request.game.moveCard(gameId, { + startPlayerId: drag.sourcePlayerId, + startZone: drag.sourceZone, + cardsToMove: { card: [{ cardId: drag.sourceCardId }] }, + targetPlayerId: drag.sourcePlayerId, + targetZone: App.ZoneName.TABLE, + x: 0, + y: 0, + isReversed: false, + }); + return; + } + webClient.request.game.createArrow(gameId, { + startPlayerId: drag.sourcePlayerId, + startZone: drag.sourceZone, + startCardId: drag.sourceCardId, + targetPlayerId, + targetZone, + targetCardId, + arrowColor: arrowColorForModifiers(e), + }); + }; + + window.addEventListener('mousemove', handleMove); + window.addEventListener('mouseup', handleUp); + return () => { + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', handleUp); + }; + }, [arrowDrag, gameId, webClient]); + + // Suppress the browser contextmenu event after a right-drag. + useEffect(() => { + const handler = (e: MouseEvent) => { + if (suppressNextContextMenuRef.current) { + e.preventDefault(); + suppressNextContextMenuRef.current = false; + } + }; + window.addEventListener('contextmenu', handler); + return () => window.removeEventListener('contextmenu', handler); + }, []); + + const handleBoardMouseDown = (e: React.MouseEvent) => { + if (e.button !== 2) { + return; + } + const el = (e.target as HTMLElement).closest('[data-card-id]') as HTMLElement | null; + if (!el) { + return; + } + const sourcePlayerId = Number(el.getAttribute('data-card-owner')); + const sourceZone = el.getAttribute('data-card-zone') ?? ''; + const sourceCardId = Number(el.getAttribute('data-card-id')); + if (!Number.isFinite(sourcePlayerId) || !sourceZone || !Number.isFinite(sourceCardId)) { + return; + } + setArrowDrag({ + sourcePlayerId, + sourceZone, + sourceCardId, + startX: e.clientX, + startY: e.clientY, + currentX: e.clientX, + currentY: e.clientY, + moved: false, + }); + }; + + const shownOpponentId = selectedOpponentId ?? opponents[0]?.playerId; + + const localAccess = useGameAccess(gameId, game?.localPlayerId); + const opponentAccess = useGameAccess(gameId, shownOpponentId); + + // Explicit localPlayer null-check closes a window during reconnect where + // `game` is present but `players[localPlayerId]` is not yet populated + // (Event_GameStateChanged arrives after Event_GameJoined echo). + const deckSelectOpen = + game != null && + localPlayer != null && + !game.started && + !isSpectator && + !isJudge && + !localPlayer.properties.readyStart; + + const arrowSourceKey = pendingArrow + ? makeCardKey(pendingArrow.sourcePlayerId, pendingArrow.sourceZone, pendingArrow.sourceCardId) + : pendingAttach + ? makeCardKey(pendingAttach.sourcePlayerId, pendingAttach.sourceZone, pendingAttach.sourceCardId) + : arrowDrag + ? makeCardKey(arrowDrag.sourcePlayerId, arrowDrag.sourceZone, arrowDrag.sourceCardId) + : null; + + // Convert arrowDrag's viewport coords → board-relative coords for the SVG + // preview line. Recomputed every render; cheap. + const dragPreview = useMemo(() => { + if (!arrowDrag || !arrowDrag.moved) { + return null; + } + const boardRect = boardRef.current?.getBoundingClientRect(); + const sourceEl = cardRegistry.get( + makeCardKey(arrowDrag.sourcePlayerId, arrowDrag.sourceZone, arrowDrag.sourceCardId), + ); + if (!boardRect || !sourceEl) { + return null; + } + const sourceRect = sourceEl.getBoundingClientRect(); + return { + x1: sourceRect.left + sourceRect.width / 2 - boardRect.left, + y1: sourceRect.top + sourceRect.height / 2 - boardRect.top, + x2: arrowDrag.currentX - boardRect.left, + y2: arrowDrag.currentY - boardRect.top, + color: App.rgbaToCss(App.ArrowColor.RED), + }; + }, [arrowDrag, cardRegistry]); + + const handleZoneClick = (playerId: number, zoneName: string) => { + setZoneViews((prev) => { + if (prev.some((v) => v.playerId === playerId && v.zoneName === zoneName)) { + return prev; + } + return [...prev, { playerId, zoneName }]; + }); + }; + + const handleCloseZoneView = (playerId: number, zoneName: string) => { + setZoneViews((prev) => + prev.filter((v) => !(v.playerId === playerId && v.zoneName === zoneName)), + ); + }; + + const handleDragStart = (event: DragStartEvent) => { + const data = event.active.data.current as + | { card: Data.ServerInfo_Card } + | undefined; + setActiveCard(data?.card ?? null); + // Starting a drag cancels any armed pending-arrow or pending-attach — + // dnd-kit owns the pointer during the drag, matching desktop where the + // arrow draw from context menu is aborted if the user grabs a card. + if (pendingArrow) { + setPendingArrow(null); + } + if (pendingAttach) { + setPendingAttach(null); + } + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveCard(null); + if (!gameId || !event.over || !event.active.data.current) { + return; + } + const source = event.active.data.current as { + card: Data.ServerInfo_Card; + sourcePlayerId: number; + sourceZone: string; + }; + const target = event.over.data.current as { + targetPlayerId: number; + targetZone: string; + row?: number; + attachTarget?: boolean; + targetCardId?: number; + }; + + // Drop onto another card on the table → attach source to target. + // Desktop's actAttach is only initiated from a table card, so source + // must also be TABLE. Non-TABLE drops onto a table card fall through + // to the normal moveCard branch (drop becomes "move to that row"). + if ( + target.attachTarget && + target.targetCardId != null && + source.sourceZone === App.ZoneName.TABLE + ) { + // Guard no-op self-drop (source === target). + if ( + source.sourcePlayerId === target.targetPlayerId && + source.sourceZone === target.targetZone && + source.card.id === target.targetCardId + ) { + return; + } + webClient.request.game.attachCard(gameId, { + startZone: source.sourceZone, + cardId: source.card.id, + targetPlayerId: target.targetPlayerId, + targetZone: target.targetZone, + targetCardId: target.targetCardId, + }); + return; + } + + const sameZone = + source.sourcePlayerId === target.targetPlayerId && + source.sourceZone === target.targetZone; + if (sameZone && source.sourceZone === App.ZoneName.TABLE && (source.card.y ?? 0) === (target.row ?? 0)) { + return; + } + if (sameZone && source.sourceZone !== App.ZoneName.TABLE) { + return; + } + + webClient.request.game.moveCard(gameId, { + startPlayerId: source.sourcePlayerId, + startZone: source.sourceZone, + cardsToMove: { card: [{ cardId: source.card.id }] }, + targetPlayerId: target.targetPlayerId, + targetZone: target.targetZone, + x: 0, + y: target.row ?? 0, + isReversed: false, + }); + }; + + const handleDragCancel = () => { + setActiveCard(null); + }; + + const handleCardContextMenu = ( + sourcePlayerId: number, + sourceZone: string, + card: Data.ServerInfo_Card, + event: React.MouseEvent, + ) => { + event.preventDefault(); + setZoneMenu(null); + setCardMenu({ + card, + sourcePlayerId, + sourceZone, + anchorPosition: { top: event.clientY, left: event.clientX }, + }); + }; + + const handleZoneContextMenu = ( + playerId: number, + zoneName: string, + event: React.MouseEvent, + ) => { + if (playerId !== game?.localPlayerId) { + return; + } + const supported = + zoneName === App.ZoneName.DECK || + zoneName === App.ZoneName.GRAVE || + zoneName === App.ZoneName.EXILE; + if (!supported) { + return; + } + event.preventDefault(); + setCardMenu(null); + setZoneMenu({ + playerId, + zoneName, + anchorPosition: { top: event.clientY, left: event.clientX }, + }); + }; + + const handlePlayerContextMenu = (event: React.MouseEvent) => { + if (gameId == null || isSpectator || localAccess.canAct === false) { + return; + } + event.preventDefault(); + setPlayerMenu({ top: event.clientY, left: event.clientX }); + }; + + const handleHandContextMenu = (event: React.MouseEvent) => { + if (gameId == null || isSpectator || localAccess.canAct === false) { + return; + } + event.preventDefault(); + setHandMenu({ top: event.clientY, left: event.clientX }); + }; + + const handleCardClick = ( + ownerPlayerId: number, + zone: string, + card: Data.ServerInfo_Card, + ) => { + if (gameId == null) { + return; + } + + // Pending-attach (from CardContextMenu "Attach to card…") takes + // precedence over pending-arrow because it was activated by a later menu + // action. Click on the pending source to cancel. + if (pendingAttach) { + if ( + pendingAttach.sourcePlayerId === ownerPlayerId && + pendingAttach.sourceZone === zone && + pendingAttach.sourceCardId === card.id + ) { + setPendingAttach(null); + return; + } + webClient.request.game.attachCard(gameId, { + startZone: pendingAttach.sourceZone, + cardId: pendingAttach.sourceCardId, + targetPlayerId: ownerPlayerId, + targetZone: zone, + targetCardId: card.id, + }); + setPendingAttach(null); + return; + } + + if (!pendingArrow) { + return; + } + // Cancel if user re-clicks the pending source. + if ( + pendingArrow.sourcePlayerId === ownerPlayerId && + pendingArrow.sourceZone === zone && + pendingArrow.sourceCardId === card.id + ) { + setPendingArrow(null); + return; + } + // Desktop parity: arrow from local-hand → non-hand target auto-plays the + // card (card_item.cpp:243-250). The server re-keys the moved card id, so + // we resolve this as a play-card intent and drop the arrow command. + if ( + pendingArrow.sourceZone === App.ZoneName.HAND && + pendingArrow.sourcePlayerId === game?.localPlayerId && + zone !== App.ZoneName.HAND + ) { + webClient.request.game.moveCard(gameId, { + startPlayerId: pendingArrow.sourcePlayerId, + startZone: pendingArrow.sourceZone, + cardsToMove: { card: [{ cardId: pendingArrow.sourceCardId }] }, + targetPlayerId: pendingArrow.sourcePlayerId, + targetZone: App.ZoneName.TABLE, + x: 0, + y: 0, + isReversed: false, + }); + setPendingArrow(null); + return; + } + webClient.request.game.createArrow(gameId, { + startPlayerId: pendingArrow.sourcePlayerId, + startZone: pendingArrow.sourceZone, + startCardId: pendingArrow.sourceCardId, + targetPlayerId: ownerPlayerId, + targetZone: zone, + targetCardId: card.id, + arrowColor: App.ArrowColor.RED, + }); + setPendingArrow(null); + }; + + const handleCardDoubleClick = ( + sourceZone: string, + card: Data.ServerInfo_Card, + ) => { + if (sourceZone !== App.ZoneName.TABLE || gameId == null) { + return; + } + // Desktop's arrow drag owns the pointer while active; mirror that by + // short-circuiting tap-toggle while a pending arrow/attach is armed. + if (pendingArrow || pendingAttach) { + return; + } + webClient.request.game.setCardAttr(gameId, { + zone: sourceZone, + cardId: card.id, + attribute: Data.CardAttribute.AttrTapped, + attrValue: card.tapped ? '0' : '1', + }); + }; + + const handleRequestSetPT = () => { + const menu = cardMenu; + if (!menu || gameId == null) { + return; + } + setPrompt({ + title: 'Set power/toughness', + label: 'P/T (e.g. 3/3)', + initialValue: menu.card.pt ?? '', + onSubmit: (value) => { + webClient.request.game.setCardAttr(gameId, { + zone: menu.sourceZone, + cardId: menu.card.id, + attribute: Data.CardAttribute.AttrPT, + attrValue: value, + }); + setPrompt(null); + }, + }); + }; + + const handleRequestSetAnnotation = () => { + const menu = cardMenu; + if (!menu || gameId == null) { + return; + } + setPrompt({ + title: 'Set annotation', + label: 'Annotation', + initialValue: menu.card.annotation ?? '', + onSubmit: (value) => { + webClient.request.game.setCardAttr(gameId, { + zone: menu.sourceZone, + cardId: menu.card.id, + attribute: Data.CardAttribute.AttrAnnotation, + attrValue: value, + }); + setPrompt(null); + }, + }); + }; + + const handleRequestSetCardCounter = () => { + const menu = cardMenu; + if (!menu || gameId == null) { + return; + } + const existing = menu.card.counterList.find((c) => c.id === 0); + setPrompt({ + title: 'Set card counter', + label: 'Counter value', + initialValue: String(existing?.value ?? 0), + validate: (v) => (/^-?\d+$/.test(v) ? null : 'Enter an integer'), + onSubmit: (value) => { + webClient.request.game.setCardCounter(gameId, { + zone: menu.sourceZone, + cardId: menu.card.id, + counterId: 0, + counterValue: Number(value), + }); + setPrompt(null); + }, + }); + }; + + const handleRequestDrawArrow = () => { + const menu = cardMenu; + if (!menu) { + return; + } + setPendingArrow({ + sourcePlayerId: menu.sourcePlayerId, + sourceZone: menu.sourceZone, + sourceCardId: menu.card.id, + }); + }; + + const handleRequestAttach = () => { + const menu = cardMenu; + if (!menu) { + return; + } + setPendingAttach({ + sourcePlayerId: menu.sourcePlayerId, + sourceZone: menu.sourceZone, + sourceCardId: menu.card.id, + }); + }; + + const handleRequestMoveToLibraryAt = () => { + const menu = cardMenu; + if (!menu || gameId == null || game == null) { + return; + } + // Desktop prompts for a 1-indexed position into the library, then + // internally subtracts 1 for the protocol's 0-indexed x-coordinate. + setPrompt({ + title: 'Move to library at position', + label: 'Position (1 = top)', + initialValue: '1', + validate: (v) => (/^[1-9]\d*$/.test(v) ? null : 'Enter a positive integer'), + onSubmit: (value) => { + webClient.request.game.moveCard(gameId, { + startPlayerId: menu.sourcePlayerId, + startZone: menu.sourceZone, + cardsToMove: { card: [{ cardId: menu.card.id }] }, + targetPlayerId: game.localPlayerId, + targetZone: App.ZoneName.DECK, + x: Math.max(0, Number(value) - 1), + y: 0, + isReversed: false, + }); + setPrompt(null); + }, + }); + }; + + const handleRequestDrawN = () => { + if (gameId == null) { + return; + } + setPrompt({ + title: 'Draw N cards', + label: 'Number of cards', + initialValue: '1', + validate: (v) => (/^[1-9]\d*$/.test(v) ? null : 'Enter a positive integer'), + onSubmit: (value) => { + webClient.request.game.drawCards(gameId, { number: Number(value) }); + setPrompt(null); + }, + }); + }; + + const handleRequestDumpN = () => { + if (gameId == null) { + return; + } + setPrompt({ + title: 'Dump top N', + label: 'Number of cards', + initialValue: '1', + validate: (v) => (/^[1-9]\d*$/.test(v) ? null : 'Enter a positive integer'), + onSubmit: (value) => { + webClient.request.game.dumpZone(gameId, { + playerId: game!.localPlayerId, + zoneName: App.ZoneName.DECK, + numberCards: Number(value), + isReversed: false, + }); + setPrompt(null); + }, + }); + }; + + const handleRollDieSubmit = ({ sides, count }: { sides: number; count: number }) => { + if (gameId == null) { + return; + } + webClient.request.game.rollDie(gameId, { sides, count }); + setLastDieSides(sides); + setLastDieCount(count); + setRollDieOpen(false); + }; + + const handleCreateCounterSubmit = ({ + name, + color, + }: { + name: string; + color: { r: number; g: number; b: number; a: number }; + }) => { + if (gameId == null) { + return; + } + webClient.request.game.createCounter(gameId, { + counterName: name, + counterColor: color, + radius: 1, + value: 0, + }); + setCreateCounterOpen(false); + }; + + const handleCreateTokenSubmit = (args: { + name: string; + color: string; + pt: string; + annotation: string; + destroyOnZoneChange: boolean; + faceDown: boolean; + }) => { + if (gameId == null) { + return; + } + webClient.request.game.createToken(gameId, { + zone: App.ZoneName.TABLE, + cardName: args.name, + color: args.color, + pt: args.pt, + annotation: args.annotation, + destroyOnZoneChange: args.destroyOnZoneChange, + x: 0, + y: 0, + faceDown: args.faceDown, + targetCardId: -1, + }); + setCreateTokenOpen(false); + }; + + const handleSideboardSubmit = (moveList: SideboardPlanMove[]) => { + if (gameId == null) { + return; + } + webClient.request.game.setSideboardPlan(gameId, { moveList }); + setSideboardOpen(false); + }; + + const handleToggleSideboardLock = (locked: boolean) => { + if (gameId == null) { + return; + } + webClient.request.game.setSideboardLock(gameId, { locked }); + }; + + const handleRequestChooseMulligan = () => { + if (gameId == null) { + return; + } + // Desktop's DlgMulligan (player_actions.cpp actMulligan) accepts any + // integer in [-handSize, handSize + deckSize]. 0 and negative values are + // "relative to current hand size" — doMulligan computes + // `handSize + number` before dispatching. Seeding with the configured + // starting hand size (7) matches desktop's default. + const handSize = localPlayer?.zones[App.ZoneName.HAND]?.cardCount ?? 0; + const deckSize = localPlayer?.zones[App.ZoneName.DECK]?.cardCount ?? 0; + const min = -handSize; + const max = handSize + deckSize; + setPrompt({ + title: 'Take mulligan', + label: 'New hand size', + initialValue: '7', + helperText: '0 and lower are in comparison to current hand size.', + validate: (v) => { + if (!/^-?\d+$/.test(v)) { + return 'Enter an integer.'; + } + const n = Number(v); + if (n < min || n > max) { + return `Enter an integer between ${min} and ${max}.`; + } + return null; + }, + onSubmit: (value) => { + const input = Number(value); + const resolved = input < 1 ? handSize + input : input; + webClient.request.game.mulligan(gameId, { number: resolved }); + setPrompt(null); + }, + }); + }; + + const handleRequestRevealHand = () => { + if (gameId == null) { + return; + } + setRevealState({ + title: 'Reveal hand', + zoneName: App.ZoneName.HAND, + zoneLabel: 'Hand', + showCountInput: false, + defaultCount: 1, + onSubmit: ({ targetPlayerId }) => { + webClient.request.game.revealCards(gameId, { + zoneName: App.ZoneName.HAND, + playerId: targetPlayerId, + topCards: -1, + }); + setRevealState(null); + }, + }); + }; + + const handleRequestRevealRandom = () => { + if (gameId == null) { + return; + } + // Desktop's RANDOM_CARD_FROM_ZONE sentinel (-2); see + // cockatrice/src/game/player/player_actions.h:47 and + // actRevealRandomHandCard at player_actions.cpp:1705-1712. + const RANDOM_CARD_FROM_ZONE = -2; + setRevealState({ + title: 'Reveal random card', + zoneName: App.ZoneName.HAND, + zoneLabel: 'Hand (random)', + showCountInput: false, + defaultCount: 1, + onSubmit: ({ targetPlayerId }) => { + webClient.request.game.revealCards(gameId, { + zoneName: App.ZoneName.HAND, + cardId: [RANDOM_CARD_FROM_ZONE], + playerId: targetPlayerId, + topCards: -1, + }); + setRevealState(null); + }, + }); + }; + + const handleRequestRevealTopN = () => { + if (gameId == null) { + return; + } + setRevealState({ + title: 'Reveal top N cards', + zoneName: App.ZoneName.DECK, + zoneLabel: 'Library', + showCountInput: true, + defaultCount: 1, + onSubmit: ({ targetPlayerId, topCards }) => { + webClient.request.game.revealCards(gameId, { + zoneName: App.ZoneName.DECK, + playerId: targetPlayerId, + topCards, + }); + setRevealState(null); + }, + }); + }; + + const handleRequestRevealZone = () => { + if (gameId == null || zoneMenu == null) { + return; + } + const { zoneName } = zoneMenu; + const label = + zoneName === App.ZoneName.GRAVE ? 'Graveyard' : + zoneName === App.ZoneName.EXILE ? 'Exile' : zoneName; + setRevealState({ + title: `Reveal ${label.toLowerCase()}`, + zoneName, + zoneLabel: label, + showCountInput: false, + defaultCount: 1, + onSubmit: ({ targetPlayerId }) => { + webClient.request.game.revealCards(gameId, { + zoneName, + playerId: targetPlayerId, + topCards: -1, + }); + setRevealState(null); + }, + }); + }; + + const revealPlayers = useMemo(() => { + if (!game) { + return []; + } + return Object.values(game.players) + .filter((p) => p.properties.playerId !== game.localPlayerId) + .map((p) => ({ + playerId: p.properties.playerId, + name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`, + })); + }, [game]); + return ( - "Game" + + +
    + + +
    + + + {!game && ( +
    + No active game. Join a game from a room to see the board. +
    + )} + + {game && shownOpponentId != null && ( +
    + + handleCardContextMenu(shownOpponentId, App.ZoneName.TABLE, card, e) + } + onCardDoubleClick={(card) => + handleCardDoubleClick(App.ZoneName.TABLE, card) + } + onZoneClick={handleZoneClick} + onZoneContextMenu={handleZoneContextMenu} + /> + o.playerId === shownOpponentId)?.name ?? + `p${shownOpponentId}`, + }, + { + playerId: game.localPlayerId, + name: + localPlayer?.properties.userInfo?.name ?? + `p${game.localPlayerId}`, + }, + ]} + onZoneClick={handleZoneClick} + /> + + handleCardContextMenu(game.localPlayerId, App.ZoneName.TABLE, card, e) + } + onCardDoubleClick={(card) => + handleCardDoubleClick(App.ZoneName.TABLE, card) + } + onZoneClick={handleZoneClick} + onZoneContextMenu={handleZoneContextMenu} + onRequestCreateCounter={() => setCreateCounterOpen(true)} + onPlayerContextMenu={handlePlayerContextMenu} + /> + {localPlayer && ( + + handleCardContextMenu(game.localPlayerId, App.ZoneName.HAND, card, e) + } + onZoneContextMenu={handleHandContextMenu} + /> + )} +
    + )} + + +
    + + setRollDieOpen(true)} + onRequestConcede={() => setConcedeConfirm('concede')} + onRequestUnconcede={() => setConcedeConfirm('unconcede')} + onRequestGameInfo={() => setGameInfoOpen(true)} + onToggleRotate90={() => setIsRotated((prev) => !prev)} + isRotated={isRotated} + /> + + + + {zoneViews.map((v, idx) => ( + handleCloseZoneView(v.playerId, v.zoneName)} + initialPosition={{ x: 80 + idx * 36, y: 80 + idx * 36 }} + /> + ))} + + setCardMenu(null)} + onRequestSetPT={handleRequestSetPT} + onRequestSetAnnotation={handleRequestSetAnnotation} + onRequestSetCounter={handleRequestSetCardCounter} + onRequestDrawArrow={handleRequestDrawArrow} + onRequestAttach={handleRequestAttach} + onRequestMoveToLibraryAt={handleRequestMoveToLibraryAt} + /> + + setZoneMenu(null)} + onRequestDrawN={handleRequestDrawN} + onRequestDumpN={handleRequestDumpN} + onRequestRevealTopN={handleRequestRevealTopN} + onRequestRevealZone={handleRequestRevealZone} + /> + + setPlayerMenu(null)} + onRequestCreateToken={() => setCreateTokenOpen(true)} + onRequestViewSideboard={() => setSideboardOpen(true)} + /> + + setHandMenu(null)} + onRequestChooseMulligan={handleRequestChooseMulligan} + onRequestRevealHand={handleRequestRevealHand} + onRequestRevealRandom={handleRequestRevealRandom} + /> + + {prompt && ( + setPrompt(null)} + /> + )} + + setRollDieOpen(false)} + /> + + setCreateCounterOpen(false)} + /> + + setCreateTokenOpen(false)} + /> + + setSideboardOpen(false)} + onToggleLock={handleToggleSideboardLock} + /> + + {revealState && ( + setRevealState(null)} + /> + )} + + { + if (gameId != null) { + webClient.request.game.concede(gameId); + } + setConcedeConfirm(null); + }} + onCancel={() => setConcedeConfirm(null)} + /> + + { + if (gameId != null) { + webClient.request.game.unconcede(gameId); + } + setConcedeConfirm(null); + }} + onCancel={() => setConcedeConfirm(null)} + /> + + setGameInfoOpen(false)} + /> +
    + + + {activeCard ? : null} + +
    +
    ); } diff --git a/webclient/src/containers/Login/Login.spec.tsx b/webclient/src/containers/Login/Login.spec.tsx index e2a398066..63c1d475e 100644 --- a/webclient/src/containers/Login/Login.spec.tsx +++ b/webclient/src/containers/Login/Login.spec.tsx @@ -29,10 +29,14 @@ vi.mock('../../hooks/useKnownHosts', () => ({ useKnownHosts: hoisted.useKnownHosts, getKnownHosts: hoisted.getKnownHosts, })); -vi.mock('../../hooks/useWebClient', () => ({ - useWebClient: () => hoisted.mockWebClient, - WebClientProvider: ({ children }: { children: any }) => children, -})); +vi.mock('../../hooks/useWebClient', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useWebClient: () => hoisted.mockWebClient, + WebClientProvider: ({ children }: { children: any }) => children, + }; +}); beforeAll(() => { const client = createMockWebClient(); diff --git a/webclient/src/containers/Room/GameSelector/GameSelector.css b/webclient/src/containers/Room/GameSelector/GameSelector.css new file mode 100644 index 000000000..2bc6c9edb --- /dev/null +++ b/webclient/src/containers/Room/GameSelector/GameSelector.css @@ -0,0 +1,33 @@ +.game-selector { + display: flex; + flex-direction: column; + height: 100%; +} + +.game-selector__title { + padding: 6px 12px; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + font-weight: 600; +} + +.game-selector__games { + flex: 1; + overflow: auto; +} + +.game-selector__toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid rgba(0, 0, 0, 0.12); + flex-wrap: wrap; +} + +.game-selector__toolbar-left, +.game-selector__toolbar-right { + display: flex; + gap: 6px; + flex-wrap: wrap; +} diff --git a/webclient/src/containers/Room/GameSelector/GameSelector.spec.tsx b/webclient/src/containers/Room/GameSelector/GameSelector.spec.tsx new file mode 100644 index 000000000..e473075ac --- /dev/null +++ b/webclient/src/containers/Room/GameSelector/GameSelector.spec.tsx @@ -0,0 +1,196 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { create } from '@bufbuild/protobuf'; +import { + renderWithProviders, + makeStoreState, + makeUser, + connectedWithRoomsState, +} from '../../../__test-utils__'; +import { App, Data } from '@app/types'; +import GameSelector from './GameSelector'; + +const { mockUseWebClient } = vi.hoisted(() => ({ mockUseWebClient: vi.fn() })); +vi.mock('@app/hooks', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useWebClient: mockUseWebClient }; +}); + +function makeRoomEntry(games: Data.ServerInfo_Game[] = [], gametypeMap: Record = {}) { + return { + info: create(Data.ServerInfo_RoomSchema, { roomId: 1, name: 'Main' }), + gametypeMap, + order: 0, + games: Object.fromEntries(games.map((info) => [info.gameId, { info, gameType: '' }])), + users: {}, + }; +} + +function makeGame(overrides: any = {}): Data.ServerInfo_Game { + return create(Data.ServerInfo_GameSchema, { + gameId: 1, + roomId: 1, + description: 'Test', + maxPlayers: 4, + playerCount: 1, + spectatorsAllowed: true, + ...overrides, + }); +} + +function makeWebClient() { + return { + request: { + rooms: { + joinRoom: vi.fn(), + leaveRoom: vi.fn(), + roomSay: vi.fn(), + createGame: vi.fn(), + joinGame: vi.fn(), + }, + }, + } as any; +} + +function buildState( + room: ReturnType, + user = makeUser(), + selectedGameId?: number, +) { + return makeStoreState({ + ...connectedWithRoomsState, + rooms: { + rooms: { 1: room }, + joinedRoomIds: { 1: true }, + joinedGameIds: {}, + messages: { 1: [] }, + sortGamesBy: { field: App.GameSortField.START_TIME, order: App.SortDirection.DESC }, + sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC }, + selectedGameIds: selectedGameId != null ? { 1: selectedGameId } : {}, + gameFilters: {}, + } as any, + server: { + ...(connectedWithRoomsState.server as any), + user, + } as any, + }); +} + +beforeEach(() => { + mockUseWebClient.mockReset(); +}); + +describe('GameSelector', () => { + it('renders the count header from getRoomGameCounts', () => { + mockUseWebClient.mockReturnValue(makeWebClient()); + const room = makeRoomEntry([makeGame({ gameId: 1 }), makeGame({ gameId: 2 })]); + renderWithProviders(, { preloadedState: buildState(room) }); + expect(screen.getByText('Games shown: 2 / 2')).toBeInTheDocument(); + }); + + it('Join button is disabled until a game is selected', () => { + mockUseWebClient.mockReturnValue(makeWebClient()); + const room = makeRoomEntry([makeGame({ gameId: 1 })]); + renderWithProviders(, { preloadedState: buildState(room) }); + expect(screen.getByRole('button', { name: /^Join$/ })).toBeDisabled(); + }); + + it('Join button enabled and dispatches joinGame when a game is selected', () => { + const client = makeWebClient(); + mockUseWebClient.mockReturnValue(client); + const game = makeGame({ gameId: 7, withPassword: false }); + const room = makeRoomEntry([game]); + renderWithProviders(, { + preloadedState: buildState(room, makeUser(), 7), + }); + + const joinBtn = screen.getByRole('button', { name: /^Join$/ }); + expect(joinBtn).not.toBeDisabled(); + fireEvent.click(joinBtn); + + expect(client.request.rooms.joinGame).toHaveBeenCalledTimes(1); + expect(client.request.rooms.joinGame).toHaveBeenCalledWith(1, expect.objectContaining({ + gameId: 7, + spectator: false, + joinAsJudge: false, + password: '', + })); + }); + + it('clicking Join on a password-protected game opens the password prompt before sending', () => { + const client = makeWebClient(); + mockUseWebClient.mockReturnValue(client); + const game = makeGame({ gameId: 8, withPassword: true }); + const room = makeRoomEntry([game]); + renderWithProviders(, { + preloadedState: buildState(room, makeUser(), 8), + }); + + fireEvent.click(screen.getByRole('button', { name: /^Join$/ })); + + expect(client.request.rooms.joinGame).not.toHaveBeenCalled(); + expect(screen.getByText('Password required')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'hunter2' } }); + // The dialog has its own "Join" button — find both and click the last (in dialog). + const joinButtons = screen.getAllByRole('button', { name: /^Join$/ }); + fireEvent.click(joinButtons[joinButtons.length - 1]); + + expect(client.request.rooms.joinGame).toHaveBeenCalledTimes(1); + expect(client.request.rooms.joinGame).toHaveBeenCalledWith(1, expect.objectContaining({ + gameId: 8, + password: 'hunter2', + })); + }); + + it('Spectate button is disabled when spectators are not allowed', () => { + mockUseWebClient.mockReturnValue(makeWebClient()); + const game = makeGame({ gameId: 1, spectatorsAllowed: false }); + const room = makeRoomEntry([game]); + renderWithProviders(, { + preloadedState: buildState(room, makeUser(), 1), + }); + expect(screen.getByRole('button', { name: /Join as Spectator/i })).toBeDisabled(); + }); + + it('Join is disabled when the selected game is full', () => { + mockUseWebClient.mockReturnValue(makeWebClient()); + const game = makeGame({ gameId: 1, playerCount: 4, maxPlayers: 4 }); + const room = makeRoomEntry([game]); + renderWithProviders(, { + preloadedState: buildState(room, makeUser(), 1), + }); + expect(screen.getByRole('button', { name: /^Join$/ })).toBeDisabled(); + }); + + it('judge buttons are hidden when the user is not a judge', () => { + mockUseWebClient.mockReturnValue(makeWebClient()); + const room = makeRoomEntry([]); + renderWithProviders(, { + preloadedState: buildState(room, makeUser({ userLevel: 0 })), + }); + expect(screen.queryByRole('button', { name: /Join as Judge$/i })).not.toBeInTheDocument(); + }); + + it('judge buttons are visible when the user has the IsJudge flag', () => { + mockUseWebClient.mockReturnValue(makeWebClient()); + const room = makeRoomEntry([]); + renderWithProviders(, { + preloadedState: buildState(room, makeUser({ userLevel: Data.ServerInfo_User_UserLevelFlag.IsJudge })), + }); + expect(screen.getByRole('button', { name: /Join as Judge$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Join as Judge Spectator/i })).toBeInTheDocument(); + }); + + it('clicking Create then submitting forwards createGame', () => { + const client = makeWebClient(); + mockUseWebClient.mockReturnValue(client); + const room = makeRoomEntry([]); + renderWithProviders(, { preloadedState: buildState(room) }); + fireEvent.click(screen.getByRole('button', { name: /^Create$/ })); + // The dialog Submit button is the second matching "Create" button + const createButtons = screen.getAllByRole('button', { name: /^Create$/ }); + fireEvent.click(createButtons[createButtons.length - 1]); + expect(client.request.rooms.createGame).toHaveBeenCalledTimes(1); + expect(client.request.rooms.createGame.mock.calls[0][0]).toBe(1); + }); +}); diff --git a/webclient/src/containers/Room/GameSelector/GameSelector.tsx b/webclient/src/containers/Room/GameSelector/GameSelector.tsx new file mode 100644 index 000000000..784ba673c --- /dev/null +++ b/webclient/src/containers/Room/GameSelector/GameSelector.tsx @@ -0,0 +1,152 @@ +import React, { useCallback, useState } from 'react'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; + +import { RoomsDispatch, RoomsSelectors, ServerSelectors, useAppSelector } from '@app/store'; +import { useWebClient } from '@app/hooks'; +import type { App, Enriched } from '@app/types'; +import { CreateGameDialog, FilterGamesDialog, PromptDialog } from '@app/dialogs'; + +import OpenGames from '../OpenGames'; +import GameSelectorToolbar from './GameSelectorToolbar'; + +import './GameSelector.css'; + +interface GameSelectorProps { + room: Enriched.Room; +} + +interface PendingJoin { + gameId: number; + asSpectator: boolean; + asJudge: boolean; +} + +const GameSelector = ({ room }: GameSelectorProps) => { + const roomId = room.info.roomId; + const webClient = useWebClient(); + + const selectedGameId = useAppSelector((state) => RoomsSelectors.getSelectedGameId(state, roomId)); + const selectedGame = useAppSelector((state) => + selectedGameId != null ? RoomsSelectors.getRoomGames(state, roomId)[selectedGameId] : undefined, + ); + const counts = useAppSelector((state) => RoomsSelectors.getRoomGameCounts(state, roomId)); + const isFilterActive = useAppSelector((state) => RoomsSelectors.isGameFilterActive(state, roomId)); + const filters = useAppSelector((state) => RoomsSelectors.getGameFilters(state, roomId)); + const isJudgeUser = useAppSelector(ServerSelectors.getIsUserJudge); + + const [createOpen, setCreateOpen] = useState(false); + const [filterOpen, setFilterOpen] = useState(false); + const [pendingJoin, setPendingJoin] = useState(null); + + const sendJoin = useCallback( + (gameId: number, asSpectator: boolean, asJudge: boolean, password: string) => { + const params: App.JoinGameParams = { + gameId, + password, + spectator: asSpectator, + overrideRestrictions: false, + joinAsJudge: asJudge, + }; + webClient.request.rooms.joinGame(roomId, params); + }, + [roomId, webClient], + ); + + const beginJoin = useCallback( + (asSpectator: boolean, asJudge: boolean) => { + const game = selectedGame; + if (!game) { + return; + } + const info = game.info; + const effectiveSpectator = + asSpectator || info.playerCount >= info.maxPlayers; + const needsPassword = + info.withPassword && !(effectiveSpectator && !info.spectatorsNeedPassword); + if (needsPassword) { + setPendingJoin({ gameId: info.gameId, asSpectator: effectiveSpectator, asJudge }); + return; + } + sendJoin(info.gameId, effectiveSpectator, asJudge, ''); + }, + [selectedGame, sendJoin], + ); + + const handleActivate = useCallback( + (_gameId: number) => { + beginJoin(false, false); + }, + [beginJoin], + ); + + const canJoin = Boolean(selectedGame && selectedGame.info.playerCount < selectedGame.info.maxPlayers); + const canSpectate = Boolean(selectedGame && selectedGame.info.spectatorsAllowed); + + const handleCreateSubmit = (params: App.CreateGameParams) => { + webClient.request.rooms.createGame(roomId, params); + setCreateOpen(false); + }; + + const handleFilterSubmit = (next) => { + RoomsDispatch.setGameFilters(roomId, next); + setFilterOpen(false); + }; + + const handlePasswordSubmit = (password: string) => { + if (!pendingJoin) { + return; + } + sendJoin(pendingJoin.gameId, pendingJoin.asSpectator, pendingJoin.asJudge, password); + setPendingJoin(null); + }; + + return ( + + + Games shown: {counts.visible} / {counts.total} + +
    + +
    + setFilterOpen(true)} + onClearFilter={() => RoomsDispatch.clearGameFilters(roomId)} + onCreate={() => setCreateOpen(true)} + onJoin={() => beginJoin(false, false)} + onSpectate={() => beginJoin(true, false)} + onJoinAsJudge={() => beginJoin(false, true)} + onSpectateAsJudge={() => beginJoin(true, true)} + /> + + setCreateOpen(false)} + onSubmit={handleCreateSubmit} + /> + setFilterOpen(false)} + onSubmit={handleFilterSubmit} + /> + setPendingJoin(null)} + /> +
    + ); +}; + +export default GameSelector; diff --git a/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.spec.tsx b/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.spec.tsx new file mode 100644 index 000000000..e317dbc85 --- /dev/null +++ b/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.spec.tsx @@ -0,0 +1,91 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { renderWithProviders } from '../../../__test-utils__'; +import GameSelectorToolbar, { GameSelectorToolbarProps } from './GameSelectorToolbar'; + +function defaultProps(overrides: Partial = {}): GameSelectorToolbarProps { + return { + isFilterActive: false, + canCreate: true, + canJoin: true, + canSpectate: true, + isJudgeUser: false, + onFilter: vi.fn(), + onClearFilter: vi.fn(), + onCreate: vi.fn(), + onJoin: vi.fn(), + onSpectate: vi.fn(), + onJoinAsJudge: vi.fn(), + onSpectateAsJudge: vi.fn(), + ...overrides, + }; +} + +describe('GameSelectorToolbar', () => { + it('renders the five always-visible buttons', () => { + renderWithProviders(); + expect(screen.getByRole('button', { name: /Filter games/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Clear filter/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Create$/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Join$/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Join as Spectator/i })).toBeInTheDocument(); + }); + + it('hides the two judge buttons when isJudgeUser is false', () => { + renderWithProviders(); + expect(screen.queryByRole('button', { name: /Join as Judge$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Join as Judge Spectator/i })).not.toBeInTheDocument(); + }); + + it('shows the two judge buttons when isJudgeUser is true', () => { + renderWithProviders(); + expect(screen.getByRole('button', { name: /Join as Judge$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Join as Judge Spectator/i })).toBeInTheDocument(); + }); + + it('disables Clear filter when no filter is active', () => { + renderWithProviders(); + expect(screen.getByRole('button', { name: /Clear filter/i })).toBeDisabled(); + }); + + it('enables Clear filter when a filter is active', () => { + renderWithProviders(); + expect(screen.getByRole('button', { name: /Clear filter/i })).not.toBeDisabled(); + }); + + it('disables Join when canJoin is false', () => { + renderWithProviders(); + expect(screen.getByRole('button', { name: /^Join$/ })).toBeDisabled(); + }); + + it('disables Join as Spectator when canSpectate is false', () => { + renderWithProviders(); + expect(screen.getByRole('button', { name: /Join as Spectator/i })).toBeDisabled(); + }); + + it('judge buttons inherit canJoin / canSpectate gating', () => { + renderWithProviders( + , + ); + expect(screen.getByRole('button', { name: /Join as Judge$/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Join as Judge Spectator/i })).toBeDisabled(); + }); + + it('invokes the corresponding callback for each button', () => { + const props = defaultProps({ isJudgeUser: true, isFilterActive: true }); + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: /Filter games/i })); + fireEvent.click(screen.getByRole('button', { name: /Clear filter/i })); + fireEvent.click(screen.getByRole('button', { name: /^Create$/ })); + fireEvent.click(screen.getByRole('button', { name: /^Join$/ })); + fireEvent.click(screen.getByRole('button', { name: /Join as Spectator/i })); + fireEvent.click(screen.getByRole('button', { name: /Join as Judge$/i })); + fireEvent.click(screen.getByRole('button', { name: /Join as Judge Spectator/i })); + expect(props.onFilter).toHaveBeenCalledTimes(1); + expect(props.onClearFilter).toHaveBeenCalledTimes(1); + expect(props.onCreate).toHaveBeenCalledTimes(1); + expect(props.onJoin).toHaveBeenCalledTimes(1); + expect(props.onSpectate).toHaveBeenCalledTimes(1); + expect(props.onJoinAsJudge).toHaveBeenCalledTimes(1); + expect(props.onSpectateAsJudge).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.tsx b/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.tsx new file mode 100644 index 000000000..4bf0791d3 --- /dev/null +++ b/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import Button from '@mui/material/Button'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import FilterListOffIcon from '@mui/icons-material/FilterListOff'; + +export interface GameSelectorToolbarProps { + isFilterActive: boolean; + canCreate: boolean; + canJoin: boolean; + canSpectate: boolean; + isJudgeUser: boolean; + onFilter: () => void; + onClearFilter: () => void; + onCreate: () => void; + onJoin: () => void; + onSpectate: () => void; + onJoinAsJudge: () => void; + onSpectateAsJudge: () => void; +} + +const GameSelectorToolbar = (props: GameSelectorToolbarProps) => { + const { + isFilterActive, + canCreate, + canJoin, + canSpectate, + isJudgeUser, + onFilter, + onClearFilter, + onCreate, + onJoin, + onSpectate, + onJoinAsJudge, + onSpectateAsJudge, + } = props; + + return ( +
    +
    + + +
    +
    + + + + {isJudgeUser && ( + <> + + + + )} +
    +
    + ); +}; + +export default GameSelectorToolbar; diff --git a/webclient/src/containers/Room/OpenGames.css b/webclient/src/containers/Room/OpenGames.css index 623ab47f5..6300906cd 100644 --- a/webclient/src/containers/Room/OpenGames.css +++ b/webclient/src/containers/Room/OpenGames.css @@ -1,8 +1,7 @@ .games { } -.games-header, -.game { +.games-header { display: flex; padding: 10px; border-bottom: 1px solid black; @@ -28,3 +27,7 @@ .game__detail.creator { width: 20%; } + +.games__row { + cursor: pointer; +} diff --git a/webclient/src/containers/Room/OpenGames.tsx b/webclient/src/containers/Room/OpenGames.tsx index 59e721aa1..34699be50 100644 --- a/webclient/src/containers/Room/OpenGames.tsx +++ b/webclient/src/containers/Room/OpenGames.tsx @@ -8,23 +8,57 @@ import TableRow from '@mui/material/TableRow'; import TableSortLabel from '@mui/material/TableSortLabel'; import Tooltip from '@mui/material/Tooltip'; -// import { RoomsService } from "AppShell/common/services"; - import { SortUtil, RoomsDispatch, RoomsSelectors } from '@app/store'; import { UserDisplay } from '@app/components'; import { useAppSelector } from '@app/store'; +import { Data, Enriched } from '@app/types'; import './OpenGames.css'; -// @TODO run interval to update timeSinceCreated interface OpenGamesProps { - room: any; + room: { info: { roomId: number } }; + onActivateGame?: (gameId: number) => void; } -const OpenGames = ({ room }: OpenGamesProps) => { +function formatRestrictions(info: Data.ServerInfo_Game): string { + const parts: string[] = []; + if (info.withPassword) { + parts.push('password'); + } + if (info.onlyBuddies) { + parts.push('buddies only'); + } + if (info.onlyRegistered) { + parts.push('reg. users only'); + } + if (info.shareDecklistsOnLoad) { + parts.push('open decklists'); + } + return parts.join(', '); +} + +function formatSpectators(info: Data.ServerInfo_Game): string { + if (!info.spectatorsAllowed) { + return 'not allowed'; + } + const flags: string[] = []; + if (info.spectatorsCanChat) { + flags.push('can chat'); + } + if (info.spectatorsOmniscient) { + flags.push('see hands'); + } + if (flags.length === 0) { + return String(info.spectatorsCount); + } + return `${info.spectatorsCount} (${flags.join(' & ')})`; +} + +const OpenGames = ({ room, onActivateGame }: OpenGamesProps) => { const roomId = room.info.roomId; const sortBy = useAppSelector(state => RoomsSelectors.getSortGamesBy(state)); - const sortedGames = useAppSelector(state => RoomsSelectors.getSortedRoomGames(state, roomId)); + const games = useAppSelector(state => RoomsSelectors.getFilteredRoomGames(state, roomId)); + const selectedGameId = useAppSelector(state => RoomsSelectors.getSelectedGameId(state, roomId)); const headerCells = [ { label: 'Age', field: 'info.startTime' }, @@ -41,18 +75,14 @@ const OpenGames = ({ room }: OpenGamesProps) => { RoomsDispatch.sortGames(roomId, field, order); }; - const isAvailable = ({ started, maxPlayers, playerCount }) => - !started && playerCount < maxPlayers; + const handleSelect = (gameId: number) => { + RoomsDispatch.selectGame(roomId, gameId); + }; - const isOpen = ({ withPassword }) => !withPassword; - - const isPublic = ({ onlyBuddies }) => !onlyBuddies; - - const games = sortedGames.filter(game => ( - isAvailable(game.info) && - isOpen(game.info) && - isPublic(game.info) - )); + const handleActivate = (gameId: number) => { + RoomsDispatch.selectGame(roomId, gameId); + onActivateGame?.(gameId); + }; return (
    @@ -81,11 +111,21 @@ const OpenGames = ({ room }: OpenGamesProps) => { - { games.map((game) => { + { games.map((game: Enriched.Game) => { const { info, gameType } = game; - const { description, gameId, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime } = info; + const { description, gameId, creatorInfo, maxPlayers, playerCount, startTime } = info; + const isSelected = gameId === selectedGameId; + const restrictions = formatRestrictions(info); + const spectators = formatSpectators(info); return ( - + handleSelect(gameId)} + onDoubleClick={() => handleActivate(gameId)} + className={isSelected ? 'games__row games__row--selected' : 'games__row'} + > {startTime} @@ -95,12 +135,12 @@ const OpenGames = ({ room }: OpenGamesProps) => { - + {creatorInfo ? : null} {gameType} - ? + {restrictions} {`${playerCount}/${maxPlayers}`} - {spectatorsCount} + {spectators} ); })} diff --git a/webclient/src/containers/Room/Room.tsx b/webclient/src/containers/Room/Room.tsx index 2da6b2871..fdde6803c 100644 --- a/webclient/src/containers/Room/Room.tsx +++ b/webclient/src/containers/Room/Room.tsx @@ -11,7 +11,7 @@ import { useAppSelector } from '@app/store'; import { App } from '@app/types'; import Layout from '../Layout/Layout'; -import OpenGames from './OpenGames'; +import GameSelector from './GameSelector/GameSelector'; import Messages from './Messages'; import SayMessage from './SayMessage'; @@ -55,9 +55,9 @@ const Room = () => { fixedHeight top={( - - - +
    + +
    )} bottom={( diff --git a/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.css b/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.css new file mode 100644 index 000000000..a1de822cb --- /dev/null +++ b/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.css @@ -0,0 +1,3 @@ +.confirm-dialog__body { + min-width: 320px; +} diff --git a/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.spec.tsx b/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.spec.tsx new file mode 100644 index 000000000..4b9587750 --- /dev/null +++ b/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.spec.tsx @@ -0,0 +1,69 @@ +import { screen, fireEvent } from '@testing-library/react'; + +import { renderWithProviders } from '../../__test-utils__'; +import ConfirmDialog from './ConfirmDialog'; + +describe('ConfirmDialog', () => { + it('renders the title, message, and default confirm/cancel labels', () => { + renderWithProviders( + {}} + onCancel={() => {}} + />, + ); + + expect(screen.getByText('Concede this game?')).toBeInTheDocument(); + expect(screen.getByText(/can't be undone/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('fires onConfirm when the confirm button is clicked', () => { + const onConfirm = vi.fn(); + renderWithProviders( + {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: /concede/i })); + expect(onConfirm).toHaveBeenCalled(); + }); + + it('fires onCancel when the cancel button is clicked', () => { + const onCancel = vi.fn(); + renderWithProviders( + {}} + onCancel={onCancel} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onCancel).toHaveBeenCalled(); + }); + + it('does not render when closed', () => { + renderWithProviders( + {}} + onCancel={() => {}} + />, + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.tsx b/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.tsx new file mode 100644 index 000000000..0c3cc5735 --- /dev/null +++ b/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.tsx @@ -0,0 +1,84 @@ +import { styled } from '@mui/material/styles'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; + +import './ConfirmDialog.css'; + +const PREFIX = 'ConfirmDialog'; + +const classes = { + root: `${PREFIX}-root`, +}; + +const StyledDialog = styled(Dialog)(({ theme }) => ({ + [`&.${classes.root}`]: { + '& .dialog-title__wrapper': { + borderColor: theme.palette.grey[300], + }, + }, +})); + +export interface ConfirmDialogProps { + isOpen: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + /** Marks the confirm button as destructive (red). */ + destructive?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * Generic confirm-before-action dialog. Mirrors desktop's QMessageBox + * question pattern used for destructive actions (concede, kick, etc. + * see cockatrice/src/interface/widgets/tabs/tab_game.cpp:487-496). + */ +function ConfirmDialog({ + isOpen, + title, + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + destructive = false, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + return ( + + +
    + {title} +
    +
    + + {message} + + + + + +
    + ); +} + +export default ConfirmDialog; diff --git a/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.css b/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.css new file mode 100644 index 000000000..5f7286f46 --- /dev/null +++ b/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.css @@ -0,0 +1,30 @@ +.CreateCounterDialog .MuiDialog-paper { + width: 400px; + max-width: 400px; +} + +.create-counter-dialog__swatches { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.create-counter-dialog__swatch { + width: 34px; + height: 34px; + border-radius: 50%; + border: 2px solid #c4c9d1; + cursor: pointer; + padding: 0; +} + +.create-counter-dialog__swatch:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(90, 120, 200, 0.35); +} + +.create-counter-dialog__swatch--selected { + border-color: #2f4a8a; + box-shadow: 0 0 0 2px #8ab0ff; +} diff --git a/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.spec.tsx b/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.spec.tsx new file mode 100644 index 000000000..38e0e42ed --- /dev/null +++ b/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.spec.tsx @@ -0,0 +1,83 @@ +import { render, screen, fireEvent } from '@testing-library/react'; + +import CreateCounterDialog from './CreateCounterDialog'; + +describe('CreateCounterDialog', () => { + it('does not render when closed', () => { + render( + {}} onCancel={() => {}} />, + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders the name input and 8 color swatches', () => { + render( {}} onCancel={() => {}} />); + + expect(screen.getByLabelText('Counter name')).toBeInTheDocument(); + expect(screen.getAllByRole('radio')).toHaveLength(8); + }); + + it('pre-selects the first swatch', () => { + render( {}} onCancel={() => {}} />); + + const radios = screen.getAllByRole('radio'); + expect(radios[0]).toHaveAttribute('aria-checked', 'true'); + radios.slice(1).forEach((r) => expect(r).toHaveAttribute('aria-checked', 'false')); + }); + + it('changes selection when a different swatch is clicked', () => { + render( {}} onCancel={() => {}} />); + + const red = screen.getByLabelText('Red'); + fireEvent.click(red); + expect(red).toHaveAttribute('aria-checked', 'true'); + }); + + it('requires a non-empty name', () => { + const onSubmit = vi.fn(); + render( {}} />); + + fireEvent.click(screen.getByRole('button', { name: /^create$/i })); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByText(/name is required/i)).toBeInTheDocument(); + }); + + it('dispatches onSubmit with the trimmed name and selected color', () => { + const onSubmit = vi.fn(); + render( {}} />); + + fireEvent.change(screen.getByLabelText('Counter name'), { + target: { value: ' Poison ' }, + }); + fireEvent.click(screen.getByLabelText('Green')); + fireEvent.click(screen.getByRole('button', { name: /^create$/i })); + + expect(onSubmit).toHaveBeenCalledWith({ + name: 'Poison', + color: { r: 61, g: 162, b: 107, a: 255 }, + }); + }); + + it('resets state when the dialog reopens', () => { + const { rerender } = render( + {}} onCancel={() => {}} />, + ); + + fireEvent.change(screen.getByLabelText('Counter name'), { target: { value: 'stale' } }); + fireEvent.click(screen.getByLabelText('Red')); + + rerender( {}} onCancel={() => {}} />); + rerender( {}} onCancel={() => {}} />); + + expect((screen.getByLabelText('Counter name') as HTMLInputElement).value).toBe(''); + expect(screen.getAllByRole('radio')[0]).toHaveAttribute('aria-checked', 'true'); + }); + + it('dispatches onCancel on Cancel', () => { + const onCancel = vi.fn(); + render( {}} onCancel={onCancel} />); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onCancel).toHaveBeenCalled(); + }); +}); diff --git a/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.tsx b/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.tsx new file mode 100644 index 000000000..03a13e510 --- /dev/null +++ b/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from 'react'; +import { styled } from '@mui/material/styles'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; + +import { App } from '@app/types'; +import { cx } from '@app/utils'; + +import './CreateCounterDialog.css'; + +const PREFIX = 'CreateCounterDialog'; + +const classes = { + root: `${PREFIX}-root`, +}; + +const StyledDialog = styled(Dialog)(({ theme }) => ({ + [`&.${classes.root}`]: { + '& .dialog-title__wrapper': { + borderColor: theme.palette.grey[300], + }, + }, +})); + +export interface CounterColor { + r: number; + g: number; + b: number; + a: number; +} + +export interface CreateCounterDialogProps { + isOpen: boolean; + onSubmit: (args: { name: string; color: CounterColor }) => void; + onCancel: () => void; +} + +interface Swatch { + label: string; + color: CounterColor; + css: string; +} + +const SWATCHES: ReadonlyArray = [ + { label: 'White', color: { r: 249, g: 248, b: 217, a: 255 }, css: '#f9f8d9' }, + { label: 'Blue', color: App.ArrowColor.BLUE, css: '#89b8e0' }, + { label: 'Black', color: { r: 60, g: 60, b: 60, a: 255 }, css: '#3c3c3c' }, + { label: 'Red', color: App.ArrowColor.RED, css: '#e04b3b' }, + { label: 'Green', color: App.ArrowColor.GREEN, css: '#3da26b' }, + { label: 'Yellow', color: App.ArrowColor.YELLOW, css: '#f0c83c' }, + { label: 'Purple', color: { r: 148, g: 90, b: 200, a: 255 }, css: '#945ac8' }, + { label: 'Gray', color: { r: 160, g: 160, b: 168, a: 255 }, css: '#a0a0a8' }, +]; + +function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialogProps) { + const [name, setName] = useState(''); + const [selectedIdx, setSelectedIdx] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + if (isOpen) { + setName(''); + setSelectedIdx(0); + setError(null); + } + }, [isOpen]); + + const handleSubmit = (e?: React.FormEvent) => { + e?.preventDefault(); + if (name.trim().length === 0) { + setError('Name is required'); + return; + } + onSubmit({ name: name.trim(), color: SWATCHES[selectedIdx].color }); + }; + + return ( + + +
    + New counter +
    +
    +
    + + { + setName(e.target.value); + if (error) { + setError(null); + } + }} + error={error != null} + helperText={error ?? ''} + slotProps={{ htmlInput: { 'aria-label': 'Counter name' } }} + /> +
    + {SWATCHES.map((s, idx) => ( +
    +
    + + + + +
    +
    + ); +} + +export default CreateCounterDialog; diff --git a/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.css b/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.css new file mode 100644 index 000000000..f162ed0f3 --- /dev/null +++ b/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.css @@ -0,0 +1,11 @@ +.create-game-dialog__body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.create-game-dialog__section { + display: flex; + flex-direction: column; + margin-top: 8px; +} diff --git a/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.spec.tsx b/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.spec.tsx new file mode 100644 index 000000000..66e3099d7 --- /dev/null +++ b/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.spec.tsx @@ -0,0 +1,93 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { renderWithProviders, makeStoreState, makeUser } from '../../__test-utils__'; +import { Data } from '@app/types'; +import CreateGameDialog from './CreateGameDialog'; + +function renderDialog(opts: { isJudge?: boolean; isRegistered?: boolean; gametypeMap?: Record } = {}) { + const userLevel = + (opts.isRegistered ? Data.ServerInfo_User_UserLevelFlag.IsRegistered : 0) | + (opts.isJudge ? Data.ServerInfo_User_UserLevelFlag.IsJudge : 0); + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + renderWithProviders( + , + { + preloadedState: makeStoreState({ + server: { + user: makeUser({ userLevel }), + } as any, + }), + }, + ); + return { onSubmit, onCancel }; +} + +describe('CreateGameDialog', () => { + it('hides the "Create as judge" checkbox for non-judge users', () => { + renderDialog({ isJudge: false }); + expect(screen.queryByLabelText(/Create as judge/i)).not.toBeInTheDocument(); + }); + + it('shows the "Create as judge" checkbox for judges', () => { + renderDialog({ isJudge: true }); + expect(screen.getByLabelText(/Create as judge/i)).toBeInTheDocument(); + }); + + it('disables spectator sub-options when "Allow spectators" is unchecked', () => { + renderDialog(); + fireEvent.click(screen.getByLabelText(/Allow spectators/i)); + expect(screen.getByLabelText(/Spectators need password/i)).toBeDisabled(); + expect(screen.getByLabelText(/Spectators can chat/i)).toBeDisabled(); + expect(screen.getByLabelText(/Spectators see everything/i)).toBeDisabled(); + }); + + it('submits desktop-default values for an unedited form', () => { + const { onSubmit } = renderDialog({ isRegistered: true }); + fireEvent.click(screen.getByRole('button', { name: /^Create$/ })); + expect(onSubmit).toHaveBeenCalledTimes(1); + const params = onSubmit.mock.calls[0][0]; + expect(params).toMatchObject({ + description: '', + password: '', + maxPlayers: 2, + onlyBuddies: false, + onlyRegistered: true, + spectatorsAllowed: true, + spectatorsNeedPassword: false, + spectatorsCanTalk: false, + spectatorsSeeEverything: false, + joinAsSpectator: false, + startingLifeTotal: 20, + shareDecklistsOnLoad: false, + joinAsJudge: false, + gameTypeIds: [], + }); + }); + + it('forwards updated description and max players to onSubmit', () => { + const { onSubmit } = renderDialog(); + fireEvent.change(screen.getByLabelText(/Description/i), { target: { value: 'Friday Casual' } }); + fireEvent.change(screen.getByLabelText(/Max players/i), { target: { value: '4' } }); + fireEvent.click(screen.getByRole('button', { name: /^Create$/ })); + const params = onSubmit.mock.calls[0][0]; + expect(params.description).toBe('Friday Casual'); + expect(params.maxPlayers).toBe(4); + }); + + it('renders a radio per available game type', () => { + renderDialog({ gametypeMap: { 0: 'Constructed', 1: 'Limited' } }); + expect(screen.getByLabelText('Constructed')).toBeInTheDocument(); + expect(screen.getByLabelText('Limited')).toBeInTheDocument(); + }); + + it('Cancel calls onCancel', () => { + const { onCancel } = renderDialog(); + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.tsx b/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.tsx new file mode 100644 index 000000000..21920aa2c --- /dev/null +++ b/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.tsx @@ -0,0 +1,281 @@ +import { useEffect, useMemo, useState } from 'react'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Radio from '@mui/material/Radio'; +import RadioGroup from '@mui/material/RadioGroup'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; + +import { ServerSelectors, useAppSelector } from '@app/store'; +import type { App, Enriched } from '@app/types'; + +import './CreateGameDialog.css'; + +export interface CreateGameDialogProps { + isOpen: boolean; + gametypeMap: Enriched.GametypeMap; + onCancel: () => void; + onSubmit: (params: App.CreateGameParams) => void; +} + +const DEFAULT_MAX_PLAYERS = 2; +const DEFAULT_STARTING_LIFE = 20; + +interface FormState { + description: string; + password: string; + maxPlayers: number; + onlyBuddies: boolean; + onlyRegistered: boolean; + spectatorsAllowed: boolean; + spectatorsNeedPassword: boolean; + spectatorsCanTalk: boolean; + spectatorsSeeEverything: boolean; + joinAsSpectator: boolean; + startingLifeTotal: number; + shareDecklistsOnLoad: boolean; + joinAsJudge: boolean; + gameTypeId: number | null; +} + +function initialFormState(isRegistered: boolean): FormState { + return { + description: '', + password: '', + maxPlayers: DEFAULT_MAX_PLAYERS, + onlyBuddies: false, + onlyRegistered: isRegistered, + spectatorsAllowed: true, + spectatorsNeedPassword: false, + spectatorsCanTalk: false, + spectatorsSeeEverything: false, + joinAsSpectator: false, + startingLifeTotal: DEFAULT_STARTING_LIFE, + shareDecklistsOnLoad: false, + joinAsJudge: false, + gameTypeId: null, + }; +} + +function CreateGameDialog({ isOpen, gametypeMap, onCancel, onSubmit }: CreateGameDialogProps) { + const isRegistered = useAppSelector(ServerSelectors.getIsUserRegistered); + const isJudge = useAppSelector(ServerSelectors.getIsUserJudge); + + const gameTypes = useMemo(() => { + return Object.entries(gametypeMap).map(([id, name]) => ({ id: Number(id), name })); + }, [gametypeMap]); + + const [form, setForm] = useState(() => initialFormState(isRegistered)); + + useEffect(() => { + if (isOpen) { + setForm(initialFormState(isRegistered)); + } + }, [isOpen, isRegistered]); + + const update = (key: K, value: FormState[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const params: App.CreateGameParams = { + description: form.description, + password: form.password, + maxPlayers: form.maxPlayers, + onlyBuddies: form.onlyBuddies, + onlyRegistered: form.onlyRegistered, + spectatorsAllowed: form.spectatorsAllowed, + spectatorsNeedPassword: form.spectatorsAllowed && form.spectatorsNeedPassword, + spectatorsCanTalk: form.spectatorsAllowed && form.spectatorsCanTalk, + spectatorsSeeEverything: form.spectatorsAllowed && form.spectatorsSeeEverything, + gameTypeIds: form.gameTypeId != null ? [form.gameTypeId] : [], + joinAsJudge: isJudge && form.joinAsJudge, + joinAsSpectator: form.joinAsSpectator, + startingLifeTotal: form.startingLifeTotal, + shareDecklistsOnLoad: form.shareDecklistsOnLoad, + }; + onSubmit(params); + }; + + return ( + + Create Game +
    + + update('description', e.target.value)} + /> + update('password', e.target.value)} + /> + update('maxPlayers', Number(e.target.value))} + /> + update('startingLifeTotal', Number(e.target.value))} + /> + + {gameTypes.length > 0 && ( +
    + Game type + update('gameTypeId', value === '' ? null : Number(value))} + > + {gameTypes.map(({ id, name }) => ( + } label={name} /> + ))} + +
    + )} + +
    + Permissions + update('onlyBuddies', c)} + disabled={!isRegistered} + /> + } + label="Only buddies" + /> + update('onlyRegistered', c)} + /> + } + label="Only registered users" + /> +
    + +
    + Spectators + update('spectatorsAllowed', c)} + /> + } + label="Allow spectators" + /> + update('spectatorsNeedPassword', c)} + disabled={!form.spectatorsAllowed} + /> + } + label="Spectators need password" + /> + update('spectatorsCanTalk', c)} + disabled={!form.spectatorsAllowed} + /> + } + label="Spectators can chat" + /> + update('spectatorsSeeEverything', c)} + disabled={!form.spectatorsAllowed} + /> + } + label="Spectators see everything" + /> + update('joinAsSpectator', c)} + /> + } + label="Create as spectator" + /> +
    + +
    + Other + update('shareDecklistsOnLoad', c)} + /> + } + label="Share decklists on load" + /> + {isJudge && ( + update('joinAsJudge', c)} + /> + } + label="Create as judge" + /> + )} +
    +
    + + + + +
    +
    + ); +} + +export default CreateGameDialog; diff --git a/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.css b/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.css new file mode 100644 index 000000000..f3929ba57 --- /dev/null +++ b/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.css @@ -0,0 +1,6 @@ +.create-token-dialog__body { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 320px; +} diff --git a/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.spec.tsx b/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.spec.tsx new file mode 100644 index 000000000..6b36f7601 --- /dev/null +++ b/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.spec.tsx @@ -0,0 +1,104 @@ +import { screen, fireEvent } from '@testing-library/react'; + +import { renderWithProviders } from '../../__test-utils__'; +import CreateTokenDialog from './CreateTokenDialog'; + +describe('CreateTokenDialog', () => { + it('submits the trimmed name, selected color, P/T, annotation, and flags', () => { + const onSubmit = vi.fn(); + renderWithProviders( + {}} />, + ); + + fireEvent.change(screen.getByLabelText('Token name'), { + target: { value: ' Goblin ' }, + }); + fireEvent.change(screen.getByLabelText('Token power/toughness'), { + target: { value: '1/1' }, + }); + fireEvent.change(screen.getByLabelText('Token annotation'), { + target: { value: 'ETB' }, + }); + fireEvent.click(screen.getByRole('button', { name: /create/i })); + + // Default color is White ('w') to match desktop DlgCreateToken default. + expect(onSubmit).toHaveBeenCalledWith({ + name: 'Goblin', + color: 'w', + pt: '1/1', + annotation: 'ETB', + destroyOnZoneChange: true, + faceDown: false, + }); + }); + + it('requires a non-empty name', () => { + const onSubmit = vi.fn(); + renderWithProviders( + {}} />, + ); + + fireEvent.click(screen.getByRole('button', { name: /create/i })); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByText(/name is required/i)).toBeInTheDocument(); + }); + + it('caps the name input at the desktop max (255 chars)', () => { + renderWithProviders( + {}} onCancel={() => {}} />, + ); + + const input = screen.getByLabelText('Token name') as HTMLInputElement; + const longInput = 'x'.repeat(300); + fireEvent.change(input, { target: { value: longInput } }); + + expect(input.value.length).toBeLessThanOrEqual(255); + }); + + it('toggles the destroyOnZoneChange checkbox off when unchecked', () => { + const onSubmit = vi.fn(); + renderWithProviders( + {}} />, + ); + + fireEvent.change(screen.getByLabelText('Token name'), { + target: { value: 'Persistent' }, + }); + fireEvent.click( + screen.getByRole('checkbox', { name: /destroy when it leaves the table/i }), + ); + fireEvent.click(screen.getByRole('button', { name: /create/i })); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ destroyOnZoneChange: false }), + ); + }); + + it('fires onCancel when Cancel is clicked', () => { + const onCancel = vi.fn(); + renderWithProviders( + {}} onCancel={onCancel} />, + ); + + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + + expect(onCancel).toHaveBeenCalled(); + }); + + it('resets form state when reopened', () => { + const { rerender } = renderWithProviders( + {}} onCancel={() => {}} />, + ); + + const input = screen.getByLabelText('Token name') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'temp' } }); + expect(input.value).toBe('temp'); + + rerender( {}} onCancel={() => {}} />); + rerender( {}} onCancel={() => {}} />); + + const freshInput = screen.getByLabelText('Token name') as HTMLInputElement; + expect(freshInput.value).toBe(''); + }); +}); diff --git a/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.tsx b/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.tsx new file mode 100644 index 000000000..e11ef2506 --- /dev/null +++ b/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from 'react'; +import { styled } from '@mui/material/styles'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import InputLabel from '@mui/material/InputLabel'; +import FormControl from '@mui/material/FormControl'; + +import './CreateTokenDialog.css'; + +const PREFIX = 'CreateTokenDialog'; + +const classes = { + root: `${PREFIX}-root`, +}; + +const StyledDialog = styled(Dialog)(({ theme }) => ({ + [`&.${classes.root}`]: { + '& .dialog-title__wrapper': { + borderColor: theme.palette.grey[300], + }, + }, +})); + +export interface CreateTokenSubmit { + name: string; + color: string; + pt: string; + annotation: string; + destroyOnZoneChange: boolean; + faceDown: boolean; +} + +export interface CreateTokenDialogProps { + isOpen: boolean; + onSubmit: (args: CreateTokenSubmit) => void; + onCancel: () => void; +} + +// Matches desktop DlgCreateToken color dropdown values. Desktop orders +// White → Blue → Black → Red → Green → Multicolor → Colorless and defaults +// to White; we mirror both. +const COLOR_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [ + { value: 'w', label: 'White' }, + { value: 'u', label: 'Blue' }, + { value: 'b', label: 'Black' }, + { value: 'r', label: 'Red' }, + { value: 'g', label: 'Green' }, + { value: 'm', label: 'Multicolor' }, + { value: '', label: 'Colorless' }, +]; + +const DEFAULT_COLOR = 'w'; + +// Desktop server-side MAX_NAME_LENGTH is 0xff (255). Client-side caps on +// the other free-text fields mirror that to prevent oversize-payload +// round-trips that the server would reject anyway. +const MAX_NAME_LEN = 255; +const MAX_PT_LEN = 255; +const MAX_ANNOTATION_LEN = 255; + +function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProps) { + const [name, setName] = useState(''); + const [color, setColor] = useState(DEFAULT_COLOR); + const [pt, setPT] = useState(''); + const [annotation, setAnnotation] = useState(''); + const [destroyOnZoneChange, setDestroyOnZoneChange] = useState(true); + const [faceDown, setFaceDown] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (isOpen) { + setName(''); + setColor(DEFAULT_COLOR); + setPT(''); + setAnnotation(''); + setDestroyOnZoneChange(true); + setFaceDown(false); + setError(null); + } + }, [isOpen]); + + const handleSubmit = (e?: React.FormEvent) => { + e?.preventDefault(); + if (name.trim().length === 0) { + setError('Name is required'); + return; + } + onSubmit({ + name: name.trim(), + color, + pt: pt.trim(), + annotation: annotation.trim(), + destroyOnZoneChange, + faceDown, + }); + }; + + return ( + + +
    + Create token +
    +
    +
    + + { + setName(e.target.value.slice(0, MAX_NAME_LEN)); + if (error) { + setError(null); + } + }} + error={error != null} + helperText={error ?? ''} + slotProps={{ htmlInput: { 'aria-label': 'Token name', maxLength: MAX_NAME_LEN } }} + /> + + Color + + + setPT(e.target.value.slice(0, MAX_PT_LEN))} + disabled={faceDown} + slotProps={{ htmlInput: { 'aria-label': 'Token power/toughness', maxLength: MAX_PT_LEN } }} + /> + setAnnotation(e.target.value.slice(0, MAX_ANNOTATION_LEN))} + slotProps={{ htmlInput: { 'aria-label': 'Token annotation', maxLength: MAX_ANNOTATION_LEN } }} + /> + setDestroyOnZoneChange(e.target.checked)} + slotProps={{ input: { 'aria-label': 'Destroy when it leaves the table' } }} + /> + } + label="Destroy when it leaves the table" + /> + setFaceDown(e.target.checked)} + slotProps={{ input: { 'aria-label': 'Create face-down' } }} + /> + } + label="Create face-down" + /> + + + + + +
    +
    + ); +} + +export default CreateTokenDialog; diff --git a/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.css b/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.css new file mode 100644 index 000000000..9d14008be --- /dev/null +++ b/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.css @@ -0,0 +1,46 @@ +.DeckSelectDialog .MuiDialog-paper { + width: 540px; + max-width: 540px; +} + +.deck-select-dialog__textarea { + width: 100%; + min-height: 220px; + box-sizing: border-box; + padding: 10px 12px; + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 13px; + line-height: 1.4; + background: #f7f8fa; + border: 1px solid #c4c9d1; + border-radius: 4px; + resize: vertical; +} + +.deck-select-dialog__textarea:focus { + outline: none; + border-color: #5a78c8; + box-shadow: 0 0 0 3px rgba(90, 120, 200, 0.15); +} + +.deck-select-dialog__hash { + margin-top: 10px; + font-size: 12px; + color: #56607a; + font-family: ui-monospace, Menlo, Consolas, monospace; + word-break: break-all; +} + +.deck-select-dialog__hash--pending { + color: #9ea6b8; + font-style: italic; +} + +.deck-select-dialog__actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #e3e6eb; +} diff --git a/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.spec.tsx b/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.spec.tsx new file mode 100644 index 000000000..12f061034 --- /dev/null +++ b/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.spec.tsx @@ -0,0 +1,139 @@ +import { screen, fireEvent } from '@testing-library/react'; + +import { createMockWebClient, makeStoreState, renderWithProviders } from '../../__test-utils__'; +import { + makeGameEntry, + makePlayerEntry, + makePlayerProperties, +} from '../../store/game/__mocks__/fixtures'; +import DeckSelectDialog from './DeckSelectDialog'; + +function stateWith(playerProps: Parameters[0] = {}) { + return makeStoreState({ + games: { + games: { + 1: makeGameEntry({ + localPlayerId: 1, + players: { + 1: makePlayerEntry({ + properties: makePlayerProperties({ playerId: 1, ...playerProps }), + }), + }, + }), + }, + }, + }); +} + +describe('DeckSelectDialog', () => { + it('does not render content when closed', () => { + renderWithProviders( + , + { preloadedState: stateWith() }, + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders textarea, Submit Deck, and Ready controls when open', () => { + renderWithProviders( + , + { preloadedState: stateWith() }, + ); + + expect(screen.getByLabelText('deck list')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /submit deck/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^ready$/i })).toBeInTheDocument(); + }); + + it('disables Submit Deck until the textarea has non-whitespace content', () => { + renderWithProviders( + , + { preloadedState: stateWith() }, + ); + + const submit = screen.getByRole('button', { name: /submit deck/i }); + expect(submit).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('deck list'), { + target: { value: '4 Lightning Bolt\n' }, + }); + expect(submit).not.toBeDisabled(); + }); + + it('dispatches deckSelect with the textarea content when Submit Deck is clicked', () => { + const webClient = createMockWebClient(); + renderWithProviders( + , + { preloadedState: stateWith(), webClient }, + ); + + fireEvent.change(screen.getByLabelText('deck list'), { + target: { value: '4 Island\n4 Mountain' }, + }); + fireEvent.click(screen.getByRole('button', { name: /submit deck/i })); + + expect(webClient.request.game.deckSelect).toHaveBeenCalledWith(1, { + deck: '4 Island\n4 Mountain', + }); + }); + + it('keeps Ready disabled until the player has a deckHash', () => { + const { rerender } = renderWithProviders( + , + { preloadedState: stateWith({ deckHash: '' }) }, + ); + + expect(screen.getByRole('button', { name: /^ready$/i })).toBeDisabled(); + + rerender(); + }); + + it('enables Ready once deckHash is populated and shows the hash text', () => { + renderWithProviders( + , + { preloadedState: stateWith({ deckHash: 'abc123' }) }, + ); + + expect(screen.getByRole('button', { name: /^ready$/i })).not.toBeDisabled(); + expect(screen.getByText(/abc123/)).toBeInTheDocument(); + }); + + it('dispatches readyStart when Ready is clicked', () => { + const webClient = createMockWebClient(); + renderWithProviders( + , + { preloadedState: stateWith({ deckHash: 'abc123' }), webClient }, + ); + + fireEvent.click(screen.getByRole('button', { name: /^ready$/i })); + + expect(webClient.request.game.readyStart).toHaveBeenCalledWith(1, { ready: true }); + }); + + it('switches the label to "Unready" and stays enabled when the player is already ready', () => { + renderWithProviders( + , + { preloadedState: stateWith({ deckHash: 'abc123', readyStart: true }) }, + ); + + const ready = screen.getByRole('button', { name: /unready/i }); + expect(ready).toHaveTextContent('Unready'); + expect(ready).not.toBeDisabled(); + }); + + it('dispatches readyStart({ready:false}) when Unready is clicked (un-ready toggle)', () => { + const webClient = createMockWebClient(); + renderWithProviders( + , + { + preloadedState: stateWith({ deckHash: 'abc123', readyStart: true }), + webClient, + }, + ); + + fireEvent.click(screen.getByRole('button', { name: /unready/i })); + + expect(webClient.request.game.readyStart).toHaveBeenCalledWith(1, { ready: false }); + }); +}); diff --git a/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.tsx b/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.tsx new file mode 100644 index 000000000..61761d68d --- /dev/null +++ b/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { styled } from '@mui/material/styles'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; + +import { useWebClient } from '@app/hooks'; +import { GameSelectors, useAppSelector } from '@app/store'; + +import './DeckSelectDialog.css'; + +const PREFIX = 'DeckSelectDialog'; + +const classes = { + root: `${PREFIX}-root`, +}; + +const StyledDialog = styled(Dialog)(({ theme }) => ({ + [`&.${classes.root}`]: { + '& .dialog-title__wrapper': { + borderColor: theme.palette.grey[300], + }, + }, +})); + +export interface DeckSelectDialogProps { + isOpen: boolean; + gameId: number | undefined; + /** + * Unlike ZoneViewDialog (which takes a required `handleClose`), the + * deck-select dialog is auto-gated by the game's pre-ready lobby state: + * it opens when `!game.started && !spectator && !judge && !readyStart` + * and closes when any of those flip. Tests pass a no-op; production + * callers typically omit this prop, letting MUI render a non-dismissable + * modal (no backdrop-click or ESC close). + */ + handleClose?: () => void; +} + +function DeckSelectDialog({ isOpen, gameId, handleClose }: DeckSelectDialogProps) { + const webClient = useWebClient(); + const localPlayer = useAppSelector((state) => + gameId != null ? GameSelectors.getLocalPlayer(state, gameId) : undefined, + ); + const [deckText, setDeckText] = useState(''); + + const deckHash = localPlayer?.properties.deckHash ?? ''; + const isReady = localPlayer?.properties.readyStart ?? false; + const hasLocalPlayer = localPlayer != null; + // Guard Submit/Ready on having a local player — today the deckSelectOpen + // predicate in Game.tsx implies one, but the dialog mounts before the + // Event_GameJoined echo populates players during reconnect. + const canSubmit = hasLocalPlayer && deckText.trim().length > 0; + const canToggleReady = hasLocalPlayer && deckHash.length > 0; + + const handleSubmitDeck = () => { + if (!canSubmit || gameId == null) { + return; + } + webClient.request.game.deckSelect(gameId, { deck: deckText.trim() }); + }; + + const handleToggleReady = () => { + if (!canToggleReady || gameId == null) { + return; + } + webClient.request.game.readyStart(gameId, { ready: !isReady }); + }; + + return ( + + +
    + Select Deck +
    +
    + + + Paste your deck list below, then click Submit Deck. After the server + accepts the deck, the Ready button unlocks. + + +