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 {
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user