feat(sdk): add isolated single-game sessions

This commit is contained in:
2026-09-05 01:00:51 +08:00
parent c0c6a7c800
commit f2823c398a
4 changed files with 469 additions and 0 deletions
@@ -0,0 +1,165 @@
import type {
GameEntry,
GameHost,
GameModule,
GameServerMessage,
PlatformToGameEvent,
} from '../contracts/index.ts';
const RESERVED_ROUTES = new Set(['platform', 'agent', 'room']);
export type GameSessionState =
| 'idle'
| 'attaching'
| 'active'
| 'restoring'
| 'disposing'
| 'disposed';
/** Validates the compile-time game identity before it reaches the runtime. */
export function assertGameEntry(entry: GameEntry): void {
assertNonEmptyText(entry.key, 'key');
assertGameId(entry.gameId);
assertNonEmptyText(entry.route, 'route');
if (RESERVED_ROUTES.has(entry.route)) {
throw new Error(`GameEntry reserved route: ${entry.route}`);
}
if (typeof entry.resolveSeatCount !== 'function') {
throw new Error('GameEntry resolveSeatCount must be a function');
}
const seatCount = entry.resolveSeatCount([]);
if (!Number.isInteger(seatCount) || seatCount <= 0) {
throw new Error(`GameEntry seat count must be a positive integer: ${seatCount}`);
}
if (typeof entry.createModule !== 'function') {
throw new Error('GameEntry createModule must be a function');
}
assertGameModule(entry.createModule());
}
/** Owns one isolated game module for one room at a time. */
export class GameSessionHost {
private module: GameModule | undefined;
private currentState: GameSessionState = 'idle';
constructor(readonly entry: GameEntry) {
assertGameEntry(entry);
}
get state(): GameSessionState {
return this.currentState;
}
open(host: GameHost): void {
if (this.currentState === 'disposed') {
throw new Error('Cannot open a disposed game session');
}
if (this.currentState !== 'idle') {
throw new Error(`Cannot open game session while ${this.currentState}`);
}
this.currentState = 'attaching';
try {
const module = this.entry.createModule();
assertGameModule(module);
this.module = module;
module.attach(host);
this.currentState = 'active';
} catch (error) {
this.module = undefined;
this.currentState = 'idle';
throw error;
}
}
publish(event: PlatformToGameEvent): void {
this.requireActive('publish').handlePlatformEvent(event);
}
dispatchGameMessage(route: string, message: GameServerMessage): void {
if (route !== this.entry.route) {
throw new Error(`Game message route mismatch: expected ${this.entry.route}, received ${route}`);
}
this.requireActive('dispatch a game message').handleGameMessage(message);
}
restore(deskinfo: unknown): void {
const module = this.requireActive('restore');
this.currentState = 'restoring';
try {
module.restore(deskinfo);
} finally {
this.currentState = 'active';
}
}
close(): void {
if (this.currentState === 'disposed') {
throw new Error('Cannot close a disposed game session');
}
if (this.currentState === 'idle') return;
if (this.currentState !== 'active') {
throw new Error(`Cannot close game session while ${this.currentState}`);
}
const module = this.module;
if (!module) {
throw new Error('Active game session has no module');
}
this.currentState = 'disposing';
this.module = undefined;
try {
module.dispose();
} finally {
this.currentState = 'idle';
}
}
dispose(): void {
if (this.currentState === 'disposed') return;
try {
if (this.currentState !== 'idle') this.close();
} finally {
this.module = undefined;
this.currentState = 'disposed';
}
}
private requireActive(operation: string): GameModule {
if (this.currentState === 'disposed') {
throw new Error(`Cannot ${operation} on a disposed game session`);
}
if (this.currentState !== 'active' || !this.module) {
throw new Error(`Cannot ${operation} without an active game session`);
}
return this.module;
}
}
function assertNonEmptyText(value: unknown, field: string): asserts value is string {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`GameEntry ${field} must be a non-empty string`);
}
}
function assertGameId(value: unknown): void {
if (typeof value === 'string') {
if (value.trim() === '') throw new Error('GameEntry gameId must be a non-empty string or number');
return;
}
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error('GameEntry gameId must be a non-empty string or number');
}
}
function assertGameModule(module: unknown): asserts module is GameModule {
if (!module || typeof module !== 'object') {
throw new Error('GameEntry module factory must return a module');
}
const candidate = module as Record<string, unknown>;
for (const method of ['attach', 'handlePlatformEvent', 'handleGameMessage', 'restore', 'dispose']) {
if (typeof candidate[method] !== 'function') {
throw new Error(`GameEntry module must implement ${method}`);
}
}
}
@@ -0,0 +1,54 @@
import { strict as assert } from 'node:assert';
import type {
GameEntry,
GameHost,
} from '../contracts/index.ts';
import { assertGameEntry, GameSessionHost } from '../runtime/game-session-host.ts';
/** A supplied fake host and its explicit invalidation assertion. */
export interface GameContractHost {
readonly host: GameHost;
invalidate(): void;
}
export type GameContractHostFactory = () => GameContractHost;
/** Runs the shared lifecycle checks required of the app's sole GameEntry. */
export function assertGameContract(entry: GameEntry, makeHost: GameContractHostFactory): void {
assertGameEntry(entry);
for (const route of ['platform', 'agent', 'room']) {
assert.throws(() => assertGameEntry({ ...entry, route }), /reserved.*route/);
}
for (const seatCount of [2, 4, 10]) {
assert.equal(entry.resolveSeatCount([seatCount]), seatCount, `roomtype must resolve ${seatCount} seats`);
}
const firstFactoryModule = entry.createModule();
const secondFactoryModule = entry.createModule();
assert.notEqual(firstFactoryModule, secondFactoryModule, 'createModule must create isolated modules');
const sessions = new GameSessionHost(entry);
runCycle(sessions, makeHost(), entry, 1);
runCycle(sessions, makeHost(), entry, 2);
assert.equal(sessions.state, 'idle');
}
function runCycle(
sessions: GameSessionHost,
contractHost: GameContractHost,
entry: GameEntry,
cycle: number,
): void {
const roomtype = [4] as const;
const deskinfo = { cycle };
sessions.open(contractHost.host);
sessions.publish({ type: 'room.entered', roomtype });
sessions.dispatchGameMessage(entry.route, { rpc: 'conformance.message', data: { cycle } });
assert.throws(
() => sessions.dispatchGameMessage(`${entry.route}.other`, { rpc: 'conformance.message', data: { cycle } }),
/route/,
);
sessions.restore(deskinfo);
sessions.close();
contractHost.invalidate();
}