feat(sdk): add isolated single-game sessions
This commit is contained in:
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import type {
|
||||||
|
GameEntry,
|
||||||
|
GameHost,
|
||||||
|
GameModule,
|
||||||
|
PlatformGameSnapshot,
|
||||||
|
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
||||||
|
import {
|
||||||
|
assertGameContract,
|
||||||
|
type GameContractHost,
|
||||||
|
} from '../../YouleNexus/assets/framework/sdk/testing/game-contract-harness.ts';
|
||||||
|
|
||||||
|
test('assertGameContract runs the reusable isolated session conformance suite', () => {
|
||||||
|
let invalidations = 0;
|
||||||
|
let nextModuleId = 0;
|
||||||
|
const entry: GameEntry = {
|
||||||
|
key: 'fixture',
|
||||||
|
gameId: 41,
|
||||||
|
route: 'fixture-route',
|
||||||
|
resolveSeatCount: (roomtype) => {
|
||||||
|
const seats = roomtype[0];
|
||||||
|
if (seats === 2 || seats === 4 || seats === 10) return seats;
|
||||||
|
return 4;
|
||||||
|
},
|
||||||
|
createModule: () => makeModule(++nextModuleId),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => assertGameContract(entry, makeConformanceHost(() => { invalidations++; })));
|
||||||
|
assert.equal(invalidations, 2);
|
||||||
|
assert.ok(nextModuleId >= 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeModule(id: number): GameModule {
|
||||||
|
let host: GameHost | undefined;
|
||||||
|
let restored: unknown;
|
||||||
|
let eventCount = 0;
|
||||||
|
let messageCycle: number | undefined;
|
||||||
|
return {
|
||||||
|
attach(value) { host = value; },
|
||||||
|
handlePlatformEvent(event) {
|
||||||
|
eventCount++;
|
||||||
|
if (event.type === 'room.entered') {
|
||||||
|
assert.equal(event.roomtype[0], 4);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleGameMessage(message) {
|
||||||
|
assert.equal(message.rpc, 'conformance.message');
|
||||||
|
const data = message.data as { cycle: number };
|
||||||
|
assert.equal(typeof data.cycle, 'number');
|
||||||
|
messageCycle = data.cycle;
|
||||||
|
},
|
||||||
|
restore(deskinfo) { restored = deskinfo; },
|
||||||
|
dispose() {
|
||||||
|
assert.ok(host);
|
||||||
|
assert.equal(eventCount, 1);
|
||||||
|
assert.deepEqual(restored, deskinfoFor(messageCycle!));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deskinfoFor(cycle: number): { readonly cycle: number } {
|
||||||
|
return { cycle };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeConformanceHost(onInvalidate: () => void): () => GameContractHost {
|
||||||
|
return () => {
|
||||||
|
const snapshot: PlatformGameSnapshot = {
|
||||||
|
connection: { phase: 'connected' },
|
||||||
|
self: { playerId: 1, seat: 0 },
|
||||||
|
room: { roomcode: 'fixture', roomtype: [], stage: 0, needprepare: 0, infinite: 0 },
|
||||||
|
seats: [],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
host: {
|
||||||
|
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
||||||
|
getSnapshot: () => snapshot,
|
||||||
|
subscribe: () => () => {},
|
||||||
|
sendGameMessage: () => {},
|
||||||
|
execute: () => {},
|
||||||
|
},
|
||||||
|
invalidate: onInvalidate,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import type {
|
||||||
|
GameEntry,
|
||||||
|
GameHost,
|
||||||
|
GameModule,
|
||||||
|
GameServerMessage,
|
||||||
|
PlatformGameSnapshot,
|
||||||
|
PlatformToGameEvent,
|
||||||
|
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
||||||
|
import {
|
||||||
|
assertGameEntry,
|
||||||
|
GameSessionHost,
|
||||||
|
} from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
|
||||||
|
|
||||||
|
function makeHost(): GameHost {
|
||||||
|
const snapshot: PlatformGameSnapshot = {
|
||||||
|
connection: { phase: 'connected' },
|
||||||
|
self: { playerId: 1, seat: 0 },
|
||||||
|
room: { roomcode: 'fixture', roomtype: [], stage: 0, needprepare: 0, infinite: 0 },
|
||||||
|
seats: [],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
||||||
|
getSnapshot: () => snapshot,
|
||||||
|
subscribe: () => () => {},
|
||||||
|
sendGameMessage: () => {},
|
||||||
|
execute: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEntry(createModule: () => GameModule): GameEntry {
|
||||||
|
return {
|
||||||
|
key: 'fixture',
|
||||||
|
gameId: 41,
|
||||||
|
route: 'fixture-route',
|
||||||
|
resolveSeatCount: () => 4,
|
||||||
|
createModule,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeModule(log: string[], onRestore?: (deskinfo: unknown) => void): GameModule {
|
||||||
|
return {
|
||||||
|
attach: () => log.push('attach'),
|
||||||
|
handlePlatformEvent: (event) => log.push(`event:${event.type}`),
|
||||||
|
handleGameMessage: (message) => log.push(`message:${message.rpc}`),
|
||||||
|
restore: (deskinfo) => {
|
||||||
|
log.push('restore');
|
||||||
|
onRestore?.(deskinfo);
|
||||||
|
},
|
||||||
|
dispose: () => log.push('dispose'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('assertGameEntry rejects invalid identity, routes, seat counts, and module factories', () => {
|
||||||
|
const entry = makeEntry(() => makeModule([]));
|
||||||
|
|
||||||
|
assert.throws(() => assertGameEntry({ ...entry, key: '' }), /key/);
|
||||||
|
assert.throws(() => assertGameEntry({ ...entry, gameId: '' }), /gameId/);
|
||||||
|
for (const route of ['platform', 'agent', 'room']) {
|
||||||
|
assert.throws(() => assertGameEntry({ ...entry, route }), /reserved.*route/);
|
||||||
|
}
|
||||||
|
assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: () => 0 }), /seat count/);
|
||||||
|
assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: () => 2.5 }), /seat count/);
|
||||||
|
assert.throws(() => assertGameEntry({ ...entry, createModule: () => undefined as never }), /module/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GameSessionHost delivers room events and deskinfo to one active module in order', () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const deskinfo = { turn: 7 };
|
||||||
|
let restoredValue: unknown;
|
||||||
|
const sessions = new GameSessionHost(makeEntry(() => makeModule(log, (value) => { restoredValue = value; })));
|
||||||
|
|
||||||
|
sessions.open(makeHost());
|
||||||
|
sessions.publish({ type: 'room.entered', roomtype: ['raw', { players: 4 }] });
|
||||||
|
sessions.restore(deskinfo);
|
||||||
|
sessions.close();
|
||||||
|
|
||||||
|
assert.deepEqual(log, ['attach', 'event:room.entered', 'restore', 'dispose']);
|
||||||
|
assert.equal(restoredValue, deskinfo);
|
||||||
|
assert.equal(sessions.state, 'idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GameSessionHost preserves raw platform events and game messages by identity', () => {
|
||||||
|
let receivedEvent: PlatformToGameEvent | undefined;
|
||||||
|
let receivedMessage: GameServerMessage | undefined;
|
||||||
|
const sessions = new GameSessionHost(makeEntry(() => ({
|
||||||
|
attach() {},
|
||||||
|
handlePlatformEvent(event) { receivedEvent = event; },
|
||||||
|
handleGameMessage(message) { receivedMessage = message; },
|
||||||
|
restore() {},
|
||||||
|
dispose() {},
|
||||||
|
})));
|
||||||
|
const rawRoomtype = ['raw', { players: 4 }];
|
||||||
|
const rawData = { cards: [3, 7] };
|
||||||
|
const message: GameServerMessage = { rpc: 'draw', data: rawData };
|
||||||
|
const event: PlatformToGameEvent = { type: 'room.entered', roomtype: rawRoomtype };
|
||||||
|
|
||||||
|
sessions.open(makeHost());
|
||||||
|
sessions.publish(event);
|
||||||
|
sessions.dispatchGameMessage('fixture-route', message);
|
||||||
|
|
||||||
|
assert.equal(receivedEvent, event);
|
||||||
|
assert.equal(receivedEvent?.roomtype, rawRoomtype);
|
||||||
|
assert.equal(receivedMessage, message);
|
||||||
|
assert.equal(receivedMessage?.data, rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GameSessionHost rejects illegal state transitions and mismatched game routes', () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const sessions = new GameSessionHost(makeEntry(() => makeModule(log)));
|
||||||
|
const message: GameServerMessage = { rpc: 'draw', data: { card: 3 } };
|
||||||
|
|
||||||
|
assert.throws(() => sessions.publish({ type: 'room.dissolved' }), /active/);
|
||||||
|
assert.throws(() => sessions.restore({}), /active/);
|
||||||
|
sessions.open(makeHost());
|
||||||
|
assert.throws(() => sessions.open(makeHost()), /open/);
|
||||||
|
assert.throws(() => sessions.dispatchGameMessage('other-route', message), /route/);
|
||||||
|
sessions.dispatchGameMessage('fixture-route', message);
|
||||||
|
assert.deepEqual(log, ['attach', 'message:draw']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GameSessionHost disposes a module exactly once and becomes terminal after dispose', () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const sessions = new GameSessionHost(makeEntry(() => makeModule(log)));
|
||||||
|
|
||||||
|
sessions.open(makeHost());
|
||||||
|
sessions.dispose();
|
||||||
|
sessions.dispose();
|
||||||
|
|
||||||
|
assert.deepEqual(log, ['attach', 'dispose']);
|
||||||
|
assert.equal(sessions.state, 'disposed');
|
||||||
|
assert.throws(() => sessions.open(makeHost()), /disposed/);
|
||||||
|
assert.throws(() => sessions.publish({ type: 'room.dissolved' }), /disposed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GameSessionHost creates a fresh module after close', () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const modules: GameModule[] = [];
|
||||||
|
const sessions = new GameSessionHost(makeEntry(() => {
|
||||||
|
const module = makeModule(log);
|
||||||
|
modules.push(module);
|
||||||
|
return module;
|
||||||
|
}));
|
||||||
|
|
||||||
|
sessions.open(makeHost());
|
||||||
|
sessions.close();
|
||||||
|
sessions.open(makeHost());
|
||||||
|
sessions.close();
|
||||||
|
|
||||||
|
assert.notEqual(modules[modules.length - 1], modules[modules.length - 2]);
|
||||||
|
assert.deepEqual(log, ['attach', 'dispose', 'attach', 'dispose']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GameSessionHost returns to active after a restore handler throws', () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const sessions = new GameSessionHost(makeEntry(() => ({
|
||||||
|
...makeModule(log),
|
||||||
|
restore: () => { throw new Error('restore failed'); },
|
||||||
|
})));
|
||||||
|
|
||||||
|
sessions.open(makeHost());
|
||||||
|
assert.throws(() => sessions.restore({}), /restore failed/);
|
||||||
|
assert.equal(sessions.state, 'active');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user