feat(platform): add atomic canonical platform store

This commit is contained in:
2026-09-05 02:44:51 +08:00
parent fdc6fc2264
commit 7acd3ed96e
5 changed files with 1139 additions and 0 deletions
@@ -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;
}