mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-07-01 11:03:54 -07:00
clean up data structures
This commit is contained in:
parent
53639a8448
commit
d04aa83258
9 changed files with 61 additions and 42 deletions
|
|
@ -23,7 +23,7 @@ import {
|
||||||
} from '@app/websocket';
|
} from '@app/websocket';
|
||||||
import type { WebSocketConnectOptions } from '@app/websocket';
|
import type { WebSocketConnectOptions } from '@app/websocket';
|
||||||
import { PROTOCOL_VERSION } from '../../../src/websocket/config';
|
import { PROTOCOL_VERSION } from '../../../src/websocket/config';
|
||||||
import { initWebClient } from '@app/api';
|
import { createWebClientRequest, createWebClientResponse } from '@app/api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildResponse,
|
buildResponse,
|
||||||
|
|
@ -196,7 +196,7 @@ installMockWebSocket();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
initWebClient();
|
new WebClient(createWebClientRequest(), createWebClientResponse());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
|
||||||
|
|
@ -121,12 +121,12 @@ describe('2B: Game state & player management', () => {
|
||||||
const state = makeState();
|
const state = makeState();
|
||||||
const result = gamesReducer(state, Actions.gameStateChanged({
|
const result = gamesReducer(state, Actions.gameStateChanged({
|
||||||
gameId: 1,
|
gameId: 1,
|
||||||
data: {
|
data: create(Data.Event_GameStateChangedSchema, {
|
||||||
gameStarted: true,
|
gameStarted: true,
|
||||||
activePlayerId: 3,
|
activePlayerId: 3,
|
||||||
activePhase: 2,
|
activePhase: 2,
|
||||||
secondsElapsed: 60,
|
secondsElapsed: 60,
|
||||||
},
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(result.games[1].started).toBe(true);
|
expect(result.games[1].started).toBe(true);
|
||||||
|
|
@ -394,7 +394,7 @@ describe('2C: CARD_MOVED', () => {
|
||||||
expect(moved.providerId).toBe('new-prov');
|
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 { state } = stateWithCard();
|
||||||
const result = gamesReducer(state, Actions.cardMoved({
|
const result = gamesReducer(state, Actions.cardMoved({
|
||||||
gameId: 1,
|
gameId: 1,
|
||||||
|
|
@ -414,7 +414,7 @@ describe('2C: CARD_MOVED', () => {
|
||||||
newCardProviderId: '',
|
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();
|
expect(result.games[1].players[1].zones['nonexistent']).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -850,7 +850,9 @@ describe('2I: Zone operations', () => {
|
||||||
const result = gamesReducer(state, Actions.zonePropertiesChanged({
|
const result = gamesReducer(state, Actions.zonePropertiesChanged({
|
||||||
gameId: 1,
|
gameId: 1,
|
||||||
playerId: 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'];
|
const zone = result.games[1].players[1].zones['hand'];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { Data, Enriched } from '@app/types';
|
import { Data, Enriched } from '@app/types';
|
||||||
import { create } from '@bufbuild/protobuf';
|
import { create, isFieldSet } from '@bufbuild/protobuf';
|
||||||
import { GamesState } from './game.interfaces';
|
import { GamesState } from './game.interfaces';
|
||||||
|
|
||||||
export const MAX_GAME_MESSAGES = 1000;
|
export const MAX_GAME_MESSAGES = 1000;
|
||||||
|
|
@ -129,16 +129,16 @@ export const gamesSlice = createSlice({
|
||||||
if (data.playerList?.length > 0) {
|
if (data.playerList?.length > 0) {
|
||||||
game.players = normalizePlayers(data.playerList);
|
game.players = normalizePlayers(data.playerList);
|
||||||
}
|
}
|
||||||
if (data.gameStarted !== undefined && data.gameStarted !== null) {
|
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.gameStarted)) {
|
||||||
game.started = data.gameStarted;
|
game.started = data.gameStarted;
|
||||||
}
|
}
|
||||||
if (data.activePlayerId !== undefined && data.activePlayerId !== null) {
|
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.activePlayerId)) {
|
||||||
game.activePlayerId = data.activePlayerId;
|
game.activePlayerId = data.activePlayerId;
|
||||||
}
|
}
|
||||||
if (data.activePhase !== undefined && data.activePhase !== null) {
|
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.activePhase)) {
|
||||||
game.activePhase = data.activePhase;
|
game.activePhase = data.activePhase;
|
||||||
}
|
}
|
||||||
if (data.secondsElapsed !== undefined) {
|
if (isFieldSet(data, Data.Event_GameStateChangedSchema.field.secondsElapsed)) {
|
||||||
game.secondsElapsed = data.secondsElapsed;
|
game.secondsElapsed = data.secondsElapsed;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -201,6 +201,12 @@ export const gamesSlice = createSlice({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetPlayer = game.players[targetPlayerId];
|
||||||
|
const targetZoneEntry = targetPlayer?.zones[targetZone];
|
||||||
|
if (!targetPlayer || !targetZoneEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let resolvedCardId = -1;
|
let resolvedCardId = -1;
|
||||||
if (cardId >= 0) {
|
if (cardId >= 0) {
|
||||||
resolvedCardId = cardId;
|
resolvedCardId = cardId;
|
||||||
|
|
@ -228,12 +234,6 @@ export const gamesSlice = createSlice({
|
||||||
}
|
}
|
||||||
: buildEmptyCard(effectiveNewId, cardName, x, y, faceDown, newCardProviderId ?? '');
|
: 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.order.push(movedCard.id);
|
||||||
targetZoneEntry.byId[movedCard.id] = movedCard;
|
targetZoneEntry.byId[movedCard.id] = movedCard;
|
||||||
targetZoneEntry.cardCount++;
|
targetZoneEntry.cardCount++;
|
||||||
|
|
@ -432,10 +432,10 @@ export const gamesSlice = createSlice({
|
||||||
if (!zone) {
|
if (!zone) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.alwaysRevealTopCard !== undefined && data.alwaysRevealTopCard !== null) {
|
if (isFieldSet(data, Data.Event_ChangeZonePropertiesSchema.field.alwaysRevealTopCard)) {
|
||||||
zone.alwaysRevealTopCard = data.alwaysRevealTopCard;
|
zone.alwaysRevealTopCard = data.alwaysRevealTopCard;
|
||||||
}
|
}
|
||||||
if (data.alwaysLookAtTopCard !== undefined && data.alwaysLookAtTopCard !== null) {
|
if (isFieldSet(data, Data.Event_ChangeZonePropertiesSchema.field.alwaysLookAtTopCard)) {
|
||||||
zone.alwaysLookAtTopCard = data.alwaysLookAtTopCard;
|
zone.alwaysLookAtTopCard = data.alwaysLookAtTopCard;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -114,12 +114,12 @@ describe('LEAVE_ROOM', () => {
|
||||||
// ── ADD_MESSAGE ───────────────────────────────────────────────────────────────
|
// ── ADD_MESSAGE ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('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 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 }));
|
const result = roomsReducer(state, Actions.addMessage({ roomId: 1, message }));
|
||||||
expect(result.messages[1]).toHaveLength(1);
|
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', () => {
|
it('creates message list for roomId when none exists', () => {
|
||||||
|
|
|
||||||
|
|
@ -69,14 +69,21 @@ export const roomsSlice = createSlice({
|
||||||
const { roomId } = action.payload;
|
const { roomId } = action.payload;
|
||||||
|
|
||||||
delete state.joinedRoomIds[roomId];
|
delete state.joinedRoomIds[roomId];
|
||||||
|
delete state.joinedGameIds[roomId];
|
||||||
delete state.messages[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 }>) => {
|
addMessage: (state, action: PayloadAction<{ roomId: number; message: Enriched.Message }>) => {
|
||||||
const { roomId, message } = action.payload;
|
const { roomId, message } = action.payload;
|
||||||
|
|
||||||
const existing = state.messages[roomId] ?? [];
|
const existing = state.messages[roomId] ?? [];
|
||||||
const normalized = normalizeUserMessage({ ...message, timeReceived: Date.now() });
|
const normalized = normalizeUserMessage(message);
|
||||||
const next =
|
const next =
|
||||||
existing.length >= MAX_ROOM_MESSAGES
|
existing.length >= MAX_ROOM_MESSAGES
|
||||||
? [...existing.slice(existing.length - MAX_ROOM_MESSAGES + 1), normalized]
|
? [...existing.slice(existing.length - MAX_ROOM_MESSAGES + 1), normalized]
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,18 @@ export const Selectors = {
|
||||||
* Reads from the room's normalized `games` map — fixes the pre-existing
|
* 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.
|
* bug where this selector read from a never-populated top-level `games` field.
|
||||||
*/
|
*/
|
||||||
getJoinedGames: (state: State, roomId: number): Enriched.Game[] => {
|
getJoinedGames: createSelector(
|
||||||
const room = state.rooms.rooms[roomId];
|
[
|
||||||
const joined = state.rooms.joinedGameIds[roomId];
|
(state: State, roomId: number) => state.rooms.rooms[roomId]?.games,
|
||||||
if (!room || !joined) {
|
(state: State, roomId: number) => state.rooms.joinedGameIds[roomId],
|
||||||
return EMPTY_GAMES;
|
],
|
||||||
|
(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],
|
getRoomMessages: (state: State, roomId: number) => state.rooms.messages[roomId],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export interface ServerState {
|
||||||
backendDecks: Data.Response_DeckList | null;
|
backendDecks: Data.Response_DeckList | null;
|
||||||
downloadedDeck: { deckId: number; deck: string } | null;
|
downloadedDeck: { deckId: number; deck: string } | null;
|
||||||
downloadedReplay: { replayId: number; replayData: Uint8Array } | null;
|
downloadedReplay: { replayId: number; replayData: Uint8Array } | null;
|
||||||
gamesOfUser: { [userName: string]: Enriched.Game[] };
|
gamesOfUser: { [userName: string]: { [gameId: number]: Enriched.Game } };
|
||||||
registrationError: string | null;
|
registrationError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -648,29 +648,29 @@ describe('Deck Storage', () => {
|
||||||
// ── GAMES_OF_USER ─────────────────────────────────────────────────────────────
|
// ── GAMES_OF_USER ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('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, {
|
const response = create(Data.Response_GetGamesOfUserSchema, {
|
||||||
gameList: [create(Data.ServerInfo_GameSchema, { gameId: 5, description: '' })],
|
gameList: [create(Data.ServerInfo_GameSchema, { gameId: 5, description: '' })],
|
||||||
roomList: [],
|
roomList: [],
|
||||||
});
|
});
|
||||||
const state = makeServerState();
|
const state = makeServerState();
|
||||||
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
|
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', () => {
|
it('overwrites previous games for same user', () => {
|
||||||
const old = [makeGame({ gameId: 1 })];
|
const old = { 1: makeGame({ gameId: 1 }) };
|
||||||
const response = create(Data.Response_GetGamesOfUserSchema, {
|
const response = create(Data.Response_GetGamesOfUserSchema, {
|
||||||
gameList: [create(Data.ServerInfo_GameSchema, { gameId: 2, description: '' })],
|
gameList: [create(Data.ServerInfo_GameSchema, { gameId: 2, description: '' })],
|
||||||
roomList: [],
|
roomList: [],
|
||||||
});
|
});
|
||||||
const state = makeServerState({ gamesOfUser: { alice: old } });
|
const state = makeServerState({ gamesOfUser: { alice: old } });
|
||||||
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
|
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', () => {
|
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 response = create(Data.Response_GetGamesOfUserSchema, { gameList: [], roomList: [] });
|
||||||
const state = makeServerState({ gamesOfUser: { bob: bobGames } });
|
const state = makeServerState({ gamesOfUser: { bob: bobGames } });
|
||||||
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
|
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
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 { create } from '@bufbuild/protobuf';
|
||||||
|
|
||||||
import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, normalizeLogs } from '../common';
|
import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, normalizeLogs } from '../common';
|
||||||
|
|
@ -179,8 +179,10 @@ export const serverSlice = createSlice({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateUser: (state, action: PayloadAction<{ user: Data.ServerInfo_User | Partial<Data.ServerInfo_User> }>) => {
|
updateUser: (state, action: PayloadAction<{ user: Partial<Data.ServerInfo_User> }>) => {
|
||||||
state.user = { ...state.user, ...action.payload.user } as Data.ServerInfo_User;
|
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[] }>) => {
|
updateUsers: (state, action: PayloadAction<{ users: Data.ServerInfo_User[] }>) => {
|
||||||
|
|
@ -356,8 +358,12 @@ export const serverSlice = createSlice({
|
||||||
const gametypeMap = normalizeGametypeMap(
|
const gametypeMap = normalizeGametypeMap(
|
||||||
(response.roomList ?? []).flatMap(room => room.gametypeList ?? [])
|
(response.roomList ?? []).flatMap(room => room.gametypeList ?? [])
|
||||||
);
|
);
|
||||||
const normalizedGames = (response.gameList ?? []).map(g => normalizeGameObject(g, gametypeMap));
|
const games: { [gameId: number]: Enriched.Game } = {};
|
||||||
state.gamesOfUser[userName] = normalizedGames;
|
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 }>) => {
|
registrationFailed: (state, action: PayloadAction<{ reason: string; endTime?: number }>) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue