upgrade packages + improve typing

This commit is contained in:
seavor 2026-04-14 11:34:29 -05:00
parent fd55f4fb7f
commit 19f5eefdd2
138 changed files with 4504 additions and 11015 deletions

View file

@ -3,12 +3,12 @@
* @description Application reducer.
*/
import { AnyAction } from 'redux'
import { UnknownAction } from '@reduxjs/toolkit'
interface InitialState {
type: string | null
payload: any
meta: any
payload: unknown
meta: unknown
error: boolean
count: number
}
@ -33,7 +33,7 @@ const initialState: InitialState = {
*/
export const actionReducer = (
state = initialState,
action: AnyAction,
action: UnknownAction,
): InitialState => {
return {
...state,

View file

@ -1,4 +1,6 @@
import { create } from '@bufbuild/protobuf';
import { SortDirection } from 'types';
import { ServerInfo_UserSchema } from 'generated/proto/serverinfo_user_pb';
import SortUtil from './SortUtil';
// ── sortByField ───────────────────────────────────────────────────────────────
@ -118,11 +120,11 @@ describe('sortByFields', () => {
describe('sortUsersByField', () => {
it('sorts by userLevel DESC first, then name ASC', () => {
const users = [
{ name: 'Alice', userLevel: 1, accountageSecs: 0n, privlevel: '' },
{ name: 'Bob', userLevel: 8, accountageSecs: 0n, privlevel: '' },
{ name: 'Carol', userLevel: 1, accountageSecs: 0n, privlevel: '' },
create(ServerInfo_UserSchema, { name: 'Alice', userLevel: 1, accountageSecs: 0n, privlevel: '' }),
create(ServerInfo_UserSchema, { name: 'Bob', userLevel: 8, accountageSecs: 0n, privlevel: '' }),
create(ServerInfo_UserSchema, { name: 'Carol', userLevel: 1, accountageSecs: 0n, privlevel: '' }),
];
SortUtil.sortUsersByField(users as any, { field: 'name', order: SortDirection.ASC });
SortUtil.sortUsersByField(users, { field: 'name', order: SortDirection.ASC });
expect(users[0].name).toBe('Bob');
expect(users[1].name).toBe('Alice');
expect(users[2].name).toBe('Carol');
@ -136,11 +138,11 @@ describe('sortUsersByField', () => {
it('returns 0 (stable) when two users tie on both userLevel and name', () => {
const users = [
{ name: 'Alice', userLevel: 1, accountageSecs: 0n, privlevel: '' },
{ name: 'Alice', userLevel: 1, accountageSecs: 0n, privlevel: '' },
create(ServerInfo_UserSchema, { name: 'Alice', userLevel: 1, accountageSecs: 0n, privlevel: '' }),
create(ServerInfo_UserSchema, { name: 'Alice', userLevel: 1, accountageSecs: 0n, privlevel: '' }),
];
expect(() =>
SortUtil.sortUsersByField(users as any, { field: 'name', order: SortDirection.ASC })
SortUtil.sortUsersByField(users, { field: 'name', order: SortDirection.ASC })
).not.toThrow();
expect(users).toHaveLength(2);
});

View file

@ -1,7 +1,7 @@
import { SortBy, SortDirection, User } from 'types';
export default class SortUtil {
static sortByField(arr: any[], sortBy: SortBy): void {
static sortByField<T extends object>(arr: T[], sortBy: SortBy): void {
if (arr.length) {
const field = SortUtil.resolveFieldChain(arr[0], sortBy.field);
const fieldType = typeof field;
@ -20,7 +20,7 @@ export default class SortUtil {
}
}
static sortByFields(arr: any[], sorts: SortBy[]) {
static sortByFields<T extends object>(arr: T[], sorts: SortBy[]) {
if (arr.length) {
arr.sort((a, b) => {
for (let i = 0; i < sorts.length; i++) {
@ -57,7 +57,7 @@ export default class SortUtil {
}
}
static toggleSortBy(field: string, sortBy: SortBy) {
static toggleSortBy<F extends string>(field: F, sortBy: SortBy): { field: F; order: SortDirection } {
const sameField = field === sortBy.field;
const isASC = sortBy.order === SortDirection.ASC;
@ -67,15 +67,15 @@ export default class SortUtil {
}
}
private static sortByNumber(arr: any[], sortBy: SortBy): void {
private static sortByNumber<T extends object>(arr: T[], sortBy: SortBy): void {
arr.sort((a, b) => SortUtil.numberComparator(a, b, sortBy));
}
private static sortByString(arr: any[], sortBy: SortBy): void {
private static sortByString<T extends object>(arr: T[], sortBy: SortBy): void {
arr.sort((a, b) => SortUtil.stringComparator(a, b, sortBy));
}
private static userComparator(a, b, sortBy, sortByUserLevel = true) {
private static userComparator(a: User, b: User, sortBy: SortBy, sortByUserLevel = true) {
if (sortByUserLevel) {
const adminSortBy = {
field: 'userLevel',
@ -98,7 +98,7 @@ export default class SortUtil {
return 0;
}
private static numberComparator(a, b, { field, order }: SortBy) {
private static numberComparator<T extends object>(a: T, b: T, { field, order }: SortBy) {
const aResolved = SortUtil.resolveFieldChain(a, field);
const bResolved = SortUtil.resolveFieldChain(b, field);
@ -109,7 +109,7 @@ export default class SortUtil {
}
}
private static stringComparator(a, b, { field, order }: SortBy) {
private static stringComparator<T extends object>(a: T, b: T, { field, order }: SortBy) {
const aResolved = SortUtil.resolveFieldChain(a, field);
const bResolved = SortUtil.resolveFieldChain(b, field);

View file

@ -1 +1,2 @@
export { default as SortUtil } from './SortUtil';
export * from './normalizers';

View file

@ -0,0 +1,121 @@
import { normalizeRoomInfo, normalizeGameObject, normalizeLogs, normalizeBannedUserError, normalizeUserMessage } from './normalizers';
import { create } from '@bufbuild/protobuf';
import { ServerInfo_RoomSchema } from 'generated/proto/serverinfo_room_pb';
import { ServerInfo_GameSchema } from 'generated/proto/serverinfo_game_pb';
import { Event_RoomSaySchema } from 'generated/proto/event_room_say_pb';
import { Message } from 'types';
describe('normalizeRoomInfo', () => {
it('builds gametypeMap from gametypeList and normalises games', () => {
const room = create(ServerInfo_RoomSchema, {
roomId: 1,
name: 'Lobby',
gametypeList: [{ gameTypeId: 1, description: 'Standard' }],
gameList: [
create(ServerInfo_GameSchema, { gameId: 10, gameTypes: [1], description: 'My Game' }),
],
});
const result = normalizeRoomInfo(room);
expect(result.gametypeMap).toEqual({ 1: 'Standard' });
expect(result.gameList).toHaveLength(1);
expect(result.gameList[0].gameType).toBe('Standard');
expect(result.order).toBe(0);
});
it('handles room with empty gametypeList', () => {
const room = create(ServerInfo_RoomSchema, { roomId: 2, name: 'Empty' });
const result = normalizeRoomInfo(room);
expect(result.gametypeMap).toEqual({});
expect(result.gameList).toEqual([]);
});
});
describe('normalizeGameObject', () => {
it('maps gameTypes[0] to gameType string via gametypeMap', () => {
const game = create(ServerInfo_GameSchema, { gameId: 1, gameTypes: [5] });
const result = normalizeGameObject(game, { 5: 'Legacy' });
expect(result.gameType).toBe('Legacy');
});
it('returns empty string when no gameTypes', () => {
const game = create(ServerInfo_GameSchema, { gameId: 2 });
const result = normalizeGameObject(game, {});
expect(result.gameType).toBe('');
});
it('fills empty description with empty string', () => {
const game = create(ServerInfo_GameSchema, { gameId: 3 });
const result = normalizeGameObject(game, {});
expect(result.description).toBe('');
});
});
describe('normalizeLogs', () => {
it('groups logs by targetType', () => {
const logs = [
{ targetType: 'room' },
{ targetType: 'game' },
{ targetType: 'room' },
] as any[];
const result = normalizeLogs(logs);
expect(result.room).toHaveLength(2);
expect(result.game).toHaveLength(1);
expect(result.chat).toBeUndefined();
});
it('returns empty object for empty logs', () => {
expect(normalizeLogs([])).toEqual({});
});
});
describe('normalizeBannedUserError', () => {
it('returns permanently banned message when endTime is 0', () => {
expect(normalizeBannedUserError('', 0)).toBe('You are permanently banned');
});
it('returns banned until date when endTime is given', () => {
const endTime = new Date('2030-01-01').getTime();
const result = normalizeBannedUserError('', endTime);
expect(result).toContain('You are banned until');
expect(result).toContain(new Date(endTime).toString());
});
it('appends reason when provided', () => {
expect(normalizeBannedUserError('bad behavior', 0)).toContain('\n\nbad behavior');
});
it('does not append separator when reason is empty', () => {
expect(normalizeBannedUserError('', 0)).not.toContain('\n\n');
});
});
describe('normalizeUserMessage', () => {
const makeMsg = (fields: Partial<Message>): Message => ({
...create(Event_RoomSaySchema),
timeReceived: 0,
...fields,
} as Message);
it('prepends "name: " to message when name is present', () => {
const result = normalizeUserMessage(makeMsg({ name: 'Alice', message: 'hello' }));
expect(result.message).toBe('Alice: hello');
});
it('returns message unchanged when name is empty', () => {
const result = normalizeUserMessage(makeMsg({ name: '', message: 'system msg' }));
expect(result.message).toBe('system msg');
});
it('does not mutate the original message', () => {
const original = makeMsg({ name: 'Bob', message: 'hi' });
normalizeUserMessage(original);
expect(original.message).toBe('hi');
});
it('returns the original reference when no name (no allocation)', () => {
const original = makeMsg({ name: '', message: 'hi' });
expect(normalizeUserMessage(original)).toBe(original);
});
});

View file

@ -0,0 +1,85 @@
import type { ServerInfo_Room } from 'generated/proto/serverinfo_room_pb';
import type { ServerInfo_Game } from 'generated/proto/serverinfo_game_pb';
import type { ServerInfo_GameType } from 'generated/proto/serverinfo_gametype_pb';
import { Game, GametypeMap, LogItem, LogGroups, Message, Room } from 'types';
/** Flatten a gametype list into a lookup map of { gameTypeId → description }. */
export function normalizeGametypeMap(gametypeList: ServerInfo_GameType[]): GametypeMap {
return gametypeList.reduce<GametypeMap>((map, type) => {
map[type.gameTypeId] = type.description;
return map;
}, {});
}
/** Flatten room gameTypes into a map object and normalize all games inside. */
export function normalizeRoomInfo(roomInfo: ServerInfo_Room): Room {
const gametypeMap = normalizeGametypeMap(roomInfo.gametypeList);
const gameList = roomInfo.gameList.map(
(game) => normalizeGameObject(game, gametypeMap),
);
return {
...roomInfo,
gametypeMap,
gameList,
order: 0,
};
}
/** Flatten gameTypes[] into a gameType string; fill in default sortable values. */
export function normalizeGameObject(game: ServerInfo_Game, gametypeMap: GametypeMap): Game {
const { gameTypes, description } = game;
const hasType = gameTypes && gameTypes.length;
return {
...game,
gameType: hasType ? gametypeMap[gameTypes[0]] : '',
description: description || '',
};
}
/** Group a flat LogItem[] into { room, game, chat } buckets for the server store. */
export function normalizeLogs(logs: LogItem[]): LogGroups {
return logs.reduce((obj, log) => {
const type = log.targetType as keyof LogGroups;
obj[type] = obj[type] || [];
obj[type]!.push(log);
return obj;
}, {} as LogGroups);
}
/**
* Prepend "name: " to the message text when a sender name is present.
* Messages from the current user are sent without a name by the server,
* so this is a no-op for those.
* Returns a new Message does not mutate the original.
*/
export function normalizeUserMessage(message: Message): Message {
if (!message.name) {
return message;
}
return { ...message, message: `${message.name}: ${message.message}` };
}
/**
* Build the user-facing ban error string from raw server data.
* The server sends a reason string and an endTime epoch ms (0 = permanent).
* Messages from the current user do not carry the username this quirk is
* handled at the dispatch layer so the redux store always stores a clean string.
*/
export function normalizeBannedUserError(reason: string, endTime: number): string {
let error: string;
if (endTime) {
error = 'You are banned until ' + new Date(endTime).toString();
} else {
error = 'You are permanently banned';
}
if (reason) {
error += '\n\n' + reason;
}
return error;
}

View file

@ -1,8 +1,14 @@
import { ArrowInfo, CardInfo, CounterInfo, PlayerProperties } from 'types';
import { ArrowInfo, CardInfo, CounterInfo, PlayerProperties, ProtoInit } from 'types';
import { create } from '@bufbuild/protobuf';
import { ServerInfo_CardSchema } from 'generated/proto/serverinfo_card_pb';
import { ServerInfo_CounterSchema } from 'generated/proto/serverinfo_counter_pb';
import { colorSchema } from 'generated/proto/color_pb';
import { ServerInfo_ArrowSchema } from 'generated/proto/serverinfo_arrow_pb';
import { ServerInfo_PlayerPropertiesSchema } from 'generated/proto/serverinfo_playerproperties_pb';
import { GameEntry, GamesState, PlayerEntry, ZoneEntry } from '../game.interfaces';
export function makeCard(overrides: Partial<CardInfo> = {}): CardInfo {
return {
export function makeCard(overrides: ProtoInit<CardInfo> = {}): CardInfo {
return create(ServerInfo_CardSchema, {
id: 1,
name: 'Test Card',
x: 0,
@ -21,22 +27,22 @@ export function makeCard(overrides: Partial<CardInfo> = {}): CardInfo {
attachCardId: -1,
providerId: '',
...overrides,
};
});
}
export function makeCounter(overrides: Partial<CounterInfo> = {}): CounterInfo {
return {
export function makeCounter(overrides: ProtoInit<CounterInfo> = {}): CounterInfo {
return create(ServerInfo_CounterSchema, {
id: 1,
name: 'Life',
counterColor: { r: 0, g: 0, b: 0, a: 255 },
counterColor: create(colorSchema, { r: 0, g: 0, b: 0, a: 255 }),
radius: 1,
count: 20,
...overrides,
};
});
}
export function makeArrow(overrides: Partial<ArrowInfo> = {}): ArrowInfo {
return {
export function makeArrow(overrides: ProtoInit<ArrowInfo> = {}): ArrowInfo {
return create(ServerInfo_ArrowSchema, {
id: 1,
startPlayerId: 1,
startZone: 'table',
@ -44,9 +50,9 @@ export function makeArrow(overrides: Partial<ArrowInfo> = {}): ArrowInfo {
targetPlayerId: 1,
targetZone: 'table',
targetCardId: 2,
arrowColor: { r: 255, g: 0, b: 0, a: 255 },
arrowColor: create(colorSchema, { r: 255, g: 0, b: 0, a: 255 }),
...overrides,
};
});
}
export function makeZoneEntry(overrides: Partial<ZoneEntry> = {}): ZoneEntry {
@ -62,10 +68,9 @@ export function makeZoneEntry(overrides: Partial<ZoneEntry> = {}): ZoneEntry {
};
}
export function makePlayerProperties(overrides: Partial<PlayerProperties> = {}): PlayerProperties {
return {
export function makePlayerProperties(overrides: ProtoInit<PlayerProperties> = {}): PlayerProperties {
return create(ServerInfo_PlayerPropertiesSchema, {
playerId: 1,
userInfo: null,
spectator: false,
conceded: false,
readyStart: false,
@ -74,7 +79,7 @@ export function makePlayerProperties(overrides: Partial<PlayerProperties> = {}):
sideboardLocked: false,
judge: false,
...overrides,
};
});
}
export function makePlayerEntry(overrides: Partial<PlayerEntry> = {}): PlayerEntry {

View file

@ -1,3 +1,4 @@
import { create } from '@bufbuild/protobuf';
import { Actions } from './game.actions';
import { Types } from './game.types';
import {
@ -6,8 +7,26 @@ import {
makeCounter,
makeGameEntry,
makePlayerProperties,
makeZoneEntry,
} from './__mocks__/fixtures';
import { Event_GameStateChangedSchema } from 'generated/proto/event_game_state_changed_pb';
import { Event_MoveCardSchema } from 'generated/proto/event_move_card_pb';
import { Event_FlipCardSchema } from 'generated/proto/event_flip_card_pb';
import { Event_DestroyCardSchema } from 'generated/proto/event_destroy_card_pb';
import { Event_AttachCardSchema } from 'generated/proto/event_attach_card_pb';
import { Event_CreateTokenSchema } from 'generated/proto/event_create_token_pb';
import { Event_SetCardAttrSchema } from 'generated/proto/event_set_card_attr_pb';
import { Event_SetCardCounterSchema } from 'generated/proto/event_set_card_counter_pb';
import { Event_CreateArrowSchema } from 'generated/proto/event_create_arrow_pb';
import { Event_DeleteArrowSchema } from 'generated/proto/event_delete_arrow_pb';
import { Event_CreateCounterSchema } from 'generated/proto/event_create_counter_pb';
import { Event_SetCounterSchema } from 'generated/proto/event_set_counter_pb';
import { Event_DelCounterSchema } from 'generated/proto/event_del_counter_pb';
import { Event_DrawCardsSchema } from 'generated/proto/event_draw_cards_pb';
import { Event_RevealCardsSchema } from 'generated/proto/event_reveal_cards_pb';
import { Event_ShuffleSchema } from 'generated/proto/event_shuffle_pb';
import { Event_RollDieSchema } from 'generated/proto/event_roll_die_pb';
import { Event_DumpZoneSchema } from 'generated/proto/event_dump_zone_pb';
import { Event_ChangeZonePropertiesSchema } from 'generated/proto/event_change_zone_properties_pb';
describe('Actions', () => {
it('clearStore', () => {
@ -32,7 +51,9 @@ describe('Actions', () => {
});
it('gameStateChanged', () => {
const data = { playerList: [], gameStarted: true, activePlayerId: 1, activePhase: 0, secondsElapsed: 0 };
const data = create(Event_GameStateChangedSchema, {
playerList: [], gameStarted: true, activePlayerId: 1, activePhase: 0, secondsElapsed: 0
});
expect(Actions.gameStateChanged(1, data)).toEqual({ type: Types.GAME_STATE_CHANGED, gameId: 1, data });
});
@ -60,85 +81,85 @@ describe('Actions', () => {
});
it('cardMoved', () => {
const data = { cardId: 1 } as any;
const data = create(Event_MoveCardSchema, { cardId: 1 });
expect(Actions.cardMoved(1, 2, data)).toEqual({ type: Types.CARD_MOVED, gameId: 1, playerId: 2, data });
});
it('cardFlipped', () => {
const data = { cardId: 1 } as any;
const data = create(Event_FlipCardSchema, { cardId: 1 });
expect(Actions.cardFlipped(1, 2, data)).toEqual({ type: Types.CARD_FLIPPED, gameId: 1, playerId: 2, data });
});
it('cardDestroyed', () => {
const data = { cardId: 1 } as any;
const data = create(Event_DestroyCardSchema, { cardId: 1 });
expect(Actions.cardDestroyed(1, 2, data)).toEqual({ type: Types.CARD_DESTROYED, gameId: 1, playerId: 2, data });
});
it('cardAttached', () => {
const data = { cardId: 1 } as any;
const data = create(Event_AttachCardSchema, { cardId: 1 });
expect(Actions.cardAttached(1, 2, data)).toEqual({ type: Types.CARD_ATTACHED, gameId: 1, playerId: 2, data });
});
it('tokenCreated', () => {
const data = { cardId: 1 } as any;
const data = create(Event_CreateTokenSchema, { cardId: 1 });
expect(Actions.tokenCreated(1, 2, data)).toEqual({ type: Types.TOKEN_CREATED, gameId: 1, playerId: 2, data });
});
it('cardAttrChanged', () => {
const data = { cardId: 1 } as any;
const data = create(Event_SetCardAttrSchema, { cardId: 1 });
expect(Actions.cardAttrChanged(1, 2, data)).toEqual({ type: Types.CARD_ATTR_CHANGED, gameId: 1, playerId: 2, data });
});
it('cardCounterChanged', () => {
const data = { cardId: 1 } as any;
const data = create(Event_SetCardCounterSchema, { cardId: 1 });
expect(Actions.cardCounterChanged(1, 2, data)).toEqual({ type: Types.CARD_COUNTER_CHANGED, gameId: 1, playerId: 2, data });
});
it('arrowCreated', () => {
const arrow = makeArrow();
const data = { arrowInfo: arrow };
const data = create(Event_CreateArrowSchema, { arrowInfo: arrow });
expect(Actions.arrowCreated(1, 2, data)).toEqual({ type: Types.ARROW_CREATED, gameId: 1, playerId: 2, data });
});
it('arrowDeleted', () => {
const data = { arrowId: 3 };
const data = create(Event_DeleteArrowSchema, { arrowId: 3 });
expect(Actions.arrowDeleted(1, 2, data)).toEqual({ type: Types.ARROW_DELETED, gameId: 1, playerId: 2, data });
});
it('counterCreated', () => {
const counter = makeCounter();
const data = { counterInfo: counter };
const data = create(Event_CreateCounterSchema, { counterInfo: counter });
expect(Actions.counterCreated(1, 2, data)).toEqual({ type: Types.COUNTER_CREATED, gameId: 1, playerId: 2, data });
});
it('counterSet', () => {
const data = { counterId: 1, value: 10 };
const data = create(Event_SetCounterSchema, { counterId: 1, value: 10 });
expect(Actions.counterSet(1, 2, data)).toEqual({ type: Types.COUNTER_SET, gameId: 1, playerId: 2, data });
});
it('counterDeleted', () => {
const data = { counterId: 1 };
const data = create(Event_DelCounterSchema, { counterId: 1 });
expect(Actions.counterDeleted(1, 2, data)).toEqual({ type: Types.COUNTER_DELETED, gameId: 1, playerId: 2, data });
});
it('cardsDrawn', () => {
const card = makeCard();
const data = { number: 2, cards: [card] };
const data = create(Event_DrawCardsSchema, { number: 2, cards: [card] });
expect(Actions.cardsDrawn(1, 2, data)).toEqual({ type: Types.CARDS_DRAWN, gameId: 1, playerId: 2, data });
});
it('cardsRevealed', () => {
const data = { zoneName: 'hand', cards: [] } as any;
const data = create(Event_RevealCardsSchema, { zoneName: 'hand', cards: [] });
expect(Actions.cardsRevealed(1, 2, data)).toEqual({ type: Types.CARDS_REVEALED, gameId: 1, playerId: 2, data });
});
it('zoneShuffled', () => {
const data = { zoneName: 'deck', start: 0, end: 39 };
const data = create(Event_ShuffleSchema, { zoneName: 'deck', start: 0, end: 39 });
expect(Actions.zoneShuffled(1, 2, data)).toEqual({ type: Types.ZONE_SHUFFLED, gameId: 1, playerId: 2, data });
});
it('dieRolled', () => {
const data = { sides: 6, value: 4, values: [4] };
const data = create(Event_RollDieSchema, { sides: 6, value: 4, values: [4] });
expect(Actions.dieRolled(1, 2, data)).toEqual({ type: Types.DIE_ROLLED, gameId: 1, playerId: 2, data });
});
@ -155,12 +176,12 @@ describe('Actions', () => {
});
it('zoneDumped', () => {
const data = { zoneOwnerId: 1, zoneName: 'hand', numberCards: 3, isReversed: false };
const data = create(Event_DumpZoneSchema, { zoneOwnerId: 1, zoneName: 'hand', numberCards: 3, isReversed: false });
expect(Actions.zoneDumped(1, 2, data)).toEqual({ type: Types.ZONE_DUMPED, gameId: 1, playerId: 2, data });
});
it('zonePropertiesChanged', () => {
const data = { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false };
const data = create(Event_ChangeZonePropertiesSchema, { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false });
expect(Actions.zonePropertiesChanged(1, 2, data)).toEqual({
type: Types.ZONE_PROPERTIES_CHANGED,
gameId: 1,

View file

@ -232,3 +232,5 @@ export const Actions = {
message,
}),
};
export type GameAction = ReturnType<typeof Actions[keyof typeof Actions]>;

View file

@ -1,5 +1,6 @@
vi.mock('store/store', () => ({ store: { dispatch: vi.fn() } }));
import { create } from '@bufbuild/protobuf';
import { store } from 'store/store';
import { Actions } from './game.actions';
import { Dispatch } from './game.dispatch';
@ -10,6 +11,25 @@ import {
makeGameEntry,
makePlayerProperties,
} from './__mocks__/fixtures';
import { Event_GameStateChangedSchema } from 'generated/proto/event_game_state_changed_pb';
import { Event_MoveCardSchema } from 'generated/proto/event_move_card_pb';
import { Event_FlipCardSchema } from 'generated/proto/event_flip_card_pb';
import { Event_DestroyCardSchema } from 'generated/proto/event_destroy_card_pb';
import { Event_AttachCardSchema } from 'generated/proto/event_attach_card_pb';
import { Event_CreateTokenSchema } from 'generated/proto/event_create_token_pb';
import { Event_SetCardAttrSchema } from 'generated/proto/event_set_card_attr_pb';
import { Event_SetCardCounterSchema } from 'generated/proto/event_set_card_counter_pb';
import { Event_CreateArrowSchema } from 'generated/proto/event_create_arrow_pb';
import { Event_DeleteArrowSchema } from 'generated/proto/event_delete_arrow_pb';
import { Event_CreateCounterSchema } from 'generated/proto/event_create_counter_pb';
import { Event_SetCounterSchema } from 'generated/proto/event_set_counter_pb';
import { Event_DelCounterSchema } from 'generated/proto/event_del_counter_pb';
import { Event_DrawCardsSchema } from 'generated/proto/event_draw_cards_pb';
import { Event_RevealCardsSchema } from 'generated/proto/event_reveal_cards_pb';
import { Event_ShuffleSchema } from 'generated/proto/event_shuffle_pb';
import { Event_RollDieSchema } from 'generated/proto/event_roll_die_pb';
import { Event_DumpZoneSchema } from 'generated/proto/event_dump_zone_pb';
import { Event_ChangeZonePropertiesSchema } from 'generated/proto/event_change_zone_properties_pb';
beforeEach(() => vi.clearAllMocks());
@ -41,7 +61,9 @@ describe('Dispatch', () => {
});
it('gameStateChanged dispatches Actions.gameStateChanged()', () => {
const data = { playerList: [], gameStarted: false, activePlayerId: 0, activePhase: 0, secondsElapsed: 0 };
const data = create(Event_GameStateChangedSchema, {
playerList: [], gameStarted: false, activePlayerId: 0, activePhase: 0, secondsElapsed: 0
});
Dispatch.gameStateChanged(1, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.gameStateChanged(1, data));
});
@ -69,97 +91,97 @@ describe('Dispatch', () => {
});
it('cardMoved dispatches Actions.cardMoved()', () => {
const data = { cardId: 1 } as any;
const data = create(Event_MoveCardSchema, { cardId: 1 });
Dispatch.cardMoved(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardMoved(1, 2, data));
});
it('cardFlipped dispatches Actions.cardFlipped()', () => {
const data = { cardId: 1 } as any;
const data = create(Event_FlipCardSchema, { cardId: 1 });
Dispatch.cardFlipped(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardFlipped(1, 2, data));
});
it('cardDestroyed dispatches Actions.cardDestroyed()', () => {
const data = { cardId: 1 } as any;
const data = create(Event_DestroyCardSchema, { cardId: 1 });
Dispatch.cardDestroyed(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardDestroyed(1, 2, data));
});
it('cardAttached dispatches Actions.cardAttached()', () => {
const data = { cardId: 1 } as any;
const data = create(Event_AttachCardSchema, { cardId: 1 });
Dispatch.cardAttached(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardAttached(1, 2, data));
});
it('tokenCreated dispatches Actions.tokenCreated()', () => {
const data = { cardId: 1 } as any;
const data = create(Event_CreateTokenSchema, { cardId: 1 });
Dispatch.tokenCreated(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.tokenCreated(1, 2, data));
});
it('cardAttrChanged dispatches Actions.cardAttrChanged()', () => {
const data = { cardId: 1 } as any;
const data = create(Event_SetCardAttrSchema, { cardId: 1 });
Dispatch.cardAttrChanged(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardAttrChanged(1, 2, data));
});
it('cardCounterChanged dispatches Actions.cardCounterChanged()', () => {
const data = { cardId: 1 } as any;
const data = create(Event_SetCardCounterSchema, { cardId: 1 });
Dispatch.cardCounterChanged(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardCounterChanged(1, 2, data));
});
it('arrowCreated dispatches Actions.arrowCreated()', () => {
const data = { arrowInfo: makeArrow() };
const data = create(Event_CreateArrowSchema, { arrowInfo: makeArrow() });
Dispatch.arrowCreated(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.arrowCreated(1, 2, data));
});
it('arrowDeleted dispatches Actions.arrowDeleted()', () => {
const data = { arrowId: 3 };
const data = create(Event_DeleteArrowSchema, { arrowId: 3 });
Dispatch.arrowDeleted(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.arrowDeleted(1, 2, data));
});
it('counterCreated dispatches Actions.counterCreated()', () => {
const data = { counterInfo: makeCounter() };
const data = create(Event_CreateCounterSchema, { counterInfo: makeCounter() });
Dispatch.counterCreated(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.counterCreated(1, 2, data));
});
it('counterSet dispatches Actions.counterSet()', () => {
const data = { counterId: 1, value: 10 };
const data = create(Event_SetCounterSchema, { counterId: 1, value: 10 });
Dispatch.counterSet(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.counterSet(1, 2, data));
});
it('counterDeleted dispatches Actions.counterDeleted()', () => {
const data = { counterId: 1 };
const data = create(Event_DelCounterSchema, { counterId: 1 });
Dispatch.counterDeleted(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.counterDeleted(1, 2, data));
});
it('cardsDrawn dispatches Actions.cardsDrawn()', () => {
const data = { number: 2, cards: [makeCard()] };
const data = create(Event_DrawCardsSchema, { number: 2, cards: [makeCard()] });
Dispatch.cardsDrawn(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardsDrawn(1, 2, data));
});
it('cardsRevealed dispatches Actions.cardsRevealed()', () => {
const data = { zoneName: 'hand', cards: [] } as any;
const data = create(Event_RevealCardsSchema, { zoneName: 'hand', cards: [] });
Dispatch.cardsRevealed(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardsRevealed(1, 2, data));
});
it('zoneShuffled dispatches Actions.zoneShuffled()', () => {
const data = { zoneName: 'deck', start: 0, end: 39 };
const data = create(Event_ShuffleSchema, { zoneName: 'deck', start: 0, end: 39 });
Dispatch.zoneShuffled(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.zoneShuffled(1, 2, data));
});
it('dieRolled dispatches Actions.dieRolled()', () => {
const data = { sides: 6, value: 4, values: [4] };
const data = create(Event_RollDieSchema, { sides: 6, value: 4, values: [4] });
Dispatch.dieRolled(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.dieRolled(1, 2, data));
});
@ -180,13 +202,13 @@ describe('Dispatch', () => {
});
it('zoneDumped dispatches Actions.zoneDumped()', () => {
const data = { zoneOwnerId: 1, zoneName: 'hand', numberCards: 3, isReversed: false };
const data = create(Event_DumpZoneSchema, { zoneOwnerId: 1, zoneName: 'hand', numberCards: 3, isReversed: false });
Dispatch.zoneDumped(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.zoneDumped(1, 2, data));
});
it('zonePropertiesChanged dispatches Actions.zonePropertiesChanged()', () => {
const data = { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false };
const data = create(Event_ChangeZonePropertiesSchema, { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false });
Dispatch.zonePropertiesChanged(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.zonePropertiesChanged(1, 2, data));
});

View file

@ -1,3 +1,4 @@
import { create } from '@bufbuild/protobuf';
import { CardAttribute, PlayerInfo } from 'types';
import { gamesReducer } from './game.reducer';
import { Types } from './game.types';
@ -11,6 +12,7 @@ import {
makeState,
makeZoneEntry,
} from './__mocks__/fixtures';
import { ServerInfo_PlayerSchema } from 'generated/proto/serverinfo_player_pb';
// ── 2A: Initialisation & lifecycle ───────────────────────────────────────────
@ -67,7 +69,7 @@ describe('2B: Game state & player management', () => {
const counter = makeCounter({ id: 2 });
const arrow = makeArrow({ id: 3 });
const playerList: PlayerInfo[] = [
{
create(ServerInfo_PlayerSchema, {
properties: makePlayerProperties({ playerId: 7 }),
deckList: 'some deck',
zoneList: [
@ -83,7 +85,7 @@ describe('2B: Game state & player management', () => {
],
counterList: [counter],
arrowList: [arrow],
},
}),
];
const result = gamesReducer(state, {
@ -620,7 +622,7 @@ describe('2F: CARD_COUNTER_CHANGED', () => {
playerId: 1,
data: { zoneName: 'table', cardId: 4, counterId: 1, counterValue: 3 },
});
expect(result.games[1].players[1].zones['table'].cards[0].counterList).toEqual([{ id: 1, value: 3 }]);
expect(result.games[1].players[1].zones['table'].cards[0].counterList).toEqual([expect.objectContaining({ id: 1, value: 3 })]);
});
it('updates existing counter value when counterId matches', () => {
@ -631,7 +633,7 @@ describe('2F: CARD_COUNTER_CHANGED', () => {
playerId: 1,
data: { zoneName: 'table', cardId: 4, counterId: 1, counterValue: 7 },
});
expect(result.games[1].players[1].zones['table'].cards[0].counterList).toEqual([{ id: 1, value: 7 }]);
expect(result.games[1].players[1].zones['table'].cards[0].counterList).toEqual([expect.objectContaining({ id: 1, value: 7 })]);
});
it('removes counter from counterList when counterValue ≤ 0', () => {

View file

@ -7,6 +7,10 @@ import {
PlayerInfo,
PlayerProperties,
} from 'types';
import { create } from '@bufbuild/protobuf';
import { ServerInfo_CardSchema } from 'generated/proto/serverinfo_card_pb';
import { ServerInfo_CardCounterSchema } from 'generated/proto/serverinfo_cardcounter_pb';
import { GameAction } from './game.actions';
import { GameEntry, GameMessage, GamesState, PlayerEntry, ZoneEntry } from './game.interfaces';
import { Types } from './game.types';
@ -120,7 +124,7 @@ function buildEmptyCard(
faceDown: boolean,
providerId: string
): CardInfo {
return {
return create(ServerInfo_CardSchema, {
id,
name,
x,
@ -138,7 +142,7 @@ function buildEmptyCard(
attachZone: '',
attachCardId: -1,
providerId,
};
});
}
// ── Initial state ─────────────────────────────────────────────────────────────
@ -149,7 +153,7 @@ const initialState: GamesState = {
// ── Reducer ───────────────────────────────────────────────────────────────────
export const gamesReducer = (state: GamesState = initialState, action: any): GamesState => {
export const gamesReducer = (state: GamesState = initialState, action: GameAction): GamesState => {
switch (action.type) {
case Types.CLEAR_STORE: {
return initialState;
@ -422,7 +426,7 @@ export const gamesReducer = (state: GamesState = initialState, action: any): Gam
return state;
}
const newCard: CardInfo = {
const newCard: CardInfo = create(ServerInfo_CardSchema, {
id: cardId,
name: cardName,
x,
@ -440,7 +444,7 @@ export const gamesReducer = (state: GamesState = initialState, action: any): Gam
attachZone: '',
attachCardId: -1,
providerId: cardProviderId,
};
});
return updateZone(state, gameId, playerId, zoneName, {
cards: [...zone.cards, newCard],
cardCount: zone.cardCount + 1,
@ -514,7 +518,7 @@ export const gamesReducer = (state: GamesState = initialState, action: any): Gam
newCounterList =
existing >= 0
? card.counterList.map(c => (c.id === counterId ? { ...c, value: counterValue } : c))
: [...card.counterList, { id: counterId, value: counterValue }];
: [...card.counterList, create(ServerInfo_CardCounterSchema, { id: counterId, value: counterValue })];
}
const updatedCards = [...zone.cards];

View file

@ -1,6 +1,5 @@
import { Selectors } from './game.selectors';
import {
makeGameEntry, makePlayerEntry, makePlayerProperties, makeState,
import { makeGameEntry, makePlayerEntry, makeState,
makeZoneEntry, makeCard, makeCounter, makeArrow,
} from './__mocks__/fixtures';
import { GamesState } from './game.interfaces';

View file

@ -1,9 +1,14 @@
import { createSelector } from '@reduxjs/toolkit';
import { CardInfo } from 'types';
import { GamesState, GameEntry, PlayerEntry, ZoneEntry } from './game.interfaces';
interface State {
games: GamesState;
}
const EMPTY_ARRAY: CardInfo[] = [];
const EMPTY_OBJECT = {} as Record<string, never>;
export const Selectors = {
getGames: ({ games }: State): { [gameId: number]: GameEntry } => games.games,
@ -41,13 +46,13 @@ export const Selectors = {
): ZoneEntry | undefined => games.games[gameId]?.players[playerId]?.zones[zoneName],
getCards: ({ games }: State, gameId: number, playerId: number, zoneName: string) =>
games.games[gameId]?.players[playerId]?.zones[zoneName]?.cards ?? [],
games.games[gameId]?.players[playerId]?.zones[zoneName]?.cards ?? EMPTY_ARRAY,
getCounters: ({ games }: State, gameId: number, playerId: number) =>
games.games[gameId]?.players[playerId]?.counters ?? {},
games.games[gameId]?.players[playerId]?.counters ?? EMPTY_OBJECT,
getArrows: ({ games }: State, gameId: number, playerId: number) =>
games.games[gameId]?.players[playerId]?.arrows ?? {},
games.games[gameId]?.players[playerId]?.arrows ?? EMPTY_OBJECT,
getActivePlayerId: ({ games }: State, gameId: number): number | undefined =>
games.games[gameId]?.activePlayerId,
@ -65,8 +70,10 @@ export const Selectors = {
games.games[gameId]?.reversed ?? false,
getMessages: ({ games }: State, gameId: number) =>
games.games[gameId]?.messages ?? [],
games.games[gameId]?.messages ?? EMPTY_ARRAY,
getActiveGameIds: ({ games }: State): number[] =>
Object.keys(games.games).map(Number),
getActiveGameIds: createSelector(
[({ games }: State) => games.games],
(games) => Object.keys(games).map(Number)
),
};

View file

@ -31,4 +31,4 @@ export const Types = {
ZONE_DUMPED: '[Games] Zone Dumped',
ZONE_PROPERTIES_CHANGED: '[Games] Zone Properties Changed',
GAME_SAY: '[Games] Game Say',
};
} as const;

View file

@ -1,52 +1,63 @@
import {
Game,
GameSortField,
Message,
ProtoInit,
Room,
SortDirection,
User,
UserSortField,
} from 'types';
import { Message, RoomsState } from '../rooms.interfaces';
import { create } from '@bufbuild/protobuf';
import { ServerInfo_UserSchema } from 'generated/proto/serverinfo_user_pb';
import { ServerInfo_GameSchema } from 'generated/proto/serverinfo_game_pb';
import { ServerInfo_RoomSchema } from 'generated/proto/serverinfo_room_pb';
import { RoomsState } from '../rooms.interfaces';
export function makeUser(overrides: Partial<User> = {}): User {
return {
export function makeUser(overrides: ProtoInit<User> = {}): User {
return create(ServerInfo_UserSchema, {
name: 'TestUser',
accountageSecs: 0n,
privlevel: '',
userLevel: 0,
...overrides,
});
}
export function makeRoom(overrides: ProtoInit<Room> = {}): Room {
const { gametypeMap = {}, order = 0, gameList = [], ...protoOverrides } = overrides;
return {
...create(ServerInfo_RoomSchema, {
roomId: 1,
name: 'Test Room',
description: '',
gameCount: 0,
gameList: [],
gametypeList: [],
autoJoin: false,
playerCount: 0,
userList: [],
...protoOverrides,
}),
gameList,
gametypeMap,
order,
};
}
export function makeRoom(overrides: Partial<Room> = {}): Room {
export function makeGame(overrides: ProtoInit<Game & { startTime: number }> = {}): Game & { startTime: number } {
const { gameType = '', startTime = 0, ...protoOverrides } = overrides;
return {
roomId: 1,
name: 'Test Room',
description: '',
gameCount: 0,
gameList: [],
gametypeList: [],
gametypeMap: {},
autoJoin: false,
permissionlevel: 0 as any,
playerCount: 0,
privilegelevel: 0 as any,
userList: [],
order: 0,
...overrides,
};
}
export function makeGame(overrides: Partial<Game & { startTime: number }> = {}): Game & { startTime: number } {
return {
gameId: 1,
roomId: 1,
description: 'Test Game',
gameType: '',
gameTypes: [],
started: false,
startTime: 0,
...overrides,
...create(ServerInfo_GameSchema, {
gameId: 1,
roomId: 1,
description: 'Test Game',
gameTypes: [],
started: false,
...protoOverrides,
}),
gameType,
startTime,
};
}

View file

@ -1,71 +1,77 @@
import { GameSortField, Message, SortDirection, User } from 'types';
import type { ServerInfo_Room } from 'generated/proto/serverinfo_room_pb';
import type { ServerInfo_Game } from 'generated/proto/serverinfo_game_pb';
import { Types } from './rooms.types';
export const Actions = {
clearStore: () => ({
type: Types.CLEAR_STORE
type: Types.CLEAR_STORE,
}),
updateRooms: rooms => ({
updateRooms: (rooms: ServerInfo_Room[]) => ({
type: Types.UPDATE_ROOMS,
rooms
rooms,
}),
joinRoom: roomInfo => ({
joinRoom: (roomInfo: ServerInfo_Room) => ({
type: Types.JOIN_ROOM,
roomInfo
roomInfo,
}),
leaveRoom: roomId => ({
leaveRoom: (roomId: number) => ({
type: Types.LEAVE_ROOM,
roomId
roomId,
}),
addMessage: (roomId, message) => ({
addMessage: (roomId: number, message: Message) => ({
type: Types.ADD_MESSAGE,
roomId,
message
message,
}),
updateGames: (roomId, games) => ({
updateGames: (roomId: number, games: ServerInfo_Game[]) => ({
type: Types.UPDATE_GAMES,
roomId,
games
games,
}),
userJoined: (roomId, user) => ({
userJoined: (roomId: number, user: User) => ({
type: Types.USER_JOINED,
roomId,
user
user,
}),
userLeft: (roomId, name) => ({
userLeft: (roomId: number, name: string) => ({
type: Types.USER_LEFT,
roomId,
name
name,
}),
sortGames: (roomId, field, order) => ({
sortGames: (roomId: number, field: GameSortField, order: SortDirection) => ({
type: Types.SORT_GAMES,
roomId,
field,
order
order,
}),
removeMessages: (roomId, name, amount) => ({
removeMessages: (roomId: number, name: string, amount: number) => ({
type: Types.REMOVE_MESSAGES,
roomId,
name,
amount
amount,
}),
gameCreated: (roomId) => ({
gameCreated: (roomId: number) => ({
type: Types.GAME_CREATED,
roomId
roomId,
}),
joinedGame: (roomId, gameId) => ({
joinedGame: (roomId: number, gameId: number) => ({
type: Types.JOINED_GAME,
roomId,
gameId
gameId,
}),
}
export type RoomsAction = ReturnType<typeof Actions[keyof typeof Actions]>;

View file

@ -1,10 +1,6 @@
vi.mock('store/store', () => ({ store: { dispatch: vi.fn() } }));
vi.mock('redux-form', () => ({
reset: vi.fn((form) => ({ type: '@@redux-form/RESET', meta: { form } })),
}));
vi.mock('store', () => ({ store: { dispatch: vi.fn() } }));
import { store } from 'store/store';
import { reset } from 'redux-form';
import { store } from 'store';
import { Actions } from './rooms.actions';
import { Dispatch } from './rooms.dispatch';
import { makeGame, makeMessage, makeRoom, makeUser } from './__mocks__/rooms-fixtures';
@ -42,11 +38,11 @@ describe('Dispatch', () => {
expect(store.dispatch).toHaveBeenCalledWith(Actions.addMessage(1, message));
});
it('addMessage with message.name truthy → dispatches reset("sayMessage") then Actions.addMessage()', () => {
it('addMessage with message.name truthy → dispatches Actions.addMessage()', () => {
const message = { ...makeMessage(), name: 'Alice' };
Dispatch.addMessage(1, message);
expect(store.dispatch).toHaveBeenNthCalledWith(1, (reset as vi.Mock)('sayMessage'));
expect(store.dispatch).toHaveBeenNthCalledWith(2, Actions.addMessage(1, message));
expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(store.dispatch).toHaveBeenCalledWith(Actions.addMessage(1, message));
});
it('updateGames dispatches Actions.updateGames()', () => {

View file

@ -1,4 +1,7 @@
import { reset } from 'redux-form';
import { GameSortField, Message, SortDirection, User } from 'types';
import type { ServerInfo_Room } from 'generated/proto/serverinfo_room_pb';
import type { ServerInfo_Game } from 'generated/proto/serverinfo_game_pb';
import { Actions } from './rooms.actions';
import { store } from 'store';
@ -7,52 +10,48 @@ export const Dispatch = {
store.dispatch(Actions.clearStore());
},
updateRooms: rooms => {
updateRooms: (rooms: ServerInfo_Room[]) => {
store.dispatch(Actions.updateRooms(rooms));
},
joinRoom: roomInfo => {
joinRoom: (roomInfo: ServerInfo_Room) => {
store.dispatch(Actions.joinRoom(roomInfo));
},
leaveRoom: roomId => {
leaveRoom: (roomId: number) => {
store.dispatch(Actions.leaveRoom(roomId));
},
addMessage: (roomId, message) => {
if (message.name) {
store.dispatch(reset('sayMessage'));
}
addMessage: (roomId: number, message: Message) => {
store.dispatch(Actions.addMessage(roomId, message));
},
updateGames: (roomId, games) => {
updateGames: (roomId: number, games: ServerInfo_Game[]) => {
store.dispatch(Actions.updateGames(roomId, games));
},
userJoined: (roomId, user) => {
userJoined: (roomId: number, user: User) => {
store.dispatch(Actions.userJoined(roomId, user));
},
userLeft: (roomId, name) => {
userLeft: (roomId: number, name: string) => {
store.dispatch(Actions.userLeft(roomId, name));
},
sortGames: (roomId, field, order) => {
sortGames: (roomId: number, field: GameSortField, order: SortDirection) => {
store.dispatch(Actions.sortGames(roomId, field, order));
},
removeMessages: (roomId, name, amount) => {
removeMessages: (roomId: number, name: string, amount: number) => {
store.dispatch(Actions.removeMessages(roomId, name, amount));
},
gameCreated: (roomId) => {
gameCreated: (roomId: number) => {
store.dispatch(Actions.gameCreated(roomId));
},
joinedGame: (roomId, gameId) => {
joinedGame: (roomId: number, gameId: number) => {
store.dispatch(Actions.joinedGame(roomId, gameId));
}
}

View file

@ -1,4 +1,4 @@
import { GameSortField, Room, Game, SortBy, UserSortField } from 'types';
import { GameSortField, Message, Room, Game, SortBy, UserSortField } from 'types';
export interface RoomsState {
rooms: RoomsStateRooms;
@ -41,10 +41,3 @@ export interface RoomsStateSortGamesBy extends SortBy {
export interface RoomsStateSortUsersBy extends SortBy {
field: UserSortField
}
export interface Message {
message: string;
messageType: number;
timeReceived: number;
timeOf?: number;
}

View file

@ -126,6 +126,20 @@ describe('ADD_MESSAGE', () => {
expect(result.messages[1][0].message).not.toBe('first');
expect(result.messages[1][MAX_ROOM_MESSAGES - 1].message).toBe('new');
});
it('prepends "name: " to message when name is present', () => {
const state = makeRoomsState({ messages: { 1: [] } });
const message = makeMessage({ name: 'Alice', message: 'hello' });
const result = roomsReducer(state, { type: Types.ADD_MESSAGE, roomId: 1, message });
expect(result.messages[1][0].message).toBe('Alice: hello');
});
it('does not prepend when name is empty', () => {
const state = makeRoomsState({ messages: { 1: [] } });
const message = makeMessage({ name: '', message: 'system msg' });
const result = roomsReducer(state, { type: Types.ADD_MESSAGE, roomId: 1, message });
expect(result.messages[1][0].message).toBe('system msg');
});
});
// ── UPDATE_GAMES ──────────────────────────────────────────────────────────────
@ -267,6 +281,16 @@ describe('REMOVE_MESSAGES', () => {
});
});
// ── GAME_CREATED ──────────────────────────────────────────────────────────────
describe('GAME_CREATED', () => {
it('returns state unchanged', () => {
const state = makeRoomsState();
const result = roomsReducer(state, { type: Types.GAME_CREATED, roomId: 1 });
expect(result).toBe(state);
});
});
// ── JOINED_GAME ───────────────────────────────────────────────────────────────
describe('JOINED_GAME', () => {

View file

@ -1,9 +1,10 @@
import * as _ from 'lodash';
import { GameSortField, UserSortField, SortDirection } from 'types';
import { GameSortField, Room, UserSortField, SortDirection } from 'types';
import { SortUtil } from '../common';
import { normalizeGameObject, normalizeGametypeMap, normalizeRoomInfo, normalizeUserMessage, SortUtil } from '../common';
import { RoomsAction } from './rooms.actions';
import { RoomsState } from './rooms.interfaces'
import { MAX_ROOM_MESSAGES, Types } from './rooms.types';
@ -23,7 +24,7 @@ const initialState: RoomsState = {
}
};
export const roomsReducer = (state = initialState, action: any) => {
export const roomsReducer = (state = initialState, action: RoomsAction) => {
switch (action.type) {
case Types.CLEAR_STORE: {
return {
@ -36,20 +37,21 @@ export const roomsReducer = (state = initialState, action: any) => {
...state.rooms
};
// Server does not send everything on updates
_.each(action.rooms, (room, order) => {
const { roomId } = room;
// Server does not send everything on updates — preserve existing gameList/userList
_.each(action.rooms, (rawRoom, order) => {
const { gameList: _g, gametypeList, userList: _u, ...roomMeta } = rawRoom;
const { roomId } = roomMeta;
const existing = rooms[roomId] || {};
const update = { ...room };
delete update.gameList;
delete update.gametypeList;
delete update.userList;
const gametypeMap = normalizeGametypeMap(gametypeList);
rooms[roomId] = {
...existing,
...update,
order
...(existing as Room),
...roomMeta,
gametypeMap,
gameList: (existing as Room).gameList,
userList: (existing as Room).userList,
order,
};
});
@ -57,9 +59,10 @@ export const roomsReducer = (state = initialState, action: any) => {
}
case Types.JOIN_ROOM: {
const { roomInfo } = action;
const { roomInfo: rawRoomInfo } = action;
const { joinedRoomIds, rooms, sortGamesBy, sortUsersBy } = state;
const roomInfo = normalizeRoomInfo(rawRoomInfo);
const { roomId } = roomInfo;
const gameList = [
@ -125,8 +128,8 @@ export const roomsReducer = (state = initialState, action: any) => {
roomMessages.shift();
}
message.timeReceived = new Date().getTime();
roomMessages.push(message);
const normalized = normalizeUserMessage({ ...message, timeReceived: Date.now() });
roomMessages.push(normalized);
return {
...state,
@ -150,8 +153,12 @@ export const roomsReducer = (state = initialState, action: any) => {
return { ...state };
}
// Normalize incoming raw proto games using the room's gametypeMap
const gametypeMap = room.gametypeMap ?? {};
const normalizedGames = games.map(g => normalizeGameObject(g, gametypeMap));
// Create map of games with update objects
const toUpdate = games.reduce((map, game) => {
const toUpdate = normalizedGames.reduce((map, game) => {
map[game.gameId] = game;
return map;
}, {});
@ -320,6 +327,10 @@ export const roomsReducer = (state = initialState, action: any) => {
}
}
// Signal-only — no state mutation needed; explicit for discriminated-union exhaustiveness
case Types.GAME_CREATED:
return state;
default:
return state;
}

View file

@ -92,16 +92,14 @@ describe('Selectors', () => {
});
it('getRoomGames → returns gameList for roomId', () => {
const games = [makeGame()];
const room = makeRoom({ roomId: 1, gameList: games });
const room = makeRoom({ roomId: 1, gameList: [makeGame()] });
const state = makeRoomsState({ rooms: { 1: room } });
expect(Selectors.getRoomGames(rootState(state), 1)).toBe(games);
expect(Selectors.getRoomGames(rootState(state), 1)).toBe(room.gameList);
});
it('getRoomUsers → returns userList for roomId', () => {
const users = [makeUser()];
const room = makeRoom({ roomId: 1, userList: users });
const room = makeRoom({ roomId: 1, userList: [makeUser()] });
const state = makeRoomsState({ rooms: { 1: room } });
expect(Selectors.getRoomUsers(rootState(state), 1)).toBe(users);
expect(Selectors.getRoomUsers(rootState(state), 1)).toBe(room.userList);
});
});

View file

@ -1,4 +1,5 @@
import * as _ from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import { RoomsState } from './rooms.interfaces';
interface State {
@ -16,15 +17,15 @@ export const Selectors = {
getSortGamesBy: ({ rooms: { sortGamesBy } }: State) => sortGamesBy,
getSortUsersBy: ({ rooms: { sortUsersBy } }: State) => sortUsersBy,
getJoinedRooms: (state: State) => {
const joined = Selectors.getJoinedRoomIds(state);
return _.filter(Selectors.getRooms(state), room => joined[room.roomId]);
},
getJoinedRooms: createSelector(
[(state: State) => state.rooms.rooms, (state: State) => state.rooms.joinedRoomIds],
(rooms, joined) => _.filter(rooms, room => joined[room.roomId])
),
getJoinedGames: (state: State, roomId: number) => {
const joined = Selectors.getJoinedGameIds(state)[roomId];
return _.filter(Selectors.getGames(state)[roomId], game => joined[game.gameId]);
},
getJoinedGames: createSelector(
[(state: State, roomId: number) => state.rooms.games[roomId], (state: State, roomId: number) => state.rooms.joinedGameIds[roomId]],
(games, joined) => _.filter(games, game => joined[game.gameId])
),
getRoomMessages: (state: State, roomId: number) => Selectors.getMessages(state)[roomId],
getRoomGames: (state: State, roomId: number) => Selectors.getRooms(state)[roomId].gameList,

View file

@ -11,6 +11,6 @@ export const Types = {
REMOVE_MESSAGES: '[Rooms] Remove Messages',
GAME_CREATED: '[Rooms] Game Created',
JOINED_GAME: '[Rooms] Joined Game',
};
} as const;
export const MAX_ROOM_MESSAGES = 1000;

View file

@ -1,16 +1,13 @@
import { combineReducers } from 'redux';
import { combineReducers } from '@reduxjs/toolkit';
import { gamesReducer } from './game';
import { roomsReducer } from './rooms';
import { serverReducer } from './server';
import { reducer as formReducer } from 'redux-form'
import { actionReducer } from './actions'
import { actionReducer } from './actions';
export default combineReducers({
games: gamesReducer,
rooms: roomsReducer,
server: serverReducer,
form: formReducer,
action: actionReducer
});

View file

@ -2,7 +2,9 @@ import {
BanHistoryItem,
DeckList,
DeckStorageTreeItem,
Game,
LogItem,
ProtoInit,
ReplayMatch,
SortDirection,
StatusEnum,
@ -12,20 +14,30 @@ import {
WarnHistoryItem,
WarnListItem,
} from 'types';
import { create } from '@bufbuild/protobuf';
import { ServerInfo_GameSchema } from 'generated/proto/serverinfo_game_pb';
import { ServerInfo_UserSchema } from 'generated/proto/serverinfo_user_pb';
import { ServerInfo_ReplayMatchSchema } from 'generated/proto/serverinfo_replay_match_pb';
import { ServerInfo_ChatMessageSchema } from 'generated/proto/serverinfo_chat_message_pb';
import { ServerInfo_BanSchema } from 'generated/proto/serverinfo_ban_pb';
import { ServerInfo_WarningSchema } from 'generated/proto/serverinfo_warning_pb';
import { Response_WarnListSchema } from 'generated/proto/response_warn_list_pb';
import { ServerInfo_DeckStorage_TreeItemSchema, ServerInfo_DeckStorage_FolderSchema } from 'generated/proto/serverinfo_deckstorage_pb';
import { Response_DeckListSchema } from 'generated/proto/response_deck_list_pb';
import { ServerState } from '../server.interfaces';
export function makeUser(overrides: Partial<User> = {}): User {
return {
export function makeUser(overrides: ProtoInit<User> = {}): User {
return create(ServerInfo_UserSchema, {
name: 'TestUser',
accountageSecs: 0n,
privlevel: '',
userLevel: 0,
...overrides,
};
});
}
export function makeLogItem(overrides: Partial<LogItem> = {}): LogItem {
return {
export function makeLogItem(overrides: ProtoInit<LogItem> = {}): LogItem {
return create(ServerInfo_ChatMessageSchema, {
message: '',
senderId: '',
senderIp: '',
@ -35,11 +47,11 @@ export function makeLogItem(overrides: Partial<LogItem> = {}): LogItem {
targetType: '',
time: '',
...overrides,
};
});
}
export function makeBanHistoryItem(overrides: Partial<BanHistoryItem> = {}): BanHistoryItem {
return {
export function makeBanHistoryItem(overrides: ProtoInit<BanHistoryItem> = {}): BanHistoryItem {
return create(ServerInfo_BanSchema, {
adminId: '',
adminName: '',
banTime: '',
@ -47,47 +59,45 @@ export function makeBanHistoryItem(overrides: Partial<BanHistoryItem> = {}): Ban
banReason: '',
visibleReason: '',
...overrides,
};
});
}
export function makeWarnHistoryItem(overrides: Partial<WarnHistoryItem> = {}): WarnHistoryItem {
return {
export function makeWarnHistoryItem(overrides: ProtoInit<WarnHistoryItem> = {}): WarnHistoryItem {
return create(ServerInfo_WarningSchema, {
userName: '',
adminName: '',
reason: '',
timeOf: '',
...overrides,
};
});
}
export function makeWarnListItem(overrides: Partial<WarnListItem> = {}): WarnListItem {
return {
warning: '',
export function makeWarnListItem(overrides: ProtoInit<WarnListItem> = {}): WarnListItem {
return create(Response_WarnListSchema, {
warning: [],
userName: '',
userClientid: '',
...overrides,
};
});
}
export function makeDeckTreeItem(overrides: Partial<DeckStorageTreeItem> = {}): DeckStorageTreeItem {
return {
export function makeDeckTreeItem(overrides: ProtoInit<DeckStorageTreeItem> = {}): DeckStorageTreeItem {
return create(ServerInfo_DeckStorage_TreeItemSchema, {
id: 1,
name: 'item',
file: { creationTime: 0 },
folder: null,
...overrides,
};
});
}
export function makeDeckList(overrides: Partial<DeckList> = {}): DeckList {
return {
root: { items: [] },
export function makeDeckList(overrides: ProtoInit<DeckList> = {}): DeckList {
return create(Response_DeckListSchema, {
root: create(ServerInfo_DeckStorage_FolderSchema, { items: [] }),
...overrides,
};
});
}
export function makeReplayMatch(overrides: Partial<ReplayMatch> = {}): ReplayMatch {
return {
export function makeReplayMatch(overrides: ProtoInit<ReplayMatch> = {}): ReplayMatch {
return create(ServerInfo_ReplayMatchSchema, {
gameId: 1,
roomName: 'Test Room',
timeStarted: 0,
@ -97,7 +107,11 @@ export function makeReplayMatch(overrides: Partial<ReplayMatch> = {}): ReplayMat
doNotHide: false,
replayList: [],
...overrides,
};
});
}
export function makeGame(overrides: Partial<Game> = {}): Game {
return { ...create(ServerInfo_GameSchema, { description: '' }), gameType: '', ...overrides };
}
export function makeConnectOptions(overrides: Partial<WebSocketConnectOptions> = {}): WebSocketConnectOptions {
@ -148,6 +162,7 @@ export function makeServerState(overrides: Partial<ServerState> = {}): ServerSta
replays: [],
backendDecks: null,
gamesOfUser: {},
registrationError: null,
...overrides,
};
}

View file

@ -1,11 +1,16 @@
import { Actions } from './server.actions';
import { Types } from './server.types';
import { create } from '@bufbuild/protobuf';
import { Event_NotifyUserSchema } from 'generated/proto/event_notify_user_pb';
import { Event_ServerShutdownSchema } from 'generated/proto/event_server_shutdown_pb';
import { Event_UserMessageSchema } from 'generated/proto/event_user_message_pb';
import {
makeBanHistoryItem,
makeConnectOptions,
makeDeckList,
makeDeckTreeItem,
makeReplayMatch,
makeGame,
makeUser,
makeWarnHistoryItem,
makeWarnListItem,
@ -107,7 +112,7 @@ describe('Actions', () => {
});
it('viewLogs', () => {
const logs = { room: [], game: [], chat: [] };
const logs = [{ targetType: 'room' }] as any[];
expect(Actions.viewLogs(logs)).toEqual({ type: Types.VIEW_LOGS, logs });
});
@ -124,7 +129,11 @@ describe('Actions', () => {
});
it('registrationFailed', () => {
expect(Actions.registrationFailed('err')).toEqual({ type: Types.REGISTRATION_FAILED, error: 'err' });
expect(Actions.registrationFailed('err', 999)).toEqual({ type: Types.REGISTRATION_FAILED, reason: 'err', endTime: 999 });
});
it('registrationFailed without endTime', () => {
expect(Actions.registrationFailed('err')).toEqual({ type: Types.REGISTRATION_FAILED, reason: 'err', endTime: undefined });
});
it('registrationEmailError', () => {
@ -209,17 +218,17 @@ describe('Actions', () => {
});
it('notifyUser', () => {
const notification = { type: 1, warningReason: '', customTitle: '', customContent: '' };
const notification = create(Event_NotifyUserSchema, { type: 1, warningReason: '', customTitle: '', customContent: '' });
expect(Actions.notifyUser(notification)).toEqual({ type: Types.NOTIFY_USER, notification });
});
it('serverShutdown', () => {
const data = { reason: 'maintenance', minutes: 5 };
const data = create(Event_ServerShutdownSchema, { reason: 'maintenance', minutes: 5 });
expect(Actions.serverShutdown(data)).toEqual({ type: Types.SERVER_SHUTDOWN, data });
});
it('userMessage', () => {
const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'hey' };
const messageData = create(Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'hey' });
expect(Actions.userMessage(messageData)).toEqual({ type: Types.USER_MESSAGE, messageData });
});
@ -347,7 +356,8 @@ describe('Actions', () => {
});
it('gamesOfUser', () => {
const games = [{ gameId: 1 }] as any;
expect(Actions.gamesOfUser('alice', games)).toEqual({ type: Types.GAMES_OF_USER, userName: 'alice', games });
const games = [makeGame({ gameId: 1 })];
const gametypeMap = { 1: 'Standard' };
expect(Actions.gamesOfUser('alice', games, gametypeMap)).toEqual({ type: Types.GAMES_OF_USER, userName: 'alice', games, gametypeMap });
});
});

View file

@ -1,4 +1,10 @@
import { DeckList, DeckStorageTreeItem, Game, ReplayMatch, WebSocketConnectOptions } from 'types';
import {
BanHistoryItem, DeckList, DeckStorageTreeItem, GametypeMap, LogItem, ReplayMatch,
User, WebSocketConnectOptions, WarnHistoryItem, WarnListItem
} from 'types';
import type { ServerInfo_Game } from 'generated/proto/serverinfo_game_pb';
import { NotifyUserData, ServerShutdownData, UserMessageData } from 'websocket/events/session/interfaces';
import { ServerStateStatus } from './server.interfaces';
import { Types } from './server.types';
export const Actions = {
@ -15,7 +21,7 @@ export const Actions = {
loginFailed: () => ({
type: Types.LOGIN_FAILED,
}),
connectionClosed: reason => ({
connectionClosed: (reason: number) => ({
type: Types.CONNECTION_CLOSED,
reason
}),
@ -28,59 +34,59 @@ export const Actions = {
testConnectionFailed: () => ({
type: Types.TEST_CONNECTION_FAILED,
}),
serverMessage: message => ({
serverMessage: (message: string) => ({
type: Types.SERVER_MESSAGE,
message
}),
updateBuddyList: buddyList => ({
updateBuddyList: (buddyList: User[]) => ({
type: Types.UPDATE_BUDDY_LIST,
buddyList
}),
addToBuddyList: user => ({
addToBuddyList: (user: User) => ({
type: Types.ADD_TO_BUDDY_LIST,
user
}),
removeFromBuddyList: userName => ({
removeFromBuddyList: (userName: string) => ({
type: Types.REMOVE_FROM_BUDDY_LIST,
userName
}),
updateIgnoreList: ignoreList => ({
updateIgnoreList: (ignoreList: User[]) => ({
type: Types.UPDATE_IGNORE_LIST,
ignoreList
}),
addToIgnoreList: user => ({
addToIgnoreList: (user: User) => ({
type: Types.ADD_TO_IGNORE_LIST,
user
}),
removeFromIgnoreList: userName => ({
removeFromIgnoreList: (userName: string) => ({
type: Types.REMOVE_FROM_IGNORE_LIST,
userName
}),
updateInfo: info => ({
updateInfo: (info: { name: string; version: string }) => ({
type: Types.UPDATE_INFO,
info
}),
updateStatus: status => ({
updateStatus: (status: ServerStateStatus) => ({
type: Types.UPDATE_STATUS,
status
}),
updateUser: user => ({
updateUser: (user: User) => ({
type: Types.UPDATE_USER,
user
}),
updateUsers: users => ({
updateUsers: (users: User[]) => ({
type: Types.UPDATE_USERS,
users
}),
userJoined: user => ({
userJoined: (user: User) => ({
type: Types.USER_JOINED,
user
}),
userLeft: name => ({
userLeft: (name: string) => ({
type: Types.USER_LEFT,
name
}),
viewLogs: logs => ({
viewLogs: (logs: LogItem[]) => ({
type: Types.VIEW_LOGS,
logs
}),
@ -93,22 +99,26 @@ export const Actions = {
registrationSuccess: () => ({
type: Types.REGISTRATION_SUCCESS,
}),
registrationFailed: (error) => ({
registrationFailed: (reason: string, endTime?: number) => ({
type: Types.REGISTRATION_FAILED,
error
reason,
endTime,
}),
registrationEmailError: (error) => ({
registrationEmailError: (error: string) => ({
type: Types.REGISTRATION_EMAIL_ERROR,
error
}),
registrationPasswordError: (error) => ({
registrationPasswordError: (error: string) => ({
type: Types.REGISTRATION_PASSWORD_ERROR,
error
}),
registrationUserNameError: (error) => ({
registrationUserNameError: (error: string) => ({
type: Types.REGISTRATION_USERNAME_ERROR,
error
}),
clearRegistrationErrors: () => ({
type: Types.CLEAR_REGISTRATION_ERRORS,
}),
accountAwaitingActivation: (options: WebSocketConnectOptions) => ({
type: Types.ACCOUNT_AWAITING_ACTIVATION,
options
@ -131,7 +141,7 @@ export const Actions = {
resetPasswordSuccess: () => ({
type: Types.RESET_PASSWORD_SUCCESS,
}),
adjustMod: (userName, shouldBeMod, shouldBeJudge) => ({
adjustMod: (userName: string, shouldBeMod: boolean, shouldBeJudge: boolean) => ({
type: Types.ADJUST_MOD,
userName,
shouldBeMod,
@ -149,59 +159,59 @@ export const Actions = {
accountPasswordChange: () => ({
type: Types.ACCOUNT_PASSWORD_CHANGE,
}),
accountEditChanged: (user) => ({
accountEditChanged: (user: Partial<User>) => ({
type: Types.ACCOUNT_EDIT_CHANGED,
user,
}),
accountImageChanged: (user) => ({
accountImageChanged: (user: Partial<User>) => ({
type: Types.ACCOUNT_IMAGE_CHANGED,
user,
}),
getUserInfo: (userInfo) => ({
getUserInfo: (userInfo: User) => ({
type: Types.GET_USER_INFO,
userInfo,
}),
notifyUser: (notification) => ({
notifyUser: (notification: NotifyUserData) => ({
type: Types.NOTIFY_USER,
notification,
}),
serverShutdown: (data) => ({
serverShutdown: (data: ServerShutdownData) => ({
type: Types.SERVER_SHUTDOWN,
data,
}),
userMessage: (messageData) => ({
userMessage: (messageData: UserMessageData) => ({
type: Types.USER_MESSAGE,
messageData,
}),
addToList: (list, userName) => ({
addToList: (list: string, userName: string) => ({
type: Types.ADD_TO_LIST,
list,
userName,
}),
removeFromList: (list, userName) => ({
removeFromList: (list: string, userName: string) => ({
type: Types.REMOVE_FROM_LIST,
list,
userName,
}),
banFromServer: (userName) => ({
banFromServer: (userName: string) => ({
type: Types.BAN_FROM_SERVER,
userName,
}),
banHistory: (userName, banHistory) => ({
banHistory: (userName: string, banHistory: BanHistoryItem[]) => ({
type: Types.BAN_HISTORY,
userName,
banHistory,
}),
warnHistory: (userName, warnHistory) => ({
warnHistory: (userName: string, warnHistory: WarnHistoryItem[]) => ({
type: Types.WARN_HISTORY,
userName,
warnHistory,
}),
warnListOptions: (warnList) => ({
warnListOptions: (warnList: WarnListItem[]) => ({
type: Types.WARN_LIST_OPTIONS,
warnList,
}),
warnUser: (userName) => ({
warnUser: (userName: string) => ({
type: Types.WARN_USER,
userName,
}),
@ -234,5 +244,8 @@ export const Actions = {
deckDelDir: (path: string) => ({ type: Types.DECK_DEL_DIR, path }),
deckUpload: (path: string, treeItem: DeckStorageTreeItem) => ({ type: Types.DECK_UPLOAD, path, treeItem }),
deckDelete: (deckId: number) => ({ type: Types.DECK_DELETE, deckId }),
gamesOfUser: (userName: string, games: Game[]) => ({ type: Types.GAMES_OF_USER, userName, games }),
gamesOfUser: (userName: string, games: ServerInfo_Game[], gametypeMap: GametypeMap) =>
({ type: Types.GAMES_OF_USER, userName, games, gametypeMap }),
}
export type ServerAction = ReturnType<typeof Actions[keyof typeof Actions]>;

View file

@ -1,17 +1,18 @@
vi.mock('store/store', () => ({ store: { dispatch: vi.fn() } }));
vi.mock('redux-form', () => ({
reset: vi.fn((form) => ({ type: '@@redux-form/RESET', meta: { form } })),
}));
vi.mock('store', () => ({ store: { dispatch: vi.fn() } }));
import { store } from 'store/store';
import { reset } from 'redux-form';
import { store } from 'store';
import { Actions } from './server.actions';
import { Dispatch } from './server.dispatch';
import { create } from '@bufbuild/protobuf';
import { Event_NotifyUserSchema } from 'generated/proto/event_notify_user_pb';
import { Event_ServerShutdownSchema } from 'generated/proto/event_server_shutdown_pb';
import { Event_UserMessageSchema } from 'generated/proto/event_user_message_pb';
import {
makeBanHistoryItem,
makeConnectOptions,
makeDeckList,
makeDeckTreeItem,
makeGame,
makeReplayMatch,
makeUser,
makeWarnHistoryItem,
@ -68,11 +69,10 @@ describe('Dispatch', () => {
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateBuddyList(list));
});
it('addToBuddyList dispatches reset("addToBuddies") then Actions.addToBuddyList()', () => {
it('addToBuddyList dispatches Actions.addToBuddyList()', () => {
const user = makeUser();
Dispatch.addToBuddyList(user);
expect(store.dispatch).toHaveBeenNthCalledWith(1, (reset as vi.Mock)('addToBuddies'));
expect(store.dispatch).toHaveBeenNthCalledWith(2, Actions.addToBuddyList(user));
expect(store.dispatch).toHaveBeenCalledWith(Actions.addToBuddyList(user));
});
it('removeFromBuddyList dispatches Actions.removeFromBuddyList()', () => {
@ -86,11 +86,10 @@ describe('Dispatch', () => {
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateIgnoreList(list));
});
it('addToIgnoreList dispatches reset("addToIgnore") then Actions.addToIgnoreList()', () => {
it('addToIgnoreList dispatches Actions.addToIgnoreList()', () => {
const user = makeUser();
Dispatch.addToIgnoreList(user);
expect(store.dispatch).toHaveBeenNthCalledWith(1, (reset as vi.Mock)('addToIgnore'));
expect(store.dispatch).toHaveBeenNthCalledWith(2, Actions.addToIgnoreList(user));
expect(store.dispatch).toHaveBeenCalledWith(Actions.addToIgnoreList(user));
});
it('removeFromIgnoreList dispatches Actions.removeFromIgnoreList()', () => {
@ -132,7 +131,7 @@ describe('Dispatch', () => {
});
it('viewLogs dispatches Actions.viewLogs()', () => {
const logs = { room: [], game: [], chat: [] };
const logs = [{ targetType: 'room' }] as any[];
Dispatch.viewLogs(logs);
expect(store.dispatch).toHaveBeenCalledWith(Actions.viewLogs(logs));
});
@ -157,9 +156,14 @@ describe('Dispatch', () => {
expect(store.dispatch).toHaveBeenCalledWith(Actions.registrationSuccess());
});
it('registrationFailed dispatches correctly', () => {
Dispatch.registrationFailed('err');
expect(store.dispatch).toHaveBeenCalledWith(Actions.registrationFailed('err'));
it('registrationFailed passes reason and endTime to action', () => {
Dispatch.registrationFailed('reason', 999);
expect(store.dispatch).toHaveBeenCalledWith(Actions.registrationFailed('reason', 999));
});
it('registrationFailed passes reason only when no endTime', () => {
Dispatch.registrationFailed('plain reason');
expect(store.dispatch).toHaveBeenCalledWith(Actions.registrationFailed('plain reason', undefined));
});
it('registrationEmailError dispatches correctly', () => {
@ -257,19 +261,19 @@ describe('Dispatch', () => {
});
it('notifyUser dispatches correctly', () => {
const notification = { type: 1, warningReason: '', customTitle: '', customContent: '' };
const notification = create(Event_NotifyUserSchema, { type: 1, warningReason: '', customTitle: '', customContent: '' });
Dispatch.notifyUser(notification);
expect(store.dispatch).toHaveBeenCalledWith(Actions.notifyUser(notification));
});
it('serverShutdown dispatches correctly', () => {
const data = { reason: 'maintenance', minutes: 5 };
const data = create(Event_ServerShutdownSchema, { reason: 'maintenance', minutes: 5 });
Dispatch.serverShutdown(data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.serverShutdown(data));
});
it('userMessage dispatches correctly', () => {
const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'hey' };
const messageData = create(Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'hey' });
Dispatch.userMessage(messageData);
expect(store.dispatch).toHaveBeenCalledWith(Actions.userMessage(messageData));
});
@ -382,8 +386,9 @@ describe('Dispatch', () => {
});
it('gamesOfUser dispatches correctly', () => {
const games = [{ gameId: 1 }] as any;
Dispatch.gamesOfUser('alice', games);
expect(store.dispatch).toHaveBeenCalledWith(Actions.gamesOfUser('alice', games));
const games = [makeGame({ gameId: 1 })];
const gametypeMap = { 1: 'Standard' };
Dispatch.gamesOfUser('alice', games, gametypeMap);
expect(store.dispatch).toHaveBeenCalledWith(Actions.gamesOfUser('alice', games, gametypeMap));
});
});

View file

@ -1,7 +1,11 @@
import { reset } from 'redux-form';
import { Actions } from './server.actions';
import { store } from 'store';
import { DeckList, DeckStorageTreeItem, Game, ReplayMatch, WebSocketConnectOptions } from 'types';
import {
BanHistoryItem, DeckList, DeckStorageTreeItem, GametypeMap, LogItem, ReplayMatch,
User, WarnHistoryItem, WarnListItem, WebSocketConnectOptions
} from 'types';
import type { ServerInfo_Game } from 'generated/proto/serverinfo_game_pb';
import { NotifyUserData, ServerShutdownData, UserMessageData } from 'websocket/events/session/interfaces';
export const Dispatch = {
initialized: () => {
@ -10,13 +14,13 @@ export const Dispatch = {
clearStore: () => {
store.dispatch(Actions.clearStore());
},
loginSuccessful: options => {
loginSuccessful: (options: WebSocketConnectOptions) => {
store.dispatch(Actions.loginSuccessful(options));
},
loginFailed: () => {
store.dispatch(Actions.loginFailed());
},
connectionClosed: reason => {
connectionClosed: (reason: number) => {
store.dispatch(Actions.connectionClosed(reason));
},
connectionFailed: () => {
@ -28,57 +32,55 @@ export const Dispatch = {
testConnectionFailed: () => {
store.dispatch(Actions.testConnectionFailed());
},
updateBuddyList: buddyList => {
updateBuddyList: (buddyList: User[]) => {
store.dispatch(Actions.updateBuddyList(buddyList));
},
addToBuddyList: user => {
store.dispatch(reset('addToBuddies'));
addToBuddyList: (user: User) => {
store.dispatch(Actions.addToBuddyList(user));
},
removeFromBuddyList: userName => {
removeFromBuddyList: (userName: string) => {
store.dispatch(Actions.removeFromBuddyList(userName));
},
updateIgnoreList: ignoreList => {
updateIgnoreList: (ignoreList: User[]) => {
store.dispatch(Actions.updateIgnoreList(ignoreList));
},
addToIgnoreList: user => {
store.dispatch(reset('addToIgnore'));
addToIgnoreList: (user: User) => {
store.dispatch(Actions.addToIgnoreList(user));
},
removeFromIgnoreList: userName => {
removeFromIgnoreList: (userName: string) => {
store.dispatch(Actions.removeFromIgnoreList(userName));
},
updateInfo: (name, version) => {
updateInfo: (name: string, version: string) => {
store.dispatch(Actions.updateInfo({
name,
version
}));
},
updateStatus: (state, description) => {
updateStatus: (state: number, description: string) => {
store.dispatch(Actions.updateStatus({
state,
description
}));
},
updateUser: user => {
updateUser: (user: User) => {
store.dispatch(Actions.updateUser(user));
},
updateUsers: users => {
updateUsers: (users: User[]) => {
store.dispatch(Actions.updateUsers(users));
},
userJoined: user => {
userJoined: (user: User) => {
store.dispatch(Actions.userJoined(user));
},
userLeft: name => {
userLeft: (name: string) => {
store.dispatch(Actions.userLeft(name));
},
viewLogs: name => {
store.dispatch(Actions.viewLogs(name));
viewLogs: (logs: LogItem[]) => {
store.dispatch(Actions.viewLogs(logs));
},
clearLogs: () => {
store.dispatch(Actions.clearLogs());
},
serverMessage: message => {
serverMessage: (message: string) => {
store.dispatch(Actions.serverMessage(message));
},
registrationRequiresEmail: () => {
@ -87,16 +89,19 @@ export const Dispatch = {
registrationSuccess: () => {
store.dispatch(Actions.registrationSuccess())
},
registrationFailed: (error) => {
store.dispatch(Actions.registrationFailed(error));
registrationFailed: (reason: string, endTime?: number) => {
store.dispatch(Actions.registrationFailed(reason, endTime));
},
registrationEmailError: (error) => {
clearRegistrationErrors: () => {
store.dispatch(Actions.clearRegistrationErrors());
},
registrationEmailError: (error: string) => {
store.dispatch(Actions.registrationEmailError(error));
},
registrationPasswordError: (error) => {
registrationPasswordError: (error: string) => {
store.dispatch(Actions.registrationPasswordError(error));
},
registrationUserNameError: (error) => {
registrationUserNameError: (error: string) => {
store.dispatch(Actions.registrationUserNameError(error));
},
accountAwaitingActivation: (options: WebSocketConnectOptions) => {
@ -120,7 +125,7 @@ export const Dispatch = {
resetPasswordSuccess: () => {
store.dispatch(Actions.resetPasswordSuccess());
},
adjustMod: (userName, shouldBeMod, shouldBeJudge) => {
adjustMod: (userName: string, shouldBeMod: boolean, shouldBeJudge: boolean) => {
store.dispatch(Actions.adjustMod(userName, shouldBeMod, shouldBeJudge));
},
reloadConfig: () => {
@ -135,43 +140,43 @@ export const Dispatch = {
accountPasswordChange: () => {
store.dispatch(Actions.accountPasswordChange());
},
accountEditChanged: (user) => {
accountEditChanged: (user: Partial<User>) => {
store.dispatch(Actions.accountEditChanged(user));
},
accountImageChanged: (user) => {
accountImageChanged: (user: Partial<User>) => {
store.dispatch(Actions.accountImageChanged(user));
},
getUserInfo: (userInfo) => {
getUserInfo: (userInfo: User) => {
store.dispatch(Actions.getUserInfo(userInfo));
},
notifyUser: (notification) => {
notifyUser: (notification: NotifyUserData) => {
store.dispatch(Actions.notifyUser(notification))
},
serverShutdown: (data) => {
serverShutdown: (data: ServerShutdownData) => {
store.dispatch(Actions.serverShutdown(data))
},
userMessage: (messageData) => {
userMessage: (messageData: UserMessageData) => {
store.dispatch(Actions.userMessage(messageData))
},
addToList: (list, userName) => {
addToList: (list: string, userName: string) => {
store.dispatch(Actions.addToList(list, userName))
},
removeFromList: (list, userName) => {
removeFromList: (list: string, userName: string) => {
store.dispatch(Actions.removeFromList(list, userName))
},
banFromServer: (userName) => {
banFromServer: (userName: string) => {
store.dispatch(Actions.banFromServer(userName));
},
banHistory: (userName, banHistory) => {
banHistory: (userName: string, banHistory: BanHistoryItem[]) => {
store.dispatch(Actions.banHistory(userName, banHistory))
},
warnHistory: (userName, warnHistory) => {
warnHistory: (userName: string, warnHistory: WarnHistoryItem[]) => {
store.dispatch(Actions.warnHistory(userName, warnHistory))
},
warnListOptions: (warnList) => {
warnListOptions: (warnList: WarnListItem[]) => {
store.dispatch(Actions.warnListOptions(warnList))
},
warnUser: (userName) => {
warnUser: (userName: string) => {
store.dispatch(Actions.warnUser(userName))
},
grantReplayAccess: (replayId: number, moderatorName: string) => {
@ -213,7 +218,7 @@ export const Dispatch = {
deckDelete: (deckId: number) => {
store.dispatch(Actions.deckDelete(deckId));
},
gamesOfUser: (userName: string, games: Game[]) => {
store.dispatch(Actions.gamesOfUser(userName, games));
gamesOfUser: (userName: string, games: ServerInfo_Game[], gametypeMap: GametypeMap) => {
store.dispatch(Actions.gamesOfUser(userName, games, gametypeMap));
},
}

View file

@ -72,6 +72,7 @@ export interface ServerState {
replays: ReplayMatch[];
backendDecks: DeckList | null;
gamesOfUser: { [userName: string]: Game[] };
registrationError: string | null;
}
export interface ServerStateStatus {

View file

@ -1,4 +1,7 @@
import { StatusEnum, UserLevelFlag } from 'types';
import { create } from '@bufbuild/protobuf';
import { Event_UserMessageSchema } from 'generated/proto/event_user_message_pb';
import { ServerInfo_DeckStorage_FolderSchema, ServerInfo_DeckStorage_TreeItemSchema } from 'generated/proto/serverinfo_deckstorage_pb';
import { serverReducer } from './server.reducer';
import { Types } from './server.types';
import {
@ -6,6 +9,7 @@ import {
makeConnectOptions,
makeDeckList,
makeDeckTreeItem,
makeGame,
makeLogItem,
makeReplayMatch,
makeServerState,
@ -71,6 +75,35 @@ describe('Account & Connection', () => {
});
});
// ── Registration ──────────────────────────────────────────────────────────────
describe('Registration', () => {
it('REGISTRATION_FAILED → stores normalized error (plain reason)', () => {
const state = makeServerState({ registrationError: null });
const result = serverReducer(state, { type: Types.REGISTRATION_FAILED, reason: 'Server is disabled', endTime: undefined });
expect(result.registrationError).toBe('Server is disabled');
});
it('REGISTRATION_FAILED → normalizes banned error when endTime is given', () => {
const state = makeServerState({ registrationError: null });
const result = serverReducer(state, { type: Types.REGISTRATION_FAILED, reason: 'bad actor', endTime: Date.now() + 100_000 });
expect(result.registrationError).toContain('banned');
expect(result.registrationError).toContain('bad actor');
});
it('CLEAR_REGISTRATION_ERRORS → sets registrationError to null', () => {
const state = makeServerState({ registrationError: 'some error' });
const result = serverReducer(state, { type: Types.CLEAR_REGISTRATION_ERRORS });
expect(result.registrationError).toBeNull();
});
it('CLEAR_STORE → resets registrationError to null', () => {
const state = makeServerState({ registrationError: 'stale error' });
const result = serverReducer(state, { type: Types.CLEAR_STORE });
expect(result.registrationError).toBeNull();
});
});
// ── Server Info & Status ──────────────────────────────────────────────────────
describe('Server Info & Status', () => {
@ -205,11 +238,11 @@ describe('Ignore List', () => {
// ── Logs ─────────────────────────────────────────────────────────────────────
describe('Logs', () => {
it('VIEW_LOGS → replaces logs entirely', () => {
const logs = { room: [makeLogItem()], game: [], chat: [] };
it('VIEW_LOGS → groups LogItem[] into room/game/chat buckets', () => {
const log = makeLogItem({ targetType: 'room' });
const state = makeServerState();
const result = serverReducer(state, { type: Types.VIEW_LOGS, logs });
expect(result.logs).toEqual(logs);
const result = serverReducer(state, { type: Types.VIEW_LOGS, logs: [log] });
expect(result.logs.room).toEqual([log]);
});
it('CLEAR_LOGS → resets logs to empty arrays', () => {
@ -241,12 +274,12 @@ describe('Messaging', () => {
});
it('USER_MESSAGE → appends to existing messages for that user', () => {
const existingMsg = { senderName: 'Alice', receiverName: 'Bob', message: 'first' };
const existingMsg = create(Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'first' });
const state = makeServerState({
user: makeUser({ name: 'Bob' }),
messages: { Alice: [existingMsg] },
});
const newMsg = { senderName: 'Alice', receiverName: 'Bob', message: 'second' };
const newMsg = create(Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'second' });
const result = serverReducer(state, { type: Types.USER_MESSAGE, messageData: newMsg });
expect(result.messages['Alice']).toHaveLength(2);
});
@ -442,8 +475,12 @@ describe('Deck Storage', () => {
});
it('DECK_UPLOAD with nested path → inserts into matching subfolder', () => {
const subfolder = { id: 0, name: 'myDecks', file: null, folder: { items: [] } };
const state = makeServerState({ backendDecks: { root: { items: [subfolder] } } });
const subfolder = create(ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: 'myDecks', folder: create(ServerInfo_DeckStorage_FolderSchema, { items: [] })
});
const state = makeServerState({
backendDecks: makeDeckList({ root: create(ServerInfo_DeckStorage_FolderSchema, { items: [subfolder] }) })
});
const item = makeDeckTreeItem({ name: 'new.cod' });
const result = serverReducer(state, { type: Types.DECK_UPLOAD, path: 'myDecks', treeItem: item });
const folder = result.backendDecks.root.items.find(i => i.name === 'myDecks');
@ -468,15 +505,19 @@ describe('Deck Storage', () => {
it('DECK_DELETE → removes item by id from tree', () => {
const item = makeDeckTreeItem({ id: 7 });
const state = makeServerState({ backendDecks: { root: { items: [item] } } });
const state = makeServerState({ backendDecks: makeDeckList({ root: create(ServerInfo_DeckStorage_FolderSchema, { items: [item] }) }) });
const result = serverReducer(state, { type: Types.DECK_DELETE, deckId: 7 });
expect(result.backendDecks.root.items).toHaveLength(0);
});
it('DECK_DELETE → recursively removes item nested inside a subfolder', () => {
const nested = makeDeckTreeItem({ id: 9, name: 'nested.cod' });
const subfolder = { id: 0, name: 'sub', file: null, folder: { items: [nested] } };
const state = makeServerState({ backendDecks: { root: { items: [subfolder] } } });
const subfolder = create(ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: 'sub', folder: create(ServerInfo_DeckStorage_FolderSchema, { items: [nested] })
});
const state = makeServerState({
backendDecks: makeDeckList({ root: create(ServerInfo_DeckStorage_FolderSchema, { items: [subfolder] }) })
});
const result = serverReducer(state, { type: Types.DECK_DELETE, deckId: 9 });
expect(result.backendDecks.root.items[0].folder.items).toHaveLength(0);
});
@ -492,12 +533,16 @@ describe('Deck Storage', () => {
const result = serverReducer(state, { type: Types.DECK_NEW_DIR, path: '', dirName: 'myDir' });
expect(result.backendDecks.root.items).toHaveLength(1);
expect(result.backendDecks.root.items[0].name).toBe('myDir');
expect(result.backendDecks.root.items[0].folder).toEqual({ items: [] });
expect(result.backendDecks.root.items[0].folder.items).toEqual([]);
});
it('DECK_NEW_DIR nested → inserts folder inside matching subfolder', () => {
const subfolder = { id: 0, name: 'parent', file: null, folder: { items: [] } };
const state = makeServerState({ backendDecks: { root: { items: [subfolder] } } });
const subfolder = create(ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: 'parent', folder: create(ServerInfo_DeckStorage_FolderSchema, { items: [] })
});
const state = makeServerState({
backendDecks: makeDeckList({ root: create(ServerInfo_DeckStorage_FolderSchema, { items: [subfolder] }) })
});
const result = serverReducer(state, { type: Types.DECK_NEW_DIR, path: 'parent', dirName: 'child' });
const parent = result.backendDecks.root.items.find(i => i.name === 'parent');
expect(parent.folder.items).toHaveLength(1);
@ -511,23 +556,37 @@ describe('Deck Storage', () => {
});
it('DECK_DEL_DIR → removes folder from root by name', () => {
const subfolder = { id: 0, name: 'myDir', file: null, folder: { items: [] } };
const state = makeServerState({ backendDecks: { root: { items: [subfolder] } } });
const subfolder = create(ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: 'myDir', folder: create(ServerInfo_DeckStorage_FolderSchema, { items: [] })
});
const state = makeServerState({
backendDecks: makeDeckList({ root: create(ServerInfo_DeckStorage_FolderSchema, { items: [subfolder] }) })
});
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: 'myDir' });
expect(result.backendDecks.root.items).toHaveLength(0);
});
it('DECK_DEL_DIR → returns deck tree unchanged when path is empty', () => {
const subfolder = { id: 0, name: 'keep', file: null, folder: { items: [] } };
const state = makeServerState({ backendDecks: { root: { items: [subfolder] } } });
const subfolder = create(ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: 'keep', folder: create(ServerInfo_DeckStorage_FolderSchema, { items: [] })
});
const state = makeServerState({
backendDecks: makeDeckList({ root: create(ServerInfo_DeckStorage_FolderSchema, { items: [subfolder] }) })
});
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: '' });
expect(result.backendDecks.root.items).toHaveLength(1);
});
it('DECK_DEL_DIR → recursively removes nested subfolder via multi-segment path', () => {
const child = { id: 0, name: 'child', file: null, folder: { items: [] } };
const parent = { id: 0, name: 'parent', file: null, folder: { items: [child] } };
const state = makeServerState({ backendDecks: { root: { items: [parent] } } });
const child = create(ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: 'child', folder: create(ServerInfo_DeckStorage_FolderSchema, { items: [] })
});
const parent = create(ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: 'parent', folder: create(ServerInfo_DeckStorage_FolderSchema, { items: [child] })
});
const state = makeServerState({
backendDecks: makeDeckList({ root: create(ServerInfo_DeckStorage_FolderSchema, { items: [parent] }) })
});
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: 'parent/child' });
expect(result.backendDecks.root.items[0].folder.items).toHaveLength(0);
});
@ -536,25 +595,25 @@ describe('Deck Storage', () => {
// ── GAMES_OF_USER ─────────────────────────────────────────────────────────────
describe('GAMES_OF_USER', () => {
it('stores games keyed by userName', () => {
const games = [{ gameId: 5, roomId: 1 }] as any;
it('stores normalized games keyed by userName', () => {
const games = [makeGame({ gameId: 5 })];
const state = makeServerState();
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', games });
expect(result.gamesOfUser['alice']).toBe(games);
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', games, gametypeMap: {} });
expect(result.gamesOfUser['alice']).toEqual(games);
});
it('overwrites previous games for same user', () => {
const old = [{ gameId: 1 }] as any;
const fresh = [{ gameId: 2 }] as any;
const old = [makeGame({ gameId: 1 })];
const fresh = [makeGame({ gameId: 2 })];
const state = makeServerState({ gamesOfUser: { alice: old } });
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', games: fresh });
expect(result.gamesOfUser['alice']).toBe(fresh);
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', games: fresh, gametypeMap: {} });
expect(result.gamesOfUser['alice']).toEqual(fresh);
});
it('does not affect other users\' entries', () => {
const bobGames = [{ gameId: 3 }] as any;
const bobGames = [makeGame({ gameId: 3 })];
const state = makeServerState({ gamesOfUser: { bob: bobGames } });
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', games: [] });
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', games: [], gametypeMap: {} });
expect(result.gamesOfUser['bob']).toBe(bobGames);
});
});

View file

@ -1,7 +1,11 @@
import { DeckStorageFolder, DeckStorageTreeItem, SortDirection, StatusEnum, UserLevelFlag, UserSortField } from 'types';
import { create } from '@bufbuild/protobuf';
import { Response_DeckListSchema } from 'generated/proto/response_deck_list_pb';
import { ServerInfo_DeckStorage_FolderSchema, ServerInfo_DeckStorage_TreeItemSchema } from 'generated/proto/serverinfo_deckstorage_pb';
import { SortUtil } from '../common';
import { normalizeBannedUserError, normalizeGameObject, normalizeLogs, SortUtil } from '../common';
import { ServerAction } from './server.actions';
import { ServerState } from './server.interfaces'
import { Types } from './server.types';
@ -11,31 +15,33 @@ function splitPath(path: string): string[] {
function insertAtPath(folder: DeckStorageFolder, pathSegments: string[], item: DeckStorageTreeItem): DeckStorageFolder {
if (pathSegments.length === 0 || (pathSegments.length === 1 && pathSegments[0] === '')) {
return { items: [...folder.items, item] };
return create(ServerInfo_DeckStorage_FolderSchema, { items: [...folder.items, item] });
}
const [head, ...tail] = pathSegments;
const match = folder.items.find(child => child.name === head && child.folder);
if (match) {
return {
return create(ServerInfo_DeckStorage_FolderSchema, {
items: folder.items.map(child =>
child === match
? { ...child, folder: insertAtPath(child.folder!, tail, item) }
: child
),
};
});
}
const created: DeckStorageTreeItem = { id: 0, name: head, file: null, folder: insertAtPath({ items: [] }, tail, item) };
return { items: [...folder.items, created] };
const created: DeckStorageTreeItem = create(ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: head, folder: insertAtPath(create(ServerInfo_DeckStorage_FolderSchema, { items: [] }), tail, item)
});
return create(ServerInfo_DeckStorage_FolderSchema, { items: [...folder.items, created] });
}
function removeById(folder: DeckStorageFolder, id: number): DeckStorageFolder {
return {
return create(ServerInfo_DeckStorage_FolderSchema, {
items: folder.items
.filter(item => item.id !== id)
.map(item =>
item.folder ? { ...item, folder: removeById(item.folder, id) } : item
),
};
});
}
function removeByPath(folder: DeckStorageFolder, pathSegments: string[]): DeckStorageFolder {
@ -44,15 +50,17 @@ function removeByPath(folder: DeckStorageFolder, pathSegments: string[]): DeckSt
}
const [head, ...tail] = pathSegments;
if (tail.length === 0) {
return { items: folder.items.filter(item => !(item.name === head && item.folder !== null)) };
return create(ServerInfo_DeckStorage_FolderSchema, {
items: folder.items.filter(item => !(item.name === head && item.folder != null))
});
}
return {
return create(ServerInfo_DeckStorage_FolderSchema, {
items: folder.items.map(item =>
item.name === head && item.folder
? { ...item, folder: removeByPath(item.folder, tail) }
: item
),
};
});
}
const initialState: ServerState = {
@ -93,9 +101,10 @@ const initialState: ServerState = {
replays: [],
backendDecks: null,
gamesOfUser: {},
registrationError: null,
};
export const serverReducer = (state = initialState, action: any) => {
export const serverReducer = (state = initialState, action: ServerAction) => {
switch (action.type) {
case Types.INITIALIZED: {
return {
@ -271,7 +280,7 @@ export const serverReducer = (state = initialState, action: any) => {
return {
...state,
logs: {
...logs
...normalizeLogs(logs)
}
};
}
@ -424,60 +433,96 @@ export const serverReducer = (state = initialState, action: any) => {
return { ...state, backendDecks: action.deckList };
}
case Types.DECK_UPLOAD: {
if (!state.backendDecks) {
if (!state.backendDecks?.root) {
return state;
}
return {
...state,
backendDecks: {
backendDecks: create(Response_DeckListSchema, {
root: insertAtPath(state.backendDecks.root, splitPath(action.path), action.treeItem),
},
}),
};
}
case Types.DECK_DELETE: {
if (!state.backendDecks) {
if (!state.backendDecks?.root) {
return state;
}
return {
...state,
backendDecks: {
backendDecks: create(Response_DeckListSchema, {
root: removeById(state.backendDecks.root, action.deckId),
},
}),
};
}
case Types.DECK_NEW_DIR: {
if (!state.backendDecks) {
if (!state.backendDecks?.root) {
return state;
}
const newFolder: DeckStorageTreeItem = { id: 0, name: action.dirName, file: null, folder: { items: [] } };
const newFolder: DeckStorageTreeItem = create(ServerInfo_DeckStorage_TreeItemSchema, {
id: 0, name: action.dirName, folder: create(ServerInfo_DeckStorage_FolderSchema, { items: [] })
});
return {
...state,
backendDecks: {
backendDecks: create(Response_DeckListSchema, {
root: insertAtPath(state.backendDecks.root, splitPath(action.path), newFolder),
},
}),
};
}
case Types.DECK_DEL_DIR: {
if (!state.backendDecks) {
if (!state.backendDecks?.root) {
return state;
}
return {
...state,
backendDecks: {
backendDecks: create(Response_DeckListSchema, {
root: removeByPath(state.backendDecks.root, splitPath(action.path)),
},
}),
};
}
case Types.GAMES_OF_USER: {
const { userName, games } = action;
const { userName, games, gametypeMap } = action;
const normalizedGames = games.map(g => normalizeGameObject(g, gametypeMap));
return {
...state,
gamesOfUser: {
...state.gamesOfUser,
[userName]: games,
[userName]: normalizedGames,
},
};
}
case Types.REGISTRATION_FAILED: {
const error = action.endTime
? normalizeBannedUserError(action.reason, action.endTime)
: action.reason;
return { ...state, registrationError: error };
}
case Types.CLEAR_REGISTRATION_ERRORS:
return { ...state, registrationError: null };
// Signal-only action types — no state mutation, explicit for discriminated-union exhaustiveness
case Types.LOGIN_SUCCESSFUL:
case Types.LOGIN_FAILED:
case Types.CONNECTION_CLOSED:
case Types.CONNECTION_FAILED:
case Types.TEST_CONNECTION_SUCCESSFUL:
case Types.TEST_CONNECTION_FAILED:
case Types.REGISTRATION_REQUIRES_EMAIL:
case Types.REGISTRATION_SUCCESS:
case Types.REGISTRATION_EMAIL_ERROR:
case Types.REGISTRATION_PASSWORD_ERROR:
case Types.REGISTRATION_USERNAME_ERROR:
case Types.RESET_PASSWORD_REQUESTED:
case Types.RESET_PASSWORD_FAILED:
case Types.RESET_PASSWORD_CHALLENGE:
case Types.RESET_PASSWORD_SUCCESS:
case Types.RELOAD_CONFIG:
case Types.SHUTDOWN_SERVER:
case Types.UPDATE_SERVER_MESSAGE:
case Types.ACCOUNT_PASSWORD_CHANGE:
case Types.ADD_TO_LIST:
case Types.REMOVE_FROM_LIST:
case Types.GRANT_REPLAY_ACCESS:
case Types.FORCE_ACTIVATE_USER:
return state;
default:
return state;
}

View file

@ -18,4 +18,5 @@ export const Selectors = {
getIgnoreList: ({ server }: State) => server.ignoreList,
getReplays: ({ server }: State) => server.replays,
getBackendDecks: ({ server }: State) => server.backendDecks,
getRegistrationError: ({ server }: State) => server.registrationError,
}

View file

@ -28,6 +28,7 @@ export const Types = {
REGISTRATION_EMAIL_ERROR: '[Server] Registration Email Error',
REGISTRATION_PASSWORD_ERROR: '[Server] Registration Password Error',
REGISTRATION_USERNAME_ERROR: '[Server] Registration Username Error',
CLEAR_REGISTRATION_ERRORS: '[Server] Clear Registration Errors',
ACCOUNT_AWAITING_ACTIVATION: '[Server] Account Awaiting Activation',
ACCOUNT_ACTIVATION_SUCCESS: '[Server] Account Activation Success',
ACCOUNT_ACTIVATION_FAILED: '[Server] Account Activation Failed',
@ -70,4 +71,4 @@ export const Types = {
DECK_DELETE: '[Server] Deck Delete',
// User games
GAMES_OF_USER: '[Server] Games Of User',
};
} as const;

View file

@ -1,9 +1,27 @@
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { configureStore, isPlain } from '@reduxjs/toolkit';
import { isMessage } from '@bufbuild/protobuf';
import { useDispatch, useSelector } from 'react-redux';
import rootReducer from './rootReducer';
const initialState = {};
// Protobuf-es v2 messages are already plain objects (no class prototype, unlike v1).
// They carry $typeName (string, identifies the message) and $unknown (binary unknown
// fields) — both are serializable and harmless in Redux state. No conversion needed.
// Fields may include Uint8Array (bytes) and BigInt (int64/uint64), which fail Redux
// Toolkits default serializable check, so we extend it to accept these types.
function isSerializable(value: unknown): boolean {
return isPlain(value) || isMessage(value) || value instanceof Uint8Array || typeof value === 'bigint';
}
const middleware: any = [thunk];
export const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
immutableCheck: { warnAfter: 128 },
serializableCheck: { isSerializable, warnAfter: 128 },
}),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const store = createStore(rootReducer, initialState, applyMiddleware(...middleware));
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();