fix(platform): separate room stage contracts

This commit is contained in:
2026-09-05 03:12:19 +08:00
parent 4ffa59bf8e
commit 462e2e9267
3 changed files with 311 additions and 46 deletions
@@ -2,6 +2,7 @@ import { signal, type Reactive, type Unsubscribe } from '../../core/reactive.ts'
import {
hasOwn,
requireArray,
requireBoolean,
requireInteger,
requireNumber,
requireRecord,
@@ -67,39 +68,98 @@ function parseRoomPlayer(value: unknown, path: string): {
return { player, isprepare, onstate };
}
function collectFreezableGraph(value: unknown): object[] {
const graph: object[] = [];
const seen = new WeakSet<object>();
const visit = (current: unknown): void => {
if (typeof current !== 'object' || current === null || seen.has(current)) return;
seen.add(current);
graph.push(current);
const prototype = Object.getPrototypeOf(current);
if (!Array.isArray(current) && prototype !== Object.prototype && prototype !== null) {
throw new TypeError('roomtype: expected JSON array or plain object container');
}
Object.isExtensible(current);
for (const key of Reflect.ownKeys(current)) {
const descriptor = Reflect.getOwnPropertyDescriptor(current, key);
if (descriptor === undefined) {
throw new TypeError('roomtype: unstable property structure');
}
visit(Reflect.get(current, key));
}
};
visit(value);
return graph;
function roomtypePropertyPath(path: string, key: string, isArray: boolean): string {
if (isArray && /^(0|[1-9]\d*)$/.test(key)) return `${path}[${key}]`;
if (/^[A-Za-z_$][\w$]*$/.test(key)) return `${path}.${key}`;
return `${path}[${JSON.stringify(key)}]`;
}
function recursivelyFreeze<T>(value: T): T {
const graph = collectFreezableGraph(value);
for (let index = graph.length - 1; index >= 0; index -= 1) {
Object.freeze(graph[index]);
function cloneAndFreezeRoomtype(value: readonly unknown[]): readonly unknown[] {
const copies = new WeakMap<object, object>();
const active = new WeakSet<object>();
const ownedGraph: object[] = [];
const copy = (current: unknown, path: string): unknown => {
if (current === null) return null;
if (typeof current === 'string' || typeof current === 'boolean') return current;
if (typeof current === 'number') {
if (!Number.isFinite(current)) {
throw new TypeError(`${path}: expected finite JSON number`);
}
return current;
}
if (typeof current !== 'object') {
throw new TypeError(`${path}: expected JSON value`);
}
if (active.has(current)) {
throw new TypeError(`${path}: cyclic roomtype reference`);
}
const priorCopy = copies.get(current);
if (priorCopy !== undefined) return priorCopy;
const sourceIsArray = Array.isArray(current);
const prototype = Object.getPrototypeOf(current);
if (!sourceIsArray && prototype !== Object.prototype && prototype !== null) {
throw new TypeError(`${path}: expected JSON array or plain object container`);
}
let target: unknown[] | Record<string, unknown>;
let arrayLength = 0;
if (sourceIsArray) {
const lengthDescriptor = Reflect.getOwnPropertyDescriptor(current, 'length');
if (lengthDescriptor === undefined || !('value' in lengthDescriptor)) {
throw new TypeError(`${path}.length: expected stable data property`);
}
arrayLength = requireInteger(lengthDescriptor.value, `${path}.length`);
if (arrayLength < 0) throw new RangeError(`${path}.length: expected non-negative integer`);
target = new Array(arrayLength);
} else {
target = prototype === null ? Object.create(null) as Record<string, unknown> : {};
}
copies.set(current, target);
active.add(current);
ownedGraph.push(target);
const keys = Reflect.ownKeys(current);
for (const key of keys) {
if (sourceIsArray && key === 'length') continue;
if (typeof key !== 'string') {
throw new TypeError(`${path}: symbol properties are not valid roomtype data`);
}
const propertyPath = roomtypePropertyPath(path, key, sourceIsArray);
if (sourceIsArray) {
const index = Number(key);
if (!Number.isInteger(index) || index < 0 || index >= arrayLength || String(index) !== key) {
throw new TypeError(`${propertyPath}: expected array index`);
}
}
const descriptor = Reflect.getOwnPropertyDescriptor(current, key);
if (descriptor === undefined) {
throw new TypeError(`${propertyPath}: unstable property structure`);
}
if (!('value' in descriptor)) {
throw new TypeError(`${propertyPath}: accessor properties are not allowed in roomtype`);
}
if (!descriptor.enumerable) {
throw new TypeError(`${propertyPath}: expected enumerable JSON data property`);
}
const copiedValue = copy(descriptor.value, propertyPath);
Object.defineProperty(target, key, {
configurable: true,
enumerable: true,
writable: true,
value: copiedValue,
});
}
active.delete(current);
return target;
};
const canonical = copy(value, 'roomtype') as readonly unknown[];
for (let index = ownedGraph.length - 1; index >= 0; index -= 1) {
Object.freeze(ownedGraph[index]);
}
return value;
return canonical;
}
function assertSelfProfileMatches(
@@ -134,6 +194,7 @@ function buildRoom(
roomValue: unknown,
rawValue: unknown,
selfPlayer: PlatformPlayer,
stage: number,
): RoomBuildResult {
const room = requireRecord(roomValue, '$.room');
const raw = requireRecord(rawValue, '$.raw');
@@ -157,14 +218,6 @@ function buildRoom(
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');
@@ -195,7 +248,7 @@ function buildRoom(
}
assertSelfProfileMatches(selfPlayer, selfRoomPlayer, `$.room.players[${selfSeat}]`);
recursivelyFreeze(roomtype);
const canonicalRoomtype = cloneAndFreezeRoomtype(roomtype);
const entities: Record<number, PlatformPlayer> = {};
for (const [playerid, parsedPlayer] of parsedPlayers) {
const player = playerid === selfPlayer.playerid
@@ -220,7 +273,7 @@ function buildRoom(
seatPlayerIds: Object.freeze(seatPlayerIds),
readySeats: Object.freeze(readySeats),
offlineSeats: Object.freeze(offlineSeats),
roomtype,
roomtype: canonicalRoomtype,
stage,
needprepare,
infinite,
@@ -315,7 +368,12 @@ export class PlatformStore {
}),
room: Object.freeze({ kind: 'outside' as const }),
}
: buildRoom(input.room, raw, player);
: buildRoom(
input.room,
raw,
player,
requireProtocolFlag(raw.isbattle, '$.login.raw.isbattle'),
);
this.stateCell.value = Object.freeze({
app: Object.freeze({ phase: 'logged-in' }),
players: roomResult.players,
@@ -345,7 +403,26 @@ export class PlatformStore {
if (selfPlayer === undefined) {
throw new Error('players.entities: missing authenticated player');
}
const roomResult = buildRoom(input.room, raw, selfPlayer);
const reconnect = requireRecord(input.reconnect, '$.response.reconnect');
const reconnectPresent = requireBoolean(
reconnect.present,
'$.response.reconnect.present',
);
const rawReconnectPresent = Boolean(raw.deskinfo);
if (reconnectPresent !== rawReconnectPresent) {
throw new RangeError(
'$.response.reconnect.present: expected deskinfo truthiness from raw response',
);
}
if (reconnectPresent && reconnect.value !== raw.deskinfo) {
throw new RangeError(
'$.response.reconnect.value: expected canonical raw deskinfo reference',
);
}
const stage = reconnectPresent
? 1
: requireProtocolFlag(raw.deskwar, '$.response.raw.deskwar');
const roomResult = buildRoom(input.room, raw, selfPlayer, stage);
this.stateCell.value = Object.freeze({
app: previous.app,
players: roomResult.players,