fix(platform): separate room stage contracts
This commit is contained in:
+119
-42
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user