refactor: even tighter api

This commit is contained in:
hypercross 2026-04-02 14:39:30 +08:00
parent b2b35c3a99
commit e945d28fc3
3 changed files with 134 additions and 97 deletions

View File

@ -1,45 +1,33 @@
import {createEntityCollection, entity, Entity, EntityCollection} from "../utils/entity"; import {entity, Entity} from "../utils/entity";
import {Part} from "./part";
import {Region} from "./region";
import { import {
Command, Command,
CommandRegistry, CommandRegistry,
CommandRunnerContext, CommandRunnerContext,
CommandRunnerContextExport, CommandSchema, createCommandRegistry, CommandRunnerContextExport,
createCommandRunnerContext, parseCommandSchema, CommandSchema,
PromptEvent, registerCommand createCommandRegistry,
createCommandRunnerContext,
parseCommandSchema,
registerCommand
} from "../utils/command"; } from "../utils/command";
import {AsyncQueue} from "../utils/async-queue";
export interface IGameContext<TState extends Record<string, unknown> = {} > { export interface IGameContext<TState extends Record<string, unknown> = {} > {
parts: EntityCollection<Part>;
regions: EntityCollection<Region>;
state: Entity<TState>; state: Entity<TState>;
commands: CommandRunnerContextExport<IGameContext<TState>>; commands: CommandRunnerContextExport<Entity<TState>>;
prompts: AsyncQueue<PromptEvent>;
} }
export function createGameContext<TState extends Record<string, unknown> = {} >( export function createGameContext<TState extends Record<string, unknown> = {} >(
commandRegistry: CommandRegistry<IGameContext<TState>>, commandRegistry: CommandRegistry<Entity<TState>>,
initialState?: TState | (() => TState) initialState?: TState | (() => TState)
): IGameContext<TState> { ): IGameContext<TState> {
const parts = createEntityCollection<Part>(); const stateValue = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
const regions = createEntityCollection<Region>(); const state = entity('state', stateValue);
const prompts = new AsyncQueue<PromptEvent>(); const commands = createCommandRunnerContext(commandRegistry, state);
const state = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
const ctx = { return {
parts, state,
regions, commands
prompts, };
commands: null!,
state: entity('gameState', state),
} as IGameContext<TState>
ctx.commands = createCommandRunnerContext(commandRegistry, ctx);
ctx.commands.on('prompt', (prompt: PromptEvent) => ctx.prompts.push(prompt));
return ctx;
} }
/** /**
@ -48,21 +36,31 @@ export function createGameContext<TState extends Record<string, unknown> = {} >(
*/ */
export function createGameContextFromModule<TState extends Record<string, unknown> = {} >( export function createGameContextFromModule<TState extends Record<string, unknown> = {} >(
module: { module: {
registry: CommandRegistry<IGameContext<TState>>, registry: CommandRegistry<Entity<TState>>,
createInitialState: () => TState createInitialState: () => TState
}, },
): IGameContext<TState> { ): IGameContext<TState> {
return createGameContext(module.registry, module.createInitialState); return createGameContext(module.registry, module.createInitialState);
} }
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >(): CommandRegistry<IGameContext<TState>> { export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {
return createCommandRegistry<IGameContext<TState>>(); const registry = createCommandRegistry<Entity<TState>>();
return {
registry,
add<TResult = unknown>(
schema: CommandSchema | string,
run: (this: CommandRunnerContext<Entity<TState>>, command: Command) => Promise<TResult>
){
createGameCommand(registry, schema, run);
return this;
}
}
} }
export function createGameCommand<TState extends Record<string, unknown> = {} , TResult = unknown>( export function createGameCommand<TState extends Record<string, unknown> = {} , TResult = unknown>(
registry: CommandRegistry<IGameContext<TState>>, registry: CommandRegistry<Entity<TState>>,
schema: CommandSchema | string, schema: CommandSchema | string,
run: (this: CommandRunnerContext<IGameContext<TState>>, command: Command) => Promise<TResult> run: (this: CommandRunnerContext<Entity<TState>>, command: Command) => Promise<TResult>
) { ) {
registerCommand(registry, { registerCommand(registry, {
schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema, schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema,

View File

@ -1,82 +1,11 @@
import {createGameCommand, createGameCommandRegistry, IGameContext} from '../core/game'; import {createGameCommandRegistry} from '../core/game';
import type { Part } from '../core/part'; import type { Part } from '../core/part';
import {Entity, entity} from "../utils/entity";
import {Region} from "../core/region";
type PlayerType = 'X' | 'O'; const BOARD_SIZE = 3;
type WinnerType = 'X' | 'O' | 'draw' | null; const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
export function createInitialState() { const WINNING_LINES: number[][][] = [
return {
currentPlayer: 'O' as PlayerType,
winner: null as WinnerType,
turn: 0,
};
}
export type TicTacToeState = ReturnType<typeof createInitialState>;
export const registry = createGameCommandRegistry<TicTacToeState>();
createGameCommand(registry, 'setup', async function() {
const {regions, state} = this.context;
regions.add({
id: 'board',
axes: [
{ name: 'x', min: 0, max: 2 },
{ name: 'y', min: 0, max: 2 },
],
children: [],
});
while (true) {
let player = 'X' as PlayerType;
let turn = 0;
state.produce(state => {
player = state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
turn = ++state.turn;
});
const turnOutput = await this.run<{winner: WinnerType}>(`turn ${player} ${turn}`);
if (!turnOutput.success) throw new Error(turnOutput.error);
state.produce(state => {
state.winner = turnOutput.result.winner;
});
if (state.value.winner) break;
}
return state.value;
});
createGameCommand(registry, 'turn <player> <turn:number>', async function(cmd) {
const [turnPlayer, turnNumber] = cmd.params as [string, number];
while (true) {
const playCmd = await this.prompt('play <player> <row:number> <col:number>');
const [player, row, col] = playCmd.params as [string, number, number];
if(turnPlayer !== player) continue;
if (isNaN(row) || isNaN(col) || row < 0 || row > 2 || col < 0 || col > 2) continue;
if (isCellOccupied(this.context, row, col)) continue;
placePiece(this.context, row, col, turnNumber);
const winner = checkWinner(this.context);
if (winner) return { winner : winner as WinnerType };
if (turnNumber >= 9) return { winner: 'draw' as WinnerType};
}
});
export function getBoardRegion(host: IGameContext<TicTacToeState>) {
return host.regions.get('board');
}
export function isCellOccupied(host: IGameContext<TicTacToeState>, row: number, col: number): boolean {
const board = getBoardRegion(host);
return board.value.children.some(
part => {
return part.value.position[0] === row && part.value.position[1] === col;
}
);
}
export function hasWinningLine(positions: number[][]): boolean {
const lines = [
[[0, 0], [0, 1], [0, 2]], [[0, 0], [0, 1], [0, 2]],
[[1, 0], [1, 1], [1, 2]], [[1, 0], [1, 1], [1, 2]],
[[2, 0], [2, 1], [2, 2]], [[2, 0], [2, 1], [2, 2]],
@ -87,34 +16,128 @@ export function hasWinningLine(positions: number[][]): boolean {
[[0, 2], [1, 1], [2, 0]], [[0, 2], [1, 1], [2, 0]],
]; ];
return lines.some(line => type PlayerType = 'X' | 'O';
type WinnerType = PlayerType | 'draw' | null;
type TicTacToePart = Part & { player: PlayerType };
export function createInitialState() {
return {
board: entity<Region>('board', {
id: 'board',
axes: [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
],
children: [],
}),
parts: [] as Entity<TicTacToePart>[],
currentPlayer: 'X' as PlayerType,
winner: null as WinnerType,
turn: 0,
};
}
export type TicTacToeState = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<TicTacToeState>();
export const registry = registration.registry;
registration.add('setup', async function() {
const {context} = this;
while (true) {
const currentPlayer = context.value.currentPlayer;
const turnNumber = context.value.turn + 1;
const turnOutput = await this.run<{winner: WinnerType}>(`turn ${currentPlayer} ${turnNumber}`);
if (!turnOutput.success) throw new Error(turnOutput.error);
context.produce(state => {
state.winner = turnOutput.result.winner;
if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
state.turn = turnNumber;
}
});
if (context.value.winner) break;
}
return context.value;
});
registration.add('turn <player> <turn:number>', async function(cmd) {
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
const maxRetries = MAX_TURNS * 2;
let retries = 0;
while (retries < maxRetries) {
retries++;
const playCmd = await this.prompt('play <player> <row:number> <col:number>');
const [player, row, col] = playCmd.params as [PlayerType, number, number];
if (player !== turnPlayer) continue;
if (!isValidMove(row, col)) continue;
if (isCellOccupied(this.context, row, col)) continue;
placePiece(this.context, row, col, turnPlayer);
const winner = checkWinner(this.context);
if (winner) return { winner };
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
return { winner: null };
}
throw new Error('Too many invalid attempts');
});
function isValidMove(row: number, col: number): boolean {
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
}
export function getBoardRegion(host: Entity<TicTacToeState>) {
return host.value.board;
}
export function isCellOccupied(host: Entity<TicTacToeState>, row: number, col: number): boolean {
const board = getBoardRegion(host);
return board.value.children.some(
part => part.value.position[0] === row && part.value.position[1] === col
);
}
export function hasWinningLine(positions: number[][]): boolean {
return WINNING_LINES.some(line =>
line.every(([r, c]) => line.every(([r, c]) =>
positions.some(([pr, pc]) => pr === r && pc === c) positions.some(([pr, pc]) => pr === r && pc === c)
) )
); );
} }
export function checkWinner(host: IGameContext<TicTacToeState>): 'X' | 'O' | 'draw' | null { export function checkWinner(host: Entity<TicTacToeState>): WinnerType {
const parts = Object.values(host.parts.collection.value).map((s: { value: Part }) => s.value); const parts = host.value.parts.map((e: Entity<TicTacToePart>) => e.value);
const xPositions = parts.filter((_: Part, i: number) => i % 2 === 0).map((p: Part) => p.position); const xPositions = parts.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
const oPositions = parts.filter((_: Part, i: number) => i % 2 === 1).map((p: Part) => p.position); const oPositions = parts.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
if (hasWinningLine(xPositions)) return 'X'; if (hasWinningLine(xPositions)) return 'X';
if (hasWinningLine(oPositions)) return 'O'; if (hasWinningLine(oPositions)) return 'O';
if (parts.length >= MAX_TURNS) return 'draw';
return null; return null;
} }
export function placePiece(host: IGameContext<TicTacToeState>, row: number, col: number, moveCount: number) { export function placePiece(host: Entity<TicTacToeState>, row: number, col: number, player: PlayerType) {
const board = getBoardRegion(host); const board = getBoardRegion(host);
const piece: Part = { const moveNumber = host.value.parts.length + 1;
id: `piece-${moveCount}`, const piece: TicTacToePart = {
id: `piece-${player}-${moveNumber}`,
region: board, region: board,
position: [row, col], position: [row, col],
player,
}; };
host.parts.add(piece); host.produce(state => {
const e = entity(piece.id, piece)
state.parts.push(e);
board.produce(draft => { board.produce(draft => {
draft.children.push(host.parts.get(piece.id)); draft.children.push(e);
});
}); });
} }

View File

@ -3,6 +3,7 @@ import type {CommandResult, CommandRunner, CommandRunnerContext, PromptEvent} fr
import { parseCommand } from './command-parse.js'; import { parseCommand } from './command-parse.js';
import { applyCommandSchema } from './command-validate.js'; import { applyCommandSchema } from './command-validate.js';
import { parseCommandSchema } from './schema-parse.js'; import { parseCommandSchema } from './schema-parse.js';
import {AsyncQueue} from "../async-queue";
export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>; export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>;
@ -42,6 +43,7 @@ type Listener = (e: PromptEvent) => void;
export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext> & { export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext> & {
registry: CommandRegistry<TContext>; registry: CommandRegistry<TContext>;
promptQueue: AsyncQueue<PromptEvent>;
_activePrompt: PromptEvent | null; _activePrompt: PromptEvent | null;
_resolvePrompt: (command: Command) => void; _resolvePrompt: (command: Command) => void;
_rejectPrompt: (error: Error) => void; _rejectPrompt: (error: Error) => void;
@ -101,12 +103,26 @@ export function createCommandRunnerContext<TContext>(
_resolvePrompt: resolvePrompt, _resolvePrompt: resolvePrompt,
_rejectPrompt: rejectPrompt, _rejectPrompt: rejectPrompt,
_pendingInput: null, _pendingInput: null,
promptQueue: null!
}; };
Object.defineProperty(runnerCtx, '_activePrompt', { Object.defineProperty(runnerCtx, '_activePrompt', {
get: () => activePrompt, get: () => activePrompt,
}); });
let promptQueue: AsyncQueue<PromptEvent>;
Object.defineProperty(runnerCtx, 'promptQueue', {
get(){
if (!promptQueue) {
promptQueue = new AsyncQueue();
listeners.add(async (event) => {
promptQueue.push(event);
});
}
return promptQueue;
}
});
return runnerCtx; return runnerCtx;
} }