From 004d49c36fbfb419fe4fb1bccbc253c8fd505ab9 Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 2 Apr 2026 12:48:29 +0800 Subject: [PATCH] refactor: update api --- src/core/game.ts | 44 +++++++++++++++-------- src/samples/tic-tac-toe.ts | 42 +++++++++++++--------- tests/core/game.test.ts | 58 +++++++++++++++++++++++++++++-- tests/samples/tic-tac-toe.test.ts | 50 +++++++++----------------- 4 files changed, 128 insertions(+), 66 deletions(-) diff --git a/src/core/game.ts b/src/core/game.ts index e11e9f6..4028293 100644 --- a/src/core/game.ts +++ b/src/core/game.ts @@ -11,37 +11,53 @@ import { } from "../utils/command"; import {AsyncQueue} from "../utils/async-queue"; -export interface IGameContext { +export interface IGameContext { parts: ReturnType>; regions: ReturnType>; - commands: CommandRunnerContextExport; + commands: CommandRunnerContextExport>; prompts: AsyncQueue; } -/** - * creates a game context. - * expects a command registry already registered with commands. - * @param commandRegistry - */ -export function createGameContext(commandRegistry: CommandRegistry) { +export function createGameContext( + commandRegistry: CommandRegistry>, + initialState?: TState | (() => TState) +): IGameContext { const parts = createEntityCollection(); const regions = createEntityCollection(); - const ctx: IGameContext = { + const prompts = new AsyncQueue(); + const state: TState = typeof initialState === 'function' ? (initialState as (() => TState))() : (initialState ?? {} as TState); + + const ctx = { parts, regions, + prompts, commands: null!, - prompts: new AsyncQueue(), - }; + state, + } as IGameContext + ctx.commands = createCommandRunnerContext(commandRegistry, ctx); ctx.commands.on('prompt', (prompt: PromptEvent) => ctx.prompts.push(prompt)); return ctx; } -export function createGameCommand( +/** + * so that we can do `import * as tictactoe from './tic-tac-toe.ts';\n\n createGameContextFromModule(tictactoe);` + * @param module + */ +export function createGameContextFromModule( + module: { + registry: CommandRegistry>, + createInitialState: () => TState + }, +): IGameContext { + return createGameContext(module.registry, module.createInitialState); +} + +export function createGameCommand( schema: CommandSchema | string, - run: (this: CommandRunnerContext, command: Command) => Promise -): CommandRunner { + run: (this: CommandRunnerContext>, command: Command) => Promise +): CommandRunner, TResult> { return { schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema, run, diff --git a/src/samples/tic-tac-toe.ts b/src/samples/tic-tac-toe.ts index 4ec3909..63d9a8c 100644 --- a/src/samples/tic-tac-toe.ts +++ b/src/samples/tic-tac-toe.ts @@ -1,7 +1,6 @@ -import { IGameContext } from '../core/game'; -import {CommandRegistry, CommandRunner, registerCommand} from '../utils/command'; +import { IGameContext, createGameCommand } from '../core/game'; +import { createCommandRegistry, type CommandRegistry, registerCommand } from '../utils/command'; import type { Part } from '../core/part'; -import {createGameCommand} from "../core/game"; export type TicTacToeState = { currentPlayer: 'X' | 'O'; @@ -9,15 +8,25 @@ export type TicTacToeState = { moveCount: number; }; +export type TicTacToeContext = IGameContext; + type TurnResult = { winner: 'X' | 'O' | 'draw' | null; }; -export function getBoardRegion(host: IGameContext) { +export function createInitialState(): TicTacToeState { + return { + currentPlayer: 'X', + winner: null, + moveCount: 0, + }; +} + +export function getBoardRegion(host: TicTacToeContext) { return host.regions.get('board'); } -export function isCellOccupied(host: IGameContext, row: number, col: number): boolean { +export function isCellOccupied(host: TicTacToeContext, row: number, col: number): boolean { const board = getBoardRegion(host); return board.value.children.some( (child: { value: { position: number[] } }) => child.value.position[0] === row && child.value.position[1] === col @@ -43,7 +52,7 @@ export function hasWinningLine(positions: number[][]): boolean { ); } -export function checkWinner(host: IGameContext): 'X' | 'O' | 'draw' | null { +export function checkWinner(host: TicTacToeContext): 'X' | 'O' | 'draw' | null { const parts = Object.values(host.parts.collection.value).map((s: { value: Part }) => s.value); const xPositions = parts.filter((_: Part, i: number) => i % 2 === 0).map((p: Part) => p.position); @@ -55,7 +64,7 @@ export function checkWinner(host: IGameContext): 'X' | 'O' | 'draw' | null { return null; } -export function placePiece(host: IGameContext, row: number, col: number, moveCount: number) { +export function placePiece(host: TicTacToeContext, row: number, col: number, moveCount: number) { const board = getBoardRegion(host); const piece: Part = { id: `piece-${moveCount}`, @@ -68,7 +77,7 @@ export function placePiece(host: IGameContext, row: number, col: number, moveCou board.value.children.push(host.parts.get(piece.id)); } -const setup = createGameCommand( +const setup = createGameCommand( 'setup', async function() { this.context.regions.add({ @@ -81,23 +90,23 @@ const setup = createGameCommand( }); let currentPlayer: 'X' | 'O' = 'X'; - let turnResult: TurnResult | undefined; + let winner: 'X' | 'O' | 'draw' | null = null; let turn = 1; while (true) { const turnOutput = await this.run(`turn ${currentPlayer} ${turn++}`); if (!turnOutput.success) throw new Error(turnOutput.error); - turnResult = turnOutput?.result.winner; - if (turnResult) break; + winner = turnOutput.result.winner; + if (winner) break; currentPlayer = currentPlayer === 'X' ? 'O' : 'X'; } - return { winner: turnResult }; + return { winner }; } ) -const turn = createGameCommand( +const turn = createGameCommand( 'turn ', async function(cmd) { const [turnPlayer, turnNumber] = cmd.params as [string, number]; @@ -119,7 +128,6 @@ const turn = createGameCommand( } ); -export function registerTicTacToeCommands(registry: CommandRegistry) { - registerCommand(registry, setup); - registerCommand(registry, turn); -} +export const registry = createCommandRegistry(); +registerCommand(registry, setup); +registerCommand(registry, turn); \ No newline at end of file diff --git a/tests/core/game.test.ts b/tests/core/game.test.ts index 6689005..0a9378e 100644 --- a/tests/core/game.test.ts +++ b/tests/core/game.test.ts @@ -1,7 +1,15 @@ import { describe, it, expect } from 'vitest'; -import { createGameContext, createGameCommand } from '../../src/core/game'; +import { createGameContext, createGameCommand, IGameContext } from '../../src/core/game'; import { createCommandRegistry, parseCommandSchema, type CommandRegistry } from '../../src/utils/command'; -import type { IGameContext } from '../../src/core/game'; + +type MyState = { + score: number; + round: number; +}; + +type MyContext = IGameContext & { + state: MyState; +}; describe('createGameContext', () => { it('should create a game context with empty parts and regions', () => { @@ -21,6 +29,26 @@ describe('createGameContext', () => { expect(ctx.commands.context).toBe(ctx); }); + it('should accept initial state as an object', () => { + const registry = createCommandRegistry(); + const ctx = createGameContext(registry, { + state: { score: 0, round: 1 }, + }); + + expect(ctx.state.score).toBe(0); + expect(ctx.state.round).toBe(1); + }); + + it('should accept initial state as a factory function', () => { + const registry = createCommandRegistry(); + const ctx = createGameContext(registry, () => ({ + state: { score: 10, round: 3 }, + })); + + expect(ctx.state.score).toBe(10); + expect(ctx.state.round).toBe(3); + }); + it('should forward prompt events to the prompts queue', async () => { const registry = createCommandRegistry(); const ctx = createGameContext(registry); @@ -123,4 +151,30 @@ describe('createGameCommand', () => { } expect(ctx.parts.get('piece-1')).not.toBeNull(); }); + + it('should run a typed command with extended context', async () => { + const registry = createCommandRegistry(); + + const addScore = createGameCommand( + 'add-score ', + async function (cmd) { + const amount = cmd.params[0] as number; + this.context.state.score += amount; + return this.context.state.score; + } + ); + + registry.set('add-score', addScore); + + const ctx = createGameContext(registry, () => ({ + state: { score: 0, round: 1 }, + })); + + const result = await ctx.commands.run('add-score 5'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe(5); + } + expect(ctx.state.score).toBe(5); + }); }); diff --git a/tests/samples/tic-tac-toe.test.ts b/tests/samples/tic-tac-toe.test.ts index d5effef..333c05f 100644 --- a/tests/samples/tic-tac-toe.test.ts +++ b/tests/samples/tic-tac-toe.test.ts @@ -1,18 +1,15 @@ import { describe, it, expect } from 'vitest'; -import { createGameContext } from '../../src/core/game'; -import { createCommandRegistry } from '../../src/utils/command'; -import { registerTicTacToeCommands, checkWinner, isCellOccupied, placePiece } from '../../src/samples/tic-tac-toe'; -import type { IGameContext } from '../../src/core/game'; +import {registry, checkWinner, isCellOccupied, placePiece, createInitialState} from '../../src/samples/tic-tac-toe'; +import type { TicTacToeContext } from '../../src/samples/tic-tac-toe'; import type { Part } from '../../src/core/part'; +import {createGameContext} from "../../src"; function createTestContext() { - const registry = createCommandRegistry(); - registerTicTacToeCommands(registry); - const ctx = createGameContext(registry); + const ctx = createGameContext(registry, createInitialState); return { registry, ctx }; } -function setupBoard(ctx: IGameContext) { +function setupBoard(ctx: TicTacToeContext) { ctx.regions.add({ id: 'board', axes: [ @@ -23,7 +20,7 @@ function setupBoard(ctx: IGameContext) { }); } -function addPiece(ctx: IGameContext, id: string, row: number, col: number) { +function addPiece(ctx: TicTacToeContext, id: string, row: number, col: number) { const board = ctx.regions.get('board'); const part: Part = { id, @@ -172,10 +169,10 @@ describe('TicTacToe - helper functions', () => { describe('TicTacToe - game flow', () => { it('should have setup and turn commands registered', () => { - const { registry } = createTestContext(); + const { registry: reg } = createTestContext(); - expect(registry.has('setup')).toBe(true); - expect(registry.has('turn')).toBe(true); + expect(reg.has('setup')).toBe(true); + expect(reg.has('turn')).toBe(true); }); it('should setup board when setup command runs', async () => { @@ -205,7 +202,6 @@ describe('TicTacToe - game flow', () => { promptEvent.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); - // After valid non-winning move, turn command prompts again, reject to stop const promptEvent2 = await ctx.prompts.pop(); promptEvent2.reject(new Error('done')); @@ -229,7 +225,6 @@ describe('TicTacToe - game flow', () => { promptEvent2.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); - // After valid non-winning move, reject next prompt const promptEvent3 = await ctx.prompts.pop(); promptEvent3.reject(new Error('done')); @@ -253,7 +248,6 @@ describe('TicTacToe - game flow', () => { promptEvent2.resolve({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} }); - // After valid non-winning move, reject next prompt const promptEvent3 = await ctx.prompts.pop(); promptEvent3.reject(new Error('done')); @@ -265,7 +259,6 @@ describe('TicTacToe - game flow', () => { const { ctx } = createTestContext(); setupBoard(ctx); - // X plays (0,0) let runPromise = ctx.commands.run('turn X 1'); let prompt = await ctx.prompts.pop(); prompt.resolve({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} }); @@ -274,7 +267,6 @@ describe('TicTacToe - game flow', () => { let result = await runPromise; expect(result.success).toBe(false); - // O plays (0,1) runPromise = ctx.commands.run('turn O 2'); prompt = await ctx.prompts.pop(); prompt.resolve({ name: 'play', params: ['O', 0, 1], options: {}, flags: {} }); @@ -283,7 +275,6 @@ describe('TicTacToe - game flow', () => { result = await runPromise; expect(result.success).toBe(false); - // X plays (1,0) runPromise = ctx.commands.run('turn X 3'); prompt = await ctx.prompts.pop(); prompt.resolve({ name: 'play', params: ['X', 1, 0], options: {}, flags: {} }); @@ -292,7 +283,6 @@ describe('TicTacToe - game flow', () => { result = await runPromise; expect(result.success).toBe(false); - // O plays (0,2) runPromise = ctx.commands.run('turn O 4'); prompt = await ctx.prompts.pop(); prompt.resolve({ name: 'play', params: ['O', 0, 2], options: {}, flags: {} }); @@ -301,7 +291,6 @@ describe('TicTacToe - game flow', () => { result = await runPromise; expect(result.success).toBe(false); - // X plays (2,0) - wins with vertical line runPromise = ctx.commands.run('turn X 5'); prompt = await ctx.prompts.pop(); prompt.resolve({ name: 'play', params: ['X', 2, 0], options: {}, flags: {} }); @@ -314,28 +303,23 @@ describe('TicTacToe - game flow', () => { const { ctx } = createTestContext(); setupBoard(ctx); - // Pre-place 8 pieces that don't form any winning line for either player - // Using positions that clearly don't form lines - // X pieces at even indices, O pieces at odd indices const pieces = [ - { id: 'p1', pos: [0, 0] }, // X - { id: 'p2', pos: [2, 2] }, // O - { id: 'p3', pos: [0, 2] }, // X - { id: 'p4', pos: [2, 0] }, // O - { id: 'p5', pos: [1, 0] }, // X - { id: 'p6', pos: [0, 1] }, // O - { id: 'p7', pos: [2, 1] }, // X - { id: 'p8', pos: [1, 2] }, // O + { id: 'p1', pos: [0, 0] }, + { id: 'p2', pos: [2, 2] }, + { id: 'p3', pos: [0, 2] }, + { id: 'p4', pos: [2, 0] }, + { id: 'p5', pos: [1, 0] }, + { id: 'p6', pos: [0, 1] }, + { id: 'p7', pos: [2, 1] }, + { id: 'p8', pos: [1, 2] }, ]; for (const { id, pos } of pieces) { addPiece(ctx, id, pos[0], pos[1]); } - // Verify no winner before 9th move expect(checkWinner(ctx)).toBeNull(); - // Now X plays (1,1) for the 9th move -> draw const runPromise = ctx.commands.run('turn X 9'); const prompt = await ctx.prompts.pop(); prompt.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });