fix(platform): serialize runtime session transitions

This commit is contained in:
2026-09-05 06:17:04 +08:00
parent 1c124cf216
commit 7e5a950cc7
13 changed files with 1297 additions and 157 deletions
@@ -48,14 +48,20 @@ function makeSession() {
};
const gameSession = new GameSessionHost(entry);
log.length = 0;
const gameHost: GameHost = {
seat: { toView: (seat) => seat, toServer: (seat) => seat },
getSnapshot: () => selectGameSnapshot(store.getState()),
subscribe: () => () => {},
sendGameMessage() {},
execute() {},
};
const session = new RuntimeSession({ store, gameSession, gameHost });
const session = new RuntimeSession({
store,
gameSession,
createGameHostLease: () => ({
host: {
seat: { toView: (seat) => seat, toServer: (seat) => seat },
getSnapshot: () => selectGameSnapshot(store.getState()),
subscribe: () => () => {},
sendGameMessage() {},
execute() {},
},
invalidate() {},
}),
});
return { store, gameSession, session, log, restored, events };
}
@@ -109,3 +115,254 @@ test('RuntimeSession self join commits, opens a fresh session, and restores the
assert.equal(setup.restored[0], join.deskinfo);
assert.deepEqual(setup.log, ['store', 'attach', 'event:room.entered', 'restore']);
});
test('RuntimeSession self join honors the parser-owned deskwar and deskinfo matrix', () => {
const cases = [
{ deskwar: 'missing', deskinfo: false, expectedStage: 0 },
{ deskwar: 0, deskinfo: false, expectedStage: 0 },
{ deskwar: 1, deskinfo: false, expectedStage: 1 },
{ deskwar: 'missing', deskinfo: true, expectedStage: 1 },
{ deskwar: 0, deskinfo: true, expectedStage: 1 },
{ deskwar: 1, deskinfo: true, expectedStage: 1 },
] as const;
for (const row of cases) {
const setup = makeSession();
setup.session.handleLogin(fixture('player-login-success.json'));
const data = fixture('self-join-room.json');
if (row.deskwar === 'missing') delete data.deskwar;
else data.deskwar = row.deskwar;
const deskinfo = { round: 7 };
if (row.deskinfo) data.deskinfo = deskinfo;
else delete data.deskinfo;
data.isbattle = 1;
setup.session.handleSelfJoin(data);
const state = setup.store.getState();
assert.equal(state.room.kind, 'inside');
if (state.room.kind === 'inside') {
assert.equal(state.room.stage, row.expectedStage, JSON.stringify(row));
}
assert.equal(setup.restored.length, row.deskinfo ? 1 : 0, JSON.stringify(row));
if (row.deskinfo) assert.equal(setup.restored[0], deskinfo);
}
});
type LeaseFailurePoint =
| 'lease-factory'
| 'module-create'
| 'attach'
| 'event'
| 'restore'
| 'dispose'
| 'invalidate';
interface LeaseRecord {
readonly host: GameHost;
invalidations: number;
}
function makeLeaseSession() {
const store = new PlatformStore();
const leases: LeaseRecord[] = [];
const attachedHosts: GameHost[] = [];
const failures = new Set<LeaseFailurePoint>();
const errors = Object.fromEntries([
'lease-factory', 'module-create', 'attach', 'event', 'restore', 'dispose', 'invalidate',
].map((point) => [point, new Error(`failure:${point}`)])) as Record<LeaseFailurePoint, Error>;
let validatingEntry = true;
let moduleDisposals = 0;
function host(): GameHost {
return {
seat: { toView: (seat) => seat, toServer: (seat) => seat },
getSnapshot: () => selectGameSnapshot(store.getState()),
subscribe: () => () => {},
sendGameMessage() {},
execute() {},
};
}
const entry: GameEntry = {
key: 'lease-fixture',
gameId: 41,
route: 'fixture-game',
resolveSeatCount: () => 4,
createModule: (): GameModule => {
if (!validatingEntry && failures.has('module-create')) throw errors['module-create'];
return {
attach(attachedHost) {
if (!validatingEntry) attachedHosts.push(attachedHost);
if (!validatingEntry && failures.has('attach')) throw errors.attach;
},
handlePlatformEvent() {
if (!validatingEntry && failures.has('event')) throw errors.event;
},
handleGameMessage() {},
restore() {
if (!validatingEntry && failures.has('restore')) throw errors.restore;
},
dispose() {
if (validatingEntry) return;
moduleDisposals += 1;
if (failures.has('dispose')) throw errors.dispose;
},
};
},
};
const gameSession = new GameSessionHost(entry);
validatingEntry = false;
const sessionOptions = {
store,
gameSession,
createGameHostLease() {
if (failures.has('lease-factory')) throw errors['lease-factory'];
const record: LeaseRecord = { host: host(), invalidations: 0 };
leases.push(record);
return {
host: record.host,
invalidate() {
record.invalidations += 1;
if (failures.has('invalidate')) throw errors.invalidate;
},
};
},
};
const session = new RuntimeSession(sessionOptions);
return {
store,
gameSession,
session,
leases,
attachedHosts,
errors,
setFailures(...points: LeaseFailurePoint[]) {
failures.clear();
for (const point of points) failures.add(point);
},
getModuleDisposals: () => moduleDisposals,
};
}
function caughtError(operation: () => void): Error {
try {
operation();
} catch (error) {
assert.ok(error instanceof Error);
return error;
}
assert.fail('expected operation to throw');
}
test('RuntimeSession creates a fresh host lease per login/self-join replacement and invalidates on self exit', () => {
const setup = makeLeaseSession();
setup.session.handleLogin(fixture('player-login-room.json'));
setup.session.handleSelfJoin(fixture('self-join-room.json'));
setup.session.handleLogin(fixture('player-login-room.json'));
assert.equal(setup.leases.length, 3);
assert.equal(new Set(setup.attachedHosts).size, 3);
assert.deepEqual(setup.leases.map((lease) => lease.invalidations), [1, 1, 0]);
assert.equal(setup.attachedHosts[0], setup.leases[0]?.host);
assert.equal(setup.attachedHosts[1], setup.leases[1]?.host);
assert.equal(setup.attachedHosts[2], setup.leases[2]?.host);
setup.session.handleSelfExit({ raw: {} });
assert.deepEqual(setup.leases.map((lease) => lease.invalidations), [1, 1, 1]);
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.session.state, 'operational');
});
test('RuntimeSession invalidates the active lease on a successful server switch', () => {
const setup = makeLeaseSession();
setup.session.handleLogin(fixture('player-login-room.json'));
setup.session.handleServerSwitch(() => {});
assert.equal(setup.leases[0]?.invalidations, 1);
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.session.state, 'operational');
});
for (const failure of [
'lease-factory', 'module-create', 'attach', 'event', 'restore',
] as const) {
test(`RuntimeSession cleans a failed room open and enters terminal fault at ${failure}`, () => {
const setup = makeLeaseSession();
setup.setFailures(failure);
const error = caughtError(() => setup.session.handleLogin(fixture('player-login-room.json')));
assert.equal(setup.session.state, 'faulted');
assert.equal(setup.session.fault, error);
assert.equal(setup.gameSession.state, 'idle');
if (failure === 'lease-factory') {
assert.equal(setup.leases.length, 0);
} else {
assert.equal(setup.leases.length, 1);
assert.equal(setup.leases[0]?.invalidations, 1);
}
assert.throws(
() => setup.session.handleLogin(fixture('player-login-success.json')),
/fault/i,
);
});
}
for (const replacement of ['login', 'self-join'] as const) {
test(`RuntimeSession invalidates the old lease and faults when ${replacement} replacement disposal fails`, () => {
const setup = makeLeaseSession();
setup.session.handleLogin(fixture('player-login-room.json'));
setup.setFailures('dispose');
const error = caughtError(() => {
if (replacement === 'login') setup.session.handleLogin(fixture('player-login-room.json'));
else setup.session.handleSelfJoin(fixture('self-join-room.json'));
});
assert.equal(setup.session.state, 'faulted');
assert.equal(setup.session.fault, error);
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.leases.length, 1, 'a failed old-session cleanup must not create a new lease');
assert.equal(setup.leases[0]?.invalidations, 1);
assert.equal(setup.getModuleDisposals(), 1);
});
}
for (const operation of ['self-exit', 'switch'] as const) {
test(`RuntimeSession invalidates once and faults when ${operation} disposal fails`, () => {
const setup = makeLeaseSession();
setup.session.handleLogin(fixture('player-login-room.json'));
setup.setFailures('dispose');
const error = caughtError(() => {
if (operation === 'self-exit') setup.session.handleSelfExit({ raw: {} });
else setup.session.handleServerSwitch(() => {});
});
assert.equal(setup.session.state, 'faulted');
assert.equal(setup.session.fault, error);
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.leases[0]?.invalidations, 1);
assert.equal(setup.getModuleDisposals(), 1);
});
}
test('RuntimeSession aggregates event, dispose, and invalidation failures after completing cleanup', () => {
const setup = makeLeaseSession();
setup.setFailures('event', 'dispose', 'invalidate');
const error = caughtError(() => setup.session.handleLogin(fixture('player-login-room.json')));
assert.equal(setup.session.state, 'faulted');
assert.equal(setup.session.fault, error);
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.leases[0]?.invalidations, 1);
assert.deepEqual(
(error as Error & { errors?: readonly unknown[] }).errors,
[setup.errors.event, setup.errors.dispose, setup.errors.invalidate],
);
});