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
@@ -3,21 +3,43 @@ import { GameSessionHost } from '../sdk/runtime/game-session-host.ts';
import {
parseLoginResponse,
parseSelfJoinRoomResponse,
type ParsedSeatPayload,
type ParsedSelfExitPayload,
type ReconnectSnapshot,
} from '../protocol/contracts/index.ts';
import { PlatformStore } from './stores/platform-store.ts';
import type { PlatformState } from './stores/platform-types.ts';
export interface GameHostLeasePort {
readonly host: GameHost;
invalidate(): void;
}
export type GameHostLeaseFactory = () => GameHostLeasePort;
export interface RuntimeSessionOptions {
readonly store: PlatformStore;
readonly gameSession: GameSessionHost;
readonly gameHost: GameHost;
readonly createGameHostLease: GameHostLeaseFactory;
}
export type RuntimeSessionState = 'operational' | 'faulted' | 'kicked';
export class RuntimeSessionFault extends Error {
readonly errors: readonly unknown[];
constructor(operation: string, errors: readonly unknown[]) {
super(`RuntimeSession fault during ${operation}`);
this.name = 'RuntimeSessionFault';
this.errors = Object.freeze([...errors]);
}
}
/** Coordinates the canonical platform snapshot with the one active game session. */
export class RuntimeSession {
private loginIsPending = false;
private wasKicked = false;
private terminalFault: RuntimeSessionFault | null = null;
private activeLease: GameHostLeasePort | null = null;
constructor(private readonly options: RuntimeSessionOptions) {}
@@ -25,83 +47,166 @@ export class RuntimeSession {
get kicked(): boolean { return this.wasKicked; }
get state(): RuntimeSessionState {
if (this.wasKicked) return 'kicked';
return this.terminalFault !== null ? 'faulted' : 'operational';
}
get fault(): Error | null { return this.terminalFault; }
markLoginPending(): void {
this.requireNotKicked('mark login pending');
this.requireOperational();
this.loginIsPending = true;
}
handleLogin(data: unknown): void {
this.requireNotKicked('handle login');
this.requireOperational();
this.loginIsPending = false;
const parsed = parseLoginResponse(data);
this.options.store.applyLoginSuccess(parsed);
const committed = this.options.store.applyLoginSuccess(parsed);
if (parsed.room === null) {
this.closeActiveGame();
this.releaseOrFault('login lobby replacement');
return;
}
this.openCommittedRoom(parsed.reconnect);
this.openCommittedRoom(committed, parsed.reconnect, 'login room open');
}
handleSelfJoin(data: unknown): void {
this.requireNotKicked('handle self join');
this.requireOperational();
const parsed = parseSelfJoinRoomResponse(data);
this.options.store.replaceRoom(parsed);
this.openCommittedRoom(parsed.reconnect);
const committed = this.options.store.replaceRoom(parsed);
this.openCommittedRoom(committed, parsed.reconnect, 'self join room open');
}
handleSelfExit(parsed: ParsedSeatPayload): void {
this.requireNotKicked('handle self exit');
handleSelfExit(parsed: ParsedSelfExitPayload): void {
this.requireOperational();
const state = this.options.store.getState();
if (state.room.kind !== 'inside') {
throw new Error('self_exit_room requires an active room');
if (this.options.gameSession.state === 'idle' && this.activeLease === null) return;
const errors = [
new Error('self_exit_room found an active game without room state'),
...this.releaseGame(),
];
this.throwFault('self exit inconsistent state', errors);
}
if (parsed.seat !== state.room.selfSeat) {
if (parsed.seat !== undefined && parsed.seat !== state.room.selfSeat) {
throw new RangeError(
`self_exit_room seat mismatch: expected ${state.room.selfSeat}, received ${parsed.seat}`,
);
}
if (this.options.gameSession.state !== 'active') {
throw new Error('self_exit_room requires an active game session');
if (this.options.gameSession.state !== 'active' || this.activeLease === null) {
const errors = [
new Error('self_exit_room requires an active game session and host lease'),
...this.releaseGame(),
];
this.throwFault('self exit inconsistent state', errors);
}
this.options.store.clearRoom();
this.options.gameSession.close();
try {
this.options.store.clearRoom();
} catch (error) {
this.throwFault('self exit state commit', [error, ...this.releaseGame()]);
}
this.releaseOrFault('self exit cleanup');
}
handleServerSwitch(): void {
this.requireNotKicked('handle server switch');
this.closeActiveGame();
handleServerSwitch(switchConnection: () => void): void {
this.requireOperational();
const errors = this.releaseGame();
try {
switchConnection();
} catch (error) {
errors.push(error);
}
if (errors.length > 0) this.throwFault('server switch', errors);
}
handleKick(_data: unknown): void {
this.closeActiveGame();
handleKick(_data: unknown, stopConnection: () => void): void {
if (this.wasKicked) return;
this.requireOperational();
this.loginIsPending = false;
this.wasKicked = true;
const errors = this.releaseGame();
try {
stopConnection();
} catch (error) {
errors.push(error);
}
if (errors.length > 0) this.throwFault('kick terminal cleanup', errors);
}
private openCommittedRoom(reconnect: ReconnectSnapshot): void {
const state = this.options.store.getState();
private openCommittedRoom(
state: PlatformState,
reconnect: ReconnectSnapshot,
operation: string,
): void {
if (state.room.kind !== 'inside') {
throw new Error('Cannot open a game session without committed room state');
this.throwFault(operation, [new Error('Cannot open a game session without committed room state')]);
}
const priorCleanupErrors = this.releaseGame();
if (priorCleanupErrors.length > 0) this.throwFault(operation, priorCleanupErrors);
let lease: GameHostLeasePort;
try {
lease = this.options.createGameHostLease();
if (!lease || typeof lease !== 'object' || typeof lease.invalidate !== 'function') {
throw new TypeError('createGameHostLease must return { host, invalidate }');
}
} catch (error) {
this.throwFault(operation, [error]);
}
this.activeLease = lease;
try {
this.options.gameSession.open(lease.host);
this.options.gameSession.publish({
type: 'room.entered',
roomtype: state.room.roomtype,
});
if (reconnect.present) this.options.gameSession.restore(reconnect.value);
} catch (error) {
this.throwFault(operation, [error, ...this.releaseGame()]);
}
this.closeActiveGame();
this.options.gameSession.open(this.options.gameHost);
this.options.gameSession.publish({
type: 'room.entered',
roomtype: state.room.roomtype,
});
if (reconnect.present) this.options.gameSession.restore(reconnect.value);
}
private closeActiveGame(): void {
const state = this.options.gameSession.state;
if (state === 'idle') return;
if (state !== 'active') {
throw new Error(`Cannot close game session while ${state}`);
}
this.options.gameSession.close();
private releaseOrFault(operation: string): void {
const errors = this.releaseGame();
if (errors.length > 0) this.throwFault(operation, errors);
}
private requireNotKicked(operation: string): void {
if (this.wasKicked) throw new Error(`Cannot ${operation} after kick_server`);
private releaseGame(): unknown[] {
const errors: unknown[] = [];
const lease = this.activeLease;
this.activeLease = null;
if (this.options.gameSession.state !== 'idle') {
try {
this.options.gameSession.close();
} catch (error) {
errors.push(error);
}
}
if (lease !== null) {
try {
lease.invalidate();
} catch (error) {
errors.push(error);
}
}
return errors;
}
private throwFault(operation: string, errors: readonly unknown[]): never {
const fault = new RuntimeSessionFault(operation, errors);
this.loginIsPending = false;
this.terminalFault = fault;
throw fault;
}
private requireOperational(): void {
if (this.terminalFault !== null) throw this.terminalFault;
if (this.wasKicked) throw new Error('RuntimeSession is kicked');
}
}
@@ -422,8 +422,8 @@ export class PlatformStore {
return (): void => { this.listeners.delete(listener); };
}
private commit(state: PlatformState): void {
if (Object.is(state, this.state)) return;
private commit(state: PlatformState): PlatformState {
if (Object.is(state, this.state)) return state;
const previous = this.state;
this.state = state;
this.transitions.push({
@@ -431,7 +431,7 @@ export class PlatformStore {
previous,
listeners: [...this.listeners],
});
if (this.publishing) return;
if (this.publishing) return state;
this.publishing = true;
try {
@@ -449,9 +449,10 @@ export class PlatformStore {
} finally {
this.publishing = false;
}
return state;
}
applyLoginSuccess(parsed: ParsedLoginResponse): void {
applyLoginSuccess(parsed: ParsedLoginResponse): PlatformState {
const input = requireRecord(parsed, '$.login');
const raw = requireRecord(input.raw, '$.login.raw');
const state = requireMatchingState(input.state, raw, '$.login');
@@ -491,14 +492,14 @@ export class PlatformStore {
player,
requireProtocolFlag(raw.isbattle, '$.login.raw.isbattle'),
);
this.commit(Object.freeze({
return this.commit(Object.freeze({
app: Object.freeze({ phase: 'logged-in' }),
players: roomResult.players,
room: roomResult.room,
}));
}
replaceRoom(parsed: ParsedRoomResponse): void {
replaceRoom(parsed: ParsedRoomResponse): PlatformState {
const input = requireRecord(parsed, '$.response');
const raw = requireRecord(input.raw, '$.response.raw');
const state = requireMatchingState(input.state, raw, '$.response');
@@ -536,11 +537,12 @@ export class PlatformStore {
'$.response.reconnect.value: expected canonical raw deskinfo reference',
);
}
const stage = reconnectPresent
? 1
: requireProtocolFlag(raw.deskwar, '$.response.raw.deskwar');
const stage = requireProtocolFlag(input.stage, '$.response.stage');
if (reconnectPresent && stage !== 1) {
throw new RangeError('$.response.stage: reconnect room must be in battle stage');
}
const roomResult = buildRoom(input.room, raw, selfPlayer, stage);
this.commit(Object.freeze({
return this.commit(Object.freeze({
app: previous.app,
players: roomResult.players,
room: roomResult.room,
@@ -33,6 +33,7 @@ export {
parseOtherOfflinePayload,
parseOtherOnlinePayload,
parsePlayerPreparePayload,
parseSelfExitRoomPayload,
parseSelfJoinRoomResponse,
type JoinRoomEnvironment,
type JoinRoomPayload,
@@ -43,6 +44,7 @@ export {
type ParsedRoomResponse,
type ParsedRoomserverPayload,
type ParsedRoomSnapshot,
type ParsedSelfExitPayload,
type ParsedSeatPayload,
type ReconnectSnapshot,
type RoomActionPayload,
@@ -150,6 +150,7 @@ export interface ParsedRoomResponse {
readonly state: number;
readonly room: ParsedRoomSnapshot | null;
readonly reconnect: ReconnectSnapshot;
readonly stage: 0 | 1;
readonly raw: JsonRecord;
}
@@ -157,7 +158,13 @@ export function parseSelfJoinRoomResponse(value: unknown): ParsedRoomResponse {
const raw = requireRecord(value, '$.data');
const state = requireInteger(raw.state, '$.data.state');
const room = state === 0 ? parseRoomEntryFields(raw) : null;
return { state, room, reconnect: reconnectFrom(raw), raw };
const parsedDeskwar = optionalField(raw, 'deskwar', '$.data', requireInteger);
if (parsedDeskwar !== undefined && parsedDeskwar !== 0 && parsedDeskwar !== 1) {
throw new RangeError('$.data.deskwar: expected protocol flag 0 or 1');
}
const reconnect = reconnectFrom(raw);
const stage = reconnect.present ? 1 : parsedDeskwar ?? 0;
return { state, room, reconnect, stage, raw };
}
export interface ParsedSeatPayload {
@@ -177,6 +184,20 @@ export const parseOtherJoinRoomPayload = parseSeatPayload;
export const parseOtherExitRoomPayload = parseSeatPayload;
export const parseOtherOfflinePayload = parseSeatPayload;
export interface ParsedSelfExitPayload {
readonly seat?: number;
readonly raw: JsonRecord;
}
export function parseSelfExitRoomPayload(value: unknown): ParsedSelfExitPayload {
const raw = requireRecord(value, '$.data');
const seat = optionalField(raw, 'seat', '$.data', requireInteger);
if (seat !== undefined && seat < 0) {
throw new RangeError('$.data.seat: expected non-negative server seat, received number');
}
return { ...(seat === undefined ? {} : { seat }), raw };
}
export interface ParsedOnlinePayload extends ParsedSeatPayload {
readonly ip: string;
}
@@ -12,6 +12,7 @@ import {
parseOtherOfflinePayload,
parseOtherOnlinePayload,
parsePlayerPreparePayload,
parseSelfExitRoomPayload,
} from './contracts/index.ts';
import {
buildFirstSliceEnvelope,
@@ -67,7 +68,7 @@ export class PlatformHandlers {
break;
}
case 'self_exit_room': {
const parsed = parseOtherExitRoomPayload(message.data);
const parsed = parseSelfExitRoomPayload(message.data);
this.options.session.handleSelfExit(parsed);
break;
}
@@ -103,22 +104,26 @@ export class PlatformHandlers {
const parsed = parseConnectRoomserverPayload(message.data);
const target = parseSwitchTarget(parsed.roomserver, '$.data.roomserver');
const resend = buildFirstSliceEnvelope('connect_roomserver', parsed.raw);
this.options.session.handleServerSwitch();
this.options.connection.switchServer(target, resend);
this.options.session.handleServerSwitch(
() => this.options.connection.switchServer(target, resend),
);
break;
}
case 'connect_agentserver': {
const parsed = parseConnectAgentserverPayload(message.data);
const target = parseSwitchTarget(parsed.agentserver, '$.data.agentserver');
const resend = buildFirstSliceEnvelope('connect_agentserver', parsed.raw);
this.options.session.handleServerSwitch();
this.options.connection.switchServer(target, resend);
this.options.session.handleServerSwitch(
() => this.options.connection.switchServer(target, resend),
);
break;
}
case 'kick_server': {
const parsed = parseKickServerPayload(message.data);
this.options.session.handleKick(parsed.raw);
this.options.connection.stopForKick(parsed.raw);
this.options.session.handleKick(
parsed.raw,
() => this.options.connection.stopForKick(parsed.raw),
);
break;
}
}
@@ -141,14 +146,10 @@ function parseSwitchTarget(address: string, path: string): string {
throw new TypeError(`${path}: expected a non-empty WebSocket server address`);
}
const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(address);
let target: string;
if (scheme) {
if (scheme[1] !== 'ws' && scheme[1] !== 'wss') {
throw new TypeError(`${path}: unsupported WebSocket scheme ${scheme[1]}`);
}
target = address;
} else {
target = `ws://${address}`;
const target = scheme ? address : `ws://${address}`;
const authority = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/.exec(target)?.[1];
if (authority === undefined || authority.length === 0) {
throw new TypeError(`${path}: expected a WebSocket address with a non-empty host`);
}
let parsed: URL;
@@ -157,7 +158,14 @@ function parseSwitchTarget(address: string, path: string): string {
} catch {
throw new TypeError(`${path}: expected a valid WebSocket server address`);
}
if ((parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') || parsed.hostname.length === 0) {
const protocol = parsed.protocol.toLowerCase();
if (
(protocol !== 'ws:' && protocol !== 'wss:')
|| parsed.hostname.length === 0
|| parsed.username.length > 0
|| parsed.password.length > 0
|| parsed.hash.length > 0
) {
throw new TypeError(`${path}: expected a valid WebSocket server address`);
}
return target;
@@ -14,11 +14,43 @@ export interface RouterOptions {
readonly gameSession: Pick<GameSessionHost, 'dispatchGameMessage'>;
}
interface QueuedDispatch {
readonly message: InboundMessage;
result?: RouterDispatchResult;
}
/** Classifies each inbound business envelope exactly once. */
export class Router {
private readonly queue: QueuedDispatch[] = [];
private draining = false;
constructor(private readonly options: RouterOptions) {}
dispatch(message: InboundMessage): RouterDispatchResult {
const queued: QueuedDispatch = { message };
this.queue.push(queued);
if (this.draining) return this.resultWithoutSideEffects(message);
this.draining = true;
try {
while (this.queue.length > 0) {
const next = this.queue.shift();
if (next === undefined) continue;
next.result = this.dispatchOne(next.message);
}
} catch (error) {
this.queue.length = 0;
throw error;
} finally {
this.draining = false;
}
if (queued.result === undefined) {
throw new Error('Router failed to produce a dispatch result');
}
return queued.result;
}
private dispatchOne(message: InboundMessage): RouterDispatchResult {
if (this.options.session.loginPending && !isLoginGateControl(message)) {
return 'ignored-login-gate';
}
@@ -31,6 +63,13 @@ export class Router {
});
return 'handled-game';
}
private resultWithoutSideEffects(message: InboundMessage): RouterDispatchResult {
if (this.options.session.loginPending && !isLoginGateControl(message)) {
return 'ignored-login-gate';
}
return isPlatformRoute(message.route) ? 'handled-platform' : 'handled-game';
}
}
function isPlatformRoute(route: string): route is 'platform' | 'agent' | 'room' {