feat(platform): expose restricted game host and commands

This commit is contained in:
2026-09-05 07:25:21 +08:00
parent a383c0330e
commit 30547f6a54
5 changed files with 940 additions and 15 deletions
@@ -0,0 +1,96 @@
import type { RuntimeConfig } from '../config/runtime-config.ts';
import type { OutboundEnvelope } from '../core/types/envelope.ts';
import {
buildExitRoomRequest,
buildJoinRoomRequest,
buildPrepareRequest,
type JoinRoomEnvironment,
type RoomIdentity,
} from '../protocol/contracts/index.ts';
import { PlatformStore } from './stores/platform-store.ts';
import type { InsideRoomState, PlatformState } from './stores/platform-types.ts';
/** The complete-envelope send capability used by platform command adapters. */
export interface OutboundEnvelopeSendPort {
send(envelope: OutboundEnvelope): void;
}
export interface PlatformCommandsOptions {
readonly runtimeConfig: RuntimeConfig;
readonly store: PlatformStore;
readonly send: OutboundEnvelopeSendPort;
}
export interface JoinRoomCommand extends JoinRoomEnvironment {
readonly roomcode: string;
}
export class UnsupportedGameHostCommandError extends Error {
constructor(message: string) {
super(message);
this.name = 'UnsupportedGameHostCommandError';
}
}
/** Builds platform-owned room requests exclusively through the first-slice contracts. */
export class PlatformCommands {
constructor(private readonly options: PlatformCommandsOptions) {}
prepare(): void {
const state = this.options.store.getState();
this.options.send.send(buildPrepareRequest(
this.options.runtimeConfig,
this.roomIdentity(state, this.requireInsideRoom(state)),
));
}
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');
}
this.options.send.send(buildExitRoomRequest(
this.options.runtimeConfig,
this.roomIdentity(state, room),
));
}
joinRoom(command: JoinRoomCommand): void {
const state = this.options.store.getState();
if (state.room.kind !== 'outside') {
throw new UnsupportedGameHostCommandError('room.join requires player to be outside a room');
}
const playerid = this.requireAuthenticatedPlayerId(state);
this.options.send.send(buildJoinRoomRequest(
this.options.runtimeConfig,
{ playerid, roomcode: command.roomcode },
command,
));
}
private requireInsideRoom(state: PlatformState): InsideRoomState {
if (state.room.kind !== 'inside') {
throw new UnsupportedGameHostCommandError('room command requires player to be inside a room');
}
return state.room;
}
private requireAuthenticatedPlayerId(state: PlatformState): number {
const playerid = state.players.selfPlayerId;
if (playerid === null) {
throw new UnsupportedGameHostCommandError('room command requires an authenticated player');
}
if (state.players.entities[playerid] === undefined) {
throw new Error('players.entities: missing authenticated player');
}
return playerid;
}
private roomIdentity(state: PlatformState, room: InsideRoomState): RoomIdentity {
return {
playerid: this.requireAuthenticatedPlayerId(state),
roomcode: room.roomcode,
};
}
}
@@ -0,0 +1,289 @@
import { APP } from '../core/constants.ts';
import type { OutboundEnvelope } from '../core/types/envelope.ts';
import type {
GameEntry,
GameHost,
GameHostCommand,
GameSeatMapper,
PlatformGameSnapshot,
} from '../sdk/contracts/index.ts';
import {
PlatformCommands,
UnsupportedGameHostCommandError,
type OutboundEnvelopeSendPort,
} from './commands.ts';
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');
this.name = 'GameSessionDisposedError';
}
}
export interface CreateGameHostAdapterOptions {
readonly gameEntry: GameEntry;
readonly store: PlatformStore;
readonly commands: PlatformCommands;
readonly send: OutboundEnvelopeSendPort;
}
/** Structurally matches RuntimeSession's per-open GameHostLeasePort. */
export interface GameHostLease {
readonly host: GameHost;
invalidate(): void;
}
interface SubscriptionRecord {
active: boolean;
unsubscribeStore: (() => void) | null;
}
type SubscriptionReleaseResult =
| { readonly threw: false }
| { readonly threw: true; readonly error: unknown };
class GameHostInvalidationError extends Error {
readonly errors: readonly unknown[];
constructor(errors: readonly unknown[]) {
super('GameHost subscription cleanup failed');
this.name = 'GameHostInvalidationError';
this.errors = Object.freeze([...errors]);
}
}
class SessionBoundGameHost {
readonly host: GameHost;
private store: PlatformStore | null;
private commands: PlatformCommands | null;
private sendPort: OutboundEnvelopeSendPort | null;
private route: string | null;
private readonly subscriptions = new Set<SubscriptionRecord>();
private disposed = false;
constructor(options: CreateGameHostAdapterOptions) {
const route = requireGameRoute(options.gameEntry.route);
const state = options.store.getState();
if (state.room.kind !== 'inside') {
throw new Error('GameHost requires player to be inside a room');
}
const seatCount = options.gameEntry.resolveSeatCount(state.room.roomtype);
requireSeatCount(seatCount, state.room.seatPlayerIds.length);
const selfSeat = requireSeat(state.room.selfSeat, seatCount, 'self seat');
this.store = options.store;
this.commands = options.commands;
this.sendPort = options.send;
this.route = route;
const seat: GameSeatMapper = Object.freeze({
toView: (serverSeat: number): number => {
this.requireActive();
const validated = requireSeat(serverSeat, seatCount, 'server seat');
return (validated - selfSeat + seatCount) % seatCount;
},
toServer: (viewSeat: number): number => {
this.requireActive();
const validated = requireSeat(viewSeat, seatCount, 'view seat');
return (validated + selfSeat) % seatCount;
},
});
this.host = Object.freeze({
seat,
getSnapshot: (): PlatformGameSnapshot => this.getSnapshot(),
subscribe: (listener: (snapshot: PlatformGameSnapshot) => void): (() => void) => (
this.subscribe(listener)
),
sendGameMessage: (rpc: string, data: unknown): void => this.sendGameMessage(rpc, data),
execute: (command: GameHostCommand): void => this.execute(command),
});
}
invalidate(): void {
if (this.disposed) return;
this.disposed = true;
const subscriptions = [...this.subscriptions];
this.subscriptions.clear();
this.store = null;
this.commands = null;
this.sendPort = null;
this.route = null;
const errors: unknown[] = [];
for (const record of subscriptions) {
const result = this.releaseSubscription(record);
if (result.threw) errors.push(result.error);
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new GameHostInvalidationError(errors);
}
private getSnapshot(): PlatformGameSnapshot {
return selectGameSnapshot(this.requireStore().getState());
}
private subscribe(listener: (snapshot: PlatformGameSnapshot) => void): () => void {
this.requireActive();
if (typeof listener !== 'function') {
throw new TypeError('GameHost subscribe listener must be a function');
}
const store = this.requireStore();
let current = selectGameSnapshot(store.getState());
const record: SubscriptionRecord = { active: true, unsubscribeStore: null };
const bridge = (state: PlatformState): void => {
if (this.disposed || !record.active || state.room.kind !== 'inside') return;
const next = selectGameSnapshot(state);
if (samePublicSnapshot(current, next)) return;
current = next;
listener(next);
};
record.unsubscribeStore = store.subscribe(bridge);
this.subscriptions.add(record);
try {
listener(current);
} catch (error) {
this.releaseSubscription(record);
throw error;
}
return (): void => {
try {
this.releaseSubscription(record);
} catch {
// The public unsubscribe contract remains non-throwing, including after invalidation.
}
};
}
private sendGameMessage(rpc: string, data: unknown): void {
this.requireActive();
if (typeof rpc !== 'string' || rpc.trim() === '') {
throw new TypeError('GameHost rpc must be a non-empty string');
}
const sendPort = this.sendPort;
const route = this.route;
if (sendPort === null || route === null) throw new GameSessionDisposedError();
const envelope: OutboundEnvelope = { app: APP, route, rpc, data };
sendPort.send(envelope);
}
private execute(command: GameHostCommand): void {
this.requireActive();
const commands = this.commands;
if (commands === null) throw new GameSessionDisposedError();
if (command !== null && typeof command === 'object') {
if (command.type === 'room.prepare') {
commands.prepare();
return;
}
if (command.type === 'room.exit') {
commands.exitRoom();
return;
}
}
throw new UnsupportedGameHostCommandError('unsupported GameHost command');
}
private requireStore(): PlatformStore {
this.requireActive();
const store = this.store;
if (store === null) throw new GameSessionDisposedError();
return store;
}
private requireActive(): void {
if (this.disposed) throw new GameSessionDisposedError();
}
private releaseSubscription(record: SubscriptionRecord): SubscriptionReleaseResult {
if (!record.active) return { threw: false };
record.active = false;
this.subscriptions.delete(record);
const unsubscribe = record.unsubscribeStore;
record.unsubscribeStore = null;
if (unsubscribe === null) return { threw: false };
try {
unsubscribe();
return { threw: false };
} catch (error) {
return { threw: true, error };
}
}
}
export function createGameHostAdapter(options: CreateGameHostAdapterOptions): GameHostLease {
const session = new SessionBoundGameHost(options);
return Object.freeze({
host: session.host,
invalidate: (): void => { session.invalidate(); },
});
}
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)}`);
}
if (seatCount !== roomSeatCount) {
throw new RangeError(
`GameEntry seat count ${seatCount} does not match room seat count ${roomSeatCount}`,
);
}
}
function requireSeat(value: unknown, seatCount: number, label: string): number {
if (!Number.isInteger(value)) {
throw new TypeError(`${label}: expected integer seat, received ${String(value)}`);
}
const seat = value as number;
if (seat < 0 || seat >= seatCount) {
throw new RangeError(`${label}: expected seat range 0..${seatCount - 1}, received ${seat}`);
}
return seat;
}
function samePublicSnapshot(first: PlatformGameSnapshot, second: PlatformGameSnapshot): boolean {
if (first === second) return true;
if (
first.connection.phase !== second.connection.phase
|| first.self.playerId !== second.self.playerId
|| first.self.seat !== second.self.seat
|| first.room.roomcode !== second.room.roomcode
|| first.room.roomtype !== second.room.roomtype
|| first.room.stage !== second.room.stage
|| first.room.needprepare !== second.room.needprepare
|| first.room.infinite !== second.room.infinite
|| first.seats.length !== second.seats.length
) return false;
for (let index = 0; index < first.seats.length; index++) {
const left = first.seats[index]!;
const right = second.seats[index]!;
if (
left.seat !== right.seat
|| left.playerId !== right.playerId
|| left.nickname !== right.nickname
|| left.avatar !== right.avatar
|| left.online !== right.online
|| left.ready !== right.ready
) return false;
}
return true;
}