import { describe, it, expect } from 'vitest'; import { createRule, type RuleContext } from '../../src/core/rule'; import { createGameContext } from '../../src/core/context'; describe('Rule System', () => { function createTestGame() { const game = createGameContext(); return game; } describe('createRule', () => { it('should create a rule definition with parsed schema', () => { const rule = createRule(' [--force]', function*(cmd) { return { from: cmd.params[0], to: cmd.params[1] }; }); expect(rule.schema.params).toHaveLength(2); expect(rule.schema.params[0].name).toBe('from'); expect(rule.schema.params[0].required).toBe(true); expect(rule.schema.params[1].name).toBe('to'); expect(rule.schema.params[1].required).toBe(true); expect(rule.schema.flags).toHaveLength(1); expect(rule.schema.flags[0].name).toBe('force'); }); it('should create a generator when called', () => { const rule = createRule('', function*(cmd) { return cmd.params[0]; }); const gen = rule.create({ name: 'test', params: ['card1'], flags: {}, options: {} }); const result = gen.next(); expect(result.done).toBe(true); expect(result.value).toBe('card1'); }); }); describe('dispatchCommand - rule invocation', () => { it('should invoke a registered rule and yield schema', () => { const game = createTestGame(); game.registerRule('move', createRule(' ', function*(cmd) { yield { name: '', params: [], options: [], flags: [] }; return { moved: cmd.params[0] }; })); const ctx = game.dispatchCommand('move card1 hand'); expect(ctx).toBeDefined(); expect(ctx!.state).toBe('yielded'); expect(ctx!.schema).toBeDefined(); expect(ctx!.resolution).toBeUndefined(); }); it('should complete a rule when final command matches yielded schema', () => { const game = createTestGame(); game.registerRule('move', createRule(' ', function*(cmd) { const confirm = yield { name: '', params: [], options: [], flags: [] }; return { moved: cmd.params[0], confirmed: confirm.name === 'confirm' }; })); game.dispatchCommand('move card1 hand'); const ctx = game.dispatchCommand('confirm'); expect(ctx).toBeDefined(); expect(ctx!.state).toBe('done'); expect(ctx!.resolution).toEqual({ moved: 'card1', confirmed: true }); }); it('should return undefined when command matches no rule and no yielded context', () => { const game = createTestGame(); const result = game.dispatchCommand('unknown command'); expect(result).toBeUndefined(); }); it('should pass the initial command to the generator', () => { const game = createTestGame(); game.registerRule('attack', createRule(' [--power: number]', function*(cmd) { return { target: cmd.params[0], power: cmd.options.power || '1' }; })); const ctx = game.dispatchCommand('attack goblin --power 5'); expect(ctx!.state).toBe('done'); expect(ctx!.resolution).toEqual({ target: 'goblin', power: '5' }); }); it('should complete immediately if generator does not yield', () => { const game = createTestGame(); game.registerRule('look', createRule('[--at]', function*() { return 'looked'; })); const ctx = game.dispatchCommand('look'); expect(ctx!.state).toBe('done'); expect(ctx!.resolution).toBe('looked'); }); }); describe('dispatchCommand - rule priority', () => { it('should prioritize new rule invocation over feeding yielded context', () => { const game = createTestGame(); game.registerRule('move', createRule(' ', function*(cmd) { yield { name: '', params: [], options: [], flags: [] }; return { moved: cmd.params[0] }; })); game.registerRule('confirm', createRule('', function*() { return 'new confirm rule'; })); game.dispatchCommand('move card1 hand'); const ctx = game.dispatchCommand('confirm'); expect(ctx!.state).toBe('done'); expect(ctx!.resolution).toBe('new confirm rule'); expect(ctx!.type).toBe(''); }); }); describe('dispatchCommand - fallback to yielded context', () => { it('should feed a yielded context when command does not match any rule', () => { const game = createTestGame(); game.registerRule('move', createRule(' ', function*(cmd) { const response = yield { name: '', params: [], options: [], flags: [] }; return { moved: cmd.params[0], response: response.name }; })); game.dispatchCommand('move card1 hand'); const ctx = game.dispatchCommand('yes'); expect(ctx!.state).toBe('done'); expect(ctx!.resolution).toEqual({ moved: 'card1', response: 'yes' }); }); it('should skip non-matching commands for yielded context', () => { const game = createTestGame(); game.registerRule('move', createRule(' ', function*(cmd) { const response = yield ''; return { response: response.params[0] }; })); game.dispatchCommand('move card1 hand'); const ctx = game.dispatchCommand('goblin'); expect(ctx).toBeUndefined(); }); it('should validate command against yielded schema', () => { const game = createTestGame(); game.registerRule('trade', createRule(' ', function*(cmd) { const response = yield ' [amount: number]'; return { traded: response.params[0] }; })); game.dispatchCommand('trade player1 player2'); const ctx = game.dispatchCommand('offer gold 5'); expect(ctx!.state).toBe('done'); expect(ctx!.resolution).toEqual({ traded: 'gold' }); }); }); describe('dispatchCommand - deepest context first', () => { it('should feed the deepest yielded context', () => { const game = createTestGame(); game.registerRule('parent', createRule('', function*() { yield { name: '', params: [], options: [], flags: [] }; return 'parent done'; })); game.registerRule('child', createRule('', function*() { yield { name: '', params: [], options: [], flags: [] }; return 'child done'; })); game.dispatchCommand('parent start'); game.dispatchCommand('child target1'); const ctx = game.dispatchCommand('grandchild_cmd'); expect(ctx!.state).toBe('done'); expect(ctx!.resolution).toBe('child done'); }); }); describe('nested rule invocations', () => { it('should link child to parent', () => { const game = createTestGame(); game.registerRule('parent', createRule('', function*() { yield 'child_cmd'; return 'parent done'; })); game.registerRule('child_cmd', createRule('', function*() { return 'child done'; })); game.dispatchCommand('parent start'); const parentCtx = game.ruleContexts.value[0]; game.dispatchCommand('child_cmd target1'); expect(parentCtx.state).toBe('waiting'); const childCtx = game.ruleContexts.value[1]; expect(childCtx.parent).toBe(parentCtx); expect(parentCtx.children).toContain(childCtx); }); it('should discard previous children when a new child is invoked', () => { const game = createTestGame(); game.registerRule('parent', createRule('', function*() { yield 'child_a | child_b'; return 'parent done'; })); game.registerRule('child_a', createRule('', function*() { return 'child_a done'; })); game.registerRule('child_b', createRule('', function*() { return 'child_b done'; })); game.dispatchCommand('parent start'); game.dispatchCommand('child_a target1'); expect(game.ruleContexts.value.length).toBe(2); const oldParent = game.ruleContexts.value[0]; expect(oldParent.children).toHaveLength(1); game.dispatchCommand('parent start'); game.dispatchCommand('child_b target2'); const newParent = game.ruleContexts.value[2]; expect(newParent.children).toHaveLength(1); expect(newParent.children[0].resolution).toBe('child_b done'); }); }); describe('context tracking', () => { it('should track rule contexts in ruleContexts signal', () => { const game = createTestGame(); game.registerRule('test', createRule('', function*() { yield { name: '', params: [], options: [], flags: [] }; return 'done'; })); expect(game.ruleContexts.value.length).toBe(0); game.dispatchCommand('test arg1'); 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', () => { it('should leave context in place when generator throws', () => { const game = createTestGame(); game.registerRule('failing', createRule('', function*() { throw new Error('rule error'); })); expect(() => game.dispatchCommand('failing arg1')).toThrow('rule error'); expect(game.ruleContexts.value.length).toBe(1); }); it('should leave children in place when child generator throws', () => { const game = createTestGame(); game.registerRule('parent', createRule('', function*() { yield 'child'; return 'parent done'; })); game.registerRule('child', createRule('', function*() { throw new Error('child error'); })); game.dispatchCommand('parent start'); expect(() => game.dispatchCommand('child target1')).toThrow('child error'); expect(game.ruleContexts.value.length).toBe(2); }); }); describe('schema yielding', () => { it('should accept a CommandSchema object as yield value', () => { const game = createTestGame(); const customSchema = { name: 'custom', params: [{ name: 'x', required: true, variadic: false }], options: [], flags: [], }; game.registerRule('test', createRule('', function*() { const cmd = yield customSchema; return { received: cmd.params[0] }; })); game.dispatchCommand('test val1'); const ctx = game.dispatchCommand('custom hello'); expect(ctx!.state).toBe('done'); expect(ctx!.resolution).toEqual({ received: 'hello' }); }); it('should parse string schema on each yield', () => { const game = createTestGame(); game.registerRule('multi', createRule('', function*() { const a = yield ''; const b = yield ''; return { a: a.params[0], b: b.params[0] }; })); game.dispatchCommand('multi init'); game.dispatchCommand('cmd first'); const ctx = game.dispatchCommand('cmd second'); expect(ctx!.state).toBe('done'); expect(ctx!.resolution).toEqual({ a: 'first', b: 'second' }); }); }); describe('complex flow', () => { it('should handle a multi-step game flow', () => { const game = createTestGame(); game.registerRule('start', createRule('', function*(cmd) { 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]'; } return { player, action: action.name }; })); const ctx1 = game.dispatchCommand('start alice'); expect(ctx1!.state).toBe('yielded'); const ctx2 = game.dispatchCommand('attack'); expect(ctx2!.state).toBe('yielded'); const ctx3 = game.dispatchCommand('attack goblin --power 3'); expect(ctx3!.state).toBe('done'); expect(ctx3!.resolution).toEqual({ player: 'alice', action: 'attack' }); }); }); });