From e945d28fc3dd78ba9501c87e5fc176667f75775e Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 2 Apr 2026 14:39:30 +0800 Subject: [PATCH] refactor: even tighter api --- src/core/game.ts | 64 ++++++----- src/samples/tic-tac-toe.ts | 151 +++++++++++++++----------- src/utils/command/command-registry.ts | 16 +++ 3 files changed, 134 insertions(+), 97 deletions(-) diff --git a/src/core/game.ts b/src/core/game.ts index 60c39f0..4d6470a 100644 --- a/src/core/game.ts +++ b/src/core/game.ts @@ -1,45 +1,33 @@ -import {createEntityCollection, entity, Entity, EntityCollection} from "../utils/entity"; -import {Part} from "./part"; -import {Region} from "./region"; +import {entity, Entity} from "../utils/entity"; import { Command, CommandRegistry, CommandRunnerContext, - CommandRunnerContextExport, CommandSchema, createCommandRegistry, - createCommandRunnerContext, parseCommandSchema, - PromptEvent, registerCommand + CommandRunnerContextExport, + CommandSchema, + createCommandRegistry, + createCommandRunnerContext, + parseCommandSchema, + registerCommand } from "../utils/command"; -import {AsyncQueue} from "../utils/async-queue"; export interface IGameContext = {} > { - parts: EntityCollection; - regions: EntityCollection; state: Entity; - commands: CommandRunnerContextExport>; - prompts: AsyncQueue; + commands: CommandRunnerContextExport>; } export function createGameContext = {} >( - commandRegistry: CommandRegistry>, + commandRegistry: CommandRegistry>, initialState?: TState | (() => TState) ): IGameContext { - const parts = createEntityCollection(); - const regions = createEntityCollection(); - const prompts = new AsyncQueue(); - const state = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState; + const stateValue = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState; + const state = entity('state', stateValue); + const commands = createCommandRunnerContext(commandRegistry, state); - const ctx = { - parts, - regions, - prompts, - commands: null!, - state: entity('gameState', state), - } as IGameContext - - ctx.commands = createCommandRunnerContext(commandRegistry, ctx); - ctx.commands.on('prompt', (prompt: PromptEvent) => ctx.prompts.push(prompt)); - - return ctx; + return { + state, + commands + }; } /** @@ -48,21 +36,31 @@ export function createGameContext = {} >( */ export function createGameContextFromModule = {} >( module: { - registry: CommandRegistry>, + registry: CommandRegistry>, createInitialState: () => TState }, ): IGameContext { return createGameContext(module.registry, module.createInitialState); } -export function createGameCommandRegistry = {} >(): CommandRegistry> { - return createCommandRegistry>(); +export function createGameCommandRegistry = {} >() { + const registry = createCommandRegistry>(); + return { + registry, + add( + schema: CommandSchema | string, + run: (this: CommandRunnerContext>, command: Command) => Promise + ){ + createGameCommand(registry, schema, run); + return this; + } + } } export function createGameCommand = {} , TResult = unknown>( - registry: CommandRegistry>, + registry: CommandRegistry>, schema: CommandSchema | string, - run: (this: CommandRunnerContext>, command: Command) => Promise + run: (this: CommandRunnerContext>, command: Command) => Promise ) { registerCommand(registry, { schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema, diff --git a/src/samples/tic-tac-toe.ts b/src/samples/tic-tac-toe.ts index 9f8cb3c..23cb59c 100644 --- a/src/samples/tic-tac-toe.ts +++ b/src/samples/tic-tac-toe.ts @@ -1,120 +1,143 @@ -import {createGameCommand, createGameCommandRegistry, IGameContext} from '../core/game'; +import {createGameCommandRegistry} from '../core/game'; import type { Part } from '../core/part'; +import {Entity, entity} from "../utils/entity"; +import {Region} from "../core/region"; + +const BOARD_SIZE = 3; +const MAX_TURNS = BOARD_SIZE * BOARD_SIZE; +const WINNING_LINES: number[][][] = [ + [[0, 0], [0, 1], [0, 2]], + [[1, 0], [1, 1], [1, 2]], + [[2, 0], [2, 1], [2, 2]], + [[0, 0], [1, 0], [2, 0]], + [[0, 1], [1, 1], [2, 1]], + [[0, 2], [1, 2], [2, 2]], + [[0, 0], [1, 1], [2, 2]], + [[0, 2], [1, 1], [2, 0]], +]; type PlayerType = 'X' | 'O'; -type WinnerType = 'X' | 'O' | 'draw' | null; +type WinnerType = PlayerType | 'draw' | null; + +type TicTacToePart = Part & { player: PlayerType }; + export function createInitialState() { return { - currentPlayer: 'O' as PlayerType, + board: entity('board', { + id: 'board', + axes: [ + { name: 'x', min: 0, max: BOARD_SIZE - 1 }, + { name: 'y', min: 0, max: BOARD_SIZE - 1 }, + ], + children: [], + }), + parts: [] as Entity[], + currentPlayer: 'X' as PlayerType, winner: null as WinnerType, turn: 0, }; } export type TicTacToeState = ReturnType; -export const registry = createGameCommandRegistry(); - -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: [], - }); +const registration = createGameCommandRegistry(); +export const registry = registration.registry; +registration.add('setup', async function() { + const {context} = this; 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}`); + 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); - state.produce(state => { + context.produce(state => { state.winner = turnOutput.result.winner; + if (!state.winner) { + state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X'; + state.turn = turnNumber; + } }); - if (state.value.winner) break; + if (context.value.winner) break; } - return state.value; + return context.value; }); -createGameCommand(registry, 'turn ', async function(cmd) { - const [turnPlayer, turnNumber] = cmd.params as [string, number]; - while (true) { - const playCmd = await this.prompt('play '); - const [player, row, col] = playCmd.params as [string, number, number]; - if(turnPlayer !== player) continue; +registration.add('turn ', async function(cmd) { + const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number]; + const maxRetries = MAX_TURNS * 2; + let retries = 0; - if (isNaN(row) || isNaN(col) || row < 0 || row > 2 || col < 0 || col > 2) continue; + while (retries < maxRetries) { + retries++; + const playCmd = await this.prompt('play '); + 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, turnNumber); + placePiece(this.context, row, col, turnPlayer); const winner = checkWinner(this.context); - if (winner) return { winner : winner as WinnerType }; + if (winner) return { winner }; + if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType }; - if (turnNumber >= 9) return { winner: 'draw' as WinnerType}; + return { winner: null }; } + + throw new Error('Too many invalid attempts'); }); -export function getBoardRegion(host: IGameContext) { - return host.regions.get('board'); +function isValidMove(row: number, col: number): boolean { + return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE; } -export function isCellOccupied(host: IGameContext, row: number, col: number): boolean { +export function getBoardRegion(host: Entity) { + return host.value.board; +} + +export function isCellOccupied(host: Entity, 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; - } + part => part.value.position[0] === row && part.value.position[1] === col ); } export function hasWinningLine(positions: number[][]): boolean { - const lines = [ - [[0, 0], [0, 1], [0, 2]], - [[1, 0], [1, 1], [1, 2]], - [[2, 0], [2, 1], [2, 2]], - [[0, 0], [1, 0], [2, 0]], - [[0, 1], [1, 1], [2, 1]], - [[0, 2], [1, 2], [2, 2]], - [[0, 0], [1, 1], [2, 2]], - [[0, 2], [1, 1], [2, 0]], - ]; - - return lines.some(line => + return WINNING_LINES.some(line => line.every(([r, c]) => positions.some(([pr, pc]) => pr === r && pc === c) ) ); } -export function checkWinner(host: IGameContext): 'X' | 'O' | 'draw' | null { - const parts = Object.values(host.parts.collection.value).map((s: { value: Part }) => s.value); +export function checkWinner(host: Entity): WinnerType { + const parts = host.value.parts.map((e: Entity) => e.value); - const xPositions = parts.filter((_: Part, i: number) => i % 2 === 0).map((p: Part) => p.position); - const oPositions = parts.filter((_: Part, i: number) => i % 2 === 1).map((p: Part) => p.position); + const xPositions = parts.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position); + const oPositions = parts.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position); if (hasWinningLine(xPositions)) return 'X'; if (hasWinningLine(oPositions)) return 'O'; + if (parts.length >= MAX_TURNS) return 'draw'; return null; } -export function placePiece(host: IGameContext, row: number, col: number, moveCount: number) { +export function placePiece(host: Entity, row: number, col: number, player: PlayerType) { const board = getBoardRegion(host); - const piece: Part = { - id: `piece-${moveCount}`, + const moveNumber = host.value.parts.length + 1; + const piece: TicTacToePart = { + id: `piece-${player}-${moveNumber}`, region: board, position: [row, col], + player, }; - host.parts.add(piece); - board.produce(draft => { - draft.children.push(host.parts.get(piece.id)); + host.produce(state => { + const e = entity(piece.id, piece) + state.parts.push(e); + board.produce(draft => { + draft.children.push(e); + }); }); -} \ No newline at end of file +} diff --git a/src/utils/command/command-registry.ts b/src/utils/command/command-registry.ts index e6741bf..bc31a51 100644 --- a/src/utils/command/command-registry.ts +++ b/src/utils/command/command-registry.ts @@ -3,6 +3,7 @@ import type {CommandResult, CommandRunner, CommandRunnerContext, PromptEvent} fr import { parseCommand } from './command-parse.js'; import { applyCommandSchema } from './command-validate.js'; import { parseCommandSchema } from './schema-parse.js'; +import {AsyncQueue} from "../async-queue"; export type CommandRegistry = Map>; @@ -42,6 +43,7 @@ type Listener = (e: PromptEvent) => void; export type CommandRunnerContextExport = CommandRunnerContext & { registry: CommandRegistry; + promptQueue: AsyncQueue; _activePrompt: PromptEvent | null; _resolvePrompt: (command: Command) => void; _rejectPrompt: (error: Error) => void; @@ -101,11 +103,25 @@ export function createCommandRunnerContext( _resolvePrompt: resolvePrompt, _rejectPrompt: rejectPrompt, _pendingInput: null, + promptQueue: null! }; Object.defineProperty(runnerCtx, '_activePrompt', { get: () => activePrompt, }); + + let promptQueue: AsyncQueue; + Object.defineProperty(runnerCtx, 'promptQueue', { + get(){ + if (!promptQueue) { + promptQueue = new AsyncQueue(); + listeners.add(async (event) => { + promptQueue.push(event); + }); + } + return promptQueue; + } + }); return runnerCtx; }