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 { import {
hasOwn, hasOwn,
requireArray, requireArray,
requireBoolean,
requireInteger, requireInteger,
requireNumber, requireNumber,
requireRecord, requireRecord,
@@ -67,39 +68,98 @@ function parseRoomPlayer(value: unknown, path: string): {
return { player, isprepare, onstate }; return { player, isprepare, onstate };
} }
function collectFreezableGraph(value: unknown): object[] { function roomtypePropertyPath(path: string, key: string, isArray: boolean): string {
const graph: object[] = []; if (isArray && /^(0|[1-9]\d*)$/.test(key)) return `${path}[${key}]`;
const seen = new WeakSet<object>(); if (/^[A-Za-z_$][\w$]*$/.test(key)) return `${path}.${key}`;
return `${path}[${JSON.stringify(key)}]`;
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 recursivelyFreeze<T>(value: T): T { function cloneAndFreezeRoomtype(value: readonly unknown[]): readonly unknown[] {
const graph = collectFreezableGraph(value); const copies = new WeakMap<object, object>();
for (let index = graph.length - 1; index >= 0; index -= 1) { const active = new WeakSet<object>();
Object.freeze(graph[index]); 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( function assertSelfProfileMatches(
@@ -134,6 +194,7 @@ function buildRoom(
roomValue: unknown, roomValue: unknown,
rawValue: unknown, rawValue: unknown,
selfPlayer: PlatformPlayer, selfPlayer: PlatformPlayer,
stage: number,
): RoomBuildResult { ): RoomBuildResult {
const room = requireRecord(roomValue, '$.room'); const room = requireRecord(roomValue, '$.room');
const raw = requireRecord(rawValue, '$.raw'); const raw = requireRecord(rawValue, '$.raw');
@@ -157,14 +218,6 @@ function buildRoom(
throw new RangeError('$.room.seat: expected server seat index within players'); 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 needprepare = requireProtocolFlag(raw.needprepare, '$.raw.needprepare');
const infinite = requireProtocolFlag(raw.infinite, '$.raw.infinite'); const infinite = requireProtocolFlag(raw.infinite, '$.raw.infinite');
@@ -195,7 +248,7 @@ function buildRoom(
} }
assertSelfProfileMatches(selfPlayer, selfRoomPlayer, `$.room.players[${selfSeat}]`); assertSelfProfileMatches(selfPlayer, selfRoomPlayer, `$.room.players[${selfSeat}]`);
recursivelyFreeze(roomtype); const canonicalRoomtype = cloneAndFreezeRoomtype(roomtype);
const entities: Record<number, PlatformPlayer> = {}; const entities: Record<number, PlatformPlayer> = {};
for (const [playerid, parsedPlayer] of parsedPlayers) { for (const [playerid, parsedPlayer] of parsedPlayers) {
const player = playerid === selfPlayer.playerid const player = playerid === selfPlayer.playerid
@@ -220,7 +273,7 @@ function buildRoom(
seatPlayerIds: Object.freeze(seatPlayerIds), seatPlayerIds: Object.freeze(seatPlayerIds),
readySeats: Object.freeze(readySeats), readySeats: Object.freeze(readySeats),
offlineSeats: Object.freeze(offlineSeats), offlineSeats: Object.freeze(offlineSeats),
roomtype, roomtype: canonicalRoomtype,
stage, stage,
needprepare, needprepare,
infinite, infinite,
@@ -315,7 +368,12 @@ export class PlatformStore {
}), }),
room: Object.freeze({ kind: 'outside' as const }), 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({ this.stateCell.value = Object.freeze({
app: Object.freeze({ phase: 'logged-in' }), app: Object.freeze({ phase: 'logged-in' }),
players: roomResult.players, players: roomResult.players,
@@ -345,7 +403,26 @@ export class PlatformStore {
if (selfPlayer === undefined) { if (selfPlayer === undefined) {
throw new Error('players.entities: missing authenticated player'); 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({ this.stateCell.value = Object.freeze({
app: previous.app, app: previous.app,
players: roomResult.players, players: roomResult.players,
@@ -47,6 +47,8 @@ test('applyLoginSuccess commits the complete login once and notifies once', () =
test('room login stores one canonical player entity and recursively freezes roomtype', () => { test('room login stores one canonical player entity and recursively freezes roomtype', () => {
const data = fixture('player-login-room.json').data; const data = fixture('player-login-room.json').data;
const sourceRoomtype = data.roomtype as unknown[];
const expectedRoomtype = structuredClone(sourceRoomtype);
const parsedLogin = parseLoginResponse(data); const parsedLogin = parseLoginResponse(data);
const store = new PlatformStore(); const store = new PlatformStore();
@@ -64,10 +66,15 @@ test('room login stores one canonical player entity and recursively freezes room
assert.equal(state.players.entities[430511]?.onstate, 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]?.isprepare, 'number');
assert.equal(typeof state.players.entities[430511]?.onstate, 'number'); assert.equal(typeof state.players.entities[430511]?.onstate, 'number');
assert.equal(room.roomtype, data.roomtype); assert.notEqual(room.roomtype, sourceRoomtype);
assert.deepEqual(room.roomtype, expectedRoomtype);
assert.equal(Object.isFrozen(room.roomtype), true); assert.equal(Object.isFrozen(room.roomtype), true);
assert.equal(Object.isFrozen(room.roomtype[5]), true); assert.equal(Object.isFrozen(room.roomtype[5]), true);
assert.equal(Object.isFrozen((room.roomtype[5] as unknown[])[2]), true); assert.equal(Object.isFrozen((room.roomtype[5] as unknown[])[2]), true);
assert.equal(Object.isFrozen(sourceRoomtype), false);
assert.equal(Object.isFrozen(sourceRoomtype[5]), false);
assert.equal(Object.isFrozen((sourceRoomtype[5] as unknown[])[2]), false);
assert.deepEqual(sourceRoomtype, expectedRoomtype);
assert.equal('deskinfo' in state, false); assert.equal('deskinfo' in state, false);
assert.equal('deskinfo' in state.room, false); assert.equal('deskinfo' in state.room, false);
assert.throws(() => { assert.throws(() => {
@@ -232,10 +239,75 @@ test('replaceRoom atomically replaces the complete room from a parsed DTO', () =
assert.equal(after.room.needprepare, 1); assert.equal(after.room.needprepare, 1);
assert.equal(after.room.infinite, 0); assert.equal(after.room.infinite, 0);
assert.deepEqual(after.room.seatPlayerIds, [null, 430511, null, null]); assert.deepEqual(after.room.seatPlayerIds, [null, 430511, null, null]);
assert.equal(after.room.roomtype, parsedRoom.room?.roomtype); assert.notEqual(after.room.roomtype, parsedRoom.room?.roomtype);
assert.deepEqual(after.room.roomtype, parsedRoom.room?.roomtype);
assert.equal(after.players.entities[430511]?.ip, '127.0.0.1'); assert.equal(after.players.entities[430511]?.ip, '127.0.0.1');
}); });
test('replaceRoom derives a reconnect stage only from deskinfo presence', () => {
for (const deskwar of [undefined, 0] as const) {
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const data = fixture('self-join-room.json').data;
if (deskwar === undefined) delete data.deskwar;
else data.deskwar = deskwar;
const parsed = parseSelfJoinRoomResponse(data);
store.replaceRoom(parsed);
const state = store.getState();
assert.equal(state.room.kind, 'inside');
if (state.room.kind !== 'inside') continue;
assert.equal(state.room.stage, 1);
}
});
test('applyLoginSuccess requires isbattle even when raw deskwar is fabricated', () => {
const store = new PlatformStore();
const data = fixture('player-login-room.json').data;
delete data.isbattle;
data.deskwar = 1;
const parsed = parseLoginResponse(data);
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.applyLoginSuccess(parsed), /isbattle/i);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
test('replaceRoom without reconnect requires deskwar and never substitutes isbattle', () => {
const successfulStore = new PlatformStore();
successfulStore.applyLoginSuccess(
parseLoginResponse(fixture('player-login-success.json').data),
);
const successfulData = fixture('self-join-room.json').data;
delete successfulData.deskinfo;
successfulData.deskwar = 0;
successfulStore.replaceRoom(parseSelfJoinRoomResponse(successfulData));
const successfulState = successfulStore.getState();
assert.equal(successfulState.room.kind, 'inside');
if (successfulState.room.kind === 'inside') {
assert.equal(successfulState.room.stage, 0);
}
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const data = fixture('self-join-room.json').data;
delete data.deskinfo;
delete data.deskwar;
data.isbattle = 1;
const parsed = parseSelfJoinRoomResponse(data);
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /deskwar/i);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
test('clearRoom leaves no fabricated room fields and retains only the self entity', () => { test('clearRoom leaves no fabricated room fields and retains only the self entity', () => {
const store = new PlatformStore(); const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
@@ -378,12 +450,14 @@ test('room self profile conflicts are rejected instead of overwriting login auth
} }
}); });
test('roomtype traversal failure freezes nothing and leaves store state untouched', () => { test('roomtype accessors are rejected without invocation or input mutation', () => {
const child = { value: 1 }; const child = { value: 1 };
const throwing = Object.create(null) as Record<string, unknown>; const throwing = Object.create(null) as Record<string, unknown>;
let getterCalls = 0;
Object.defineProperty(throwing, 'boom', { Object.defineProperty(throwing, 'boom', {
enumerable: true, enumerable: true,
get(): never { get(): never {
getterCalls += 1;
throw new Error('roomtype getter boom'); throw new Error('roomtype getter boom');
}, },
}); });
@@ -397,10 +471,104 @@ test('roomtype traversal failure freezes nothing and leaves store state untouche
let notifications = 0; let notifications = 0;
store.subscribe(() => { notifications += 1; }); store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /roomtype getter boom/); assert.throws(() => store.replaceRoom(parsed), /roomtype.*boom.*accessor/i);
assert.equal(getterCalls, 0);
assert.equal(Object.isFrozen(child), false); assert.equal(Object.isFrozen(child), false);
assert.equal(Object.isFrozen(throwing), false); assert.equal(Object.isFrozen(throwing), false);
assert.equal(Object.isFrozen(roomtype), false); assert.equal(Object.isFrozen(roomtype), false);
assert.equal(store.getState(), before); assert.equal(store.getState(), before);
assert.equal(notifications, 0); assert.equal(notifications, 0);
}); });
test('roomtype copying never runs preventExtensions on caller-owned proxies', () => {
const sibling = { value: 1 };
const nested = { value: 2 };
let preventExtensionsCalls = 0;
const proxy = new Proxy({ nested }, {
preventExtensions(): boolean {
preventExtensionsCalls += 1;
return false;
},
});
const roomtype = [sibling, proxy];
const data = fixture('self-join-room.json').data;
data.roomtype = roomtype;
const parsed = parseSelfJoinRoomResponse(data);
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
store.replaceRoom(parsed);
const state = store.getState();
assert.equal(state.room.kind, 'inside');
if (state.room.kind !== 'inside') return;
assert.notEqual(state.room.roomtype, roomtype);
assert.deepEqual(state.room.roomtype, roomtype);
assert.equal(Object.isFrozen(state.room.roomtype), true);
assert.equal(Object.isFrozen(state.room.roomtype[0]), true);
assert.equal(Object.isFrozen(state.room.roomtype[1]), true);
assert.equal(
Object.isFrozen(
(state.room.roomtype[1] as { readonly nested: object }).nested,
),
true,
);
assert.equal(preventExtensionsCalls, 0);
assert.equal(Object.isFrozen(roomtype), false);
assert.equal(Object.isFrozen(sibling), false);
assert.equal(Object.isFrozen(proxy), false);
assert.equal(Object.isFrozen(nested), false);
});
test('dynamic roomtype accessors cannot leak mutable children into canonical state', () => {
const first = { value: 1 };
const second = { value: 2 };
let getterCalls = 0;
const dynamic = {} as Record<string, unknown>;
Object.defineProperty(dynamic, 'child', {
enumerable: true,
get(): object {
getterCalls += 1;
return getterCalls === 1 ? first : second;
},
});
const roomtype = [dynamic];
const data = fixture('self-join-room.json').data;
data.roomtype = roomtype;
const parsed = parseSelfJoinRoomResponse(data);
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /roomtype.*child.*accessor/i);
assert.equal(getterCalls, 0);
assert.equal(Object.isFrozen(roomtype), false);
assert.equal(Object.isFrozen(dynamic), false);
assert.equal(Object.isFrozen(first), false);
assert.equal(Object.isFrozen(second), false);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
test('cyclic roomtype input is rejected atomically without mutating caller data', () => {
const cyclic: unknown[] = [];
cyclic.push(cyclic);
const roomtype = [{ value: 1 }, cyclic];
const data = fixture('self-join-room.json').data;
data.roomtype = roomtype;
const parsed = parseSelfJoinRoomResponse(data);
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /roomtype\[1\]\[0\].*cyclic/i);
assert.equal(Object.isFrozen(roomtype), false);
assert.equal(Object.isFrozen(roomtype[0]), false);
assert.equal(Object.isFrozen(cyclic), false);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
@@ -130,3 +130,23 @@ test('selectGameSnapshot memoizes each SDK branch by its immutable source subtre
assert.notEqual(afterRoom.room, first.room); assert.notEqual(afterRoom.room, first.room);
assert.notEqual(afterRoom.seats, first.seats); assert.notEqual(afterRoom.seats, first.seats);
}); });
test('selector memoization remains isolated across independent stores', () => {
const firstStore = new PlatformStore();
const secondStore = new PlatformStore();
firstStore.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
secondStore.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
const firstState = firstStore.getState();
const secondState = secondStore.getState();
const firstSnapshot = selectGameSnapshot(firstState);
const secondSnapshot = selectGameSnapshot(secondState);
assert.notEqual(firstSnapshot, secondSnapshot);
assert.notEqual(firstSnapshot.connection, secondSnapshot.connection);
assert.notEqual(firstSnapshot.self, secondSnapshot.self);
assert.notEqual(firstSnapshot.room, secondSnapshot.room);
assert.notEqual(firstSnapshot.seats, secondSnapshot.seats);
assert.equal(selectGameSnapshot(firstState), firstSnapshot);
assert.equal(selectGameSnapshot(secondState), secondSnapshot);
});