fix(platform): preserve recoverable outcomes and terminal sessions
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user