fix(platform): serialize runtime session transitions
This commit is contained in:
@@ -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],
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user