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:
parent
59391c74d9
commit
20993d3b72
|
|
@ -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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 () {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue