From e06dc8ecba509c971c2daa5e4fe40182a2d8c679 Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 2 Apr 2026 00:44:29 +0800 Subject: [PATCH] refactor: improve rule handling --- src/core/context.ts | 17 ++- src/core/rule.ts | 295 ++++++++++++++++--------------------- src/samples/tic-tac-toe.ts | 39 +++-- tests/core/rule.test.ts | 59 +++----- 4 files changed, 184 insertions(+), 226 deletions(-) diff --git a/src/core/context.ts b/src/core/context.ts index 509d9eb..29a5189 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, GameContextLike, dispatchCommand as dispatchRuleCommand} from "./rule"; +import {RuleDef, RuleRegistry, RuleContext, RuleEngineHost, dispatchCommand as dispatchRuleCommand} from "./rule"; export type Context = { type: string; @@ -37,7 +37,7 @@ export const GameContext = createModel((root: Context) => { return undefined; } - function registerRule(name: string, rule: RuleDef) { + function registerRule(name: string, rule: RuleDef) { const newRules = new Map(rules.value); newRules.set(name, rule); rules.value = newRules; @@ -57,15 +57,18 @@ export const GameContext = createModel((root: Context) => { ruleContexts.value = ruleContexts.value.filter(c => c !== ctx); } - function dispatchCommand(this: GameContextLike, input: string) { + function dispatchCommand(this: GameContextInstance, input: string) { return dispatchRuleCommand({ - ...this, rules: rules.value, ruleContexts: ruleContexts.value, - contexts, addRuleContext, removeRuleContext, - }, input); + pushContext, + popContext, + latestContext, + parts, + regions, + } as any, input); } return { @@ -83,7 +86,7 @@ export const GameContext = createModel((root: Context) => { } }) -/** 创建游戏上下文实?*/ +/** 创建游戏上下文实�?*/ export function createGameContext(root: Context = { type: 'game' }) { return new GameContext(root); } diff --git a/src/core/rule.ts b/src/core/rule.ts index 7a4d032..aeb8bc1 100644 --- a/src/core/rule.ts +++ b/src/core/rule.ts @@ -2,13 +2,9 @@ import {Command, CommandSchema, parseCommand, parseCommandSchema, applyCommandSc 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 SchemaYield = { type: 'schema'; value: string | CommandSchema }; +export type InvokeYield = { type: 'invoke'; rule: string; command: Command }; +export type RuleYield = SchemaYield | InvokeYield; export type RuleContext = { type: string; @@ -20,27 +16,30 @@ export type RuleContext = { resolution?: T; } -export type RuleDef = { +export type RuleDef = { schema: CommandSchema; - create: (this: GameContextLike, cmd: Command) => Generator>; + create: (this: H, cmd: Command) => Generator>; }; -export type RuleRegistry = Map>; +export type RuleRegistry = Map>; -export function createRule( +export type RuleEngineHost = { + rules: RuleRegistry; + ruleContexts: RuleContext[]; + addRuleContext: (ctx: RuleContext) => void; + removeRuleContext: (ctx: RuleContext) => void; +}; + +export function createRule( schemaStr: string, - fn: (this: GameContextLike, cmd: Command) => Generator> -): RuleDef { + fn: (this: H, cmd: Command) => Generator> +): RuleDef { return { schema: parseCommandSchema(schemaStr, ''), - create: fn as RuleDef['create'], + create: fn as RuleDef['create'], }; } -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, ''); @@ -48,31 +47,19 @@ function parseYieldedSchema(value: string | CommandSchema): CommandSchema { return value; } -function parseCommandWithSchema(command: Command, schema: CommandSchema): Command { - return applyCommandSchema(command, schema).command; +function addContextToHost(host: RuleEngineHost, ctx: RuleContext) { + host.addRuleContext(ctx); } -function pushContextToGame(game: GameContextLike, ctx: RuleContext) { - game.contexts.value = [...game.contexts.value, { value: ctx } as any]; - game.addRuleContext(ctx); -} - -function discardChildren(game: GameContextLike, parent: RuleContext) { +function discardChildren(host: RuleEngineHost, parent: RuleContext) { for (const child of parent.children) { - game.removeRuleContext(child); - - const ctxIdx = game.contexts.value.findIndex((c: any) => c.value === child); - if (ctxIdx !== -1) { - const arr = [...game.contexts.value]; - arr.splice(ctxIdx, 1); - game.contexts.value = arr; - } + host.removeRuleContext(child); } parent.children = []; parent.state = 'yielded'; } -function validateYieldedSchema(command: Command, schema: CommandSchema): boolean { +function commandMatchesSchema(command: Command, schema: CommandSchema): boolean { const requiredParams = schema.params.filter(p => p.required); const variadicParam = schema.params.find(p => p.variadic); @@ -95,31 +82,93 @@ function validateYieldedSchema(command: Command, schema: CommandSchema): boolean return true; } -function invokeChildRule( - game: GameContextLike, - ruleName: string, +function applySchemaToCommand(command: Command, schema: CommandSchema): Command { + return applyCommandSchema(command, schema).command; +} + +function findYieldedContext(contexts: RuleContext[]): RuleContext | undefined { + for (let i = contexts.length - 1; i >= 0; i--) { + const ctx = contexts[i]; + if (ctx.state === 'yielded') { + return ctx; + } + } + return undefined; +} + +function createContext( command: Command, - parent: RuleContext -): RuleContext { - const ruleDef = game.rules.get(ruleName)!; - const ctx: RuleContext = { + ruleDef: RuleDef, + host: RuleEngineHost, + parent?: RuleContext +): RuleContext { + return { type: ruleDef.schema.name, schema: undefined, - generator: ruleDef.create.call(game, command), + generator: ruleDef.create.call(host, command), parent, children: [], state: 'running', resolution: undefined, }; - - parent.children.push(ctx); - pushContextToGame(game, ctx); - - return stepGenerator(game, ctx); } -function resumeInvokingParent( - game: GameContextLike, +function handleGeneratorResult( + host: RuleEngineHost, + ctx: RuleContext, + result: IteratorResult +): RuleContext | undefined { + if (result.done) { + ctx.resolution = result.value; + ctx.state = 'done'; + return resumeParentAfterChildComplete(host, ctx as RuleContext); + } + + const yielded = result.value; + if (yielded.type === 'invoke') { + const childRuleDef = host.rules.get(yielded.rule); + if (childRuleDef) { + ctx.state = 'invoking'; + return invokeChildRule(host, yielded.rule, yielded.command, ctx as RuleContext); + } else { + ctx.schema = parseYieldedSchema({ name: '', params: [], options: [], flags: [] }); + ctx.state = 'yielded'; + } + } else { + ctx.schema = parseYieldedSchema(yielded.value); + ctx.state = 'yielded'; + } + + return undefined; +} + +function stepGenerator( + host: RuleEngineHost, + ctx: RuleContext +): RuleContext { + const result = ctx.generator.next(); + const resumed = handleGeneratorResult(host, ctx, result); + if (resumed) return resumed as RuleContext; + return ctx; +} + +function invokeChildRule( + host: RuleEngineHost, + ruleName: string, + command: Command, + parent: RuleContext +): RuleContext { + const ruleDef = host.rules.get(ruleName)!; + const ctx = createContext(command, ruleDef, host, parent); + + parent.children.push(ctx as RuleContext); + addContextToHost(host, ctx as RuleContext); + + return stepGenerator(host, ctx) as RuleContext; +} + +function resumeParentAfterChildComplete( + host: RuleEngineHost, childCtx: RuleContext ): RuleContext | undefined { const parent = childCtx.parent; @@ -128,147 +177,57 @@ function resumeInvokingParent( 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'; - } - + const resumed = handleGeneratorResult(host, parent, result); + if (resumed) return resumed; 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, + host: RuleEngineHost, command: Command, ruleDef: RuleDef, parent?: RuleContext ): RuleContext { - const ctx: RuleContext = { - type: ruleDef.schema.name, - schema: undefined, - generator: ruleDef.create.call(game, command), - parent, - children: [], - state: 'running', - resolution: undefined, - }; + const ctx = createContext(command, ruleDef, host, parent); if (parent) { - discardChildren(game, parent); + discardChildren(host, parent); parent.children.push(ctx as RuleContext); parent.state = 'waiting'; } - pushContextToGame(game, ctx as RuleContext); + addContextToHost(host, ctx as RuleContext); - return stepGenerator(game, ctx); + return stepGenerator(host, ctx); } -export function dispatchCommand(game: GameContextLike, input: string): RuleContext | undefined { +function feedYieldedContext( + host: RuleEngineHost, + ctx: RuleContext, + command: Command +): RuleContext { + const typedCommand = applySchemaToCommand(command, ctx.schema!); + const result = ctx.generator.next(typedCommand); + const resumed = handleGeneratorResult(host, ctx, result); + return resumed ?? ctx; +} + +export function dispatchCommand(host: RuleEngineHost, input: string): RuleContext | undefined { const command = parseCommand(input); - if (game.rules.has(command.name)) { - const ruleDef = game.rules.get(command.name)!; - const typedCommand = parseCommandWithSchema(command, ruleDef.schema); - - const parent = findYieldedParent(game); - - return invokeRule(game, typedCommand, ruleDef, parent); + const matchedRule = host.rules.get(command.name); + if (matchedRule) { + const typedCommand = applySchemaToCommand(command, matchedRule.schema); + const parent = findYieldedContext(host.ruleContexts); + return invokeRule(host, typedCommand, matchedRule, parent); } - for (let i = game.ruleContexts.length - 1; i >= 0; i--) { - const ctx = game.ruleContexts[i]; - if (ctx.state === 'yielded' && ctx.schema) { - if (validateYieldedSchema(command, ctx.schema)) { - const typedCommand = parseCommandWithSchema(command, ctx.schema); - const result = ctx.generator.next(typedCommand); - 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'; - } - return ctx; - } + for (let i = host.ruleContexts.length - 1; i >= 0; i--) { + const ctx = host.ruleContexts[i]; + if (ctx.state === 'yielded' && ctx.schema && commandMatchesSchema(command, ctx.schema)) { + return feedYieldedContext(host, ctx, command); } } return undefined; } - -function findYieldedParent(game: GameContextLike): RuleContext | undefined { - for (let i = game.ruleContexts.length - 1; i >= 0; i--) { - const ctx = game.ruleContexts[i]; - if (ctx.state === 'yielded') { - return ctx; - } - } - return undefined; -} - -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 index 442e129..d464ee8 100644 --- a/src/samples/tic-tac-toe.ts +++ b/src/samples/tic-tac-toe.ts @@ -1,6 +1,6 @@ import { GameContextInstance } from '../core/context'; -import type { GameContextLike, RuleContext } from '../core/rule'; -import { createRule, type InvokeYield, type RuleYield } from '../core/rule'; +import type { RuleEngineHost, RuleContext } from '../core/rule'; +import { createRule, type InvokeYield, type SchemaYield } from '../core/rule'; import type { Command } from '../utils/command'; import type { Part } from '../core/part'; import type { Region } from '../core/region'; @@ -17,19 +17,26 @@ type TurnResult = { winner: 'X' | 'O' | 'draw' | null; }; -function getBoardRegion(game: GameContextLike) { - return game.regions.get('board'); +type TicTacToeHost = RuleEngineHost & { + pushContext: (context: Context) => any; + latestContext: (type: string) => { value: T } | undefined; + regions: { add: (...entities: any[]) => void; get: (id: string) => { value: { children: any[] } } }; + parts: { add: (...entities: any[]) => void; get: (id: string) => any; collection: { value: Record } }; +}; + +function getBoardRegion(host: TicTacToeHost) { + return host.regions.get('board'); } -function isCellOccupied(game: GameContextLike, row: number, col: number): boolean { - const board = getBoardRegion(game); +function isCellOccupied(host: TicTacToeHost, 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(game: GameContextLike): 'X' | 'O' | 'draw' | null { - const parts = Object.values(game.parts.collection.value).map((s: { value: Part }) => s.value); +function checkWinner(host: TicTacToeHost): '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); const oPositions = parts.filter((_: Part, i: number) => i % 2 === 1).map((p: Part) => p.position); @@ -59,8 +66,8 @@ function hasWinningLine(positions: number[][]): boolean { ); } -function placePiece(game: GameContextLike, row: number, col: number, moveCount: number) { - const board = getBoardRegion(game); +function placePiece(host: TicTacToeHost, row: number, col: number, moveCount: number) { + const board = getBoardRegion(host); const piece: Part = { id: `piece-${moveCount}`, sides: 1, @@ -68,14 +75,14 @@ function placePiece(game: GameContextLike, row: number, col: number, moveCount: region: board, position: [row, col], }; - game.parts.add(piece); - board.value.children.push(game.parts.get(piece.id)); + host.parts.add(piece); + board.value.children.push(host.parts.get(piece.id)); } -const playSchema = 'play '; +const playSchema: SchemaYield = { type: 'schema', value: 'play ' }; export function createSetupRule() { - return createRule('start', function*() { + return createRule('start', function*(this: TicTacToeHost) { this.pushContext({ type: 'tic-tac-toe', currentPlayer: 'X', @@ -101,7 +108,7 @@ export function createSetupRule() { rule: 'turn', command: { name: 'turn', params: [currentPlayer], flags: {}, options: {} } as Command, }; - const ctx = yield yieldValue as RuleYield; + const ctx = yield yieldValue; turnResult = (ctx as RuleContext).resolution; if (turnResult?.winner) break; @@ -117,7 +124,7 @@ export function createSetupRule() { } export function createTurnRule() { - return createRule('turn ', function*(cmd) { + return createRule('turn ', function*(this: TicTacToeHost, cmd) { while (true) { const received = yield playSchema; if ('resolution' in received) continue; diff --git a/tests/core/rule.test.ts b/tests/core/rule.test.ts index ac97fc3..32daa43 100644 --- a/tests/core/rule.test.ts +++ b/tests/core/rule.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createRule, type RuleContext, type GameContextLike } from '../../src/core/rule'; +import { createRule, type RuleContext, type RuleEngineHost } from '../../src/core/rule'; import { createGameContext } from '../../src/core/context'; import type { Command } from '../../src/utils/command'; @@ -7,6 +7,10 @@ function isCommand(value: Command | RuleContext): value is Command { return 'name' in value; } +function schema(value: string | { name: string; params: any[]; options: any[]; flags: any[] }) { + return { type: 'schema' as const, value }; +} + describe('Rule System', () => { function createTestGame() { const game = createGameContext(); @@ -34,7 +38,7 @@ describe('Rule System', () => { return cmd.params[0]; }); - const gen = rule.create.call(game as unknown as GameContextLike, { name: 'test', params: ['card1'], flags: {}, options: {} }); + const gen = rule.create.call(game as unknown as RuleEngineHost, { name: 'test', params: ['card1'], flags: {}, options: {} }); const result = gen.next(); expect(result.done).toBe(true); expect(result.value).toBe('card1'); @@ -46,7 +50,7 @@ describe('Rule System', () => { const game = createTestGame(); game.registerRule('move', createRule(' ', function*(cmd) { - yield { name: '', params: [], options: [], flags: [] }; + yield schema({ name: '', params: [], options: [], flags: [] }); return { moved: cmd.params[0] }; })); @@ -62,7 +66,7 @@ describe('Rule System', () => { const game = createTestGame(); game.registerRule('move', createRule(' ', function*(cmd) { - const confirm = yield { name: '', params: [], options: [], flags: [] }; + const confirm = yield schema({ name: '', params: [], options: [], flags: [] }); const confirmCmd = isCommand(confirm) ? confirm : undefined; return { moved: cmd.params[0], confirmed: confirmCmd?.name === 'confirm' }; })); @@ -115,7 +119,7 @@ describe('Rule System', () => { const game = createTestGame(); game.registerRule('move', createRule(' ', function*(cmd) { - yield { name: '', params: [], options: [], flags: [] }; + yield schema({ name: '', params: [], options: [], flags: [] }); return { moved: cmd.params[0] }; })); @@ -138,7 +142,7 @@ describe('Rule System', () => { const game = createTestGame(); game.registerRule('move', createRule(' ', function*(cmd) { - const response = yield { name: '', params: [], options: [], flags: [] }; + const response = yield schema({ name: '', params: [], options: [], flags: [] }); const rcmd = isCommand(response) ? response : undefined; return { moved: cmd.params[0], response: rcmd?.name }; })); @@ -154,7 +158,7 @@ describe('Rule System', () => { const game = createTestGame(); game.registerRule('move', createRule(' ', function*(cmd) { - const response = yield ''; + const response = yield schema(''); const rcmd = isCommand(response) ? response : undefined; return { response: rcmd?.params[0] }; })); @@ -170,7 +174,7 @@ describe('Rule System', () => { const game = createTestGame(); game.registerRule('trade', createRule(' ', function*(cmd) { - const response = yield ' [amount: number]'; + const response = yield schema(' [amount: number]'); const rcmd = isCommand(response) ? response : undefined; return { traded: rcmd?.params[0] }; })); @@ -188,12 +192,12 @@ describe('Rule System', () => { const game = createTestGame(); game.registerRule('parent', createRule('', function*() { - yield { name: '', params: [], options: [], flags: [] }; + yield schema({ name: '', params: [], options: [], flags: [] }); return 'parent done'; })); game.registerRule('child', createRule('', function*() { - yield { name: '', params: [], options: [], flags: [] }; + yield schema({ name: '', params: [], options: [], flags: [] }); return 'child done'; })); @@ -212,7 +216,7 @@ describe('Rule System', () => { const game = createTestGame(); game.registerRule('parent', createRule('', function*() { - yield 'child_cmd'; + yield schema('child_cmd'); return 'parent done'; })); @@ -236,7 +240,7 @@ describe('Rule System', () => { const game = createTestGame(); game.registerRule('parent', createRule('', function*() { - yield 'child_a | child_b'; + yield schema('child_a | child_b'); return 'parent done'; })); @@ -270,7 +274,7 @@ describe('Rule System', () => { const game = createTestGame(); game.registerRule('test', createRule('', function*() { - yield { name: '', params: [], options: [], flags: [] }; + yield schema({ name: '', params: [], options: [], flags: [] }); return 'done'; })); @@ -281,21 +285,6 @@ describe('Rule System', () => { expect(game.ruleContexts.value.length).toBe(1); expect(game.ruleContexts.value[0].state).toBe('yielded'); }); - - it('should add context to the context stack', () => { - const game = createTestGame(); - - game.registerRule('test', createRule('', function*() { - yield { name: '', params: [], options: [], flags: [] }; - return 'done'; - })); - - const initialStackLength = game.contexts.value.length; - - game.dispatchCommand('test arg1'); - - expect(game.contexts.value.length).toBe(initialStackLength + 1); - }); }); describe('error handling', () => { @@ -315,7 +304,7 @@ describe('Rule System', () => { const game = createTestGame(); game.registerRule('parent', createRule('', function*() { - yield 'child'; + yield schema('child'); return 'parent done'; })); @@ -342,7 +331,7 @@ describe('Rule System', () => { }; game.registerRule('test', createRule('', function*() { - const cmd = yield customSchema; + const cmd = yield schema(customSchema); const rcmd = isCommand(cmd) ? cmd : undefined; return { received: rcmd?.params[0] }; })); @@ -358,8 +347,8 @@ describe('Rule System', () => { const game = createTestGame(); game.registerRule('multi', createRule('', function*() { - const a = yield ''; - const b = yield ''; + const a = yield schema(''); + const b = yield schema(''); const acmd = isCommand(a) ? a : undefined; const bcmd = isCommand(b) ? b : undefined; return { a: acmd?.params[0], b: bcmd?.params[0] }; @@ -380,13 +369,13 @@ describe('Rule System', () => { game.registerRule('start', createRule('', function*(cmd) { const player = cmd.params[0]; - const action = yield { name: '', params: [], options: [], flags: [] }; + const action = yield schema({ name: '', params: [], options: [], flags: [] }); if (isCommand(action)) { if (action.name === 'move') { - yield ''; + yield schema(''); } else if (action.name === 'attack') { - yield ' [--power: number]'; + yield schema(' [--power: number]'); } }