diff --git a/webclient/eslint.boundaries.mjs b/webclient/eslint.boundaries.mjs index c5012b44b..d79272665 100644 --- a/webclient/eslint.boundaries.mjs +++ b/webclient/eslint.boundaries.mjs @@ -12,7 +12,6 @@ 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/**'] }, ]; @@ -24,25 +23,24 @@ 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', 'utils', 'websocket-types') }, - { from: { type: 'api' }, allow: types('store', 'types', 'utils', 'websocket', 'websocket-types') }, + { from: { type: 'store' }, allow: types('types', 'websocket-types') }, + { from: { type: 'api' }, allow: types('store', 'types', 'websocket', 'websocket-types') }, { from: { type: 'images' }, allow: types('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: 'services' }, allow: types('api', 'store', 'types') }, + { from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'websocket', 'websocket-types') }, { from: { type: 'components' }, - allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'utils', 'websocket-types') + allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'websocket-types') }, { from: { type: 'containers' }, - allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'utils', 'websocket-types') + allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', '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') }, + { 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') }, ]; export const boundariesConfig = [ diff --git a/webclient/integration/src/app/game-board.spec.tsx b/webclient/integration/src/app/game-board.spec.tsx deleted file mode 100644 index efb4b7840..000000000 --- a/webclient/integration/src/app/game-board.spec.tsx +++ /dev/null @@ -1,443 +0,0 @@ -// 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/connection.spec.ts b/webclient/integration/src/websocket/connection.spec.ts index 78a366f71..1903bb75d 100644 --- a/webclient/integration/src/websocket/connection.spec.ts +++ b/webclient/integration/src/websocket/connection.spec.ts @@ -100,12 +100,7 @@ describe('connection lifecycle', () => { vi.advanceTimersByTime(5000); - // Fire onclose the way a real browser would when the connection-attempt - // timer closes a still-connecting socket. - mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent); - expect(mock.close).toHaveBeenCalled(); - // Never-opened sockets bypass reconnect and land on DISCONNECTED directly. expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); @@ -116,15 +111,12 @@ describe('connection lifecycle', () => { const mock = getMockWebSocket(); getWebClient().disconnect(); - // The transport schedules close() synchronously; onclose follows in the - // browser event loop. Simulate it so the status transition fires. - mock.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent); expect(mock.close).toHaveBeenCalled(); expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); - it('enters RECONNECTING on unexpected socket close after a successful handshake', () => { + it('drops pending commands and clears state on unexpected socket close', () => { connectAndHandshake(); // A login command is now pending (sent during handshake) @@ -135,8 +127,6 @@ describe('connection lifecycle', () => { mock.readyState = 3; mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent); - // With reconnect configured, a drop after a successful open enters the - // reconnect state machine rather than going straight to DISCONNECTED. - expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.RECONNECTING); + expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED); }); }); \ No newline at end of file diff --git a/webclient/integration/src/websocket/game.spec.ts b/webclient/integration/src/websocket/game.spec.ts index 43ad1056c..4775b5332 100644 --- a/webclient/integration/src/websocket/game.spec.ts +++ b/webclient/integration/src/websocket/game.spec.ts @@ -306,22 +306,10 @@ 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: [], @@ -368,14 +356,7 @@ describe('game', () => { ext: Data.Event_GameSay_ext, value: create(Data.Event_GameSaySchema, { message: 'good luck!' }), })); - // 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!'); + expect(store.getState().games.games[99].messages).toHaveLength(1); // ── 6. Discard (move card from hand to graveyard) ──────────────────── deliverMessage(buildGameEventMessage({ @@ -432,4 +413,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 d36442220..8697b0d3e 100644 --- a/webclient/package-lock.json +++ b/webclient/package-lock.json @@ -9,8 +9,6 @@ "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", @@ -55,7 +53,6 @@ "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.2", "@vitejs/plugin-react": "^6.0.1", - "@vitejs/plugin-react-swc": "^4.3.0", "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", "eslint-import-resolver-typescript": "^4.4.4", @@ -637,45 +634,6 @@ "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", @@ -1671,268 +1629,6 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, - "node_modules/@swc/core": { - "version": "1.15.30", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.30.tgz", - "integrity": "sha512-R8VQbQY1BZcbIF2p3gjlTCwAQzx1A194ugWfwld5y+WgVVWqVKm7eURGGOVbQVubgKWzidP2agomBbg96rZilQ==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.26" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.30", - "@swc/core-darwin-x64": "1.15.30", - "@swc/core-linux-arm-gnueabihf": "1.15.30", - "@swc/core-linux-arm64-gnu": "1.15.30", - "@swc/core-linux-arm64-musl": "1.15.30", - "@swc/core-linux-ppc64-gnu": "1.15.30", - "@swc/core-linux-s390x-gnu": "1.15.30", - "@swc/core-linux-x64-gnu": "1.15.30", - "@swc/core-linux-x64-musl": "1.15.30", - "@swc/core-win32-arm64-msvc": "1.15.30", - "@swc/core-win32-ia32-msvc": "1.15.30", - "@swc/core-win32-x64-msvc": "1.15.30" - }, - "peerDependencies": { - "@swc/helpers": ">=0.5.17" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.30", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.30.tgz", - "integrity": "sha512-VvpP+vq08HmGYewMWvrdsxh9s2lthz/808zXm8Yu5kaqeR8Yia2b0eYXleHQ3VAjoStUDk6LzTheBW9KXYQdMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.15.30", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.30.tgz", - "integrity": "sha512-WiJA0hiZI3nwQAO6mu5RqigtWGDtth4Hiq6rbZxAaQyhIcqKIg5IoMRc1Y071lrNJn29eEDMC86Rq58xgUxlDg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.30", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.30.tgz", - "integrity": "sha512-YANuFUo48kIT6plJgCD0keae9HFXfjxsbvsgevqc0hr/07X/p7sAWTFOGYEc2SXcASaK7UvuQqzlbW8pr7R79g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.30", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.30.tgz", - "integrity": "sha512-VndG8jaR4ugY6u+iVOT0Q+d2fZd7sLgjPgN8W/Le+3EbZKl+cRfFxV7Eoz4gfLqhmneZPdcIzf9T3LkgkmqNLg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.30", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.30.tgz", - "integrity": "sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.15.30", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.30.tgz", - "integrity": "sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.15.30", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.30.tgz", - "integrity": "sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.30", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.30.tgz", - "integrity": "sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.30", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.30.tgz", - "integrity": "sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.30", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.30.tgz", - "integrity": "sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.30", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.30.tgz", - "integrity": "sha512-30VdLeGk6fugiUs/kUdJ/pAg7z/zpvVbR11RH60jZ0Z42WIeIniYx0rLEWN7h/pKJ3CopqsQ3RsogCAkRKiA2g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.30", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.30.tgz", - "integrity": "sha512-4iObHPR+Q4oDY110EF5SF5eIaaVJNpMdG9C0q3Q92BsJ5y467uHz7sYQhP60WYlLFsLQ1el2YrIPUItUAQGOKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@swc/types": { - "version": "0.1.26", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", - "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/counter": "^0.1.3" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2713,23 +2409,6 @@ } } }, - "node_modules/@vitejs/plugin-react-swc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.3.0.tgz", - "integrity": "sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7", - "@swc/core": "^1.15.11" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4 || ^5 || ^6 || ^7 || ^8" - } - }, "node_modules/@vitest/coverage-v8": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", diff --git a/webclient/package.json b/webclient/package.json index 2312f5694..386070a37 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -27,8 +27,6 @@ }, "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", @@ -73,7 +71,6 @@ "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.2", "@vitejs/plugin-react": "^6.0.1", - "@vitejs/plugin-react-swc": "^4.3.0", "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", "eslint-import-resolver-typescript": "^4.4.4", diff --git a/webclient/src/__test-utils__/index.ts b/webclient/src/__test-utils__/index.ts index 890c4a9d5..7ee47e601 100644 --- a/webclient/src/__test-utils__/index.ts +++ b/webclient/src/__test-utils__/index.ts @@ -1,10 +1,4 @@ export { withMockLocation } from './globalGuards'; export { renderWithProviders } from './renderWithProviders'; export { createMockWebClient } from './mockWebClient'; -export { - disconnectedState, - connectedState, - connectedWithRoomsState, - makeStoreState, - makeUser, -} from './storeFixtures'; +export { disconnectedState, connectedState, connectedWithRoomsState, makeUser } from './storeFixtures'; diff --git a/webclient/src/__test-utils__/makeHookWrapper.tsx b/webclient/src/__test-utils__/makeHookWrapper.tsx deleted file mode 100644 index 6a2514d84..000000000 --- a/webclient/src/__test-utils__/makeHookWrapper.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { ReactNode } from 'react'; -import { Provider } from 'react-redux'; -import { configureStore, Reducer } from '@reduxjs/toolkit'; - -import { WebClientContext } from '../hooks/useWebClient'; -import type { WebClient } from '../websocket'; -import { createMockWebClient } from './mockWebClient'; - -// Minimal Provider wrapper for hook-only tests. Use this instead of -// `renderWithProviders` when you need `renderHook` — the full provider tree -// auto-instantiates the singleton store via `@app/store`, which races with -// any test-local store you preload. Deep-import the reducer(s) you need and -// pass them here (see useCurrentGame.spec.tsx for the canonical pattern). - -export function makeReduxHookWrapper( - reducer: Reducer, - preloadedState: S, -) { - const store = configureStore({ - reducer, - preloadedState: preloadedState as Parameters[0]['preloadedState'], - }); - function Wrapper({ children }: { children: ReactNode }) { - return {children}; - } - return { Wrapper, store }; -} - -export interface MakeReduxWebClientHookWrapperOptions { - reducer: Reducer; - preloadedState: S; - webClient?: WebClient; -} - -export function makeReduxWebClientHookWrapper({ - reducer, - preloadedState, - webClient, -}: MakeReduxWebClientHookWrapperOptions) { - const store = configureStore({ - reducer, - preloadedState: preloadedState as Parameters[0]['preloadedState'], - }); - const client = webClient ?? createMockWebClient(); - function Wrapper({ children }: { children: ReactNode }) { - return ( - - {children} - - ); - } - return { Wrapper, store, webClient: client }; -} diff --git a/webclient/src/__test-utils__/mockWebClient.ts b/webclient/src/__test-utils__/mockWebClient.ts index 3460199ef..875a4a9d9 100644 --- a/webclient/src/__test-utils__/mockWebClient.ts +++ b/webclient/src/__test-utils__/mockWebClient.ts @@ -34,43 +34,10 @@ 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 6cef77e4d..7d78f171b 100644 --- a/webclient/src/__test-utils__/renderWithProviders.tsx +++ b/webclient/src/__test-utils__/renderWithProviders.tsx @@ -6,57 +6,13 @@ 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'; -// Disables MUI's ripple animation AND all component transitions in tests. -// The ripple fires a deferred state update after clicks/focus that would -// trigger a noisy "update to ForwardRef(TouchRipple) was not wrapped in -// act(...)" warning. Transitions (Grow/Fade/Slide used by Menu, Dialog, -// Popover, Tooltip) default to ~225ms, which is pure wait-time in jsdom -// — every portal open paid this cost before. Zeroing `transitions.duration` -// plus the per-component `transitionDuration: 0` override belt-and-braces -// covers the full v9 surface: styled transitions read the theme; component- -// level Transition props need the defaultProps override. -const testTheme = createTheme({ - transitions: { - duration: { - shortest: 0, shorter: 0, short: 0, - standard: 0, complex: 0, - enteringScreen: 0, leavingScreen: 0, - }, - create: () => 'none', - }, - components: { - MuiButtonBase: { defaultProps: { disableRipple: true } }, - MuiDialog: { defaultProps: { transitionDuration: 0 } }, - MuiMenu: { defaultProps: { transitionDuration: 0 } }, - MuiPopover: { defaultProps: { transitionDuration: 0 } }, - MuiTooltip: { defaultProps: { enterDelay: 0, leaveDelay: 0 } }, - }, -}); - -import { WebClientContext } from '../hooks/useWebClient'; -import type { WebClient } from '../websocket'; -import rootReducer from '../store/rootReducer'; +import { gamesReducer } from '../store/game'; +import { roomsReducer } from '../store/rooms'; +import { serverReducer } from '../store/server'; +import { actionReducer } from '../store/actions'; import { ToastProvider } from '../components/Toast/ToastContext'; -import { storeMiddlewareOptions } from '../store/store'; import type { RootState } from '../store/store'; -import { createMockWebClient } from './mockWebClient'; - -// Lazy-initialized per test file (vitest isolate: true re-evaluates module -// graph per file). Reused by every `renderWithProviders` call that doesn't -// inject its own webClient, so the ~65 vi.fn() allocations happen once per -// file instead of once per render. The global `afterEach` in setupTests.ts -// runs `vi.clearAllMocks()` which resets call history between tests without -// destroying the fn instances — exactly what we want here. -let defaultWebClient: WebClient | undefined; -function getDefaultWebClient(): WebClient { - if (!defaultWebClient) { - defaultWebClient = createMockWebClient(); - } - return defaultWebClient; -} // Non-empty `resources` registers en-US so `resolvedLanguage` is defined; // without it MUI warns about out-of-range Select values. @@ -68,18 +24,15 @@ 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: 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), + reducer: { + games: gamesReducer, + rooms: roomsReducer, + server: serverReducer, + action: actionReducer, + }, + preloadedState: preloadedState as any, }); } @@ -87,7 +40,6 @@ interface ExtendedRenderOptions extends Omit { preloadedState?: Partial; store?: EnhancedStore; route?: string; - webClient?: WebClient; } export function renderWithProviders( @@ -96,7 +48,6 @@ export function renderWithProviders( preloadedState, store = createTestStore(preloadedState), route = '/', - webClient = getDefaultWebClient(), ...renderOptions }: ExtendedRenderOptions = {}, ) { @@ -104,21 +55,11 @@ export function renderWithProviders( return ( - - - - - - {children} - - - - - + + + {children} + + ); @@ -126,7 +67,6 @@ 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 0f0c3efd7..2d7a57878 100644 --- a/webclient/src/__test-utils__/storeFixtures.ts +++ b/webclient/src/__test-utils__/storeFixtures.ts @@ -27,7 +27,6 @@ function makeUser(overrides: Partial = {}): Data.ServerInf export const disconnectedState: Partial = { server: { initialized: false, - testConnectionStatus: null, buddyList: {}, ignoreList: {}, status: { @@ -64,10 +63,6 @@ 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: {}, - joinGamePending: false, - joinGameError: null, }, games: { games: {} }, action: { type: null, payload: null, meta: null, error: false, count: 0 }, @@ -127,27 +122,3 @@ 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/AuthenticationRequestImpl.ts b/webclient/src/api/request/AuthenticationRequestImpl.ts index e94f9d2bf..ece8a1891 100644 --- a/webclient/src/api/request/AuthenticationRequestImpl.ts +++ b/webclient/src/api/request/AuthenticationRequestImpl.ts @@ -15,20 +15,11 @@ interface AppAuthRequestOverrides extends WebsocketTypes.AuthRequestMap { ForgotPasswordResetParams: Omit; } -const CONNECTING_STATUS_LABEL = 'Connecting...'; - -function beginConnect( - options: { host: string; port: string | number }, - reason: WebsocketTypes.WebSocketConnectReason, -): void { - setPendingOptions({ ...options, reason }); - SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, CONNECTING_STATUS_LABEL); - WebClient.instance.connect({ host: options.host, port: options.port }); -} - export class AuthenticationRequestImpl implements WebsocketTypes.IAuthenticationRequest { login(options: Omit): void { - beginConnect(options, WebsocketTypes.WebSocketConnectReason.LOGIN); + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.LOGIN }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); + WebClient.instance.connect({ host: options.host, port: options.port }); } testConnection(options: Omit): void { @@ -36,23 +27,33 @@ export class AuthenticationRequestImpl implements WebsocketTypes.IAuthentication } register(options: Omit): void { - beginConnect(options, WebsocketTypes.WebSocketConnectReason.REGISTER); + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.REGISTER }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); + WebClient.instance.connect({ host: options.host, port: options.port }); } activateAccount(options: Omit): void { - beginConnect(options, WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT); + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); + WebClient.instance.connect({ host: options.host, port: options.port }); } resetPasswordRequest(options: Omit): void { - beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST); + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); + WebClient.instance.connect({ host: options.host, port: options.port }); } resetPasswordChallenge(options: Omit): void { - beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE); + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); + WebClient.instance.connect({ host: options.host, port: options.port }); } resetPassword(options: Omit): void { - beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET); + setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET }); + SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...'); + WebClient.instance.connect({ host: options.host, port: options.port }); } disconnect(): void { diff --git a/webclient/src/api/request/ModeratorRequestImpl.ts b/webclient/src/api/request/ModeratorRequestImpl.ts index d8b7dd40e..97984e397 100644 --- a/webclient/src/api/request/ModeratorRequestImpl.ts +++ b/webclient/src/api/request/ModeratorRequestImpl.ts @@ -15,14 +15,6 @@ export class ModeratorRequestImpl implements WebsocketTypes.IModeratorRequest { ModeratorCommands.banFromServer(minutes, userName, address, reason, visibleReason, clientid, removeMessages); } - forceActivateUser(usernameToActivate: string, moderatorName: string): void { - ModeratorCommands.forceActivateUser(usernameToActivate, moderatorName); - } - - getAdminNotes(userName: string): void { - ModeratorCommands.getAdminNotes(userName); - } - getBanHistory(userName: string): void { ModeratorCommands.getBanHistory(userName); } @@ -35,14 +27,6 @@ export class ModeratorRequestImpl implements WebsocketTypes.IModeratorRequest { ModeratorCommands.getWarnList(modName, userName, userClientid); } - grantReplayAccess(replayId: number, moderatorName: string): void { - ModeratorCommands.grantReplayAccess(replayId, moderatorName); - } - - updateAdminNotes(userName: string, notes: string): void { - ModeratorCommands.updateAdminNotes(userName, notes); - } - viewLogHistory(filters: Data.ViewLogHistoryParams): void { ModeratorCommands.viewLogHistory(filters); } diff --git a/webclient/src/api/request/RoomsRequestImpl.ts b/webclient/src/api/request/RoomsRequestImpl.ts index 27ff2eebb..e7264ca3b 100644 --- a/webclient/src/api/request/RoomsRequestImpl.ts +++ b/webclient/src/api/request/RoomsRequestImpl.ts @@ -1,6 +1,5 @@ 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 { @@ -14,12 +13,4 @@ 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/api/response/RoomResponseImpl.ts b/webclient/src/api/response/RoomResponseImpl.ts index 409779b78..38a8ee7d4 100644 --- a/webclient/src/api/response/RoomResponseImpl.ts +++ b/webclient/src/api/response/RoomResponseImpl.ts @@ -48,12 +48,4 @@ export class RoomResponseImpl implements WebsocketTypes.IRoomResponse { - if (!card) { - return null; - } + const src = `https://api.scryfall.com/cards/${card?.identifiers?.scryfallId}?format=image`; - const src = `https://api.scryfall.com/cards/${card.identifiers?.scryfallId}?format=image`; - - return {card.name}; -}; + return card && ( + {card?.name} + ); +} export default Card; diff --git a/webclient/src/components/CardDetails/CardDetails.tsx b/webclient/src/components/CardDetails/CardDetails.tsx index 7899c6a8b..2ed241d74 100644 --- a/webclient/src/components/CardDetails/CardDetails.tsx +++ b/webclient/src/components/CardDetails/CardDetails.tsx @@ -1,3 +1,6 @@ +// eslint-disable-next-line +import React, { useMemo, useState } from 'react'; + import { CardDTO } from '@app/services'; import Card from '../Card/Card'; @@ -30,7 +33,7 @@ const CardDetails = ({ card }: CardProps) => { (!card.power && !card.toughness) ? null : (
P/T: - {card.power ?? 0}/{card.toughness ?? 0} + {card.power || 0}/{card.toughness || 0}
) } diff --git a/webclient/src/components/CheckboxField/CheckboxField.tsx b/webclient/src/components/CheckboxField/CheckboxField.tsx index 388f26d9b..562687489 100644 --- a/webclient/src/components/CheckboxField/CheckboxField.tsx +++ b/webclient/src/components/CheckboxField/CheckboxField.tsx @@ -1,28 +1,21 @@ -import Checkbox, { CheckboxProps } from '@mui/material/Checkbox'; +import React from 'react'; +import Checkbox from '@mui/material/Checkbox'; import FormControlLabel from '@mui/material/FormControlLabel'; -import type { FinalFormFieldProps } from '../fieldTypes'; - -type CheckboxFieldProps = FinalFormFieldProps & { - label?: string; -} & Omit; - -const CheckboxField = ({ input, meta: _meta, label, ...args }: CheckboxFieldProps) => { - const { value, onChange, onBlur, onFocus, name } = input; +const CheckboxField = (props) => { + const { input: { value, onChange }, label, ...args } = props; + // @TODO this isnt unchecking properly return ( onChange(checked)} color="primary" /> } diff --git a/webclient/src/components/CountryDropdown/CountryDropdown.tsx b/webclient/src/components/CountryDropdown/CountryDropdown.tsx index 7112e35d3..5a7d519bf 100644 --- a/webclient/src/components/CountryDropdown/CountryDropdown.tsx +++ b/webclient/src/components/CountryDropdown/CountryDropdown.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { Select, MenuItem } from '@mui/material'; import FormControl from '@mui/material/FormControl'; import InputLabel from '@mui/material/InputLabel'; @@ -7,48 +8,49 @@ import { useLocaleSort } from '@app/hooks'; import { Images } from '@app/images'; import { App } from '@app/types'; -import type { FinalFormFieldProps } from '../fieldTypes'; import './CountryDropdown.css'; -type CountryDropdownProps = FinalFormFieldProps; - -const CountryDropdown = ({ input }: CountryDropdownProps) => { +const CountryDropdown = ({ input: { onChange } }) => { + const [value, setValue] = useState(''); const { t } = useTranslation(); - const currentValue = (input.value as string | undefined) ?? ''; - const translateCountry = (country: string) => t(`Common.countries.${country}`); + useEffect(() => onChange(value), [value]); + + const translateCountry = country => t(`Common.countries.${country}`); const sortedCountries = useLocaleSort(App.countryCodes, translateCountry); return ( - - Country + + Country - ); + ) }; export default CountryDropdown; diff --git a/webclient/src/components/Game/Battlefield/Battlefield.css b/webclient/src/components/Game/Battlefield/Battlefield.css deleted file mode 100644 index d0bd68b36..000000000 --- a/webclient/src/components/Game/Battlefield/Battlefield.css +++ /dev/null @@ -1,29 +0,0 @@ -.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 deleted file mode 100644 index 56eccd11d..000000000 --- a/webclient/src/components/Game/Battlefield/Battlefield.spec.tsx +++ /dev/null @@ -1,196 +0,0 @@ -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 deleted file mode 100644 index 4f2231292..000000000 --- a/webclient/src/components/Game/Battlefield/Battlefield.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { App, Data } from '@app/types'; - -import CardSlot from '../CardSlot/CardSlot'; -import { makeCardKey } from '../CardRegistry/CardRegistryContext'; -import BattlefieldRow from './BattlefieldRow'; -import { useBattlefield } from './useBattlefield'; - -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; -} - -function Battlefield({ - gameId, - playerId, - mirrored = false, - canAct = false, - arrowSourceKey = null, - onCardHover, - onCardClick, - onCardContextMenu, - onCardDoubleClick, -}: BattlefieldProps) { - const { rows, rowOrder, isInverted } = useBattlefield({ gameId, playerId, mirrored }); - - 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 deleted file mode 100644 index eabc67b3e..000000000 --- a/webclient/src/components/Game/Battlefield/BattlefieldRow.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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/Battlefield/useBattlefield.ts b/webclient/src/components/Game/Battlefield/useBattlefield.ts deleted file mode 100644 index c9d63afcf..000000000 --- a/webclient/src/components/Game/Battlefield/useBattlefield.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useMemo } from 'react'; -import { App, Data } from '@app/types'; -import { GameSelectors, useAppSelector } from '@app/store'; -import { useSettings } from '@app/hooks'; - -export interface Battlefield { - rows: Data.ServerInfo_Card[][]; - rowOrder: number[]; - isInverted: boolean; -} - -export interface UseBattlefieldArgs { - gameId: number; - playerId: number; - mirrored: boolean; -} - -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)); -} - -export function useBattlefield({ gameId, playerId, mirrored }: UseBattlefieldArgs): Battlefield { - 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 { rows, rowOrder, isInverted }; -} diff --git a/webclient/src/components/Game/CardContextMenu/CardContextMenu.css b/webclient/src/components/Game/CardContextMenu/CardContextMenu.css deleted file mode 100644 index b40a5813b..000000000 --- a/webclient/src/components/Game/CardContextMenu/CardContextMenu.css +++ /dev/null @@ -1,3 +0,0 @@ -.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 deleted file mode 100644 index b50cc5801..000000000 --- a/webclient/src/components/Game/CardContextMenu/CardContextMenu.spec.tsx +++ /dev/null @@ -1,396 +0,0 @@ -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 deleted file mode 100644 index c49db0382..000000000 --- a/webclient/src/components/Game/CardContextMenu/CardContextMenu.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import Divider from '@mui/material/Divider'; - -import { Data } from '@app/types'; - -import { useCardContextMenu } from './useCardContextMenu'; - -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; -} - -function CardContextMenu(props: CardContextMenuProps) { - const { isOpen, anchorPosition, card, onClose } = props; - const { - ready, - isOwnedByLocal, - canAttach, - isAttached, - moveTargets, - handleFlip, - handleTapToggle, - handleFaceDownToggle, - handleDoesntUntapToggle, - handleSetPT, - handleSetAnnotation, - handleCardCounterDelta, - handleSetCardCounter, - handleDrawArrow, - handleAttach, - handleUnattach, - handleMove, - handleMoveToLibraryAt, - } = useCardContextMenu(props); - - if (!ready || !card) { - return null; - } - - 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 && ( - <> - - {moveTargets.map((t) => ( - handleMove(t)}> - {t.label} - - ))} - - Move to library at position… - - - )} - - ); -} - -export default CardContextMenu; diff --git a/webclient/src/components/Game/CardContextMenu/useCardContextMenu.ts b/webclient/src/components/Game/CardContextMenu/useCardContextMenu.ts deleted file mode 100644 index f31c2c288..000000000 --- a/webclient/src/components/Game/CardContextMenu/useCardContextMenu.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { useWebClient } from '@app/hooks'; -import { App, Data } from '@app/types'; - -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. -export const CARD_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 }, -]; - -export interface CardContextMenu { - ready: boolean; - isOwnedByLocal: boolean; - canAttach: boolean; - isAttached: boolean; - moveTargets: ReadonlyArray; - handleFlip: () => void; - handleTapToggle: () => void; - handleFaceDownToggle: () => void; - handleDoesntUntapToggle: () => void; - handleSetPT: () => void; - handleSetAnnotation: () => void; - handleCardCounterDelta: (delta: number) => void; - handleSetCardCounter: () => void; - handleDrawArrow: () => void; - handleAttach: () => void; - handleUnattach: () => void; - handleMove: (target: MoveTarget) => void; - handleMoveToLibraryAt: () => void; -} - -export interface UseCardContextMenuArgs { - 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; -} - -export function useCardContextMenu({ - gameId, - localPlayerId, - card, - ownerPlayerId, - sourceZone, - onClose, - onRequestSetPT, - onRequestSetAnnotation, - onRequestSetCounter, - onRequestDrawArrow, - onRequestAttach, - onRequestMoveToLibraryAt, -}: UseCardContextMenuArgs): CardContextMenu { - const webClient = useWebClient(); - - const ready = card != null && ownerPlayerId != null && sourceZone != null && localPlayerId != null; - - // 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 = ready && ownerPlayerId === localPlayerId; - const isAttached = ready && (card!.attachCardId ?? -1) >= 0; - // Desktop's actAttach is only available from a table card; other zones - // never expose the attach arrow. - const canAttach = ready && sourceZone === App.ZoneName.TABLE; - - const setAttr = (attribute: Data.CardAttribute, value: string) => { - if (!ready) { - return; - } - webClient.request.game.setCardAttr(gameId, { - zone: sourceZone!, - cardId: card!.id, - attribute, - attrValue: value, - }); - }; - - const handleFlip = () => { - if (!ready) { - return; - } - // 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. - webClient.request.game.flipCard(gameId, { - zone: sourceZone!, - cardId: card!.id, - faceDown: !card!.faceDown, - }); - onClose(); - }; - - const handleTapToggle = () => { - if (!ready) { - return; - } - setAttr(Data.CardAttribute.AttrTapped, card!.tapped ? '0' : '1'); - onClose(); - }; - - const handleFaceDownToggle = () => { - if (!ready) { - return; - } - setAttr(Data.CardAttribute.AttrFaceDown, card!.faceDown ? '0' : '1'); - onClose(); - }; - - const handleDoesntUntapToggle = () => { - if (!ready) { - return; - } - setAttr(Data.CardAttribute.AttrDoesntUntap, card!.doesntUntap ? '0' : '1'); - onClose(); - }; - - const handleSetPT = () => { - onRequestSetPT(); - onClose(); - }; - - const handleSetAnnotation = () => { - onRequestSetAnnotation(); - onClose(); - }; - - const handleCardCounterDelta = (delta: number) => { - if (!ready) { - return; - } - webClient.request.game.incCardCounter(gameId, { - zone: sourceZone!, - cardId: card!.id, - counterId: 0, - counterDelta: delta, - }); - onClose(); - }; - - const handleSetCardCounter = () => { - onRequestSetCounter(); - onClose(); - }; - - const handleDrawArrow = () => { - onRequestDrawArrow(); - onClose(); - }; - - const handleAttach = () => { - onRequestAttach(); - onClose(); - }; - - const handleUnattach = () => { - if (!ready) { - return; - } - // 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. - webClient.request.game.attachCard(gameId, { startZone: sourceZone!, cardId: card!.id }); - onClose(); - }; - - const handleMove = (target: MoveTarget) => { - if (!ready) { - return; - } - // targetPlayerId is the ACTING player (local), matching desktop's - // Player::actMoveCardTo* which uses playerInfo->getId(). - webClient.request.game.moveCard(gameId, { - startPlayerId: ownerPlayerId!, - startZone: sourceZone!, - cardsToMove: { card: [{ cardId: card!.id }] }, - targetPlayerId: localPlayerId!, - targetZone: target.zone, - x: target.x, - y: target.y, - isReversed: false, - }); - onClose(); - }; - - const handleMoveToLibraryAt = () => { - onRequestMoveToLibraryAt(); - onClose(); - }; - - return { - ready, - isOwnedByLocal, - canAttach, - isAttached, - moveTargets: CARD_MOVE_TARGETS, - handleFlip, - handleTapToggle, - handleFaceDownToggle, - handleDoesntUntapToggle, - handleSetPT, - handleSetAnnotation, - handleCardCounterDelta, - handleSetCardCounter, - handleDrawArrow, - handleAttach, - handleUnattach, - handleMove, - handleMoveToLibraryAt, - }; -} diff --git a/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.css b/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.css deleted file mode 100644 index fab536600..000000000 --- a/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.css +++ /dev/null @@ -1,24 +0,0 @@ -.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 deleted file mode 100644 index c76481887..000000000 --- a/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.spec.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 590c3e4a8..000000000 --- a/webclient/src/components/Game/CardDragOverlay/CardDragOverlay.tsx +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 308d2ce8f..000000000 --- a/webclient/src/components/Game/CardPreview/CardPreview.css +++ /dev/null @@ -1,44 +0,0 @@ -.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 deleted file mode 100644 index 05f2c3beb..000000000 --- a/webclient/src/components/Game/CardPreview/CardPreview.spec.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 3a4da2ccb..000000000 --- a/webclient/src/components/Game/CardPreview/CardPreview.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 24c4f4039..000000000 --- a/webclient/src/components/Game/CardRegistry/CardRegistryContext.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 4a52b1f52..000000000 --- a/webclient/src/components/Game/CardSlot/CardSlot.css +++ /dev/null @@ -1,153 +0,0 @@ -.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 deleted file mode 100644 index 5d4217906..000000000 --- a/webclient/src/components/Game/CardSlot/CardSlot.spec.tsx +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index 15ab4e605..000000000 --- a/webclient/src/components/Game/CardSlot/CardSlot.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { memo } from 'react'; - -import type { Data } from '@app/types'; -import { cx } from '@app/utils'; - -import { useCardSlot } from './useCardSlot'; - -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, attributes, listeners, isDragging, isOver, rootRef } = useCardSlot({ - card, - draggable, - ownerPlayerId, - zone, - }); - - 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 memo(CardSlot); diff --git a/webclient/src/components/Game/CardSlot/useCardSlot.ts b/webclient/src/components/Game/CardSlot/useCardSlot.ts deleted file mode 100644 index 066b6d702..000000000 --- a/webclient/src/components/Game/CardSlot/useCardSlot.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { useCallback, useId } from 'react'; -import { - useDraggable, - useDroppable, - type DraggableAttributes, - type DraggableSyntheticListeners, -} from '@dnd-kit/core'; - -import { useScryfallCard } from '@app/hooks'; -import { App } from '@app/types'; -import type { Data } from '@app/types'; - -import { makeCardKey, useRegisterCardRef } from '../CardRegistry/CardRegistryContext'; - -export interface CardSlot { - smallUrl: string | null | undefined; - attributes: DraggableAttributes; - listeners: DraggableSyntheticListeners; - isDragging: boolean; - isOver: boolean; - rootRef: (el: HTMLElement | null) => void; -} - -export interface UseCardSlotArgs { - card: Data.ServerInfo_Card; - draggable: boolean; - ownerPlayerId: number | undefined; - zone: string | undefined; -} - -export function useCardSlot({ card, draggable, ownerPlayerId, zone }: UseCardSlotArgs): CardSlot { - 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], - ); - - return { - smallUrl, - attributes, - listeners, - isDragging, - isOver, - rootRef, - }; -} diff --git a/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.css b/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.css deleted file mode 100644 index 8b879fc22..000000000 --- a/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.css +++ /dev/null @@ -1,24 +0,0 @@ -.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 deleted file mode 100644 index c2aa75946..000000000 --- a/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.spec.tsx +++ /dev/null @@ -1,129 +0,0 @@ -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 deleted file mode 100644 index 5b033eae5..000000000 --- a/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useGameArrowOverlay } from './useGameArrowOverlay'; - -import './GameArrowOverlay.css'; - -export interface GameArrowOverlayProps { - gameId: number | undefined; - boardRef: React.RefObject; - dragPreview?: { x1: number; y1: number; x2: number; y2: number; color: string } | null; -} - -function GameArrowOverlay({ gameId, boardRef, dragPreview = null }: GameArrowOverlayProps) { - const { arrows, width, height, handleArrowClick } = useGameArrowOverlay({ gameId, boardRef }); - - return ( - - - - - - - {arrows.map((a) => ( - handleArrowClick(a.arrowId)} - /> - ))} - {dragPreview && ( - - )} - - ); -} - -export default GameArrowOverlay; diff --git a/webclient/src/components/Game/GameArrowOverlay/useGameArrowOverlay.ts b/webclient/src/components/Game/GameArrowOverlay/useGameArrowOverlay.ts deleted file mode 100644 index d84510ee4..000000000 --- a/webclient/src/components/Game/GameArrowOverlay/useGameArrowOverlay.ts +++ /dev/null @@ -1,129 +0,0 @@ -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'; - -export 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 }); -} - -export interface GameArrowOverlay { - arrows: ResolvedArrow[]; - width: number; - height: number; - handleArrowClick: (arrowId: number) => void; -} - -export interface UseGameArrowOverlayArgs { - gameId: number | undefined; - boardRef: React.RefObject; -} - -export function useGameArrowOverlay({ - gameId, - boardRef, -}: UseGameArrowOverlayArgs): GameArrowOverlay { - 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, width, height, handleArrowClick }; -} diff --git a/webclient/src/components/Game/GameLog/GameLog.css b/webclient/src/components/Game/GameLog/GameLog.css deleted file mode 100644 index a8c96c871..000000000 --- a/webclient/src/components/Game/GameLog/GameLog.css +++ /dev/null @@ -1,97 +0,0 @@ -.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 deleted file mode 100644 index b3fa9c7da..000000000 --- a/webclient/src/components/Game/GameLog/GameLog.spec.tsx +++ /dev/null @@ -1,249 +0,0 @@ -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 deleted file mode 100644 index a2c72d051..000000000 --- a/webclient/src/components/Game/GameLog/GameLog.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useRef } from 'react'; - -import { formatElapsed, useGameLog } from './useGameLog'; - -import './GameLog.css'; - -export interface GameLogProps { - gameId: number | undefined; -} - -function GameLog({ gameId }: GameLogProps) { - const listRef = useRef(null); - const { - messages, - players, - displaySeconds, - draft, - setDraft, - handleMessagesScroll, - handleSubmit, - } = useGameLog({ gameId, listRef }); - - 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/GameLog/useGameLog.ts b/webclient/src/components/Game/GameLog/useGameLog.ts deleted file mode 100644 index a2c0252b7..000000000 --- a/webclient/src/components/Game/GameLog/useGameLog.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useEffect, useRef, useState, RefObject } from 'react'; -import { useWebClient } from '@app/hooks'; -import { GameSelectors, useAppSelector } from '@app/store'; -import type { Enriched } from '@app/types'; - -const EMPTY_MESSAGES: Enriched.GameMessage[] = []; - -export 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 GameLog { - messages: Enriched.GameMessage[]; - players: Record | undefined; - displaySeconds: number; - draft: string; - setDraft: (v: string) => void; - handleMessagesScroll: () => void; - handleSubmit: (e: React.FormEvent) => void; -} - -export interface UseGameLogArgs { - gameId: number | undefined; - listRef: RefObject; -} - -export function useGameLog({ gameId, listRef }: UseGameLogArgs): GameLog { - const webClient = useWebClient(); - // getMessages falls back to a shared EMPTY_ARRAY typed as ServerInfo_Card[] - // (see game.selectors.ts). The runtime array is empty, so the cast is safe; - // fixing the selector's fallback type is out of scope for this refactor. - const messages = useAppSelector((state) => - gameId != null ? GameSelectors.getMessages(state, gameId) : EMPTY_MESSAGES, - ) as Enriched.GameMessage[]; - 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(''); - - // 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, listRef]); - - 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 { - messages, - players, - displaySeconds, - draft, - setDraft, - handleMessagesScroll, - handleSubmit, - }; -} diff --git a/webclient/src/components/Game/HandContextMenu/HandContextMenu.css b/webclient/src/components/Game/HandContextMenu/HandContextMenu.css deleted file mode 100644 index 3d27194d5..000000000 --- a/webclient/src/components/Game/HandContextMenu/HandContextMenu.css +++ /dev/null @@ -1,3 +0,0 @@ -.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 deleted file mode 100644 index b1e532bf4..000000000 --- a/webclient/src/components/Game/HandContextMenu/HandContextMenu.spec.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index fefd7c5f7..000000000 --- a/webclient/src/components/Game/HandContextMenu/HandContextMenu.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import Divider from '@mui/material/Divider'; - -import { useHandContextMenu } from './useHandContextMenu'; - -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 { handleChoose, handleSameSize, handleMinusOne, handleRevealHand, handleRevealRandom } = - useHandContextMenu({ - gameId, - handSize, - onClose, - onRequestChooseMulligan, - onRequestRevealHand, - onRequestRevealRandom, - }); - - 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/HandContextMenu/useHandContextMenu.ts b/webclient/src/components/Game/HandContextMenu/useHandContextMenu.ts deleted file mode 100644 index e78ace088..000000000 --- a/webclient/src/components/Game/HandContextMenu/useHandContextMenu.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useWebClient } from '@app/hooks'; - -export interface HandContextMenu { - handleChoose: () => void; - handleSameSize: () => void; - handleMinusOne: () => void; - handleRevealHand: () => void; - handleRevealRandom: () => void; -} - -export interface UseHandContextMenuArgs { - gameId: number; - handSize: number; - onClose: () => void; - onRequestChooseMulligan: () => void; - onRequestRevealHand: () => void; - onRequestRevealRandom: () => void; -} - -export function useHandContextMenu({ - gameId, - handSize, - onClose, - onRequestChooseMulligan, - onRequestRevealHand, - onRequestRevealRandom, -}: UseHandContextMenuArgs): HandContextMenu { - 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 { handleChoose, handleSameSize, handleMinusOne, handleRevealHand, handleRevealRandom }; -} diff --git a/webclient/src/components/Game/HandZone/HandZone.css b/webclient/src/components/Game/HandZone/HandZone.css deleted file mode 100644 index 6d4a3449c..000000000 --- a/webclient/src/components/Game/HandZone/HandZone.css +++ /dev/null @@ -1,33 +0,0 @@ -.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 deleted file mode 100644 index c16bfcdfc..000000000 --- a/webclient/src/components/Game/HandZone/HandZone.spec.tsx +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index 0c78382bf..000000000 --- a/webclient/src/components/Game/HandZone/HandZone.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { App, Data } from '@app/types'; -import { cx } from '@app/utils'; - -import CardSlot from '../CardSlot/CardSlot'; -import { makeCardKey } from '../CardRegistry/CardRegistryContext'; -import { useHandZone } from './useHandZone'; - -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, setNodeRef, isOver, handleZoneContextMenu } = useHandZone({ - gameId, - playerId, - canAct, - onZoneContextMenu, - }); - - 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/HandZone/useHandZone.ts b/webclient/src/components/Game/HandZone/useHandZone.ts deleted file mode 100644 index d11226a3f..000000000 --- a/webclient/src/components/Game/HandZone/useHandZone.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useDroppable } from '@dnd-kit/core'; -import type { Ref } from 'react'; - -import { App, Data } from '@app/types'; -import { GameSelectors, useAppSelector } from '@app/store'; - -export interface HandZone { - cards: Data.ServerInfo_Card[]; - setNodeRef: Ref; - isOver: boolean; - handleZoneContextMenu: (e: React.MouseEvent) => void; -} - -export interface UseHandZoneArgs { - gameId: number; - playerId: number; - canAct: boolean; - onZoneContextMenu?: (event: React.MouseEvent) => void; -} - -export function useHandZone({ - gameId, - playerId, - canAct, - onZoneContextMenu, -}: UseHandZoneArgs): HandZone { - 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 { cards, setNodeRef, isOver, handleZoneContextMenu }; -} diff --git a/webclient/src/components/Game/OpponentSelector/OpponentSelector.css b/webclient/src/components/Game/OpponentSelector/OpponentSelector.css deleted file mode 100644 index 8f76f6d80..000000000 --- a/webclient/src/components/Game/OpponentSelector/OpponentSelector.css +++ /dev/null @@ -1,26 +0,0 @@ -.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 deleted file mode 100644 index 310676cad..000000000 --- a/webclient/src/components/Game/OpponentSelector/OpponentSelector.spec.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 07a090ff7..000000000 --- a/webclient/src/components/Game/OpponentSelector/OpponentSelector.tsx +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 72a8896f4..000000000 --- a/webclient/src/components/Game/PhaseBar/PhaseBar.css +++ /dev/null @@ -1,53 +0,0 @@ -.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 deleted file mode 100644 index 89dd3c4ba..000000000 --- a/webclient/src/components/Game/PhaseBar/PhaseBar.spec.tsx +++ /dev/null @@ -1,258 +0,0 @@ -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 deleted file mode 100644 index 1b0ddcfe4..000000000 --- a/webclient/src/components/Game/PhaseBar/PhaseBar.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import Tooltip from '@mui/material/Tooltip'; - -import { App } from '@app/types'; -import { cx } from '@app/utils'; - -import { usePhaseBar } from './usePhaseBar'; - -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 { activePhase, canAdvance, handlePhaseClick, handlePass, handleUntapAll, handleDrawOne } = - usePhaseBar(gameId); - - 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/PhaseBar/usePhaseBar.ts b/webclient/src/components/Game/PhaseBar/usePhaseBar.ts deleted file mode 100644 index 5a25f38e4..000000000 --- a/webclient/src/components/Game/PhaseBar/usePhaseBar.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useCurrentGame, useWebClient } from '@app/hooks'; -import { GameSelectors, useAppSelector } from '@app/store'; -import { App, Data } from '@app/types'; - -export interface PhaseBar { - activePhase: App.Phase | undefined; - canAdvance: boolean; - handlePhaseClick: (phase: App.Phase) => void; - handlePass: () => void; - handleUntapAll: () => void; - handleDrawOne: () => void; -} - -export function usePhaseBar(gameId: number | undefined): PhaseBar { - 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 }); - }; - - return { activePhase, canAdvance, handlePhaseClick, handlePass, handleUntapAll, handleDrawOne }; -} diff --git a/webclient/src/components/Game/PlayerBoard/PlayerBoard.css b/webclient/src/components/Game/PlayerBoard/PlayerBoard.css deleted file mode 100644 index 0d53ce60e..000000000 --- a/webclient/src/components/Game/PlayerBoard/PlayerBoard.css +++ /dev/null @@ -1,13 +0,0 @@ -.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 deleted file mode 100644 index 2a76fc278..000000000 --- a/webclient/src/components/Game/PlayerBoard/PlayerBoard.spec.tsx +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index 6552dac90..000000000 --- a/webclient/src/components/Game/PlayerBoard/PlayerBoard.tsx +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 574d9d480..000000000 --- a/webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.css +++ /dev/null @@ -1,3 +0,0 @@ -.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 deleted file mode 100644 index f6fe7e996..000000000 --- a/webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.spec.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index f175c84fa..000000000 --- a/webclient/src/components/Game/PlayerContextMenu/PlayerContextMenu.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 8a5500760..000000000 --- a/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.css +++ /dev/null @@ -1,267 +0,0 @@ -.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 deleted file mode 100644 index 1ff41d5c1..000000000 --- a/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.spec.tsx +++ /dev/null @@ -1,348 +0,0 @@ -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 deleted file mode 100644 index a45a0430b..000000000 --- a/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { cx } from '@app/utils'; -import type { Data } from '@app/types'; - -import { cssColor, usePlayerInfoPanel } from './usePlayerInfoPanel'; - -import './PlayerInfoPanel.css'; - -export interface PlayerInfoPanelProps { - gameId: number; - playerId: number; - canEdit?: boolean; - onRequestCreateCounter?: () => void; - onContextMenu?: (event: React.MouseEvent) => void; -} - -function PlayerInfoPanel({ - gameId, - playerId, - canEdit = false, - onRequestCreateCounter, - onContextMenu, -}: PlayerInfoPanelProps) { - const { - player, - isHost, - lifeCounter, - otherCounters, - editingId, - editDraft, - setEditDraft, - beginEdit, - commitEdit, - cancelEdit, - handleIncrement, - handleDelete, - } = usePlayerInfoPanel({ gameId, playerId }); - - 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 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/PlayerInfoPanel/usePlayerInfoPanel.ts b/webclient/src/components/Game/PlayerInfoPanel/usePlayerInfoPanel.ts deleted file mode 100644 index 6229e5718..000000000 --- a/webclient/src/components/Game/PlayerInfoPanel/usePlayerInfoPanel.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { useState } from 'react'; -import { useWebClient } from '@app/hooks'; -import { GameSelectors, useAppSelector } from '@app/store'; -import type { Data, Enriched } from '@app/types'; - -export 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. -export function isLifeCounter(c: { name: string }): boolean { - return c.name.trim().toLowerCase() === 'life'; -} - -export interface PlayerInfoPanel { - player: Enriched.PlayerEntry | undefined; - isHost: boolean; - lifeCounter: Data.ServerInfo_Counter | undefined; - otherCounters: Data.ServerInfo_Counter[]; - editingId: number | null; - editDraft: string; - setEditDraft: (v: string) => void; - beginEdit: (counterId: number, currentValue: number) => void; - commitEdit: (counterId: number) => void; - cancelEdit: () => void; - handleIncrement: (counterId: number, delta: number) => void; - handleDelete: (counterId: number) => void; -} - -export interface UsePlayerInfoPanelArgs { - gameId: number; - playerId: number; -} - -export function usePlayerInfoPanel({ - gameId, - playerId, -}: UsePlayerInfoPanelArgs): PlayerInfoPanel { - 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(''); - - 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); - }; - - return { - player, - isHost, - lifeCounter, - otherCounters, - editingId, - editDraft, - setEditDraft, - beginEdit, - commitEdit, - cancelEdit, - handleIncrement, - handleDelete, - }; -} diff --git a/webclient/src/components/Game/PlayerList/PlayerList.css b/webclient/src/components/Game/PlayerList/PlayerList.css deleted file mode 100644 index c76e3a15b..000000000 --- a/webclient/src/components/Game/PlayerList/PlayerList.css +++ /dev/null @@ -1,76 +0,0 @@ -.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 deleted file mode 100644 index 790972b47..000000000 --- a/webclient/src/components/Game/PlayerList/PlayerList.spec.tsx +++ /dev/null @@ -1,143 +0,0 @@ -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 deleted file mode 100644 index 9b0374887..000000000 --- a/webclient/src/components/Game/PlayerList/PlayerList.tsx +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index 99cd058d1..000000000 --- a/webclient/src/components/Game/RightPanel/RightPanel.css +++ /dev/null @@ -1,21 +0,0 @@ -.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 deleted file mode 100644 index 0edd295ec..000000000 --- a/webclient/src/components/Game/RightPanel/RightPanel.spec.tsx +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 20e39f40c..000000000 --- a/webclient/src/components/Game/RightPanel/RightPanel.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index b66fb6c77..000000000 --- a/webclient/src/components/Game/StackStrip/StackStrip.css +++ /dev/null @@ -1,54 +0,0 @@ -.stack-strip { - display: flex; - align-items: center; - gap: 12px; - padding: 4px 16px; - background: #0a1225; - border-top: 1px solid #1a2b52; - border-bottom: 1px solid #1a2b52; - font-size: 11px; - color: #c8d4ef; -} - -.stack-strip__heading { - font-weight: 700; - letter-spacing: 1px; - text-transform: uppercase; - color: #8597bb; - flex-shrink: 0; -} - -.stack-strip__cell { - display: flex; - align-items: center; - gap: 6px; - padding: 2px 8px; - border: 1px solid #233a68; - border-radius: 3px; - background: #17223d; -} - -.stack-strip__cell[role='button'] { - cursor: pointer; -} - -.stack-strip__cell[role='button']:hover { - background: #223060; - border-color: #355090; -} - -.stack-strip__label { - font-weight: 600; - color: #c8d4ef; -} - -.stack-strip__count { - min-width: 16px; - padding: 0 4px; - background: #050914; - color: var(--color-highlight-yellow); - font-variant-numeric: tabular-nums; - font-weight: 700; - border-radius: 2px; - text-align: center; -} diff --git a/webclient/src/components/Game/StackStrip/StackStrip.spec.tsx b/webclient/src/components/Game/StackStrip/StackStrip.spec.tsx deleted file mode 100644 index 9e192cfe0..000000000 --- a/webclient/src/components/Game/StackStrip/StackStrip.spec.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { screen, fireEvent } from '@testing-library/react'; -import { App } from '@app/types'; - -import { makeStoreState, renderWithProviders } from '../../../__test-utils__'; -import { - makeGameEntry, - makePlayerEntry, - makePlayerProperties, - makeZoneEntry, -} from '../../../store/game/__mocks__/fixtures'; -import StackStrip from './StackStrip'; - -function stateWithStacks(localCount: number, opponentCount: number) { - const local = makePlayerEntry({ - properties: makePlayerProperties({ playerId: 1 }), - zones: { - [App.ZoneName.STACK]: makeZoneEntry({ - name: App.ZoneName.STACK, - cardCount: localCount, - }), - }, - }); - const opponent = makePlayerEntry({ - properties: makePlayerProperties({ playerId: 2 }), - zones: { - [App.ZoneName.STACK]: makeZoneEntry({ - name: App.ZoneName.STACK, - cardCount: opponentCount, - }), - }, - }); - return makeStoreState({ - games: { - games: { - 1: makeGameEntry({ localPlayerId: 1, players: { 1: local, 2: opponent } }), - }, - }, - }); -} - -describe('StackStrip', () => { - it('renders a cell per entry with the zone cardCount', () => { - renderWithProviders( - , - { 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 deleted file mode 100644 index 1f1b1e075..000000000 --- a/webclient/src/components/Game/StackStrip/StackStrip.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { GameSelectors, useAppSelector } from '@app/store'; -import { App } from '@app/types'; - -import './StackStrip.css'; - -export interface StackStripEntry { - playerId: number; - name: string; -} - -export interface StackStripProps { - gameId: number; - entries: StackStripEntry[]; - onZoneClick?: (playerId: number, zoneName: string) => void; -} - -interface StackCellProps { - gameId: number; - playerId: number; - name: string; - onClick?: (playerId: number, zoneName: string) => void; -} - -function StackCell({ gameId, playerId, name, onClick }: StackCellProps) { - const zone = useAppSelector((state) => - GameSelectors.getZone(state, gameId, playerId, App.ZoneName.STACK), - ); - const count = zone?.cardCount ?? 0; - const clickable = onClick != null; - - const handleClick = () => { - onClick?.(playerId, App.ZoneName.STACK); - }; - - return ( -
    { - 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 deleted file mode 100644 index 21fa1c716..000000000 --- a/webclient/src/components/Game/TurnControls/TurnControls.css +++ /dev/null @@ -1,39 +0,0 @@ -.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 deleted file mode 100644 index 61f8bc6b4..000000000 --- a/webclient/src/components/Game/TurnControls/TurnControls.spec.tsx +++ /dev/null @@ -1,394 +0,0 @@ -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 deleted file mode 100644 index ad0ef9a9a..000000000 --- a/webclient/src/components/Game/TurnControls/TurnControls.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; - -import { useTurnControls } from './useTurnControls'; - -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 { - isHost, - isConceded, - invertVerticalCoordinate, - settingsReady, - canAdvance, - canLeave, - canConcede, - canUnconcede, - canRoll, - canKick, - canRemoveArrows, - hasLiveGame, - opponents, - kickAnchor, - setKickAnchor, - handlePassTurn, - handleReverseTurn, - handleNextPhase, - handleConcedeToggle, - handleRemoveArrows, - handleLeave, - handleToggleInvert, - handleKick, - } = useTurnControls({ gameId, onRequestConcede, onRequestUnconcede }); - - return ( -
    - - - - - - - - - - - {isHost && ( - <> - - setKickAnchor(null)} - > - {opponents.map((o) => ( - handleKick(o.playerId)}> - {o.name} - - ))} - - - )} -
    - ); -} - -export default TurnControls; diff --git a/webclient/src/components/Game/TurnControls/useTurnControls.ts b/webclient/src/components/Game/TurnControls/useTurnControls.ts deleted file mode 100644 index b73298577..000000000 --- a/webclient/src/components/Game/TurnControls/useTurnControls.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { useMemo, useState } from 'react'; - -import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks'; -import { GameSelectors, useAppSelector } from '@app/store'; - -/** - * MTG turn phase count (0..10). Mirrors desktop's wrap-around behavior in - * `GameView::actNextPhase` — see `types/game.ts` for the Phase enum. - */ -const PHASE_COUNT = 11; - -export interface TurnControlsOpponent { - playerId: number; - name: string; -} - -export interface TurnControls { - isHost: boolean; - isConceded: boolean; - invertVerticalCoordinate: boolean; - settingsReady: boolean; - canAdvance: boolean; - canLeave: boolean; - canConcede: boolean; - canUnconcede: boolean; - canRoll: boolean; - canKick: boolean; - canRemoveArrows: boolean; - hasLiveGame: boolean; - opponents: TurnControlsOpponent[]; - kickAnchor: HTMLElement | null; - setKickAnchor: (el: HTMLElement | null) => void; - handlePassTurn: () => void; - handleReverseTurn: () => void; - handleNextPhase: () => void; - handleConcedeToggle: () => void; - handleRemoveArrows: () => void; - handleLeave: () => void; - handleToggleInvert: () => void; - handleKick: (playerId: number) => void; -} - -export interface UseTurnControlsArgs { - gameId: number | undefined; - onRequestConcede: () => void; - onRequestUnconcede: () => void; -} - -export function useTurnControls({ - gameId, - onRequestConcede, - onRequestUnconcede, -}: UseTurnControlsArgs): TurnControls { - 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 PHASE_COUNT → 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) % PHASE_COUNT : 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, - isConceded, - invertVerticalCoordinate, - settingsReady: settingsStatus === LoadingState.READY, - canAdvance, - canLeave, - canConcede, - canUnconcede, - canRoll, - canKick, - canRemoveArrows, - hasLiveGame, - opponents, - kickAnchor, - setKickAnchor, - handlePassTurn, - handleReverseTurn, - handleNextPhase, - handleConcedeToggle, - handleRemoveArrows, - handleLeave, - handleToggleInvert, - handleKick, - }; -} diff --git a/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.css b/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.css deleted file mode 100644 index 379f4bc2f..000000000 --- a/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.css +++ /dev/null @@ -1,16 +0,0 @@ -.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 deleted file mode 100644 index 1a2a0285c..000000000 --- a/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.spec.tsx +++ /dev/null @@ -1,208 +0,0 @@ -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 deleted file mode 100644 index b973f0329..000000000 --- a/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.tsx +++ /dev/null @@ -1,119 +0,0 @@ -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 { App } from '@app/types'; - -import { useZoneContextMenu } from './useZoneContextMenu'; - -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(props: ZoneContextMenuProps) { - const { - isOpen, - anchorPosition, - zoneName, - onClose, - onRequestDrawN, - onRequestDumpN, - onRequestRevealTopN, - onRequestRevealZone, - } = props; - const { - ready, - alwaysReveal, - alwaysLook, - handleDrawOne, - handleShuffle, - handleRevealTop, - handleToggleAlwaysReveal, - handleToggleAlwaysLook, - runAndClose, - } = useZoneContextMenu(props); - - if (!ready) { - return null; - } - - 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/ZoneContextMenu/useZoneContextMenu.ts b/webclient/src/components/Game/ZoneContextMenu/useZoneContextMenu.ts deleted file mode 100644 index 43dbcceb1..000000000 --- a/webclient/src/components/Game/ZoneContextMenu/useZoneContextMenu.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { useWebClient } from '@app/hooks'; -import { GameSelectors, useAppSelector } from '@app/store'; -import { App } from '@app/types'; - -export interface ZoneContextMenu { - ready: boolean; - alwaysReveal: boolean; - alwaysLook: boolean; - handleDrawOne: () => void; - handleShuffle: () => void; - handleRevealTop: () => void; - handleToggleAlwaysReveal: () => void; - handleToggleAlwaysLook: () => void; - runAndClose: (fn: () => void) => () => void; -} - -export interface UseZoneContextMenuArgs { - gameId: number; - playerId: number | null; - zoneName: string | null; - onClose: () => void; -} - -export function useZoneContextMenu({ - gameId, - playerId, - zoneName, - onClose, -}: UseZoneContextMenuArgs): ZoneContextMenu { - const webClient = useWebClient(); - - const zone = useAppSelector((state) => - playerId != null && zoneName != null - ? GameSelectors.getZone(state, gameId, playerId, zoneName) - : undefined, - ); - - const ready = playerId != null && zoneName != null; - const alwaysReveal = zone?.alwaysRevealTopCard ?? false; - const alwaysLook = zone?.alwaysLookAtTopCard ?? false; - - // Close-then-act helpers (avoid duplicating onClose at every site). - const runAndClose = (fn: () => void) => () => { - fn(); - onClose(); - }; - - const handleDrawOne = () => { - webClient.request.game.drawCards(gameId, { number: 1 }); - }; - - const handleShuffle = () => { - webClient.request.game.shuffle(gameId, { zoneName: App.ZoneName.DECK, start: 0, end: -1 }); - }; - - const handleRevealTop = () => { - webClient.request.game.revealCards(gameId, { - zoneName: App.ZoneName.DECK, - playerId: -1, - topCards: 1, - }); - }; - - const handleToggleAlwaysReveal = () => { - webClient.request.game.changeZoneProperties(gameId, { - zoneName: App.ZoneName.DECK, - alwaysRevealTopCard: !alwaysReveal, - }); - }; - - const handleToggleAlwaysLook = () => { - webClient.request.game.changeZoneProperties(gameId, { - zoneName: App.ZoneName.DECK, - alwaysLookAtTopCard: !alwaysLook, - }); - }; - - return { - ready, - alwaysReveal, - alwaysLook, - handleDrawOne, - handleShuffle, - handleRevealTop, - handleToggleAlwaysReveal, - handleToggleAlwaysLook, - runAndClose, - }; -} diff --git a/webclient/src/components/Game/ZoneRail/ZoneRail.css b/webclient/src/components/Game/ZoneRail/ZoneRail.css deleted file mode 100644 index 991546067..000000000 --- a/webclient/src/components/Game/ZoneRail/ZoneRail.css +++ /dev/null @@ -1,11 +0,0 @@ -.zone-rail { - width: 110px; - height: 100%; - padding: 8px 4px; - box-sizing: border-box; - display: flex; - flex-direction: column; - gap: 8px; - background: #0a1225; - border-left: 1px solid #1a2b52; -} diff --git a/webclient/src/components/Game/ZoneRail/ZoneRail.spec.tsx b/webclient/src/components/Game/ZoneRail/ZoneRail.spec.tsx deleted file mode 100644 index 196fc630f..000000000 --- a/webclient/src/components/Game/ZoneRail/ZoneRail.spec.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { screen, fireEvent } from '@testing-library/react'; -import { App } from '@app/types'; - -import { makeStoreState, renderWithProviders } from '../../../__test-utils__'; -import { - makeGameEntry, - makePlayerEntry, - makeZoneEntry, -} from '../../../store/game/__mocks__/fixtures'; -import ZoneRail from './ZoneRail'; - -const baseState = makeStoreState({ - games: { - games: { - 1: makeGameEntry({ - localPlayerId: 1, - players: { - 1: makePlayerEntry({ - zones: { - [App.ZoneName.STACK]: makeZoneEntry({ name: App.ZoneName.STACK }), - [App.ZoneName.EXILE]: makeZoneEntry({ name: App.ZoneName.EXILE }), - [App.ZoneName.GRAVE]: makeZoneEntry({ name: App.ZoneName.GRAVE }), - [App.ZoneName.DECK]: makeZoneEntry({ name: App.ZoneName.DECK, cardCount: 60 }), - }, - }), - }, - }), - }, - }, -}); - -describe('ZoneRail', () => { - it('renders deck, graveyard, and exile top-to-bottom (desktop pile order)', () => { - const { container } = renderWithProviders(, { - 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 deleted file mode 100644 index 90f0e66c6..000000000 --- a/webclient/src/components/Game/ZoneRail/ZoneRail.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { App, Data } from '@app/types'; - -import ZoneStack from '../ZoneStack/ZoneStack'; - -import './ZoneRail.css'; - -export interface ZoneRailProps { - gameId: number; - playerId: number; - onCardHover?: (card: Data.ServerInfo_Card) => void; - onZoneClick?: (playerId: number, zoneName: string) => void; - onZoneContextMenu?: (playerId: number, zoneName: string, event: React.MouseEvent) => void; -} - -const ZONES: Array<{ name: string; label: string }> = [ - { name: App.ZoneName.DECK, label: 'Deck' }, - { name: App.ZoneName.GRAVE, label: 'Graveyard' }, - { name: App.ZoneName.EXILE, label: 'Exile' }, -]; - -function ZoneRail({ - gameId, - playerId, - onCardHover, - onZoneClick, - onZoneContextMenu, -}: ZoneRailProps) { - return ( -
    - {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 deleted file mode 100644 index 786102e8d..000000000 --- a/webclient/src/components/Game/ZoneStack/ZoneStack.css +++ /dev/null @@ -1,57 +0,0 @@ -.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 deleted file mode 100644 index 268887493..000000000 --- a/webclient/src/components/Game/ZoneStack/ZoneStack.spec.tsx +++ /dev/null @@ -1,163 +0,0 @@ -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 deleted file mode 100644 index b8afc65d4..000000000 --- a/webclient/src/components/Game/ZoneStack/ZoneStack.tsx +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 0159ae6ff..000000000 --- a/webclient/src/components/Game/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/Guard/AuthGuard.tsx b/webclient/src/components/Guard/AuthGuard.tsx index 43051a5e9..897556613 100644 --- a/webclient/src/components/Guard/AuthGuard.tsx +++ b/webclient/src/components/Guard/AuthGuard.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Navigate } from 'react-router-dom'; import { ServerSelectors, useAppSelector } from '@app/store'; diff --git a/webclient/src/components/Guard/ModGuard.tsx b/webclient/src/components/Guard/ModGuard.tsx index 5c6bdf529..96844b436 100644 --- a/webclient/src/components/Guard/ModGuard.tsx +++ b/webclient/src/components/Guard/ModGuard.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Navigate } from 'react-router-dom'; import { ServerSelectors, useAppSelector } from '@app/store'; diff --git a/webclient/src/components/InputAction/InputAction.css b/webclient/src/components/InputAction/InputAction.css index 269d8a3f2..25e784a3a 100644 --- a/webclient/src/components/InputAction/InputAction.css +++ b/webclient/src/components/InputAction/InputAction.css @@ -12,7 +12,7 @@ .input-action__item { width: 100%; - height: 100%; + height: 100%; } .input-action__item > div { margin: 0; diff --git a/webclient/src/components/InputAction/InputAction.tsx b/webclient/src/components/InputAction/InputAction.tsx index 6cccbab46..7326c0a35 100644 --- a/webclient/src/components/InputAction/InputAction.tsx +++ b/webclient/src/components/InputAction/InputAction.tsx @@ -1,25 +1,12 @@ -import { Field } from 'react-final-form'; +import React from 'react'; +import { Field } from 'react-final-form' import Button from '@mui/material/Button'; import { InputField } from '..'; import './InputAction.css'; -interface InputActionProps { - action: string; - label: string; - name: string; - validate?: (value: unknown) => string | undefined | false; - disabled?: boolean; -} - -const InputAction = ({ - action, - label, - name, - validate = () => undefined, - disabled = false, -}: InputActionProps) => ( +const InputAction = ({ action, label, name, validate = () => false, disabled = false }) => (
    diff --git a/webclient/src/components/InputField/InputField.tsx b/webclient/src/components/InputField/InputField.tsx index 8a8ead4ae..3299ba383 100644 --- a/webclient/src/components/InputField/InputField.tsx +++ b/webclient/src/components/InputField/InputField.tsx @@ -1,57 +1,57 @@ +import React from 'react'; import { styled } from '@mui/material/styles'; -import TextField, { TextFieldProps } from '@mui/material/TextField'; +import TextField from '@mui/material/TextField'; import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined'; -import type { FinalFormFieldProps } from '../fieldTypes'; - import './InputField.css'; const PREFIX = 'InputField'; const classes = { - root: `${PREFIX}-root`, + root: `${PREFIX}-root` }; const Root = styled('div')(({ theme }) => ({ [`&.${classes.root}`]: { '& .InputField-error': { - color: theme.palette.error.main, + color: theme.palette.error.main }, + '& .InputField-warning': { - color: theme.palette.warning.main, + color: theme.palette.warning.main }, }, })); -type InputFieldProps = - FinalFormFieldProps & - Omit; - -const InputField = ({ input, meta, ...args }: InputFieldProps) => { +const InputField = ({ input, meta, ...args }) => { const { touched, error, warning } = meta; return ( - - {touched && ( + + { touched && (
    - {(error && -
    - {error} - -
    - ) || (warning &&
    {warning}
    )} + { + (error && +
    + {error} + +
    + ) || + + (warning &&
    {warning}
    ) + }
    - )} + ) }
    ); diff --git a/webclient/src/components/KnownHosts/KnownHosts.tsx b/webclient/src/components/KnownHosts/KnownHosts.tsx index 57e0621a3..416de9875 100644 --- a/webclient/src/components/KnownHosts/KnownHosts.tsx +++ b/webclient/src/components/KnownHosts/KnownHosts.tsx @@ -1,6 +1,7 @@ +import { useEffect, useState } from 'react'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; -import { Select, MenuItem, SelectChangeEvent } from '@mui/material'; +import { Select, MenuItem } from '@mui/material'; import Button from '@mui/material/Button'; import FormControl from '@mui/material/FormControl'; import IconButton from '@mui/material/IconButton'; @@ -12,14 +13,21 @@ import AddIcon from '@mui/icons-material/Add'; import EditRoundedIcon from '@mui/icons-material/Edit'; import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined'; +import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks'; import { KnownHostDialog } from '@app/dialogs'; import { getHostPort, HostDTO } from '@app/services'; - -import type { FinalFormFieldProps } from '../fieldTypes'; -import { TestConnection, useKnownHostsComponent } from './useKnownHostsComponent'; +import { ServerTypes } from '@app/store'; +import { App } from '@app/types'; +import Toast from '../Toast/Toast'; import './KnownHosts.css'; +enum TestConnection { + TESTING = 'testing', + FAILED = 'failed', + SUCCESS = 'success', +} + const PREFIX = 'KnownHosts'; const classes = { @@ -49,39 +57,120 @@ const Root = styled('div')(({ theme }) => ({ }, }, })); - -type KnownHostsProps = FinalFormFieldProps & { - disabled?: boolean; -}; - -const KnownHosts = ({ input, meta, disabled }: KnownHostsProps) => { +const KnownHosts = (props: any) => { + const { input, meta, disabled } = props; + const onChange: (value: HostDTO) => void = input.onChange; const { touched, error, warning } = meta; const { t } = useTranslation(); - const { - hosts, - selectedHost, - testConnectionStatus, - dialogState, - onPick, - openAddKnownHostDialog, - openEditKnownHostDialog, - closeKnownHostDialog, - handleDialogRemove, - handleDialogSubmit, - } = useKnownHostsComponent({ onChange: input.onChange }); + const webClient = useWebClient(); + const knownHosts = useKnownHosts(); - const selectedId = selectedHost?.id ?? ''; + const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({ + open: false, + edit: null, + }); - const handleSelectChange = (event: SelectChangeEvent) => { - const value = event.target.value; - if (typeof value === 'number') { - void onPick(value); + const [testingConnection, setTestingConnection] = useState(null); + + const [showCreateToast, setShowCreateToast] = useState(false); + const [showDeleteToast, setShowDeleteToast] = useState(false); + const [showEditToast, setShowEditToast] = useState(false); + + const selectedHost = + knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined; + const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : []; + + const testConnection = (host: HostDTO) => { + setTestingConnection(TestConnection.TESTING); + webClient.request.authentication.testConnection({ ...getHostPort(host) }); + }; + + // Mirror the store's selectedHost into the form field. Also kick off a + // connection test so the user sees the green/red indicator on mount. + useEffect(() => { + if (!selectedHost) { + return; } + onChange(selectedHost); + testConnection(selectedHost); + }, [selectedHost]); + + useReduxEffect( + () => { + setTestingConnection(TestConnection.SUCCESS); + }, + ServerTypes.TEST_CONNECTION_SUCCESSFUL, + [] + ); + + useReduxEffect( + () => { + setTestingConnection(TestConnection.FAILED); + }, + ServerTypes.TEST_CONNECTION_FAILED, + [] + ); + + const onPick = async (host: HostDTO) => { + if (knownHosts.status !== LoadingState.READY) { + return; + } + onChange(host); + await knownHosts.select(host.id!); + testConnection(host); + }; + + const openAddKnownHostDialog = () => { + setDialogState((s) => ({ ...s, open: true, edit: null })); + }; + + const openEditKnownHostDialog = (host: HostDTO) => { + setDialogState((s) => ({ ...s, open: true, edit: host })); + }; + + const closeKnownHostDialog = () => { + setDialogState((s) => ({ ...s, open: false })); + }; + + const handleDialogRemove = async ({ id }: { id: number }) => { + if (knownHosts.status !== LoadingState.READY) { + return; + } + await knownHosts.remove(id); + closeKnownHostDialog(); + setShowDeleteToast(true); + }; + + const handleDialogSubmit = async ({ + id, + name, + host, + port, + }: { + id?: number; + name: string; + host: string; + port: string; + }) => { + if (knownHosts.status !== LoadingState.READY) { + return; + } + + if (id) { + await knownHosts.update(id, { name, host, port }); + setShowEditToast(true); + } else { + const newHost: App.Host = { name, host, port, editable: true }; + await knownHosts.add(newHost); + setShowCreateToast(true); + } + + closeKnownHostDialog(); }; return ( - + {touched && (
    @@ -102,25 +191,25 @@ const KnownHosts = ({ input, meta, disabled }: KnownHostsProps) => { label="Host" margin="dense" name="host" - value={selectedId} - fullWidth - onChange={handleSelectChange} + value={selectedHost ?? ''} + fullWidth={true} + onChange={(e) => onPick(e.target.value as unknown as HostDTO)} disabled={disabled} > - - {hosts.map((host) => { + {hosts.map((host, index) => { const hostPort = getHostPort(host); return ( - +
    -
    - {testConnectionStatus === TestConnection.FAILED ? ( +
    + {testingConnection === TestConnection.FAILED ? ( ) : ( @@ -156,11 +245,20 @@ const KnownHosts = ({ input, meta, disabled }: KnownHostsProps) => { + setShowCreateToast(false)}> + {t('KnownHosts.toast', { mode: 'created' })} + + setShowDeleteToast(false)}> + {t('KnownHosts.toast', { mode: 'deleted' })} + + setShowEditToast(false)}> + {t('KnownHosts.toast', { mode: 'edited' })} + ); }; diff --git a/webclient/src/components/KnownHosts/useKnownHostsComponent.ts b/webclient/src/components/KnownHosts/useKnownHostsComponent.ts deleted file mode 100644 index 8ea7a2618..000000000 --- a/webclient/src/components/KnownHosts/useKnownHostsComponent.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { useToast } from '@app/components'; -import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks'; -import { getHostPort, HostDTO } from '@app/services'; -import { ServerDispatch, ServerSelectors, ServerTypes, useAppSelector } from '@app/store'; -import { App } from '@app/types'; - -export enum TestConnection { - TESTING = 'testing', - FAILED = 'failed', - SUCCESS = 'success', -} - -export interface KnownHostsComponent { - hosts: App.Host[]; - selectedHost: App.Host | undefined; - testConnectionStatus: TestConnection | null; - dialogState: { open: boolean; edit: HostDTO | null }; - onPick: (id: number) => Promise; - openAddKnownHostDialog: () => void; - openEditKnownHostDialog: (host: HostDTO) => void; - closeKnownHostDialog: () => void; - handleDialogRemove: (args: { id: number }) => Promise; - handleDialogSubmit: (args: { - id?: number; - name: string; - host: string; - port: string; - }) => Promise; -} - -export interface UseKnownHostsComponentArgs { - onChange: (value: HostDTO) => void; -} - -type ToastMode = 'created' | 'deleted' | 'edited'; - -export function useKnownHostsComponent({ - onChange, -}: UseKnownHostsComponentArgs): KnownHostsComponent { - const webClient = useWebClient(); - const knownHosts = useKnownHosts(); - const { t } = useTranslation(); - - const [toastMode, setToastMode] = useState('created'); - const knownHostToast = useToast({ - key: 'known-hosts-action', - children: t('KnownHosts.toast', { mode: toastMode }), - }); - - const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({ - open: false, - edit: null, - }); - - // UI status lives in redux (see ServerSelectors.getTestConnectionStatus) so - // the LoginForm can gate its submit button + hashing-capability UI on the - // same signal. Tracks-the-host-pending lives in a ref — redux doesn't know - // the Host.id, and we need it to persist `supportsHashedPassword` on success. - const testConnectionStatus = useAppSelector(ServerSelectors.getTestConnectionStatus) as - | TestConnection - | null; - const pendingTestRef = useRef(null); - - const selectedHost = - knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined; - const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : []; - - const testConnection = (host: HostDTO) => { - pendingTestRef.current = host; - ServerDispatch.testConnectionStarted(); - webClient.request.authentication.testConnection({ ...getHostPort(host) }); - }; - - useEffect(() => { - if (!selectedHost) { - return; - } - onChange(selectedHost); - testConnection(selectedHost); - }, [selectedHost]); - - useReduxEffect<{ supportsHashedPassword: boolean }>(({ payload: { supportsHashedPassword } }) => { - const host = pendingTestRef.current; - if (!host) { - return; - } - pendingTestRef.current = null; - - if (host.id != null && host.supportsHashedPassword !== supportsHashedPassword) { - void knownHosts.update(host.id, { supportsHashedPassword }); - } - }, ServerTypes.TEST_CONNECTION_SUCCESSFUL, []); - - useReduxEffect(() => { - pendingTestRef.current = null; - }, ServerTypes.TEST_CONNECTION_FAILED, []); - - const fireToast = (mode: ToastMode) => { - setToastMode(mode); - knownHostToast.openToast(); - }; - - const onPick = async (id: number) => { - if (knownHosts.status !== LoadingState.READY) { - return; - } - const host = knownHosts.value?.hosts.find((h) => h.id === id); - if (!host) { - return; - } - onChange(host); - await knownHosts.select(id); - testConnection(host); - }; - - const openAddKnownHostDialog = () => { - setDialogState((s) => ({ ...s, open: true, edit: null })); - }; - - const openEditKnownHostDialog = (host: HostDTO) => { - setDialogState((s) => ({ ...s, open: true, edit: host })); - }; - - const closeKnownHostDialog = () => { - setDialogState((s) => ({ ...s, open: false })); - }; - - const handleDialogRemove = async ({ id }: { id: number }) => { - if (knownHosts.status !== LoadingState.READY) { - return; - } - await knownHosts.remove(id); - closeKnownHostDialog(); - fireToast('deleted'); - }; - - const handleDialogSubmit = async ({ - id, - name, - host, - port, - }: { - id?: number; - name: string; - host: string; - port: string; - }) => { - if (knownHosts.status !== LoadingState.READY) { - return; - } - - if (id) { - await knownHosts.update(id, { name, host, port }); - fireToast('edited'); - } else { - const newHost: App.Host = { name, host, port, editable: true }; - await knownHosts.add(newHost); - fireToast('created'); - } - - closeKnownHostDialog(); - }; - - return { - hosts, - selectedHost, - testConnectionStatus, - dialogState, - onPick, - openAddKnownHostDialog, - openEditKnownHostDialog, - closeKnownHostDialog, - handleDialogRemove, - handleDialogSubmit, - }; -} diff --git a/webclient/src/components/LanguageDropdown/LanguageDropdown.css b/webclient/src/components/LanguageDropdown/LanguageDropdown.css index 1af4f4efa..c4db3d0d0 100644 --- a/webclient/src/components/LanguageDropdown/LanguageDropdown.css +++ b/webclient/src/components/LanguageDropdown/LanguageDropdown.css @@ -1,3 +1,6 @@ +.LanguageDropdown { +} + .LanguageDropdown-item { display: flex; align-items: center; diff --git a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx index cadc715fc..d98defb2b 100644 --- a/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx +++ b/webclient/src/components/LanguageDropdown/LanguageDropdown.tsx @@ -1,5 +1,7 @@ + +import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Select, MenuItem, SelectChangeEvent } from '@mui/material'; +import { Select, MenuItem } from '@mui/material'; import FormControl from '@mui/material/FormControl'; import { Images } from '@app/images'; @@ -9,43 +11,48 @@ import './LanguageDropdown.css'; const LanguageDropdown = () => { const { t, i18n } = useTranslation(); - const currentLanguage = i18n.resolvedLanguage ?? i18n.language ?? ''; + // i18next `resolvedLanguage` is undefined until a registered resource matches; + // MUI Select requires a concrete, in-range value. + const [language, setLanguage] = useState(i18n.resolvedLanguage ?? i18n.language ?? ''); - const onLanguageChange = (event: SelectChangeEvent) => { - const next = event.target.value as App.Language; - if (next !== currentLanguage) { - void i18n.changeLanguage(next); + useEffect(() => { + if (language !== i18n.resolvedLanguage) { + i18n.changeLanguage(language); } - }; + }, [language]); return ( - + - ); + ) }; export default LanguageDropdown; diff --git a/webclient/src/components/Message/CardCallout.tsx b/webclient/src/components/Message/CardCallout.tsx index ac80b366f..3872ae17f 100644 --- a/webclient/src/components/Message/CardCallout.tsx +++ b/webclient/src/components/Message/CardCallout.tsx @@ -1,11 +1,13 @@ + +import React, { useMemo, useState } from 'react'; import { styled } from '@mui/material/styles'; import Popover from '@mui/material/Popover'; +import { CardDTO, TokenDTO } from '@app/services'; + import CardDetails from '../CardDetails/CardDetails'; import TokenDetails from '../TokenDetails/TokenDetails'; -import { useCardCallout } from './useCardCallout'; - import './CardCallout.css'; const PREFIX = 'CardCallout'; @@ -25,13 +27,32 @@ const Root = styled('span')(() => ({ } })); -interface CardCalloutProps { - name: string; -} +const CardCallout = ({ name }) => { + const [card, setCard] = useState(null); + const [token, setToken] = useState(null); + const [anchorEl, setAnchorEl] = useState(null); -const CardCallout = ({ name }: CardCalloutProps) => { - const { card, token, anchorEl, open, handlePopoverOpen, handlePopoverClose } = - useCardCallout(name); + useMemo(async () => { + const card = await CardDTO.get(name); + if (card) { + return setCard(card) + } + + const token = await TokenDTO.get(name); + if (token) { + return setToken(token); + } + }, [name]); + + const handlePopoverOpen = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handlePopoverClose = () => { + setAnchorEl(null); + }; + + const open = Boolean(anchorEl); return ( @@ -60,8 +81,8 @@ const CardCallout = ({ name }: CardCalloutProps) => { }} >
    - {card && ()} - {token && ()} + { card && () } + { token && () }
    ) diff --git a/webclient/src/components/Message/Message.tsx b/webclient/src/components/Message/Message.tsx index ad3466e07..bb5617242 100644 --- a/webclient/src/components/Message/Message.tsx +++ b/webclient/src/components/Message/Message.tsx @@ -1,21 +1,14 @@ + +import React, { useEffect, useState } from 'react'; + import { NavLink, generatePath } from 'react-router-dom'; -import type { ReactNode } from 'react'; import { App } from '@app/types'; import CardCallout from './CardCallout'; -import { useParsedMessage } from './useMessage'; import './Message.css'; -interface MessagePayload { - message: string; -} - -interface MessageProps { - message: MessagePayload; -} - -const Message = ({ message: { message } }: MessageProps) => ( +const Message = ({ message: { message } }) => (
    @@ -23,33 +16,42 @@ const Message = ({ message: { message } }: MessageProps) => (
    ); -interface ParsedMessageProps { - message: string; -} +const ParsedMessage = ({ message }) => { + const [messageChunks, setMessageChunks] = useState(null); + const [name, setName] = useState(null); -const ParsedMessage = ({ message }: ParsedMessageProps) => { - const { name, chunks } = useParsedMessage(message, parseChunks); + useEffect(() => { + const name = message.match(App.MESSAGE_SENDER_REGEX); + + if (name) { + setName(name[1]); + } + + setMessageChunks(parseMessage(message)); + }, [message]); return (
    - {name && (:)} - {chunks} + { name && (:) } + { messageChunks }
    ); }; -interface PlayerLinkProps { - name: string; - label?: string; -} - -const PlayerLink = ({ name, label = name }: PlayerLinkProps) => ( +const PlayerLink = ({ name, label = name }) => ( {label} ); -function parseChunks(chunk: string, index: number): ReactNode { +function parseMessage(message) { + return message.replace(App.MESSAGE_SENDER_REGEX, '') + .split(App.CARD_CALLOUT_REGEX) + .filter(chunk => !!chunk) + .map(parseChunks); +} + +function parseChunks(chunk, index) { if (chunk.match(App.CARD_CALLOUT_REGEX)) { const name = chunk.replace(App.CALLOUT_BOUNDARY_REGEX, '').trim(); return (); @@ -66,9 +68,9 @@ function parseChunks(chunk: string, index: number): ReactNode { return chunk; } -function parseUrlChunk(chunk: string): ReactNode { +function parseUrlChunk(chunk) { return chunk.split(App.URL_REGEX) - .filter((urlChunk) => !!urlChunk) + .filter(urlChunk => !!urlChunk) .map((urlChunk, index) => { if (urlChunk.match(App.URL_REGEX)) { return ({urlChunk}); @@ -78,15 +80,15 @@ function parseUrlChunk(chunk: string): ReactNode { }); } -function parseMentionChunk(chunk: string): ReactNode { +function parseMentionChunk(chunk) { return chunk.split(App.MENTION_REGEX) - .filter((mentionChunk) => !!mentionChunk) + .filter(mentionChunk => !!mentionChunk) .map((mentionChunk, index) => { const mention = mentionChunk.match(App.MENTION_REGEX); if (mention) { const name = mention[0].substr(1); - return (); + return (); } return mentionChunk; diff --git a/webclient/src/components/Message/useCardCallout.ts b/webclient/src/components/Message/useCardCallout.ts deleted file mode 100644 index f6e92a220..000000000 --- a/webclient/src/components/Message/useCardCallout.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useMemo, useState } from 'react'; - -import { CardDTO, TokenDTO } from '@app/services'; - -export interface CardCallout { - card: CardDTO | null; - token: TokenDTO | null; - anchorEl: Element | null; - open: boolean; - handlePopoverOpen: (event: React.MouseEvent) => void; - handlePopoverClose: () => void; -} - -export function useCardCallout(name: string): CardCallout { - const [card, setCard] = useState(null); - const [token, setToken] = useState(null); - const [anchorEl, setAnchorEl] = useState(null); - - useMemo(async () => { - const c = await CardDTO.get(name); - if (c) { - return setCard(c); - } - - const t = await TokenDTO.get(name); - if (t) { - return setToken(t); - } - }, [name]); - - const handlePopoverOpen = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handlePopoverClose = () => { - setAnchorEl(null); - }; - - const open = Boolean(anchorEl); - - return { card, token, anchorEl, open, handlePopoverOpen, handlePopoverClose }; -} diff --git a/webclient/src/components/Message/useMessage.ts b/webclient/src/components/Message/useMessage.ts deleted file mode 100644 index 1139adadc..000000000 --- a/webclient/src/components/Message/useMessage.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMemo, type ReactNode } from 'react'; - -import { App } from '@app/types'; - -export interface ParsedMessage { - name: string | null; - chunks: ReactNode[]; -} - -export type ChunkParser = (chunk: string, index: number) => ReactNode; - -// `parseChunk` must be a stable reference across renders (module-level function -// or `useCallback`). Passing a fresh closure every render will thrash the memo. -export function useParsedMessage(message: string, parseChunk: ChunkParser): ParsedMessage { - return useMemo(() => { - const match = message.match(App.MESSAGE_SENDER_REGEX); - const name = match ? match[1] : null; - return { - name, - chunks: parseMessage(message, parseChunk), - }; - }, [message, parseChunk]); -} - -export function parseMessage(message: string, parseChunk: ChunkParser): ReactNode[] { - return message - .replace(App.MESSAGE_SENDER_REGEX, '') - .split(App.CARD_CALLOUT_REGEX) - .filter((chunk) => !!chunk) - .map(parseChunk); -} diff --git a/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx b/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx index 2f952f021..95f10f62c 100644 --- a/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx +++ b/webclient/src/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges.tsx @@ -1,23 +1,24 @@ -import { ReactNode, useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; -interface ScrollToBottomOnChangesProps { - content: ReactNode; - changes: unknown; -} +const ScrollToBottomOnChanges = ({ content, changes }) => { + const messagesEndRef = useRef(null); -const ScrollToBottomOnChanges = ({ content, changes }: ScrollToBottomOnChangesProps) => { - const messagesEndRef = useRef(null); + const scrollToBottom = () => { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) + } - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [changes]); + useEffect(scrollToBottom, [changes]); + + const styling = { + height: '100%' + }; return ( -
    +
    {content}
    - ); -}; + ) +} export default ScrollToBottomOnChanges; diff --git a/webclient/src/components/SelectField/SelectField.tsx b/webclient/src/components/SelectField/SelectField.tsx index d01ba7e19..fdbef0e9c 100644 --- a/webclient/src/components/SelectField/SelectField.tsx +++ b/webclient/src/components/SelectField/SelectField.tsx @@ -1,29 +1,14 @@ +import React from 'react'; import FormControl from '@mui/material/FormControl'; import InputLabel from '@mui/material/InputLabel'; import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; -import type { FinalFormFieldProps } from '../fieldTypes'; - import './SelectField.css'; -export interface SelectFieldOption { - value: V; - label: string; -} - -interface SelectFieldProps extends FinalFormFieldProps { - label: string; - options: SelectFieldOption[]; -} - -const SelectField = ({ - input, - label, - options, -}: SelectFieldProps) => { - const id = `${label}-select-field`; - const labelId = `${id}-label`; +const SelectField = ({ input, label, options, value }) => { + const id = label + '-select-field'; + const labelId = id + '-label'; return ( @@ -31,15 +16,13 @@ const SelectField = ({ + value={value} + { ...input } + >{ + options.map((option, index) => ( + { option } + )) + } ); }; diff --git a/webclient/src/components/ThreePaneLayout/ThreePaneLayout.css b/webclient/src/components/ThreePaneLayout/ThreePaneLayout.css index 19597eacd..f439dee5c 100644 --- a/webclient/src/components/ThreePaneLayout/ThreePaneLayout.css +++ b/webclient/src/components/ThreePaneLayout/ThreePaneLayout.css @@ -12,7 +12,7 @@ .three-pane-layout .grid-main { display: flex; - flex-direction: column; + flex-direction: column; } .three-pane-layout .grid-main__top { diff --git a/webclient/src/components/Toast/Toast.tsx b/webclient/src/components/Toast/Toast.tsx index 2f69869cb..faf4af586 100644 --- a/webclient/src/components/Toast/Toast.tsx +++ b/webclient/src/components/Toast/Toast.tsx @@ -1,53 +1,59 @@ -import { ReactNode, SyntheticEvent } from 'react'; +import * as React from 'react' +import { createPortal } from 'react-dom' -import Alert, { AlertColor } from '@mui/material/Alert'; +import Alert from '@mui/material/Alert'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import Slide, { SlideProps } from '@mui/material/Slide'; +import Slide from '@mui/material/Slide'; import Snackbar from '@mui/material/Snackbar'; const iconMapping = { - success: , -}; - -export interface ToastProps { - open: boolean; - onClose: (event?: SyntheticEvent) => void; - severity?: AlertColor; - autoHideDuration?: number; - children?: ReactNode; + success: } -// MUI's Snackbar already self-portals to the end of document.body; adding our -// own createPortal wrapper would leak
    s under React StrictMode's double- -// invoked effects. Render the Snackbar directly. -function Toast({ open, onClose, severity = 'success', autoHideDuration = 10000, children }: ToastProps) { - const handleClose = (event?: SyntheticEvent | Event, reason?: string) => { +function Toast(props) { + const { open, onClose, severity = 'success', autoHideDuration = 10000, children } = props + + const rootElemRef = React.useRef(document.createElement('div')); + + React.useEffect(() => { + document.body.appendChild(rootElemRef.current) + return () => { + rootElemRef.current.remove(); + } + }, [rootElemRef]) + + const handleClose = (event?: React.SyntheticEvent, reason?: string) => { if (reason === 'clickaway') { return; } - onClose(event as SyntheticEvent | undefined); + onClose(event); }; - return ( + const node = ( - + ) + if (!rootElemRef.current) { + return null + } + + return createPortal( + node, + rootElemRef.current ); } -function TransitionLeft(props: SlideProps) { +function TransitionLeft(props) { return ; } -export default Toast; +export default Toast diff --git a/webclient/src/components/Toast/ToastContext.tsx b/webclient/src/components/Toast/ToastContext.tsx index 9978c1680..0b92fdde3 100644 --- a/webclient/src/components/Toast/ToastContext.tsx +++ b/webclient/src/components/Toast/ToastContext.tsx @@ -1,77 +1,71 @@ -import { createContext, FC, PropsWithChildren, ReactNode, useContext, useEffect, useReducer } from 'react'; +import { createContext, FC, PropsWithChildren, ReactChild, ReactNode, useContext, useEffect, useReducer, Context } from 'react' -import { ACTIONS, initialState, reducer, ToastEntry } from './reducer'; -import Toast from './Toast'; +import { ACTIONS, initialState, reducer } from './reducer'; +import Toast from './Toast' -interface ToastContextValue { - toasts: Record; - addToast: (key: string, children: ReactNode) => void; - openToast: (key: string) => void; - closeToast: (key: string) => void; - removeToast: (key: string) => void; +interface ToastEntry { + isOpen: boolean, + children: ReactChild, } -const ToastContext = createContext({ - toasts: {}, - addToast: () => {}, - openToast: () => {}, - closeToast: () => {}, - removeToast: () => {}, +interface ToastState { + toasts: Map, + addToast: (key, children) => void, + openToast: (key) => void, + closeToast: (key) => void, + removeToast: (key) => void, +} + +const ToastContext: Context = createContext({ + toasts: new Map(), + addToast: (_key, _children) => {}, + openToast: (_key) => {}, + closeToast: (_key) => {}, + removeToast: (_key) => {}, }); -export const ToastProvider: FC = ({ children }) => { - const [state, dispatch] = useReducer(reducer, initialState); - const providerState: ToastContextValue = { +export const ToastProvider: FC = (props) => { + const { children } = props + const [state, dispatch] = useReducer(reducer, initialState) + const providerState = { toasts: state.toasts, - addToast: (key, toastChildren) => dispatch({ type: ACTIONS.ADD_TOAST, payload: { key, children: toastChildren } }), - openToast: (key) => dispatch({ type: ACTIONS.OPEN_TOAST, payload: { key } }), - closeToast: (key) => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } }), - removeToast: (key) => dispatch({ type: ACTIONS.REMOVE_TOAST, payload: { key } }), - }; + addToast: (key, children) => dispatch({ type: ACTIONS.ADD_TOAST, payload: { key, children } }), + openToast: key => dispatch({ type: ACTIONS.OPEN_TOAST, payload: { key } }), + closeToast: key => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } }), + removeToast: key => dispatch({ type: ACTIONS.REMOVE_TOAST, payload: { key } }), + } return ( {children}
    - {Object.entries(state.toasts).map(([key, entry]) => ( - dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } })} - > - {entry.children} - - ))} + {Array.from(state.toasts).map(([key, value]) => { + const { isOpen, children } = value; + return ( + dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } })}> + {children} + + ) + })}
    - ); -}; + ) +} export interface ToastHookOptions { - key: string; - children: ReactNode; + key: string, + children: ReactNode } -export interface ToastHandle { - openToast: () => void; - closeToast: () => void; - removeToast: () => void; -} +export function useToast({ key, children }) { + const { addToast, openToast, closeToast, removeToast } = useContext(ToastContext) -export function useToast({ key, children }: ToastHookOptions): ToastHandle { - const { addToast, openToast, closeToast, removeToast } = useContext(ToastContext); - - // Toast children are captured at registration; re-registering every render - // would churn provider state. Intentional mount/unmount-only effect keyed on `key`. useEffect(() => { - addToast(key, children); - return () => { - removeToast(key); - }; - }, [key]); + addToast(key, children) + }, []) return { openToast: () => openToast(key), closeToast: () => closeToast(key), removeToast: () => removeToast(key), - }; + } } diff --git a/webclient/src/components/Toast/reducer.ts b/webclient/src/components/Toast/reducer.ts index e7b1da72f..3f600db52 100644 --- a/webclient/src/components/Toast/reducer.ts +++ b/webclient/src/components/Toast/reducer.ts @@ -1,88 +1,61 @@ -import type { ReactNode } from 'react'; - export const ACTIONS = { ADD_TOAST: 'ADD_TOAST', OPEN_TOAST: 'OPEN_TOAST', CLOSE_TOAST: 'CLOSE_TOAST', REMOVE_TOAST: 'REMOVE_TOAST', -} as const; - -export interface ToastEntry { - isOpen: boolean; - children: ReactNode; - // Refcount of active registrants for this key. Incremented on ADD, decremented on REMOVE. - // Prevents two mounted callers sharing a key from stomping each other's registration. - refs: number; } -export interface ToastState { - toasts: Record; +export const initialState = { + toasts: {} } -export const initialState: ToastState = { - toasts: {}, -}; +export function reducer(state, { type, payload }) { + const { key, children } = payload; -export type ToastAction = - | { type: typeof ACTIONS.ADD_TOAST; payload: { key: string; children: ReactNode } } - | { type: typeof ACTIONS.OPEN_TOAST; payload: { key: string } } - | { type: typeof ACTIONS.CLOSE_TOAST; payload: { key: string } } - | { type: typeof ACTIONS.REMOVE_TOAST; payload: { key: string } }; - -export function reducer(state: ToastState, action: ToastAction): ToastState { - switch (action.type) { + switch (type) { case ACTIONS.ADD_TOAST: { - const { key, children } = action.payload; - const existing = state.toasts[key]; return { ...state, toasts: { ...state.toasts, - [key]: existing - ? { ...existing, refs: existing.refs + 1 } - : { isOpen: false, children, refs: 1 }, + [key]: { + isOpen: false, + children, + }, }, }; } case ACTIONS.OPEN_TOAST: { - const { key } = action.payload; - const existing = state.toasts[key]; - if (!existing) { - return state; - } return { ...state, - toasts: { ...state.toasts, [key]: { ...existing, isOpen: true } }, + toasts: { + ...state.toasts, + [key]: { + ...state.toasts[key], + isOpen: true, + }, + }, }; } case ACTIONS.CLOSE_TOAST: { - const { key } = action.payload; - const existing = state.toasts[key]; - if (!existing) { - return state; - } return { ...state, - toasts: { ...state.toasts, [key]: { ...existing, isOpen: false } }, + toasts: { + ...state.toasts, + [key]: { + ...state.toasts[key], + isOpen: false, + }, + }, }; } case ACTIONS.REMOVE_TOAST: { - const { key } = action.payload; - const existing = state.toasts[key]; - if (!existing) { - return state; - } - if (existing.refs > 1) { - return { - ...state, - toasts: { ...state.toasts, [key]: { ...existing, refs: existing.refs - 1 } }, - }; - } - const nextToasts = { ...state.toasts }; - delete nextToasts[key]; - return { ...state, toasts: nextToasts }; + const newState = { ...state }; + delete newState.toasts[key]; + + return newState; } default: - return state; + throw Error('Please pick an available action') } } diff --git a/webclient/src/components/Token/Token.tsx b/webclient/src/components/Token/Token.tsx index 5daa4e2a9..9a9b4768d 100644 --- a/webclient/src/components/Token/Token.tsx +++ b/webclient/src/components/Token/Token.tsx @@ -1,3 +1,6 @@ +// eslint-disable-next-line +import React, { useMemo, useState } from 'react'; + import { TokenDTO } from '@app/services'; import './Token.css'; @@ -7,11 +10,10 @@ interface TokenProps { } const Token = ({ token }: TokenProps) => { - if (!token) { - return null; - } - const set = Array.isArray(token.set) ? token.set[0] : token.set; - return {token.name?.value}; -}; + const set = Array.isArray(token?.set) ? token?.set[0] : token?.set; + return token && ( + {token?.name?.value} + ); +} export default Token; diff --git a/webclient/src/components/TokenDetails/TokenDetails.tsx b/webclient/src/components/TokenDetails/TokenDetails.tsx index 460173627..9166a554f 100644 --- a/webclient/src/components/TokenDetails/TokenDetails.tsx +++ b/webclient/src/components/TokenDetails/TokenDetails.tsx @@ -1,3 +1,6 @@ +// eslint-disable-next-line +import React, { useMemo, useState } from 'react'; + import { TokenDTO } from '@app/services'; import Token from '../Token/Token'; @@ -18,7 +21,7 @@ const TokenDetails = ({ token }: TokenProps) => {
    { - token && props && ( + token && (
    @@ -26,42 +29,52 @@ const TokenDetails = ({ token }: TokenProps) => { {token.name?.value}
    - {props.pt?.value && ( -
    - P/T: - {props.pt.value} -
    - )} + { + (!props.pt?.value) ? null : ( +
    + P/T: + {props.pt.value} +
    + ) + } - {props.colors?.value && ( -
    - Color(s): - {props.colors.value} -
    - )} + { + !props.colors?.value ? null : ( +
    + Color(s): + {props.colors.value} +
    + ) + } - {props.maintype?.value && ( -
    - Main Type: - {props.maintype.value} -
    - )} + { + !props.maintype?.value ? null : ( +
    + Main Type: + {props.maintype.value} +
    + ) + } - {props.type?.value && ( -
    - Type: - {props.type.value} -
    - )} + { + !props.type?.value ? null : ( +
    + Type: + {props.type.value} +
    + ) + }
    - {token.text?.value && ( -
    -
    - {token.text.value} + { + !token.text?.value ? null : ( +
    +
    + {token.text.value} +
    -
    - )} + ) + }
    ) } diff --git a/webclient/src/components/UserDisplay/UserDisplay.tsx b/webclient/src/components/UserDisplay/UserDisplay.tsx index 994ae8d99..c3d46446e 100644 --- a/webclient/src/components/UserDisplay/UserDisplay.tsx +++ b/webclient/src/components/UserDisplay/UserDisplay.tsx @@ -1,38 +1,59 @@ + +import React, { useState } from 'react'; import { NavLink, generatePath } from 'react-router-dom'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import { Images } from '@app/images'; +import { useWebClient } from '@app/hooks'; +import { ServerSelectors } from '@app/store'; import { App, Data } from '@app/types'; - -import { useUserDisplay } from './useUserDisplay'; +import { useAppSelector } from '@app/store'; import './UserDisplay.css'; -interface UserDisplayProps { - user: Data.ServerInfo_User; -} const UserDisplay = ({ user }: UserDisplayProps) => { + const buddyList = useAppSelector(state => ServerSelectors.getBuddyList(state)); + const ignoreList = useAppSelector(state => ServerSelectors.getIgnoreList(state)); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const webClient = useWebClient(); + const { name, country } = user; - const { - position, - isABuddy, - isIgnored, - handleClick, - handleClose, - onAddBuddy, - onRemoveBuddy, - onAddIgnore, - onRemoveIgnore, - } = useUserDisplay(name); + + const handleClick = (event) => { + event.preventDefault(); + setPosition({ x: event.clientX + 2, y: event.clientY + 4 }); + }; + + const handleClose = () => setPosition(null); + + const isABuddy = Boolean(buddyList[user.name]); + const isIgnored = Boolean(ignoreList[user.name]); + + const onAddBuddy = () => { + webClient.request.session.addToBuddyList(user.name); + handleClose(); + }; + const onRemoveBuddy = () => { + webClient.request.session.removeFromBuddyList(user.name); + handleClose(); + }; + const onAddIgnore = () => { + webClient.request.session.addToIgnoreList(user.name); + handleClose(); + }; + const onRemoveIgnore = () => { + webClient.request.session.removeFromIgnoreList(user.name); + handleClose(); + }; return (
    - {country} + {country}
    {name}
    @@ -66,4 +87,8 @@ const UserDisplay = ({ user }: UserDisplayProps) => { ); }; +interface UserDisplayProps { + user: Data.ServerInfo_User; +} + export default UserDisplay; diff --git a/webclient/src/components/UserDisplay/useUserDisplay.ts b/webclient/src/components/UserDisplay/useUserDisplay.ts deleted file mode 100644 index 92e27e665..000000000 --- a/webclient/src/components/UserDisplay/useUserDisplay.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useState } from 'react'; - -import { useWebClient } from '@app/hooks'; -import { ServerSelectors, useAppSelector } from '@app/store'; - -export interface UserDisplay { - position: { x: number; y: number } | null; - isABuddy: boolean; - isIgnored: boolean; - handleClick: (event: React.MouseEvent) => void; - handleClose: () => void; - onAddBuddy: () => void; - onRemoveBuddy: () => void; - onAddIgnore: () => void; - onRemoveIgnore: () => void; -} - -export function useUserDisplay(userName: string): UserDisplay { - const buddyList = useAppSelector((state) => ServerSelectors.getBuddyList(state)); - const ignoreList = useAppSelector((state) => ServerSelectors.getIgnoreList(state)); - const [position, setPosition] = useState<{ x: number; y: number } | null>(null); - const webClient = useWebClient(); - - const handleClick = (event: React.MouseEvent) => { - event.preventDefault(); - setPosition({ x: event.clientX + 2, y: event.clientY + 4 }); - }; - - const handleClose = () => setPosition(null); - - const isABuddy = Boolean(buddyList[userName]); - const isIgnored = Boolean(ignoreList[userName]); - - const onAddBuddy = () => { - webClient.request.session.addToBuddyList(userName); - handleClose(); - }; - const onRemoveBuddy = () => { - webClient.request.session.removeFromBuddyList(userName); - handleClose(); - }; - const onAddIgnore = () => { - webClient.request.session.addToIgnoreList(userName); - handleClose(); - }; - const onRemoveIgnore = () => { - webClient.request.session.removeFromIgnoreList(userName); - handleClose(); - }; - - return { - position, - isABuddy, - isIgnored, - handleClick, - handleClose, - onAddBuddy, - onRemoveBuddy, - onAddIgnore, - onRemoveIgnore, - }; -} diff --git a/webclient/src/components/VirtualList/VirtualList.tsx b/webclient/src/components/VirtualList/VirtualList.tsx index da9bf8e64..90c15436e 100644 --- a/webclient/src/components/VirtualList/VirtualList.tsx +++ b/webclient/src/components/VirtualList/VirtualList.tsx @@ -1,16 +1,12 @@ -import { ReactNode } from 'react'; +// eslint-disable-next-line +import React from "react"; + import { List, RowComponentProps } from 'react-window'; import './VirtualList.css'; interface RowData { - items: ReactNode[]; -} - -interface VirtualListProps { - items: ReactNode[]; - className?: string; - size?: number; + items: any[]; } const Row = ({ index, style, items }: RowComponentProps) => ( @@ -19,7 +15,7 @@ const Row = ({ index, style, items }: RowComponentProps) => (
    ); -const VirtualList = ({ items, className = '', size = 30 }: VirtualListProps) => ( +const VirtualList = ({ items, className = '', size = 30 }) => (
    className={`virtual-list__list ${className}`} diff --git a/webclient/src/components/fieldTypes.ts b/webclient/src/components/fieldTypes.ts deleted file mode 100644 index 43af60a13..000000000 --- a/webclient/src/components/fieldTypes.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { FieldRenderProps } from 'react-final-form'; - -export type FinalFormFieldProps = FieldRenderProps; diff --git a/webclient/src/components/index.ts b/webclient/src/components/index.ts index e8a52ca6e..5337b3b10 100644 --- a/webclient/src/components/index.ts +++ b/webclient/src/components/index.ts @@ -1,5 +1,3 @@ -export type { FinalFormFieldProps } from './fieldTypes'; - // Common components export { default as Card } from './Card/Card'; export { default as CardDetails } from './CardDetails/CardDetails'; @@ -22,6 +20,3 @@ 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/Account/Account.tsx b/webclient/src/containers/Account/Account.tsx index 556ff442d..3902b31d6 100644 --- a/webclient/src/containers/Account/Account.tsx +++ b/webclient/src/containers/Account/Account.tsx @@ -1,3 +1,4 @@ +import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import Button from '@mui/material/Button'; @@ -5,27 +6,49 @@ import ListItemButton from '@mui/material/ListItemButton'; import Paper from '@mui/material/Paper'; import { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components'; +import { useWebClient } from '@app/hooks'; +import { ServerSelectors } from '@app/store'; import Layout from '../Layout/Layout'; +import { useAppSelector } from '@app/store'; -import AddUserForm from './AddUserForm'; -import { useAccount } from './useAccount'; +import AddToBuddies from './AddToBuddies'; +import AddToIgnore from './AddToIgnore'; import './Account.css'; const Account = () => { + const buddyList = useAppSelector(state => ServerSelectors.getSortedBuddyList(state)); + const ignoreList = useAppSelector(state => ServerSelectors.getSortedIgnoreList(state)); + const serverName = useAppSelector(state => ServerSelectors.getName(state)); + const serverVersion = useAppSelector(state => ServerSelectors.getVersion(state)); + const user = useAppSelector(state => ServerSelectors.getUser(state)); + const webClient = useWebClient(); + const { country, realName, name, userLevel, accountageSecs, avatarBmp } = user || {}; + + const avatarUrl = useMemo(() => { + if (!avatarBmp) { + return ''; + } + return URL.createObjectURL(new Blob([avatarBmp as BlobPart], { type: 'image/png' })); + }, [avatarBmp]); + + useEffect(() => { + return () => { + if (avatarUrl) { + URL.revokeObjectURL(avatarUrl); + } + }; + }, [avatarUrl]); + const { t } = useTranslation(); - const { - buddyList, - ignoreList, - serverName, - serverVersion, - user, - avatarUrl, - handleAddToBuddies, - handleAddToIgnore, - handleDisconnect, - } = useAccount(); - const { country, realName, name, userLevel, accountageSecs } = user || {}; + + const handleAddToBuddies = ({ userName }) => { + webClient.request.session.addToBuddyList(userName); + }; + + const handleAddToIgnore = ({ userName }) => { + webClient.request.session.addToIgnoreList(userName); + }; return ( @@ -36,14 +59,14 @@ const Account = () => { Buddies Online: ?/{buddyList.length}
    ( + items={ buddyList.map(user => ( - ))} + )) } />
    - +
    @@ -53,20 +76,20 @@ const Account = () => { Ignored Users Online: ?/{ignoreList.length}
    ( + items={ ignoreList.map(user => ( - ))} + )) } />
    - +
    - {avatarUrl && {name}} + { avatarUrl && {name} }

    {name}

    Location: ({country?.toUpperCase()})

    User Level: {userLevel}

    @@ -82,8 +105,12 @@ const Account = () => {

    Server Name: {serverName}

    Server Version: {serverVersion}

    -
    @@ -92,7 +119,7 @@ const Account = () => {
    - ); -}; + ) +} export default Account; diff --git a/webclient/src/containers/Account/AddToBuddies.tsx b/webclient/src/containers/Account/AddToBuddies.tsx new file mode 100644 index 000000000..7fb05859d --- /dev/null +++ b/webclient/src/containers/Account/AddToBuddies.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Form } from 'react-final-form' + +import { InputAction } from '@app/components'; + +const AddToBuddies = ({ onSubmit }) => ( +
    onSubmit(values)}> + {({ handleSubmit }) => ( + + + + )} + +); + +export default AddToBuddies; diff --git a/webclient/src/containers/Account/AddToIgnore.tsx b/webclient/src/containers/Account/AddToIgnore.tsx new file mode 100644 index 000000000..5149de0f5 --- /dev/null +++ b/webclient/src/containers/Account/AddToIgnore.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Form } from 'react-final-form' + +import { InputAction } from '@app/components'; + +const AddToIgnore = ({ onSubmit }) => ( +
    onSubmit(values)}> + {({ handleSubmit }) => ( + + + + )} + +); + +export default AddToIgnore; diff --git a/webclient/src/containers/Account/AddUserForm.tsx b/webclient/src/containers/Account/AddUserForm.tsx deleted file mode 100644 index 4c75597a5..000000000 --- a/webclient/src/containers/Account/AddUserForm.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Form } from 'react-final-form'; - -import { InputAction } from '@app/components'; - -interface AddUserFormValues { - userName: string; -} - -interface AddUserFormProps { - label: string; - onSubmit: (values: AddUserFormValues) => void; -} - -const AddUserForm = ({ label, onSubmit }: AddUserFormProps) => ( - onSubmit={(values) => onSubmit(values)}> - {({ handleSubmit }) => ( -
    - - - )} - -); - -export default AddUserForm; diff --git a/webclient/src/containers/Account/useAccount.ts b/webclient/src/containers/Account/useAccount.ts deleted file mode 100644 index e67f8acd4..000000000 --- a/webclient/src/containers/Account/useAccount.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useEffect, useMemo } from 'react'; - -import { useWebClient } from '@app/hooks'; -import { ServerSelectors, useAppSelector } from '@app/store'; -import { Data } from '@app/types'; - -export interface Account { - buddyList: Data.ServerInfo_User[]; - ignoreList: Data.ServerInfo_User[]; - serverName: string | undefined; - serverVersion: string | undefined; - user: Data.ServerInfo_User | null; - avatarUrl: string; - handleAddToBuddies: (args: { userName: string }) => void; - handleAddToIgnore: (args: { userName: string }) => void; - handleDisconnect: () => void; -} - -export function useAccount(): Account { - const buddyList = useAppSelector((state) => ServerSelectors.getSortedBuddyList(state)); - const ignoreList = useAppSelector((state) => ServerSelectors.getSortedIgnoreList(state)); - const serverName = useAppSelector((state) => ServerSelectors.getName(state)); - const serverVersion = useAppSelector((state) => ServerSelectors.getVersion(state)); - const user = useAppSelector((state) => ServerSelectors.getUser(state)); - const webClient = useWebClient(); - const avatarBmp = user?.avatarBmp; - - const avatarUrl = useMemo(() => { - if (!avatarBmp) { - return ''; - } - return URL.createObjectURL(new Blob([avatarBmp], { type: 'image/png' })); - }, [avatarBmp]); - - useEffect(() => { - return () => { - if (avatarUrl) { - URL.revokeObjectURL(avatarUrl); - } - }; - }, [avatarUrl]); - - const handleAddToBuddies = ({ userName }: { userName: string }) => { - webClient.request.session.addToBuddyList(userName); - }; - - const handleAddToIgnore = ({ userName }: { userName: string }) => { - webClient.request.session.addToIgnoreList(userName); - }; - - const handleDisconnect = () => { - webClient.request.authentication.disconnect(); - }; - - return { - buddyList, - ignoreList, - serverName, - serverVersion, - user, - avatarUrl, - handleAddToBuddies, - handleAddToIgnore, - handleDisconnect, - }; -} diff --git a/webclient/src/containers/App/AppShell.tsx b/webclient/src/containers/App/AppShell.tsx index dedb5c63b..4a0856bc5 100644 --- a/webclient/src/containers/App/AppShell.tsx +++ b/webclient/src/containers/App/AppShell.tsx @@ -8,22 +8,23 @@ import FeatureDetection from './FeatureDetection'; import './AppShell.css'; -import { ToastProvider } from '@app/components'; +import { ToastProvider } from '@app/components' function AppShell() { useEffect(() => { window.onbeforeunload = () => true; - return () => { - window.onbeforeunload = null; - }; }, []); + const handleContextMenu = (event) => { + event.preventDefault(); + }; + return ( -
    +
    diff --git a/webclient/src/containers/App/AppShellRoutes.tsx b/webclient/src/containers/App/AppShellRoutes.tsx index 2f4fb2f76..fc097ffd4 100644 --- a/webclient/src/containers/App/AppShellRoutes.tsx +++ b/webclient/src/containers/App/AppShellRoutes.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Route, Routes } from 'react-router-dom'; import { App } from '@app/types'; diff --git a/webclient/src/containers/Decks/Decks.tsx b/webclient/src/containers/Decks/Decks.tsx index 5050a2535..e5856eaeb 100644 --- a/webclient/src/containers/Decks/Decks.tsx +++ b/webclient/src/containers/Decks/Decks.tsx @@ -7,7 +7,7 @@ function Decks() { return ( - Decks + "Decks" ); } diff --git a/webclient/src/containers/Game/Game.css b/webclient/src/containers/Game/Game.css index ba86773c2..e69de29bb 100644 --- a/webclient/src/containers/Game/Game.css +++ b/webclient/src/containers/Game/Game.css @@ -1,67 +0,0 @@ -.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 deleted file mode 100644 index 64a7a8672..000000000 --- a/webclient/src/containers/Game/Game.dragdrop.spec.tsx +++ /dev/null @@ -1,142 +0,0 @@ -// 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.orchestration.spec.tsx b/webclient/src/containers/Game/Game.orchestration.spec.tsx deleted file mode 100644 index 0d6c03be5..000000000 --- a/webclient/src/containers/Game/Game.orchestration.spec.tsx +++ /dev/null @@ -1,356 +0,0 @@ -// M4–M6 orchestration tests — extracted from Game.spec.tsx so they run in -// their own vitest worker slot (pool: 'threads'). Each of these goes through -// the 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. -import { screen, fireEvent, waitFor } from '@testing-library/react'; -import { App } 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 orchestration (M4–M6)', () => { - 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 })); - 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 }); - }); - - 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. - const menuItem = (await screen.findAllByText('P3')).find((el) => el.closest('[role="menuitem"]')); - fireEvent.click(menuItem!); - - expect(webClient.request.game.kickFromGame).toHaveBeenCalledWith(1, { playerId: 3 }); - }); - - 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 }), - ); - }); - - 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 }); - }); - - 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)); - - expect( - await screen.findByText('0 and lower are in comparison to current hand size.'), - ).toBeInTheDocument(); - - 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 }); - }); - - 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 }); - }); - - 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 }); - - const handCard = document.querySelector('[data-card-zone="hand"][data-card-id="10"]')!; - fireEvent.contextMenu(handCard); - - const drawArrowItem = await screen.findByText('Draw arrow from here'); - fireEvent.click(drawArrowItem); - - 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(); - }); - - 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(); - }); - - it('Sideboard: PlayerContextMenu → SideboardDialog → setSideboardPlan with the accumulated moveList', async () => { - const webClient = createMockWebClient(); - const state = buildGame({ localId: 1, opponentIds: [2] }); - 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 }, - ], - }), - ); - }); - - 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 }); - }); - - 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, - }), - ); - }); -}); diff --git a/webclient/src/containers/Game/Game.spec.tsx b/webclient/src/containers/Game/Game.spec.tsx deleted file mode 100644 index b65e081c4..000000000 --- a/webclient/src/containers/Game/Game.spec.tsx +++ /dev/null @@ -1,466 +0,0 @@ -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 tests live in Game.orchestration.spec.tsx — that - // file pins the end-to-end dispatch flows (dialog/menu → command) that go - // through Game.tsx state wiring. Splitting them out lets vitest's threads - // pool run them in parallel with the unit-style tests in this file. - -}); diff --git a/webclient/src/containers/Game/Game.tsx b/webclient/src/containers/Game/Game.tsx index 622c4c43f..139147ab1 100644 --- a/webclient/src/containers/Game/Game.tsx +++ b/webclient/src/containers/Game/Game.tsx @@ -1,338 +1,13 @@ -import { DndContext, DragOverlay } from '@dnd-kit/core'; - -import { - AuthGuard, - CardContextMenu, - CardDragOverlay, - CardRegistryContext, - GameArrowOverlay, - HandContextMenu, - HandZone, - OpponentSelector, - PhaseBar, - PlayerBoard, - PlayerContextMenu, - RightPanel, - StackStrip, - ZoneContextMenu, -} from '@app/components'; -import { - ConfirmDialog, - CreateCounterDialog, - CreateTokenDialog, - DeckSelectDialog, - GameInfoDialog, - PromptDialog, - RevealCardsDialog, - RollDieDialog, - SideboardDialog, - cardsFromZone, - ZoneViewDialog, -} from '@app/dialogs'; -import { App } from '@app/types'; - +import { AuthGuard } from '@app/components'; import Layout from '../Layout/Layout'; -import { useGame } from './useGame'; - import './Game.css'; function Game() { - const g = useGame(); - const { - gameId, - game, - localPlayer, - boardRef, - cardRegistry, - sensors, - hoveredCard, - setHoveredCard, - isRotated, - toggleRotated, - localAccess, - opponentAccess, - deckSelectOpen, - opponents, - arrows, - dialogs, - dnd, - } = g; - return ( - - -
    - - -
    - - - {!game && ( -
    - No active game. Join a game from a room to see the board. -
    - )} - - {game && opponents.shownOpponentId != null && ( -
    - - dialogs.handleCardContextMenu(opponents.shownOpponentId!, App.ZoneName.TABLE, card, e) - } - onCardDoubleClick={(card) => - arrows.handleCardDoubleClick(App.ZoneName.TABLE, card) - } - onZoneClick={dialogs.handleZoneClick} - onZoneContextMenu={dialogs.handleZoneContextMenu} - /> - o.playerId === opponents.shownOpponentId)?.name ?? - `p${opponents.shownOpponentId}`, - }, - { - playerId: game.localPlayerId, - name: - localPlayer?.properties.userInfo?.name ?? - `p${game.localPlayerId}`, - }, - ]} - onZoneClick={dialogs.handleZoneClick} - /> - - dialogs.handleCardContextMenu(game.localPlayerId, App.ZoneName.TABLE, card, e) - } - onCardDoubleClick={(card) => - arrows.handleCardDoubleClick(App.ZoneName.TABLE, card) - } - onZoneClick={dialogs.handleZoneClick} - onZoneContextMenu={dialogs.handleZoneContextMenu} - onRequestCreateCounter={dialogs.openCreateCounter} - onPlayerContextMenu={dialogs.handlePlayerContextMenu} - /> - {localPlayer && ( - - dialogs.handleCardContextMenu(game.localPlayerId, App.ZoneName.HAND, card, e) - } - onZoneContextMenu={dialogs.handleHandContextMenu} - /> - )} -
    - )} - - -
    - - - - - - {dialogs.zoneViews.map((v, idx) => ( - dialogs.handleCloseZoneView(v.playerId, v.zoneName)} - initialPosition={{ x: 80 + idx * 36, y: 80 + idx * 36 }} - /> - ))} - - - - - - - - - - {dialogs.prompt && ( - - )} - - - - - - - - - - {dialogs.revealState && ( - - )} - - - - - - -
    - - - {dnd.activeCard ? : null} - -
    -
    + "Game"
    ); } diff --git a/webclient/src/containers/Game/useGame.ts b/webclient/src/containers/Game/useGame.ts deleted file mode 100644 index 17a20d695..000000000 --- a/webclient/src/containers/Game/useGame.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { RefObject, useCallback, useMemo, useRef, useState } from 'react'; -import { KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; - -import { createCardRegistry, type CardRegistry } from '@app/components'; -import { useCurrentGame, useGameAccess, type CurrentGame, type GameAccess } from '@app/hooks'; -import type { Data } from '@app/types'; - -import { useGameArrowInteractions, type GameArrowInteractions } from './useGameArrowInteractions'; -import { useGameDialogs, type GameDialogs } from './useGameDialogs'; -import { useGameDnd, type GameDnd } from './useGameDnd'; -import { useGameLifecycleNavigation } from './useGameLifecycleNavigation'; -import { useGameOpponentSelector, type GameOpponentSelector } from './useGameOpponentSelector'; - -export interface Game extends CurrentGame { - boardRef: RefObject; - cardRegistry: CardRegistry; - sensors: ReturnType; - hoveredCard: Data.ServerInfo_Card | null; - setHoveredCard: (card: Data.ServerInfo_Card | null) => void; - isRotated: boolean; - toggleRotated: () => void; - localAccess: GameAccess; - opponentAccess: GameAccess; - deckSelectOpen: boolean; - opponents: GameOpponentSelector; - arrows: GameArrowInteractions; - dialogs: GameDialogs; - dnd: GameDnd; -} - -export function useGame(): Game { - const current = useCurrentGame(); - const { gameId, game, localPlayer, isSpectator } = current; - - useGameLifecycleNavigation(gameId); - - const boardRef = useRef(null); - const cardRegistry = useMemo(() => createCardRegistry(), []); - const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor)); - const [hoveredCard, setHoveredCard] = useState(null); - // 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 toggleRotated = useCallback(() => setIsRotated((prev) => !prev), []); - - const opponents = useGameOpponentSelector(game); - const localAccess = useGameAccess(gameId, game?.localPlayerId); - const opponentAccess = useGameAccess(gameId, opponents.shownOpponentId); - - const arrows = useGameArrowInteractions({ gameId, game, boardRef, cardRegistry }); - const dialogs = useGameDialogs({ - gameId, - game, - localPlayer, - localAccess, - isSpectator, - startPendingArrow: arrows.startPendingArrow, - startPendingAttach: arrows.startPendingAttach, - }); - const dnd = useGameDnd({ gameId, onDragStart: arrows.cancelPendingOnDragStart }); - - // 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 && - !current.isSpectator && - !current.isJudge && - !localPlayer.properties.readyStart; - - return { - ...current, - boardRef, - cardRegistry, - sensors, - hoveredCard, - setHoveredCard, - isRotated, - toggleRotated, - localAccess, - opponentAccess, - deckSelectOpen, - opponents, - arrows, - dialogs, - dnd, - }; -} diff --git a/webclient/src/containers/Game/useGameArrowInteractions.spec.ts b/webclient/src/containers/Game/useGameArrowInteractions.spec.ts deleted file mode 100644 index 571a50309..000000000 --- a/webclient/src/containers/Game/useGameArrowInteractions.spec.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { createRef } from 'react'; -import { act, renderHook } from '@testing-library/react'; - -import { createCardRegistry } from '../../components/Game/CardRegistry/CardRegistryContext'; -import { combineReducers } from '@reduxjs/toolkit'; - -import { gamesReducer } from '../../store/game/game.reducer'; -import { makeGameEntry, makePlayerEntry, makePlayerProperties } from '../../store/game/__mocks__/fixtures'; -import type { GamesState } from '../../store/game/game.interfaces'; -import { makeReduxWebClientHookWrapper } from '../../__test-utils__/makeHookWrapper'; -import { App } from '../../types'; -import { useGameArrowInteractions } from './useGameArrowInteractions'; - -function setup({ localPlayerId = 1 }: { localPlayerId?: number } = {}) { - const game = makeGameEntry({ - localPlayerId, - players: { - [localPlayerId]: makePlayerEntry({ - properties: makePlayerProperties({ playerId: localPlayerId }), - }), - }, - }); - const gamesState: GamesState = { games: { 1: { ...game, info: { ...game.info, gameId: 1 } } } }; - - const { Wrapper, webClient } = makeReduxWebClientHookWrapper({ - reducer: combineReducers({ games: gamesReducer }), - preloadedState: { games: gamesState }, - }); - - const boardRef = createRef(); - const board = document.createElement('div'); - board.getBoundingClientRect = () => - ({ left: 0, top: 0, right: 1000, bottom: 1000, width: 1000, height: 1000, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect; - (boardRef as { current: HTMLDivElement | null }).current = board; - - const cardRegistry = createCardRegistry(); - - const { result } = renderHook( - () => - useGameArrowInteractions({ - gameId: 1, - game: { ...game, info: { ...game.info, gameId: 1 } }, - boardRef, - cardRegistry, - }), - { wrapper: Wrapper }, - ); - - return { result, webClient, boardRef }; -} - -function makeCardElement({ - playerId, - zone, - cardId, -}: { - playerId: number; - zone: string; - cardId: number; -}): HTMLElement { - const el = document.createElement('div'); - el.setAttribute('data-card-id', String(cardId)); - el.setAttribute('data-card-owner', String(playerId)); - el.setAttribute('data-card-zone', zone); - document.body.appendChild(el); - return el; -} - -function fireMouseEvent(type: string, init: Partial = {}) { - window.dispatchEvent(new MouseEvent(type, { bubbles: true, ...init })); -} - -describe('useGameArrowInteractions', () => { - afterEach(() => { - document.body.innerHTML = ''; - }); - - it('creates an arrow after right-click-drag past the 8px threshold', () => { - const { result, webClient } = setup(); - const targetEl = makeCardElement({ playerId: 2, zone: App.ZoneName.TABLE, cardId: 99 }); - const origElementFromPoint = document.elementFromPoint; - document.elementFromPoint = () => targetEl; - - act(() => { - result.current.handleBoardMouseDown({ - button: 2, - clientX: 10, - clientY: 10, - target: makeCardElement({ playerId: 1, zone: App.ZoneName.TABLE, cardId: 5 }), - } as unknown as React.MouseEvent); - }); - - act(() => { - fireMouseEvent('mousemove', { clientX: 30, clientY: 30 }); - }); - - act(() => { - fireMouseEvent('mouseup', { button: 2, clientX: 30, clientY: 30 }); - }); - - expect(webClient.request.game.createArrow).toHaveBeenCalledWith( - 1, - expect.objectContaining({ - startPlayerId: 1, - startCardId: 5, - targetPlayerId: 2, - targetCardId: 99, - targetZone: App.ZoneName.TABLE, - }), - ); - - document.elementFromPoint = origElementFromPoint; - }); - - it('plays the card (moveCard) when dragging from HAND to a non-HAND target', () => { - const { result, webClient } = setup({ localPlayerId: 1 }); - const targetEl = makeCardElement({ playerId: 2, zone: App.ZoneName.TABLE, cardId: 99 }); - const origElementFromPoint = document.elementFromPoint; - document.elementFromPoint = () => targetEl; - - act(() => { - result.current.handleBoardMouseDown({ - button: 2, - clientX: 0, - clientY: 0, - target: makeCardElement({ playerId: 1, zone: App.ZoneName.HAND, cardId: 5 }), - } as unknown as React.MouseEvent); - }); - act(() => fireMouseEvent('mousemove', { clientX: 30, clientY: 30 })); - act(() => fireMouseEvent('mouseup', { button: 2, clientX: 30, clientY: 30 })); - - expect(webClient.request.game.moveCard).toHaveBeenCalled(); - expect(webClient.request.game.createArrow).not.toHaveBeenCalled(); - - document.elementFromPoint = origElementFromPoint; - }); - - it('does not send a request when the drop lands on the same card (cancel)', () => { - const { result, webClient } = setup(); - const sameEl = makeCardElement({ playerId: 1, zone: App.ZoneName.TABLE, cardId: 5 }); - const origElementFromPoint = document.elementFromPoint; - document.elementFromPoint = () => sameEl; - - act(() => { - result.current.handleBoardMouseDown({ - button: 2, - clientX: 0, - clientY: 0, - target: sameEl, - } as unknown as React.MouseEvent); - }); - act(() => fireMouseEvent('mousemove', { clientX: 30, clientY: 30 })); - act(() => fireMouseEvent('mouseup', { button: 2, clientX: 30, clientY: 30 })); - - expect(webClient.request.game.createArrow).not.toHaveBeenCalled(); - - document.elementFromPoint = origElementFromPoint; - }); - - it('does not send a request when mouseup is below the drag threshold', () => { - const { result, webClient } = setup(); - const targetEl = makeCardElement({ playerId: 2, zone: App.ZoneName.TABLE, cardId: 99 }); - const origElementFromPoint = document.elementFromPoint; - document.elementFromPoint = () => targetEl; - - act(() => { - result.current.handleBoardMouseDown({ - button: 2, - clientX: 10, - clientY: 10, - target: makeCardElement({ playerId: 1, zone: App.ZoneName.TABLE, cardId: 5 }), - } as unknown as React.MouseEvent); - }); - act(() => fireMouseEvent('mouseup', { button: 2, clientX: 12, clientY: 12 })); - - expect(webClient.request.game.createArrow).not.toHaveBeenCalled(); - expect(webClient.request.game.moveCard).not.toHaveBeenCalled(); - - document.elementFromPoint = origElementFromPoint; - }); - - it('ESC cancels pending arrow state', () => { - const { result } = setup(); - - act(() => { - result.current.startPendingArrow({ sourcePlayerId: 1, sourceZone: App.ZoneName.TABLE, sourceCardId: 5 }); - }); - expect(result.current.arrowSourceKey).not.toBeNull(); - - act(() => { - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); - }); - expect(result.current.arrowSourceKey).toBeNull(); - }); - - it('ESC does not cancel while a MUI dialog is open', () => { - const { result } = setup(); - - const dialog = document.createElement('div'); - dialog.className = 'MuiDialog-root'; - dialog.setAttribute('role', 'dialog'); - document.body.appendChild(dialog); - - act(() => { - result.current.startPendingArrow({ sourcePlayerId: 1, sourceZone: App.ZoneName.TABLE, sourceCardId: 5 }); - }); - act(() => { - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); - }); - - expect(result.current.arrowSourceKey).not.toBeNull(); - }); -}); diff --git a/webclient/src/containers/Game/useGameArrowInteractions.ts b/webclient/src/containers/Game/useGameArrowInteractions.ts deleted file mode 100644 index c994bf419..000000000 --- a/webclient/src/containers/Game/useGameArrowInteractions.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import type { CardRegistry } from '@app/components'; -import { makeCardKey } from '@app/components'; -import { useWebClient } from '@app/hooks'; -import { App, Data, type Enriched } from '@app/types'; - -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 ArrowDragState { - sourcePlayerId: number; - sourceZone: string; - sourceCardId: number; - startX: number; - startY: number; - currentX: number; - currentY: number; - moved: boolean; -} - -export interface ArrowDragPreview { - x1: number; - y1: number; - x2: number; - y2: number; - color: string; -} - -export interface GameArrowInteractions { - arrowSourceKey: string | null; - dragPreview: ArrowDragPreview | null; - handleBoardMouseDown: (e: React.MouseEvent) => void; - handleCardClick: ( - ownerPlayerId: number, - zone: string, - card: Data.ServerInfo_Card, - ) => void; - handleCardDoubleClick: (sourceZone: string, card: Data.ServerInfo_Card) => void; - startPendingArrow: (source: PendingArrow) => void; - startPendingAttach: (source: PendingAttach) => void; - cancelPendingOnDragStart: () => void; -} - -export interface UseGameArrowInteractionsArgs { - gameId: number | undefined; - game: Enriched.GameEntry | undefined; - boardRef: RefObject; - cardRegistry: CardRegistry; -} - -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; - -export function useGameArrowInteractions({ - gameId, - game, - boardRef, - cardRegistry, -}: UseGameArrowInteractionsArgs): GameArrowInteractions { - const webClient = useWebClient(); - - const [pendingArrow, setPendingArrow] = useState(null); - const [pendingAttach, setPendingAttach] = useState(null); - const [arrowDrag, setArrowDrag] = useState(null); - const suppressNextContextMenuRef = useRef(false); - - // 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, game?.localPlayerId]); - - // 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 = useCallback((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 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, boardRef]); - - const handleCardClick = useCallback( - (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); - }, - [gameId, game?.localPlayerId, pendingArrow, pendingAttach, webClient], - ); - - const handleCardDoubleClick = useCallback( - (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', - }); - }, - [gameId, pendingArrow, pendingAttach, webClient], - ); - - const startPendingArrow = useCallback((source: PendingArrow) => { - setPendingArrow(source); - }, []); - - const startPendingAttach = useCallback((source: PendingAttach) => { - setPendingAttach(source); - }, []); - - const cancelPendingOnDragStart = useCallback(() => { - setPendingArrow((prev) => (prev ? null : prev)); - setPendingAttach((prev) => (prev ? null : prev)); - }, []); - - return { - arrowSourceKey, - dragPreview, - handleBoardMouseDown, - handleCardClick, - handleCardDoubleClick, - startPendingArrow, - startPendingAttach, - cancelPendingOnDragStart, - }; -} diff --git a/webclient/src/containers/Game/useGameDialogs.ts b/webclient/src/containers/Game/useGameDialogs.ts deleted file mode 100644 index e7927d57e..000000000 --- a/webclient/src/containers/Game/useGameDialogs.ts +++ /dev/null @@ -1,730 +0,0 @@ -import { useCallback, useState } from 'react'; - -import { DEFAULT_DIE_COUNT, DEFAULT_DIE_SIDES, type SideboardPlanMove } from '@app/dialogs'; -import { useWebClient, type GameAccess } from '@app/hooks'; -import { App, Data, type Enriched } from '@app/types'; - -export interface AnchorPosition { - top: number; - left: number; -} - -export interface ZoneViewTarget { - playerId: number; - zoneName: string; -} - -export interface CardMenuState { - card: Data.ServerInfo_Card; - sourcePlayerId: number; - sourceZone: string; - anchorPosition: AnchorPosition; -} - -export interface ZoneMenuState { - playerId: number; - zoneName: string; - anchorPosition: AnchorPosition; -} - -export interface PromptState { - title: string; - label: string; - initialValue?: string; - helperText?: string; - validate?: (value: string) => string | null; - onSubmit: (value: string) => void; -} - -export interface RevealState { - title: string; - zoneName: string; - zoneLabel: string; - showCountInput: boolean; - defaultCount: number; - onSubmit: (args: { targetPlayerId: number; topCards: number }) => void; -} - -export type ConcedeConfirm = 'concede' | 'unconcede' | null; - -export interface StartPendingSource { - sourcePlayerId: number; - sourceZone: string; - sourceCardId: number; -} - -export interface GameDialogs { - // Card/zone/player/hand menus - cardMenu: CardMenuState | null; - zoneMenu: ZoneMenuState | null; - playerMenu: AnchorPosition | null; - handMenu: AnchorPosition | null; - closeCardMenu: () => void; - closeZoneMenu: () => void; - closePlayerMenu: () => void; - closeHandMenu: () => void; - handleCardContextMenu: ( - sourcePlayerId: number, - sourceZone: string, - card: Data.ServerInfo_Card, - event: React.MouseEvent, - ) => void; - handleZoneContextMenu: ( - playerId: number, - zoneName: string, - event: React.MouseEvent, - ) => void; - handlePlayerContextMenu: (event: React.MouseEvent) => void; - handleHandContextMenu: (event: React.MouseEvent) => void; - - // Zone-view dialog stack - zoneViews: ZoneViewTarget[]; - handleZoneClick: (playerId: number, zoneName: string) => void; - handleCloseZoneView: (playerId: number, zoneName: string) => void; - - // Prompt dialog - prompt: PromptState | null; - closePrompt: () => void; - - // Roll die dialog - rollDieOpen: boolean; - lastDieSides: number; - lastDieCount: number; - openRollDie: () => void; - closeRollDie: () => void; - handleRollDieSubmit: (args: { sides: number; count: number }) => void; - - // Counter / token / sideboard / game info / concede - createCounterOpen: boolean; - openCreateCounter: () => void; - closeCreateCounter: () => void; - handleCreateCounterSubmit: (args: { - name: string; - color: { r: number; g: number; b: number; a: number }; - }) => void; - - createTokenOpen: boolean; - openCreateToken: () => void; - closeCreateToken: () => void; - handleCreateTokenSubmit: (args: { - name: string; - color: string; - pt: string; - annotation: string; - destroyOnZoneChange: boolean; - faceDown: boolean; - }) => void; - - sideboardOpen: boolean; - openSideboard: () => void; - closeSideboard: () => void; - handleSideboardSubmit: (moveList: SideboardPlanMove[]) => void; - handleToggleSideboardLock: (locked: boolean) => void; - - gameInfoOpen: boolean; - openGameInfo: () => void; - closeGameInfo: () => void; - - concedeConfirm: ConcedeConfirm; - openConcede: () => void; - openUnconcede: () => void; - closeConcedeConfirm: () => void; - confirmConcede: () => void; - confirmUnconcede: () => void; - - // Reveal-cards dialog - revealState: RevealState | null; - closeReveal: () => void; - - // Card context menu action handlers - handleRequestSetPT: () => void; - handleRequestSetAnnotation: () => void; - handleRequestSetCardCounter: () => void; - handleRequestDrawArrow: () => void; - handleRequestAttach: () => void; - handleRequestMoveToLibraryAt: () => void; - - // Zone context menu action handlers - handleRequestDrawN: () => void; - handleRequestDumpN: () => void; - handleRequestRevealTopN: () => void; - handleRequestRevealZone: () => void; - - // Hand context menu action handlers - handleRequestChooseMulligan: () => void; - handleRequestRevealHand: () => void; - handleRequestRevealRandom: () => void; -} - -export interface UseGameDialogsArgs { - gameId: number | undefined; - game: Enriched.GameEntry | undefined; - localPlayer: Enriched.PlayerEntry | undefined; - localAccess: GameAccess; - isSpectator: boolean; - startPendingArrow: (source: StartPendingSource) => void; - startPendingAttach: (source: StartPendingSource) => void; -} - -export function useGameDialogs({ - gameId, - game, - localPlayer, - localAccess, - isSpectator, - startPendingArrow, - startPendingAttach, -}: UseGameDialogsArgs): GameDialogs { - const webClient = useWebClient(); - - const [zoneViews, setZoneViews] = useState([]); - 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 [concedeConfirm, setConcedeConfirm] = useState(null); - const [gameInfoOpen, setGameInfoOpen] = useState(false); - - const handleZoneClick = useCallback((playerId: number, zoneName: string) => { - setZoneViews((prev) => { - if (prev.some((v) => v.playerId === playerId && v.zoneName === zoneName)) { - return prev; - } - return [...prev, { playerId, zoneName }]; - }); - }, []); - - const handleCloseZoneView = useCallback((playerId: number, zoneName: string) => { - setZoneViews((prev) => - prev.filter((v) => !(v.playerId === playerId && v.zoneName === zoneName)), - ); - }, []); - - const handleCardContextMenu = useCallback( - ( - 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 = useCallback( - (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 }, - }); - }, - [game?.localPlayerId], - ); - - const handlePlayerContextMenu = useCallback( - (event: React.MouseEvent) => { - if (gameId == null || isSpectator || localAccess.canAct === false) { - return; - } - event.preventDefault(); - setPlayerMenu({ top: event.clientY, left: event.clientX }); - }, - [gameId, isSpectator, localAccess.canAct], - ); - - const handleHandContextMenu = useCallback( - (event: React.MouseEvent) => { - if (gameId == null || isSpectator || localAccess.canAct === false) { - return; - } - event.preventDefault(); - setHandMenu({ top: event.clientY, left: event.clientX }); - }, - [gameId, isSpectator, localAccess.canAct], - ); - - const handleRequestSetPT = useCallback(() => { - 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); - }, - }); - }, [cardMenu, gameId, webClient]); - - const handleRequestSetAnnotation = useCallback(() => { - 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); - }, - }); - }, [cardMenu, gameId, webClient]); - - const handleRequestSetCardCounter = useCallback(() => { - 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); - }, - }); - }, [cardMenu, gameId, webClient]); - - const handleRequestDrawArrow = useCallback(() => { - const menu = cardMenu; - if (!menu) { - return; - } - startPendingArrow({ - sourcePlayerId: menu.sourcePlayerId, - sourceZone: menu.sourceZone, - sourceCardId: menu.card.id, - }); - }, [cardMenu, startPendingArrow]); - - const handleRequestAttach = useCallback(() => { - const menu = cardMenu; - if (!menu) { - return; - } - startPendingAttach({ - sourcePlayerId: menu.sourcePlayerId, - sourceZone: menu.sourceZone, - sourceCardId: menu.card.id, - }); - }, [cardMenu, startPendingAttach]); - - const handleRequestMoveToLibraryAt = useCallback(() => { - 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); - }, - }); - }, [cardMenu, game, gameId, webClient]); - - const handleRequestDrawN = useCallback(() => { - 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); - }, - }); - }, [gameId, webClient]); - - const handleRequestDumpN = useCallback(() => { - 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); - }, - }); - }, [game, gameId, webClient]); - - const handleRollDieSubmit = useCallback( - ({ sides, count }: { sides: number; count: number }) => { - if (gameId == null) { - return; - } - webClient.request.game.rollDie(gameId, { sides, count }); - setLastDieSides(sides); - setLastDieCount(count); - setRollDieOpen(false); - }, - [gameId, webClient], - ); - - const handleCreateCounterSubmit = useCallback( - ({ - 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); - }, - [gameId, webClient], - ); - - const handleCreateTokenSubmit = useCallback( - (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); - }, - [gameId, webClient], - ); - - const handleSideboardSubmit = useCallback( - (moveList: SideboardPlanMove[]) => { - if (gameId == null) { - return; - } - webClient.request.game.setSideboardPlan(gameId, { moveList }); - setSideboardOpen(false); - }, - [gameId, webClient], - ); - - const handleToggleSideboardLock = useCallback( - (locked: boolean) => { - if (gameId == null) { - return; - } - webClient.request.game.setSideboardLock(gameId, { locked }); - }, - [gameId, webClient], - ); - - const handleRequestChooseMulligan = useCallback(() => { - 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); - }, - }); - }, [gameId, localPlayer, webClient]); - - const handleRequestRevealHand = useCallback(() => { - 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); - }, - }); - }, [gameId, webClient]); - - const handleRequestRevealRandom = useCallback(() => { - 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); - }, - }); - }, [gameId, webClient]); - - const handleRequestRevealTopN = useCallback(() => { - 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); - }, - }); - }, [gameId, webClient]); - - const handleRequestRevealZone = useCallback(() => { - 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); - }, - }); - }, [gameId, zoneMenu, webClient]); - - const confirmConcede = useCallback(() => { - if (gameId != null) { - webClient.request.game.concede(gameId); - } - setConcedeConfirm(null); - }, [gameId, webClient]); - - const confirmUnconcede = useCallback(() => { - if (gameId != null) { - webClient.request.game.unconcede(gameId); - } - setConcedeConfirm(null); - }, [gameId, webClient]); - - return { - cardMenu, - zoneMenu, - playerMenu, - handMenu, - closeCardMenu: useCallback(() => setCardMenu(null), []), - closeZoneMenu: useCallback(() => setZoneMenu(null), []), - closePlayerMenu: useCallback(() => setPlayerMenu(null), []), - closeHandMenu: useCallback(() => setHandMenu(null), []), - handleCardContextMenu, - handleZoneContextMenu, - handlePlayerContextMenu, - handleHandContextMenu, - - zoneViews, - handleZoneClick, - handleCloseZoneView, - - prompt, - closePrompt: useCallback(() => setPrompt(null), []), - - rollDieOpen, - lastDieSides, - lastDieCount, - openRollDie: useCallback(() => setRollDieOpen(true), []), - closeRollDie: useCallback(() => setRollDieOpen(false), []), - handleRollDieSubmit, - - createCounterOpen, - openCreateCounter: useCallback(() => setCreateCounterOpen(true), []), - closeCreateCounter: useCallback(() => setCreateCounterOpen(false), []), - handleCreateCounterSubmit, - - createTokenOpen, - openCreateToken: useCallback(() => setCreateTokenOpen(true), []), - closeCreateToken: useCallback(() => setCreateTokenOpen(false), []), - handleCreateTokenSubmit, - - sideboardOpen, - openSideboard: useCallback(() => setSideboardOpen(true), []), - closeSideboard: useCallback(() => setSideboardOpen(false), []), - handleSideboardSubmit, - handleToggleSideboardLock, - - gameInfoOpen, - openGameInfo: useCallback(() => setGameInfoOpen(true), []), - closeGameInfo: useCallback(() => setGameInfoOpen(false), []), - - concedeConfirm, - openConcede: useCallback(() => setConcedeConfirm('concede'), []), - openUnconcede: useCallback(() => setConcedeConfirm('unconcede'), []), - closeConcedeConfirm: useCallback(() => setConcedeConfirm(null), []), - confirmConcede, - confirmUnconcede, - - revealState, - closeReveal: useCallback(() => setRevealState(null), []), - - handleRequestSetPT, - handleRequestSetAnnotation, - handleRequestSetCardCounter, - handleRequestDrawArrow, - handleRequestAttach, - handleRequestMoveToLibraryAt, - handleRequestDrawN, - handleRequestDumpN, - handleRequestRevealTopN, - handleRequestRevealZone, - handleRequestChooseMulligan, - handleRequestRevealHand, - handleRequestRevealRandom, - }; -} diff --git a/webclient/src/containers/Game/useGameDnd.ts b/webclient/src/containers/Game/useGameDnd.ts deleted file mode 100644 index 96d959e7e..000000000 --- a/webclient/src/containers/Game/useGameDnd.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { useCallback, useState } from 'react'; -import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; - -import { useWebClient } from '@app/hooks'; -import { App, Data } from '@app/types'; - -export interface GameDnd { - activeCard: Data.ServerInfo_Card | null; - handleDragStart: (event: DragStartEvent) => void; - handleDragEnd: (event: DragEndEvent) => void; - handleDragCancel: () => void; -} - -export interface UseGameDndArgs { - gameId: number | undefined; - onDragStart: () => void; -} - -export function useGameDnd({ gameId, onDragStart }: UseGameDndArgs): GameDnd { - const webClient = useWebClient(); - const [activeCard, setActiveCard] = useState(null); - - const handleDragStart = useCallback( - (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. - onDragStart(); - }, - [onDragStart], - ); - - const handleDragEnd = useCallback( - (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, - }); - }, - [gameId, webClient], - ); - - const handleDragCancel = useCallback(() => { - setActiveCard(null); - }, []); - - return { activeCard, handleDragStart, handleDragEnd, handleDragCancel }; -} diff --git a/webclient/src/containers/Game/useGameLifecycleNavigation.ts b/webclient/src/containers/Game/useGameLifecycleNavigation.ts deleted file mode 100644 index 91e3036f2..000000000 --- a/webclient/src/containers/Game/useGameLifecycleNavigation.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useNavigate } from 'react-router-dom'; - -import { useToast } from '@app/components'; -import { useGameLifecycle } from '@app/hooks'; -import { App } from '@app/types'; - -export function useGameLifecycleNavigation(gameId: number | undefined): void { - 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); - }, - }); -} diff --git a/webclient/src/containers/Game/useGameOpponentSelector.ts b/webclient/src/containers/Game/useGameOpponentSelector.ts deleted file mode 100644 index e09f9f129..000000000 --- a/webclient/src/containers/Game/useGameOpponentSelector.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; - -import type { Enriched } from '@app/types'; - -export interface OpponentEntry { - playerId: number; - name: string; -} - -export interface GameOpponentSelector { - opponents: OpponentEntry[]; - selectedOpponentId: number | undefined; - setSelectedOpponentId: (id: number | undefined) => void; - shownOpponentId: number | undefined; - revealPlayers: OpponentEntry[]; -} - -export function useGameOpponentSelector( - game: Enriched.GameEntry | undefined, -): GameOpponentSelector { - const [selectedOpponentId, setSelectedOpponentId] = useState(); - - 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]); - - const shownOpponentId = selectedOpponentId ?? opponents[0]?.playerId; - - return { - opponents, - selectedOpponentId, - setSelectedOpponentId, - shownOpponentId, - revealPlayers: opponents, - }; -} diff --git a/webclient/src/containers/Layout/Layout.tsx b/webclient/src/containers/Layout/Layout.tsx index 6bdfdc150..858011b42 100644 --- a/webclient/src/containers/Layout/Layout.tsx +++ b/webclient/src/containers/Layout/Layout.tsx @@ -1,21 +1,12 @@ -import { ReactNode } from 'react'; - import LeftNav from './LeftNav'; import './Layout.css' -interface LayoutProps { - showNav?: boolean; - children: ReactNode; - className?: string; - noHeightLimit?: boolean; -} - -function Layout(props: LayoutProps) { +function Layout(props:LayoutProps) { const { children, className, showNav = true, noHeightLimit = false } = props; - const containerClasses = ['layout']; - if (noHeightLimit) { - containerClasses.push('layout--no-height-limit'); + const containerClasses = ['layout'] + if (noHeightLimit === true) { + containerClasses.push('layout--no-height-limit') } return ( @@ -38,4 +29,11 @@ function BottomBar() { ) } +interface LayoutProps { + showNav?: boolean; + children: any; + className?: string; + noHeightLimit?: boolean +} + export default Layout; diff --git a/webclient/src/containers/Layout/LeftNav.css b/webclient/src/containers/Layout/LeftNav.css index d3936ffd4..85c851d2e 100644 --- a/webclient/src/containers/Layout/LeftNav.css +++ b/webclient/src/containers/Layout/LeftNav.css @@ -38,6 +38,9 @@ margin-left: 10px; } +.LeftNav-nav { +} + .LeftNav-nav__links { display: flex; flex-flow: column; @@ -99,7 +102,27 @@ justify-content: center; } +.LeftNav-nav__action { + +} + .LeftNav-nav__action button { color: white; } +.temp-subnav__rooms { + display: flex; + align-items: center; + font-size: 10px; + padding: 5px; +} + +.temp-chip { + margin-left: 5px; + text-decoration: none; +} + + +.temp-chip > div { + cursor: inherit; +} diff --git a/webclient/src/containers/Layout/LeftNav.tsx b/webclient/src/containers/Layout/LeftNav.tsx index d3d671cb4..00d89455b 100644 --- a/webclient/src/containers/Layout/LeftNav.tsx +++ b/webclient/src/containers/Layout/LeftNav.tsx @@ -1,4 +1,5 @@ -import { NavLink, generatePath } from 'react-router-dom'; +import React, { useState, useEffect } from 'react'; +import { NavLink, useNavigate, generatePath } from 'react-router-dom'; import IconButton from '@mui/material/IconButton'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; @@ -8,25 +9,75 @@ import MailOutlineRoundedIcon from '@mui/icons-material/MailOutlineRounded'; import MenuRoundedIcon from '@mui/icons-material/MenuRounded'; import { CardImportDialog } from '@app/dialogs'; +import { useWebClient } from '@app/hooks'; import { Images } from '@app/images'; +import { RoomsSelectors, ServerSelectors } from '@app/store'; import { App } from '@app/types'; - -import { useLeftNav } from './useLeftNav'; +import { useAppSelector } from '@app/store'; import './LeftNav.css'; +interface LeftNavState { + anchorEl: Element; + showCardImportDialog: boolean; + options: string[]; +} + const LeftNav = () => { - const { - joinedRooms, - isConnected, - state, - handleMenuOpen, - handleMenuItemClick, - handleMenuClose, - leaveRoom, - openImportCardWizard, - closeImportCardWizard, - } = useLeftNav(); + const joinedRooms = useAppSelector(state => RoomsSelectors.getJoinedRooms(state)); + const isConnected = useAppSelector(ServerSelectors.getIsConnected); + const isModerator = useAppSelector(ServerSelectors.getIsUserModerator); + const navigate = useNavigate(); + const webClient = useWebClient(); + const [state, setState] = useState({ + anchorEl: null, + showCardImportDialog: false, + options: [], + }); + + useEffect(() => { + let options: string[] = [ + 'Account', + 'Replays', + ]; + + if (isModerator) { + options = [ + ...options, + 'Administration', + 'Logs' + ]; + } + + setState(s => ({ ...s, options })); + }, [isModerator]); + + const handleMenuOpen = (event) => { + setState(s => ({ ...s, anchorEl: event.target })); + } + + const handleMenuItemClick = (option: string) => { + const route = App.RouteEnum[option.toUpperCase()]; + navigate(generatePath(route)); + } + + const handleMenuClose = () => { + setState(s => ({ ...s, anchorEl: null })); + } + + const leaveRoom = (event, roomId) => { + event.preventDefault(); + webClient.request.rooms.leaveRoom(roomId); + }; + + const openImportCardWizard = () => { + setState(s => ({ ...s, showCardImportDialog: true })); + handleMenuClose(); + } + + const closeImportCardWizard = () => { + setState(s => ({ ...s, showCardImportDialog: false })); + } return (
    @@ -35,11 +86,11 @@ const LeftNav = () => { logo - {isConnected && ( + { isConnected && ( - )} + ) }
    - {isConnected && ( + { isConnected && (
    - + Games
    - + Decks @@ -109,8 +160,8 @@ const LeftNav = () => { }} > {state.options.map((option) => ( - handleMenuItemClick(option)}> - {option.label} + handleMenuItemClick(option)}> + {option} ))} @@ -122,7 +173,7 @@ const LeftNav = () => {
    - )} + ) }
    { >
    ); -}; +} export default LeftNav; diff --git a/webclient/src/containers/Layout/useLeftNav.ts b/webclient/src/containers/Layout/useLeftNav.ts deleted file mode 100644 index 66de06c56..000000000 --- a/webclient/src/containers/Layout/useLeftNav.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { useMemo, useState } from 'react'; -import { useNavigate, generatePath } from 'react-router-dom'; - -import { useWebClient } from '@app/hooks'; -import { RoomsSelectors, ServerSelectors, useAppSelector } from '@app/store'; -import { App } from '@app/types'; - -export interface LeftNavOption { - label: string; - route: App.RouteEnum; -} - -interface LeftNavState { - anchorEl: Element | null; - showCardImportDialog: boolean; - options: LeftNavOption[]; -} - -export interface LeftNav { - joinedRooms: ReturnType; - isConnected: boolean; - state: LeftNavState; - handleMenuOpen: (event: React.MouseEvent) => void; - handleMenuItemClick: (option: LeftNavOption) => void; - handleMenuClose: () => void; - leaveRoom: (event: React.MouseEvent, roomId: number) => void; - openImportCardWizard: () => void; - closeImportCardWizard: () => void; -} - -const BASE_OPTIONS: LeftNavOption[] = [ - { label: 'Account', route: App.RouteEnum.ACCOUNT }, - { label: 'Replays', route: App.RouteEnum.REPLAYS }, -]; - -const MODERATOR_OPTIONS: LeftNavOption[] = [ - { label: 'Administration', route: App.RouteEnum.ADMINISTRATION }, - { label: 'Logs', route: App.RouteEnum.LOGS }, -]; - -export function useLeftNav(): LeftNav { - const joinedRooms = useAppSelector((state) => RoomsSelectors.getJoinedRooms(state)); - const isConnected = useAppSelector(ServerSelectors.getIsConnected); - const isModerator = useAppSelector(ServerSelectors.getIsUserModerator); - const navigate = useNavigate(); - const webClient = useWebClient(); - const [anchorEl, setAnchorEl] = useState(null); - const [showCardImportDialog, setShowCardImportDialog] = useState(false); - - const options = useMemo( - () => (isModerator ? [...BASE_OPTIONS, ...MODERATOR_OPTIONS] : BASE_OPTIONS), - [isModerator], - ); - - const state: LeftNavState = { anchorEl, showCardImportDialog, options }; - - const handleMenuOpen = (event: React.MouseEvent) => { - setAnchorEl(event.target as Element); - }; - - const handleMenuItemClick = (option: LeftNavOption) => { - navigate(generatePath(option.route)); - }; - - const handleMenuClose = () => { - setAnchorEl(null); - }; - - const leaveRoom = (event: React.MouseEvent, roomId: number) => { - event.preventDefault(); - webClient.request.rooms.leaveRoom(roomId); - }; - - const openImportCardWizard = () => { - setShowCardImportDialog(true); - handleMenuClose(); - }; - - const closeImportCardWizard = () => { - setShowCardImportDialog(false); - }; - - return { - joinedRooms, - isConnected, - state, - handleMenuOpen, - handleMenuItemClick, - handleMenuClose, - leaveRoom, - openImportCardWizard, - closeImportCardWizard, - }; -} diff --git a/webclient/src/containers/Login/Login.css b/webclient/src/containers/Login/Login.css index f79b0d008..7166187ad 100644 --- a/webclient/src/containers/Login/Login.css +++ b/webclient/src/containers/Login/Login.css @@ -54,7 +54,7 @@ overflow: hidden; } -.login-content__description-wrapper { +.login-content__description-wrapper { position: relative; width: 70%; display: flex; @@ -115,8 +115,8 @@ margin: 40px 0 20px; font-size: 28px; font-weight: bold; + } - .login-content__description-subtitle2 { font-size: 14px; } diff --git a/webclient/src/containers/Login/Login.spec.tsx b/webclient/src/containers/Login/Login.spec.tsx index 33b57263c..e2a398066 100644 --- a/webclient/src/containers/Login/Login.spec.tsx +++ b/webclient/src/containers/Login/Login.spec.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, waitFor } from '@testing-library/react'; +import { act, waitFor } from '@testing-library/react'; import { renderWithProviders, createMockWebClient, disconnectedState } from '../../__test-utils__'; @@ -29,14 +29,10 @@ vi.mock('../../hooks/useKnownHosts', () => ({ useKnownHosts: hoisted.useKnownHosts, getKnownHosts: hoisted.getKnownHosts, })); -vi.mock('../../hooks/useWebClient', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useWebClient: () => hoisted.mockWebClient, - WebClientProvider: ({ children }: { children: any }) => children, - }; -}); +vi.mock('../../hooks/useWebClient', () => ({ + useWebClient: () => hoisted.mockWebClient, + WebClientProvider: ({ children }: { children: any }) => children, +})); beforeAll(() => { const client = createMockWebClient(); @@ -166,31 +162,6 @@ describe('Login — logout cycle (same JS session)', () => { await flushEffects(); expect(hoisted.mockWebClient.request.authentication.login).not.toHaveBeenCalled(); }); - - test('submits with the restored host after a logout→remount without Required error', async () => { - const first = renderWithProviders(, { preloadedState: disconnectedState }); - await flushEffects(); - first.unmount(); - - // Submit button stays disabled until testConnectionStatus resolves to 'success'; - // preload it so the click actually dispatches. - const { getByRole, queryByText } = renderWithProviders(, { - preloadedState: { - ...disconnectedState, - server: { ...(disconnectedState.server as any), testConnectionStatus: 'success' }, - }, - }); - await flushEffects(); - - fireEvent.click(getByRole('button', { name: /LoginForm\.label\.login/ })); - await flushEffects(); - - expect(queryByText(/required/i)).toBeNull(); - expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); - expect(hoisted.mockWebClient.request.authentication.login.mock.calls[0][0]).toMatchObject({ - userName: 'alice', - }); - }); }); describe('Login — refresh cycle', () => { @@ -204,96 +175,3 @@ describe('Login — refresh cycle', () => { }); }); }); - -// End-to-end regression: the symptom reported on this branch was "save-password -// checkbox shows, login succeeds, but HostDTO.hashedPassword stays empty in -// Dexie". These tests wire the full chain — auto-login fires onSubmitLogin -// (capturing the form in rememberLoginRef), then a store.dispatch of -// LOGIN_SUCCESSFUL drives the useReduxEffect that calls knownHosts.update. -describe('Login — LOGIN_SUCCESSFUL → knownHosts persistence', () => { - const armWithUpdate = () => { - const update = vi.fn().mockResolvedValue(undefined); - const host = makeHost({ - id: 7, - remember: true, - userName: 'alice', - hashedPassword: 'stored-hash', - supportsHashedPassword: true, - lastSelected: true, - }); - hoisted.useSettings.mockReturnValue( - makeSettingsHook({ - status: LoadingState.READY, - value: makeSettings({ autoConnect: true }), - update: vi.fn().mockResolvedValue(undefined), - }) - ); - hoisted.useKnownHosts.mockReturnValue( - makeKnownHostsHook({ - status: LoadingState.READY, - value: { hosts: [host], selectedHost: host }, - update, - }) - ); - hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true })); - hoisted.getKnownHosts.mockResolvedValue({ hosts: [host], selectedHost: host }); - return { update, hostId: host.id! }; - }; - - test('persists userName + hashedPassword when loginSuccessful carries a real hash', async () => { - const { update, hostId } = armWithUpdate(); - - const { store } = renderWithProviders(, { - preloadedState: { - ...disconnectedState, - server: { ...(disconnectedState.server as any), testConnectionStatus: 'success' }, - }, - }); - await waitFor(() => { - expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); - }); - - store.dispatch({ - type: 'server/loginSuccessful', - payload: { options: { hashedPassword: 'real-hash-xyz' } }, - }); - await flushEffects(); - - expect(update).toHaveBeenCalledWith(hostId, { - remember: true, - userName: 'alice', - hashedPassword: 'real-hash-xyz', - }); - }); - - test('does not persist credentials when hashedPassword is empty (empty-salt fallback)', async () => { - const { update, hostId } = armWithUpdate(); - - const { store } = renderWithProviders(, { - preloadedState: { - ...disconnectedState, - server: { ...(disconnectedState.server as any), testConnectionStatus: 'success' }, - }, - }); - await waitFor(() => { - expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1); - }); - - // Empty-salt fallback path: login went through as plain password, so the - // response layer has no hash to carry forward. - store.dispatch({ - type: 'server/loginSuccessful', - payload: { options: { hashedPassword: undefined } }, - }); - await flushEffects(); - - // Guard in useLogin.updateHost clears remember+credentials so next load - // reflects that save-password wasn't honoured — no stale "checked" checkbox - // sitting against a null hash. - expect(update).toHaveBeenCalledWith(hostId, { - remember: false, - userName: null, - hashedPassword: null, - }); - }); -}); diff --git a/webclient/src/containers/Login/Login.tsx b/webclient/src/containers/Login/Login.tsx index 423a09793..78bcdf242 100644 --- a/webclient/src/containers/Login/Login.tsx +++ b/webclient/src/containers/Login/Login.tsx @@ -1,3 +1,4 @@ +import { useState, useCallback, useRef } from 'react'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import { Navigate } from 'react-router-dom'; @@ -8,25 +9,28 @@ import Typography from '@mui/material/Typography'; import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs'; import { LanguageDropdown } from '@app/components'; import { LoginForm } from '@app/forms'; +import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks'; import { Images } from '@app/images'; -import { serverProps } from '@app/services'; +import { getHostPort, serverProps } from '@app/services'; import { App } from '@app/types'; +import { WebsocketTypes } from '@app/websocket/types'; +import { ServerSelectors, ServerTypes } from '@app/store'; import Layout from '../Layout/Layout'; - -import { useLogin } from './useLogin'; +import { useAppSelector } from '@app/store'; import './Login.css'; +import { useToast } from '@app/components'; const PREFIX = 'Login'; const classes = { - root: `${PREFIX}-root`, + root: `${PREFIX}-root` }; const Root = styled('div')(({ theme }) => ({ [`&.${classes.root}`]: { '& .login-content__header': { - color: theme.palette.success.light, + color: theme.palette.success.light }, '& .login-content__description': { @@ -57,36 +61,188 @@ const Root = styled('div')(({ theme }) => ({ display: 'flex', }, }, - }, + } })); const Login = () => { + const description = useAppSelector(s => ServerSelectors.getDescription(s)); + const isConnected = useAppSelector(ServerSelectors.getIsConnected); + const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade); + const webClient = useWebClient(); const { t } = useTranslation(); - const { - description, - isConnected, - dialogState, - userToResetPassword, - submitButtonDisabled, - handleLogin, - showDescription, - handleRegistrationDialogSubmit, - handleAccountActivationDialogSubmit, - handleRequestPasswordResetDialogSubmit, - handleResetPasswordDialogSubmit, - skipTokenRequest, - closeRequestPasswordResetDialog, - openRequestPasswordResetDialog, - closeResetPasswordDialog, - closeRegistrationDialog, - openRegistrationDialog, - closeActivateAccountDialog, - } = useLogin(); + + const [pendingActivationOptions, setPendingActivationOptions] = useState(null); + + const rememberLoginRef = useRef(null); + const knownHosts = useKnownHosts(); + const [dialogState, setDialogState] = useState({ + passwordResetRequestDialog: false, + resetPasswordDialog: false, + registrationDialog: false, + activationDialog: false, + }); + const [userToResetPassword, setUserToResetPassword] = useState(null); + + const passwordResetToast = useToast({ key: 'password-reset-success', children: t('LoginContainer.toasts.passwordResetSuccess') }); + const accountActivatedToast = useToast({ + key: 'account-activation-success', + children: t('LoginContainer.toasts.accountActivationSuccess') + }); + + useReduxEffect(() => { + closeRequestPasswordResetDialog(); + openResetPasswordDialog(); + }, ServerTypes.RESET_PASSWORD_REQUESTED, []); + + useReduxEffect(() => { + passwordResetToast.openToast() + closeResetPasswordDialog(); + }, ServerTypes.RESET_PASSWORD_SUCCESS, []); + + useReduxEffect(() => { + accountActivatedToast.openToast() + closeActivateAccountDialog(); + setPendingActivationOptions(null); + }, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []); + + useReduxEffect(({ payload: { options } }) => { + setPendingActivationOptions(options); + closeRegistrationDialog(); + openActivateAccountDialog(); + }, ServerTypes.ACCOUNT_AWAITING_ACTIVATION, []); + + useReduxEffect(() => { + resetSubmitButton(); + }, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []); + + useReduxEffect(({ payload: { options: { hashedPassword } } }) => { + if (rememberLoginRef.current) { + updateHost(hashedPassword, rememberLoginRef.current); + } + }, ServerTypes.LOGIN_SUCCESSFUL, []); + + const showDescription = () => { + return !isConnected && description?.length; + }; + + const onSubmitLogin = useCallback((loginForm) => { + rememberLoginRef.current = loginForm; + const { userName, password, selectedHost, remember } = loginForm; + + const options: Omit = { + ...getHostPort(selectedHost), + userName, + password, + }; + + if (remember && !password) { + options.hashedPassword = selectedHost.hashedPassword; + } + + webClient.request.authentication.login(options); + }, []); + + const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin); + + useAutoLogin(handleLogin, connectionAttemptMade); + + const updateHost = (hashedPassword, { selectedHost, remember, userName }) => { + knownHosts.update(selectedHost.id, { + remember, + userName: remember ? userName : null, + hashedPassword: remember ? hashedPassword : null, + }); + }; + + const handleRegistrationDialogSubmit = (registerForm) => { + rememberLoginRef.current = registerForm; + const { userName, password, email, country, realName, selectedHost } = registerForm; + + webClient.request.authentication.register({ + ...getHostPort(selectedHost), + userName, + password, + email, + country, + realName, + }); + }; + + const handleAccountActivationDialogSubmit = ({ token }) => { + if (!pendingActivationOptions) { + return; + } + webClient.request.authentication.activateAccount({ + host: pendingActivationOptions.host, + port: pendingActivationOptions.port, + userName: pendingActivationOptions.userName, + token, + }); + }; + + const handleRequestPasswordResetDialogSubmit = (form) => { + const { userName, email, selectedHost } = form; + const { host, port } = getHostPort(selectedHost); + + if (email) { + webClient.request.authentication.resetPasswordChallenge({ userName, email, host, port }); + } else { + setUserToResetPassword(userName); + webClient.request.authentication.resetPasswordRequest({ userName, host, port }); + } + }; + + const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => { + const { host, port } = getHostPort(selectedHost); + + webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port }); + }; + + const skipTokenRequest = (userName) => { + setUserToResetPassword(userName); + + setDialogState(s => ({ ...s, + passwordResetRequestDialog: false, + resetPasswordDialog: true, + })); + }; + + const closeRequestPasswordResetDialog = () => { + setDialogState(s => ({ ...s, passwordResetRequestDialog: false })); + } + + const openRequestPasswordResetDialog = () => { + setDialogState(s => ({ ...s, passwordResetRequestDialog: true })); + } + + const closeResetPasswordDialog = () => { + setDialogState(s => ({ ...s, resetPasswordDialog: false })); + } + + const openResetPasswordDialog = () => { + setDialogState(s => ({ ...s, resetPasswordDialog: true })); + } + + const closeRegistrationDialog = () => { + setDialogState(s => ({ ...s, registrationDialog: false })); + } + + const openRegistrationDialog = () => { + setDialogState(s => ({ ...s, registrationDialog: true })); + } + + const closeActivateAccountDialog = () => { + setDialogState(s => ({ ...s, activationDialog: false })); + }; + + const openActivateAccountDialog = () => { + setDialogState(s => ({ ...s, activationDialog: true })); + }; return ( - {isConnected && } + { isConnected && }
    @@ -95,8 +251,8 @@ const Login = () => { logo COCKATRICE
    - {t('LoginContainer.header.title')} - {t('LoginContainer.header.subtitle')} + { t('LoginContainer.header.title') } + { t('LoginContainer.header.subtitle') }
    { />
    - {showDescription() && ( - - {description} - - )} + { + showDescription() && ( + + {description} + + ) + }
    - {t('LoginContainer.footer.registerPrompt')} - + { t('LoginContainer.footer.registerPrompt') } +
    - {t('LoginContainer.footer.credit')} - {new Date().getUTCFullYear()} + { t('LoginContainer.footer.credit') } - { new Date().getUTCFullYear() } - {serverProps.REACT_APP_VERSION && ( - - {t('LoginContainer.footer.version')}: {serverProps.REACT_APP_VERSION} - - )} + { + serverProps.REACT_APP_VERSION && ( + + { t('LoginContainer.footer.version') }: { serverProps.REACT_APP_VERSION } + + ) + }
    @@ -161,8 +321,9 @@ const Login = () => {
    -

    {t('LoginContainer.content.subtitle1')}

    -

    {t('LoginContainer.content.subtitle2')}

    + { /**/} +

    { t('LoginContainer.content.subtitle1') }

    +

    { t('LoginContainer.content.subtitle2') }

    @@ -196,6 +357,6 @@ const Login = () => { ); -}; +} export default Login; diff --git a/webclient/src/containers/Login/useLogin.ts b/webclient/src/containers/Login/useLogin.ts deleted file mode 100644 index 724c00dcf..000000000 --- a/webclient/src/containers/Login/useLogin.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { useCallback, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { useToast } from '@app/components'; -import type { - LoginFormValues, - RegisterFormValues, - RequestPasswordResetFormValues, - ResetPasswordFormValues, -} from '@app/forms'; -import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks'; -import { getHostPort } from '@app/services'; -import { ServerSelectors, ServerTypes, useAppSelector } from '@app/store'; -import { WebsocketTypes } from '@app/websocket/types'; - -export interface LoginDialogState { - passwordResetRequestDialog: boolean; - resetPasswordDialog: boolean; - registrationDialog: boolean; - activationDialog: boolean; -} - -export interface Login { - description: string | undefined; - isConnected: boolean; - dialogState: LoginDialogState; - userToResetPassword: string | null; - submitButtonDisabled: boolean; - handleLogin: (form: LoginFormValues) => void; - showDescription: () => boolean; - handleRegistrationDialogSubmit: (form: RegisterFormValues) => void; - handleAccountActivationDialogSubmit: (args: { token: string }) => void; - handleRequestPasswordResetDialogSubmit: (form: RequestPasswordResetFormValues) => void; - handleResetPasswordDialogSubmit: (form: ResetPasswordFormValues) => void; - skipTokenRequest: (userName: string) => void; - closeRequestPasswordResetDialog: () => void; - openRequestPasswordResetDialog: () => void; - closeResetPasswordDialog: () => void; - closeRegistrationDialog: () => void; - openRegistrationDialog: () => void; - closeActivateAccountDialog: () => void; -} - -export function useLogin(): Login { - const description = useAppSelector((s) => ServerSelectors.getDescription(s)); - const isConnected = useAppSelector(ServerSelectors.getIsConnected); - const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade); - const webClient = useWebClient(); - const { t } = useTranslation(); - - const [pendingActivationOptions, setPendingActivationOptions] = - useState(null); - - const rememberLoginRef = useRef(null); - const knownHosts = useKnownHosts(); - const [dialogState, setDialogState] = useState({ - passwordResetRequestDialog: false, - resetPasswordDialog: false, - registrationDialog: false, - activationDialog: false, - }); - const [userToResetPassword, setUserToResetPassword] = useState(null); - - const passwordResetToast = useToast({ - key: 'password-reset-success', - children: t('LoginContainer.toasts.passwordResetSuccess'), - }); - const accountActivatedToast = useToast({ - key: 'account-activation-success', - children: t('LoginContainer.toasts.accountActivationSuccess'), - }); - - const closeRequestPasswordResetDialog = () => { - setDialogState((s) => ({ ...s, passwordResetRequestDialog: false })); - }; - - const openRequestPasswordResetDialog = () => { - setDialogState((s) => ({ ...s, passwordResetRequestDialog: true })); - }; - - const closeResetPasswordDialog = () => { - setDialogState((s) => ({ ...s, resetPasswordDialog: false })); - }; - - const openResetPasswordDialog = () => { - setDialogState((s) => ({ ...s, resetPasswordDialog: true })); - }; - - const closeRegistrationDialog = () => { - setDialogState((s) => ({ ...s, registrationDialog: false })); - }; - - const openRegistrationDialog = () => { - setDialogState((s) => ({ ...s, registrationDialog: true })); - }; - - const closeActivateAccountDialog = () => { - setDialogState((s) => ({ ...s, activationDialog: false })); - }; - - const openActivateAccountDialog = () => { - setDialogState((s) => ({ ...s, activationDialog: true })); - }; - - useReduxEffect(() => { - closeRequestPasswordResetDialog(); - openResetPasswordDialog(); - }, ServerTypes.RESET_PASSWORD_REQUESTED, []); - - useReduxEffect(() => { - passwordResetToast.openToast(); - closeResetPasswordDialog(); - }, ServerTypes.RESET_PASSWORD_SUCCESS, []); - - useReduxEffect(() => { - accountActivatedToast.openToast(); - closeActivateAccountDialog(); - setPendingActivationOptions(null); - }, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []); - - useReduxEffect<{ options: WebsocketTypes.PendingActivationContext }>(({ payload: { options } }) => { - setPendingActivationOptions(options); - closeRegistrationDialog(); - openActivateAccountDialog(); - }, ServerTypes.ACCOUNT_AWAITING_ACTIVATION, []); - - const onSubmitLogin = useCallback((loginForm: LoginFormValues) => { - rememberLoginRef.current = loginForm; - const { userName, password, selectedHost, remember } = loginForm; - - const options: Omit = { - ...getHostPort(selectedHost), - userName, - password, - }; - - if (remember && !password) { - options.hashedPassword = selectedHost.hashedPassword; - } - - webClient.request.authentication.login(options); - }, [webClient]); - - const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin); - - useReduxEffect(() => { - resetSubmitButton(); - }, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []); - - const updateHost = ( - hashedPassword: string | undefined, - { selectedHost, remember, userName }: LoginFormValues, - ) => { - if (selectedHost.id == null) { - return; - } - // Only honour the Remember checkbox when we actually received a hash to - // store. A server that advertises SupportsPasswordHash but returns an empty - // salt falls through to a plain-password login (no hash produced); writing - // `remember: true` with a null hash would leave the checkbox visibly - // checked on next load while the stored-password flow silently couldn't - // activate. Resetting to the unchecked state makes the failure visible. - const persistCredentials = remember && Boolean(hashedPassword); - knownHosts.update(selectedHost.id, { - remember: persistCredentials, - userName: persistCredentials ? userName : null, - hashedPassword: persistCredentials ? hashedPassword : null, - }); - }; - - useReduxEffect<{ options: WebsocketTypes.LoginSuccessContext }>(({ payload: { options } }) => { - const loginForm = rememberLoginRef.current; - if (loginForm && 'remember' in loginForm) { - updateHost(options.hashedPassword, loginForm); - } - }, ServerTypes.LOGIN_SUCCESSFUL, []); - - useAutoLogin(handleLogin, connectionAttemptMade); - - const showDescription = () => { - return Boolean(!isConnected && description?.length); - }; - - const handleRegistrationDialogSubmit = (registerForm: RegisterFormValues) => { - rememberLoginRef.current = registerForm; - const { userName, password, email, country, realName, selectedHost } = registerForm; - - webClient.request.authentication.register({ - ...getHostPort(selectedHost), - userName, - password, - email, - country, - realName, - }); - }; - - const handleAccountActivationDialogSubmit = ({ token }: { token: string }) => { - if (!pendingActivationOptions) { - return; - } - webClient.request.authentication.activateAccount({ - host: pendingActivationOptions.host, - port: pendingActivationOptions.port, - userName: pendingActivationOptions.userName, - token, - }); - }; - - const handleRequestPasswordResetDialogSubmit = (form: RequestPasswordResetFormValues) => { - const { userName, email, selectedHost } = form; - const { host, port } = getHostPort(selectedHost); - - if (email) { - webClient.request.authentication.resetPasswordChallenge({ userName, email, host, port }); - } else { - setUserToResetPassword(userName); - webClient.request.authentication.resetPasswordRequest({ userName, host, port }); - } - }; - - const handleResetPasswordDialogSubmit = ({ - userName, - token, - newPassword, - selectedHost, - }: ResetPasswordFormValues) => { - const { host, port } = getHostPort(selectedHost); - webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port }); - }; - - const skipTokenRequest = (userName: string) => { - setUserToResetPassword(userName); - - setDialogState((s) => ({ - ...s, - passwordResetRequestDialog: false, - resetPasswordDialog: true, - })); - }; - - return { - description, - isConnected, - dialogState, - userToResetPassword, - submitButtonDisabled, - handleLogin, - showDescription, - handleRegistrationDialogSubmit, - handleAccountActivationDialogSubmit, - handleRequestPasswordResetDialogSubmit, - handleResetPasswordDialogSubmit, - skipTokenRequest, - closeRequestPasswordResetDialog, - openRequestPasswordResetDialog, - closeResetPasswordDialog, - closeRegistrationDialog, - openRegistrationDialog, - closeActivateAccountDialog, - }; -} diff --git a/webclient/src/containers/Logs/LogResults.tsx b/webclient/src/containers/Logs/LogResults.tsx index 5f99479cb..201ca0db0 100644 --- a/webclient/src/containers/Logs/LogResults.tsx +++ b/webclient/src/containers/Logs/LogResults.tsx @@ -1,5 +1,4 @@ -import { ReactNode } from 'react'; -import { useTranslation } from 'react-i18next'; +import React from 'react'; import AppBar from '@mui/material/AppBar'; import Box from '@mui/material/Box'; @@ -11,99 +10,51 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Tab from '@mui/material/Tab'; import Tabs from '@mui/material/Tabs'; - -import type { Data } from '@app/types'; -import type { ServerStateLogs } from '@app/store'; - -import { useLogResults } from './useLogResults'; +import Typography from '@mui/material/Typography'; import './LogResults.css'; -interface LogResultsProps { - logs: ServerStateLogs; -} +const LogResults = (props) => { + const { logs } = props; -interface HeaderCell { - label: string; -} + const hasRoomLogs = logs.room && logs.room.length; + const hasGameLogs = logs.game && logs.game.length; + const hasChatLogs = logs.chat && logs.chat.length; -interface ResultsProps { - headerCells: HeaderCell[]; - logs: Data.ServerInfo_ChatMessage[]; -} + const [value, setValue] = React.useState(0); -interface TabPanelProps { - children?: ReactNode; - value: number; - index: number; -} + const handleChange = (event, newValue) => { + setValue(newValue); + }; -const a11yProps = (index: number): { id: string; 'aria-controls': string } => ({ - id: `logs-tab-${index}`, - 'aria-controls': `logs-tabpanel-${index}`, -}); - -const TabPanel = ({ children, value, index }: TabPanelProps) => ( - -); - -const Results = ({ headerCells, logs }: ResultsProps) => ( - - - - - {headerCells.map(({ label }) => ( - {label} - ))} - - - - {logs.map((log, index) => ( - - {log.time} - {log.senderName} - {log.senderIp} - {log.message} - {log.targetId} - {log.targetName} - - ))} - -
    -
    -); - -const LogResults = ({ logs }: LogResultsProps) => { - const { t } = useTranslation(); - const { value, handleChange } = useLogResults(); - - const headerCells: HeaderCell[] = [ - { label: t('Logs.column.time') }, - { label: t('Logs.column.senderName') }, - { label: t('Logs.column.senderIp') }, - { label: t('Logs.column.message') }, - { label: t('Logs.column.targetId') }, - { label: t('Logs.column.targetName') }, + const headerCells = [ + { + label: 'Time' + }, + { + label: 'Sender Name' + }, + { + label: 'Sender IP' + }, + { + label: 'Message' + }, + { + label: 'Target ID' + }, + { + label: 'Target Name' + } ]; - const roomCount = logs.room?.length ?? 0; - const gameCount = logs.game?.length ?? 0; - const chatCount = logs.chat?.length ?? 0; - return (
    - - 0 ? ` [${roomCount}]` : ''}`} {...a11yProps(0)} /> - 0 ? ` [${gameCount}]` : ''}`} {...a11yProps(1)} /> - 0 ? ` [${chatCount}]` : ''}`} {...a11yProps(2)} /> + + + + @@ -116,7 +67,55 @@ const LogResults = ({ logs }: LogResultsProps) => {
    + ) +}; + +const a11yProps = index => { + return { + id: `simple-tab-${index}`, + 'aria-controls': `simple-tabpanel-${index}`, + }; +}; + +const TabPanel = ({ children, value, index, ...other }) => { + return ( + ); }; +const Results = ({ headerCells, logs }) => ( + + + + + { headerCells.map(({ label }) => ( + {label} + ))} + + + + { logs.map(({ time, senderName, senderIp, message, targetId, targetName }, index) => ( + + {time} + {senderName} + {senderIp} + {message} + {targetId} + {targetName} + + ))} + +
    +
    +); + export default LogResults; diff --git a/webclient/src/containers/Logs/Logs.i18n.json b/webclient/src/containers/Logs/Logs.i18n.json deleted file mode 100644 index 6ffaf1b6f..000000000 --- a/webclient/src/containers/Logs/Logs.i18n.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "Logs": { - "tab": { - "rooms": "Rooms", - "games": "Games", - "chats": "Chats" - }, - "column": { - "time": "Time", - "senderName": "Sender Name", - "senderIp": "Sender IP", - "message": "Message", - "targetId": "Target ID", - "targetName": "Target Name" - }, - "message": { - "emptyFilter": "Enter at least one search field before submitting." - } - } -} diff --git a/webclient/src/containers/Logs/Logs.tsx b/webclient/src/containers/Logs/Logs.tsx index 1c7dbfe40..bf2fe9a6d 100644 --- a/webclient/src/containers/Logs/Logs.tsx +++ b/webclient/src/containers/Logs/Logs.tsx @@ -1,13 +1,61 @@ +import { useEffect } from 'react'; + import { AuthGuard, ModGuard } from '@app/components'; import { SearchForm } from '@app/forms'; +import { useWebClient } from '@app/hooks'; +import { ServerDispatch, ServerSelectors } from '@app/store'; +import { Data } from '@app/types'; +import { useAppSelector } from '@app/store'; import LogResults from './LogResults'; -import { useLogs } from './useLogs'; - import './Logs.css'; const Logs = () => { - const { logs, onSubmit } = useLogs(); + const logs = useAppSelector(state => ServerSelectors.getLogs(state)); + const webClient = useWebClient(); + const MAXIMUM_RESULTS = 1000; + + useEffect(() => { + return () => { + ServerDispatch.clearLogs(); + }; + }, []); + + const trimFields = (fields) => { + const result: any = {}; + for (const [key, field] of Object.entries(fields)) { + if (typeof field === 'string') { + const trimmed = field.trim(); + if (trimmed) { + result[key] = trimmed; + } + } else { + result[key] = field; + } + } + return result; + }; + + const flattenLogLocations = (logLocations) => Object.keys(logLocations); + + const onSubmit = (fields: Data.ViewLogHistoryParams) => { + const trimmedFields: any = trimFields(fields); + const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields; + + const required = [userName, ipAddress, gameName, gameId, message].filter(Boolean); + + if (logLocation) { + trimmedFields.logLocation = flattenLogLocations(logLocation); + } + + trimmedFields.maximumResults = MAXIMUM_RESULTS; + + if (required.length) { + webClient.request.moderator.viewLogHistory(trimmedFields); + } else { + // @TODO use yet-to-be-implemented banner/alert + } + }; return (
    @@ -26,3 +74,13 @@ const Logs = () => { }; export default Logs; + + + + + + + + + + diff --git a/webclient/src/containers/Logs/useLogResults.ts b/webclient/src/containers/Logs/useLogResults.ts deleted file mode 100644 index 68e174bbe..000000000 --- a/webclient/src/containers/Logs/useLogResults.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useState } from 'react'; - -export interface LogResults { - value: number; - handleChange: (event: unknown, newValue: number) => void; -} - -export function useLogResults(): LogResults { - const [value, setValue] = useState(0); - - const handleChange = (_event: unknown, newValue: number) => { - setValue(newValue); - }; - - return { value, handleChange }; -} diff --git a/webclient/src/containers/Logs/useLogs.ts b/webclient/src/containers/Logs/useLogs.ts deleted file mode 100644 index 57d5c5c59..000000000 --- a/webclient/src/containers/Logs/useLogs.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { useToast } from '@app/components'; -import { useWebClient } from '@app/hooks'; -import { ServerDispatch, ServerSelectors, ServerStateLogs, useAppSelector } from '@app/store'; -import { Data } from '@app/types'; - -const MAXIMUM_RESULTS = 1000; - -export interface Logs { - logs: ServerStateLogs; - onSubmit: (fields: Data.ViewLogHistoryParams) => void; -} - -export function useLogs(): Logs { - const { t } = useTranslation(); - const logs = useAppSelector((state) => ServerSelectors.getLogs(state)); - const webClient = useWebClient(); - const { openToast } = useToast({ - key: 'logs-empty-filter', - children: t('Logs.message.emptyFilter'), - }); - - useEffect(() => { - return () => { - ServerDispatch.clearLogs(); - }; - }, []); - - const trimFields = (fields: Data.ViewLogHistoryParams): Data.ViewLogHistoryParams => { - const result: Data.ViewLogHistoryParams = { ...fields }; - for (const key of Object.keys(result) as (keyof Data.ViewLogHistoryParams)[]) { - const field = result[key]; - if (typeof field === 'string') { - const trimmed = field.trim(); - if (trimmed) { - (result as Record)[key] = trimmed; - } else { - delete (result as Record)[key]; - } - } - } - return result; - }; - - const flattenLogLocations = (logLocations: Record): string[] => - Object.keys(logLocations); - - const onSubmit = (fields: Data.ViewLogHistoryParams) => { - const trimmedFields = trimFields(fields); - const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields as - Data.ViewLogHistoryParams & { logLocation?: Record }; - - const required = [userName, ipAddress, gameName, gameId, message].filter(Boolean); - - if (logLocation) { - trimmedFields.logLocation = flattenLogLocations(logLocation); - } - - trimmedFields.maximumResults = MAXIMUM_RESULTS; - - if (required.length) { - webClient.request.moderator.viewLogHistory(trimmedFields); - } else { - openToast(); - } - }; - - return { logs, onSubmit }; -} diff --git a/webclient/src/containers/Player/Player.css b/webclient/src/containers/Player/Player.css deleted file mode 100644 index 11d841ca1..000000000 --- a/webclient/src/containers/Player/Player.css +++ /dev/null @@ -1,66 +0,0 @@ -.player-view { - display: flex; - justify-content: center; - padding: 24px; -} - -.player-view__card { - width: 100%; - max-width: 640px; - padding: 24px; -} - -.player-view__avatar-wrapper { - display: flex; - justify-content: center; - margin-bottom: 16px; -} - -.player-view__avatar { - width: 160px; - height: 160px; - border-radius: 8px; - object-fit: cover; - background-color: var(--bg-subtle, #eee); -} - -.player-view__name { - text-align: center; - margin: 0 0 8px; -} - -.player-view__level-badge { - text-align: center; - margin-bottom: 16px; - color: var(--text-muted, #666); -} - -.player-view__details { - display: grid; - grid-template-columns: max-content 1fr; - gap: 8px 16px; - margin-bottom: 16px; -} - -.player-view__label { - font-weight: bold; -} - -.player-view__country-flag { - display: inline-block; - vertical-align: middle; - margin-right: 8px; - height: 16px; -} - -.player-view__actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: center; -} - -.player-view__empty { - text-align: center; - color: var(--text-muted, #666); -} diff --git a/webclient/src/containers/Player/Player.i18n.json b/webclient/src/containers/Player/Player.i18n.json deleted file mode 100644 index ac33abfe1..000000000 --- a/webclient/src/containers/Player/Player.i18n.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "Player": { - "title": "User Information", - "label": { - "realName": "Real Name", - "location": "Location", - "userLevel": "User Level", - "accountAge": "Account Age" - }, - "level": { - "administrator": "Administrator", - "moderator": "Moderator", - "registered": "Registered user", - "unregistered": "Unregistered user", - "judge": "Judge" - }, - "age": { - "unknown": "Unknown", - "days": "{count, plural, one {# day} other {# days}}", - "daysWithYears": "{years, plural, one {# year} other {# years}}, {days, plural, one {# day} other {# days}}" - }, - "action": { - "addBuddy": "Add to Buddy List", - "removeBuddy": "Remove from Buddy List", - "addIgnore": "Add to Ignore List", - "removeIgnore": "Remove from Ignore List", - "message": "Message", - "warn": "Warn User", - "ban": "Ban from Server", - "notFound": "User not found or still loading…" - } - } -} diff --git a/webclient/src/containers/Player/Player.tsx b/webclient/src/containers/Player/Player.tsx index 4bd23685d..899285054 100644 --- a/webclient/src/containers/Player/Player.tsx +++ b/webclient/src/containers/Player/Player.tsx @@ -1,177 +1,14 @@ -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import Button from '@mui/material/Button'; -import Paper from '@mui/material/Paper'; -import Typography from '@mui/material/Typography'; - import Layout from '../Layout/Layout'; + import { AuthGuard } from '@app/components'; -import { Images } from '@app/images'; -import { Data } from '@app/types'; - -import { usePlayer } from './usePlayer'; - -import './Player.css'; - -const AVATAR_DATA_URI_PREFIX = 'data:image/png;base64,'; - -/** Builds a data URI from a raw avatar payload, or returns null when no avatar is set. */ -function avatarSrc(bmp: Uint8Array | undefined): string | null { - if (!bmp || bmp.byteLength === 0) { - return null; - } - let binary = ''; - for (let i = 0; i < bmp.byteLength; i += 1) { - binary += String.fromCharCode(bmp[i]); - } - return AVATAR_DATA_URI_PREFIX + btoa(binary); -} - -/** Matches desktop UserInfoBox level hierarchy: admin > moderator > registered > unregistered, plus Judge marker. */ -function userLevelLabel(userLevel: number, t: (k: string) => string): string { - const Flag = Data.ServerInfo_User_UserLevelFlag; - const parts: string[] = []; - if ((userLevel & Flag.IsAdmin) === Flag.IsAdmin) { - parts.push(t('Player.level.administrator')); - } else if ((userLevel & Flag.IsModerator) === Flag.IsModerator) { - parts.push(t('Player.level.moderator')); - } else if ((userLevel & Flag.IsRegistered) === Flag.IsRegistered) { - parts.push(t('Player.level.registered')); - } else { - parts.push(t('Player.level.unregistered')); - } - if ((userLevel & Flag.IsJudge) === Flag.IsJudge) { - parts.push(t('Player.level.judge')); - } - return parts.join(' | '); -} - -/** Formats account age like desktop's getAgeString: "Unknown" | "N days" | "Y years, D days". */ -function formatAccountAge( - accountageSecs: bigint | undefined, - userLevel: number, - t: (k: string, params?: Record) => string, -): string { - const Flag = Data.ServerInfo_User_UserLevelFlag; - const isRegistered = - (userLevel & Flag.IsAdmin) === Flag.IsAdmin || - (userLevel & Flag.IsModerator) === Flag.IsModerator || - (userLevel & Flag.IsRegistered) === Flag.IsRegistered; - if (!isRegistered) { - return t('Player.level.unregistered'); - } - if (!accountageSecs || accountageSecs <= 0n) { - return t('Player.age.unknown'); - } - const totalDays = Number(accountageSecs / 86400n); - const years = Math.floor(totalDays / 365); - const days = totalDays - years * 365; - if (years > 0) { - return t('Player.age.daysWithYears', { years, days }); - } - return t('Player.age.days', { count: days }); -} - -const Player = () => { - const { t } = useTranslation(); - const { - name, - userInfo, - isSelf, - isABuddy, - isIgnored, - isModerator, - onAddBuddy, - onRemoveBuddy, - onAddIgnore, - onRemoveIgnore, - onSendMessage, - onWarnUser, - onBanFromServer, - } = usePlayer(); - - const avatar = useMemo(() => avatarSrc(userInfo?.avatarBmp), [userInfo?.avatarBmp]); - const countryCode = userInfo?.country?.toUpperCase() ?? ''; +function Player() { return ( -
    - - - {t('Player.title')} - - - {!userInfo && ( - {t('Player.action.notFound')} - )} - - {userInfo && ( - <> -
    - {avatar - ? {name - : - - {userInfo.name} - - {userLevelLabel(userInfo.userLevel, t)} - {userInfo.privlevel && userInfo.privlevel !== 'NONE' ? ` | ${userInfo.privlevel}` : ''} - - -
    - {t('Player.label.realName')} - {userInfo.realName || '—'} - - {t('Player.label.location')} - - {countryCode && ( - {countryCode} - )} - {countryCode || '—'} - - - {t('Player.label.userLevel')} - {userLevelLabel(userInfo.userLevel, t)} - - {t('Player.label.accountAge')} - {formatAccountAge(userInfo.accountageSecs, userInfo.userLevel, t)} -
    - - {!isSelf && ( -
    - - - - {isModerator && ( - <> - - - - )} -
    - )} - - )} - -
    + "Player" ); -}; +} export default Player; diff --git a/webclient/src/containers/Player/usePlayer.ts b/webclient/src/containers/Player/usePlayer.ts deleted file mode 100644 index 6a74467b8..000000000 --- a/webclient/src/containers/Player/usePlayer.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { useEffect, useMemo } from 'react'; -import { useParams } from 'react-router-dom'; - -import { useWebClient } from '@app/hooks'; -import { ServerSelectors, useAppSelector } from '@app/store'; -import type { Data } from '@app/types'; - -export interface PlayerViewModel { - /** Resolved username from the route; null when the `:name` param is missing. */ - name: string | null; - /** Profile record from server.userInfo[name]; undefined until the server response lands. */ - userInfo: Data.ServerInfo_User | undefined; - /** The logged-in user, for self-profile and mod-permission checks. */ - currentUser: Data.ServerInfo_User | null; - isSelf: boolean; - isABuddy: boolean; - isIgnored: boolean; - isModerator: boolean; - - onAddBuddy: () => void; - onRemoveBuddy: () => void; - onAddIgnore: () => void; - onRemoveIgnore: () => void; - onSendMessage: (message: string) => void; - onWarnUser: (reason: string) => void; - onBanFromServer: (minutes: number, reason: string, visibleReason?: string) => void; -} - -/** - * Drives the Player container: resolves the `:name` route param, dispatches - * `getUserInfo` on mount so the server populates `server.userInfo[name]`, and - * exposes the buddy/ignore/mod-action callbacks desktop surfaces in UserInfoBox. - */ -export function usePlayer(): PlayerViewModel { - const webClient = useWebClient(); - const params = useParams<{ name?: string }>(); - const name = params.name ?? null; - - const userInfo = useAppSelector((state) => - name ? ServerSelectors.getUserInfoByName(state, name) : undefined, - ); - const currentUser = useAppSelector(ServerSelectors.getUser); - const buddyList = useAppSelector(ServerSelectors.getBuddyList); - const ignoreList = useAppSelector(ServerSelectors.getIgnoreList); - const isModerator = useAppSelector(ServerSelectors.getIsUserModerator); - - useEffect(() => { - if (name) { - webClient.request.session.getUserInfo(name); - } - }, [name, webClient]); - - const { isSelf, isABuddy, isIgnored } = useMemo(() => ({ - isSelf: Boolean(currentUser && name && currentUser.name === name), - isABuddy: Boolean(name && buddyList[name]), - isIgnored: Boolean(name && ignoreList[name]), - }), [currentUser, name, buddyList, ignoreList]); - - const onAddBuddy = () => name && webClient.request.session.addToBuddyList(name); - const onRemoveBuddy = () => name && webClient.request.session.removeFromBuddyList(name); - const onAddIgnore = () => name && webClient.request.session.addToIgnoreList(name); - const onRemoveIgnore = () => name && webClient.request.session.removeFromIgnoreList(name); - const onSendMessage = (message: string) => name && webClient.request.session.sendDirectMessage(name, message); - const onWarnUser = (reason: string) => name && webClient.request.moderator.warnUser(name, reason); - const onBanFromServer = (minutes: number, reason: string, visibleReason?: string) => - name && webClient.request.moderator.banFromServer(minutes, name, undefined, reason, visibleReason); - - return { - name, - userInfo, - currentUser, - isSelf, - isABuddy, - isIgnored, - isModerator, - onAddBuddy, - onRemoveBuddy, - onAddIgnore, - onRemoveIgnore, - onSendMessage, - onWarnUser, - onBanFromServer, - }; -} diff --git a/webclient/src/containers/Room/GameSelector/GameSelector.css b/webclient/src/containers/Room/GameSelector/GameSelector.css deleted file mode 100644 index 2bc6c9edb..000000000 --- a/webclient/src/containers/Room/GameSelector/GameSelector.css +++ /dev/null @@ -1,33 +0,0 @@ -.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 deleted file mode 100644 index 097fffa13..000000000 --- a/webclient/src/containers/Room/GameSelector/GameSelector.spec.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import { act, 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 { GameTypes } from '@app/store'; -import GameSelector from './GameSelector'; - -const { mockUseWebClient, mockNavigate } = vi.hoisted(() => ({ - mockUseWebClient: vi.fn(), - mockNavigate: vi.fn(), -})); -vi.mock('@app/hooks', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, useWebClient: mockUseWebClient }; -}); -vi.mock('react-router-dom', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, useNavigate: () => mockNavigate }; -}); - -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, - roomsOverrides: Partial<{ joinGamePending: boolean; joinGameError: { code: number; message: string } | null }> = {}, -) { - 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: {}, - joinGamePending: false, - joinGameError: null, - ...roomsOverrides, - } as any, - server: { - ...(connectedWithRoomsState.server as any), - user, - } as any, - }); -} - -beforeEach(() => { - mockUseWebClient.mockReset(); - mockNavigate.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('renders AlertDialog with the join error message from state', () => { - mockUseWebClient.mockReturnValue(makeWebClient()); - const room = makeRoomEntry([]); - renderWithProviders(, { - preloadedState: buildState(room, makeUser(), undefined, { - joinGameError: { code: 10, message: 'The game is already full.' }, - }), - }); - expect(screen.getByText('Error')).toBeInTheDocument(); - expect(screen.getByText('The game is already full.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /^ok$/i })).toBeInTheDocument(); - }); - - it('does not render AlertDialog when joinGameError is null (covers silent RespContextError)', () => { - mockUseWebClient.mockReturnValue(makeWebClient()); - const room = makeRoomEntry([]); - renderWithProviders(, { - preloadedState: buildState(room, makeUser(), undefined, { joinGameError: null }), - }); - // Only the CreateGame / FilterGames / PromptDialog / AlertDialog dialogs might exist; none - // should be open, so no role="dialog" in the DOM. - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - it('clicking Join on a game already present in games.games navigates to /game without sending a command', () => { - const client = makeWebClient(); - mockUseWebClient.mockReturnValue(client); - const game = makeGame({ gameId: 7, withPassword: false }); - const room = makeRoomEntry([game]); - const state = buildState(room, makeUser(), 7); - (state as any).games = { games: { 7: { info: { gameId: 7 } } } }; - renderWithProviders(, { preloadedState: state }); - - fireEvent.click(screen.getByRole('button', { name: /^Join$/ })); - - expect(client.request.rooms.joinGame).not.toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith(App.RouteEnum.GAME); - }); - - it('dispatching GAME_JOINED navigates to /game (mirrors JOIN_ROOM → /room)', async () => { - mockUseWebClient.mockReturnValue(makeWebClient()); - const room = makeRoomEntry([]); - const { store } = renderWithProviders(, { - preloadedState: buildState(room), - }); - - mockNavigate.mockClear(); - await act(async () => { - store.dispatch({ - type: GameTypes.GAME_JOINED, - payload: { data: { gameInfo: { gameId: 42 }, hostId: 0, playerId: 0, spectator: false } }, - }); - }); - expect(mockNavigate).toHaveBeenCalledWith(App.RouteEnum.GAME); - }); - - it('Join button is disabled while joinGamePending is true even when a game is selected', () => { - mockUseWebClient.mockReturnValue(makeWebClient()); - const game = makeGame({ gameId: 7 }); - const room = makeRoomEntry([game]); - renderWithProviders(, { - preloadedState: buildState(room, makeUser(), 7, { joinGamePending: true }), - }); - expect(screen.getByRole('button', { name: /^Join$/ })).toBeDisabled(); - expect(screen.getByRole('button', { name: /Join as Spectator/i })).toBeDisabled(); - }); - - 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 deleted file mode 100644 index ec920fafc..000000000 --- a/webclient/src/containers/Room/GameSelector/GameSelector.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useCallback, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import Paper from '@mui/material/Paper'; -import Typography from '@mui/material/Typography'; - -import { - GameSelectors, - GameTypes, - RoomsDispatch, - RoomsSelectors, - ServerSelectors, - useAppSelector, - type GameFilters, -} from '@app/store'; -import { useReduxEffect, useWebClient } from '@app/hooks'; -import { App, type Enriched } from '@app/types'; -import { AlertDialog, CreateGameDialog, FilterGamesDialog, PromptDialog } from '@app/dialogs'; - -import OpenGames from '../OpenGames'; -import GameSelectorToolbar from './GameSelectorToolbar'; - -import './GameSelector.css'; - -interface GameSelectorProps { - room: Enriched.Room; -} - -interface PendingPasswordJoin { - gameId: number; - asSpectator: boolean; - asJudge: boolean; -} - -const GameSelector = ({ room }: GameSelectorProps) => { - const roomId = room.info.roomId; - const webClient = useWebClient(); - const navigate = useNavigate(); - - 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 joinPending = useAppSelector(RoomsSelectors.getJoinGamePending); - const joinError = useAppSelector(RoomsSelectors.getJoinGameError); - const activeGameIds = useAppSelector(GameSelectors.getActiveGameIds); - - // Mirrors Server.tsx's JOIN_ROOM → navigate(ROOM) pattern: when Event_GameJoined - // lands, we're actually in the game — route to /game. - useReduxEffect(() => { - navigate(App.RouteEnum.GAME); - }, GameTypes.GAME_JOINED, [navigate]); - - const [createOpen, setCreateOpen] = useState(false); - const [filterOpen, setFilterOpen] = useState(false); - const [pendingPasswordJoin, setPendingPasswordJoin] = useState(null); - - const sendJoin = useCallback( - (gameId: number, asSpectator: boolean, asJudge: boolean, password: string) => { - // Mirrors Rooms.tsx short-circuit: if we already have a live game entry - // (Event_GameJoined has populated games.games[gameId]), skip the duplicate - // JoinGame — the server would reject it with RespContextError — and go - // straight to the game view. - if (activeGameIds.includes(gameId)) { - navigate(App.RouteEnum.GAME); - return; - } - const params: App.JoinGameParams = { - gameId, - password, - spectator: asSpectator, - overrideRestrictions: false, - joinAsJudge: asJudge, - }; - webClient.request.rooms.joinGame(roomId, params); - }, - [activeGameIds, navigate, 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) { - setPendingPasswordJoin({ 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) && !joinPending; - const canSpectate = Boolean(selectedGame && selectedGame.info.spectatorsAllowed) && !joinPending; - - const handleCreateSubmit = (params: App.CreateGameParams) => { - webClient.request.rooms.createGame(roomId, params); - setCreateOpen(false); - }; - - const handleFilterSubmit = (next: GameFilters) => { - RoomsDispatch.setGameFilters(roomId, next); - setFilterOpen(false); - }; - - const handlePasswordSubmit = (password: string) => { - if (!pendingPasswordJoin) { - return; - } - sendJoin(pendingPasswordJoin.gameId, pendingPasswordJoin.asSpectator, pendingPasswordJoin.asJudge, password); - setPendingPasswordJoin(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} - /> - setPendingPasswordJoin(null)} - /> - RoomsDispatch.clearJoinGameError()} - /> -
    - ); -}; - -export default GameSelector; diff --git a/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.spec.tsx b/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.spec.tsx deleted file mode 100644 index e317dbc85..000000000 --- a/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.spec.tsx +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 7c844ce15..000000000 --- a/webclient/src/containers/Room/GameSelector/GameSelectorToolbar.tsx +++ /dev/null @@ -1,88 +0,0 @@ -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/Games.css b/webclient/src/containers/Room/Games.css new file mode 100644 index 000000000..623ab47f5 --- /dev/null +++ b/webclient/src/containers/Room/Games.css @@ -0,0 +1,30 @@ +.games { +} + +.games-header, +.game { + display: flex; + padding: 10px; + border-bottom: 1px solid black; +} + +.games-header__cell { + max-width: 200px; +} + +.games-header__label, +.game__detail { + width: 10%; + flex-grow: 0; +} + +.games-header__label.description, +.game__detail.description { + width: 20%; + flex-grow: 1; +} + +.games-header__label.creator, +.game__detail.creator { + width: 20%; +} diff --git a/webclient/src/containers/Room/Games.tsx b/webclient/src/containers/Room/Games.tsx new file mode 100644 index 000000000..578895633 --- /dev/null +++ b/webclient/src/containers/Room/Games.tsx @@ -0,0 +1,114 @@ +// eslint-disable-next-line +import React from "react"; + +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableHead from '@mui/material/TableHead'; +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 './Games.css'; + +// @TODO run interval to update timeSinceCreated +interface GamesProps { + room: any; +} + +const Games = ({ room }: GamesProps) => { + const roomId = room.info.roomId; + const sortBy = useAppSelector(state => RoomsSelectors.getSortGamesBy(state)); + const sortedGames = useAppSelector(state => RoomsSelectors.getSortedRoomGames(state, roomId)); + + const headerCells = [ + { label: 'Age', field: 'info.startTime' }, + { label: 'Description', field: 'info.description' }, + { label: 'Creator', field: 'info.creatorInfo.name' }, + { label: 'Type', field: 'gameType' }, + { label: 'Restrictions' }, + { label: 'Players' }, + { label: 'Spectators', field: 'info.spectatorsCount' }, + ]; + + const handleSort = (sortByField) => { + const { field, order } = SortUtil.toggleSortBy(sortByField, sortBy); + RoomsDispatch.sortGames(roomId, field, order); + }; + + const isUnavailableGame = ({ started, maxPlayers, playerCount }) => + !started && playerCount < maxPlayers; + + const isPasswordProtectedGame = ({ withPassword }) => !withPassword; + + const isBuddiesOnlyGame = ({ onlyBuddies }) => !onlyBuddies; + + const games = sortedGames.filter(game => ( + isUnavailableGame(game.info) && + isPasswordProtectedGame(game.info) && + isBuddiesOnlyGame(game.info) + )); + + return ( +
    + + + + { headerCells.map(({ label, field }) => { + const active = field === sortBy.field; + const order = sortBy.order.toLowerCase(); + const sortDirection = active ? (order === 'asc' ? 'asc' : 'desc') : false; + + return ( + + {!field ? label : ( + handleSort(field)} + > + {label} + + )} + + ); + })} + + + + { games.map((game) => { + const { info, gameType } = game; + const { description, gameId, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime } = info; + return ( + + {startTime} + + +
    + {description} +
    +
    +
    + + + + {gameType} + ? + {`${playerCount}/${maxPlayers}`} + {spectatorsCount} +
    + ); + })} +
    +
    +
    + ); +}; + +export default Games; diff --git a/webclient/src/containers/Room/Messages.tsx b/webclient/src/containers/Room/Messages.tsx index 764bb6aa7..88e727b53 100644 --- a/webclient/src/containers/Room/Messages.tsx +++ b/webclient/src/containers/Room/Messages.tsx @@ -1,17 +1,15 @@ +// eslint-disable-next-line +import React from "react"; + import { Message } from '@app/components'; -import type { Enriched } from '@app/types'; import './Messages.css'; -interface MessagesProps { - messages?: Enriched.Message[]; -} - -const Messages = ({ messages }: MessagesProps) => ( +const Messages = ({ messages }) => (
    { - messages && messages.map((message, idx) => ( -
    + messages && messages.map((message) => ( +
    )) diff --git a/webclient/src/containers/Room/OpenGames.css b/webclient/src/containers/Room/OpenGames.css index 6300906cd..623ab47f5 100644 --- a/webclient/src/containers/Room/OpenGames.css +++ b/webclient/src/containers/Room/OpenGames.css @@ -1,7 +1,8 @@ .games { } -.games-header { +.games-header, +.game { display: flex; padding: 10px; border-bottom: 1px solid black; @@ -27,7 +28,3 @@ .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 c82ae6a78..59e721aa1 100644 --- a/webclient/src/containers/Room/OpenGames.tsx +++ b/webclient/src/containers/Room/OpenGames.tsx @@ -1,3 +1,5 @@ +import React from 'react'; + import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; @@ -6,58 +8,23 @@ import TableRow from '@mui/material/TableRow'; import TableSortLabel from '@mui/material/TableSortLabel'; import Tooltip from '@mui/material/Tooltip'; -import { UserDisplay } from '@app/components'; -import { Data, Enriched } from '@app/types'; +// import { RoomsService } from "AppShell/common/services"; -import { useOpenGames } from './useOpenGames'; +import { SortUtil, RoomsDispatch, RoomsSelectors } from '@app/store'; +import { UserDisplay } from '@app/components'; +import { useAppSelector } from '@app/store'; import './OpenGames.css'; +// @TODO run interval to update timeSinceCreated interface OpenGamesProps { - room: Enriched.Room; - onActivateGame?: (gameId: number) => void; + room: any; } -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 OpenGames = ({ room }: OpenGamesProps) => { const roomId = room.info.roomId; - const { sortBy, games, selectedGameId, handleSort, handleSelect, handleActivate } = useOpenGames({ - roomId, - onActivateGame, - }); + const sortBy = useAppSelector(state => RoomsSelectors.getSortGamesBy(state)); + const sortedGames = useAppSelector(state => RoomsSelectors.getSortedRoomGames(state, roomId)); const headerCells = [ { label: 'Age', field: 'info.startTime' }, @@ -69,12 +36,30 @@ const OpenGames = ({ room, onActivateGame }: OpenGamesProps) => { { label: 'Spectators', field: 'info.spectatorsCount' }, ]; + const handleSort = (sortByField) => { + const { field, order } = SortUtil.toggleSortBy(sortByField, sortBy); + RoomsDispatch.sortGames(roomId, field, order); + }; + + const isAvailable = ({ started, maxPlayers, playerCount }) => + !started && playerCount < maxPlayers; + + const isOpen = ({ withPassword }) => !withPassword; + + const isPublic = ({ onlyBuddies }) => !onlyBuddies; + + const games = sortedGames.filter(game => ( + isAvailable(game.info) && + isOpen(game.info) && + isPublic(game.info) + )); + return (
    - {headerCells.map(({ label, field }) => { + { headerCells.map(({ label, field }) => { const active = field === sortBy.field; const order = sortBy.order.toLowerCase(); const sortDirection = active ? (order === 'asc' ? 'asc' : 'desc') : false; @@ -96,21 +81,11 @@ const OpenGames = ({ room, onActivateGame }: OpenGamesProps) => { - {games.map((game: Enriched.Game) => { + { games.map((game) => { const { info, gameType } = game; - const { description, gameId, creatorInfo, maxPlayers, playerCount, startTime } = info; - const isSelected = gameId === selectedGameId; - const restrictions = formatRestrictions(info); - const spectators = formatSpectators(info); + const { description, gameId, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime } = info; return ( - handleSelect(gameId)} - onDoubleClick={() => handleActivate(gameId)} - className={isSelected ? 'games__row games__row--selected' : 'games__row'} - > + {startTime} @@ -120,12 +95,12 @@ const OpenGames = ({ room, onActivateGame }: OpenGamesProps) => { - {creatorInfo ? : null} + {gameType} - {restrictions} + ? {`${playerCount}/${maxPlayers}`} - {spectators} + {spectatorsCount} ); })} diff --git a/webclient/src/containers/Room/Room.tsx b/webclient/src/containers/Room/Room.tsx index 92750d441..2da6b2871 100644 --- a/webclient/src/containers/Room/Room.tsx +++ b/webclient/src/containers/Room/Room.tsx @@ -1,23 +1,51 @@ +import React, { useEffect } from 'react'; +import { useNavigate, useParams, generatePath } from 'react-router-dom'; + import ListItemButton from '@mui/material/ListItemButton'; import Paper from '@mui/material/Paper'; import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, AuthGuard } from '@app/components'; +import { useWebClient } from '@app/hooks'; +import { RoomsSelectors } from '@app/store'; +import { useAppSelector } from '@app/store'; +import { App } from '@app/types'; import Layout from '../Layout/Layout'; -import GameSelector from './GameSelector/GameSelector'; +import OpenGames from './OpenGames'; import Messages from './Messages'; import SayMessage from './SayMessage'; -import { useRoom } from './useRoom'; import './Room.css'; const Room = () => { - const { room, roomMessages, users, handleRoomSay } = useRoom(); + const joined = useAppSelector(state => RoomsSelectors.getJoinedRooms(state)); + const rooms = useAppSelector(state => RoomsSelectors.getRooms(state)); + const messages = useAppSelector(state => RoomsSelectors.getMessages(state)); + const navigate = useNavigate(); + const params = useParams(); + + const roomId = parseInt(params.roomId, 10); + const room = rooms[roomId]; + const roomMessages = messages[roomId]; + const users = useAppSelector(state => RoomsSelectors.getSortedRoomUsers(state, roomId)); + const webClient = useWebClient(); + + useEffect(() => { + if (!joined.find(r => r.info.roomId === roomId)) { + navigate(generatePath(App.RouteEnum.SERVER)); + } + }, [joined]); if (!room) { return null; } + const handleRoomSay = ({ message }) => { + if (message) { + webClient.request.rooms.roomSay(roomId, message); + } + } + return ( @@ -27,9 +55,9 @@ const Room = () => { fixedHeight top={( -
    - -
    + + + )} bottom={( @@ -48,15 +76,15 @@ const Room = () => { side={(
    - Users in this room: {users.length} + Users in this room: {users.length}
    ( + items={ users.map(user => ( - ))} + )) } />
    )} @@ -64,6 +92,6 @@ const Room = () => {
    ); -}; +} export default Room; diff --git a/webclient/src/containers/Room/SayMessage.tsx b/webclient/src/containers/Room/SayMessage.tsx index 52f416e1b..589ebdacc 100644 --- a/webclient/src/containers/Room/SayMessage.tsx +++ b/webclient/src/containers/Room/SayMessage.tsx @@ -1,17 +1,14 @@ -import { Form } from 'react-final-form'; +import React from 'react'; +import { Form } from 'react-final-form' import { InputAction } from '@app/components'; -interface SayMessageProps { - onSubmit: (args: { message: string }) => void; -} - -const SayMessage = ({ onSubmit }: SayMessageProps) => ( +const SayMessage = ({ onSubmit }) => (
    {({ handleSubmit, form }) => ( { - handleSubmit(e); - form.restart(); + handleSubmit(e) + form.restart() }}> diff --git a/webclient/src/containers/Room/useOpenGames.ts b/webclient/src/containers/Room/useOpenGames.ts deleted file mode 100644 index 17f1136bc..000000000 --- a/webclient/src/containers/Room/useOpenGames.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { SortUtil, RoomsDispatch, RoomsSelectors, useAppSelector } from '@app/store'; -import { App, Enriched } from '@app/types'; - -export interface OpenGames { - sortBy: { field: string; order: string }; - games: Enriched.Game[]; - selectedGameId: number | undefined; - handleSort: (sortByField: string) => void; - handleSelect: (gameId: number) => void; - handleActivate: (gameId: number) => void; -} - -export interface UseOpenGamesArgs { - roomId: number; - onActivateGame?: (gameId: number) => void; -} - -export function useOpenGames({ roomId, onActivateGame }: UseOpenGamesArgs): OpenGames { - const sortBy = useAppSelector((state) => RoomsSelectors.getSortGamesBy(state)); - const games = useAppSelector((state) => RoomsSelectors.getFilteredRoomGames(state, roomId)); - const selectedGameId = useAppSelector((state) => RoomsSelectors.getSelectedGameId(state, roomId)); - - const handleSort = (sortByField: string) => { - const { field, order } = SortUtil.toggleSortBy(sortByField as App.GameSortField, sortBy); - RoomsDispatch.sortGames(roomId, field, order); - }; - - const handleSelect = (gameId: number) => { - RoomsDispatch.selectGame(roomId, gameId); - }; - - const handleActivate = (gameId: number) => { - RoomsDispatch.selectGame(roomId, gameId); - onActivateGame?.(gameId); - }; - - return { sortBy, games, selectedGameId, handleSort, handleSelect, handleActivate }; -} diff --git a/webclient/src/containers/Room/useRoom.ts b/webclient/src/containers/Room/useRoom.ts deleted file mode 100644 index c43a29246..000000000 --- a/webclient/src/containers/Room/useRoom.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import { useNavigate, useParams, generatePath } from 'react-router-dom'; - -import { useWebClient } from '@app/hooks'; -import { RoomsSelectors, useAppSelector } from '@app/store'; -import { App, Data, Enriched } from '@app/types'; - -export interface Room { - roomId: number; - room: Enriched.Room | undefined; - roomMessages: Enriched.Message[] | undefined; - users: Data.ServerInfo_User[]; - handleRoomSay: (args: { message: string }) => void; -} - -export function useRoom(): Room { - const joined = useAppSelector((state) => RoomsSelectors.getJoinedRooms(state)); - const rooms = useAppSelector((state) => RoomsSelectors.getRooms(state)); - const messages = useAppSelector((state) => RoomsSelectors.getMessages(state)); - const navigate = useNavigate(); - const params = useParams(); - - const parsed = params.roomId != null ? parseInt(params.roomId, 10) : NaN; - const roomId = Number.isNaN(parsed) ? -1 : parsed; - const room = roomId === -1 ? undefined : rooms[roomId]; - const roomMessages = roomId === -1 ? undefined : messages[roomId]; - const users = useAppSelector((state) => RoomsSelectors.getSortedRoomUsers(state, roomId)); - const webClient = useWebClient(); - - useEffect(() => { - if (roomId === -1 || !joined.find((r) => r.info.roomId === roomId)) { - navigate(generatePath(App.RouteEnum.SERVER)); - } - }, [joined, roomId, navigate]); - - const handleRoomSay = useCallback(({ message }: { message: string }) => { - if (message) { - webClient.request.rooms.roomSay(roomId, message); - } - }, [webClient, roomId]); - - return { roomId, room, roomMessages, users, handleRoomSay }; -} diff --git a/webclient/src/containers/Server/Rooms.css b/webclient/src/containers/Server/Rooms.css index 0cc990fca..bfcdc82cf 100644 --- a/webclient/src/containers/Server/Rooms.css +++ b/webclient/src/containers/Server/Rooms.css @@ -1,3 +1,6 @@ +.rooms { +} + .rooms-header, .room { display: flex; diff --git a/webclient/src/containers/Server/Rooms.tsx b/webclient/src/containers/Server/Rooms.tsx index 98915599c..1eecce729 100644 --- a/webclient/src/containers/Server/Rooms.tsx +++ b/webclient/src/containers/Server/Rooms.tsx @@ -1,4 +1,5 @@ -import { useMemo } from 'react'; +// eslint-disable-next-line +import React from "react"; import { generatePath, useNavigate } from 'react-router-dom'; import Button from '@mui/material/Button'; @@ -9,31 +10,21 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import { useWebClient } from '@app/hooks'; -import { App, Enriched } from '@app/types'; +import { App } from '@app/types'; import './Rooms.css'; -interface RoomsProps { - rooms: Record; - joinedRooms: Enriched.Room[]; -} - -const Rooms = ({ rooms, joinedRooms }: RoomsProps) => { +const Rooms = ({ rooms, joinedRooms }) => { const navigate = useNavigate(); const webClient = useWebClient(); - const joinedRoomIds = useMemo( - () => new Set(joinedRooms.map((room) => room.info.roomId)), - [joinedRooms], - ); - - const onClick = (roomId: number) => { - if (joinedRoomIds.has(roomId)) { - navigate(generatePath(App.RouteEnum.ROOM, { roomId: String(roomId) })); + function onClick(roomId) { + if (joinedRooms.find(room => room.info.roomId === roomId)) { + navigate(generatePath(App.RouteEnum.ROOM, { roomId })); } else { webClient.request.rooms.joinRoom(roomId); } - }; + } return (
    @@ -49,7 +40,7 @@ const Rooms = ({ rooms, joinedRooms }: RoomsProps) => { - {Object.values(rooms).map((room) => { + { Object.values(rooms).map((room) => { const { description, gameCount, name, permissionlevel, playerCount, roomId } = room.info; return ( diff --git a/webclient/src/containers/Server/Server.tsx b/webclient/src/containers/Server/Server.tsx index 3eba3aa83..1620138fc 100644 --- a/webclient/src/containers/Server/Server.tsx +++ b/webclient/src/containers/Server/Server.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import React from 'react'; import { generatePath, useNavigate } from 'react-router-dom'; import ListItemButton from '@mui/material/ListItemButton'; @@ -6,8 +6,9 @@ import Paper from '@mui/material/Paper'; import { AuthGuard, ThreePaneLayout, UserDisplay, VirtualList } from '@app/components'; import { useReduxEffect } from '@app/hooks'; -import { RoomsSelectors, RoomsTypes, ServerSelectors, useAppSelector } from '@app/store'; -import { App, Data } from '@app/types'; +import { RoomsSelectors, RoomsTypes, ServerSelectors } from '@app/store'; +import { App } from '@app/types'; +import { useAppSelector } from '@app/store'; import Rooms from './Rooms'; import Layout from '../Layout/Layout'; @@ -20,20 +21,11 @@ const Server = () => { const users = useAppSelector(state => ServerSelectors.getSortedUsers(state)); const navigate = useNavigate(); - useReduxEffect<{ roomInfo: Data.ServerInfo_Room }>((action) => { + useReduxEffect((action: any) => { const roomId = action.payload.roomInfo.roomId.toString(); navigate(generatePath(App.RouteEnum.ROOM, { roomId })); }, RoomsTypes.JOIN_ROOM, []); - const userItems = useMemo( - () => users.map((user) => ( - - - - )), - [users], - ); - return ( @@ -57,7 +49,13 @@ const Server = () => {
    Users connected to server: {users.length}
    - + ( + + + + )) } + /> )} /> diff --git a/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.css b/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.css index afa94e3f9..5175ab845 100644 --- a/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.css +++ b/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.css @@ -1,3 +1,13 @@ -.content { - margin-bottom: 20px; -} +.dialog-title { + display: flex; + justify-content: space-between; + align-items: center; +} + +.MuiDialogTitle-root.dialog-title { + padding-bottom: 0; +} + +.content { + margin-bottom: 20px; +} \ No newline at end of file diff --git a/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.tsx b/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.tsx index 62e49e094..476a399ec 100644 --- a/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.tsx +++ b/webclient/src/dialogs/AccountActivationDialog/AccountActivationDialog.tsx @@ -1,35 +1,43 @@ +import React from 'react'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import IconButton from '@mui/material/IconButton'; +import CloseIcon from '@mui/icons-material/Close'; import Typography from '@mui/material/Typography'; import { useTranslation } from 'react-i18next'; -import type { AccountActivationFormValues } from '@app/forms'; import { AccountActivationForm } from '@app/forms'; -import AuthDialogShell from '../AuthDialogShell/AuthDialogShell'; - import './AccountActivationDialog.css'; -interface AccountActivationDialogProps { - isOpen: boolean; - handleClose?: () => void; - onSubmit: (values: AccountActivationFormValues) => void; -} - -const AccountActivationDialog = ({ handleClose, isOpen, onSubmit }: AccountActivationDialogProps) => { +const AccountActivationDialog = ({ handleClose, isOpen, onSubmit }: any) => { const { t } = useTranslation(); - return ( - -
    - { t('AccountActivationDialog.subtitle1') } - { t('AccountActivationDialog.subtitle2') } -
    + const handleOnClose = () => { + handleClose(); + } - -
    + return ( + + + { t('AccountActivationDialog.title') } + + {handleOnClose ? ( + + + + ) : null} + + +
    + { t('AccountActivationDialog.subtitle1') } + { t('AccountActivationDialog.subtitle2') } +
    + + +
    +
    ); }; diff --git a/webclient/src/dialogs/AlertDialog/AlertDialog.css b/webclient/src/dialogs/AlertDialog/AlertDialog.css deleted file mode 100644 index 96f2b704e..000000000 --- a/webclient/src/dialogs/AlertDialog/AlertDialog.css +++ /dev/null @@ -1,3 +0,0 @@ -.alert-dialog__body { - min-width: 320px; -} diff --git a/webclient/src/dialogs/AlertDialog/AlertDialog.spec.tsx b/webclient/src/dialogs/AlertDialog/AlertDialog.spec.tsx deleted file mode 100644 index d5a98f05d..000000000 --- a/webclient/src/dialogs/AlertDialog/AlertDialog.spec.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { screen, fireEvent } from '@testing-library/react'; - -import { renderWithProviders } from '../../__test-utils__'; -import AlertDialog from './AlertDialog'; - -describe('AlertDialog', () => { - it('renders the title, message, and default OK button', () => { - renderWithProviders( - {}} - />, - ); - - expect(screen.getByText('Error')).toBeInTheDocument(); - expect(screen.getByText('The game is already full.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /^ok$/i })).toBeInTheDocument(); - }); - - it('uses a custom buttonLabel when provided', () => { - renderWithProviders( - {}} - />, - ); - expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument(); - }); - - it('fires onDismiss when the OK button is clicked', () => { - const onDismiss = vi.fn(); - renderWithProviders( - , - ); - - fireEvent.click(screen.getByRole('button', { name: /^ok$/i })); - expect(onDismiss).toHaveBeenCalled(); - }); - - it('fires onDismiss on Escape key', () => { - const onDismiss = vi.fn(); - renderWithProviders( - , - ); - - fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape', code: 'Escape' }); - expect(onDismiss).toHaveBeenCalled(); - }); - - it('does not render when closed', () => { - renderWithProviders( - {}} - />, - ); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); -}); diff --git a/webclient/src/dialogs/AlertDialog/AlertDialog.tsx b/webclient/src/dialogs/AlertDialog/AlertDialog.tsx deleted file mode 100644 index a0bdf0e0c..000000000 --- a/webclient/src/dialogs/AlertDialog/AlertDialog.tsx +++ /dev/null @@ -1,80 +0,0 @@ -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 './AlertDialog.css'; - -const PREFIX = 'AlertDialog'; - -const classes = { - root: `${PREFIX}-root`, -}; - -const StyledDialog = styled(Dialog)(({ theme }) => ({ - [`&.${classes.root}`]: { - '& .dialog-title__wrapper': { - borderColor: theme.palette.grey[300], - }, - }, -})); - -export type AlertDialogSeverity = 'error' | 'info'; - -export interface AlertDialogProps { - isOpen: boolean; - title: string; - message: string; - buttonLabel?: string; - severity?: AlertDialogSeverity; - onDismiss: () => void; -} - -/** - * Single-button modal alert. Mirrors desktop's QMessageBox::critical pattern - * (see cockatrice/src/interface/widgets/server/game_selector.cpp:234-260 for - * the join-game error dialogs this was originally built for). - */ -function AlertDialog({ - isOpen, - title, - message, - buttonLabel = 'OK', - severity = 'error', - onDismiss, -}: AlertDialogProps) { - return ( - - -
    - {title} -
    -
    - - {message} - - - - -
    - ); -} - -export default AlertDialog; diff --git a/webclient/src/dialogs/AuthDialogShell/AuthDialogShell.tsx b/webclient/src/dialogs/AuthDialogShell/AuthDialogShell.tsx deleted file mode 100644 index 44d2db4da..000000000 --- a/webclient/src/dialogs/AuthDialogShell/AuthDialogShell.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { ReactNode } from 'react'; -import Dialog, { DialogProps } from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import IconButton from '@mui/material/IconButton'; -import Typography from '@mui/material/Typography'; -import CloseIcon from '@mui/icons-material/Close'; - -import './AuthDialogShell.css'; - -export interface AuthDialogShellProps { - isOpen: boolean; - handleClose?: () => void; - title: string; - children: ReactNode; - className?: string; - contentClassName?: string; - maxWidth?: DialogProps['maxWidth']; -} - -const AuthDialogShell = ({ - isOpen, - handleClose, - title, - children, - className, - contentClassName, - maxWidth, -}: AuthDialogShellProps) => { - const closeGuarded = handleClose ? () => handleClose() : undefined; - - return ( - - - {title} - - {closeGuarded ? ( - - - - ) : null} - - - {children} - - - ); -}; - -export default AuthDialogShell; diff --git a/webclient/src/dialogs/CardImportDialog/CardImportDialog.tsx b/webclient/src/dialogs/CardImportDialog/CardImportDialog.tsx index e887f8169..a5fc1da90 100644 --- a/webclient/src/dialogs/CardImportDialog/CardImportDialog.tsx +++ b/webclient/src/dialogs/CardImportDialog/CardImportDialog.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; @@ -9,23 +10,24 @@ import { CardImportForm } from '@app/forms'; import './CardImportDialog.css'; -export interface CardImportDialogProps { - isOpen: boolean; - handleClose: () => void; -} +const CardImportDialog = ({ handleClose, isOpen }: any) => { + const handleOnClose = () => { + handleClose(); + } -const CardImportDialog = ({ handleClose, isOpen }: CardImportDialogProps) => { return ( - + Import Cards - - - + {handleOnClose ? ( + + + + ) : null} - + ); diff --git a/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.css b/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.css deleted file mode 100644 index a1de822cb..000000000 --- a/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.css +++ /dev/null @@ -1,3 +0,0 @@ -.confirm-dialog__body { - min-width: 320px; -} diff --git a/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.spec.tsx b/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.spec.tsx deleted file mode 100644 index 4b9587750..000000000 --- a/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.spec.tsx +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 0c3cc5735..000000000 --- a/webclient/src/dialogs/ConfirmDialog/ConfirmDialog.tsx +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 5f7286f46..000000000 --- a/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.css +++ /dev/null @@ -1,30 +0,0 @@ -.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 deleted file mode 100644 index 38e0e42ed..000000000 --- a/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.spec.tsx +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 09f0ffede..000000000 --- a/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.tsx +++ /dev/null @@ -1,131 +0,0 @@ -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 { useCreateCounterDialog } from './useCreateCounterDialog'; - -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, selectedIdx, error, handleNameChange, setSelectedIdx, handleSubmit } = - useCreateCounterDialog({ isOpen, swatches: SWATCHES, onSubmit }); - - return ( - - -
    - New counter -
    -
    -
    - - handleNameChange(e.target.value)} - error={error != null} - helperText={error ?? ''} - slotProps={{ htmlInput: { 'aria-label': 'Counter name' } }} - /> -
    { - if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedIdx((selectedIdx + 1) % SWATCHES.length); - } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedIdx((selectedIdx - 1 + SWATCHES.length) % SWATCHES.length); - } - }} - > - {SWATCHES.map((s, idx) => ( -
    -
    - - - - - -
    - ); -} - -export default CreateCounterDialog; diff --git a/webclient/src/dialogs/CreateCounterDialog/useCreateCounterDialog.ts b/webclient/src/dialogs/CreateCounterDialog/useCreateCounterDialog.ts deleted file mode 100644 index 3c783dd56..000000000 --- a/webclient/src/dialogs/CreateCounterDialog/useCreateCounterDialog.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useEffect, useState } from 'react'; - -import type { CounterColor } from './CreateCounterDialog'; - -export interface CreateCounterDialogState { - name: string; - selectedIdx: number; - error: string | null; - handleNameChange: (value: string) => void; - setSelectedIdx: (idx: number) => void; - handleSubmit: (e?: React.FormEvent) => void; -} - -export interface UseCreateCounterDialogArgs { - isOpen: boolean; - swatches: ReadonlyArray<{ color: CounterColor }>; - onSubmit: (args: { name: string; color: CounterColor }) => void; -} - -export function useCreateCounterDialog({ - isOpen, - swatches, - onSubmit, -}: UseCreateCounterDialogArgs): CreateCounterDialogState { - const [name, setName] = useState(''); - const [selectedIdx, setSelectedIdx] = useState(0); - const [error, setError] = useState(null); - - useEffect(() => { - if (isOpen) { - setName(''); - setSelectedIdx(0); - setError(null); - } - }, [isOpen]); - - const handleNameChange = (value: string) => { - setName(value); - if (error) { - setError(null); - } - }; - - 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 { name, selectedIdx, error, handleNameChange, setSelectedIdx, handleSubmit }; -} diff --git a/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.css b/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.css deleted file mode 100644 index f162ed0f3..000000000 --- a/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.css +++ /dev/null @@ -1,11 +0,0 @@ -.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 deleted file mode 100644 index 66e3099d7..000000000 --- a/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.spec.tsx +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 21920aa2c..000000000 --- a/webclient/src/dialogs/CreateGameDialog/CreateGameDialog.tsx +++ /dev/null @@ -1,281 +0,0 @@ -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 deleted file mode 100644 index 91e367cf9..000000000 --- a/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.css +++ /dev/null @@ -1,44 +0,0 @@ -.create-token-dialog__body { - display: flex; - gap: 24px; - min-width: 720px; - min-height: 420px; -} - -.create-token-dialog__chooser { - display: flex; - flex-direction: column; - gap: 8px; - width: 320px; - min-height: 0; -} - -.create-token-dialog__chooser-list { - flex: 1 1 auto; - min-height: 200px; - overflow-y: auto; - border: 1px solid var(--border-subtle, #ddd); - border-radius: 4px; -} - -.create-token-dialog__chooser-empty { - padding: 12px; - color: var(--text-muted, #666); - font-style: italic; -} - -.create-token-dialog__preview { - margin-top: 8px; - padding: 8px 12px; - border-radius: 4px; - background-color: var(--bg-subtle, #f5f5f5); - font-size: 0.9em; -} - -.create-token-dialog__form { - display: flex; - flex-direction: column; - gap: 12px; - flex: 1 1 auto; - min-width: 320px; -} diff --git a/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.spec.tsx b/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.spec.tsx deleted file mode 100644 index 6b36f7601..000000000 --- a/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.spec.tsx +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index a07be54aa..000000000 --- a/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.tsx +++ /dev/null @@ -1,254 +0,0 @@ -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 Radio from '@mui/material/Radio'; -import RadioGroup from '@mui/material/RadioGroup'; -import List from '@mui/material/List'; -import ListItemButton from '@mui/material/ListItemButton'; -import ListItemText from '@mui/material/ListItemText'; - -import { - MAX_ANNOTATION_LEN, - MAX_NAME_LEN, - MAX_PT_LEN, - useCreateTokenDialog, -} from './useCreateTokenDialog'; - -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; - providerId?: string; -} - -export interface CreateTokenDialogProps { - isOpen: boolean; - onSubmit: (args: CreateTokenSubmit) => void; - onCancel: () => void; - /** Optional deck-scoped predefined token names; enables the "Deck" radio in the chooser. */ - predefinedTokenNames?: string[]; -} - -// 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' }, -]; - -function CreateTokenDialog({ isOpen, onSubmit, onCancel, predefinedTokenNames }: CreateTokenDialogProps) { - const { - name, - color, - pt, - annotation, - destroyOnZoneChange, - faceDown, - error, - scope, - search, - filteredTokens, - selectedTokenName, - setScope, - setSearch, - selectPredefinedToken, - handleNameChange, - setColor, - setPT, - setAnnotation, - setDestroyOnZoneChange, - setFaceDown, - handleSubmit, - } = useCreateTokenDialog({ isOpen, onSubmit, predefinedTokenNames }); - - const hasDeckScope = Boolean(predefinedTokenNames?.length); - - return ( - - -
    - Create token -
    -
    -
    - -
    - setScope(e.target.value as 'all' | 'deck')} - aria-label="Token source" - > - } label="All Tokens" /> - } - label="Deck Tokens" - disabled={!hasDeckScope} - /> - - setSearch(e.target.value)} - slotProps={{ htmlInput: { 'aria-label': 'Search tokens' } }} - /> -
    - {filteredTokens.length === 0 ? ( -
    - No predefined tokens available. -
    - ) : ( - - {filteredTokens.map((token) => { - const tokenName = token.name?.value ?? ''; - return ( - selectPredefinedToken(token)} - > - - - ); - })} - - )} -
    - {selectedTokenName && ( -
    - {selectedTokenName} - {pt ? ` — ${pt}` : ''} -
    - )} -
    - -
    - handleNameChange(e.target.value)} - error={error != null} - helperText={error ?? ''} - disabled={faceDown} - 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/CreateTokenDialog/useCreateTokenDialog.ts b/webclient/src/dialogs/CreateTokenDialog/useCreateTokenDialog.ts deleted file mode 100644 index 4f5253f6e..000000000 --- a/webclient/src/dialogs/CreateTokenDialog/useCreateTokenDialog.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; - -import { TokenDTO } from '@app/services'; - -import type { CreateTokenSubmit } from './CreateTokenDialog'; - -export type ChooserScope = 'all' | 'deck'; - -export interface CreateTokenDialogState { - name: string; - color: string; - pt: string; - annotation: string; - destroyOnZoneChange: boolean; - faceDown: boolean; - error: string | null; - - scope: ChooserScope; - search: string; - availableTokens: TokenDTO[]; - filteredTokens: TokenDTO[]; - selectedTokenName: string | null; - - setScope: (value: ChooserScope) => void; - setSearch: (value: string) => void; - selectPredefinedToken: (token: TokenDTO) => void; - - handleNameChange: (value: string) => void; - setColor: (value: string) => void; - setPT: (value: string) => void; - setAnnotation: (value: string) => void; - setDestroyOnZoneChange: (value: boolean) => void; - setFaceDown: (value: boolean) => void; - handleSubmit: (e?: React.FormEvent) => void; -} - -export const CREATE_TOKEN_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. -export const MAX_NAME_LEN = 255; -export const MAX_PT_LEN = 255; -export const MAX_ANNOTATION_LEN = 255; - -export interface UseCreateTokenDialogArgs { - isOpen: boolean; - onSubmit: (args: CreateTokenSubmit) => void; - /** Optional deck-scoped token names; mirrors desktop DlgCreateToken predefinedTokens. */ - predefinedTokenNames?: string[]; -} - -/** Maps a MTGJSON-shaped color list ("W", "U", ...) to the dialog's single-letter color value. */ -function colorFromToken(token: TokenDTO): string { - const raw = token.prop?.value?.colors?.value ?? ''; - if (!raw) { - return ''; - } - const colors = raw.split(/[\s,]+/).filter(Boolean).map((c: string) => c.toLowerCase()); - if (colors.length === 0) { - return ''; - } - if (colors.length > 1) { - return 'm'; - } - const first = colors[0]; - if (first === 'w' || first === 'u' || first === 'b' || first === 'r' || first === 'g') { - return first; - } - return ''; -} - -/** Best-effort providerId from the token's first set entry; matches desktop TokenInfo.providerId. */ -function providerIdFromToken(token: TokenDTO): string | undefined { - return token.set?.[0]?.value ?? undefined; -} - -export function useCreateTokenDialog({ - isOpen, - onSubmit, - predefinedTokenNames, -}: UseCreateTokenDialogArgs): CreateTokenDialogState { - const [name, setName] = useState(''); - const [color, setColor] = useState(CREATE_TOKEN_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); - - const [scope, setScope] = useState(predefinedTokenNames?.length ? 'deck' : 'all'); - const [search, setSearch] = useState(''); - const [availableTokens, setAvailableTokens] = useState([]); - const [selectedTokenName, setSelectedTokenName] = useState(null); - const [providerId, setProviderId] = useState(undefined); - - useEffect(() => { - if (isOpen) { - setName(''); - setColor(CREATE_TOKEN_DEFAULT_COLOR); - setPT(''); - setAnnotation(''); - setDestroyOnZoneChange(true); - setFaceDown(false); - setError(null); - setSearch(''); - setSelectedTokenName(null); - setProviderId(undefined); - setScope(predefinedTokenNames?.length ? 'deck' : 'all'); - } - }, [isOpen, predefinedTokenNames]); - - useEffect(() => { - if (!isOpen) { - return; - } - let cancelled = false; - // Best-effort load of the token library. On failure the chooser renders - // empty and freeform creation still works. - import('@app/services').then(({ dexieService }) => { - dexieService.tokens.toArray().then((tokens: TokenDTO[]) => { - if (!cancelled) { - setAvailableTokens(tokens); - } - }).catch(() => { - if (!cancelled) { - setAvailableTokens([]); - } - }); - }); - return () => { - cancelled = true; - }; - }, [isOpen]); - - const filteredTokens = useMemo(() => { - const allowByScope = scope === 'deck' && predefinedTokenNames?.length - ? new Set(predefinedTokenNames.map((n) => n.toLowerCase())) - : null; - const needle = search.trim().toLowerCase(); - return availableTokens.filter((token) => { - const tokenName = token.name?.value ?? ''; - if (allowByScope && !allowByScope.has(tokenName.toLowerCase())) { - return false; - } - if (needle && !tokenName.toLowerCase().includes(needle)) { - return false; - } - return true; - }); - }, [availableTokens, scope, search, predefinedTokenNames]); - - const handleNameChange = (value: string) => { - setName(value.slice(0, MAX_NAME_LEN)); - if (error) { - setError(null); - } - }; - - const selectPredefinedToken = (token: TokenDTO) => { - const tokenName = token.name?.value ?? ''; - setSelectedTokenName(tokenName); - setName(tokenName.slice(0, MAX_NAME_LEN)); - setColor(colorFromToken(token)); - const ptRaw = token.prop?.value?.pt?.value ?? ''; - setPT(ptRaw.slice(0, MAX_PT_LEN)); - setProviderId(providerIdFromToken(token)); - if (error) { - setError(null); - } - }; - - const handleSubmit = (e?: React.FormEvent) => { - e?.preventDefault(); - if (name.trim().length === 0) { - setError('Name is required'); - return; - } - const payload: CreateTokenSubmit = { - name: name.trim(), - color, - pt: pt.trim(), - annotation: annotation.trim(), - destroyOnZoneChange, - faceDown, - }; - if (providerId) { - payload.providerId = providerId; - } - onSubmit(payload); - }; - - return { - name, - color, - pt, - annotation, - destroyOnZoneChange, - faceDown, - error, - scope, - search, - availableTokens, - filteredTokens, - selectedTokenName, - setScope, - setSearch, - selectPredefinedToken, - handleNameChange, - setColor, - setPT, - setAnnotation, - setDestroyOnZoneChange, - setFaceDown, - handleSubmit, - }; -} diff --git a/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.css b/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.css deleted file mode 100644 index 9d14008be..000000000 --- a/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.css +++ /dev/null @@ -1,46 +0,0 @@ -.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 deleted file mode 100644 index 12f061034..000000000 --- a/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.spec.tsx +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index aa4755fcb..000000000 --- a/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.tsx +++ /dev/null @@ -1,105 +0,0 @@ -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 { useDeckSelectDialog } from './useDeckSelectDialog'; - -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 { - deckText, - setDeckText, - deckHash, - isReady, - canSubmit, - canToggleReady, - handleSubmitDeck, - handleToggleReady, - } = useDeckSelectDialog(gameId); - - return ( - - -
    - Select Deck -
    -
    - - - Paste your deck list below, then click Submit Deck. After the server - accepts the deck, the Ready button unlocks. - - -