fix(platform): stabilize store snapshots and notifications

This commit is contained in:
2026-09-05 03:29:49 +08:00
parent 462e2e9267
commit 9840ed70b2
2 changed files with 383 additions and 33 deletions
@@ -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<object, object>();
const active = new WeakSet<object>();
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<string, unknown>;
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<string, unknown> : {};
}
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<PlatformState> = signal(INITIAL_STATE);
private state: PlatformState = INITIAL_STATE;
private readonly listeners = new Set<PlatformStateListener>();
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),
}),
});
}));
}
}