feat(platform): expose restricted game host and commands
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user