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
@@ -5,6 +5,7 @@ export interface GameEntry {
readonly key: string;
readonly gameId: string | number;
readonly route: string;
/** Interprets this game's actual server roomtype; no synthetic/default input is required. */
resolveSeatCount(roomtype: readonly unknown[]): number;
createModule(): GameModule;
}
@@ -85,10 +85,6 @@ export function assertGameEntry(entry: GameEntry): void {
if (typeof entry.resolveSeatCount !== 'function') {
throw new Error('GameEntry resolveSeatCount must be a function');
}
const seatCount = entry.resolveSeatCount([]);
if (!Number.isInteger(seatCount) || seatCount <= 0) {
throw new Error(`GameEntry seat count must be a positive integer: ${seatCount}`);
}
if (typeof entry.createModule !== 'function') {
throw new Error('GameEntry createModule must be a function');
}
@@ -130,24 +126,28 @@ export class GameSessionHost {
}
this.currentState = 'attaching';
let module: GameModule | undefined;
try {
module = this.entry.createModule();
assertGameModule(module);
const module = this.entry.createModule();
this.module = module;
// A factory may stop the application before returning its new instance.
if (this.isDisposed()) {
this.releaseModule();
return;
}
assertGameModule(module);
module.attach(host);
this.currentState = 'active';
if (this.currentState === 'attaching') this.currentState = 'active';
} catch (primaryError) {
this.module = undefined;
if (!this.isDisposed()) this.currentState = 'disposing';
let cleanupFailed = false;
let cleanupError: unknown;
try {
disposeModuleIfPresent(module);
this.releaseModule();
} catch (error) {
cleanupFailed = true;
cleanupError = error;
} finally {
this.currentState = 'idle';
if (!this.isDisposed()) this.currentState = 'idle';
}
if (cleanupFailed) throw new GameSessionOpenFault(primaryError, cleanupError);
throw primaryError;
@@ -171,7 +171,7 @@ export class GameSessionHost {
try {
module.restore(deskinfo);
} finally {
this.currentState = 'active';
if (this.currentState === 'restoring') this.currentState = 'active';
}
}
@@ -184,27 +184,32 @@ export class GameSessionHost {
throw new Error(`Cannot close game session while ${this.currentState}`);
}
const module = this.module;
if (!module) {
if (!this.module) {
throw new Error('Active game session has no module');
}
this.currentState = 'disposing';
this.module = undefined;
try {
module.dispose();
this.releaseModule();
} finally {
this.currentState = 'idle';
if (!this.isDisposed()) this.currentState = 'idle';
}
}
dispose(): void {
if (this.currentState === 'disposed') return;
try {
if (this.currentState !== 'idle') this.close();
} finally {
this.module = undefined;
this.currentState = 'disposed';
}
if (this.isDisposed()) return;
// Terminal state is visible before user cleanup, including reentrant dispose.
this.currentState = 'disposed';
this.releaseModule();
}
private isDisposed(): boolean {
return this.currentState === 'disposed';
}
private releaseModule(): void {
const module = this.module;
this.module = undefined;
disposeModuleIfPresent(module);
}
private requireActive(operation: string): GameModule {
@@ -14,14 +14,26 @@ export interface GameContractHost {
export type GameContractHostFactory = () => GameContractHost;
/** The game owns each room configuration, its expected count and matching Host fixture. */
export interface GameContractSample {
readonly roomtype: readonly unknown[];
readonly expectedSeatCount: number;
readonly makeHost: GameContractHostFactory;
}
/** Runs the shared lifecycle checks required of the app's sole GameEntry. */
export function assertGameContract(entry: GameEntry, makeHost: GameContractHostFactory): void {
export function assertGameContract(entry: GameEntry, samples: readonly GameContractSample[]): void {
assert.ok(samples.length > 0, 'conformance requires game-owned roomtype samples');
assertGameEntry(entry);
for (const route of ['platform', 'agent', 'room']) {
assert.throws(() => assertGameEntry({ ...entry, route }), /reserved.*route/);
}
for (const seatCount of [2, 4, 10]) {
assert.equal(entry.resolveSeatCount([seatCount]), seatCount, `roomtype must resolve ${seatCount} seats`);
for (const sample of samples) {
assert.ok(
Number.isInteger(sample.expectedSeatCount) && sample.expectedSeatCount > 0,
'sample expectedSeatCount must be a positive integer',
);
assert.equal(entry.resolveSeatCount(sample.roomtype), sample.expectedSeatCount);
}
const firstFactoryModule = entry.createModule();
@@ -35,21 +47,28 @@ export function assertGameContract(entry: GameEntry, makeHost: GameContractHostF
}
const sessions = new GameSessionHost(entry);
runCycle(sessions, makeHost(), entry, 1);
runCycle(sessions, makeHost(), entry, 2);
for (const sample of samples) {
runCycle(sessions, sample, entry, 1);
runCycle(sessions, sample, entry, 2);
}
assert.equal(sessions.state, 'idle');
}
function runCycle(
sessions: GameSessionHost,
contractHost: GameContractHost,
sample: GameContractSample,
entry: GameEntry,
cycle: number,
): void {
const roomtype = [4] as const;
const contractHost = sample.makeHost();
const snapshot = contractHost.host.getSnapshot();
assert.deepEqual(snapshot.room.roomtype, sample.roomtype, 'Host fixture must match the game sample');
// Public seats contain occupied seats only; the mapper owns the full room range.
assert.doesNotThrow(() => contractHost.host.seat.toView(sample.expectedSeatCount - 1));
assert.throws(() => contractHost.host.seat.toView(sample.expectedSeatCount));
const deskinfo = { cycle };
sessions.open(contractHost.host);
sessions.publish({ type: 'room.entered', roomtype });
sessions.publish({ type: 'room.entered', roomtype: sample.roomtype });
sessions.dispatchGameMessage(entry.route, { rpc: 'conformance.message', data: { cycle } });
assert.throws(
() => sessions.dispatchGameMessage(`${entry.route}.other`, { rpc: 'conformance.message', data: { cycle } }),