fix(platform): preserve recoverable outcomes and terminal sessions

This commit is contained in:
2026-09-05 10:50:55 +08:00
parent 1e3a21b476
commit 8eae3258ca
16 changed files with 500 additions and 48 deletions
@@ -63,6 +63,7 @@ function setup() {
showLoading() { scenes.push('loading'); }, showLogin() { scenes.push('login'); },
showLobby() { scenes.push('lobby'); }, showRoom() { scenes.push('room'); },
showReconnect() { scenes.push('reconnect'); }, showKicked() { scenes.push('kicked'); },
showServerDenial() { assert.fail('Unexpected server denial'); },
showFatal(error) { fatals.push(error); },
},
loadResources: async () => {}, waitForMinimumDisplay: async () => {}, clock,
@@ -74,6 +74,7 @@ test('real modern vertical replay preserves wire contracts, room ordering and te
const configRequests: Array<{ url: string; init: RequestInit | undefined }> = [];
const scenes: string[] = [];
const fatals: Error[] = [];
const denials: Array<{ readonly rpc: string; readonly state: number; readonly data: unknown }> = [];
const observations: string[] = [];
const attached: GameHost[] = [];
const restored: unknown[] = [];
@@ -154,6 +155,7 @@ test('real modern vertical replay preserves wire contracts, room ordering and te
showLoading() { scenes.push('loading'); }, showLogin() { scenes.push('login'); },
showLobby() { scenes.push('lobby'); }, showRoom() { scenes.push('room'); },
showReconnect() { scenes.push('reconnect'); }, showKicked() { scenes.push('kicked'); },
showServerDenial(outcome) { denials.push(outcome); },
showFatal(error) { fatals.push(error); },
},
loadResources: async () => {}, waitForMinimumDisplay: async () => {}, clock,
@@ -205,6 +207,18 @@ test('real modern vertical replay preserves wire contracts, room ordering and te
await push(agent, fixture('player-login-success'));
assert.equal(scenes[scenes.length - 1], 'lobby');
assert.equal(runtime.state.room.kind, 'outside');
const lobby = runtime.state;
runtime.joinRoom({ roomcode: '999999', location: { latitude: 1, longitude: 2 }, ip: '127.0.0.1' });
expectedFrames.push({ ...JOIN, data: { ...JOIN.data, roomcode: '999999' } });
const denied = await push(agent, {
route: 'agent', rpc: 'self_join_room',
data: { state: 99, roomcode: '999999', showerror: 1, error: 'Room does not exist' },
});
assert.equal(runtime.state, lobby);
assert.equal(scenes[scenes.length - 1], 'lobby');
assert.equal(denials.length, 1);
assert.deepEqual(denials[0], { rpc: 'self_join_room', state: 99, data: denied.data });
assert.equal(denials[0]?.data, denied.data);
runtime.joinRoom({ roomcode: '100001', location: { latitude: 1, longitude: 2 }, ip: '127.0.0.1' });
expectedFrames.push(JOIN);
verifyFrames();
@@ -202,7 +202,7 @@ test('RuntimeSession room replacement keeps the old lease isolated until release
const staleDisposeResults: unknown[] = [];
const gameEntry: GameEntry = {
...entry(),
resolveSeatCount: (roomtype) => roomtype.length === 0 ? 4 : roomtype[0] as number,
resolveSeatCount: (roomtype) => roomtype[0] as number,
createModule: () => {
let host: GameHost | null = null;
return {
@@ -234,6 +234,7 @@ test('RuntimeSession room replacement keeps the old lease isolated until release
const runtime = new RuntimeSession({
store,
gameSession,
onServerDenial() { assert.fail('Unexpected server denial'); },
createGameHostLease: () => createGameHostAdapter({ gameEntry, store, commands, send }),
});
const login = fixture('player-login-room.json');
@@ -25,6 +25,7 @@ function makeSession() {
const store = new PlatformStore();
const log: string[] = [];
const restored: unknown[] = [];
const denials: unknown[] = [];
const events: Array<{ event: PlatformToGameEvent; snapshot: PlatformGameSnapshot }> = [];
let host: GameHost;
const entry: GameEntry = {
@@ -55,6 +56,7 @@ function makeSession() {
const session = new RuntimeSession({
store,
gameSession,
onServerDenial: outcome => { denials.push(outcome); },
createGameHostLease: () => ({
host: {
seat: { toView: (seat) => seat, toServer: (seat) => seat },
@@ -66,9 +68,30 @@ function makeSession() {
invalidate() {},
}),
});
return { store, gameSession, session, log, restored, events };
return { store, gameSession, session, log, restored, events, denials };
}
test('server denials preserve an active room and module without committing a success action', () => {
const setup = makeSession();
setup.session.handleLogin(fixture('player-login-room.json'));
const before = setup.store.getState();
setup.log.length = 0;
const joinDenial = { state: 99, roomcode: '999999', showerror: 1, error: 'Room does not exist' };
const loginDenial = { state: 1, error: 'Login denied' };
setup.session.handleSelfJoin(joinDenial);
setup.session.markLoginPending();
setup.session.handleLogin(loginDenial);
assert.equal(setup.store.getState(), before);
assert.equal(setup.session.loginPending, false);
assert.equal(setup.session.state, 'operational');
assert.equal(setup.gameSession.state, 'active');
assert.deepEqual(setup.log, []);
assert.deepEqual(setup.denials, [
{ rpc: 'self_join_room', state: 99, data: joinDenial },
{ rpc: 'player_login', state: 1, data: loginDenial },
]);
});
test('RuntimeSession commits a room login before opening, publishing, and restoring deskinfo', () => {
const setup = makeSession();
const data = fixture('player-login-room.json');
@@ -220,6 +243,7 @@ function makeLeaseSession() {
const sessionOptions = {
store,
gameSession,
onServerDenial() { assert.fail('Unexpected server denial'); },
createGameHostLease() {
if (failures.has('lease-factory')) throw errors['lease-factory'];
const record: LeaseRecord = { host: host(), invalidations: 0 };
@@ -262,6 +262,7 @@ class RecordingScene implements ScenePort {
showRoom(): void { this.record('room'); }
showReconnect(): void { this.record('reconnect'); }
showKicked(data: unknown): void { this.record('kicked', data); }
showServerDenial(outcome: unknown): void { this.record('denied', outcome); }
showFatal(error: Error): void { this.record('fatal', error); }
}
@@ -303,6 +304,254 @@ function makeRuntime(config: RuntimeConfig = runtimeConfig(), entry = makeGameEn
return { runtime, wire, scene, clock, factoryConfigs, config };
}
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();
wire.emit({ type: 'open', server: 'ws://agent' });
runtime.login(ACCOUNT);
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
const lobby = runtime.state;
const denial = { state: 99, roomcode: '999999', showerror: 1, error: 'Room does not exist' };
runtime.joinRoom({ roomcode: '999999', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room', data: denial } });
assert.equal(runtime.state, lobby);
assert.equal(wire.stopCalls, 0);
assert.deepEqual(scene.calls.filter(call => call.name === 'fatal'), []);
const outcome = scene.calls.find(call => call.name === 'denied')?.value;
assert.deepEqual(outcome, { rpc: 'self_join_room', state: 99, data: denial });
assert.equal((outcome as { data: unknown }).data, denial);
runtime.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json'),
} });
assert.equal(runtime.state.room.kind, 'inside');
assert.equal(scene.calls[scene.calls.length - 1]?.name, 'room');
runtime.stop();
});
test('runtime resolves seats only from the actual game-owned server roomtype at room entry', async () => {
const roomtypes: Array<readonly unknown[]> = [];
const attached: GameHost[] = [];
const setup = makeRuntime(runtimeConfig(), makeGameEntry({
resolveSeatCount(roomtype) {
roomtypes.push(roomtype);
if (roomtype.length !== 7 || roomtype[1] !== 4 || !Array.isArray(roomtype[5])) {
throw new Error('Game roomtype requires the full nested server configuration');
}
return 4;
},
createModule: () => ({
attach(host) { attached.push(host); },
handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {},
}),
}));
await setup.runtime.start();
assert.deepEqual(roomtypes, []);
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
} });
assert.equal(roomtypes.length, 1);
assert.equal(attached.length, 1);
assert.equal(roomtypes[0], attached[0]!.getSnapshot().room.roomtype);
assert.equal(attached[0]!.seat.toView(1), 0);
assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []);
setup.runtime.stop();
});
for (const count of [0, 2.5, 2]) {
test(`runtime rejects invalid game count ${count} at the real room boundary`, async () => {
let attaches = 0;
const setup = makeRuntime(runtimeConfig(), makeGameEntry({
resolveSeatCount: () => count,
createModule: () => ({
attach() { attaches += 1; },
handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {},
}),
}));
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
} });
assert.equal(attaches, 0);
assert.equal(setup.wire.stopCalls, 1);
const failure = setup.scene.calls.find(call => call.name === 'fatal')?.value;
assert.match(String((failure as { errors: unknown[] }).errors[0]), /seat count/);
setup.runtime.stop();
});
}
test('a nonzero login outcome clears its guard, retains state and permits account retry', async () => {
const { runtime, wire, scene, clock } = makeRuntime();
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
const before = runtime.state;
runtime.login(ACCOUNT);
const denial = { state: 7, roomcode: 'unavailable', showerror: 1, error: 'Login denied' };
wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: denial } });
assert.equal(runtime.state, before);
assert.equal(wire.stopCalls, 0);
assert.equal(clock.timers.size, 0);
const outcome = scene.calls.find(call => call.name === 'denied')?.value;
assert.deepEqual(outcome, { rpc: 'player_login', state: 7, data: denial });
assert.equal((outcome as { data: unknown }).data, denial);
assert.deepEqual(scene.calls.filter(call => call.name === 'fatal'), []);
runtime.login({ ...ACCOUNT, openid: 'retry-account' });
assert.equal((wire.sent[wire.sent.length - 1]!.data as Record<string, unknown>).openid, 'retry-account');
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
assert.equal(runtime.state.app.phase, 'logged-in');
runtime.stop();
});
for (const interruption of ['attach', 'restore', 'room-scene', 'room-event'] as const) {
test(`stop from ${interruption} releases the actual module and host without reviving runtime`, async () => {
let runtime: PlatformRuntime;
let attachedHost: GameHost | undefined;
let moduleNumber = 0;
const disposed: number[] = [];
const events: string[] = [];
const entry = makeGameEntry({
createModule() {
const id = moduleNumber++;
return {
attach(host) {
attachedHost = host;
host.subscribe(() => { events.push('snapshot'); });
if (interruption === 'attach') runtime.stop();
},
handlePlatformEvent() {
events.push('room-event');
if (interruption === 'room-event') runtime.stop();
},
handleGameMessage() { events.push('game-message'); },
restore() {
events.push('restore');
if (interruption === 'restore') runtime.stop();
},
dispose() { disposed.push(id); },
};
},
});
const setup = makeRuntime(runtimeConfig(), entry);
runtime = setup.runtime;
setup.scene.onCall = name => {
if (name === 'room' && interruption === 'room-scene') runtime.stop();
};
await runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
runtime.login(ACCOUNT);
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
} });
assert.deepEqual(disposed, [0, 1], 'validation and actual room modules each dispose once');
assert.ok(attachedHost);
const stoppedHost = attachedHost;
assert.throws(() => stoppedHost.getSnapshot(), /disposed/);
assert.throws(() => stoppedHost.subscribe(() => {}), /disposed/);
assert.equal(setup.clock.timers.size, 0);
assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []);
assert.equal(setup.wire.stopCalls, 1);
assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'start', 'unsubscribe', 'stop']);
if (interruption === 'attach') assert.deepEqual(events, ['snapshot']);
if (interruption === 'room-event' || interruption === 'room-scene') {
assert.deepEqual(events, ['snapshot', 'room-event']);
}
const stoppedState = runtime.state;
const stoppedEvents = [...events];
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.wire.emit({ type: 'message', message: {
route: 'fixture-game', rpc: 'late', data: {},
} });
assert.equal(runtime.state, stoppedState);
assert.deepEqual(events, stoppedEvents);
assert.throws(() => runtime.login(ACCOUNT), /stopped/);
await assert.rejects(runtime.start(), /stopped/);
runtime.stop();
assert.deepEqual(disposed, [0, 1]);
assert.equal(setup.wire.stopCalls, 1);
});
}
test('stop reentered by an old module disposal prevents replacement room activation', async () => {
let runtime: PlatformRuntime;
const attached: GameHost[] = [];
let disposals = 0;
const setup = makeRuntime(runtimeConfig(), makeGameEntry({
createModule() {
let host: GameHost | undefined;
return {
attach(value) { host = value; attached.push(value); },
handlePlatformEvent() {}, handleGameMessage() {}, restore() {},
dispose() {
if (host === undefined) return;
disposals += 1;
runtime.stop();
},
};
},
}));
runtime = setup.runtime;
await runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
} });
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
} });
assert.equal(disposals, 1);
assert.equal(attached.length, 1);
assert.throws(() => attached[0]!.getSnapshot(), /disposed/);
assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []);
assert.equal(setup.wire.stopCalls, 1);
assert.throws(() => runtime.login(ACCOUNT), /stopped/);
runtime.stop();
});
test('an actual callback error after stop remains explicit without changing the stopped lifecycle', async () => {
let runtime: PlatformRuntime;
const failure = new Error('restore callback failed after stop');
const setup = makeRuntime(runtimeConfig(), makeGameEntry({
createModule: () => ({
attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, dispose() {},
restore() { runtime.stop(); throw failure; },
}),
}));
runtime = setup.runtime;
await runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
const thrown = thrownValue(() => setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
} }));
assert.deepEqual((thrown as { errors: unknown[] }).errors, [failure]);
assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []);
assert.throws(() => runtime.login(ACCOUNT), /stopped/);
runtime.stop();
assert.equal(setup.wire.stopCalls, 1);
});
for (const rpc of ['player_login', 'self_join_room']) {
test(`malformed successful ${rpc} still terminates explicitly`, async () => {
const { runtime, wire, scene } = makeRuntime();
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
wire.emit({ type: 'message', message: { route: 'agent', rpc, data: { state: 0 } } });
assert.equal(wire.stopCalls, 1);
assert.equal(scene.calls.filter(call => call.name === 'fatal').length, 1);
assert.deepEqual(scene.calls.filter(call => call.name === 'denied'), []);
runtime.stop();
});
}
function makeLoggingEntry(log: string[], restored: unknown[], attached: GameHost[]): GameEntry {
let recording = false;
const entry = makeGameEntry({
@@ -82,6 +82,7 @@ function makeHandlers() {
const session = new RuntimeSession({
store,
gameSession,
onServerDenial() { assert.fail('Unexpected server denial'); },
createGameHostLease: () => ({
host: {
seat: { toView: (seat) => seat, toServer: (seat) => seat },
@@ -156,6 +157,7 @@ function makeReentrantRouterFixture(log: string[]) {
const session = new RuntimeSession({
store,
gameSession,
onServerDenial() { assert.fail('Unexpected server denial'); },
createGameHostLease: () => ({
host: {
seat: { toView: (seat) => seat, toServer: (seat) => seat },
@@ -17,7 +17,11 @@ import {
type GameContractHost,
} from '../../YouleNexus/assets/framework/sdk/testing/game-contract-harness.ts';
test('assertGameContract runs the reusable isolated session conformance suite', () => {
const GAME_ROOMTYPE = JSON.parse(readFileSync(
new URL('../fixtures/contracts/player-login-room.json', import.meta.url), 'utf8',
)).data.roomtype as readonly unknown[];
test('assertGameContract accepts a fixed-four game with its real nested roomtype and host fixture', () => {
let invalidations = 0;
let nextModuleId = 0;
const restoredDeskinfo: unknown[] = [];
@@ -25,17 +29,17 @@ test('assertGameContract runs the reusable isolated session conformance suite',
key: 'fixture',
gameId: 41,
route: 'fixture-route',
resolveSeatCount: (roomtype) => {
const seats = roomtype[0];
if (seats === 2 || seats === 4 || seats === 10) return seats;
return 4;
},
resolveSeatCount: () => 4,
createModule: () => makeModule(++nextModuleId, (deskinfo) => { restoredDeskinfo.push(deskinfo); }),
};
assert.doesNotThrow(() => assertGameContract(
entry,
makeConformanceHost(entry, () => { invalidations++; }, restoredDeskinfo),
[{
roomtype: GAME_ROOMTYPE,
expectedSeatCount: 4,
makeHost: makeConformanceHost(entry, () => { invalidations++; }, restoredDeskinfo),
}],
));
assert.equal(invalidations, 2);
assert.equal(restoredDeskinfo.length, 0);
@@ -65,7 +69,8 @@ function makeModule(id: number, onRestore: (deskinfo: unknown) => void): GameMod
handlePlatformEvent(event) {
eventCount++;
if (event.type === 'room.entered') {
assert.equal(event.roomtype[0], 4);
assert.deepEqual(event.roomtype, GAME_ROOMTYPE);
assert.deepEqual(host!.getSnapshot().room.roomtype, event.roomtype);
}
},
handleGameMessage(message) {
@@ -105,7 +110,6 @@ function makeConformanceHost(
new URL('../fixtures/contracts/player-login-room.json', import.meta.url),
'utf8',
)) as { data: Record<string, unknown> };
login.data.roomtype = [4];
login.data.isbattle = 0;
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(login.data));
@@ -73,7 +73,7 @@ function makeModule(log: string[], onRestore?: (deskinfo: unknown) => void): Gam
};
}
test('assertGameEntry rejects invalid identity, routes, seat counts, and module factories', () => {
test('assertGameEntry rejects invalid identity, routes, resolver presence, and module factories', () => {
const entry = makeEntry(() => makeModule([]));
assert.throws(() => assertGameEntry({ ...entry, key: '' }), /key/);
@@ -81,11 +81,24 @@ test('assertGameEntry rejects invalid identity, routes, seat counts, and module
for (const route of ['platform', 'agent', 'room']) {
assert.throws(() => assertGameEntry({ ...entry, route }), /reserved.*route/);
}
assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: () => 0 }), /seat count/);
assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: () => 2.5 }), /seat count/);
assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: undefined as never }), /resolveSeatCount/);
assert.throws(() => assertGameEntry({ ...entry, createModule: () => undefined as never }), /module/);
});
test('GameEntry preflight does not call a resolver before its game-owned roomtype exists', () => {
const calls: Array<readonly unknown[]> = [];
const entry = {
...makeEntry(() => makeModule([])),
resolveSeatCount(roomtype: readonly unknown[]) {
calls.push(roomtype);
if (roomtype[1] !== 4) throw new Error('Game roomtype requires four seats at index 1');
return 4;
},
};
assert.doesNotThrow(() => new GameSessionHost(entry));
assert.deepEqual(calls, []);
});
test('assertGameEntry disposes its validation module exactly once', () => {
let disposals = 0;
const entry = makeEntry(() => ({
@@ -428,3 +441,76 @@ test('GameSessionHost returns to active after a restore handler throws', () => {
assert.throws(() => sessions.restore({}), /restore failed/);
assert.equal(sessions.state, 'active');
});
for (const callback of ['attach', 'restore'] as const) {
for (const throwsAfterStop of [false, true]) {
test(`terminal dispose during ${callback} survives callback completion (throws=${throwsAfterStop})`, () => {
let sessions: GameSessionHost;
let disposals = 0;
const failure = new Error('callback failed after disposal');
sessions = new GameSessionHost(makeEntry(() => ({
...makeModule([]),
[callback]() {
sessions.dispose();
sessions.dispose();
assert.equal(sessions.state, 'disposed');
if (throwsAfterStop) throw failure;
},
dispose() { disposals += 1; },
})));
disposals = 0;
const invoke = () => {
sessions.open(makeHost());
if (callback === 'restore') sessions.restore({ round: 1 });
};
if (throwsAfterStop) assert.equal(caughtError(invoke), failure);
else assert.doesNotThrow(invoke);
assert.equal(disposals, 1);
assert.equal(sessions.state, 'disposed');
sessions.dispose();
assert.equal(disposals, 1);
assert.throws(() => sessions.open(makeHost()), /disposed/);
assert.throws(() => sessions.restore({}), /disposed/);
});
}
}
for (const release of ['close', 'dispose'] as const) {
test(`${release} tolerates module.dispose reentering terminal disposal`, () => {
let sessions: GameSessionHost | undefined;
let disposals = 0;
sessions = new GameSessionHost(makeEntry(() => ({
...makeModule([]),
dispose() {
if (sessions === undefined) return;
disposals += 1;
sessions.dispose();
},
})));
sessions.open(makeHost());
assert.doesNotThrow(() => sessions![release]());
assert.equal(sessions.state, 'disposed');
assert.equal(disposals, 1);
sessions.dispose();
assert.equal(disposals, 1);
});
}
test('terminal disposal during module creation cleans the returned module without attaching it', () => {
let sessions: GameSessionHost | undefined;
let attaches = 0;
let disposals = 0;
sessions = new GameSessionHost(makeEntry(() => {
if (sessions !== undefined) sessions.dispose();
return {
...makeModule([]),
attach() { attaches += 1; },
dispose() { disposals += 1; },
};
}));
disposals = 0;
assert.doesNotThrow(() => sessions!.open(makeHost()));
assert.equal(disposals, 1);
assert.equal(attaches, 0);
assert.equal(sessions.state, 'disposed');
});