diff --git a/src/core/game.ts b/src/core/game.ts index 211dcc9..e11e9f6 100644 --- a/src/core/game.ts +++ b/src/core/game.ts @@ -1,27 +1,49 @@ import {createEntityCollection} from "../utils/entity"; import {Part} from "./part"; import {Region} from "./region"; -import {CommandRegistry, CommandRunnerContextExport, createCommandRunnerContext, PromptEvent} from "../utils/command"; +import { + Command, + CommandRegistry, + type CommandRunner, CommandRunnerContext, + CommandRunnerContextExport, CommandSchema, + createCommandRunnerContext, parseCommandSchema, + PromptEvent +} from "../utils/command"; import {AsyncQueue} from "../utils/async-queue"; export interface IGameContext { parts: ReturnType>; regions: ReturnType>; commands: CommandRunnerContextExport; - inputs: AsyncQueue; + prompts: AsyncQueue; } +/** + * creates a game context. + * expects a command registry already registered with commands. + * @param commandRegistry + */ export function createGameContext(commandRegistry: CommandRegistry) { const parts = createEntityCollection(); const regions = createEntityCollection(); const ctx: IGameContext = { parts, regions, - commands: null, - inputs: new AsyncQueue(), + commands: null!, + prompts: new AsyncQueue(), }; ctx.commands = createCommandRunnerContext(commandRegistry, ctx); - ctx.commands.on('prompt', (prompt: PromptEvent) => ctx.inputs.push(prompt)); + ctx.commands.on('prompt', (prompt: PromptEvent) => ctx.prompts.push(prompt)); return ctx; -} \ No newline at end of file +} + +export function createGameCommand( + schema: CommandSchema | string, + run: (this: CommandRunnerContext, command: Command) => Promise +): CommandRunner { + return { + schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema, + run, + }; +} diff --git a/src/index.ts b/src/index.ts index d61b4bf..ea74990 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,8 @@ */ // Core types -export type { Context, GameContextInstance, GameQueueState } from './core/context'; -export { GameContext, createGameContext } from './core/context'; +export type { IGameContext } from './core/game'; +export { createGameContext } from './core/game'; export type { Part } from './core/part'; export { flip, flipTo, roll } from './core/part'; diff --git a/src/samples/tic-tac-toe.ts b/src/samples/tic-tac-toe.ts index 9615ad0..d3eb09f 100644 --- a/src/samples/tic-tac-toe.ts +++ b/src/samples/tic-tac-toe.ts @@ -1,12 +1,9 @@ -import { GameContextInstance } from '../core/context'; -import type { Command, CommandRunner, CommandRunnerContext } from '../utils/command'; +import { IGameContext } from '../core/game'; +import {CommandRegistry, CommandRunner, registerCommand} from '../utils/command'; import type { Part } from '../core/part'; -import type { Region } from '../core/region'; -import type { Context } from '../core/context'; -import { parseCommandSchema } from '../utils/command/schema-parse'; +import {createGameCommand} from "../core/game"; -export type TicTacToeState = Context & { - type: 'tic-tac-toe'; +export type TicTacToeState = { currentPlayer: 'X' | 'O'; winner: 'X' | 'O' | 'draw' | null; moveCount: number; @@ -16,18 +13,18 @@ type TurnResult = { winner: 'X' | 'O' | 'draw' | null; }; -function getBoardRegion(host: GameContextInstance) { +function getBoardRegion(host: IGameContext) { return host.regions.get('board'); } -function isCellOccupied(host: GameContextInstance, row: number, col: number): boolean { +function isCellOccupied(host: IGameContext, 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 ); } -function checkWinner(host: GameContextInstance): 'X' | 'O' | 'draw' | null { +function checkWinner(host: IGameContext): '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); @@ -58,7 +55,7 @@ function hasWinningLine(positions: number[][]): boolean { ); } -function placePiece(host: GameContextInstance, row: number, col: number, moveCount: number) { +function placePiece(host: IGameContext, row: number, col: number, moveCount: number) { const board = getBoardRegion(host); const piece: Part = { id: `piece-${moveCount}`, @@ -71,80 +68,58 @@ function placePiece(host: GameContextInstance, row: number, col: number, moveCou board.value.children.push(host.parts.get(piece.id)); } -export function createSetupCommand(): CommandRunner { - return { - schema: parseCommandSchema('start'), - run: async function(this: CommandRunnerContext) { - this.context.pushContext({ - type: 'tic-tac-toe', - currentPlayer: 'X', - winner: null, - moveCount: 0, - } as TicTacToeState); +const setup = createGameCommand( + 'setup', + async function() { + this.context.regions.add({ + id: 'board', + axes: [ + { name: 'x', min: 0, max: 2 }, + { name: 'y', min: 0, max: 2 }, + ], + children: [], + }); - this.context.regions.add({ - id: 'board', - axes: [ - { name: 'x', min: 0, max: 2 }, - { name: 'y', min: 0, max: 2 }, - ], - children: [], - } as Region); + let currentPlayer: 'X' | 'O' = 'X'; + let turnResult: TurnResult | undefined; + let turn = 1; - let currentPlayer: 'X' | 'O' = 'X'; - let turnResult: TurnResult | undefined; + 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; - while (true) { - const turnOutput = await this.run(`turn ${currentPlayer}`); - if (!turnOutput.success) throw new Error(turnOutput.error); - turnResult = turnOutput.result as TurnResult; - if (turnResult?.winner) break; + currentPlayer = currentPlayer === 'X' ? 'O' : 'X'; + } - currentPlayer = currentPlayer === 'X' ? 'O' : 'X'; - const state = this.context.latestContext('tic-tac-toe')!; - state.value.currentPlayer = currentPlayer; - } + return { winner: turnResult }; + } +) - const state = this.context.latestContext('tic-tac-toe')!; - state.value.winner = turnResult?.winner ?? null; - return { winner: state.value.winner }; - }, - }; -} - -export function createTurnCommand(): CommandRunner { - return { - schema: parseCommandSchema('turn '), - run: async function(this: CommandRunnerContext, cmd: Command) { - while (true) { - const playCmd = await this.prompt('play '); - - const row = Number(playCmd.params[1]); - const col = Number(playCmd.params[2]); - - if (isNaN(row) || isNaN(col) || row < 0 || row > 2 || col < 0 || col > 2) continue; - if (isCellOccupied(this.context, row, col)) continue; - - const state = this.context.latestContext('tic-tac-toe')!; - if (state.value.winner) continue; - - placePiece(this.context, row, col, state.value.moveCount); - state.value.moveCount++; - - const winner = checkWinner(this.context); - if (winner) return { winner }; - - if (state.value.moveCount >= 9) return { winner: 'draw' as const }; - } - }, - }; -} - -export function registerTicTacToeCommands(game: GameContextInstance) { - game.registerCommand('start', createSetupCommand()); - game.registerCommand('turn', createTurnCommand()); -} - -export function startTicTacToe(game: GameContextInstance) { - game.dispatchCommand('start'); +const turn = createGameCommand( + '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; + + 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 }; + + if (turnNumber >= 9) return { winner: 'draw' as const }; + } + } +); + +export function registerTicTacToeCommands(registry: CommandRegistry) { + registerCommand(registry, setup); + registerCommand(registry, turn); } diff --git a/tests/core/rule.test.ts b/tests/core/rule.test.ts deleted file mode 100644 index b9c4811..0000000 --- a/tests/core/rule.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createGameContext } from '../../src/core/context'; -import type { Command, CommandRunner, CommandRunnerContext } from '../../src/utils/command'; -import { parseCommandSchema } from '../../src/utils/command/schema-parse'; - -describe('Command System', () => { - function createTestGame() { - const game = createGameContext(); - return game; - } - - function createRunner( - schemaStr: string, - fn: (this: CommandRunnerContext, cmd: Command) => Promise - ): CommandRunner { - return { - schema: parseCommandSchema(schemaStr), - run: fn, - }; - } - - describe('registerCommand', () => { - it('should register and execute a command', async () => { - const game = createTestGame(); - - game.registerCommand('look', createRunner('[--at]', async () => { - return 'looked'; - })); - - game.enqueue('look'); - await new Promise(resolve => setTimeout(resolve, 50)); - - expect(game.commandRegistry.value.has('look')).toBe(true); - }); - - it('should return error for unknown command', async () => { - const game = createTestGame(); - - game.enqueue('unknown command'); - await new Promise(resolve => setTimeout(resolve, 50)); - }); - }); - - describe('prompt and queue resolution', () => { - it('should resolve prompt from queue input', async () => { - const game = createTestGame(); - let promptReceived: Command | null = null; - - game.registerCommand('move', createRunner(' ', async function(this: CommandRunnerContext, cmd) { - const confirm = await this.prompt('confirm'); - promptReceived = confirm; - return { moved: cmd.params[0], confirmed: confirm.name }; - })); - - game.enqueueAll([ - 'move card1 hand', - 'confirm', - ]); - - await new Promise(resolve => setTimeout(resolve, 100)); - - expect(promptReceived).not.toBeNull(); - expect(promptReceived!.name).toBe('confirm'); - }); - - it('should handle multiple prompts in sequence', async () => { - const game = createTestGame(); - const prompts: Command[] = []; - - game.registerCommand('multi', createRunner('', async function() { - const a = await this.prompt(''); - prompts.push(a); - const b = await this.prompt(''); - prompts.push(b); - return { a: a.params[0], b: b.params[0] }; - })); - - game.enqueueAll([ - 'multi init', - 'first', - 'second', - ]); - - await new Promise(resolve => setTimeout(resolve, 100)); - - expect(prompts).toHaveLength(2); - expect(prompts[0].params[0]).toBe('first'); - expect(prompts[1].params[0]).toBe('second'); - }); - - it('should handle command that completes without prompting', async () => { - const game = createTestGame(); - let executed = false; - - game.registerCommand('attack', createRunner(' [--power: number]', async function(cmd) { - executed = true; - return { target: cmd.params[0], power: cmd.options.power || '1' }; - })); - - game.enqueue('attack goblin --power 5'); - await new Promise(resolve => setTimeout(resolve, 50)); - - expect(executed).toBe(true); - }); - }); - - describe('nested command execution', () => { - it('should allow a command to run another command', async () => { - const game = createTestGame(); - let childResult: unknown; - - game.registerCommand('child', createRunner('', async (cmd) => { - return `child:${cmd.params[0]}`; - })); - - game.registerCommand('parent', createRunner('', async function() { - const output = await this.run('child test_arg'); - if (!output.success) throw new Error(output.error); - childResult = output.result; - return `parent:${output.result}`; - })); - - game.enqueue('parent start'); - await new Promise(resolve => setTimeout(resolve, 100)); - - expect(childResult).toBe('child:test_arg'); - }); - - it('should handle nested commands with prompts', async () => { - const game = createTestGame(); - let childPromptResult: Command | null = null; - - game.registerCommand('child', createRunner('', async function() { - const confirm = await this.prompt('yes | no'); - childPromptResult = confirm; - return `child:${confirm.name}`; - })); - - game.registerCommand('parent', createRunner('', async function() { - const output = await this.run('child target1'); - if (!output.success) throw new Error(output.error); - return `parent:${output.result}`; - })); - - game.enqueueAll([ - 'parent start', - 'yes', - ]); - - await new Promise(resolve => setTimeout(resolve, 100)); - - expect(childPromptResult).not.toBeNull(); - expect(childPromptResult!.name).toBe('yes'); - }); - }); - - describe('enqueueAll for action log replay', () => { - it('should process all inputs in order', async () => { - const game = createTestGame(); - const results: string[] = []; - - game.registerCommand('step', createRunner('', async (cmd) => { - results.push(cmd.params[0] as string); - return cmd.params[0]; - })); - - game.enqueueAll([ - 'step one', - 'step two', - 'step three', - ]); - - await new Promise(resolve => setTimeout(resolve, 100)); - - expect(results).toEqual(['one', 'two', 'three']); - }); - - it('should buffer inputs and resolve prompts automatically', async () => { - const game = createTestGame(); - let prompted: Command | null = null; - - game.registerCommand('interactive', createRunner('', async function() { - const response = await this.prompt(''); - prompted = response; - return { start: 'start', reply: response.params[0] }; - })); - - game.enqueueAll([ - 'interactive begin', - 'hello', - ]); - - await new Promise(resolve => setTimeout(resolve, 100)); - - expect(prompted).not.toBeNull(); - expect(prompted!.params[0]).toBe('hello'); - }); - }); - - describe('command schema validation', () => { - it('should reject commands that do not match schema', async () => { - const game = createTestGame(); - let errors: string[] = []; - - game.registerCommand('strict', createRunner('', async () => { - return 'ok'; - })); - - const originalError = console.error; - console.error = (...args: unknown[]) => { - errors.push(String(args[0])); - }; - - game.enqueue('strict'); - await new Promise(resolve => setTimeout(resolve, 50)); - - console.error = originalError; - - expect(errors.some(e => e.includes('Unknown') || e.includes('error'))).toBe(true); - }); - }); - - describe('context management', () => { - it('should push and pop contexts', () => { - const game = createTestGame(); - - game.pushContext({ type: 'sub-game' }); - expect(game.contexts.value.length).toBe(2); - - game.popContext(); - expect(game.contexts.value.length).toBe(1); - }); - - it('should find latest context by type', () => { - const game = createTestGame(); - - game.pushContext({ type: 'sub-game' }); - const found = game.latestContext('sub-game'); - - expect(found).toBeDefined(); - expect(found!.value.type).toBe('sub-game'); - }); - }); -}); diff --git a/tests/samples/tic-tac-toe.test.ts b/tests/samples/tic-tac-toe.test.ts deleted file mode 100644 index 166077c..0000000 --- a/tests/samples/tic-tac-toe.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createGameContext } from '../../src/core/context'; -import { registerTicTacToeCommands, startTicTacToe, type TicTacToeState } from '../../src/samples/tic-tac-toe'; - -describe('Tic-Tac-Toe', () => { - function createGame() { - const game = createGameContext(); - registerTicTacToeCommands(game); - return game; - } - - function getBoardState(game: ReturnType) { - return game.latestContext('tic-tac-toe')!.value; - } - - it('should initialize the board and start the game', async () => { - const game = createGame(); - startTicTacToe(game); - await new Promise(resolve => setTimeout(resolve, 100)); - - const state = getBoardState(game); - expect(state.currentPlayer).toBe('X'); - expect(state.winner).toBeNull(); - expect(state.moveCount).toBe(0); - - const board = game.regions.get('board'); - expect(board.value.axes).toHaveLength(2); - expect(board.value.axes[0].name).toBe('x'); - expect(board.value.axes[1].name).toBe('y'); - }); - - it('should play moves and determine a winner', async () => { - const game = createGame(); - startTicTacToe(game); - await new Promise(resolve => setTimeout(resolve, 100)); - - game.enqueueAll([ - 'play X 0 0', - 'play O 0 1', - 'play X 1 0', - 'play O 1 1', - 'play X 2 0', - ]); - - await new Promise(resolve => setTimeout(resolve, 200)); - - const state = getBoardState(game); - expect(state.winner).toBe('X'); - expect(state.moveCount).toBe(5); - }); - - it('should reject out-of-bounds moves', async () => { - const game = createGame(); - startTicTacToe(game); - await new Promise(resolve => setTimeout(resolve, 100)); - - const beforeCount = getBoardState(game).moveCount; - - game.enqueueAll([ - 'play X 5 5', - 'play X -1 0', - 'play X 3 3', - ]); - - await new Promise(resolve => setTimeout(resolve, 200)); - - expect(getBoardState(game).moveCount).toBe(beforeCount); - }); - - it('should reject moves on occupied cells', async () => { - const game = createGame(); - startTicTacToe(game); - await new Promise(resolve => setTimeout(resolve, 100)); - - game.enqueue('play X 1 1'); - await new Promise(resolve => setTimeout(resolve, 100)); - expect(getBoardState(game).moveCount).toBe(1); - - game.enqueue('play O 1 1'); - await new Promise(resolve => setTimeout(resolve, 100)); - expect(getBoardState(game).moveCount).toBe(1); - }); - - it('should ignore moves after game is over', async () => { - const game = createGame(); - startTicTacToe(game); - await new Promise(resolve => setTimeout(resolve, 100)); - - game.enqueueAll([ - 'play X 0 0', - 'play O 0 1', - 'play X 1 0', - 'play O 1 1', - 'play X 2 0', - ]); - - await new Promise(resolve => setTimeout(resolve, 200)); - - expect(getBoardState(game).winner).toBe('X'); - const moveCountAfterWin = getBoardState(game).moveCount; - - game.enqueueAll([ - 'play X 2 1', - 'play O 2 2', - ]); - - await new Promise(resolve => setTimeout(resolve, 200)); - - expect(getBoardState(game).moveCount).toBe(moveCountAfterWin); - }); - - it('should detect a draw', async () => { - const game = createGame(); - startTicTacToe(game); - await new Promise(resolve => setTimeout(resolve, 100)); - - game.enqueueAll([ - 'play X 1 1', - 'play O 0 0', - 'play X 0 2', - 'play O 2 0', - 'play X 2 2', - 'play O 0 1', - 'play X 1 0', - 'play O 1 2', - 'play X 2 1', - ]); - - await new Promise(resolve => setTimeout(resolve, 300)); - - const state = getBoardState(game); - expect(state.winner).toBe('draw'); - expect(state.moveCount).toBe(9); - }); - - it('should place parts on the board region at correct positions', async () => { - const game = createGame(); - startTicTacToe(game); - await new Promise(resolve => setTimeout(resolve, 100)); - - game.enqueue('play X 1 2'); - await new Promise(resolve => setTimeout(resolve, 100)); - - const board = game.regions.get('board'); - expect(board.value.children).toHaveLength(1); - - const piece = board.value.children[0].value; - expect(piece.position).toEqual([1, 2]); - }); -});