From 462e2e92671b112ccf8c267b4e6410c9f63fe20e Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 03:12:19 +0800 Subject: [PATCH] fix(platform): separate room stage contracts --- .../platform/stores/platform-store.ts | 161 +++++++++++----- .../platform/platform-store.test.ts | 176 +++++++++++++++++- .../platform/selectors.test.ts | 20 ++ 3 files changed, 311 insertions(+), 46 deletions(-) diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts index 8087257..ac2f09f 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts @@ -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(); - - 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(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(); + const active = new WeakSet(); + 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; + 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 : {}; + } + + 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 = {}; 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, diff --git a/cocoscreator_projects/framework-tests/platform/platform-store.test.ts b/cocoscreator_projects/framework-tests/platform/platform-store.test.ts index 5ee6982..aefb1da 100644 --- a/cocoscreator_projects/framework-tests/platform/platform-store.test.ts +++ b/cocoscreator_projects/framework-tests/platform/platform-store.test.ts @@ -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; + 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; + 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); +}); diff --git a/cocoscreator_projects/framework-tests/platform/selectors.test.ts b/cocoscreator_projects/framework-tests/platform/selectors.test.ts index 45ee659..d970bda 100644 --- a/cocoscreator_projects/framework-tests/platform/selectors.test.ts +++ b/cocoscreator_projects/framework-tests/platform/selectors.test.ts @@ -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); +});