feat(platform): expose lifecycle-safe UI observation

This commit is contained in:
2026-09-05 11:50:12 +08:00
parent 2d7655a5d7
commit bd45503d47
2 changed files with 419 additions and 3 deletions
@@ -304,6 +304,332 @@ function makeRuntime(config: RuntimeConfig = runtimeConfig(), entry = makeGameEn
return { runtime, wire, scene, clock, factoryConfigs, config };
}
test('UI observes canonical commits and unsubscribe is idempotent', async () => {
const { runtime, wire } = makeRuntime();
const seen: unknown[] = [];
const off = runtime.subscribeState(
state => { seen.push(state); },
error => { throw error; },
);
assert.equal(seen.length, 0);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(seen[seen.length - 1], runtime.state);
off();
off();
const count = seen.length;
runtime.login(ACCOUNT);
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
assert.equal(seen.length, count);
runtime.stop();
assert.throws(() => runtime.subscribeState(() => {}, () => {}), /stopped/);
});
test('a failing UI observer reports its original error once while other observers continue', async () => {
const { runtime, wire } = makeRuntime();
const failure = { source: 'render' };
const reported: unknown[] = [];
let failingCalls = 0;
const healthyStates: unknown[] = [];
runtime.subscribeState(
() => {
failingCalls += 1;
throw failure;
},
error => { reported.push(error); },
);
runtime.subscribeState(
state => { healthyStates.push(state); },
error => { throw error; },
);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(failingCalls, 1);
assert.deepEqual(reported, [failure]);
assert.equal(reported[0], failure);
assert.equal(healthyStates[0], runtime.state);
runtime.login(ACCOUNT);
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
assert.equal(failingCalls, 1);
assert.equal(healthyStates[healthyStates.length - 1], runtime.state);
runtime.stop();
});
test('an onError failure enters fatal with both UI error identities and cancels observers', async () => {
const { runtime, wire, scene } = makeRuntime();
const listenerFailure = { source: 'listener' };
const onErrorFailure = { source: 'onError' };
let laterObserverCalls = 0;
runtime.subscribeState(
() => { throw listenerFailure; },
() => { throw onErrorFailure; },
);
runtime.subscribeState(
() => { laterObserverCalls += 1; },
error => { throw error; },
);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(laterObserverCalls, 0);
assert.equal(wire.stopCalls, 1);
const fatal = scene.calls.find(call => call.name === 'fatal')?.value;
assert.ok(fatal instanceof Error);
const errors = (fatal as Error & { readonly errors: readonly unknown[] }).errors;
assert.equal(errors.length, 2);
assert.equal(errors[0], listenerFailure);
assert.equal(errors[1], onErrorFailure);
assert.throws(() => runtime.subscribeState(() => {}, () => {}), /fatal/);
});
for (const terminal of ['stop', 'fatal', 'kick'] as const) {
test(`${terminal} invalidates a late Store delivery and rejects new UI observers`, async () => {
const originalSubscribe = PlatformStore.prototype.subscribe;
let retainedListener: Parameters<PlatformStore['subscribe']>[0] | null = null;
PlatformStore.prototype.subscribe = function retainFirstListener(listener) {
retainedListener ??= listener;
return originalSubscribe.call(this, listener);
};
try {
const setup = makeRuntime();
let calls = 0;
setup.runtime.subscribeState(
() => { calls += 1; },
error => { throw error; },
);
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
const callsBeforeTermination = calls;
setup.wire.onAction = (name) => {
if (name !== 'stop') return;
const listener = retainedListener;
assert.ok(listener);
listener(setup.runtime.state, setup.runtime.state);
};
if (terminal === 'stop') {
setup.runtime.stop();
} else if (terminal === 'fatal') {
setup.scene.onCall = (name) => {
if (name === 'reconnect') throw new Error('reconnect render failed');
};
setup.wire.emit({ type: 'slow' });
} else {
setup.runtime.login(ACCOUNT);
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'kick_server', data: { code: 1, msg: 'terminal' },
} });
}
assert.equal(
calls,
callsBeforeTermination + (terminal === 'fatal' ? 1 : 0),
'observers must be inactive before terminal cleanup callbacks',
);
const callsAtTermination = calls;
const lateListener = retainedListener as Parameters<PlatformStore['subscribe']>[0] | null;
assert.ok(lateListener);
lateListener(setup.runtime.state, setup.runtime.state);
assert.equal(calls, callsAtTermination);
assert.throws(
() => setup.runtime.subscribeState(() => {}, () => {}),
new RegExp(terminal === 'kick' ? 'kicked' : terminal),
);
} finally {
PlatformStore.prototype.subscribe = originalSubscribe;
}
});
}
test('stop during a Store commit cancels later observers and prevents scene revival', async () => {
const setup = makeRuntime();
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
let laterObserverCalls = 0;
let resubscribeFailure: unknown;
setup.runtime.subscribeState(
() => {
setup.runtime.stop();
resubscribeFailure = thrownValue(
() => setup.runtime.subscribeState(() => {}, () => {}),
);
},
error => { throw error; },
);
setup.runtime.subscribeState(
() => { laterObserverCalls += 1; },
error => { throw error; },
);
const sceneCallsBefore = setup.scene.calls.length;
setup.wire.emit({ type: 'slow' });
assert.equal(laterObserverCalls, 0);
assert.match(String(resubscribeFailure), /stopped/);
assert.equal(setup.scene.calls.length, sceneCallsBefore);
assert.equal(setup.wire.stopCalls, 1);
});
test('listener and onError failures after stopping escape the Store boundary without reviving fatal', async () => {
const setup = makeRuntime();
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
const listenerFailure = { source: 'listener-after-stop' };
const onErrorFailure = { source: 'onError-after-stop' };
const reported: unknown[] = [];
setup.runtime.subscribeState(
() => {
setup.runtime.stop();
throw listenerFailure;
},
error => {
reported.push(error);
setup.runtime.stop();
throw onErrorFailure;
},
);
const sceneCallsBefore = setup.scene.calls.length;
const thrown = thrownValue(() => { setup.wire.emit({ type: 'slow' }); });
assert.deepEqual(reported, [listenerFailure]);
assert.ok(thrown instanceof Error);
const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors;
assert.equal(errors.length, 2);
assert.equal(errors[0], listenerFailure);
assert.equal(errors[1], onErrorFailure);
assert.equal(setup.scene.calls.length, sceneCallsBefore);
assert.equal(setup.scene.calls.filter(call => call.name === 'fatal').length, 0);
assert.equal(setup.wire.stopCalls, 1);
assert.throws(() => setup.runtime.subscribeState(() => {}, () => {}), /stopped/);
});
test('stopped observer errors survive a later active-room publish failure', async () => {
const setup = makeRuntime();
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.runtime.login(ACCOUNT);
const roomLogin = fixture('player-login-room.json');
(roomLogin.players as unknown[])[2] = {
playerid: 430512,
nickname: '同桌玩家',
avatar: 'http://b',
sex: 0,
bean: 10,
isprepare: 0,
onstate: 0,
};
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: roomLogin,
} });
const listenerFailure = { source: 'room-listener-after-stop' };
const onErrorFailure = { source: 'room-onError-after-stop' };
setup.runtime.subscribeState(
() => {
setup.runtime.stop();
throw listenerFailure;
},
() => { throw onErrorFailure; },
);
const thrown = thrownValue(() => {
setup.wire.emit({ type: 'message', message: {
route: 'room', rpc: 'player_prepare', data: { seat: 2 },
} });
});
assert.ok(thrown instanceof Error);
const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors;
assert.equal(errors.length, 2);
const observerFailure = errors[0];
assert.ok(observerFailure instanceof Error);
const observerErrors = (
observerFailure as Error & { readonly errors: readonly unknown[] }
).errors;
assert.equal(observerErrors[0], listenerFailure);
assert.equal(observerErrors[1], onErrorFailure);
assert.match(String(errors[1]), /disposed game session/i);
assert.equal(setup.scene.calls.filter(call => call.name === 'fatal').length, 0);
assert.throws(() => setup.runtime.subscribeState(() => {}, () => {}), /stopped/);
});
test('UI observers receive each committed snapshot even when a listener commits reentrantly', async () => {
const setup = makeRuntime();
const delivered: unknown[] = [];
let firstCommit: unknown;
let latestDuringFirstCommit: unknown;
let reentered = false;
setup.runtime.subscribeState(
state => {
if (reentered) return;
reentered = true;
firstCommit = state;
setup.wire.emit({ type: 'slow' });
latestDuringFirstCommit = setup.runtime.state;
},
error => { throw error; },
);
setup.runtime.subscribeState(
state => { delivered.push(state); },
error => { throw error; },
);
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
assert.notEqual(firstCommit, latestDuringFirstCommit);
assert.equal(delivered[0], firstCommit);
assert.equal(delivered[1], latestDuringFirstCommit);
assert.deepEqual(
delivered.map(state => (state as { app: { phase: string } }).app.phase),
['connected', 'slow'],
);
setup.runtime.stop();
});
test('cancelling and subscribing observers during a commit cannot alter that delivery snapshot', async () => {
const setup = makeRuntime();
let cancelledCalls = 0;
const replacementPhases: string[] = [];
let changedObservers = false;
let offCancelled = (): void => {};
setup.runtime.subscribeState(
() => {
if (changedObservers) return;
changedObservers = true;
offCancelled();
setup.runtime.subscribeState(
state => { replacementPhases.push(state.app.phase); },
error => { throw error; },
);
},
error => { throw error; },
);
offCancelled = setup.runtime.subscribeState(
() => { cancelledCalls += 1; },
error => { throw error; },
);
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(cancelledCalls, 0);
assert.deepEqual(replacementPhases, []);
setup.wire.emit({ type: 'slow' });
assert.equal(cancelledCalls, 0);
assert.deepEqual(replacementPhases, ['slow']);
setup.runtime.stop();
});
test('a denied join retains the lobby root, reports the raw outcome and permits a valid retry', async () => {
const { runtime, wire, scene } = makeRuntime();
await runtime.start();