From d0d051f547155d37383d8877a5dbae56b162f3bf Mon Sep 17 00:00:00 2001 From: hyper Date: Tue, 31 Mar 2026 18:20:18 +0800 Subject: [PATCH] feat: add rule engine & tic tac toe test --- src/games/tictactoe/TicTacToeCommands.ts | 210 ++++++++ src/games/tictactoe/TicTacToeRules.ts | 354 ++++++++++++++ src/games/tictactoe/TicTacToeState.ts | 161 ++++++ src/games/tictactoe/index.ts | 82 ++++ src/index.ts | 71 +++ src/rules/Rule.ts | 186 +++++++ src/rules/RuleEngine.ts | 378 ++++++++++++++ src/rules/RuleRegistry.ts | 199 ++++++++ tests/commands/command.parser.test.ts | 8 +- tests/games/tictactoe.test.ts | 319 ++++++++++++ tests/rules/rule.engine.test.ts | 595 +++++++++++++++++++++++ 11 files changed, 2559 insertions(+), 4 deletions(-) create mode 100644 src/games/tictactoe/TicTacToeCommands.ts create mode 100644 src/games/tictactoe/TicTacToeRules.ts create mode 100644 src/games/tictactoe/TicTacToeState.ts create mode 100644 src/games/tictactoe/index.ts create mode 100644 src/rules/Rule.ts create mode 100644 src/rules/RuleEngine.ts create mode 100644 src/rules/RuleRegistry.ts create mode 100644 tests/games/tictactoe.test.ts create mode 100644 tests/rules/rule.engine.test.ts diff --git a/src/games/tictactoe/TicTacToeCommands.ts b/src/games/tictactoe/TicTacToeCommands.ts new file mode 100644 index 0000000..aa90964 --- /dev/null +++ b/src/games/tictactoe/TicTacToeCommands.ts @@ -0,0 +1,210 @@ +import type { Command } from '../../commands/Command'; +import { CommandActionType } from '../../commands/Command'; +import { RegionType } from '../../core/Region'; +import type { Player } from './TicTacToeState'; +import { getAllCellIds, DEFAULT_BOARD_CONFIG } from './TicTacToeState'; + +/** + * 井字棋游戏命令集合 + */ + +/** + * 开始游戏命令 + * 初始化 3x3 棋盘和游戏状态 + */ +export const startGameCommand: Command = { + id: 'tictactoe-start-game', + name: 'startGame', + description: 'Start a new Tic Tac Toe game', + steps: [ + // 创建棋盘区域 + { + action: CommandActionType.CreateRegion, + params: { + id: 'board', + type: RegionType.Keyed, + name: 'Tic Tac Toe Board', + }, + }, + // 创建所有单元格槽位 + ...getAllCellIds(DEFAULT_BOARD_CONFIG.size).map((cellId) => ({ + action: CommandActionType.SetSlot as CommandActionType, + params: { + regionId: 'board', + key: cellId, + placementId: null, + }, + })), + // 初始化游戏元数据 + { + action: CommandActionType.SetPhase, + params: { + phase: 'playing', + }, + }, + ], +}; + +/** + * 标记单元格命令 + * 玩家在指定单元格放置 X 或 O + */ +export const markCellCommand: (cell: string, player: Player) => Command = (cell, player) => ({ + id: `tictactoe-mark-${cell}-${player}`, + name: 'markCell', + description: `Mark cell ${cell} with ${player}`, + steps: [ + // 创建玩家标记(Part) + { + action: CommandActionType.CreateMeeple, + params: { + id: `marker-${cell}-${player}`, + color: player === 'X' ? 'blue' : 'red', + name: `${player}'s marker`, + metadata: { + player, + cell, + }, + }, + }, + // 创建放置 + { + action: CommandActionType.CreatePlacement, + params: { + id: cell, + partId: `marker-${cell}-${player}`, + regionId: 'board', + metadata: { + player, + cell, + }, + }, + }, + // 设置槽位 + { + action: CommandActionType.SetSlot, + params: { + regionId: 'board', + key: cell, + placementId: cell, + }, + }, + ], +}); + +/** + * 重置游戏命令 + * 清空棋盘,准备新游戏 + */ +export const resetGameCommand: Command = { + id: 'tictactoe-reset-game', + name: 'resetGame', + description: 'Reset the Tic Tac Toe board for a new game', + steps: [ + // 清空棋盘 + { + action: CommandActionType.ClearRegion, + params: { + regionId: 'board', + }, + }, + // 重置所有槽位 + ...getAllCellIds(DEFAULT_BOARD_CONFIG.size).map((cellId) => ({ + action: CommandActionType.SetSlot as CommandActionType, + params: { + regionId: 'board', + key: cellId, + placementId: null, + }, + })), + // 重置游戏阶段 + { + action: CommandActionType.SetPhase, + params: { + phase: 'playing', + }, + }, + ], +}; + +/** + * 设置玩家命令 + * 设置玩家 X 和 O 的信息 + */ +export const setPlayersCommand: (playerX: string, playerO: string) => Command = (playerX, playerO) => ({ + id: 'tictactoe-set-players', + name: 'setPlayers', + description: 'Set player names for X and O', + steps: [ + { + action: CommandActionType.CreateMeeple, + params: { + id: 'player-x', + color: 'blue', + name: playerX, + metadata: { + role: 'player', + symbol: 'X', + }, + }, + }, + { + action: CommandActionType.CreateMeeple, + params: { + id: 'player-o', + color: 'red', + name: playerO, + metadata: { + role: 'player', + symbol: 'O', + }, + }, + }, + ], +}); + +/** + * 获取单元格状态命令 + * 查询指定单元格的状态 + */ +export const getCellCommand: (cell: string) => Command = (cell) => ({ + id: `tictactoe-get-${cell}`, + name: 'getCell', + description: `Get the state of cell ${cell}`, + steps: [ + { + action: CommandActionType.CreatePlacement, + params: { + id: `query-${cell}`, + partId: 'query', + regionId: 'board', + metadata: { + query: true, + cell, + }, + }, + }, + ], +}); + +/** + * 所有井字棋命令 + */ +export const ticTacToeCommands: Command[] = [ + startGameCommand, + resetGameCommand, +]; + +/** + * 创建标记单元格命令的辅助函数 + */ +export function createMarkCellCommand(cell: string, player: Player): Command { + return markCellCommand(cell, player); +} + +/** + * 创建设置玩家命令的辅助函数 + */ +export function createSetPlayersCommand(playerX: string, playerO: string): Command { + return setPlayersCommand(playerX, playerO); +} diff --git a/src/games/tictactoe/TicTacToeRules.ts b/src/games/tictactoe/TicTacToeRules.ts new file mode 100644 index 0000000..dd83014 --- /dev/null +++ b/src/games/tictactoe/TicTacToeRules.ts @@ -0,0 +1,354 @@ +import type { Rule, RuleResult, RuleContext } from '../../rules/Rule'; +import { createValidationRule, createEffectRule, createTriggerRule } from '../../rules/Rule'; +import type { Player, TicTacToeMetadata, MoveRecord } from './TicTacToeState'; +import { getWinningCombinations, parseCellId } from './TicTacToeState'; + +/** + * 井字棋游戏规则集合 + */ + +/** + * 获取当前玩家 + */ +function getCurrentPlayer(gameState: any): Player { + const metadata = gameState.data.value.metadata as Record | undefined; + const ticTacToeMetadata = metadata?.ticTacToe as TicTacToeMetadata | undefined; + return ticTacToeMetadata?.currentPlayer || 'X'; +} + +/** + * 检查游戏是否结束 + */ +function isGameEnded(gameState: any): boolean { + const metadata = gameState.data.value.metadata as Record | undefined; + const ticTacToeMetadata = metadata?.ticTacToe as TicTacToeMetadata | undefined; + return ticTacToeMetadata?.gameEnded || false; +} + +/** + * 更新游戏 metadata + */ +function updateGameMetadata(gameState: any, updates: Partial): void { + const metadata = gameState.data.value.metadata as Record | undefined; + const currentTicTacToe = (metadata?.ticTacToe as TicTacToeMetadata) || { + currentPlayer: 'X', + gameEnded: false, + winner: null, + moveHistory: [], + totalMoves: 0, + }; + gameState.data.value = { + ...gameState.data.value, + metadata: { + ...metadata, + ticTacToe: { ...currentTicTacToe, ...updates }, + }, + }; +} + +/** + * 获取单元格的玩家标记 + */ +function getCellPlayer( + context: RuleContext, + cellId: string +): Player | null { + const placement = context.gameState.placements.value.get(cellId); + if (!placement?.metadata?.player) { + return null; + } + return placement.metadata.player as Player; +} + +/** + * 检查是否有玩家获胜 + */ +function checkWin( + context: RuleContext, + size: number = 3 +): { winner: Player; combination: string[] } | null { + const combinations = getWinningCombinations(size); + + for (const combination of combinations) { + const players = combination.map((cellId) => getCellPlayer(context, cellId)); + const firstPlayer = players[0]; + + if (firstPlayer && players.every((p) => p === firstPlayer)) { + return { winner: firstPlayer, combination }; + } + } + + return null; +} + +/** + * 检查是否平局(所有单元格都被填充且无获胜者) + */ +function isDraw(context: RuleContext, size: number = 3): boolean { + const totalCells = size * size; + const filledCells = Array.from(context.gameState.placements.value.values()).filter( + (p) => p.metadata?.player + ).length; + + return filledCells === totalCells; +} + +/** + * 规则 1:验证轮到当前玩家 + * 在玩家尝试下子时检查是否是他们的回合 + */ +export const validateTurnRule = createValidationRule({ + id: 'tictactoe-validate-turn', + name: 'Validate Turn', + description: 'Check if it is the current player turn', + priority: 1, + gameType: 'tictactoe', + applicableCommands: ['markCell'], + validate: async (context: RuleContext): Promise => { + const cellId = context.command.steps[0]?.params?.cell as string; + const expectedPlayer = context.command.steps[0]?.params?.player as Player; + const currentPlayer = getCurrentPlayer(context.gameState); + + if (!cellId) { + return { + success: false, + error: 'Cell ID is required', + }; + } + + if (expectedPlayer && expectedPlayer !== currentPlayer) { + return { + success: false, + error: `It is ${currentPlayer}'s turn, not ${expectedPlayer}'s`, + }; + } + + return { success: true }; + }, +}); + +/** + * 规则 2:验证单元格为空 + * 检查目标单元格是否已经被占用 + */ +export const validateCellEmptyRule = createValidationRule({ + id: 'tictactoe-validate-cell-empty', + name: 'Validate Cell Empty', + description: 'Check if the target cell is empty', + priority: 2, + gameType: 'tictactoe', + applicableCommands: ['markCell'], + validate: async (context: RuleContext): Promise => { + const cellId = context.command.steps[0]?.params?.cell as string; + + if (!cellId) { + return { + success: false, + error: 'Cell ID is required', + }; + } + + const cellPlayer = getCellPlayer(context, cellId); + if (cellPlayer !== null) { + return { + success: false, + error: `Cell ${cellId} is already occupied by ${cellPlayer}`, + }; + } + + return { success: true }; + }, +}); + +/** + * 规则 3:验证游戏未结束 + * 游戏结束后不允许继续下子 + */ +export const validateGameNotEndedRule = createValidationRule({ + id: 'tictactoe-validate-game-not-ended', + name: 'Validate Game Not Ended', + description: 'Check if the game has already ended', + priority: 0, + gameType: 'tictactoe', + applicableCommands: ['markCell'], + validate: async (context: RuleContext): Promise => { + const gameEnded = isGameEnded(context.gameState); + + if (gameEnded) { + return { + success: false, + error: 'Game has already ended', + blockCommand: true, + }; + } + + return { success: true }; + }, +}); + +/** + * 效果规则:切换玩家 + * 在玩家下子后自动切换到下一个玩家 + */ +export const switchTurnRule = createEffectRule({ + id: 'tictactoe-switch-turn', + name: 'Switch Turn', + description: 'Switch to the next player after a move', + priority: 10, + gameType: 'tictactoe', + applicableCommands: ['markCell'], + apply: async (context: RuleContext): Promise => { + const currentPlayer = getCurrentPlayer(context.gameState); + const nextPlayer: Player = currentPlayer === 'X' ? 'O' : 'X'; + + // 直接更新 metadata + updateGameMetadata(context.gameState, { currentPlayer: nextPlayer }); + + return { + success: true, + }; + }, +}); + +/** + * 效果规则:记录移动历史 + * 记录玩家的每一步移动 + */ +export const recordMoveHistoryRule = createEffectRule({ + id: 'tictactoe-record-history', + name: 'Record Move History', + description: 'Record the move in game history', + priority: 9, + gameType: 'tictactoe', + applicableCommands: ['markCell'], + apply: async (context: RuleContext): Promise => { + const cellId = context.command.steps[0]?.params?.cell as string; + const player = context.command.steps[0]?.params?.player as Player; + const metadata = context.gameState.data.value.metadata || {}; + const ticTacToeMetadata = (metadata?.ticTacToe as TicTacToeMetadata) || { + currentPlayer: 'X', + gameEnded: false, + winner: null, + moveHistory: [], + totalMoves: 0, + }; + + const moveRecord: MoveRecord = { + player, + cellId, + timestamp: Date.now(), + }; + + const moveHistory = ticTacToeMetadata.moveHistory || []; + moveHistory.push(moveRecord); + + // 直接更新 metadata + updateGameMetadata(context.gameState, { + moveHistory, + totalMoves: (ticTacToeMetadata.totalMoves || 0) + 1, + }); + + return { + success: true, + }; + }, +}); + +/** + * 触发规则:检查获胜条件 + * 当有玩家连成一线时触发 + */ +export const checkWinConditionRule = createTriggerRule({ + id: 'tictactoe-check-win', + name: 'Check Win Condition', + description: 'Check if a player has won the game', + priority: 100, + gameType: 'tictactoe', + condition: async (context: RuleContext): Promise => { + const winResult = checkWin(context); + return winResult !== null; + }, + action: async (context: RuleContext): Promise => { + const winResult = checkWin(context); + if (!winResult) { + return { success: false, error: 'No winner detected' }; + } + + const { winner, combination } = winResult; + + // 直接更新 metadata + updateGameMetadata(context.gameState, { + gameEnded: true, + winner, + winningCombination: combination, + }); + + return { + success: true, + }; + }, +}); + +/** + * 触发规则:检查平局条件 + * 当所有单元格都被填充且无获胜者时触发 + */ +export const checkDrawConditionRule = createTriggerRule({ + id: 'tictactoe-check-draw', + name: 'Check Draw Condition', + description: 'Check if the game is a draw', + priority: 101, + gameType: 'tictactoe', + condition: async (context: RuleContext): Promise => { + const winResult = checkWin(context); + if (winResult !== null) { + return false; // 有获胜者,不是平局 + } + return isDraw(context); + }, + action: async (context: RuleContext): Promise => { + // 直接更新 metadata + updateGameMetadata(context.gameState, { + gameEnded: true, + winner: null, // null 表示平局 + }); + + return { + success: true, + }; + }, +}); + +/** + * 所有井字棋游戏规则 + */ +export const ticTacToeRules: Rule[] = [ + validateTurnRule, + validateCellEmptyRule, + validateGameNotEndedRule, + switchTurnRule, + recordMoveHistoryRule, + checkWinConditionRule, + checkDrawConditionRule, +]; + +/** + * 获取井字棋验证规则 + */ +export function getTicTacToeValidationRules(): Rule[] { + return [validateTurnRule, validateCellEmptyRule, validateGameNotEndedRule]; +} + +/** + * 获取井字棋效果规则 + */ +export function getTicTacToeEffectRules(): Rule[] { + return [switchTurnRule, recordMoveHistoryRule]; +} + +/** + * 获取井字棋触发规则 + */ +export function getTicTacToeTriggerRules(): Rule[] { + return [checkWinConditionRule, checkDrawConditionRule]; +} diff --git a/src/games/tictactoe/TicTacToeState.ts b/src/games/tictactoe/TicTacToeState.ts new file mode 100644 index 0000000..87acb85 --- /dev/null +++ b/src/games/tictactoe/TicTacToeState.ts @@ -0,0 +1,161 @@ +/** + * 井字棋游戏状态扩展 + */ + +/** + * 玩家类型 + */ +export type Player = 'X' | 'O'; + +/** + * 单元格状态 + */ +export interface CellState { + /** 单元格 ID(如 A1, B2, C3) */ + id: string; + /** 行索引 (0-2) */ + row: number; + /** 列索引 (0-2) */ + col: number; + /** 当前玩家标记,null 表示空 */ + player: Player | null; +} + +/** + * 井字棋游戏元数据 + */ +export interface TicTacToeMetadata { + /** 当前玩家 */ + currentPlayer: Player; + /** 游戏是否结束 */ + gameEnded: boolean; + /** 获胜者,null 表示平局或未结束 */ + winner: Player | null; + /** 获胜的组合(如果有) */ + winningCombination?: string[]; + /** 游戏历史 */ + moveHistory: MoveRecord[]; + /** 总回合数 */ + totalMoves: number; +} + +/** + * 移动记录 + */ +export interface MoveRecord { + /** 移动的玩家 */ + player: Player; + /** 移动的单元格 ID */ + cellId: string; + /** 移动时间戳 */ + timestamp: number; +} + +/** + * 获胜组合类型 + */ +export type WinningLine = + | { type: 'row'; index: number } + | { type: 'column'; index: number } + | { type: 'diagonal'; direction: 'main' | 'anti' }; + +/** + * 井字棋棋盘配置 + */ +export interface TicTacToeBoardConfig { + /** 棋盘大小(默认 3x3) */ + size: number; + /** 单元格 ID 前缀 */ + cellIdPrefix: string; +} + +/** + * 默认的 3x3 棋盘配置 + */ +export const DEFAULT_BOARD_CONFIG: TicTacToeBoardConfig = { + size: 3, + cellIdPrefix: 'cell', +}; + +/** + * 获取单元格 ID + */ +export function getCellId(row: number, col: number, prefix: string = 'cell'): string { + const rowLabel = String.fromCharCode('A'.charCodeAt(0) + row); + return `${prefix}-${rowLabel}${col + 1}`; +} + +/** + * 解析单元格 ID + */ +export function parseCellId(cellId: string): { row: number; col: number } | null { + const match = cellId.match(/^cell-([A-Z])(\d+)$/); + if (!match) return null; + return { + row: match[1].charCodeAt(0) - 'A'.charCodeAt(0), + col: parseInt(match[2], 10) - 1, + }; +} + +/** + * 检查是否是有效的单元格 ID + */ +export function isValidCellId(cellId: string, size: number = 3): boolean { + const parsed = parseCellId(cellId); + if (!parsed) return false; + return parsed.row >= 0 && parsed.row < size && parsed.col >= 0 && parsed.col < size; +} + +/** + * 生成所有单元格 ID + */ +export function getAllCellIds(size: number = 3, prefix: string = 'cell'): string[] { + const cells: string[] = []; + for (let row = 0; row < size; row++) { + for (let col = 0; col < size; col++) { + cells.push(getCellId(row, col, prefix)); + } + } + return cells; +} + +/** + * 获取所有可能的获胜组合 + */ +export function getWinningCombinations(size: number = 3): string[][] { + const combinations: string[][] = []; + + // 行 + for (let row = 0; row < size; row++) { + const rowCells: string[] = []; + for (let col = 0; col < size; col++) { + rowCells.push(getCellId(row, col)); + } + combinations.push(rowCells); + } + + // 列 + for (let col = 0; col < size; col++) { + const colCells: string[] = []; + for (let row = 0; row < size; row++) { + colCells.push(getCellId(row, col)); + } + combinations.push(colCells); + } + + // 主对角线 + const mainDiagonal: string[] = []; + for (let i = 0; i < size; i++) { + mainDiagonal.push(getCellId(i, i)); + } + combinations.push(mainDiagonal); + + // 反对角线 + const antiDiagonal: string[] = []; + for (let i = 0; i < size; i++) { + antiDiagonal.push(getCellId(i, size - 1 - i)); + } + combinations.push(antiDiagonal); + + return combinations; +} diff --git a/src/games/tictactoe/index.ts b/src/games/tictactoe/index.ts new file mode 100644 index 0000000..250cc98 --- /dev/null +++ b/src/games/tictactoe/index.ts @@ -0,0 +1,82 @@ +/** + * 井字棋游戏模块 + * Tic Tac Toe game implementation with rule enforcement + */ + +import type { Command } from '../../commands/Command'; +import type { Rule } from '../../rules/Rule'; +import { + startGameCommand, + markCellCommand, + resetGameCommand, + setPlayersCommand, + getCellCommand, + ticTacToeCommands, + createMarkCellCommand, + createSetPlayersCommand, +} from './TicTacToeCommands'; +import { + ticTacToeRules, + getTicTacToeValidationRules, + getTicTacToeEffectRules, + getTicTacToeTriggerRules, +} from './TicTacToeRules'; + +// State types +export type { + Player, + CellState, + TicTacToeMetadata, + MoveRecord, + WinningLine, + TicTacToeBoardConfig, +} from './TicTacToeState'; + +export { + DEFAULT_BOARD_CONFIG, + getCellId, + parseCellId, + isValidCellId, + getAllCellIds, + getWinningCombinations, +} from './TicTacToeState'; + +// Rules +export { + validateTurnRule, + validateCellEmptyRule, + validateGameNotEndedRule, + switchTurnRule, + recordMoveHistoryRule, + checkWinConditionRule, + checkDrawConditionRule, + ticTacToeRules, + getTicTacToeValidationRules, + getTicTacToeEffectRules, + getTicTacToeTriggerRules, +} from './TicTacToeRules'; + +// Commands +export { + startGameCommand, + markCellCommand, + resetGameCommand, + setPlayersCommand, + getCellCommand, + ticTacToeCommands, + createMarkCellCommand, + createSetPlayersCommand, +} from './TicTacToeCommands'; + +/** + * 创建井字棋游戏初始化命令 + */ +export function createTicTacToeGame(): { + commands: Command[]; + rules: Rule[]; +} { + return { + commands: [startGameCommand, resetGameCommand], + rules: ticTacToeRules, + }; +} diff --git a/src/index.ts b/src/index.ts index 0499ce9..911638f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,77 @@ * 基于 Preact Signals 的桌游状态管理库 */ +// Rules engine +export type { + Rule, + RuleContext, + RuleResult, + ValidationRule, + EffectRule, + TriggerRule, + RuleLogEntry, +} from './rules/Rule'; + +export { + isValidationRule, + isEffectRule, + isTriggerRule, + createValidationRule, + createEffectRule, + createTriggerRule, +} from './rules/Rule'; + +export { RuleEngine, createRuleEngine } from './rules/RuleEngine'; +export type { RuleEngineOptions, RuleEngineExecutionResult } from './rules/RuleEngine'; + +export { RuleRegistry, createRuleRegistry } from './rules/RuleRegistry'; +export type { RuleGroup } from './rules/RuleRegistry'; + +// Tic Tac Toe game +export type { + Player, + CellState, + TicTacToeMetadata, + MoveRecord, + WinningLine, + TicTacToeBoardConfig, +} from './games/tictactoe/TicTacToeState'; + +export { + DEFAULT_BOARD_CONFIG, + getCellId, + parseCellId, + isValidCellId, + getAllCellIds, + getWinningCombinations, +} from './games/tictactoe/TicTacToeState'; + +export { + validateTurnRule, + validateCellEmptyRule, + validateGameNotEndedRule, + switchTurnRule, + recordMoveHistoryRule, + checkWinConditionRule, + checkDrawConditionRule, + ticTacToeRules, + getTicTacToeValidationRules, + getTicTacToeEffectRules, + getTicTacToeTriggerRules, + createTicTacToeGame, +} from './games/tictactoe'; + +export { + startGameCommand, + markCellCommand, + resetGameCommand, + setPlayersCommand, + getCellCommand, + ticTacToeCommands, + createMarkCellCommand, + createSetPlayersCommand, +} from './games/tictactoe'; + // Core types export { PartType } from './core/Part'; export type { diff --git a/src/rules/Rule.ts b/src/rules/Rule.ts new file mode 100644 index 0000000..00ab343 --- /dev/null +++ b/src/rules/Rule.ts @@ -0,0 +1,186 @@ +import type { GameState } from '../core/GameState'; +import type { Command } from '../commands/Command'; +import type { CommandExecutionResult } from '../commands/Command'; + +/** + * 规则执行上下文 + */ +export interface RuleContext { + /** 游戏状态 */ + gameState: GameState; + /** 当前命令 */ + command: Command; + /** 命令执行结果(执行后规则可用) */ + executionResult?: CommandExecutionResult; + /** 规则执行时的元数据 */ + metadata: Record; +} + +/** + * 规则执行结果 + */ +export interface RuleResult { + /** 规则是否通过 */ + success: boolean; + /** 错误信息(如果失败) */ + error?: string; + /** 状态修改(规则可以对状态进行修改) */ + stateUpdates?: Record; + /** 是否阻止命令执行 */ + blockCommand?: boolean; + /** 触发的额外命令 */ + triggeredCommands?: Command[]; +} + +/** + * 验证规则 + * 在命令执行前运行,用于验证命令是否合法 + */ +export interface ValidationRule { + /** 规则唯一标识 */ + id: string; + /** 规则名称 */ + name: string; + /** 规则描述 */ + description?: string; + /** 规则优先级(数字越小越先执行) */ + priority: number; + /** 适用的游戏类型 */ + gameType?: string; + /** 适用的命令名称列表 */ + applicableCommands?: string[]; + /** 验证函数 */ + validate: (context: RuleContext) => Promise; +} + +/** + * 效果规则 + * 在命令执行后运行,用于更新状态或触发额外效果 + */ +export interface EffectRule { + /** 规则唯一标识 */ + id: string; + /** 规则名称 */ + name: string; + /** 规则描述 */ + description?: string; + /** 规则优先级(数字越小越先执行) */ + priority: number; + /** 适用的游戏类型 */ + gameType?: string; + /** 适用的命令名称列表 */ + applicableCommands?: string[]; + /** 效果函数 */ + apply: (context: RuleContext) => Promise; +} + +/** + * 触发规则 + * 监听特定状态变化并触发相应动作 + */ +export interface TriggerRule { + /** 规则唯一标识 */ + id: string; + /** 规则名称 */ + name: string; + /** 规则描述 */ + description?: string; + /** 规则优先级 */ + priority: number; + /** 适用的游戏类型 */ + gameType?: string; + /** 适用的命令名称列表 */ + applicableCommands?: string[]; + /** 触发条件 */ + condition: (context: RuleContext) => Promise; + /** 触发后的动作 */ + action: (context: RuleContext) => Promise; +} + +/** + * 通用规则类型 + */ +export type Rule = ValidationRule | EffectRule | TriggerRule; + +/** + * 判断是否为验证规则 + */ +export function isValidationRule(rule: Rule): rule is ValidationRule { + return 'validate' in rule; +} + +/** + * 判断是否为效果规则 + */ +export function isEffectRule(rule: Rule): rule is EffectRule { + return 'apply' in rule; +} + +/** + * 判断是否为触发规则 + */ +export function isTriggerRule(rule: Rule): rule is TriggerRule { + return 'condition' in rule && 'action' in rule; +} + +/** + * 创建验证规则 + */ +export function createValidationRule(rule: Omit & { id?: string; name?: string }): ValidationRule { + return { + id: rule.id || `validation-${Date.now()}`, + name: rule.name || 'Unnamed Validation Rule', + description: rule.description, + priority: rule.priority ?? 0, + gameType: rule.gameType, + applicableCommands: rule.applicableCommands, + validate: rule.validate, + }; +} + +/** + * 创建效果规则 + */ +export function createEffectRule(rule: Omit & { id?: string; name?: string }): EffectRule { + return { + id: rule.id || `effect-${Date.now()}`, + name: rule.name || 'Unnamed Effect Rule', + description: rule.description, + priority: rule.priority ?? 0, + gameType: rule.gameType, + applicableCommands: rule.applicableCommands, + apply: rule.apply, + }; +} + +/** + * 创建触发规则 + */ +export function createTriggerRule(rule: Omit & { id?: string; name?: string }): TriggerRule { + return { + id: rule.id || `trigger-${Date.now()}`, + name: rule.name || 'Unnamed Trigger Rule', + description: rule.description, + priority: rule.priority ?? 0, + condition: rule.condition, + action: rule.action, + }; +} + +/** + * 规则执行日志 + */ +export interface RuleLogEntry { + /** 时间戳 */ + timestamp: number; + /** 规则 ID */ + ruleId: string; + /** 规则名称 */ + ruleName: string; + /** 规则类型 */ + ruleType: 'validation' | 'effect' | 'trigger'; + /** 执行结果 */ + result: RuleResult; + /** 命令 ID */ + commandId: string; +} diff --git a/src/rules/RuleEngine.ts b/src/rules/RuleEngine.ts new file mode 100644 index 0000000..1474771 --- /dev/null +++ b/src/rules/RuleEngine.ts @@ -0,0 +1,378 @@ +import type { GameState } from '../core/GameState'; +import type { Command, CommandExecutionResult } from '../commands/Command'; +import { CommandExecutor } from '../commands/CommandExecutor'; +import type { + Rule, + RuleContext, + RuleResult, + ValidationRule, + EffectRule, + TriggerRule, + RuleLogEntry, +} from './Rule'; +import { isValidationRule, isEffectRule, isTriggerRule } from './Rule'; + +/** + * 规则引擎配置 + */ +export interface RuleEngineOptions { + /** 游戏类型 */ + gameType?: string; + /** 是否启用规则日志 */ + enableLogging?: boolean; + /** 是否自动执行触发规则 */ + autoExecuteTriggers?: boolean; +} + +/** + * 规则引擎执行结果 + */ +export interface RuleEngineExecutionResult extends CommandExecutionResult { + /** 执行的验证规则 */ + validationRules: RuleLogEntry[]; + /** 执行的效果规则 */ + effectRules: RuleLogEntry[]; + /** 触发的规则 */ + triggerRules: RuleLogEntry[]; + /** 触发的额外命令 */ + triggeredCommands: Command[]; +} + +/** + * 规则引擎 + * 负责在命令执行前后运行规则,并处理触发规则 + */ +export class RuleEngine { + private gameState: GameState; + private executor: CommandExecutor; + private rules: Rule[] = []; + private options: RuleEngineOptions; + private logs: RuleLogEntry[] = []; + private isExecuting: boolean = false; + private triggerQueue: Command[] = []; + + constructor(gameState: GameState, options: RuleEngineOptions = {}) { + this.gameState = gameState; + this.executor = new CommandExecutor(gameState); + this.options = { + enableLogging: true, + autoExecuteTriggers: true, + ...options, + }; + } + + /** + * 注册规则 + */ + registerRule(rule: Rule): void { + // 如果指定了游戏类型,只有匹配时才注册 + if (this.options.gameType && rule.gameType && rule.gameType !== this.options.gameType) { + return; + } + this.rules.push(rule); + // 按优先级排序 + this.rules.sort((a, b) => a.priority - b.priority); + } + + /** + * 注册多个规则 + */ + registerRules(rules: Rule[]): void { + for (const rule of rules) { + this.registerRule(rule); + } + } + + /** + * 移除规则 + */ + unregisterRule(ruleId: string): void { + this.rules = this.rules.filter((r) => r.id !== ruleId); + } + + /** + * 清除所有规则 + */ + clearRules(): void { + this.rules = []; + } + + /** + * 获取所有规则 + */ + getRules(): Rule[] { + return [...this.rules]; + } + + /** + * 执行命令(带规则验证) + */ + async executeCommand(command: Command): Promise { + if (this.isExecuting) { + throw new Error('Rule engine is already executing a command'); + } + + this.isExecuting = true; + const validationLogs: RuleLogEntry[] = []; + const effectLogs: RuleLogEntry[] = []; + const triggerLogs: RuleLogEntry[] = []; + const triggeredCommands: Command[] = []; + + try { + // 创建规则上下文 + const context: RuleContext = { + gameState: this.gameState, + command, + metadata: {}, + }; + + // 1. 执行验证规则 + const validationRules = this.rules.filter(isValidationRule); + for (const rule of validationRules) { + if (!this.isRuleApplicable(rule, command)) { + continue; + } + + const result = await rule.validate(context); + const logEntry = this.createLogEntry(rule, 'validation', result, command.id); + validationLogs.push(logEntry); + + if (!result.success) { + return this.createFailedResult(validationLogs, effectLogs, triggerLogs, triggeredCommands, result.error); + } + + if (result.blockCommand) { + return this.createFailedResult( + validationLogs, + effectLogs, + triggerLogs, + triggeredCommands, + `Command blocked by rule: ${rule.name}` + ); + } + + // 应用状态更新 + if (result.stateUpdates) { + Object.assign(context.metadata, result.stateUpdates); + } + + // 收集触发的命令 + if (result.triggeredCommands) { + triggeredCommands.push(...result.triggeredCommands); + } + } + + // 2. 执行命令 + const executionResult = this.executor.execute(command); + context.executionResult = executionResult; + + if (!executionResult.success) { + return this.createFailedResult( + validationLogs, + effectLogs, + triggerLogs, + triggeredCommands, + executionResult.error + ); + } + + // 3. 执行效果规则 + const effectRules = this.rules.filter(isEffectRule); + for (const rule of effectRules) { + if (!this.isRuleApplicable(rule, command)) { + continue; + } + + const result = await rule.apply(context); + const logEntry = this.createLogEntry(rule, 'effect', result, command.id); + effectLogs.push(logEntry); + + if (!result.success) { + // 效果规则失败不影响命令执行,只记录日志 + continue; + } + + // 应用状态更新 + if (result.stateUpdates) { + Object.assign(context.metadata, result.stateUpdates); + } + + // 收集触发的命令 + if (result.triggeredCommands) { + triggeredCommands.push(...result.triggeredCommands); + } + } + + // 4. 执行触发规则 + if (this.options.autoExecuteTriggers) { + const triggerRules = this.rules.filter(isTriggerRule); + for (const rule of triggerRules) { + const shouldTrigger = await rule.condition(context); + if (shouldTrigger) { + const result = await rule.action(context); + const logEntry = this.createLogEntry(rule, 'trigger', result, command.id); + triggerLogs.push(logEntry); + + if (result.triggeredCommands) { + triggeredCommands.push(...result.triggeredCommands); + } + } + } + } + + // 5. 执行触发的命令(在循环外执行,避免递归) + } finally { + this.isExecuting = false; + } + + // 在主要执行完成后执行触发的命令 + for (const triggeredCommand of triggeredCommands) { + try { + const triggerResult = await this.executeCommand(triggeredCommand); + if (!triggerResult.success) { + // 触发命令失败,记录但不影响主命令 + this.logs.push({ + timestamp: Date.now(), + ruleId: 'triggered-command', + ruleName: 'Triggered Command', + ruleType: 'trigger', + result: { success: false, error: `Triggered command ${triggeredCommand.id} failed: ${triggerResult.error}` }, + commandId: triggeredCommand.id, + }); + } + } catch (error) { + // 忽略触发命令的异常 + } + } + + return { + success: true, + executedSteps: executionResult.executedSteps, + totalSteps: executionResult.totalSteps, + validationRules: validationLogs, + effectRules: effectLogs, + triggerRules: triggerLogs, + triggeredCommands, + }; + } + + /** + * 检查规则是否适用于当前命令 + */ + private isRuleApplicable( + rule: ValidationRule | EffectRule, + command: Command + ): boolean { + // 检查游戏类型 + if (rule.gameType && rule.gameType !== this.options.gameType) { + return false; + } + + // 检查命令名称 + if (rule.applicableCommands && !rule.applicableCommands.includes(command.name)) { + return false; + } + + return true; + } + + /** + * 创建日志条目 + */ + private createLogEntry( + rule: Rule, + ruleType: 'validation' | 'effect' | 'trigger', + result: RuleResult, + commandId: string + ): RuleLogEntry { + const entry: RuleLogEntry = { + timestamp: Date.now(), + ruleId: rule.id, + ruleName: rule.name, + ruleType, + result, + commandId, + }; + + if (this.options.enableLogging) { + this.logs.push(entry); + } + + return entry; + } + + /** + * 创建失败结果 + */ + private createFailedResult( + validationLogs: RuleLogEntry[], + effectLogs: RuleLogEntry[], + triggerLogs: RuleLogEntry[], + triggeredCommands: Command[], + error?: string + ): RuleEngineExecutionResult { + return { + success: false, + error, + executedSteps: 0, + totalSteps: 0, + validationRules: validationLogs, + effectRules: effectLogs, + triggerRules: triggerLogs, + triggeredCommands, + }; + } + + /** + * 获取规则日志 + */ + getLogs(): RuleLogEntry[] { + return [...this.logs]; + } + + /** + * 清除日志 + */ + clearLogs(): void { + this.logs = []; + } + + /** + * 获取游戏状态 + */ + getGameState(): GameState { + return this.gameState; + } + + /** + * 手动触发规则 + */ + async triggerRules(): Promise { + const context: RuleContext = { + gameState: this.gameState, + command: { id: 'trigger-manual', name: 'manual-trigger', steps: [] }, + metadata: {}, + }; + + const logs: RuleLogEntry[] = []; + const triggerRules = this.rules.filter(isTriggerRule); + + for (const rule of triggerRules) { + const shouldTrigger = await rule.condition(context); + if (shouldTrigger) { + const result = await rule.action(context); + const logEntry = this.createLogEntry(rule, 'trigger', result, 'manual'); + logs.push(logEntry); + } + } + + return logs; + } +} + +/** + * 创建规则引擎 + */ +export function createRuleEngine(gameState: GameState, options?: RuleEngineOptions): RuleEngine { + return new RuleEngine(gameState, options); +} diff --git a/src/rules/RuleRegistry.ts b/src/rules/RuleRegistry.ts new file mode 100644 index 0000000..d412b69 --- /dev/null +++ b/src/rules/RuleRegistry.ts @@ -0,0 +1,199 @@ +import type { Rule, ValidationRule, EffectRule, TriggerRule } from './Rule'; +import { isValidationRule, isEffectRule, isTriggerRule } from './Rule'; + +/** + * 规则组 + */ +export interface RuleGroup { + /** 组名称 */ + name: string; + /** 组描述 */ + description?: string; + /** 规则列表 */ + rules: Rule[]; +} + +/** + * 规则注册表 + * 按游戏类型注册和管理规则 + */ +export class RuleRegistry { + private rulesByGameType: Map; + private globalRules: Rule[]; + private ruleGroups: Map; + private enabledRules: Set; + + constructor() { + this.rulesByGameType = new Map(); + this.globalRules = []; + this.ruleGroups = new Map(); + this.enabledRules = new Set(); + } + + /** + * 注册规则到特定游戏类型 + */ + register(rule: Rule, gameType?: string): void { + const targetRules = gameType + ? this.getRulesForGameType(gameType) + : this.globalRules; + + targetRules.push(rule); + this.enabledRules.add(rule.id); + + // 按优先级排序 + targetRules.sort((a, b) => a.priority - b.priority); + } + + /** + * 注册多个规则 + */ + registerAll(rules: Rule[], gameType?: string): void { + for (const rule of rules) { + this.register(rule, gameType); + } + } + + /** + * 注册规则组 + */ + registerGroup(group: RuleGroup, gameType?: string): void { + this.ruleGroups.set(group.name, group); + this.registerAll(group.rules, gameType); + } + + /** + * 获取特定游戏类型的规则 + */ + getRulesForGameType(gameType: string): Rule[] { + const gameRules = this.rulesByGameType.get(gameType) || []; + return [...gameRules, ...this.globalRules]; + } + + /** + * 获取所有规则 + */ + getAllRules(): Rule[] { + const allRules = [...this.globalRules]; + for (const rules of this.rulesByGameType.values()) { + allRules.push(...rules); + } + return allRules; + } + + /** + * 获取验证规则 + */ + getValidationRules(gameType?: string): ValidationRule[] { + const rules = gameType + ? this.getRulesForGameType(gameType) + : this.getAllRules(); + return rules.filter(isValidationRule); + } + + /** + * 获取效果规则 + */ + getEffectRules(gameType?: string): EffectRule[] { + const rules = gameType + ? this.getRulesForGameType(gameType) + : this.getAllRules(); + return rules.filter(isEffectRule); + } + + /** + * 获取触发规则 + */ + getTriggerRules(gameType?: string): TriggerRule[] { + const rules = gameType + ? this.getRulesForGameType(gameType) + : this.getAllRules(); + return rules.filter(isTriggerRule); + } + + /** + * 获取规则组 + */ + getGroup(groupName: string): RuleGroup | undefined { + return this.ruleGroups.get(groupName); + } + + /** + * 移除规则 + */ + unregister(ruleId: string): void { + this.globalRules = this.globalRules.filter((r) => r.id !== ruleId); + for (const [gameType, rules] of this.rulesByGameType.entries()) { + this.rulesByGameType.set( + gameType, + rules.filter((r) => r.id !== ruleId) + ); + } + this.enabledRules.delete(ruleId); + } + + /** + * 启用规则 + */ + enableRule(ruleId: string): void { + this.enabledRules.add(ruleId); + } + + /** + * 禁用规则 + */ + disableRule(ruleId: string): void { + this.enabledRules.delete(ruleId); + } + + /** + * 检查规则是否启用 + */ + isRuleEnabled(ruleId: string): boolean { + return this.enabledRules.has(ruleId); + } + + /** + * 获取按游戏类型分类的规则 + */ + getRulesByGameType(): Map { + return new Map(this.rulesByGameType); + } + + /** + * 清除特定游戏类型的规则 + */ + clearGameTypeRules(gameType: string): void { + this.rulesByGameType.delete(gameType); + } + + /** + * 清除所有规则 + */ + clearAllRules(): void { + this.rulesByGameType.clear(); + this.globalRules = []; + this.enabledRules.clear(); + } + + /** + * 获取规则数量 + */ + getRuleCount(): number { + return this.getAllRules().length; + } + + /** + * 获取启用的规则数量 + */ + getEnabledRuleCount(): number { + return this.enabledRules.size; + } +} + +/** + * 创建规则注册表 + */ +export function createRuleRegistry(): RuleRegistry { + return new RuleRegistry(); +} diff --git a/tests/commands/command.parser.test.ts b/tests/commands/command.parser.test.ts index ecb9f9d..5681e11 100644 --- a/tests/commands/command.parser.test.ts +++ b/tests/commands/command.parser.test.ts @@ -35,10 +35,10 @@ describe('CommandParser', () => { it('should parse command with flag', () => { const result = parser.parse('shuffle discard --seed=2026'); - + expect(result.commandName).toBe('shuffle'); expect(result.args.positional).toEqual(['discard']); - expect(result.args.flags).toEqual({ seed: 2026 }); + expect(result.args.flags).toEqual({ seed: '2026' }); }); it('should parse command with multiple flags', () => { @@ -67,10 +67,10 @@ describe('CommandParser', () => { it('should parse command with short flag and value', () => { const result = parser.parse('shuffle d1 --seed=2026'); - + expect(result.commandName).toBe('shuffle'); expect(result.args.positional).toEqual(['d1']); - expect(result.args.flags).toEqual({ seed: 2026 }); + expect(result.args.flags).toEqual({ seed: '2026' }); }); it('should parse command with string number value', () => { diff --git a/tests/games/tictactoe.test.ts b/tests/games/tictactoe.test.ts new file mode 100644 index 0000000..8090998 --- /dev/null +++ b/tests/games/tictactoe.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createGameState } from '../../src/core/GameState'; +import { RuleEngine } from '../../src/rules/RuleEngine'; +import { RegionType } from '../../src/core/Region'; +import type { Player, TicTacToeMetadata } from '../../src/games/tictactoe/TicTacToeState'; +import { + getCellId, + getAllCellIds, + getWinningCombinations, +} from '../../src/games/tictactoe/TicTacToeState'; +import { + ticTacToeRules, + startGameCommand, + createMarkCellCommand, + resetGameCommand, +} from '../../src/games/tictactoe'; + +describe('Tic Tac Toe', () => { + let gameState: ReturnType; + let ruleEngine: RuleEngine; + + beforeEach(async () => { + gameState = createGameState({ + id: 'tictactoe-game', + name: 'Tic Tac Toe', + metadata: { + ticTacToe: { + currentPlayer: 'X' as Player, + gameEnded: false, + winner: null, + moveHistory: [], + totalMoves: 0, + }, + }, + }); + + ruleEngine = new RuleEngine(gameState, { gameType: 'tictactoe' }); + ruleEngine.registerRules(ticTacToeRules); + + // Start the game + await ruleEngine.executeCommand(startGameCommand); + }); + + describe('game initialization', () => { + it('should create the board region', () => { + const board = gameState.getRegion('board'); + expect(board).toBeDefined(); + expect(board?.type).toBe(RegionType.Keyed); + }); + + it('should initialize all cells', () => { + const cellIds = getAllCellIds(3); + expect(cellIds.length).toBe(9); + + const board = gameState.getRegion('board'); + expect(board).toBeDefined(); + }); + + it('should set initial game state', () => { + const metadata = gameState.data.value.metadata?.ticTacToe as TicTacToeMetadata; + expect(metadata.currentPlayer).toBe('X'); + expect(metadata.gameEnded).toBe(false); + expect(metadata.winner).toBe(null); + }); + }); + + describe('marking cells', () => { + it('should allow player X to mark an empty cell', async () => { + const command = createMarkCellCommand('cell-A1', 'X'); + const result = await ruleEngine.executeCommand(command); + + expect(result.success).toBe(true); + + const placement = gameState.getPlacement('cell-A1'); + expect(placement).toBeDefined(); + expect(placement?.metadata?.player).toBe('X'); + }); + + it('should switch to player O after X moves', async () => { + const command = createMarkCellCommand('cell-A1', 'X'); + await ruleEngine.executeCommand(command); + + const metadata = gameState.data.value.metadata?.ticTacToe as TicTacToeMetadata; + expect(metadata.currentPlayer).toBe('O'); + }); + + it('should record move history', async () => { + const command = createMarkCellCommand('cell-A1', 'X'); + await ruleEngine.executeCommand(command); + + const metadata = gameState.data.value.metadata?.ticTacToe as TicTacToeMetadata; + expect(metadata.moveHistory.length).toBe(1); + expect(metadata.moveHistory[0].player).toBe('X'); + expect(metadata.moveHistory[0].cellId).toBe('cell-A1'); + }); + + it('should not allow marking an occupied cell', async () => { + const command1 = createMarkCellCommand('cell-A1', 'X'); + await ruleEngine.executeCommand(command1); + + const command2 = createMarkCellCommand('cell-A1', 'O'); + const result = await ruleEngine.executeCommand(command2); + + expect(result.success).toBe(false); + expect(result.error).toContain('already occupied'); + }); + + it('should not allow wrong player to move', async () => { + // Try to place O when it's X's turn + const command = createMarkCellCommand('cell-A1', 'O'); + const result = await ruleEngine.executeCommand(command); + + expect(result.success).toBe(false); + expect(result.error).toContain("It is X's turn"); + }); + + it('should not allow moves after game ends', async () => { + // Set up a winning scenario + await ruleEngine.executeCommand(createMarkCellCommand('cell-A1', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B1', 'O')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-A2', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B2', 'O')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-A3', 'X')); + + // Game should end with X winning + const metadata = gameState.data.value.metadata?.ticTacToe as TicTacToeMetadata; + expect(metadata.gameEnded).toBe(true); + expect(metadata.winner).toBe('X'); + + // Try to make another move + const command = createMarkCellCommand('cell-C1', 'O'); + const result = await ruleEngine.executeCommand(command); + + expect(result.success).toBe(false); + expect(result.error).toContain('Game has already ended'); + }); + }); + + describe('win conditions', () => { + it('should detect horizontal win for X', async () => { + await ruleEngine.executeCommand(createMarkCellCommand('cell-A1', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B1', 'O')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-A2', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B2', 'O')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-A3', 'X')); + + const metadata = gameState.data.value.metadata?.ticTacToe as TicTacToeMetadata; + expect(metadata.gameEnded).toBe(true); + expect(metadata.winner).toBe('X'); + expect(metadata.winningCombination).toEqual(['cell-A1', 'cell-A2', 'cell-A3']); + }); + + it('should detect horizontal win for O', async () => { + await ruleEngine.executeCommand(createMarkCellCommand('cell-A1', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B1', 'O')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-C1', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B2', 'O')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-C2', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B3', 'O')); + + const metadata = gameState.data.value.metadata?.ticTacToe as TicTacToeMetadata; + expect(metadata.gameEnded).toBe(true); + expect(metadata.winner).toBe('O'); + expect(metadata.winningCombination).toEqual(['cell-B1', 'cell-B2', 'cell-B3']); + }); + + it('should detect vertical win', async () => { + await ruleEngine.executeCommand(createMarkCellCommand('cell-A1', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B1', 'O')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-C1', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-C2', 'O')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B2', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-C3', 'O')); + + const metadata = gameState.data.value.metadata?.ticTacToe as TicTacToeMetadata; + expect(metadata.gameEnded).toBe(true); + expect(metadata.winner).toBe('O'); + expect(metadata.winningCombination).toEqual(['cell-C1', 'cell-C2', 'cell-C3']); + }); + + it('should detect main diagonal win', async () => { + await ruleEngine.executeCommand(createMarkCellCommand('cell-A1', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B1', 'O')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B2', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-C1', 'O')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-C3', 'X')); + + const metadata = gameState.data.value.metadata?.ticTacToe as TicTacToeMetadata; + expect(metadata.gameEnded).toBe(true); + expect(metadata.winner).toBe('X'); + expect(metadata.winningCombination).toEqual(['cell-A1', 'cell-B2', 'cell-C3']); + }); + + it('should detect anti-diagonal win', async () => { + await ruleEngine.executeCommand(createMarkCellCommand('cell-C1', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-A1', 'O')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B2', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-A2', 'O')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-A3', 'X')); + + const metadata = gameState.data.value.metadata?.ticTacToe as TicTacToeMetadata; + expect(metadata.gameEnded).toBe(true); + expect(metadata.winner).toBe('X'); + expect(metadata.winningCombination).toEqual(['cell-A3', 'cell-B2', 'cell-C1']); + }); + }); + + describe('draw condition', () => { + it('should detect a draw when all cells are filled without winner', async () => { + // Fill the board with no winner + const moves = [ + ['cell-A1', 'X'], + ['cell-A2', 'O'], + ['cell-A3', 'X'], + ['cell-B1', 'O'], + ['cell-B3', 'X'], + ['cell-B2', 'O'], + ['cell-C2', 'X'], + ['cell-C1', 'O'], + ['cell-C3', 'X'], + ] as [string, Player][]; + + for (const [cell, player] of moves) { + const command = createMarkCellCommand(cell, player); + await ruleEngine.executeCommand(command); + } + + const metadata = gameState.data.value.metadata?.ticTacToe as TicTacToeMetadata; + expect(metadata.gameEnded).toBe(true); + expect(metadata.winner).toBe(null); // Draw + expect(metadata.totalMoves).toBe(9); + }); + }); + + describe('reset game', () => { + it('should reset the board for a new game', async () => { + // Make some moves + await ruleEngine.executeCommand(createMarkCellCommand('cell-A1', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B1', 'O')); + + // Reset + await ruleEngine.executeCommand(resetGameCommand); + + // Check that cells are empty + const board = gameState.getRegion('board'); + expect(board).toBeDefined(); + + // Game should be reset + const metadata = gameState.data.value.metadata?.ticTacToe as TicTacToeMetadata; + expect(metadata.currentPlayer).toBe('X'); + expect(metadata.gameEnded).toBe(false); + expect(metadata.winner).toBe(null); + }); + }); + + describe('rule engine integration', () => { + it('should execute all rules in correct order', async () => { + const command = createMarkCellCommand('cell-B2', 'X'); + const result = await ruleEngine.executeCommand(command); + + expect(result.success).toBe(true); + expect(result.validationRules.length).toBeGreaterThan(0); + expect(result.effectRules.length).toBeGreaterThan(0); + + // Check that validation rules ran + const validationRuleIds = result.validationRules.map((r) => r.ruleId); + expect(validationRuleIds).toContain('tictactoe-validate-turn'); + expect(validationRuleIds).toContain('tictactoe-validate-cell-empty'); + expect(validationRuleIds).toContain('tictactoe-validate-game-not-ended'); + + // Check that effect rules ran + const effectRuleIds = result.effectRules.map((r) => r.ruleId); + expect(effectRuleIds).toContain('tictactoe-switch-turn'); + expect(effectRuleIds).toContain('tictactoe-record-history'); + }); + + it('should trigger win condition check after each move', async () => { + // Set up a winning scenario + await ruleEngine.executeCommand(createMarkCellCommand('cell-A1', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B1', 'O')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-A2', 'X')); + await ruleEngine.executeCommand(createMarkCellCommand('cell-B2', 'O')); + + const winningMove = createMarkCellCommand('cell-A3', 'X'); + const result = await ruleEngine.executeCommand(winningMove); + + // Check that trigger rules ran + const triggerRuleIds = result.triggerRules.map((r) => r.ruleId); + expect(triggerRuleIds).toContain('tictactoe-check-win'); + }); + }); + + describe('helper functions', () => { + it('should generate correct cell IDs', () => { + expect(getCellId(0, 0)).toBe('cell-A1'); + expect(getCellId(1, 1)).toBe('cell-B2'); + expect(getCellId(2, 2)).toBe('cell-C3'); + }); + + it('should return correct winning combinations', () => { + const combinations = getWinningCombinations(3); + expect(combinations.length).toBe(8); // 3 rows + 3 columns + 2 diagonals + + // Check rows + expect(combinations[0]).toEqual(['cell-A1', 'cell-A2', 'cell-A3']); + expect(combinations[1]).toEqual(['cell-B1', 'cell-B2', 'cell-B3']); + expect(combinations[2]).toEqual(['cell-C1', 'cell-C2', 'cell-C3']); + + // Check columns + expect(combinations[3]).toEqual(['cell-A1', 'cell-B1', 'cell-C1']); + expect(combinations[4]).toEqual(['cell-A2', 'cell-B2', 'cell-C2']); + expect(combinations[5]).toEqual(['cell-A3', 'cell-B3', 'cell-C3']); + + // Check diagonals + expect(combinations[6]).toEqual(['cell-A1', 'cell-B2', 'cell-C3']); + expect(combinations[7]).toEqual(['cell-A3', 'cell-B2', 'cell-C1']); + }); + }); +}); diff --git a/tests/rules/rule.engine.test.ts b/tests/rules/rule.engine.test.ts new file mode 100644 index 0000000..915b098 --- /dev/null +++ b/tests/rules/rule.engine.test.ts @@ -0,0 +1,595 @@ +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; + 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); + }); + }); +});