fix(platform): isolate game host leases

This commit is contained in:
2026-09-05 07:48:23 +08:00
parent 30547f6a54
commit 471e2e9479
5 changed files with 237 additions and 33 deletions
@@ -47,8 +47,8 @@ export class PlatformCommands {
exitRoom(): void {
const state = this.options.store.getState();
const room = this.requireInsideRoom(state);
if (room.stage !== 0) {
throw new UnsupportedGameHostCommandError('room.exit requires stage=0');
if (room.stage !== 0 && room.infinite !== 1) {
throw new UnsupportedGameHostCommandError('room.exit requires stage=0 unless infinite=1');
}
this.options.send.send(buildExitRoomRequest(
this.options.runtimeConfig,
@@ -7,6 +7,7 @@ import type {
GameSeatMapper,
PlatformGameSnapshot,
} from '../sdk/contracts/index.ts';
import { assertGameRoute } from '../sdk/runtime/game-session-host.ts';
import {
PlatformCommands,
UnsupportedGameHostCommandError,
@@ -16,8 +17,6 @@ import { PlatformStore } from './stores/platform-store.ts';
import { selectGameSnapshot } from './stores/selectors.ts';
import type { PlatformState } from './stores/platform-types.ts';
const RESERVED_ROUTES = new Set(['platform', 'agent', 'room']);
export class GameSessionDisposedError extends Error {
constructor() {
super('GameHost session lease is disposed');
@@ -47,6 +46,13 @@ type SubscriptionReleaseResult =
| { readonly threw: false }
| { readonly threw: true; readonly error: unknown };
interface BoundRoomGeneration {
readonly roomcode: string;
readonly roomtype: readonly unknown[];
readonly selfSeat: number;
readonly seatCount: number;
}
class GameHostInvalidationError extends Error {
readonly errors: readonly unknown[];
@@ -64,11 +70,14 @@ class SessionBoundGameHost {
private commands: PlatformCommands | null;
private sendPort: OutboundEnvelopeSendPort | null;
private route: string | null;
private roomGeneration: BoundRoomGeneration | null;
private readonly subscriptions = new Set<SubscriptionRecord>();
private invalidationErrors: unknown[] | null = null;
private disposed = false;
constructor(options: CreateGameHostAdapterOptions) {
const route = requireGameRoute(options.gameEntry.route);
const route = options.gameEntry.route;
assertGameRoute(route);
const state = options.store.getState();
if (state.room.kind !== 'inside') {
throw new Error('GameHost requires player to be inside a room');
@@ -81,15 +90,21 @@ class SessionBoundGameHost {
this.commands = options.commands;
this.sendPort = options.send;
this.route = route;
this.roomGeneration = {
roomcode: state.room.roomcode,
roomtype: state.room.roomtype,
selfSeat,
seatCount,
};
const seat: GameSeatMapper = Object.freeze({
toView: (serverSeat: number): number => {
this.requireActive();
this.requireCurrentRoom();
const validated = requireSeat(serverSeat, seatCount, 'server seat');
return (validated - selfSeat + seatCount) % seatCount;
},
toServer: (viewSeat: number): number => {
this.requireActive();
this.requireCurrentRoom();
const validated = requireSeat(viewSeat, seatCount, 'view seat');
return (validated + selfSeat) % seatCount;
},
@@ -116,18 +131,24 @@ class SessionBoundGameHost {
this.commands = null;
this.sendPort = null;
this.route = null;
this.roomGeneration = null;
const errors: unknown[] = [];
for (const record of subscriptions) {
const result = this.releaseSubscription(record);
if (result.threw) errors.push(result.error);
this.invalidationErrors = errors;
try {
for (const record of subscriptions) {
const result = this.releaseSubscription(record);
if (result.threw) errors.push(result.error);
}
} finally {
this.invalidationErrors = null;
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new GameHostInvalidationError(errors);
}
private getSnapshot(): PlatformGameSnapshot {
return selectGameSnapshot(this.requireStore().getState());
return selectGameSnapshot(this.requireCurrentRoom());
}
private subscribe(listener: (snapshot: PlatformGameSnapshot) => void): () => void {
@@ -136,10 +157,10 @@ class SessionBoundGameHost {
throw new TypeError('GameHost subscribe listener must be a function');
}
const store = this.requireStore();
let current = selectGameSnapshot(store.getState());
let current = selectGameSnapshot(this.requireCurrentRoom());
const record: SubscriptionRecord = { active: true, unsubscribeStore: null };
const bridge = (state: PlatformState): void => {
if (this.disposed || !record.active || state.room.kind !== 'inside') return;
if (this.disposed || !record.active || !this.isCurrentRoom(state)) return;
const next = selectGameSnapshot(state);
if (samePublicSnapshot(current, next)) return;
current = next;
@@ -157,7 +178,10 @@ class SessionBoundGameHost {
return (): void => {
try {
this.releaseSubscription(record);
const result = this.releaseSubscription(record);
if (result.threw && this.invalidationErrors !== null) {
this.invalidationErrors.push(result.error);
}
} catch {
// The public unsubscribe contract remains non-throwing, including after invalidation.
}
@@ -165,7 +189,7 @@ class SessionBoundGameHost {
}
private sendGameMessage(rpc: string, data: unknown): void {
this.requireActive();
this.requireCurrentRoom();
if (typeof rpc !== 'string' || rpc.trim() === '') {
throw new TypeError('GameHost rpc must be a non-empty string');
}
@@ -177,7 +201,7 @@ class SessionBoundGameHost {
}
private execute(command: GameHostCommand): void {
this.requireActive();
this.requireCurrentRoom();
const commands = this.commands;
if (commands === null) throw new GameSessionDisposedError();
if (command !== null && typeof command === 'object') {
@@ -200,6 +224,22 @@ class SessionBoundGameHost {
return store;
}
private requireCurrentRoom(): PlatformState {
const state = this.requireStore().getState();
if (!this.isCurrentRoom(state)) throw new GameSessionDisposedError();
return state;
}
private isCurrentRoom(state: PlatformState): boolean {
const generation = this.roomGeneration;
return generation !== null
&& state.room.kind === 'inside'
&& state.room.roomcode === generation.roomcode
&& state.room.roomtype === generation.roomtype
&& state.room.selfSeat === generation.selfSeat
&& state.room.seatPlayerIds.length === generation.seatCount;
}
private requireActive(): void {
if (this.disposed) throw new GameSessionDisposedError();
}
@@ -228,16 +268,6 @@ export function createGameHostAdapter(options: CreateGameHostAdapterOptions): Ga
});
}
function requireGameRoute(route: unknown): string {
if (typeof route !== 'string' || route.trim() === '') {
throw new TypeError('GameEntry route must be a non-empty string');
}
if (RESERVED_ROUTES.has(route)) {
throw new Error(`GameEntry reserved route: ${route}`);
}
return route;
}
function requireSeatCount(seatCount: unknown, roomSeatCount: number): asserts seatCount is number {
if (!Number.isInteger(seatCount) || (seatCount as number) <= 0) {
throw new RangeError(`GameEntry seat count must be a positive integer: ${String(seatCount)}`);
@@ -5,8 +5,9 @@ import type {
GameServerMessage,
PlatformToGameEvent,
} from '../contracts/index.ts';
import { Route } from '../../core/constants.ts';
const RESERVED_ROUTES = new Set(['platform', 'agent', 'room']);
const RESERVED_ROUTES = new Set<string>(Object.values(Route));
const GAME_SESSION_OPEN_FAULT_BRAND = Symbol.for(
'youle.framework.sdk.GameSessionOpenFault',
);
@@ -81,10 +82,7 @@ export function isGameSessionOpenFault(value: unknown): value is GameSessionOpen
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}`);
}
assertGameRoute(entry.route);
if (typeof entry.resolveSeatCount !== 'function') {
throw new Error('GameEntry resolveSeatCount must be a function');
}
@@ -103,6 +101,14 @@ export function assertGameEntry(entry: GameEntry): void {
}
}
/** The single GameEntry route admission check shared by runtime adapters. */
export function assertGameRoute(route: unknown): asserts route is string {
assertNonEmptyText(route, 'route');
if (RESERVED_ROUTES.has(route)) {
throw new Error(`GameEntry reserved route: ${route}`);
}
}
/** Owns one isolated game module for one room at a time. */
export class GameSessionHost {
private module: GameModule | undefined;