diff --git a/webclient/integration/src/helpers/setup.ts b/webclient/integration/src/helpers/setup.ts index 4c58dbcca..8e4413d98 100644 --- a/webclient/integration/src/helpers/setup.ts +++ b/webclient/integration/src/helpers/setup.ts @@ -23,7 +23,7 @@ import { } from '@app/websocket'; import type { WebSocketConnectOptions } from '@app/websocket'; import { PROTOCOL_VERSION } from '../../../src/websocket/config'; -import { initWebClient } from '@app/api'; +import { createWebClientRequest, createWebClientResponse } from '@app/api'; import { buildResponse, @@ -196,7 +196,7 @@ installMockWebSocket(); beforeEach(() => { vi.useFakeTimers(); - initWebClient(); + new WebClient(createWebClientRequest(), createWebClientResponse()); }); afterEach(() => { diff --git a/webclient/src/store/game/game.reducer.spec.ts b/webclient/src/store/game/game.reducer.spec.ts index ccf5c1fd7..3d4b8abb8 100644 --- a/webclient/src/store/game/game.reducer.spec.ts +++ b/webclient/src/store/game/game.reducer.spec.ts @@ -121,12 +121,12 @@ describe('2B: Game state & player management', () => { const state = makeState(); const result = gamesReducer(state, Actions.gameStateChanged({ gameId: 1, - data: { + data: create(Data.Event_GameStateChangedSchema, { gameStarted: true, activePlayerId: 3, activePhase: 2, secondsElapsed: 60, - }, + }), })); expect(result.games[1].started).toBe(true); @@ -394,7 +394,7 @@ describe('2C: CARD_MOVED', () => { expect(moved.providerId).toBe('new-prov'); }); - it('CARD_MOVED → returns newState (card removed from source) when targetZone does not exist on player', () => { + it('CARD_MOVED → no-ops when targetZone does not exist on player', () => { const { state } = stateWithCard(); const result = gamesReducer(state, Actions.cardMoved({ gameId: 1, @@ -414,7 +414,7 @@ describe('2C: CARD_MOVED', () => { newCardProviderId: '', }, })); - expect(cardsIn(result, 1, 1, 'hand')).toHaveLength(0); + expect(cardsIn(result, 1, 1, 'hand')).toHaveLength(1); expect(result.games[1].players[1].zones['nonexistent']).toBeUndefined(); }); }); @@ -850,7 +850,9 @@ describe('2I: Zone operations', () => { const result = gamesReducer(state, Actions.zonePropertiesChanged({ gameId: 1, playerId: 1, - data: { zoneName: 'hand', alwaysRevealTopCard: true, alwaysLookAtTopCard: true }, + data: create(Data.Event_ChangeZonePropertiesSchema, { + zoneName: 'hand', alwaysRevealTopCard: true, alwaysLookAtTopCard: true, + }), })); const zone = result.games[1].players[1].zones['hand']; diff --git a/webclient/src/store/game/game.reducer.ts b/webclient/src/store/game/game.reducer.ts index 3e854b48a..eff362c32 100644 --- a/webclient/src/store/game/game.reducer.ts +++ b/webclient/src/store/game/game.reducer.ts @@ -1,6 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { Data, Enriched } from '@app/types'; -import { create } from '@bufbuild/protobuf'; +import { create, isFieldSet } from '@bufbuild/protobuf'; import { GamesState } from './game.interfaces'; export const MAX_GAME_MESSAGES = 1000; @@ -129,16 +129,16 @@ export const gamesSlice = createSlice({ if (data.playerList?.length > 0) { game.players = normalizePlayers(data.playerList); } - if (data.gameStarted !== undefined && data.gameStarted !== null) { + if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.gameStarted)) { game.started = data.gameStarted; } - if (data.activePlayerId !== undefined && data.activePlayerId !== null) { + if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.activePlayerId)) { game.activePlayerId = data.activePlayerId; } - if (data.activePhase !== undefined && data.activePhase !== null) { + if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.activePhase)) { game.activePhase = data.activePhase; } - if (data.secondsElapsed !== undefined) { + if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.secondsElapsed)) { game.secondsElapsed = data.secondsElapsed; } }, @@ -201,6 +201,12 @@ export const gamesSlice = createSlice({ return; } + const targetPlayer = game.players[targetPlayerId]; + const targetZoneEntry = targetPlayer?.zones[targetZone]; + if (!targetPlayer || !targetZoneEntry) { + return; + } + let resolvedCardId = -1; if (cardId >= 0) { resolvedCardId = cardId; @@ -228,12 +234,6 @@ export const gamesSlice = createSlice({ } : buildEmptyCard(effectiveNewId, cardName, x, y, faceDown, newCardProviderId ?? ''); - const targetPlayer = game.players[targetPlayerId]; - const targetZoneEntry = targetPlayer?.zones[targetZone]; - if (!targetPlayer || !targetZoneEntry) { - return; - } - targetZoneEntry.order.push(movedCard.id); targetZoneEntry.byId[movedCard.id] = movedCard; targetZoneEntry.cardCount++; @@ -432,10 +432,10 @@ export const gamesSlice = createSlice({ if (!zone) { return; } - if (data.alwaysRevealTopCard !== undefined && data.alwaysRevealTopCard !== null) { + if (isFieldSet(data, Data.Event_ChangeZonePropertiesSchema.field.alwaysRevealTopCard)) { zone.alwaysRevealTopCard = data.alwaysRevealTopCard; } - if (data.alwaysLookAtTopCard !== undefined && data.alwaysLookAtTopCard !== null) { + if (isFieldSet(data, Data.Event_ChangeZonePropertiesSchema.field.alwaysLookAtTopCard)) { zone.alwaysLookAtTopCard = data.alwaysLookAtTopCard; } }, diff --git a/webclient/src/store/rooms/rooms.reducer.spec.ts b/webclient/src/store/rooms/rooms.reducer.spec.ts index 6bf8de4ca..ea09fefc7 100644 --- a/webclient/src/store/rooms/rooms.reducer.spec.ts +++ b/webclient/src/store/rooms/rooms.reducer.spec.ts @@ -114,12 +114,12 @@ describe('LEAVE_ROOM', () => { // ── ADD_MESSAGE ─────────────────────────────────────────────────────────────── describe('ADD_MESSAGE', () => { - it('appends message with timeReceived set', () => { + it('appends message preserving the timeReceived from the event handler', () => { const state = makeRoomsState({ messages: { 1: [] } }); - const message = makeMessage({ message: 'hello', timeReceived: 0 }); + const message = makeMessage({ message: 'hello', timeReceived: 1700000000000 }); const result = roomsReducer(state, Actions.addMessage({ roomId: 1, message })); expect(result.messages[1]).toHaveLength(1); - expect(result.messages[1][0].timeReceived).toBeGreaterThan(0); + expect(result.messages[1][0].timeReceived).toBe(1700000000000); }); it('creates message list for roomId when none exists', () => { diff --git a/webclient/src/store/rooms/rooms.reducer.tsx b/webclient/src/store/rooms/rooms.reducer.tsx index 3335dabc9..efd2c916d 100644 --- a/webclient/src/store/rooms/rooms.reducer.tsx +++ b/webclient/src/store/rooms/rooms.reducer.tsx @@ -69,14 +69,21 @@ export const roomsSlice = createSlice({ const { roomId } = action.payload; delete state.joinedRoomIds[roomId]; + delete state.joinedGameIds[roomId]; delete state.messages[roomId]; + + const room = state.rooms[roomId]; + if (room) { + room.games = {}; + room.users = {}; + } }, addMessage: (state, action: PayloadAction<{ roomId: number; message: Enriched.Message }>) => { const { roomId, message } = action.payload; const existing = state.messages[roomId] ?? []; - const normalized = normalizeUserMessage({ ...message, timeReceived: Date.now() }); + const normalized = normalizeUserMessage(message); const next = existing.length >= MAX_ROOM_MESSAGES ? [...existing.slice(existing.length - MAX_ROOM_MESSAGES + 1), normalized] diff --git a/webclient/src/store/rooms/rooms.selectors.tsx b/webclient/src/store/rooms/rooms.selectors.tsx index 82bb44b85..47e4b7d27 100644 --- a/webclient/src/store/rooms/rooms.selectors.tsx +++ b/webclient/src/store/rooms/rooms.selectors.tsx @@ -31,14 +31,18 @@ export const Selectors = { * Reads from the room's normalized `games` map — fixes the pre-existing * bug where this selector read from a never-populated top-level `games` field. */ - getJoinedGames: (state: State, roomId: number): Enriched.Game[] => { - const room = state.rooms.rooms[roomId]; - const joined = state.rooms.joinedGameIds[roomId]; - if (!room || !joined) { - return EMPTY_GAMES; + getJoinedGames: createSelector( + [ + (state: State, roomId: number) => state.rooms.rooms[roomId]?.games, + (state: State, roomId: number) => state.rooms.joinedGameIds[roomId], + ], + (games, joined): Enriched.Game[] => { + if (!games || !joined) { + return EMPTY_GAMES; + } + return Object.values(games).filter(game => joined[game.info.gameId]); } - return Object.values(room.games).filter(game => joined[game.info.gameId]); - }, + ), getRoomMessages: (state: State, roomId: number) => state.rooms.messages[roomId], diff --git a/webclient/src/store/server/server.interfaces.ts b/webclient/src/store/server/server.interfaces.ts index 4f3e05ca9..2a4e1ca5a 100644 --- a/webclient/src/store/server/server.interfaces.ts +++ b/webclient/src/store/server/server.interfaces.ts @@ -36,7 +36,7 @@ export interface ServerState { backendDecks: Data.Response_DeckList | null; downloadedDeck: { deckId: number; deck: string } | null; downloadedReplay: { replayId: number; replayData: Uint8Array } | null; - gamesOfUser: { [userName: string]: Enriched.Game[] }; + gamesOfUser: { [userName: string]: { [gameId: number]: Enriched.Game } }; registrationError: string | null; } diff --git a/webclient/src/store/server/server.reducer.spec.ts b/webclient/src/store/server/server.reducer.spec.ts index 33bfc42ff..99ac79e97 100644 --- a/webclient/src/store/server/server.reducer.spec.ts +++ b/webclient/src/store/server/server.reducer.spec.ts @@ -648,29 +648,29 @@ describe('Deck Storage', () => { // ── GAMES_OF_USER ───────────────────────────────────────────────────────────── describe('GAMES_OF_USER', () => { - it('stores normalized games keyed by userName', () => { + it('stores normalized games keyed by userName and gameId', () => { const response = create(Data.Response_GetGamesOfUserSchema, { gameList: [create(Data.ServerInfo_GameSchema, { gameId: 5, description: '' })], roomList: [], }); const state = makeServerState(); const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response })); - expect(result.gamesOfUser['alice']).toEqual([makeGame({ gameId: 5 })]); + expect(result.gamesOfUser['alice']).toEqual({ 5: makeGame({ gameId: 5 }) }); }); it('overwrites previous games for same user', () => { - const old = [makeGame({ gameId: 1 })]; + const old = { 1: makeGame({ gameId: 1 }) }; const response = create(Data.Response_GetGamesOfUserSchema, { gameList: [create(Data.ServerInfo_GameSchema, { gameId: 2, description: '' })], roomList: [], }); const state = makeServerState({ gamesOfUser: { alice: old } }); const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response })); - expect(result.gamesOfUser['alice']).toEqual([makeGame({ gameId: 2 })]); + expect(result.gamesOfUser['alice']).toEqual({ 2: makeGame({ gameId: 2 }) }); }); it('does not affect other users\' entries', () => { - const bobGames = [makeGame({ gameId: 3 })]; + const bobGames = { 3: makeGame({ gameId: 3 }) }; const response = create(Data.Response_GetGamesOfUserSchema, { gameList: [], roomList: [] }); const state = makeServerState({ gamesOfUser: { bob: bobGames } }); const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response })); diff --git a/webclient/src/store/server/server.reducer.ts b/webclient/src/store/server/server.reducer.ts index 3fa13ce71..a7e7682fa 100644 --- a/webclient/src/store/server/server.reducer.ts +++ b/webclient/src/store/server/server.reducer.ts @@ -1,5 +1,5 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { App, Data } from '@app/types'; +import { App, Data, Enriched } from '@app/types'; import { create } from '@bufbuild/protobuf'; import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, normalizeLogs } from '../common'; @@ -179,8 +179,10 @@ export const serverSlice = createSlice({ } }, - updateUser: (state, action: PayloadAction<{ user: Data.ServerInfo_User | Partial }>) => { - state.user = { ...state.user, ...action.payload.user } as Data.ServerInfo_User; + updateUser: (state, action: PayloadAction<{ user: Partial }>) => { + state.user = state.user + ? { ...state.user, ...action.payload.user } as Data.ServerInfo_User + : action.payload.user as Data.ServerInfo_User; }, updateUsers: (state, action: PayloadAction<{ users: Data.ServerInfo_User[] }>) => { @@ -356,8 +358,12 @@ export const serverSlice = createSlice({ const gametypeMap = normalizeGametypeMap( (response.roomList ?? []).flatMap(room => room.gametypeList ?? []) ); - const normalizedGames = (response.gameList ?? []).map(g => normalizeGameObject(g, gametypeMap)); - state.gamesOfUser[userName] = normalizedGames; + const games: { [gameId: number]: Enriched.Game } = {}; + for (const g of response.gameList ?? []) { + const normalized = normalizeGameObject(g, gametypeMap); + games[normalized.info.gameId] = normalized; + } + state.gamesOfUser[userName] = games; }, registrationFailed: (state, action: PayloadAction<{ reason: string; endTime?: number }>) => {