feat(platform): add single-entry platform runtime

This commit is contained in:
2026-09-05 09:13:00 +08:00
parent 4cc83ea713
commit 3a9c5d6fdb
6 changed files with 1221 additions and 0 deletions
@@ -0,0 +1,25 @@
export type ReadyGateName =
| 'resources'
| 'config'
| 'socket'
| 'minimum-display';
const REQUIRED_GATES: readonly ReadyGateName[] = Object.freeze([
'resources',
'config',
'socket',
'minimum-display',
]);
/** One-way startup latch: readiness is reached only after all four signals arrive. */
export class ReadyGate {
private readonly marked = new Set<ReadyGateName>();
get ready(): boolean {
return REQUIRED_GATES.every((name) => this.marked.has(name));
}
mark(name: ReadyGateName): void {
this.marked.add(name);
}
}
@@ -0,0 +1,504 @@
import type { RuntimeConfig } from '../config/runtime-config.ts';
import { LOGIN_GUARD_MS } from '../core/constants.ts';
import type { OutboundEnvelope } from '../core/types/envelope.ts';
import { realClock, type Clock } from '../net/heartbeat.ts';
import type { WireClient, WireEvent } from '../net/wire-client.ts';
import {
buildLoginRequest,
type LoginAccountIdentity,
type LoginDeviceSnapshot,
} from '../protocol/contracts/index.ts';
import { PlatformHandlers } from '../protocol/platform-handlers.ts';
import { Router } from '../protocol/router.ts';
import type { GameEntry, GameHost } from '../sdk/contracts/index.ts';
import { GameSessionHost } from '../sdk/runtime/game-session-host.ts';
import { PlatformCommands, type JoinRoomCommand } from './commands.ts';
import type { ConnectionIntent } from './connection-intent.ts';
import {
createGameHostAdapter,
type GameHostLease,
} from './game-host-adapter.ts';
import { RuntimeSession } from './runtime-session.ts';
import type { ScenePort } from './scene-port.ts';
import { PlatformStore } from './stores/platform-store.ts';
import type { PlatformState } from './stores/platform-types.ts';
import { ReadyGate, type ReadyGateName } from './ready-gate.ts';
export type PlatformWireClient = Pick<
WireClient,
'subscribe' | 'start' | 'send' | 'switchServer' | 'reconnectCurrent' | 'stop'
>;
export interface PlatformRuntimeOptions {
readonly gameEntry: GameEntry;
readonly resolveRuntimeConfig: () => Promise<RuntimeConfig>;
readonly createWireClient: (config: RuntimeConfig) => PlatformWireClient;
readonly scene: ScenePort;
readonly loadResources: () => Promise<void>;
readonly waitForMinimumDisplay: () => Promise<void>;
readonly getLoginDeviceSnapshot: () => LoginDeviceSnapshot;
readonly clock?: Clock;
}
type RuntimeLifecycle = 'idle' | 'starting' | 'running' | 'kicked' | 'fatal' | 'stopped';
/** Keeps the scene transition between room activation and optional snapshot restore. */
class SceneOrderedGameSessionHost extends GameSessionHost {
private roomSceneShown = false;
constructor(entry: GameEntry, private readonly scene: ScenePort) {
super(entry);
}
open(host: GameHost): void {
this.roomSceneShown = false;
super.open(host);
}
restore(deskinfo: unknown): void {
this.showRoomAfterOpen();
super.restore(deskinfo);
}
showRoomAfterOpen(): void {
if (this.roomSceneShown) return;
if (this.state !== 'active') {
throw new Error('Cannot show the room without an active game session');
}
this.roomSceneShown = true;
this.scene.showRoom();
}
close(): void {
try {
super.close();
} finally {
this.roomSceneShown = false;
}
}
dispose(): void {
try {
super.dispose();
} finally {
this.roomSceneShown = false;
}
}
}
class PlatformRuntimeCleanupError extends Error {
readonly errors: readonly unknown[];
constructor(operation: string, errors: readonly unknown[]) {
super(`PlatformRuntime cleanup failed during ${operation}`);
this.name = 'PlatformRuntimeCleanupError';
this.errors = Object.freeze([...errors]);
}
}
/** Owns the sole headless config, wire, router, store, and game-session composition. */
export class PlatformRuntime {
private readonly store = new PlatformStore();
private readonly gate = new ReadyGate();
private readonly clock: Clock;
private lifecycle: RuntimeLifecycle = 'idle';
private config: RuntimeConfig | null = null;
private wire: PlatformWireClient | null = null;
private unsubscribeWire: (() => void) | null = null;
private session: RuntimeSession | null = null;
private router: Router | null = null;
private gameSession: SceneOrderedGameSessionHost | null = null;
private commands: PlatformCommands | null = null;
private activeGameHostLease: GameHostLease | null = null;
private connectionIntent: ConnectionIntent = { type: 'none' };
private loginEnvelope: OutboundEnvelope | null = null;
private loginGuard: unknown = null;
private wireIsOpen = false;
private loginSceneShown = false;
private fatalError: Error | null = null;
private wireStopped = false;
constructor(private readonly options: PlatformRuntimeOptions) {
this.clock = options.clock ?? realClock;
}
get state(): PlatformState {
return this.store.getState();
}
get ready(): boolean {
return this.gate.ready;
}
async start(): Promise<void> {
if (this.lifecycle !== 'idle') {
throw new Error(`PlatformRuntime cannot start while ${this.lifecycle}`);
}
this.lifecycle = 'starting';
this.options.scene.showLoading();
const resourcesReady = this.beginReadyTask('resources', this.options.loadResources);
const minimumDisplayReady = this.beginReadyTask(
'minimum-display',
this.options.waitForMinimumDisplay,
);
try {
const config = await this.options.resolveRuntimeConfig();
if (this.fatalError !== null) throw this.fatalError;
if (config.identity.gameid !== this.options.gameEntry.gameId) {
throw new Error(
'RuntimeConfig identity.gameid must strictly equal GameEntry.gameId: '
+ `config=${String(config.identity.gameid)} (${typeof config.identity.gameid}), `
+ `entry=${String(this.options.gameEntry.gameId)} (${typeof this.options.gameEntry.gameId})`,
);
}
this.config = config;
const gameSession = new SceneOrderedGameSessionHost(
this.options.gameEntry,
this.options.scene,
);
const send = Object.freeze({
send: (envelope: OutboundEnvelope): void => {
this.requireOpenWire().send(envelope);
},
});
const commands = new PlatformCommands({
runtimeConfig: config,
store: this.store,
send,
});
const session = new RuntimeSession({
store: this.store,
gameSession,
createGameHostLease: () => this.createGameHostLease(commands, send),
});
const handlers = new PlatformHandlers({
store: this.store,
gameSession,
session,
connection: {
switchServer: (target, resend) => { this.beginServerSwitch(target, resend); },
stopForKick: (data) => { this.stopForKick(data); },
},
});
const router = new Router({ session, platform: handlers, gameSession });
const wire = this.options.createWireClient(config);
this.gameSession = gameSession;
this.commands = commands;
this.session = session;
this.router = router;
this.wire = wire;
this.unsubscribeWire = wire.subscribe((event) => { this.onWireEvent(event); });
this.markReady('config');
this.lifecycle = 'running';
wire.start();
await Promise.all([resourcesReady, minimumDisplayReady]);
if (this.fatalError !== null) throw this.fatalError;
} catch (error) {
const fatal = this.showFatalOnce(error);
throw fatal;
}
}
login(account: LoginAccountIdentity): void {
const config = this.requireConfig();
const wire = this.requireOpenWire();
const session = this.requireSession();
const envelope = buildLoginRequest(
config,
account,
this.options.getLoginDeviceSnapshot(),
);
wire.send(envelope);
this.loginEnvelope = envelope;
this.connectionIntent = { type: 'login', envelope };
session.markLoginPending();
this.startLoginGuard();
}
joinRoom(command: JoinRoomCommand): void {
this.requireCommands().joinRoom(command);
}
prepare(): void {
this.requireCommands().prepare();
}
exitRoom(): void {
this.requireCommands().exitRoom();
}
stop(): void {
if (this.lifecycle === 'stopped') return;
this.lifecycle = 'stopped';
this.clearLoginGuard();
this.connectionIntent = { type: 'none' };
this.wireIsOpen = false;
const errors: unknown[] = [];
const unsubscribe = this.unsubscribeWire;
this.unsubscribeWire = null;
if (unsubscribe !== null) this.captureCleanupError(errors, unsubscribe);
const gameSession = this.gameSession;
if (gameSession !== null) {
this.captureCleanupError(errors, () => { gameSession.dispose(); });
}
const lease = this.activeGameHostLease;
this.activeGameHostLease = null;
if (lease !== null) {
this.captureCleanupError(errors, () => { lease.invalidate(); });
}
this.captureCleanupError(errors, () => { this.stopWireOnce(); });
this.throwCleanupErrors('stop', errors);
}
private beginReadyTask(
name: ReadyGateName,
task: () => Promise<void>,
): Promise<void> {
return Promise.resolve()
.then(task)
.then(
() => { this.markReady(name); },
(error: unknown) => { this.showFatalOnce(error); },
);
}
private markReady(name: ReadyGateName): void {
if (
this.lifecycle === 'fatal'
|| this.lifecycle === 'kicked'
|| this.lifecycle === 'stopped'
) return;
this.gate.mark(name);
if (this.gate.ready && !this.loginSceneShown) {
this.loginSceneShown = true;
this.options.scene.showLogin();
}
}
private onWireEvent(event: WireEvent): void {
if (
this.lifecycle === 'fatal'
|| this.lifecycle === 'kicked'
|| this.lifecycle === 'stopped'
) return;
try {
switch (event.type) {
case 'open':
this.wireIsOpen = true;
this.markReady('socket');
if (this.connectionIntent.type === 'switch') {
const loginEnvelope = this.loginEnvelope;
if (loginEnvelope === null) {
throw new Error('server switch requires a previously sent player_login envelope');
}
this.requireOpenWire().send(this.connectionIntent.resend);
this.connectionIntent = { type: 'login', envelope: loginEnvelope };
} else if (this.connectionIntent.type === 'login') {
this.sendLoginEnvelope(this.connectionIntent.envelope);
}
break;
case 'message':
if (event.message.route === 'agent' && event.message.rpc === 'player_login') {
this.clearLoginGuard();
}
this.requireRouter().dispatch(event.message);
this.updateSceneAfterMessage(event.message.route, event.message.rpc);
break;
case 'slow':
case 'reconnecting':
this.wireIsOpen = false;
this.options.scene.showReconnect();
break;
case 'close':
this.wireIsOpen = false;
break;
}
} catch (error) {
this.showFatalOnce(error);
}
}
private sendLoginEnvelope(envelope: OutboundEnvelope): void {
const wire = this.requireOpenWire();
const session = this.requireSession();
wire.send(envelope);
session.markLoginPending();
this.startLoginGuard();
}
private beginServerSwitch(target: string, resend: OutboundEnvelope): void {
if (this.loginEnvelope === null) {
throw new Error('server switch requires a previously sent player_login envelope');
}
const wire = this.requireWire();
this.clearLoginGuard();
this.connectionIntent = { type: 'switch', target, resend };
this.wireIsOpen = false;
wire.switchServer(target);
}
private createGameHostLease(
commands: PlatformCommands,
send: { send(envelope: OutboundEnvelope): void },
): GameHostLease {
const lease = createGameHostAdapter({
gameEntry: this.options.gameEntry,
store: this.store,
commands,
send,
});
let invalidated = false;
const trackedLease: GameHostLease = Object.freeze({
host: lease.host,
invalidate: (): void => {
if (invalidated) return;
invalidated = true;
try {
lease.invalidate();
} finally {
if (this.activeGameHostLease === trackedLease) {
this.activeGameHostLease = null;
}
}
},
});
this.activeGameHostLease = trackedLease;
return trackedLease;
}
private updateSceneAfterMessage(route: string, rpc: string): void {
if (route !== 'platform' && route !== 'agent' && route !== 'room') return;
if (rpc === 'player_login') {
if (this.store.getState().room.kind === 'outside') {
this.options.scene.showLobby();
} else {
this.requireGameSession().showRoomAfterOpen();
}
return;
}
if (rpc === 'self_join_room') {
this.requireGameSession().showRoomAfterOpen();
return;
}
if (rpc === 'self_exit_room') this.options.scene.showLobby();
}
private stopForKick(data: unknown): void {
this.lifecycle = 'kicked';
this.clearLoginGuard();
this.connectionIntent = { type: 'none' };
this.wireIsOpen = false;
const errors: unknown[] = [];
this.captureCleanupError(errors, () => { this.options.scene.showKicked(data); });
this.captureCleanupError(errors, () => { this.stopWireOnce(); });
this.throwCleanupErrors('kick', errors);
}
private stopWireOnce(): void {
if (this.wireStopped) return;
const wire = this.wire;
if (wire === null) return;
this.wireStopped = true;
wire.stop();
}
private captureCleanupError(errors: unknown[], operation: () => void): void {
try {
operation();
} catch (error) {
errors.push(error);
}
}
private throwCleanupErrors(operation: string, errors: readonly unknown[]): void {
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new PlatformRuntimeCleanupError(operation, errors);
}
private startLoginGuard(): void {
this.clearLoginGuard();
let handle: unknown = null;
handle = this.clock.setTimeout(() => {
if (this.loginGuard !== handle) return;
this.loginGuard = null;
try {
this.requireWire().reconnectCurrent();
} catch (error) {
this.showFatalOnce(error);
}
}, LOGIN_GUARD_MS);
this.loginGuard = handle;
}
private clearLoginGuard(): void {
if (this.loginGuard === null) return;
this.clock.clearTimeout(this.loginGuard);
this.loginGuard = null;
}
private requireConfig(): RuntimeConfig {
this.requireRunning();
if (this.config === null) throw new Error('PlatformRuntime has no RuntimeConfig');
return this.config;
}
private requireOpenWire(): PlatformWireClient {
const wire = this.requireWire();
if (!this.wireIsOpen) throw new Error('PlatformRuntime connection is not open');
return wire;
}
private requireWire(): PlatformWireClient {
this.requireRunning();
if (this.wire === null) throw new Error('PlatformRuntime has no WireClient');
return this.wire;
}
private requireSession(): RuntimeSession {
this.requireRunning();
if (this.session === null) throw new Error('PlatformRuntime has no RuntimeSession');
return this.session;
}
private requireRouter(): Router {
this.requireRunning();
if (this.router === null) throw new Error('PlatformRuntime has no Router');
return this.router;
}
private requireGameSession(): SceneOrderedGameSessionHost {
this.requireRunning();
if (this.gameSession === null) throw new Error('PlatformRuntime has no game session');
return this.gameSession;
}
private requireCommands(): PlatformCommands {
this.requireRunning();
if (this.commands === null) throw new Error('PlatformRuntime has no platform commands');
return this.commands;
}
private requireRunning(): void {
if (this.lifecycle !== 'running') {
throw this.fatalError ?? new Error(`PlatformRuntime is ${this.lifecycle}`);
}
}
private showFatalOnce(value: unknown): Error {
const error = value instanceof Error ? value : new Error(String(value));
if (this.fatalError !== null) return this.fatalError;
this.fatalError = error;
this.lifecycle = 'fatal';
this.clearLoginGuard();
this.options.scene.showFatal(error);
return error;
}
}
@@ -0,0 +1,10 @@
/** Semantic scene intents; the headless runtime never imports Cocos. */
export interface ScenePort {
showLoading(): void;
showLogin(): void;
showLobby(): void;
showRoom(): void;
showReconnect(): void;
showKicked(data: unknown): void;
showFatal(error: Error): void;
}