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 ac2f09f..9f8e3dd 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts @@ -1,4 +1,4 @@ -import { signal, type Reactive, type Unsubscribe } from '../../core/reactive.ts'; +import type { Unsubscribe } from '../../core/reactive.ts'; import { hasOwn, requireArray, @@ -74,10 +74,70 @@ function roomtypePropertyPath(path: string, key: string, isArray: boolean): stri return `${path}[${JSON.stringify(key)}]`; } +interface RoomtypePropertySnapshot { + readonly key: PropertyKey; + readonly path: string; + readonly descriptor: PropertyDescriptor; +} + +interface RoomtypeObjectSnapshot { + readonly source: object; + readonly path: string; + readonly isArray: boolean; + readonly prototype: object | null; + readonly keys: readonly PropertyKey[]; + readonly properties: readonly RoomtypePropertySnapshot[]; +} + +function samePropertyDescriptor( + first: PropertyDescriptor, + second: PropertyDescriptor, +): boolean { + const firstIsData = 'value' in first; + const secondIsData = 'value' in second; + if (firstIsData !== secondIsData) return false; + if ( + first.enumerable !== second.enumerable + || first.configurable !== second.configurable + ) return false; + if (firstIsData && secondIsData) { + return first.writable === second.writable && Object.is(first.value, second.value); + } + return Object.is(first.get, second.get) && Object.is(first.set, second.set); +} + +function verifyRoomtypeSourceGraph(graph: readonly RoomtypeObjectSnapshot[]): void { + for (const snapshot of graph) { + if (Array.isArray(snapshot.source) !== snapshot.isArray) { + throw new TypeError(`${snapshot.path}: unstable roomtype container kind`); + } + if (!Object.is(Object.getPrototypeOf(snapshot.source), snapshot.prototype)) { + throw new TypeError(`${snapshot.path}: unstable roomtype prototype`); + } + const keys = Reflect.ownKeys(snapshot.source); + if ( + keys.length !== snapshot.keys.length + || keys.some((key, index) => !Object.is(key, snapshot.keys[index])) + ) { + throw new TypeError(`${snapshot.path}: unstable roomtype own keys`); + } + for (const property of snapshot.properties) { + const descriptor = Reflect.getOwnPropertyDescriptor(snapshot.source, property.key); + if ( + descriptor === undefined + || !samePropertyDescriptor(property.descriptor, descriptor) + ) { + throw new TypeError(`${property.path}: unstable roomtype property descriptor`); + } + } + } +} + function cloneAndFreezeRoomtype(value: readonly unknown[]): readonly unknown[] { const copies = new WeakMap(); const active = new WeakSet(); const ownedGraph: object[] = []; + const sourceGraph: RoomtypeObjectSnapshot[] = []; const copy = (current: unknown, path: string): unknown => { if (current === null) return null; @@ -103,40 +163,47 @@ function cloneAndFreezeRoomtype(value: readonly unknown[]): readonly unknown[] { throw new TypeError(`${path}: expected JSON array or plain object container`); } + const keys = Reflect.ownKeys(current); + const properties: RoomtypePropertySnapshot[] = []; + for (const key of keys) { + if (typeof key !== 'string') { + throw new TypeError(`${path}: symbol properties are not valid roomtype data`); + } + const propertyPath = roomtypePropertyPath(path, key, sourceIsArray); + const descriptor = Reflect.getOwnPropertyDescriptor(current, key); + if (descriptor === undefined) { + throw new TypeError(`${propertyPath}: unstable property structure`); + } + properties.push({ key, path: propertyPath, descriptor }); + } + + const lengthProperty = properties.find((property) => property.key === 'length'); let target: unknown[] | Record; let arrayLength = 0; if (sourceIsArray) { - const lengthDescriptor = Reflect.getOwnPropertyDescriptor(current, 'length'); - if (lengthDescriptor === undefined || !('value' in lengthDescriptor)) { + if (lengthProperty === undefined || !('value' in lengthProperty.descriptor)) { throw new TypeError(`${path}.length: expected stable data property`); } - arrayLength = requireInteger(lengthDescriptor.value, `${path}.length`); + arrayLength = requireInteger(lengthProperty.descriptor.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 : {}; } + sourceGraph.push({ source: current, path, isArray: sourceIsArray, prototype, keys, properties }); copies.set(current, target); active.add(current); ownedGraph.push(target); - const keys = Reflect.ownKeys(current); - for (const key of keys) { + for (const property of properties) { + const { key, path: propertyPath, descriptor } = property; 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`); } @@ -156,6 +223,7 @@ function cloneAndFreezeRoomtype(value: readonly unknown[]): readonly unknown[] { }; const canonical = copy(value, 'roomtype') as readonly unknown[]; + verifyRoomtypeSourceGraph(sourceGraph); for (let index = ownedGraph.length - 1; index >= 0; index -= 1) { Object.freeze(ownedGraph[index]); } @@ -323,15 +391,54 @@ const INITIAL_STATE: PlatformState = Object.freeze({ export type PlatformStateListener = (state: PlatformState, previous: PlatformState) => void; +interface PlatformTransition { + readonly state: PlatformState; + readonly previous: PlatformState; + readonly listeners: readonly PlatformStateListener[]; +} + export class PlatformStore { - private readonly stateCell: Reactive = signal(INITIAL_STATE); + private state: PlatformState = INITIAL_STATE; + private readonly listeners = new Set(); + private readonly transitions: PlatformTransition[] = []; + private publishing = false; getState(): PlatformState { - return this.stateCell.value; + return this.state; } subscribe(listener: PlatformStateListener): Unsubscribe { - return this.stateCell.subscribe(listener); + this.listeners.add(listener); + return (): void => { this.listeners.delete(listener); }; + } + + private commit(state: PlatformState): void { + if (Object.is(state, this.state)) return; + const previous = this.state; + this.state = state; + this.transitions.push({ + state, + previous, + listeners: [...this.listeners], + }); + if (this.publishing) return; + + this.publishing = true; + try { + while (this.transitions.length > 0) { + const transition = this.transitions.shift(); + if (transition === undefined) continue; + for (const listener of transition.listeners) { + try { + listener(transition.state, transition.previous); + } catch (error) { + console.error('[reactive] subscriber error:', error); + } + } + } + } finally { + this.publishing = false; + } } applyLoginSuccess(parsed: ParsedLoginResponse): void { @@ -374,11 +481,11 @@ export class PlatformStore { player, requireProtocolFlag(raw.isbattle, '$.login.raw.isbattle'), ); - this.stateCell.value = Object.freeze({ + this.commit(Object.freeze({ app: Object.freeze({ phase: 'logged-in' }), players: roomResult.players, room: roomResult.room, - }); + })); } replaceRoom(parsed: ParsedRoomResponse): void { @@ -423,11 +530,11 @@ export class PlatformStore { ? 1 : requireProtocolFlag(raw.deskwar, '$.response.raw.deskwar'); const roomResult = buildRoom(input.room, raw, selfPlayer, stage); - this.stateCell.value = Object.freeze({ + this.commit(Object.freeze({ app: previous.app, players: roomResult.players, room: roomResult.room, - }); + })); } clearRoom(): void { @@ -441,14 +548,14 @@ export class PlatformStore { if (selfPlayer === undefined) { throw new Error('players.entities: missing authenticated player'); } - this.stateCell.value = Object.freeze({ + this.commit(Object.freeze({ app: previous.app, players: Object.freeze({ selfPlayerId, entities: Object.freeze({ [selfPlayerId]: selfPlayer }), }), room: Object.freeze({ kind: 'outside' }), - }); + })); } playerJoin(parsed: ParsedSeatPayload): void { @@ -480,7 +587,7 @@ export class PlatformStore { ? [...room.offlineSeats, event.seat] : [...room.offlineSeats]; - this.stateCell.value = Object.freeze({ + this.commit(Object.freeze({ app: previous.app, players: Object.freeze({ selfPlayerId: previous.players.selfPlayerId, @@ -492,7 +599,7 @@ export class PlatformStore { readySeats: Object.freeze(readySeats), offlineSeats: Object.freeze(offlineSeats), }), - }); + })); } playerReady(parsed: ParsedSeatPayload): void { @@ -512,14 +619,14 @@ export class PlatformStore { [playerid]: Object.freeze({ ...player, isprepare: 1 }), }); const readySeats = Object.freeze([...room.readySeats, event.seat]); - this.stateCell.value = Object.freeze({ + this.commit(Object.freeze({ app: previous.app, players: Object.freeze({ selfPlayerId: previous.players.selfPlayerId, entities, }), room: Object.freeze({ ...room, readySeats }), - }); + })); } playerOffline(parsed: ParsedSeatPayload): void { @@ -539,14 +646,14 @@ export class PlatformStore { [playerid]: Object.freeze({ ...player, onstate: 1 }), }); const offlineSeats = Object.freeze([...room.offlineSeats, event.seat]); - this.stateCell.value = Object.freeze({ + this.commit(Object.freeze({ app: previous.app, players: Object.freeze({ selfPlayerId: previous.players.selfPlayerId, entities, }), room: Object.freeze({ ...room, offlineSeats }), - }); + })); } playerOnline(parsed: ParsedOnlinePayload): void { @@ -573,14 +680,14 @@ export class PlatformStore { const offlineSeats = Object.freeze( room.offlineSeats.filter((seat) => seat !== event.seat), ); - this.stateCell.value = Object.freeze({ + this.commit(Object.freeze({ app: previous.app, players: Object.freeze({ selfPlayerId: previous.players.selfPlayerId, entities, }), room: Object.freeze({ ...room, offlineSeats }), - }); + })); } playerExit(parsed: ParsedSeatPayload): void { @@ -601,7 +708,7 @@ export class PlatformStore { const readySeats = room.readySeats.filter((seat) => seat !== event.seat); const offlineSeats = room.offlineSeats.filter((seat) => seat !== event.seat); - this.stateCell.value = Object.freeze({ + this.commit(Object.freeze({ app: previous.app, players: Object.freeze({ selfPlayerId: previous.players.selfPlayerId, @@ -613,6 +720,6 @@ export class PlatformStore { readySeats: Object.freeze(readySeats), offlineSeats: Object.freeze(offlineSeats), }), - }); + })); } } diff --git a/cocoscreator_projects/framework-tests/platform/platform-store.test.ts b/cocoscreator_projects/framework-tests/platform/platform-store.test.ts index aefb1da..81ff8bc 100644 --- a/cocoscreator_projects/framework-tests/platform/platform-store.test.ts +++ b/cocoscreator_projects/framework-tests/platform/platform-store.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts'; +import type { PlatformState } from '../../YouleNexus/assets/framework/platform/stores/platform-types.ts'; import { parseLoginResponse, parseOtherExitRoomPayload, @@ -572,3 +573,245 @@ test('cyclic roomtype input is rejected atomically without mutating caller data' assert.equal(store.getState(), before); assert.equal(notifications, 0); }); + +test('roomtype descriptor changes between copy and verification fail atomically', () => { + const sibling = { stable: true }; + const target = { value: 0 }; + let descriptorReads = 0; + const unstable = new Proxy(target, { + getOwnPropertyDescriptor(object, key): PropertyDescriptor | undefined { + const descriptor = Reflect.getOwnPropertyDescriptor(object, key); + if (key !== 'value' || descriptor === undefined) return descriptor; + descriptorReads += 1; + return { ...descriptor, value: descriptorReads }; + }, + }); + const roomtype = [sibling, unstable]; + 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\]\.value.*unstable/i); + assert.equal(descriptorReads >= 2, true); + assert.equal(target.value, 0); + assert.equal(Object.isFrozen(roomtype), false); + assert.equal(Object.isFrozen(sibling), false); + assert.equal(Object.isFrozen(target), false); + assert.equal(store.getState(), before); + assert.equal(notifications, 0); +}); + +test('roomtype own-key changes between copy and verification fail atomically', () => { + const target = { first: 1, second: 2 }; + let ownKeyReads = 0; + const unstable = new Proxy(target, { + ownKeys(): ArrayLike { + ownKeyReads += 1; + return ownKeyReads === 1 ? ['first'] : ['second']; + }, + }); + const roomtype = [unstable]; + 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\[0\].*unstable.*own keys/i); + assert.equal(ownKeyReads >= 2, true); + assert.deepEqual(target, { first: 1, second: 2 }); + assert.equal(Object.isFrozen(roomtype), false); + assert.equal(Object.isFrozen(target), false); + assert.equal(store.getState(), before); + assert.equal(notifications, 0); +}); + +test('canonical roomtype preserves sparse length, holes, and negative zero', () => { + const sparse = new Array(5); + sparse[1] = -0; + sparse[4] = { tail: true }; + const data = fixture('player-login-room.json').data; + data.roomtype = sparse; + const store = new PlatformStore(); + store.applyLoginSuccess(parseLoginResponse(data)); + const state = store.getState(); + + assert.equal(state.room.kind, 'inside'); + if (state.room.kind !== 'inside') return; + assert.equal(state.room.roomtype.length, 5); + assert.equal(0 in state.room.roomtype, false); + assert.equal(1 in state.room.roomtype, true); + assert.equal(Object.is(state.room.roomtype[1], -0), true); + assert.equal(2 in state.room.roomtype, false); + assert.equal(3 in state.room.roomtype, false); + assert.deepEqual(state.room.roomtype[4], { tail: true }); + assert.equal(Object.isFrozen(state.room.roomtype[4]), true); + assert.equal(Object.isFrozen(sparse), false); + assert.equal(Object.isFrozen(sparse[4]), false); +}); + +test('canonical roomtype keeps own __proto__ data without prototype pollution', () => { + const source = {} as Record; + Object.defineProperty(source, '__proto__', { + configurable: true, + enumerable: true, + writable: true, + value: { polluted: true }, + }); + const data = fixture('player-login-room.json').data; + data.roomtype = [source]; + const store = new PlatformStore(); + store.applyLoginSuccess(parseLoginResponse(data)); + const state = store.getState(); + + assert.equal(state.room.kind, 'inside'); + if (state.room.kind !== 'inside') return; + const canonical = state.room.roomtype[0] as Record; + assert.equal(Object.getPrototypeOf(canonical), Object.prototype); + assert.equal(Object.prototype.hasOwnProperty.call(canonical, '__proto__'), true); + assert.deepEqual(canonical.__proto__, { polluted: true }); + assert.equal(({} as { polluted?: boolean }).polluted, undefined); + assert.equal(Object.isFrozen(canonical.__proto__), true); +}); + +test('non-JSON roomtype own data is rejected without state or caller mutation', () => { + const cases: ReadonlyArray unknown[]]> = [ + ['symbol', () => { + const source = { value: 1 } as Record; + source[Symbol('hidden')] = 2; + return [source]; + }], + ['enumerable', () => { + const source = { value: 1 }; + Object.defineProperty(source, 'hidden', { enumerable: false, value: 2 }); + return [source]; + }], + ['finite', () => [Number.NaN]], + ]; + + for (const [error, makeRoomtype] of cases) { + const roomtype = makeRoomtype(); + const data = fixture('player-login-room.json').data; + data.roomtype = roomtype; + const parsed = parseLoginResponse(data); + const store = new PlatformStore(); + const before = store.getState(); + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + + assert.throws(() => store.applyLoginSuccess(parsed), new RegExp(error, 'i')); + assert.equal(Object.isFrozen(roomtype), false); + if (typeof roomtype[0] === 'object' && roomtype[0] !== null) { + assert.equal(Object.isFrozen(roomtype[0]), false); + } + assert.equal(store.getState(), before); + assert.equal(notifications, 0); + } +}); + +test('reentrant store commits publish each stable transition before the next', () => { + const store = new PlatformStore(); + const events: string[] = []; + let nested = false; + const describe = (label: string, next: PlatformState, previous: PlatformState): void => { + events.push(`${label}:${next.room.kind}<-${previous.room.kind}`); + }; + store.subscribe((next, previous) => { + describe('A', next, previous); + if (!nested && next.room.kind === 'inside' && previous.room.kind === 'outside') { + nested = true; + store.clearRoom(); + } + }); + store.subscribe((next, previous) => { describe('B', next, previous); }); + + store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); + + assert.deepEqual(events, [ + 'A:inside<-outside', + 'B:inside<-outside', + 'A:outside<-inside', + 'B:outside<-inside', + ]); + assert.equal(store.getState().room.kind, 'outside'); +}); + +test('multiple reentrant commits retain their real previous and next states', () => { + const store = new PlatformStore(); + const events: string[] = []; + let nested = false; + store.subscribe((next, previous) => { + events.push(`A:${next.room.kind}<-${previous.room.kind}`); + if (!nested && next.room.kind === 'inside' && previous.room.kind === 'outside') { + nested = true; + store.clearRoom(); + assert.equal(store.getState().room.kind, 'outside'); + store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); + assert.equal(store.getState().room.kind, 'inside'); + } + }); + store.subscribe((next, previous) => { + events.push(`B:${next.room.kind}<-${previous.room.kind}`); + }); + + store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); + + assert.deepEqual(events, [ + 'A:inside<-outside', 'B:inside<-outside', + 'A:outside<-inside', 'B:outside<-inside', + 'A:inside<-outside', 'B:inside<-outside', + ]); +}); + +test('subscription changes during publish apply only to later transition snapshots', () => { + const store = new PlatformStore(); + const events: string[] = []; + let nested = false; + let unsubscribeB = (): void => undefined; + store.subscribe((next, previous) => { + events.push(`A:${next.room.kind}<-${previous.room.kind}`); + if (!nested && next.room.kind === 'inside') { + nested = true; + unsubscribeB(); + store.subscribe((laterNext, laterPrevious) => { + events.push(`C:${laterNext.room.kind}<-${laterPrevious.room.kind}`); + }); + store.clearRoom(); + } + }); + unsubscribeB = store.subscribe((next, previous) => { + events.push(`B:${next.room.kind}<-${previous.room.kind}`); + }); + + store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); + + assert.deepEqual(events, [ + 'A:inside<-outside', + 'B:inside<-outside', + 'A:outside<-inside', + 'C:outside<-inside', + ]); +}); + +test('subscriber exceptions remain isolated from later store listeners', () => { + const store = new PlatformStore(); + let reached = false; + const originalConsoleError = console.error; + console.error = (): void => undefined; + try { + store.subscribe(() => { throw new Error('subscriber boom'); }); + store.subscribe(() => { reached = true; }); + store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data)); + } finally { + console.error = originalConsoleError; + } + assert.equal(reached, true); +});