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