Compare commits
9
Commits
f5df1c26dc
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ed23295ae | ||
|
|
ca86bfc427 | ||
|
|
09c3fc443b | ||
|
|
a0b2003c65 | ||
|
|
03db1e8a06 | ||
|
|
20993d3b72 | ||
|
|
59391c74d9 | ||
|
|
fb3c98b1ef | ||
|
|
5c1ef232fd |
+1
-1
@@ -18,7 +18,7 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:samples": "tsup --config tsup.samples.config.ts",
|
||||
"prepare": "npm run build",
|
||||
"prepare": "npm run build && npm run build:samples",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { createPromptContext, PromptDef } from "@/utils/command";
|
||||
import { computed, effect, signal } from "@preact/signals-core";
|
||||
import { createGameContext, IGameContext } from "./game";
|
||||
|
||||
export enum ClientStatus {
|
||||
Idle = "idle",
|
||||
Running = "running",
|
||||
Disposed = "disposed",
|
||||
}
|
||||
|
||||
export type PromptInfo =
|
||||
| {
|
||||
hasPrompt: false;
|
||||
player: string;
|
||||
}
|
||||
| {
|
||||
hasPrompt: true;
|
||||
player: string;
|
||||
def: PromptDef<unknown[]>;
|
||||
tryAnswer(...args: unknown[]): string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 1. create a client instance
|
||||
* 2. setStatus("running")
|
||||
* 3. select("selector", t => {}) for c# to capture game state
|
||||
* 4. useClient("status" | "prompt") for c# to handle prompt / game status
|
||||
*/
|
||||
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 = signal(ClientStatus.Idle);
|
||||
protected _result = signal(null as null | R);
|
||||
public get state() {
|
||||
return this._context.value;
|
||||
}
|
||||
constructor(is: T) {
|
||||
this._initialState = is;
|
||||
this._context = createGameContext(this._prompts, is);
|
||||
}
|
||||
|
||||
async setStatus(status: ClientStatus) {
|
||||
this._status.value = status;
|
||||
if (status === ClientStatus.Disposed) {
|
||||
this._prompts.reset();
|
||||
} else if (status === ClientStatus.Running) {
|
||||
this._prompts.reset();
|
||||
this._context._state.value = this._initialState;
|
||||
this._result.value = null;
|
||||
this._result.value = await this.start(this._context);
|
||||
this.setStatus(ClientStatus.Idle);
|
||||
}
|
||||
}
|
||||
|
||||
abstract start(ctx: IGameContext<T>): Promise<R>;
|
||||
|
||||
assignSelector<S>(selector: (t: T) => S, callback: (s: S) => void) {
|
||||
const selected = computed(() => selector(this._context.value));
|
||||
return effect(() => {
|
||||
callback(selected.value);
|
||||
});
|
||||
}
|
||||
|
||||
addInterruption() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolver) => {
|
||||
resolve = resolver;
|
||||
});
|
||||
this._context._state.addInterruption(promise);
|
||||
return resolve;
|
||||
}
|
||||
|
||||
selectPrompt(callback: (prompt: PromptInfo) => void) {
|
||||
return this._prompts.handleCall.use(async (ctx, next) => {
|
||||
function tryAnswer(...args: any[]) {
|
||||
try {
|
||||
const res = ctx.validator(...args);
|
||||
ctx.resolve(res);
|
||||
return null;
|
||||
} catch (reason) {
|
||||
if (typeof reason === "string") {
|
||||
return reason;
|
||||
}
|
||||
throw reason;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
callback({
|
||||
hasPrompt: true,
|
||||
player: ctx.player || "global",
|
||||
def: ctx.def,
|
||||
tryAnswer,
|
||||
});
|
||||
return await next();
|
||||
} finally {
|
||||
callback({
|
||||
hasPrompt: false,
|
||||
player: ctx.player || "global",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
selectStatus(callback: (status: ClientStatus) => void) {
|
||||
return effect(() => {
|
||||
callback(this._status.value);
|
||||
});
|
||||
}
|
||||
selectResult(callback: (status: R | null) => void) {
|
||||
return effect(() => {
|
||||
callback(this._result.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
+18
-23
@@ -1,29 +1,28 @@
|
||||
import { ReadonlySignal, Signal } from "@preact/signals-core";
|
||||
import { createPromptContext } from "@/utils/command";
|
||||
import { createGameContext, IGameContext } from "./game";
|
||||
import { PromptContext } from "@/utils/command/command-prompt";
|
||||
import { ReadonlySignal, Signal } from "@preact/signals-core";
|
||||
import { createGameContext, IGameContext } from "./game";
|
||||
|
||||
export type GameHostStatus = "created" | "running" | "disposed";
|
||||
|
||||
export class GameHost<TModule extends GameModule> {
|
||||
readonly state: ReadonlySignal<GameState<TModule>>;
|
||||
export class GameHost<
|
||||
TState extends Record<string, unknown>,
|
||||
TResult = unknown,
|
||||
TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
|
||||
> {
|
||||
readonly state: ReadonlySignal<TState>;
|
||||
readonly status: ReadonlySignal<GameHostStatus>;
|
||||
readonly prompts: PromptContext;
|
||||
|
||||
private _context: IGameContext<GameState<TModule>>;
|
||||
private _start: (
|
||||
ctx: IGameContext<GameState<TModule>>,
|
||||
) => Promise<GameResult<TModule>>;
|
||||
private _context: IGameContext<TState>;
|
||||
private _start: (ctx: IGameContext<TState>) => Promise<TResult>;
|
||||
private _status: Signal<GameHostStatus>;
|
||||
private _createInitialState: () => GameState<TModule>;
|
||||
private _createInitialState: () => TState;
|
||||
private _eventListeners: Map<"start" | "dispose", Set<() => void>>;
|
||||
private _isDisposed = false;
|
||||
|
||||
constructor(public readonly gameModule: TModule) {
|
||||
const { createInitialState, start } = gameModule as unknown as GameModule<
|
||||
GameState<TModule>,
|
||||
GameResult<TModule>
|
||||
>;
|
||||
const { createInitialState, start } = gameModule;
|
||||
this._createInitialState = createInitialState;
|
||||
this._eventListeners = new Map();
|
||||
|
||||
@@ -53,7 +52,7 @@ export class GameHost<TModule extends GameModule> {
|
||||
this._context._state.clearInterruptions();
|
||||
}
|
||||
|
||||
start(seed?: number): Promise<GameResult<TModule>> {
|
||||
start(seed?: number): Promise<TResult> {
|
||||
if (this._isDisposed) {
|
||||
throw new Error("GameHost is disposed");
|
||||
}
|
||||
@@ -115,15 +114,11 @@ export type GameModule<
|
||||
createInitialState: () => TState;
|
||||
start: (ctx: IGameContext<TState>) => Promise<TResult>;
|
||||
};
|
||||
export type GameState<TModule extends GameModule> = ReturnType<
|
||||
TModule["createInitialState"]
|
||||
>;
|
||||
export type GameResult<TModule extends GameModule> = Awaited<
|
||||
ReturnType<TModule["start"]>
|
||||
>;
|
||||
|
||||
export function createGameHost<TModule extends GameModule>(
|
||||
gameModule: TModule,
|
||||
): GameHost<TModule> {
|
||||
export function createGameHost<
|
||||
TState extends Record<string, unknown> = Record<string, unknown>,
|
||||
TResult = unknown,
|
||||
TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
|
||||
>(gameModule: TModule): GameHost<TState, TResult, TModule> {
|
||||
return new GameHost(gameModule);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export {
|
||||
moveToRegion,
|
||||
} from "./core/region";
|
||||
|
||||
export * from "./core/game-client";
|
||||
|
||||
// Utils
|
||||
export type {
|
||||
Command,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Part } from "@/core/part";
|
||||
import { createRegion } from "@/core/region";
|
||||
import { createRegion, Region } from "@/core/region";
|
||||
import { createPromptDef, IGameContext } from "@/core/game";
|
||||
import { BaseGameClient } from "@/core/game-client";
|
||||
import { createMiddlewareChain } from "@/utils/middleware";
|
||||
|
||||
const BOARD_SIZE = 3;
|
||||
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
||||
@@ -77,7 +79,7 @@ export async function start(game: TicTacToeGame) {
|
||||
const turnNumber = game.value.turn + 1;
|
||||
const turnOutput = await turn(game, currentPlayer, turnNumber);
|
||||
|
||||
game.produce((state) => {
|
||||
await game.produceAsync((state) => {
|
||||
state.winner = turnOutput.winner;
|
||||
if (!state.winner) {
|
||||
state.currentPlayer = state.currentPlayer === "X" ? "O" : "X";
|
||||
@@ -111,7 +113,7 @@ async function turn(
|
||||
game.value.currentPlayer,
|
||||
);
|
||||
|
||||
placePiece(game, row, col, turnPlayer);
|
||||
await placePiece(game, row, col, turnPlayer);
|
||||
|
||||
const winner = checkWinner(game);
|
||||
if (winner) return { winner };
|
||||
@@ -163,7 +165,7 @@ export function checkWinner(host: TicTacToeGame): WinnerType {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function placePiece(
|
||||
export async function placePiece(
|
||||
host: TicTacToeGame,
|
||||
row: number,
|
||||
col: number,
|
||||
@@ -177,9 +179,52 @@ export function placePiece(
|
||||
player,
|
||||
id: `piece-${player}-${moveNumber}`,
|
||||
};
|
||||
host.produce((state) => {
|
||||
await host.produceAsync((state) => {
|
||||
state.parts[piece.id] = piece;
|
||||
board.childIds.push(piece.id);
|
||||
board.partMap[`${row},${col}`] = piece.id;
|
||||
});
|
||||
}
|
||||
|
||||
export class Client extends BaseGameClient<TicTacToeState> {
|
||||
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;
|
||||
}
|
||||
selectPart(id: string, callback: (t: TicTacToePart) => void): () => void {
|
||||
return this.assignSelector((state) => state.parts[id], callback);
|
||||
}
|
||||
selectTurn(callback: (turns: number) => void): () => void {
|
||||
return this.assignSelector((s) => s.turn, callback);
|
||||
}
|
||||
selectCurrentPlayer(callback: (player: PlayerType) => void) {
|
||||
return this.assignSelector((s) => s.currentPlayer, callback);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-5
@@ -1,28 +1,38 @@
|
||||
type Middleware<TContext, TReturn> = (
|
||||
context: TContext,
|
||||
next: () => Promise<TReturn>
|
||||
next: () => Promise<TReturn>,
|
||||
) => Promise<TReturn>;
|
||||
|
||||
export type MiddlewareChain<TContext, TReturn> = {
|
||||
use: (middleware: Middleware<TContext, TReturn>) => void;
|
||||
use: (middleware: Middleware<TContext, TReturn>) => () => void;
|
||||
execute: (context: TContext) => Promise<TReturn>;
|
||||
};
|
||||
|
||||
export function createMiddlewareChain<TContext extends object, TReturn=TContext>(
|
||||
fallback?: (context: TContext) => Promise<TReturn>
|
||||
export function createMiddlewareChain<
|
||||
TContext extends object,
|
||||
TReturn = TContext,
|
||||
>(
|
||||
fallback?: (context: TContext) => Promise<TReturn>,
|
||||
): MiddlewareChain<TContext, TReturn> {
|
||||
const middlewares: Middleware<TContext, TReturn>[] = [];
|
||||
|
||||
return {
|
||||
use(middleware: Middleware<TContext, TReturn>) {
|
||||
middlewares.push(middleware);
|
||||
|
||||
return function () {
|
||||
const index = middlewares.indexOf(middleware);
|
||||
if (index !== -1) {
|
||||
middlewares.splice(index, 1);
|
||||
}
|
||||
};
|
||||
},
|
||||
async execute(context: TContext) {
|
||||
let index = 0;
|
||||
|
||||
async function dispatch(ctx: TContext): Promise<TReturn> {
|
||||
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++];
|
||||
return current(ctx, () => dispatch(ctx));
|
||||
|
||||
Reference in New Issue
Block a user