596 lines
16 KiB
TypeScript
596 lines
16 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { createGameState } from '../../src/core/GameState';
|
|
import { RuleEngine } from '../../src/rules/RuleEngine';
|
|
import { createValidationRule, createEffectRule, createTriggerRule } from '../../src/rules/Rule';
|
|
import type { RuleResult, RuleContext } from '../../src/rules/Rule';
|
|
import { Command, CommandActionType } from '../../src/commands/Command';
|
|
import { RegionType } from '../../src/core/Region';
|
|
|
|
describe('RuleEngine', () => {
|
|
let gameState: ReturnType<typeof createGameState>;
|
|
let ruleEngine: RuleEngine;
|
|
|
|
beforeEach(() => {
|
|
gameState = createGameState({ id: 'test-game', name: 'Test Game' });
|
|
ruleEngine = new RuleEngine(gameState);
|
|
});
|
|
|
|
describe('registerRule', () => {
|
|
it('should register a validation rule', () => {
|
|
const rule = createValidationRule({
|
|
id: 'test-validation',
|
|
name: 'Test Validation',
|
|
priority: 1,
|
|
validate: async () => ({ success: true }),
|
|
});
|
|
|
|
ruleEngine.registerRule(rule);
|
|
const rules = ruleEngine.getRules();
|
|
|
|
expect(rules.length).toBe(1);
|
|
expect(rules[0].id).toBe('test-validation');
|
|
});
|
|
|
|
it('should register an effect rule', () => {
|
|
const rule = createEffectRule({
|
|
id: 'test-effect',
|
|
name: 'Test Effect',
|
|
priority: 1,
|
|
apply: async () => ({ success: true }),
|
|
});
|
|
|
|
ruleEngine.registerRule(rule);
|
|
const rules = ruleEngine.getRules();
|
|
|
|
expect(rules.length).toBe(1);
|
|
expect(rules[0].id).toBe('test-effect');
|
|
});
|
|
|
|
it('should register a trigger rule', () => {
|
|
const rule = createTriggerRule({
|
|
id: 'test-trigger',
|
|
name: 'Test Trigger',
|
|
priority: 1,
|
|
condition: async () => true,
|
|
action: async () => ({ success: true }),
|
|
});
|
|
|
|
ruleEngine.registerRule(rule);
|
|
const rules = ruleEngine.getRules();
|
|
|
|
expect(rules.length).toBe(1);
|
|
expect(rules[0].id).toBe('test-trigger');
|
|
});
|
|
|
|
it('should sort rules by priority', () => {
|
|
const rule1 = createValidationRule({
|
|
id: 'rule-1',
|
|
name: 'Rule 1',
|
|
priority: 3,
|
|
validate: async () => ({ success: true }),
|
|
});
|
|
const rule2 = createValidationRule({
|
|
id: 'rule-2',
|
|
name: 'Rule 2',
|
|
priority: 1,
|
|
validate: async () => ({ success: true }),
|
|
});
|
|
const rule3 = createValidationRule({
|
|
id: 'rule-3',
|
|
name: 'Rule 3',
|
|
priority: 2,
|
|
validate: async () => ({ success: true }),
|
|
});
|
|
|
|
ruleEngine.registerRules([rule1, rule2, rule3]);
|
|
const rules = ruleEngine.getRules();
|
|
|
|
expect(rules[0].id).toBe('rule-2');
|
|
expect(rules[1].id).toBe('rule-3');
|
|
expect(rules[2].id).toBe('rule-1');
|
|
});
|
|
});
|
|
|
|
describe('executeCommand with validation rules', () => {
|
|
it('should execute command when all validation rules pass', async () => {
|
|
ruleEngine.registerRule(
|
|
createValidationRule({
|
|
id: 'always-pass',
|
|
name: 'Always Pass',
|
|
priority: 1,
|
|
validate: async () => ({ success: true }),
|
|
})
|
|
);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'Test Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = await ruleEngine.executeCommand(command);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(gameState.getPart('meeple-1')).toBeDefined();
|
|
});
|
|
|
|
it('should block command when validation rule fails', async () => {
|
|
ruleEngine.registerRule(
|
|
createValidationRule({
|
|
id: 'always-fail',
|
|
name: 'Always Fail',
|
|
priority: 1,
|
|
validate: async () => ({
|
|
success: false,
|
|
error: 'Validation failed',
|
|
}),
|
|
})
|
|
);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'Test Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = await ruleEngine.executeCommand(command);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Validation failed');
|
|
expect(gameState.getPart('meeple-1')).toBeUndefined();
|
|
});
|
|
|
|
it('should block command when rule sets blockCommand', async () => {
|
|
ruleEngine.registerRule(
|
|
createValidationRule({
|
|
id: 'block-command',
|
|
name: 'Block Command',
|
|
priority: 1,
|
|
validate: async () => ({
|
|
success: true,
|
|
blockCommand: true,
|
|
}),
|
|
})
|
|
);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'Test Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = await ruleEngine.executeCommand(command);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('blocked by rule');
|
|
});
|
|
|
|
it('should apply state updates from validation rules', async () => {
|
|
ruleEngine.registerRule(
|
|
createValidationRule({
|
|
id: 'set-metadata',
|
|
name: 'Set Metadata',
|
|
priority: 1,
|
|
validate: async (context) => ({
|
|
success: true,
|
|
stateUpdates: { validated: true },
|
|
}),
|
|
})
|
|
);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'Test Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = await ruleEngine.executeCommand(command);
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('executeCommand with effect rules', () => {
|
|
it('should execute effect rules after command', async () => {
|
|
let effectExecuted = false;
|
|
|
|
ruleEngine.registerRules([
|
|
createValidationRule({
|
|
id: 'validation',
|
|
name: 'Validation',
|
|
priority: 1,
|
|
validate: async () => ({ success: true }),
|
|
}),
|
|
createEffectRule({
|
|
id: 'effect',
|
|
name: 'Effect',
|
|
priority: 1,
|
|
apply: async () => {
|
|
effectExecuted = true;
|
|
return { success: true };
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'Test Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
await ruleEngine.executeCommand(command);
|
|
|
|
expect(effectExecuted).toBe(true);
|
|
});
|
|
|
|
it('should apply state updates from effect rules', async () => {
|
|
ruleEngine.registerRule(
|
|
createEffectRule({
|
|
id: 'update-metadata',
|
|
name: 'Update Metadata',
|
|
priority: 1,
|
|
apply: async () => ({
|
|
success: true,
|
|
stateUpdates: { effectApplied: true },
|
|
}),
|
|
})
|
|
);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'Test Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
await ruleEngine.executeCommand(command);
|
|
|
|
// Effect rule state updates are stored in metadata
|
|
expect(gameState.data.value.metadata).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('executeCommand with trigger rules', () => {
|
|
it('should execute trigger rules when condition is met', async () => {
|
|
let triggerExecuted = false;
|
|
|
|
ruleEngine.registerRule(
|
|
createTriggerRule({
|
|
id: 'trigger',
|
|
name: 'Trigger',
|
|
priority: 1,
|
|
condition: async () => true,
|
|
action: async () => {
|
|
triggerExecuted = true;
|
|
return { success: true };
|
|
},
|
|
})
|
|
);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'Test Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
await ruleEngine.executeCommand(command);
|
|
|
|
expect(triggerExecuted).toBe(true);
|
|
});
|
|
|
|
it('should not execute trigger rules when condition is not met', async () => {
|
|
let triggerExecuted = false;
|
|
|
|
ruleEngine.registerRule(
|
|
createTriggerRule({
|
|
id: 'trigger',
|
|
name: 'Trigger',
|
|
priority: 1,
|
|
condition: async () => false,
|
|
action: async () => {
|
|
triggerExecuted = true;
|
|
return { success: true };
|
|
},
|
|
})
|
|
);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'Test Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
await ruleEngine.executeCommand(command);
|
|
|
|
expect(triggerExecuted).toBe(false);
|
|
});
|
|
|
|
it('should trigger commands from trigger rules', async () => {
|
|
ruleEngine.registerRule(
|
|
createTriggerRule({
|
|
id: 'trigger-command',
|
|
name: 'Trigger Command',
|
|
priority: 1,
|
|
condition: async () => true,
|
|
action: async () => ({
|
|
success: true,
|
|
triggeredCommands: [
|
|
{
|
|
id: 'triggered',
|
|
name: 'Triggered Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'triggered-meeple', color: 'blue' },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'Test Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = await ruleEngine.executeCommand(command);
|
|
|
|
expect(result.triggeredCommands.length).toBe(1);
|
|
expect(gameState.getPart('triggered-meeple')).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('rule logging', () => {
|
|
it('should log rule executions', async () => {
|
|
ruleEngine.registerRule(
|
|
createValidationRule({
|
|
id: 'logged-rule',
|
|
name: 'Logged Rule',
|
|
priority: 1,
|
|
validate: async () => ({ success: true }),
|
|
})
|
|
);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'Test Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
await ruleEngine.executeCommand(command);
|
|
const logs = ruleEngine.getLogs();
|
|
|
|
expect(logs.length).toBe(1);
|
|
expect(logs[0].ruleId).toBe('logged-rule');
|
|
expect(logs[0].ruleType).toBe('validation');
|
|
});
|
|
|
|
it('should clear logs', async () => {
|
|
ruleEngine.registerRule(
|
|
createValidationRule({
|
|
id: 'logged-rule',
|
|
name: 'Logged Rule',
|
|
priority: 1,
|
|
validate: async () => ({ success: true }),
|
|
})
|
|
);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'Test Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
await ruleEngine.executeCommand(command);
|
|
expect(ruleEngine.getLogs().length).toBe(1);
|
|
|
|
ruleEngine.clearLogs();
|
|
expect(ruleEngine.getLogs().length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('game type filtering', () => {
|
|
it('should only apply rules matching the game type', async () => {
|
|
const gameTypeRuleEngine = new RuleEngine(gameState, { gameType: 'tictactoe' });
|
|
|
|
let tictactoeRuleExecuted = false;
|
|
let otherRuleExecuted = false;
|
|
|
|
gameTypeRuleEngine.registerRules([
|
|
createValidationRule({
|
|
id: 'tictactoe-rule',
|
|
name: 'Tic Tac Toe Rule',
|
|
priority: 1,
|
|
gameType: 'tictactoe',
|
|
validate: async () => {
|
|
tictactoeRuleExecuted = true;
|
|
return { success: true };
|
|
},
|
|
}),
|
|
createValidationRule({
|
|
id: 'other-rule',
|
|
name: 'Other Rule',
|
|
priority: 1,
|
|
gameType: 'chess',
|
|
validate: async () => {
|
|
otherRuleExecuted = true;
|
|
return { success: true };
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'Test Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
await gameTypeRuleEngine.executeCommand(command);
|
|
|
|
expect(tictactoeRuleExecuted).toBe(true);
|
|
expect(otherRuleExecuted).toBe(false);
|
|
});
|
|
|
|
it('should apply rules without game type to all games', async () => {
|
|
const gameTypeRuleEngine = new RuleEngine(gameState, { gameType: 'tictactoe' });
|
|
|
|
let globalRuleExecuted = false;
|
|
|
|
gameTypeRuleEngine.registerRules([
|
|
createValidationRule({
|
|
id: 'global-rule',
|
|
name: 'Global Rule',
|
|
priority: 1,
|
|
validate: async () => {
|
|
globalRuleExecuted = true;
|
|
return { success: true };
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'Test Command',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
await gameTypeRuleEngine.executeCommand(command);
|
|
|
|
expect(globalRuleExecuted).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('command filtering', () => {
|
|
it('should only apply rules to applicable commands', async () => {
|
|
let ruleExecuted = false;
|
|
|
|
ruleEngine.registerRule(
|
|
createValidationRule({
|
|
id: 'specific-command-rule',
|
|
name: 'Specific Command Rule',
|
|
priority: 1,
|
|
applicableCommands: ['specificCommand'],
|
|
validate: async () => {
|
|
ruleExecuted = true;
|
|
return { success: true };
|
|
},
|
|
})
|
|
);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'otherCommand',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
await ruleEngine.executeCommand(command);
|
|
|
|
expect(ruleExecuted).toBe(false);
|
|
});
|
|
|
|
it('should apply rules to matching commands', async () => {
|
|
let ruleExecuted = false;
|
|
|
|
ruleEngine.registerRule(
|
|
createValidationRule({
|
|
id: 'specific-command-rule',
|
|
name: 'Specific Command Rule',
|
|
priority: 1,
|
|
applicableCommands: ['testCommand'],
|
|
validate: async () => {
|
|
ruleExecuted = true;
|
|
return { success: true };
|
|
},
|
|
})
|
|
);
|
|
|
|
const command: Command = {
|
|
id: 'test-command',
|
|
name: 'testCommand',
|
|
steps: [
|
|
{
|
|
action: CommandActionType.CreateMeeple,
|
|
params: { id: 'meeple-1', color: 'red' },
|
|
},
|
|
],
|
|
};
|
|
|
|
await ruleEngine.executeCommand(command);
|
|
|
|
expect(ruleExecuted).toBe(true);
|
|
});
|
|
});
|
|
});
|