From b33c901c115a67582b32f0d03817dc984088bde5 Mon Sep 17 00:00:00 2001 From: hypercross Date: Wed, 1 Apr 2026 23:58:07 +0800 Subject: [PATCH] feat: add tic-tac-toe with rule invoking rule --- src/core/context.ts | 9 +- src/core/rule.ts | 164 +++++++++++++++++++++++++++--- src/samples/tic-tac-toe.ts | 155 ++++++++++++++++++++++++++++ tests/core/rule.test.ts | 41 +++++--- tests/samples/tic-tac-toe.test.ts | 127 +++++++++++++++++++++++ 5 files changed, 464 insertions(+), 32 deletions(-) create mode 100644 src/samples/tic-tac-toe.ts create mode 100644 tests/samples/tic-tac-toe.test.ts diff --git a/src/core/context.ts b/src/core/context.ts index 4ea5eef..509d9eb 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -2,7 +2,7 @@ import {createModel, Signal, signal} from '@preact/signals-core'; import {createEntityCollection} from "../utils/entity"; import {Part} from "./part"; import {Region} from "./region"; -import {RuleDef, RuleRegistry, RuleContext, dispatchCommand as dispatchRuleCommand} from "./rule"; +import {RuleDef, RuleRegistry, RuleContext, GameContextLike, dispatchCommand as dispatchRuleCommand} from "./rule"; export type Context = { type: string; @@ -57,8 +57,9 @@ export const GameContext = createModel((root: Context) => { ruleContexts.value = ruleContexts.value.filter(c => c !== ctx); } - function dispatchCommand(input: string) { + function dispatchCommand(this: GameContextLike, input: string) { return dispatchRuleCommand({ + ...this, rules: rules.value, ruleContexts: ruleContexts.value, contexts, @@ -82,7 +83,9 @@ export const GameContext = createModel((root: Context) => { } }) -/** 创建游戏上下文实例 */ +/** 创建游戏上下文实?*/ export function createGameContext(root: Context = { type: 'game' }) { return new GameContext(root); } + +export type GameContextInstance = ReturnType; diff --git a/src/core/rule.ts b/src/core/rule.ts index f093620..887a0c6 100644 --- a/src/core/rule.ts +++ b/src/core/rule.ts @@ -1,11 +1,20 @@ import {Command, CommandSchema, parseCommand, parseCommandSchema} from "../utils/command"; +import { defineSchema, type ParseError } from 'inline-schema'; -export type RuleState = 'running' | 'yielded' | 'waiting' | 'done'; +export type RuleState = 'running' | 'yielded' | 'waiting' | 'invoking' | 'done'; + +export type InvokeYield = { + type: 'invoke'; + rule: string; + command: Command; +}; + +export type RuleYield = string | CommandSchema | InvokeYield; export type RuleContext = { type: string; schema?: CommandSchema; - generator: Generator; + generator: Generator>; parent?: RuleContext; children: RuleContext[]; state: RuleState; @@ -14,14 +23,14 @@ export type RuleContext = { export type RuleDef = { schema: CommandSchema; - create: (cmd: Command) => Generator; + create: (this: GameContextLike, cmd: Command) => Generator>; }; export type RuleRegistry = Map>; export function createRule( schemaStr: string, - fn: (cmd: Command) => Generator + fn: (this: GameContextLike, cmd: Command) => Generator> ): RuleDef { return { schema: parseCommandSchema(schemaStr, ''), @@ -29,6 +38,10 @@ export function createRule( }; } +function isInvokeYield(value: RuleYield): value is InvokeYield { + return typeof value === 'object' && value !== null && 'type' in value && (value as InvokeYield).type === 'invoke'; +} + function parseYieldedSchema(value: string | CommandSchema): CommandSchema { if (typeof value === 'string') { return parseCommandSchema(value, ''); @@ -36,6 +49,34 @@ function parseYieldedSchema(value: string | CommandSchema): CommandSchema { return value; } +function parseCommandWithSchema(command: Command, schema: CommandSchema): Command { + const parsedParams: unknown[] = [...command.params]; + for (let i = 0; i < command.params.length; i++) { + const paramSchema = schema.params[i]?.schema; + if (paramSchema && typeof command.params[i] === 'string') { + try { + parsedParams[i] = paramSchema.parse(command.params[i] as string); + } catch { + // keep original value + } + } + } + + const parsedOptions: Record = { ...command.options }; + for (const [key, value] of Object.entries(command.options)) { + const optSchema = schema.options.find(o => o.name === key || o.short === key); + if (optSchema?.schema && typeof value === 'string') { + try { + parsedOptions[key] = optSchema.schema.parse(value); + } catch { + // keep original value + } + } + } + + return { ...command, params: parsedParams, options: parsedOptions }; +} + function pushContextToGame(game: GameContextLike, ctx: RuleContext) { game.contexts.value = [...game.contexts.value, { value: ctx } as any]; game.addRuleContext(ctx); @@ -79,6 +120,85 @@ function validateYieldedSchema(command: Command, schema: CommandSchema): boolean return true; } +function invokeChildRule( + game: GameContextLike, + ruleName: string, + command: Command, + parent: RuleContext +): RuleContext { + const ruleDef = game.rules.get(ruleName)!; + const ctx: RuleContext = { + type: ruleDef.schema.name, + schema: undefined, + generator: ruleDef.create.call(game, command), + parent, + children: [], + state: 'running', + resolution: undefined, + }; + + parent.children.push(ctx); + pushContextToGame(game, ctx); + + return stepGenerator(game, ctx); +} + +function resumeInvokingParent( + game: GameContextLike, + childCtx: RuleContext +): RuleContext | undefined { + const parent = childCtx.parent; + if (!parent || parent.state !== 'invoking') return undefined; + + parent.children = parent.children.filter(c => c !== childCtx); + + const result = parent.generator.next(childCtx); + if (result.done) { + (parent as RuleContext).resolution = result.value; + (parent as RuleContext).state = 'done'; + const resumed = resumeInvokingParent(game, parent); + return resumed ?? parent; + } else if (isInvokeYield(result.value)) { + (parent as RuleContext).state = 'invoking'; + const childCtx2 = invokeChildRule(game, result.value.rule, result.value.command, parent); + return childCtx2; + } else { + (parent as RuleContext).schema = parseYieldedSchema(result.value); + (parent as RuleContext).state = 'yielded'; + } + + return parent; +} + +function stepGenerator( + game: GameContextLike, + ctx: RuleContext +): RuleContext { + const result = ctx.generator.next(); + + if (result.done) { + ctx.resolution = result.value; + ctx.state = 'done'; + const resumed = resumeInvokingParent(game, ctx as RuleContext); + if (resumed) return resumed as RuleContext; + } else if (isInvokeYield(result.value)) { + const childRuleDef = game.rules.get(result.value.rule); + if (childRuleDef) { + ctx.state = 'invoking'; + const childCtx = invokeChildRule(game, result.value.rule, result.value.command, ctx as RuleContext); + return childCtx as RuleContext; + } else { + ctx.schema = parseYieldedSchema(''); + ctx.state = 'yielded'; + } + } else { + ctx.schema = parseYieldedSchema(result.value); + ctx.state = 'yielded'; + } + + return ctx; +} + function invokeRule( game: GameContextLike, command: Command, @@ -88,7 +208,7 @@ function invokeRule( const ctx: RuleContext = { type: ruleDef.schema.name, schema: undefined, - generator: ruleDef.create(command), + generator: ruleDef.create.call(game, command), parent, children: [], state: 'running', @@ -103,16 +223,7 @@ function invokeRule( pushContextToGame(game, ctx as RuleContext); - const result = ctx.generator.next(); - if (result.done) { - ctx.resolution = result.value; - ctx.state = 'done'; - } else { - ctx.schema = parseYieldedSchema(result.value); - ctx.state = 'yielded'; - } - - return ctx; + return stepGenerator(game, ctx); } export function dispatchCommand(game: GameContextLike, input: string): RuleContext | undefined { @@ -134,6 +245,12 @@ export function dispatchCommand(game: GameContextLike, input: string): RuleConte if (result.done) { ctx.resolution = result.value; ctx.state = 'done'; + const resumed = resumeInvokingParent(game, ctx); + return resumed ?? ctx; + } else if (isInvokeYield(result.value)) { + ctx.state = 'invoking'; + const childCtx = invokeChildRule(game, result.value.rule, result.value.command, ctx); + return childCtx; } else { ctx.schema = parseYieldedSchema(result.value); ctx.state = 'yielded'; @@ -156,10 +273,25 @@ function findYieldedParent(game: GameContextLike): RuleContext | undefi return undefined; } -type GameContextLike = { +export type GameContextLike = { rules: RuleRegistry; ruleContexts: RuleContext[]; contexts: { value: any[] }; addRuleContext: (ctx: RuleContext) => void; removeRuleContext: (ctx: RuleContext) => void; + parts: { + collection: { value: Record }; + add: (...entities: any[]) => void; + remove: (...ids: string[]) => void; + get: (id: string) => any; + }; + regions: { + collection: { value: Record }; + add: (...entities: any[]) => void; + remove: (...ids: string[]) => void; + get: (id: string) => any; + }; + pushContext: (context: any) => any; + popContext: () => void; + latestContext: (type: string) => any | undefined; }; diff --git a/src/samples/tic-tac-toe.ts b/src/samples/tic-tac-toe.ts new file mode 100644 index 0000000..442e129 --- /dev/null +++ b/src/samples/tic-tac-toe.ts @@ -0,0 +1,155 @@ +import { GameContextInstance } from '../core/context'; +import type { GameContextLike, RuleContext } from '../core/rule'; +import { createRule, type InvokeYield, type RuleYield } from '../core/rule'; +import type { Command } from '../utils/command'; +import type { Part } from '../core/part'; +import type { Region } from '../core/region'; +import type { Context } from '../core/context'; + +export type TicTacToeState = Context & { + type: 'tic-tac-toe'; + currentPlayer: 'X' | 'O'; + winner: 'X' | 'O' | 'draw' | null; + moveCount: number; +}; + +type TurnResult = { + winner: 'X' | 'O' | 'draw' | null; +}; + +function getBoardRegion(game: GameContextLike) { + return game.regions.get('board'); +} + +function isCellOccupied(game: GameContextLike, row: number, col: number): boolean { + const board = getBoardRegion(game); + return board.value.children.some( + (child: { value: { position: number[] } }) => child.value.position[0] === row && child.value.position[1] === col + ); +} + +function checkWinner(game: GameContextLike): 'X' | 'O' | 'draw' | null { + const parts = Object.values(game.parts.collection.value).map((s: { value: Part }) => s.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); + + if (hasWinningLine(xPositions)) return 'X'; + if (hasWinningLine(oPositions)) return 'O'; + + return null; +} + +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 => + line.every(([r, c]) => + positions.some(([pr, pc]) => pr === r && pc === c) + ) + ); +} + +function placePiece(game: GameContextLike, row: number, col: number, moveCount: number) { + const board = getBoardRegion(game); + const piece: Part = { + id: `piece-${moveCount}`, + sides: 1, + side: 0, + region: board, + position: [row, col], + }; + game.parts.add(piece); + board.value.children.push(game.parts.get(piece.id)); +} + +const playSchema = 'play '; + +export function createSetupRule() { + return createRule('start', function*() { + this.pushContext({ + type: 'tic-tac-toe', + currentPlayer: 'X', + winner: null, + moveCount: 0, + } as TicTacToeState); + + this.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; + + while (true) { + const yieldValue: InvokeYield = { + type: 'invoke', + rule: 'turn', + command: { name: 'turn', params: [currentPlayer], flags: {}, options: {} } as Command, + }; + const ctx = yield yieldValue as RuleYield; + turnResult = (ctx as RuleContext).resolution; + if (turnResult?.winner) break; + + currentPlayer = currentPlayer === 'X' ? 'O' : 'X'; + const state = this.latestContext('tic-tac-toe')!; + state.value.currentPlayer = currentPlayer; + } + + const state = this.latestContext('tic-tac-toe')!; + state.value.winner = turnResult?.winner ?? null; + return { winner: state.value.winner }; + }); +} + +export function createTurnRule() { + return createRule('turn ', function*(cmd) { + while (true) { + const received = yield playSchema; + if ('resolution' in received) continue; + + const playCmd = received as Command; + if (playCmd.name !== 'play') continue; + + const row = playCmd.params[1] as number; + const col = playCmd.params[2] as number; + + if (isNaN(row) || isNaN(col) || row < 0 || row > 2 || col < 0 || col > 2) continue; + if (isCellOccupied(this, row, col)) continue; + + const state = this.latestContext('tic-tac-toe')!; + if (state.value.winner) continue; + + placePiece(this, row, col, state.value.moveCount); + state.value.moveCount++; + + const winner = checkWinner(this); + if (winner) return { winner }; + + if (state.value.moveCount >= 9) return { winner: 'draw' as const }; + } + }); +} + +export function registerTicTacToeRules(game: GameContextInstance) { + game.registerRule('start', createSetupRule()); + game.registerRule('turn', createTurnRule()); +} + +export function startTicTacToe(game: GameContextInstance) { + game.dispatchCommand('start'); +} diff --git a/tests/core/rule.test.ts b/tests/core/rule.test.ts index 8138f15..ecef65b 100644 --- a/tests/core/rule.test.ts +++ b/tests/core/rule.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { createRule, type RuleContext } from '../../src/core/rule'; +import { createRule, type RuleContext, type GameContextLike } from '../../src/core/rule'; import { createGameContext } from '../../src/core/context'; +import type { Command } from '../../src/utils/command'; + +function isCommand(value: Command | RuleContext): value is Command { + return 'name' in value; +} describe('Rule System', () => { function createTestGame() { @@ -24,11 +29,12 @@ describe('Rule System', () => { }); it('should create a generator when called', () => { + const game = createTestGame(); const rule = createRule('', function*(cmd) { return cmd.params[0]; }); - const gen = rule.create({ name: 'test', params: ['card1'], flags: {}, options: {} }); + const gen = rule.create.call(game as unknown as GameContextLike, { name: 'test', params: ['card1'], flags: {}, options: {} }); const result = gen.next(); expect(result.done).toBe(true); expect(result.value).toBe('card1'); @@ -57,7 +63,8 @@ describe('Rule System', () => { game.registerRule('move', createRule(' ', function*(cmd) { const confirm = yield { name: '', params: [], options: [], flags: [] }; - return { moved: cmd.params[0], confirmed: confirm.name === 'confirm' }; + const confirmCmd = isCommand(confirm) ? confirm : undefined; + return { moved: cmd.params[0], confirmed: confirmCmd?.name === 'confirm' }; })); game.dispatchCommand('move card1 hand'); @@ -132,7 +139,8 @@ describe('Rule System', () => { game.registerRule('move', createRule(' ', function*(cmd) { const response = yield { name: '', params: [], options: [], flags: [] }; - return { moved: cmd.params[0], response: response.name }; + const rcmd = isCommand(response) ? response : undefined; + return { moved: cmd.params[0], response: rcmd?.name }; })); game.dispatchCommand('move card1 hand'); @@ -147,7 +155,8 @@ describe('Rule System', () => { game.registerRule('move', createRule(' ', function*(cmd) { const response = yield ''; - return { response: response.params[0] }; + const rcmd = isCommand(response) ? response : undefined; + return { response: rcmd?.params[0] }; })); game.dispatchCommand('move card1 hand'); @@ -162,7 +171,8 @@ describe('Rule System', () => { game.registerRule('trade', createRule(' ', function*(cmd) { const response = yield ' [amount: number]'; - return { traded: response.params[0] }; + const rcmd = isCommand(response) ? response : undefined; + return { traded: rcmd?.params[0] }; })); game.dispatchCommand('trade player1 player2'); @@ -333,7 +343,8 @@ describe('Rule System', () => { game.registerRule('test', createRule('', function*() { const cmd = yield customSchema; - return { received: cmd.params[0] }; + const rcmd = isCommand(cmd) ? cmd : undefined; + return { received: rcmd?.params[0] }; })); game.dispatchCommand('test val1'); @@ -349,7 +360,9 @@ describe('Rule System', () => { game.registerRule('multi', createRule('', function*() { const a = yield ''; const b = yield ''; - return { a: a.params[0], b: b.params[0] }; + const acmd = isCommand(a) ? a : undefined; + const bcmd = isCommand(b) ? b : undefined; + return { a: acmd?.params[0], b: bcmd?.params[0] }; })); game.dispatchCommand('multi init'); @@ -369,13 +382,15 @@ describe('Rule System', () => { const player = cmd.params[0]; const action = yield { name: '', params: [], options: [], flags: [] }; - if (action.name === 'move') { - yield ''; - } else if (action.name === 'attack') { - yield ' [--power: number]'; + if (isCommand(action)) { + if (action.name === 'move') { + yield ''; + } else if (action.name === 'attack') { + yield ' [--power: number]'; + } } - return { player, action: action.name }; + return { player, action: isCommand(action) ? action.name : '' }; })); const ctx1 = game.dispatchCommand('start alice'); diff --git a/tests/samples/tic-tac-toe.test.ts b/tests/samples/tic-tac-toe.test.ts new file mode 100644 index 0000000..c80a099 --- /dev/null +++ b/tests/samples/tic-tac-toe.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import { createGameContext } from '../../src/core/context'; +import { registerTicTacToeRules, startTicTacToe, type TicTacToeState } from '../../src/samples/tic-tac-toe'; + +describe('Tic-Tac-Toe', () => { + function createGame() { + const game = createGameContext(); + registerTicTacToeRules(game); + return game; + } + + function getBoardState(game: ReturnType) { + return game.latestContext('tic-tac-toe')!.value; + } + + it('should initialize the board and start the game', () => { + const game = createGame(); + startTicTacToe(game); + + 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', () => { + const game = createGame(); + startTicTacToe(game); + + // X wins with column 0 + game.dispatchCommand('play X 0 0'); + game.dispatchCommand('play O 0 1'); + game.dispatchCommand('play X 1 0'); + game.dispatchCommand('play O 1 1'); + game.dispatchCommand('play X 2 0'); + + const state = getBoardState(game); + expect(state.winner).toBe('X'); + expect(state.moveCount).toBe(5); + }); + + it('should reject out-of-bounds moves', () => { + const game = createGame(); + startTicTacToe(game); + + const beforeCount = getBoardState(game).moveCount; + + game.dispatchCommand('play X 5 5'); + game.dispatchCommand('play X -1 0'); + game.dispatchCommand('play X 3 3'); + + expect(getBoardState(game).moveCount).toBe(beforeCount); + }); + + it('should reject moves on occupied cells', () => { + const game = createGame(); + startTicTacToe(game); + + game.dispatchCommand('play X 1 1'); + expect(getBoardState(game).moveCount).toBe(1); + + // Try to play on the same cell + game.dispatchCommand('play O 1 1'); + expect(getBoardState(game).moveCount).toBe(1); + }); + + it('should ignore moves after game is over', () => { + const game = createGame(); + startTicTacToe(game); + + // X wins + game.dispatchCommand('play X 0 0'); + game.dispatchCommand('play O 0 1'); + game.dispatchCommand('play X 1 0'); + game.dispatchCommand('play O 1 1'); + game.dispatchCommand('play X 2 0'); + + expect(getBoardState(game).winner).toBe('X'); + const moveCountAfterWin = getBoardState(game).moveCount; + + // Try to play more + game.dispatchCommand('play X 2 1'); + game.dispatchCommand('play O 2 2'); + + expect(getBoardState(game).moveCount).toBe(moveCountAfterWin); + }); + + it('should detect a draw', () => { + const game = createGame(); + startTicTacToe(game); + + // Fill board with no winner (cat's game) + // X: (0,0), (0,2), (1,1), (2,0) + // O: (0,1), (1,0), (1,2), (2,1), (2,2) + game.dispatchCommand('play X 0 0'); // X + game.dispatchCommand('play O 0 1'); // O + game.dispatchCommand('play X 0 2'); // X + game.dispatchCommand('play O 1 0'); // O + game.dispatchCommand('play X 1 1'); // X (center) + game.dispatchCommand('play O 1 2'); // O + game.dispatchCommand('play X 2 0'); // X + game.dispatchCommand('play O 2 1'); // O + game.dispatchCommand('play X 2 2'); // X (last move, draw) + + 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', () => { + const game = createGame(); + startTicTacToe(game); + + game.dispatchCommand('play X 1 2'); + + 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]); + }); +});