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,
|
||||
|
||||
@@ -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', () => {
|
||||
const data = fixture('player-login-room.json').data;
|
||||
const sourceRoomtype = data.roomtype as unknown[];
|
||||
const expectedRoomtype = structuredClone(sourceRoomtype);
|
||||
const parsedLogin = parseLoginResponse(data);
|
||||
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(typeof state.players.entities[430511]?.isprepare, '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[5]), 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.room, false);
|
||||
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.infinite, 0);
|
||||
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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const store = new PlatformStore();
|
||||
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 throwing = Object.create(null) as Record<string, unknown>;
|
||||
let getterCalls = 0;
|
||||
Object.defineProperty(throwing, 'boom', {
|
||||
enumerable: true,
|
||||
get(): never {
|
||||
getterCalls += 1;
|
||||
throw new Error('roomtype getter boom');
|
||||
},
|
||||
});
|
||||
@@ -397,10 +471,104 @@ test('roomtype traversal failure freezes nothing and leaves store state untouche
|
||||
let notifications = 0;
|
||||
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(throwing), false);
|
||||
assert.equal(Object.isFrozen(roomtype), false);
|
||||
assert.equal(store.getState(), before);
|
||||
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.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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user