feat: add BaseGameClient for external integration

Introduces `BaseGameClient` to allow external environments (like C#)
to interact with the game engine. It provides mechanisms for:
- Reactive state selection via `select`
- Async event interception via `use`
- Lifecycle management and status tracking
- Interruption handling for async game loops

Updated `middleware.ts` to allow unregistering middleware via the
returned function from `use`.
This commit is contained in:
hypercross 2026-04-25 17:31:15 +08:00
parent 59391c74d9
commit 20993d3b72
4 changed files with 181 additions and 5 deletions

103
src/core/game-client.ts Normal file
View File

@ -0,0 +1,103 @@
import { createPromptContext, PromptCall } from "@/utils/command";
import { createMiddlewareChain } from "@/utils/middleware";
import { computed } from "@preact/signals-core";
import { createGameContext, IGameContext } from "./game";
export interface GameClient<E extends {} = {}, T extends {} = {}> {
// c# calls this to generate a ReactiveProperty<T>
select<K extends keyof E>(
type: K,
id: string,
callback: (t: E[K]) => void,
): () => void;
// c# calls this to interrupt and capture async events
use<K extends keyof T>(
trigger: K,
callback: (ctx: T[K], next: () => Promise<any>) => Promise<any>,
): () => void;
}
export type EntityMap = {};
export type ClientTriggerMap = {
status: { status: ClientStatus; result?: unknown };
prompt: PromptCall;
};
export enum ClientStatus {
Idle = "idle",
Running = "running",
Disposed = "disposed",
}
export abstract class BaseGameClient<
T extends Record<string, unknown> = Record<string, unknown>,
R = unknown,
> {
protected _initialState: T;
protected _context: IGameContext<T>;
protected _prompts = createPromptContext();
protected _status = ClientStatus.Idle;
protected _setStatus = createMiddlewareChain(
async (ctx: ClientTriggerMap["status"]) => {
this._status = ctx.status;
if (this._status === ClientStatus.Disposed) {
this._prompts.reset();
} else if (this._status === ClientStatus.Running) {
this._prompts.reset();
this._context._state.value = this._initialState;
const r = await this.start(this._context);
this._setStatus.execute({ status: ClientStatus.Idle, result: r });
}
},
);
constructor(is: T) {
this._initialState = is;
this._context = createGameContext(this._prompts, is);
}
setStatus(status: ClientStatus) {
this._setStatus.execute({ status });
}
abstract start(ctx: IGameContext<T>): Promise<R>;
protected assignSelector<S>(selector: (t: T) => S, callback: (s: S) => void) {
const selected = computed(() => selector(this._context.value));
return selected.subscribe(callback);
}
addInterruption() {
let resolve!: () => void;
const promise = new Promise<void>((resolver) => {
resolve = resolver;
});
this._context._state.addInterruption(promise);
return resolve;
}
useClient<K extends keyof ClientTriggerMap>(
trigger: K,
callback: (
ctx: ClientTriggerMap[K],
next: () => Promise<void>,
) => Promise<void>,
) {
if (trigger === "prompt") {
return this._prompts.handleCall.use(
callback as (
ctx: ClientTriggerMap["prompt"],
next: () => Promise<unknown>,
) => Promise<void>,
);
} else if (trigger === "status") {
return this._setStatus.use(
callback as (
ctx: ClientTriggerMap["status"],
next: () => Promise<void>,
) => Promise<void>,
);
}
}
}

View File

@ -24,6 +24,8 @@ export {
moveToRegion, moveToRegion,
} from "./core/region"; } from "./core/region";
export * from "./core/game-client";
// Utils // Utils
export type { export type {
Command, Command,

View File

@ -1,6 +1,8 @@
import { Part } from "@/core/part"; import { Part } from "@/core/part";
import { createRegion } from "@/core/region"; import { createRegion } from "@/core/region";
import { createPromptDef, IGameContext } from "@/core/game"; import { createPromptDef, IGameContext } from "@/core/game";
import { BaseGameClient, GameClient } from "@/core/game-client";
import { createMiddlewareChain } from "@/utils/middleware";
const BOARD_SIZE = 3; const BOARD_SIZE = 3;
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE; const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
@ -183,3 +185,62 @@ export async function placePiece(
board.partMap[`${row},${col}`] = piece.id; board.partMap[`${row},${col}`] = piece.id;
}); });
} }
type EntityMap = { parts: TicTacToePart };
type TriggerMap = {
turn: { player: PlayerType; turn: number };
};
export class Client
extends BaseGameClient<TicTacToeState>
implements GameClient<EntityMap, TriggerMap>
{
private onTurn = createMiddlewareChain(
async (ctx: { player: PlayerType; turn: number }) => {
return await turn(this._context, ctx.player, ctx.turn);
},
);
constructor() {
super(createInitialState());
}
async start(game: IGameContext<TicTacToeState>): Promise<TicTacToeState> {
while (true) {
const currentPlayer = game.value.currentPlayer;
const turnNumber = game.value.turn + 1;
const turnOutput = await this.onTurn.execute({
player: currentPlayer,
turn: turnNumber,
});
await game.produceAsync((state) => {
state.winner = turnOutput.winner;
if (!state.winner) {
state.currentPlayer = state.currentPlayer === "X" ? "O" : "X";
state.turn = turnNumber;
}
});
if (game.value.winner) break;
}
return game.value;
}
select<K extends keyof EntityMap>(
type: K,
id: string,
callback: (t: EntityMap[K]) => void,
): () => void {
if (type === "parts")
return this.assignSelector((state) => state.parts[id], callback);
return function () {};
}
use<K extends keyof TriggerMap>(
trigger: K,
callback: (ctx: TriggerMap[K], next: () => Promise<any>) => Promise<any>,
): () => void {
if (trigger === "turn") {
return this.onTurn.use(callback);
}
return function () {};
}
}

View File

@ -1,28 +1,38 @@
type Middleware<TContext, TReturn> = ( type Middleware<TContext, TReturn> = (
context: TContext, context: TContext,
next: () => Promise<TReturn> next: () => Promise<TReturn>,
) => Promise<TReturn>; ) => Promise<TReturn>;
export type MiddlewareChain<TContext, TReturn> = { export type MiddlewareChain<TContext, TReturn> = {
use: (middleware: Middleware<TContext, TReturn>) => void; use: (middleware: Middleware<TContext, TReturn>) => () => void;
execute: (context: TContext) => Promise<TReturn>; execute: (context: TContext) => Promise<TReturn>;
}; };
export function createMiddlewareChain<TContext extends object, TReturn=TContext>( export function createMiddlewareChain<
fallback?: (context: TContext) => Promise<TReturn> TContext extends object,
TReturn = TContext,
>(
fallback?: (context: TContext) => Promise<TReturn>,
): MiddlewareChain<TContext, TReturn> { ): MiddlewareChain<TContext, TReturn> {
const middlewares: Middleware<TContext, TReturn>[] = []; const middlewares: Middleware<TContext, TReturn>[] = [];
return { return {
use(middleware: Middleware<TContext, TReturn>) { use(middleware: Middleware<TContext, TReturn>) {
middlewares.push(middleware); middlewares.push(middleware);
return function () {
const index = middlewares.indexOf(middleware);
if (index !== -1) {
middlewares.splice(index, 1);
}
};
}, },
async execute(context: TContext) { async execute(context: TContext) {
let index = 0; let index = 0;
async function dispatch(ctx: TContext): Promise<TReturn> { async function dispatch(ctx: TContext): Promise<TReturn> {
if (index >= middlewares.length) { if (index >= middlewares.length) {
return fallback ? fallback(ctx) : ctx as unknown as TReturn; return fallback ? fallback(ctx) : (ctx as unknown as TReturn);
} }
const current = middlewares[index++]; const current = middlewares[index++];
return current(ctx, () => dispatch(ctx)); return current(ctx, () => dispatch(ctx));