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