fix(platform): stabilize store snapshots and notifications
This commit is contained in:
+140
-33
@@ -1,4 +1,4 @@
|
|||||||
import { signal, type Reactive, type Unsubscribe } from '../../core/reactive.ts';
|
import type { Unsubscribe } from '../../core/reactive.ts';
|
||||||
import {
|
import {
|
||||||
hasOwn,
|
hasOwn,
|
||||||
requireArray,
|
requireArray,
|
||||||
@@ -74,10 +74,70 @@ function roomtypePropertyPath(path: string, key: string, isArray: boolean): stri
|
|||||||
return `${path}[${JSON.stringify(key)}]`;
|
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[] {
|
function cloneAndFreezeRoomtype(value: readonly unknown[]): readonly unknown[] {
|
||||||
const copies = new WeakMap<object, object>();
|
const copies = new WeakMap<object, object>();
|
||||||
const active = new WeakSet<object>();
|
const active = new WeakSet<object>();
|
||||||
const ownedGraph: object[] = [];
|
const ownedGraph: object[] = [];
|
||||||
|
const sourceGraph: RoomtypeObjectSnapshot[] = [];
|
||||||
|
|
||||||
const copy = (current: unknown, path: string): unknown => {
|
const copy = (current: unknown, path: string): unknown => {
|
||||||
if (current === null) return null;
|
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`);
|
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<string, unknown>;
|
let target: unknown[] | Record<string, unknown>;
|
||||||
let arrayLength = 0;
|
let arrayLength = 0;
|
||||||
if (sourceIsArray) {
|
if (sourceIsArray) {
|
||||||
const lengthDescriptor = Reflect.getOwnPropertyDescriptor(current, 'length');
|
if (lengthProperty === undefined || !('value' in lengthProperty.descriptor)) {
|
||||||
if (lengthDescriptor === undefined || !('value' in lengthDescriptor)) {
|
|
||||||
throw new TypeError(`${path}.length: expected stable data property`);
|
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`);
|
if (arrayLength < 0) throw new RangeError(`${path}.length: expected non-negative integer`);
|
||||||
target = new Array(arrayLength);
|
target = new Array(arrayLength);
|
||||||
} else {
|
} else {
|
||||||
target = prototype === null ? Object.create(null) as Record<string, unknown> : {};
|
target = prototype === null ? Object.create(null) as Record<string, unknown> : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sourceGraph.push({ source: current, path, isArray: sourceIsArray, prototype, keys, properties });
|
||||||
copies.set(current, target);
|
copies.set(current, target);
|
||||||
active.add(current);
|
active.add(current);
|
||||||
ownedGraph.push(target);
|
ownedGraph.push(target);
|
||||||
const keys = Reflect.ownKeys(current);
|
for (const property of properties) {
|
||||||
for (const key of keys) {
|
const { key, path: propertyPath, descriptor } = property;
|
||||||
if (sourceIsArray && key === 'length') continue;
|
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) {
|
if (sourceIsArray) {
|
||||||
const index = Number(key);
|
const index = Number(key);
|
||||||
if (!Number.isInteger(index) || index < 0 || index >= arrayLength || String(index) !== key) {
|
if (!Number.isInteger(index) || index < 0 || index >= arrayLength || String(index) !== key) {
|
||||||
throw new TypeError(`${propertyPath}: expected array index`);
|
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)) {
|
if (!('value' in descriptor)) {
|
||||||
throw new TypeError(`${propertyPath}: accessor properties are not allowed in roomtype`);
|
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[];
|
const canonical = copy(value, 'roomtype') as readonly unknown[];
|
||||||
|
verifyRoomtypeSourceGraph(sourceGraph);
|
||||||
for (let index = ownedGraph.length - 1; index >= 0; index -= 1) {
|
for (let index = ownedGraph.length - 1; index >= 0; index -= 1) {
|
||||||
Object.freeze(ownedGraph[index]);
|
Object.freeze(ownedGraph[index]);
|
||||||
}
|
}
|
||||||
@@ -323,15 +391,54 @@ const INITIAL_STATE: PlatformState = Object.freeze({
|
|||||||
|
|
||||||
export type PlatformStateListener = (state: PlatformState, previous: PlatformState) => void;
|
export type PlatformStateListener = (state: PlatformState, previous: PlatformState) => void;
|
||||||
|
|
||||||
|
interface PlatformTransition {
|
||||||
|
readonly state: PlatformState;
|
||||||
|
readonly previous: PlatformState;
|
||||||
|
readonly listeners: readonly PlatformStateListener[];
|
||||||
|
}
|
||||||
|
|
||||||
export class PlatformStore {
|
export class PlatformStore {
|
||||||
private readonly stateCell: Reactive<PlatformState> = signal(INITIAL_STATE);
|
private state: PlatformState = INITIAL_STATE;
|
||||||
|
private readonly listeners = new Set<PlatformStateListener>();
|
||||||
|
private readonly transitions: PlatformTransition[] = [];
|
||||||
|
private publishing = false;
|
||||||
|
|
||||||
getState(): PlatformState {
|
getState(): PlatformState {
|
||||||
return this.stateCell.value;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(listener: PlatformStateListener): Unsubscribe {
|
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 {
|
applyLoginSuccess(parsed: ParsedLoginResponse): void {
|
||||||
@@ -374,11 +481,11 @@ export class PlatformStore {
|
|||||||
player,
|
player,
|
||||||
requireProtocolFlag(raw.isbattle, '$.login.raw.isbattle'),
|
requireProtocolFlag(raw.isbattle, '$.login.raw.isbattle'),
|
||||||
);
|
);
|
||||||
this.stateCell.value = Object.freeze({
|
this.commit(Object.freeze({
|
||||||
app: Object.freeze({ phase: 'logged-in' }),
|
app: Object.freeze({ phase: 'logged-in' }),
|
||||||
players: roomResult.players,
|
players: roomResult.players,
|
||||||
room: roomResult.room,
|
room: roomResult.room,
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceRoom(parsed: ParsedRoomResponse): void {
|
replaceRoom(parsed: ParsedRoomResponse): void {
|
||||||
@@ -423,11 +530,11 @@ export class PlatformStore {
|
|||||||
? 1
|
? 1
|
||||||
: requireProtocolFlag(raw.deskwar, '$.response.raw.deskwar');
|
: requireProtocolFlag(raw.deskwar, '$.response.raw.deskwar');
|
||||||
const roomResult = buildRoom(input.room, raw, selfPlayer, stage);
|
const roomResult = buildRoom(input.room, raw, selfPlayer, stage);
|
||||||
this.stateCell.value = Object.freeze({
|
this.commit(Object.freeze({
|
||||||
app: previous.app,
|
app: previous.app,
|
||||||
players: roomResult.players,
|
players: roomResult.players,
|
||||||
room: roomResult.room,
|
room: roomResult.room,
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
clearRoom(): void {
|
clearRoom(): void {
|
||||||
@@ -441,14 +548,14 @@ 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');
|
||||||
}
|
}
|
||||||
this.stateCell.value = Object.freeze({
|
this.commit(Object.freeze({
|
||||||
app: previous.app,
|
app: previous.app,
|
||||||
players: Object.freeze({
|
players: Object.freeze({
|
||||||
selfPlayerId,
|
selfPlayerId,
|
||||||
entities: Object.freeze({ [selfPlayerId]: selfPlayer }),
|
entities: Object.freeze({ [selfPlayerId]: selfPlayer }),
|
||||||
}),
|
}),
|
||||||
room: Object.freeze({ kind: 'outside' }),
|
room: Object.freeze({ kind: 'outside' }),
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
playerJoin(parsed: ParsedSeatPayload): void {
|
playerJoin(parsed: ParsedSeatPayload): void {
|
||||||
@@ -480,7 +587,7 @@ export class PlatformStore {
|
|||||||
? [...room.offlineSeats, event.seat]
|
? [...room.offlineSeats, event.seat]
|
||||||
: [...room.offlineSeats];
|
: [...room.offlineSeats];
|
||||||
|
|
||||||
this.stateCell.value = Object.freeze({
|
this.commit(Object.freeze({
|
||||||
app: previous.app,
|
app: previous.app,
|
||||||
players: Object.freeze({
|
players: Object.freeze({
|
||||||
selfPlayerId: previous.players.selfPlayerId,
|
selfPlayerId: previous.players.selfPlayerId,
|
||||||
@@ -492,7 +599,7 @@ export class PlatformStore {
|
|||||||
readySeats: Object.freeze(readySeats),
|
readySeats: Object.freeze(readySeats),
|
||||||
offlineSeats: Object.freeze(offlineSeats),
|
offlineSeats: Object.freeze(offlineSeats),
|
||||||
}),
|
}),
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
playerReady(parsed: ParsedSeatPayload): void {
|
playerReady(parsed: ParsedSeatPayload): void {
|
||||||
@@ -512,14 +619,14 @@ export class PlatformStore {
|
|||||||
[playerid]: Object.freeze({ ...player, isprepare: 1 }),
|
[playerid]: Object.freeze({ ...player, isprepare: 1 }),
|
||||||
});
|
});
|
||||||
const readySeats = Object.freeze([...room.readySeats, event.seat]);
|
const readySeats = Object.freeze([...room.readySeats, event.seat]);
|
||||||
this.stateCell.value = Object.freeze({
|
this.commit(Object.freeze({
|
||||||
app: previous.app,
|
app: previous.app,
|
||||||
players: Object.freeze({
|
players: Object.freeze({
|
||||||
selfPlayerId: previous.players.selfPlayerId,
|
selfPlayerId: previous.players.selfPlayerId,
|
||||||
entities,
|
entities,
|
||||||
}),
|
}),
|
||||||
room: Object.freeze({ ...room, readySeats }),
|
room: Object.freeze({ ...room, readySeats }),
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
playerOffline(parsed: ParsedSeatPayload): void {
|
playerOffline(parsed: ParsedSeatPayload): void {
|
||||||
@@ -539,14 +646,14 @@ export class PlatformStore {
|
|||||||
[playerid]: Object.freeze({ ...player, onstate: 1 }),
|
[playerid]: Object.freeze({ ...player, onstate: 1 }),
|
||||||
});
|
});
|
||||||
const offlineSeats = Object.freeze([...room.offlineSeats, event.seat]);
|
const offlineSeats = Object.freeze([...room.offlineSeats, event.seat]);
|
||||||
this.stateCell.value = Object.freeze({
|
this.commit(Object.freeze({
|
||||||
app: previous.app,
|
app: previous.app,
|
||||||
players: Object.freeze({
|
players: Object.freeze({
|
||||||
selfPlayerId: previous.players.selfPlayerId,
|
selfPlayerId: previous.players.selfPlayerId,
|
||||||
entities,
|
entities,
|
||||||
}),
|
}),
|
||||||
room: Object.freeze({ ...room, offlineSeats }),
|
room: Object.freeze({ ...room, offlineSeats }),
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
playerOnline(parsed: ParsedOnlinePayload): void {
|
playerOnline(parsed: ParsedOnlinePayload): void {
|
||||||
@@ -573,14 +680,14 @@ export class PlatformStore {
|
|||||||
const offlineSeats = Object.freeze(
|
const offlineSeats = Object.freeze(
|
||||||
room.offlineSeats.filter((seat) => seat !== event.seat),
|
room.offlineSeats.filter((seat) => seat !== event.seat),
|
||||||
);
|
);
|
||||||
this.stateCell.value = Object.freeze({
|
this.commit(Object.freeze({
|
||||||
app: previous.app,
|
app: previous.app,
|
||||||
players: Object.freeze({
|
players: Object.freeze({
|
||||||
selfPlayerId: previous.players.selfPlayerId,
|
selfPlayerId: previous.players.selfPlayerId,
|
||||||
entities,
|
entities,
|
||||||
}),
|
}),
|
||||||
room: Object.freeze({ ...room, offlineSeats }),
|
room: Object.freeze({ ...room, offlineSeats }),
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
playerExit(parsed: ParsedSeatPayload): void {
|
playerExit(parsed: ParsedSeatPayload): void {
|
||||||
@@ -601,7 +708,7 @@ export class PlatformStore {
|
|||||||
const readySeats = room.readySeats.filter((seat) => seat !== event.seat);
|
const readySeats = room.readySeats.filter((seat) => seat !== event.seat);
|
||||||
const offlineSeats = room.offlineSeats.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,
|
app: previous.app,
|
||||||
players: Object.freeze({
|
players: Object.freeze({
|
||||||
selfPlayerId: previous.players.selfPlayerId,
|
selfPlayerId: previous.players.selfPlayerId,
|
||||||
@@ -613,6 +720,6 @@ export class PlatformStore {
|
|||||||
readySeats: Object.freeze(readySeats),
|
readySeats: Object.freeze(readySeats),
|
||||||
offlineSeats: Object.freeze(offlineSeats),
|
offlineSeats: Object.freeze(offlineSeats),
|
||||||
}),
|
}),
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
||||||
|
import type { PlatformState } from '../../YouleNexus/assets/framework/platform/stores/platform-types.ts';
|
||||||
import {
|
import {
|
||||||
parseLoginResponse,
|
parseLoginResponse,
|
||||||
parseOtherExitRoomPayload,
|
parseOtherExitRoomPayload,
|
||||||
@@ -572,3 +573,245 @@ test('cyclic roomtype input is rejected atomically without mutating caller data'
|
|||||||
assert.equal(store.getState(), before);
|
assert.equal(store.getState(), before);
|
||||||
assert.equal(notifications, 0);
|
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<string | symbol> {
|
||||||
|
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<unknown>(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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<readonly [string, () => unknown[]]> = [
|
||||||
|
['symbol', () => {
|
||||||
|
const source = { value: 1 } as Record<PropertyKey, unknown>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user