feat(platform): add atomic canonical platform store
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
import { signal, type Reactive, type Unsubscribe } from '../../core/reactive.ts';
|
||||
import {
|
||||
hasOwn,
|
||||
requireArray,
|
||||
requireInteger,
|
||||
requireRecord,
|
||||
requireString,
|
||||
type ParsedLoginResponse,
|
||||
type ParsedOnlinePayload,
|
||||
type ParsedRoomResponse,
|
||||
type ParsedSeatPayload,
|
||||
} from '../../protocol/contracts/index.ts';
|
||||
import type {
|
||||
InsideRoomState,
|
||||
PlatformPlayer,
|
||||
PlatformState,
|
||||
} from './platform-types.ts';
|
||||
|
||||
interface RoomBuildResult {
|
||||
readonly players: PlatformState['players'];
|
||||
readonly room: Extract<PlatformState['room'], { readonly kind: 'inside' }>;
|
||||
}
|
||||
|
||||
function requirePositiveInteger(value: unknown, path: string): number {
|
||||
const parsed = requireInteger(value, path);
|
||||
if (parsed <= 0) throw new RangeError(`${path}: expected positive integer`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requireProtocolFlag(value: unknown, path: string): number {
|
||||
const parsed = requireInteger(value, path);
|
||||
if (parsed !== 0 && parsed !== 1) {
|
||||
throw new RangeError(`${path}: expected protocol flag 0 or 1`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requireOnlineState(value: unknown, path: string): number {
|
||||
const parsed = requireInteger(value, path);
|
||||
if (parsed < 0 || parsed > 2) {
|
||||
throw new RangeError(`${path}: expected onstate 0, 1, or 2`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseRoomPlayer(value: unknown, path: string): {
|
||||
readonly player: PlatformPlayer;
|
||||
readonly isprepare: number;
|
||||
readonly onstate: number;
|
||||
} {
|
||||
const raw = requireRecord(value, path);
|
||||
const isprepare = requireProtocolFlag(raw.isprepare, `${path}.isprepare`);
|
||||
const onstate = requireOnlineState(raw.onstate, `${path}.onstate`);
|
||||
const player: PlatformPlayer = {
|
||||
playerid: requirePositiveInteger(raw.playerid, `${path}.playerid`),
|
||||
nickname: requireString(raw.nickname, `${path}.nickname`),
|
||||
avatar: requireString(raw.avatar, `${path}.avatar`),
|
||||
...(hasOwn(raw, 'ip') ? { ip: requireString(raw.ip, `${path}.ip`) } : {}),
|
||||
isprepare,
|
||||
onstate,
|
||||
};
|
||||
return { player, isprepare, onstate };
|
||||
}
|
||||
|
||||
function recursivelyFreeze<T>(value: T, seen = new WeakSet<object>()): T {
|
||||
if (typeof value !== 'object' || value === null) return value;
|
||||
if (seen.has(value)) return value;
|
||||
seen.add(value);
|
||||
for (const child of Object.values(value)) recursivelyFreeze(child, seen);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
function buildRoom(
|
||||
roomValue: unknown,
|
||||
rawValue: unknown,
|
||||
selfPlayer: PlatformPlayer,
|
||||
): RoomBuildResult {
|
||||
const room = requireRecord(roomValue, '$.room');
|
||||
const raw = requireRecord(rawValue, '$.raw');
|
||||
const roomcode = requireString(room.roomcode, '$.room.roomcode');
|
||||
const selfSeat = requireInteger(room.seat, '$.room.seat');
|
||||
const roomtype = requireArray(room.roomtype, '$.room.roomtype');
|
||||
const sourcePlayers = requireArray(room.players, '$.room.players');
|
||||
if (requireString(raw.roomcode, '$.raw.roomcode') !== roomcode) {
|
||||
throw new RangeError('$.room.roomcode: expected value parsed from raw roomcode');
|
||||
}
|
||||
if (requireInteger(raw.seat, '$.raw.seat') !== selfSeat) {
|
||||
throw new RangeError('$.room.seat: expected value parsed from raw seat');
|
||||
}
|
||||
if (raw.roomtype !== roomtype) {
|
||||
throw new RangeError('$.room.roomtype: expected canonical raw roomtype reference');
|
||||
}
|
||||
if (raw.players !== sourcePlayers) {
|
||||
throw new RangeError('$.room.players: expected canonical raw players reference');
|
||||
}
|
||||
if (selfSeat < 0 || selfSeat >= sourcePlayers.length) {
|
||||
throw new RangeError('$.room.seat: expected server seat index within players');
|
||||
}
|
||||
|
||||
let stage: number;
|
||||
if (hasOwn(raw, 'isbattle')) {
|
||||
stage = requireProtocolFlag(raw.isbattle, '$.raw.isbattle');
|
||||
} else if (hasOwn(raw, 'deskwar')) {
|
||||
stage = requireProtocolFlag(raw.deskwar, '$.raw.deskwar');
|
||||
} else {
|
||||
throw new TypeError('$.raw: expected isbattle or deskwar');
|
||||
}
|
||||
const needprepare = requireProtocolFlag(raw.needprepare, '$.raw.needprepare');
|
||||
const infinite = requireProtocolFlag(raw.infinite, '$.raw.infinite');
|
||||
|
||||
const seatPlayerIds: (number | null)[] = [];
|
||||
const readySeats: number[] = [];
|
||||
const offlineSeats: number[] = [];
|
||||
const parsedPlayers = new Map<number, PlatformPlayer>();
|
||||
|
||||
for (let seat = 0; seat < sourcePlayers.length; seat += 1) {
|
||||
const value = sourcePlayers[seat];
|
||||
if (value === null) {
|
||||
seatPlayerIds.push(null);
|
||||
continue;
|
||||
}
|
||||
const parsed = parseRoomPlayer(value, `$.room.players[${seat}]`);
|
||||
if (parsedPlayers.has(parsed.player.playerid)) {
|
||||
throw new RangeError(`$.room.players[${seat}].playerid: duplicate player seat`);
|
||||
}
|
||||
parsedPlayers.set(parsed.player.playerid, parsed.player);
|
||||
seatPlayerIds.push(parsed.player.playerid);
|
||||
if (parsed.isprepare === 1) readySeats.push(seat);
|
||||
if (parsed.onstate === 1) offlineSeats.push(seat);
|
||||
}
|
||||
|
||||
if (seatPlayerIds[selfSeat] !== selfPlayer.playerid) {
|
||||
throw new RangeError('$.room.players: self seat must contain the authenticated player');
|
||||
}
|
||||
|
||||
recursivelyFreeze(roomtype);
|
||||
const entities: Record<number, PlatformPlayer> = {};
|
||||
for (const [playerid, parsedPlayer] of parsedPlayers) {
|
||||
const player = playerid === selfPlayer.playerid
|
||||
? { ...selfPlayer, ...parsedPlayer }
|
||||
: parsedPlayer;
|
||||
entities[playerid] = Object.freeze(player);
|
||||
}
|
||||
|
||||
return {
|
||||
players: Object.freeze({
|
||||
selfPlayerId: selfPlayer.playerid,
|
||||
entities: Object.freeze(entities),
|
||||
}),
|
||||
room: Object.freeze({
|
||||
kind: 'inside',
|
||||
roomcode,
|
||||
selfSeat,
|
||||
seatPlayerIds: Object.freeze(seatPlayerIds),
|
||||
readySeats: Object.freeze(readySeats),
|
||||
offlineSeats: Object.freeze(offlineSeats),
|
||||
roomtype,
|
||||
stage,
|
||||
needprepare,
|
||||
infinite,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function requireInsideRoom(state: PlatformState): InsideRoomState {
|
||||
if (state.room.kind !== 'inside') {
|
||||
throw new Error('room: expected player to be inside a room');
|
||||
}
|
||||
return state.room;
|
||||
}
|
||||
|
||||
function parseSeatAction(value: ParsedSeatPayload): {
|
||||
readonly seat: number;
|
||||
readonly raw: Readonly<Record<string, unknown>>;
|
||||
} {
|
||||
const input = requireRecord(value, '$.event');
|
||||
const seat = requireInteger(input.seat, '$.event.seat');
|
||||
const raw = requireRecord(input.raw, '$.event.raw');
|
||||
if (requireInteger(raw.seat, '$.event.raw.seat') !== seat) {
|
||||
throw new RangeError('$.event.raw.seat: expected the parsed seat');
|
||||
}
|
||||
return { seat, raw };
|
||||
}
|
||||
|
||||
function requireOccupiedSeat(room: InsideRoomState, seat: number): number {
|
||||
if (seat < 0 || seat >= room.seatPlayerIds.length) {
|
||||
throw new RangeError('$.event.seat: expected room seat range');
|
||||
}
|
||||
const playerid = room.seatPlayerIds[seat];
|
||||
if (playerid === null) {
|
||||
throw new RangeError('$.event.seat: expected an occupied seat');
|
||||
}
|
||||
return playerid;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: PlatformState = Object.freeze({
|
||||
app: Object.freeze({ phase: 'idle' }),
|
||||
players: Object.freeze({
|
||||
selfPlayerId: null,
|
||||
entities: Object.freeze({}),
|
||||
}),
|
||||
room: Object.freeze({ kind: 'outside' }),
|
||||
});
|
||||
|
||||
export type PlatformStateListener = (state: PlatformState, previous: PlatformState) => void;
|
||||
|
||||
export class PlatformStore {
|
||||
private readonly stateCell: Reactive<PlatformState> = signal(INITIAL_STATE);
|
||||
|
||||
getState(): PlatformState {
|
||||
return this.stateCell.value;
|
||||
}
|
||||
|
||||
subscribe(listener: PlatformStateListener): Unsubscribe {
|
||||
return this.stateCell.subscribe(listener);
|
||||
}
|
||||
|
||||
applyLoginSuccess(parsed: ParsedLoginResponse): void {
|
||||
const input = requireRecord(parsed, '$.login');
|
||||
const state = requireInteger(input.state, '$.login.state');
|
||||
if (state !== 0) {
|
||||
throw new RangeError('$.login.state: expected successful state 0');
|
||||
}
|
||||
const playerid = requireInteger(input.playerid, '$.login.playerid');
|
||||
if (playerid <= 0) {
|
||||
throw new RangeError('$.login.playerid: expected positive integer');
|
||||
}
|
||||
const raw = requireRecord(input.raw, '$.login.raw');
|
||||
if (requireInteger(raw.playerid, '$.login.raw.playerid') !== playerid) {
|
||||
throw new RangeError('$.login.raw.playerid: expected the parsed playerid');
|
||||
}
|
||||
const player: PlatformPlayer = Object.freeze({
|
||||
playerid,
|
||||
nickname: requireString(raw.nickname, '$.login.raw.nickname'),
|
||||
avatar: requireString(raw.avatar, '$.login.raw.avatar'),
|
||||
ip: requireString(raw.ip, '$.login.raw.ip'),
|
||||
});
|
||||
const roomResult = input.room === null
|
||||
? {
|
||||
players: Object.freeze({
|
||||
selfPlayerId: playerid,
|
||||
entities: Object.freeze({ [playerid]: player }),
|
||||
}),
|
||||
room: Object.freeze({ kind: 'outside' as const }),
|
||||
}
|
||||
: buildRoom(input.room, raw, player);
|
||||
this.stateCell.value = Object.freeze({
|
||||
app: Object.freeze({ phase: 'logged-in' }),
|
||||
players: roomResult.players,
|
||||
room: roomResult.room,
|
||||
});
|
||||
}
|
||||
|
||||
replaceRoom(parsed: ParsedRoomResponse): void {
|
||||
const input = requireRecord(parsed, '$.response');
|
||||
if (requireInteger(input.state, '$.response.state') !== 0) {
|
||||
throw new RangeError('$.response.state: expected successful state 0');
|
||||
}
|
||||
if (input.room === null || input.room === undefined) {
|
||||
throw new TypeError('$.response.room: expected parsed room snapshot');
|
||||
}
|
||||
const previous = this.getState();
|
||||
const selfPlayerId = previous.players.selfPlayerId;
|
||||
if (selfPlayerId === null) {
|
||||
throw new Error('players.selfPlayerId: expected authenticated player');
|
||||
}
|
||||
const selfPlayer = previous.players.entities[selfPlayerId];
|
||||
if (selfPlayer === undefined) {
|
||||
throw new Error('players.entities: missing authenticated player');
|
||||
}
|
||||
const roomResult = buildRoom(input.room, input.raw, selfPlayer);
|
||||
this.stateCell.value = Object.freeze({
|
||||
app: previous.app,
|
||||
players: roomResult.players,
|
||||
room: roomResult.room,
|
||||
});
|
||||
}
|
||||
|
||||
clearRoom(): void {
|
||||
const previous = this.getState();
|
||||
requireInsideRoom(previous);
|
||||
const selfPlayerId = previous.players.selfPlayerId;
|
||||
if (selfPlayerId === null) {
|
||||
throw new Error('players.selfPlayerId: expected authenticated player');
|
||||
}
|
||||
const selfPlayer = previous.players.entities[selfPlayerId];
|
||||
if (selfPlayer === undefined) {
|
||||
throw new Error('players.entities: missing authenticated player');
|
||||
}
|
||||
this.stateCell.value = Object.freeze({
|
||||
app: previous.app,
|
||||
players: Object.freeze({
|
||||
selfPlayerId,
|
||||
entities: Object.freeze({ [selfPlayerId]: selfPlayer }),
|
||||
}),
|
||||
room: Object.freeze({ kind: 'outside' }),
|
||||
});
|
||||
}
|
||||
|
||||
playerJoin(parsed: ParsedSeatPayload): void {
|
||||
const event = parseSeatAction(parsed);
|
||||
const previous = this.getState();
|
||||
const room = requireInsideRoom(previous);
|
||||
if (event.seat < 0 || event.seat >= room.seatPlayerIds.length) {
|
||||
throw new RangeError('$.event.seat: expected occupied room seat range');
|
||||
}
|
||||
if (room.seatPlayerIds[event.seat] !== null) {
|
||||
throw new RangeError('$.event.seat: expected an empty seat');
|
||||
}
|
||||
const joined = parseRoomPlayer(event.raw, '$.event.raw');
|
||||
if (room.seatPlayerIds.includes(joined.player.playerid)) {
|
||||
throw new RangeError('$.event.raw.playerid: duplicate player seat');
|
||||
}
|
||||
|
||||
const player = Object.freeze(joined.player);
|
||||
const entities = Object.freeze({
|
||||
...previous.players.entities,
|
||||
[player.playerid]: player,
|
||||
});
|
||||
const seatPlayerIds = [...room.seatPlayerIds];
|
||||
seatPlayerIds[event.seat] = player.playerid;
|
||||
const readySeats = joined.isprepare === 1
|
||||
? [...room.readySeats, event.seat]
|
||||
: [...room.readySeats];
|
||||
const offlineSeats = joined.onstate === 1
|
||||
? [...room.offlineSeats, event.seat]
|
||||
: [...room.offlineSeats];
|
||||
|
||||
this.stateCell.value = Object.freeze({
|
||||
app: previous.app,
|
||||
players: Object.freeze({
|
||||
selfPlayerId: previous.players.selfPlayerId,
|
||||
entities,
|
||||
}),
|
||||
room: Object.freeze({
|
||||
...room,
|
||||
seatPlayerIds: Object.freeze(seatPlayerIds),
|
||||
readySeats: Object.freeze(readySeats),
|
||||
offlineSeats: Object.freeze(offlineSeats),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
playerReady(parsed: ParsedSeatPayload): void {
|
||||
const event = parseSeatAction(parsed);
|
||||
const previous = this.getState();
|
||||
const room = requireInsideRoom(previous);
|
||||
const playerid = requireOccupiedSeat(room, event.seat);
|
||||
if (room.readySeats.includes(event.seat)) {
|
||||
throw new RangeError('$.event.seat: player is already ready');
|
||||
}
|
||||
const player = previous.players.entities[playerid];
|
||||
if (player === undefined) {
|
||||
throw new RangeError('players.entities: occupied seat has no canonical player');
|
||||
}
|
||||
const entities = Object.freeze({
|
||||
...previous.players.entities,
|
||||
[playerid]: Object.freeze({ ...player, isprepare: 1 }),
|
||||
});
|
||||
const readySeats = Object.freeze([...room.readySeats, event.seat]);
|
||||
this.stateCell.value = Object.freeze({
|
||||
app: previous.app,
|
||||
players: Object.freeze({
|
||||
selfPlayerId: previous.players.selfPlayerId,
|
||||
entities,
|
||||
}),
|
||||
room: Object.freeze({ ...room, readySeats }),
|
||||
});
|
||||
}
|
||||
|
||||
playerOffline(parsed: ParsedSeatPayload): void {
|
||||
const event = parseSeatAction(parsed);
|
||||
const previous = this.getState();
|
||||
const room = requireInsideRoom(previous);
|
||||
const playerid = requireOccupiedSeat(room, event.seat);
|
||||
if (room.offlineSeats.includes(event.seat)) {
|
||||
throw new RangeError('$.event.seat: player is already offline');
|
||||
}
|
||||
const player = previous.players.entities[playerid];
|
||||
if (player === undefined) {
|
||||
throw new RangeError('players.entities: occupied seat has no canonical player');
|
||||
}
|
||||
const entities = Object.freeze({
|
||||
...previous.players.entities,
|
||||
[playerid]: Object.freeze({ ...player, onstate: 1 }),
|
||||
});
|
||||
const offlineSeats = Object.freeze([...room.offlineSeats, event.seat]);
|
||||
this.stateCell.value = Object.freeze({
|
||||
app: previous.app,
|
||||
players: Object.freeze({
|
||||
selfPlayerId: previous.players.selfPlayerId,
|
||||
entities,
|
||||
}),
|
||||
room: Object.freeze({ ...room, offlineSeats }),
|
||||
});
|
||||
}
|
||||
|
||||
playerOnline(parsed: ParsedOnlinePayload): void {
|
||||
const event = parseSeatAction(parsed);
|
||||
const input = requireRecord(parsed, '$.event');
|
||||
const ip = requireString(input.ip, '$.event.ip');
|
||||
if (requireString(event.raw.ip, '$.event.raw.ip') !== ip) {
|
||||
throw new RangeError('$.event.raw.ip: expected the parsed ip');
|
||||
}
|
||||
const previous = this.getState();
|
||||
const room = requireInsideRoom(previous);
|
||||
const playerid = requireOccupiedSeat(room, event.seat);
|
||||
if (!room.offlineSeats.includes(event.seat)) {
|
||||
throw new RangeError('$.event.seat: player is already online');
|
||||
}
|
||||
const player = previous.players.entities[playerid];
|
||||
if (player === undefined) {
|
||||
throw new RangeError('players.entities: occupied seat has no canonical player');
|
||||
}
|
||||
const entities = Object.freeze({
|
||||
...previous.players.entities,
|
||||
[playerid]: Object.freeze({ ...player, ip, onstate: 0 }),
|
||||
});
|
||||
const offlineSeats = Object.freeze(
|
||||
room.offlineSeats.filter((seat) => seat !== event.seat),
|
||||
);
|
||||
this.stateCell.value = Object.freeze({
|
||||
app: previous.app,
|
||||
players: Object.freeze({
|
||||
selfPlayerId: previous.players.selfPlayerId,
|
||||
entities,
|
||||
}),
|
||||
room: Object.freeze({ ...room, offlineSeats }),
|
||||
});
|
||||
}
|
||||
|
||||
playerExit(parsed: ParsedSeatPayload): void {
|
||||
const event = parseSeatAction(parsed);
|
||||
const previous = this.getState();
|
||||
const room = requireInsideRoom(previous);
|
||||
const playerid = requireOccupiedSeat(room, event.seat);
|
||||
if (event.seat === room.selfSeat) {
|
||||
throw new RangeError('$.event.seat: self exit must clear the room');
|
||||
}
|
||||
|
||||
const entities: Record<number, PlatformPlayer> = {
|
||||
...previous.players.entities,
|
||||
};
|
||||
delete entities[playerid];
|
||||
const seatPlayerIds = [...room.seatPlayerIds];
|
||||
seatPlayerIds[event.seat] = null;
|
||||
const readySeats = room.readySeats.filter((seat) => seat !== event.seat);
|
||||
const offlineSeats = room.offlineSeats.filter((seat) => seat !== event.seat);
|
||||
|
||||
this.stateCell.value = Object.freeze({
|
||||
app: previous.app,
|
||||
players: Object.freeze({
|
||||
selfPlayerId: previous.players.selfPlayerId,
|
||||
entities: Object.freeze(entities),
|
||||
}),
|
||||
room: Object.freeze({
|
||||
...room,
|
||||
seatPlayerIds: Object.freeze(seatPlayerIds),
|
||||
readySeats: Object.freeze(readySeats),
|
||||
offlineSeats: Object.freeze(offlineSeats),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { PlatformGameConnectionPhase } from '../../sdk/contracts/snapshots.ts';
|
||||
|
||||
export type PlatformConnectionPhase =
|
||||
| 'idle'
|
||||
| 'connecting'
|
||||
| PlatformGameConnectionPhase;
|
||||
|
||||
export interface PlatformAppState {
|
||||
readonly phase: PlatformConnectionPhase;
|
||||
}
|
||||
|
||||
export interface PlatformPlayer {
|
||||
readonly playerid: number;
|
||||
readonly nickname: string;
|
||||
readonly avatar: string;
|
||||
readonly ip?: string;
|
||||
readonly isprepare?: number;
|
||||
readonly onstate?: number;
|
||||
}
|
||||
|
||||
export interface PlayerDirectoryState {
|
||||
readonly selfPlayerId: number | null;
|
||||
readonly entities: Readonly<Record<number, PlatformPlayer>>;
|
||||
}
|
||||
|
||||
export interface OutsideRoomState {
|
||||
readonly kind: 'outside';
|
||||
}
|
||||
|
||||
export interface InsideRoomState {
|
||||
readonly kind: 'inside';
|
||||
readonly roomcode: string;
|
||||
readonly selfSeat: number;
|
||||
readonly seatPlayerIds: readonly (number | null)[];
|
||||
readonly readySeats: readonly number[];
|
||||
readonly offlineSeats: readonly number[];
|
||||
readonly roomtype: readonly unknown[];
|
||||
readonly stage: number;
|
||||
readonly needprepare: number;
|
||||
readonly infinite: number;
|
||||
}
|
||||
|
||||
export type RoomState = OutsideRoomState | InsideRoomState;
|
||||
|
||||
export interface PlatformState {
|
||||
readonly app: PlatformAppState;
|
||||
readonly players: PlayerDirectoryState;
|
||||
readonly room: RoomState;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type {
|
||||
GameSeatSnapshot,
|
||||
PlatformGameAppSnapshot,
|
||||
PlatformGameConnectionPhase,
|
||||
PlatformGameRoomSnapshot,
|
||||
PlatformGameSelfSnapshot,
|
||||
PlatformGameSnapshot,
|
||||
} from '../../sdk/contracts/snapshots.ts';
|
||||
import type {
|
||||
InsideRoomState,
|
||||
PlatformAppState,
|
||||
PlatformPlayer,
|
||||
PlatformState,
|
||||
PlayerDirectoryState,
|
||||
} from './platform-types.ts';
|
||||
|
||||
const roomPlayerCache = new WeakMap<
|
||||
InsideRoomState,
|
||||
WeakMap<PlayerDirectoryState, readonly (PlatformPlayer | null)[]>
|
||||
>();
|
||||
const connectionCache = new WeakMap<PlatformAppState, PlatformGameAppSnapshot>();
|
||||
const roomSnapshotCache = new WeakMap<InsideRoomState, PlatformGameRoomSnapshot>();
|
||||
const selfSnapshotCache = new WeakMap<
|
||||
InsideRoomState,
|
||||
WeakMap<PlayerDirectoryState, PlatformGameSelfSnapshot>
|
||||
>();
|
||||
const seatsSnapshotCache = new WeakMap<
|
||||
InsideRoomState,
|
||||
WeakMap<PlayerDirectoryState, readonly GameSeatSnapshot[]>
|
||||
>();
|
||||
const gameSnapshotCache = new WeakMap<
|
||||
PlatformAppState,
|
||||
WeakMap<PlayerDirectoryState, WeakMap<InsideRoomState, PlatformGameSnapshot>>
|
||||
>();
|
||||
|
||||
export function selectSelfPlayer(state: PlatformState): PlatformPlayer | null {
|
||||
const playerId = state.players.selfPlayerId;
|
||||
if (playerId === null) return null;
|
||||
const player = state.players.entities[playerId];
|
||||
if (player === undefined) {
|
||||
throw new RangeError('players.entities: missing authenticated player');
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
export function selectRoomPlayers(
|
||||
state: PlatformState,
|
||||
): readonly (PlatformPlayer | null)[] {
|
||||
if (state.room.kind !== 'inside') {
|
||||
throw new Error('room: expected player to be inside a room');
|
||||
}
|
||||
let byPlayers = roomPlayerCache.get(state.room);
|
||||
if (byPlayers === undefined) {
|
||||
byPlayers = new WeakMap();
|
||||
roomPlayerCache.set(state.room, byPlayers);
|
||||
}
|
||||
const cached = byPlayers.get(state.players);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const result = state.room.seatPlayerIds.map((playerid, seat) => {
|
||||
if (playerid === null) return null;
|
||||
const player = state.players.entities[playerid];
|
||||
if (player === undefined) {
|
||||
throw new RangeError(`players.entities: missing player for occupied seat ${seat}`);
|
||||
}
|
||||
return player;
|
||||
});
|
||||
const frozen = Object.freeze(result);
|
||||
byPlayers.set(state.players, frozen);
|
||||
return frozen;
|
||||
}
|
||||
|
||||
function selectConnectionPhase(state: PlatformState): PlatformGameConnectionPhase {
|
||||
const phase = state.app.phase;
|
||||
if (phase === 'idle' || phase === 'connecting') {
|
||||
throw new Error(`app.phase: cannot expose game snapshot while ${phase}`);
|
||||
}
|
||||
return phase;
|
||||
}
|
||||
|
||||
function selectConnectionSnapshot(state: PlatformState): PlatformGameAppSnapshot {
|
||||
const cached = connectionCache.get(state.app);
|
||||
if (cached !== undefined) return cached;
|
||||
const snapshot = Object.freeze({ phase: selectConnectionPhase(state) });
|
||||
connectionCache.set(state.app, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function selectSelfSnapshot(
|
||||
state: PlatformState,
|
||||
room: InsideRoomState,
|
||||
): PlatformGameSelfSnapshot {
|
||||
let byPlayers = selfSnapshotCache.get(room);
|
||||
if (byPlayers === undefined) {
|
||||
byPlayers = new WeakMap();
|
||||
selfSnapshotCache.set(room, byPlayers);
|
||||
}
|
||||
const cached = byPlayers.get(state.players);
|
||||
if (cached !== undefined) return cached;
|
||||
const self = selectSelfPlayer(state);
|
||||
if (self === null) {
|
||||
throw new Error('players.selfPlayerId: expected authenticated player');
|
||||
}
|
||||
if (room.seatPlayerIds[room.selfSeat] !== self.playerid) {
|
||||
throw new RangeError('room.selfSeat: expected authenticated player seat');
|
||||
}
|
||||
const snapshot = Object.freeze({ playerId: self.playerid, seat: room.selfSeat });
|
||||
byPlayers.set(state.players, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function selectRoomSnapshot(room: InsideRoomState): PlatformGameRoomSnapshot {
|
||||
const cached = roomSnapshotCache.get(room);
|
||||
if (cached !== undefined) return cached;
|
||||
const snapshot = Object.freeze({
|
||||
roomcode: room.roomcode,
|
||||
roomtype: room.roomtype,
|
||||
stage: room.stage,
|
||||
needprepare: room.needprepare,
|
||||
infinite: room.infinite,
|
||||
});
|
||||
roomSnapshotCache.set(room, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function selectSeatsSnapshot(
|
||||
state: PlatformState,
|
||||
room: InsideRoomState,
|
||||
): readonly GameSeatSnapshot[] {
|
||||
let byPlayers = seatsSnapshotCache.get(room);
|
||||
if (byPlayers === undefined) {
|
||||
byPlayers = new WeakMap();
|
||||
seatsSnapshotCache.set(room, byPlayers);
|
||||
}
|
||||
const cached = byPlayers.get(state.players);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const seats: GameSeatSnapshot[] = [];
|
||||
const roomPlayers = selectRoomPlayers(state);
|
||||
for (let seat = 0; seat < roomPlayers.length; seat += 1) {
|
||||
const player = roomPlayers[seat];
|
||||
if (player === null) continue;
|
||||
if (player.onstate === undefined) {
|
||||
throw new TypeError(`players.entities[${player.playerid}].onstate: expected protocol number`);
|
||||
}
|
||||
if (player.isprepare === undefined) {
|
||||
throw new TypeError(`players.entities[${player.playerid}].isprepare: expected protocol number`);
|
||||
}
|
||||
seats.push(Object.freeze({
|
||||
seat,
|
||||
playerId: player.playerid,
|
||||
nickname: player.nickname,
|
||||
avatar: player.avatar,
|
||||
online: player.onstate !== 1,
|
||||
ready: player.isprepare === 1,
|
||||
}));
|
||||
}
|
||||
const snapshot = Object.freeze(seats);
|
||||
byPlayers.set(state.players, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function selectGameSnapshot(state: PlatformState): PlatformGameSnapshot {
|
||||
if (state.room.kind !== 'inside') {
|
||||
throw new Error('room: expected player to be inside a room');
|
||||
}
|
||||
let byPlayers = gameSnapshotCache.get(state.app);
|
||||
if (byPlayers === undefined) {
|
||||
byPlayers = new WeakMap();
|
||||
gameSnapshotCache.set(state.app, byPlayers);
|
||||
}
|
||||
let byRoom = byPlayers.get(state.players);
|
||||
if (byRoom === undefined) {
|
||||
byRoom = new WeakMap();
|
||||
byPlayers.set(state.players, byRoom);
|
||||
}
|
||||
const cached = byRoom.get(state.room);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const snapshot = Object.freeze({
|
||||
connection: selectConnectionSnapshot(state),
|
||||
self: selectSelfSnapshot(state, state.room),
|
||||
room: selectRoomSnapshot(state.room),
|
||||
seats: selectSeatsSnapshot(state, state.room),
|
||||
});
|
||||
byRoom.set(state.room, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
||||
import {
|
||||
parseLoginResponse,
|
||||
parseOtherExitRoomPayload,
|
||||
parseOtherOfflinePayload,
|
||||
parseOtherJoinRoomPayload,
|
||||
parseOtherOnlinePayload,
|
||||
parsePlayerPreparePayload,
|
||||
parseSelfJoinRoomResponse,
|
||||
} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
|
||||
|
||||
function fixture(name: string): { data: Record<string, unknown> } {
|
||||
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
||||
}
|
||||
|
||||
test('PlatformStore starts unauthenticated and outside a room', () => {
|
||||
const state = new PlatformStore().getState();
|
||||
assert.equal(state.players.selfPlayerId, null);
|
||||
assert.deepEqual(state.room, { kind: 'outside' });
|
||||
});
|
||||
|
||||
test('applyLoginSuccess commits the complete login once and notifies once', () => {
|
||||
const store = new PlatformStore();
|
||||
const parsedLogin = parseLoginResponse(fixture('player-login-success.json').data);
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.applyLoginSuccess(parsedLogin);
|
||||
|
||||
const state = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(state.app.phase, 'logged-in');
|
||||
assert.equal(state.players.selfPlayerId, 430511);
|
||||
assert.deepEqual(state.players.entities[430511], {
|
||||
playerid: 430511,
|
||||
nickname: '测试号',
|
||||
avatar: 'http://a',
|
||||
ip: '127.0.0.1',
|
||||
});
|
||||
assert.deepEqual(state.room, { kind: 'outside' });
|
||||
});
|
||||
|
||||
test('room login stores one canonical player entity and recursively freezes roomtype', () => {
|
||||
const data = fixture('player-login-room.json').data;
|
||||
const parsedLogin = parseLoginResponse(data);
|
||||
const store = new PlatformStore();
|
||||
|
||||
store.applyLoginSuccess(parsedLogin);
|
||||
|
||||
const state = store.getState();
|
||||
assert.equal(state.players.selfPlayerId, 430511);
|
||||
assert.deepEqual(Object.keys(state.players.entities), ['430511']);
|
||||
assert.equal(state.players.entities[430511]?.nickname, '测试号');
|
||||
assert.equal(state.room.kind, 'inside');
|
||||
if (state.room.kind !== 'inside') return;
|
||||
const room = state.room;
|
||||
assert.deepEqual(room.seatPlayerIds, [null, 430511, null, null]);
|
||||
assert.equal(state.players.entities[430511]?.isprepare, 1);
|
||||
assert.equal(state.players.entities[430511]?.onstate, 1);
|
||||
assert.equal(typeof state.players.entities[430511]?.isprepare, 'number');
|
||||
assert.equal(typeof state.players.entities[430511]?.onstate, 'number');
|
||||
assert.equal(room.roomtype, data.roomtype);
|
||||
assert.equal(Object.isFrozen(room.roomtype), true);
|
||||
assert.equal(Object.isFrozen(room.roomtype[5]), true);
|
||||
assert.equal(Object.isFrozen((room.roomtype[5] as unknown[])[2]), true);
|
||||
assert.equal('deskinfo' in state, false);
|
||||
assert.equal('deskinfo' in state.room, false);
|
||||
assert.throws(() => {
|
||||
(room.roomtype[5] as unknown[])[0] = 99;
|
||||
}, TypeError);
|
||||
});
|
||||
|
||||
test('playerJoin atomically adds one canonical entity and its seat id', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
const joined = parseOtherJoinRoomPayload({
|
||||
seat: 2,
|
||||
playerid: 77,
|
||||
nickname: '对家',
|
||||
avatar: 'http://b',
|
||||
sex: 1,
|
||||
ip: '127.0.0.2',
|
||||
onstate: 0,
|
||||
bean: 90,
|
||||
isprepare: 0,
|
||||
});
|
||||
|
||||
store.playerJoin(joined);
|
||||
|
||||
const state = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(state.room.kind, 'inside');
|
||||
if (state.room.kind !== 'inside') return;
|
||||
assert.deepEqual(state.room.seatPlayerIds, [null, 430511, 77, null]);
|
||||
assert.deepEqual(state.players.entities[77], {
|
||||
playerid: 77,
|
||||
nickname: '对家',
|
||||
avatar: 'http://b',
|
||||
ip: '127.0.0.2',
|
||||
isprepare: 0,
|
||||
onstate: 0,
|
||||
});
|
||||
assert.equal(Object.isFrozen(state.players.entities[77]), true);
|
||||
});
|
||||
|
||||
test('playerReady replaces the affected player and room subtrees without prior mutation', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
store.playerJoin(parseOtherJoinRoomPayload({
|
||||
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
|
||||
ip: '127.0.0.2', onstate: 0, isprepare: 0,
|
||||
}));
|
||||
const before = store.getState();
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.playerReady(parsePlayerPreparePayload({ seat: 2 }));
|
||||
|
||||
const after = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(after.app, before.app);
|
||||
assert.notEqual(after.players, before.players);
|
||||
assert.equal(after.players.entities[77]?.isprepare, 1);
|
||||
assert.notEqual(after.room, before.room);
|
||||
assert.equal(after.room.kind, 'inside');
|
||||
assert.equal(before.room.kind, 'inside');
|
||||
if (after.room.kind !== 'inside' || before.room.kind !== 'inside') return;
|
||||
assert.deepEqual(before.room.readySeats, [1]);
|
||||
assert.deepEqual(after.room.readySeats, [1, 2]);
|
||||
assert.equal(Object.isFrozen(after.room.readySeats), true);
|
||||
});
|
||||
|
||||
test('playerOffline records protocol and room status with one atomic commit', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
store.playerJoin(parseOtherJoinRoomPayload({
|
||||
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
|
||||
ip: '127.0.0.2', onstate: 0, isprepare: 0,
|
||||
}));
|
||||
const before = store.getState();
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.playerOffline(parseOtherOfflinePayload({ seat: 2 }));
|
||||
|
||||
const after = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(after.app, before.app);
|
||||
assert.notEqual(after.players, before.players);
|
||||
assert.equal(after.players.entities[77]?.onstate, 1);
|
||||
assert.equal(after.room.kind, 'inside');
|
||||
assert.equal(before.room.kind, 'inside');
|
||||
if (after.room.kind !== 'inside' || before.room.kind !== 'inside') return;
|
||||
assert.deepEqual(before.room.offlineSeats, [1]);
|
||||
assert.deepEqual(after.room.offlineSeats, [1, 2]);
|
||||
});
|
||||
|
||||
test('playerOnline updates the canonical player ip and removes the offline seat atomically', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
const before = store.getState();
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.playerOnline(parseOtherOnlinePayload({ seat: 1, ip: '127.0.0.9' }));
|
||||
|
||||
const after = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(after.app, before.app);
|
||||
assert.notEqual(after.players, before.players);
|
||||
assert.notEqual(after.room, before.room);
|
||||
assert.equal(after.players.entities[430511]?.ip, '127.0.0.9');
|
||||
assert.equal(before.players.entities[430511]?.ip, '127.0.0.1');
|
||||
assert.equal(after.room.kind, 'inside');
|
||||
if (after.room.kind !== 'inside') return;
|
||||
assert.deepEqual(after.room.offlineSeats, []);
|
||||
});
|
||||
|
||||
test('playerExit removes the seat entity and every seat-owned status in one commit', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
store.playerJoin(parseOtherJoinRoomPayload({
|
||||
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
|
||||
ip: '127.0.0.2', onstate: 1, isprepare: 1,
|
||||
}));
|
||||
const before = store.getState();
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.playerExit(parseOtherExitRoomPayload({ seat: 2 }));
|
||||
|
||||
const after = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(after.app, before.app);
|
||||
assert.equal(after.players.selfPlayerId, 430511);
|
||||
assert.equal(after.players.entities[430511], before.players.entities[430511]);
|
||||
assert.equal(after.players.entities[77], undefined);
|
||||
assert.equal(after.room.kind, 'inside');
|
||||
if (after.room.kind !== 'inside') return;
|
||||
assert.deepEqual(after.room.seatPlayerIds, [null, 430511, null, null]);
|
||||
assert.deepEqual(after.room.readySeats, [1]);
|
||||
assert.deepEqual(after.room.offlineSeats, [1]);
|
||||
});
|
||||
|
||||
test('replaceRoom atomically replaces the complete room from a parsed DTO', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
|
||||
const parsedRoom = parseSelfJoinRoomResponse(fixture('self-join-room.json').data);
|
||||
const before = store.getState();
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.replaceRoom(parsedRoom);
|
||||
|
||||
const after = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(after.app, before.app);
|
||||
assert.equal(after.room.kind, 'inside');
|
||||
if (after.room.kind !== 'inside') return;
|
||||
assert.equal(after.room.roomcode, '100001');
|
||||
assert.equal(after.room.selfSeat, 1);
|
||||
assert.equal(after.room.stage, 1);
|
||||
assert.equal(after.room.needprepare, 1);
|
||||
assert.equal(after.room.infinite, 0);
|
||||
assert.deepEqual(after.room.seatPlayerIds, [null, 430511, null, null]);
|
||||
assert.equal(after.room.roomtype, parsedRoom.room?.roomtype);
|
||||
assert.equal(after.players.entities[430511]?.ip, '127.0.0.1');
|
||||
});
|
||||
|
||||
test('clearRoom leaves no fabricated room fields and retains only the self entity', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
store.playerJoin(parseOtherJoinRoomPayload({
|
||||
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
|
||||
ip: '127.0.0.2', onstate: 0, isprepare: 0,
|
||||
}));
|
||||
const before = store.getState();
|
||||
const self = before.players.entities[430511];
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.clearRoom();
|
||||
|
||||
const after = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(after.app, before.app);
|
||||
assert.deepEqual(after.room, { kind: 'outside' });
|
||||
assert.deepEqual(Object.keys(after.room), ['kind']);
|
||||
assert.deepEqual(Object.keys(after.players.entities), ['430511']);
|
||||
assert.equal(after.players.entities[430511], self);
|
||||
});
|
||||
|
||||
test('failed actions keep the exact root, notify nobody, and do not mutate inputs', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
const before = store.getState();
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
const tampered = parseSelfJoinRoomResponse(fixture('self-join-room.json').data);
|
||||
(tampered.room as { roomcode: string }).roomcode = 'tampered';
|
||||
assert.throws(() => store.replaceRoom(tampered), /roomcode/);
|
||||
assert.equal(store.getState(), before);
|
||||
assert.equal(notifications, 0);
|
||||
|
||||
assert.throws(
|
||||
() => store.playerReady(parsePlayerPreparePayload({ seat: 99 })),
|
||||
/seat/,
|
||||
);
|
||||
assert.equal(store.getState(), before);
|
||||
assert.equal(notifications, 0);
|
||||
|
||||
assert.throws(
|
||||
() => store.playerJoin(parseOtherJoinRoomPayload({
|
||||
seat: 2, playerid: 430511, nickname: '重复', avatar: 'http://duplicate',
|
||||
ip: '127.0.0.3', onstate: 0, isprepare: 0,
|
||||
})),
|
||||
/duplicate player seat/,
|
||||
);
|
||||
assert.equal(store.getState(), before);
|
||||
assert.equal(notifications, 0);
|
||||
|
||||
const duplicateData = fixture('self-join-room.json').data;
|
||||
const duplicatePlayers = duplicateData.players as Array<Record<string, unknown> | null>;
|
||||
duplicatePlayers[2] = { ...duplicatePlayers[1] as Record<string, unknown> };
|
||||
const mutableRoomtype = duplicateData.roomtype as unknown[];
|
||||
const duplicateRoom = parseSelfJoinRoomResponse(duplicateData);
|
||||
assert.throws(() => store.replaceRoom(duplicateRoom), /duplicate player seat/);
|
||||
assert.equal(Object.isFrozen(mutableRoomtype), false);
|
||||
assert.equal(store.getState(), before);
|
||||
assert.equal(notifications, 0);
|
||||
|
||||
assert.throws(() => {
|
||||
(before.players.entities[430511] as { nickname: string }).nickname = '篡改';
|
||||
}, TypeError);
|
||||
assert.throws(() => {
|
||||
(before.room as { kind: string }).kind = 'outside';
|
||||
}, TypeError);
|
||||
assert.equal(store.getState(), before);
|
||||
assert.equal(notifications, 0);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
selectGameSnapshot,
|
||||
selectRoomPlayers,
|
||||
selectSelfPlayer,
|
||||
} from '../../YouleNexus/assets/framework/platform/stores/selectors.ts';
|
||||
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
||||
import type { PlatformState } from '../../YouleNexus/assets/framework/platform/stores/platform-types.ts';
|
||||
import { parseLoginResponse } from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
|
||||
|
||||
function fixture(name: string): { data: Record<string, unknown> } {
|
||||
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
||||
}
|
||||
|
||||
test('selectSelfPlayer returns null for an unauthenticated state', () => {
|
||||
const state = {
|
||||
app: { phase: 'idle' },
|
||||
players: { selfPlayerId: null, entities: {} },
|
||||
room: { kind: 'outside' },
|
||||
} as PlatformState;
|
||||
assert.equal(selectSelfPlayer(state), null);
|
||||
});
|
||||
|
||||
test('selectRoomPlayers memoizes a frozen positional projection of canonical entities', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
const state = store.getState();
|
||||
|
||||
const first = selectRoomPlayers(state);
|
||||
const second = selectRoomPlayers(state);
|
||||
|
||||
assert.equal(first, second);
|
||||
assert.equal(Object.isFrozen(first), true);
|
||||
assert.deepEqual(
|
||||
first.map((player) => player === null ? null : player.playerid),
|
||||
[null, 430511, null, null],
|
||||
);
|
||||
assert.equal(first[1], selectSelfPlayer(state));
|
||||
assert.throws(() => {
|
||||
(first as unknown[])[1] = null;
|
||||
}, TypeError);
|
||||
});
|
||||
|
||||
test('selectGameSnapshot returns only recursively frozen SDK-owned DTOs', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
const state = store.getState();
|
||||
assert.equal(state.room.kind, 'inside');
|
||||
if (state.room.kind !== 'inside') return;
|
||||
|
||||
const snapshot = selectGameSnapshot(state);
|
||||
|
||||
assert.deepEqual(snapshot, {
|
||||
connection: { phase: 'logged-in' },
|
||||
self: { playerId: 430511, seat: 1 },
|
||||
room: {
|
||||
roomcode: '100001',
|
||||
roomtype: state.room.roomtype,
|
||||
stage: 1,
|
||||
needprepare: 1,
|
||||
infinite: 0,
|
||||
},
|
||||
seats: [{
|
||||
seat: 1,
|
||||
playerId: 430511,
|
||||
nickname: '测试号',
|
||||
avatar: 'http://a',
|
||||
online: false,
|
||||
ready: true,
|
||||
}],
|
||||
});
|
||||
assert.equal(Object.isFrozen(snapshot), true);
|
||||
assert.equal(Object.isFrozen(snapshot.connection), true);
|
||||
assert.equal(Object.isFrozen(snapshot.self), true);
|
||||
assert.equal(Object.isFrozen(snapshot.room), true);
|
||||
assert.equal(Object.isFrozen(snapshot.seats), true);
|
||||
assert.equal(Object.isFrozen(snapshot.seats[0]), true);
|
||||
assert.equal(snapshot.room.roomtype, state.room.roomtype);
|
||||
assert.deepEqual(Object.keys(snapshot).sort(), ['connection', 'room', 'seats', 'self']);
|
||||
});
|
||||
|
||||
test('selectGameSnapshot memoizes each SDK branch by its immutable source subtrees', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
const base = store.getState();
|
||||
assert.equal(base.room.kind, 'inside');
|
||||
if (base.room.kind !== 'inside') return;
|
||||
|
||||
const first = selectGameSnapshot(base);
|
||||
const repeated = selectGameSnapshot(base);
|
||||
assert.equal(repeated, first);
|
||||
assert.equal(repeated.connection, first.connection);
|
||||
assert.equal(repeated.self, first.self);
|
||||
assert.equal(repeated.room, first.room);
|
||||
assert.equal(repeated.seats, first.seats);
|
||||
|
||||
const appChanged: PlatformState = Object.freeze({
|
||||
...base,
|
||||
app: Object.freeze({ phase: 'reconnecting' }),
|
||||
});
|
||||
const afterApp = selectGameSnapshot(appChanged);
|
||||
assert.notEqual(afterApp, first);
|
||||
assert.notEqual(afterApp.connection, first.connection);
|
||||
assert.equal(afterApp.self, first.self);
|
||||
assert.equal(afterApp.room, first.room);
|
||||
assert.equal(afterApp.seats, first.seats);
|
||||
|
||||
const playersChanged: PlatformState = Object.freeze({
|
||||
...base,
|
||||
players: Object.freeze({
|
||||
selfPlayerId: base.players.selfPlayerId,
|
||||
entities: Object.freeze({ ...base.players.entities }),
|
||||
}),
|
||||
});
|
||||
const afterPlayers = selectGameSnapshot(playersChanged);
|
||||
assert.equal(afterPlayers.connection, first.connection);
|
||||
assert.notEqual(afterPlayers.self, first.self);
|
||||
assert.equal(afterPlayers.room, first.room);
|
||||
assert.notEqual(afterPlayers.seats, first.seats);
|
||||
|
||||
const roomChanged: PlatformState = Object.freeze({
|
||||
...base,
|
||||
room: Object.freeze({ ...base.room, stage: 0 }),
|
||||
});
|
||||
const afterRoom = selectGameSnapshot(roomChanged);
|
||||
assert.equal(afterRoom.connection, first.connection);
|
||||
assert.notEqual(afterRoom.self, first.self);
|
||||
assert.notEqual(afterRoom.room, first.room);
|
||||
assert.notEqual(afterRoom.seats, first.seats);
|
||||
});
|
||||
Reference in New Issue
Block a user