feat: add rule engine & tic tac toe test
This commit is contained in:
parent
d27948fbfc
commit
d0d051f547
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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<string, unknown> | 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<string, unknown> | undefined;
|
||||||
|
const ticTacToeMetadata = metadata?.ticTacToe as TicTacToeMetadata | undefined;
|
||||||
|
return ticTacToeMetadata?.gameEnded || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新游戏 metadata
|
||||||
|
*/
|
||||||
|
function updateGameMetadata(gameState: any, updates: Partial<TicTacToeMetadata>): void {
|
||||||
|
const metadata = gameState.data.value.metadata as Record<string, unknown> | 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<RuleResult> => {
|
||||||
|
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<RuleResult> => {
|
||||||
|
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<RuleResult> => {
|
||||||
|
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<RuleResult> => {
|
||||||
|
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<RuleResult> => {
|
||||||
|
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<boolean> => {
|
||||||
|
const winResult = checkWin(context);
|
||||||
|
return winResult !== null;
|
||||||
|
},
|
||||||
|
action: async (context: RuleContext): Promise<RuleResult> => {
|
||||||
|
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<boolean> => {
|
||||||
|
const winResult = checkWin(context);
|
||||||
|
if (winResult !== null) {
|
||||||
|
return false; // 有获胜者,不是平局
|
||||||
|
}
|
||||||
|
return isDraw(context);
|
||||||
|
},
|
||||||
|
action: async (context: RuleContext): Promise<RuleResult> => {
|
||||||
|
// 直接更新 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];
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
71
src/index.ts
71
src/index.ts
|
|
@ -3,6 +3,77 @@
|
||||||
* 基于 Preact Signals 的桌游状态管理库
|
* 基于 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
|
// Core types
|
||||||
export { PartType } from './core/Part';
|
export { PartType } from './core/Part';
|
||||||
export type {
|
export type {
|
||||||
|
|
|
||||||
|
|
@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规则执行结果
|
||||||
|
*/
|
||||||
|
export interface RuleResult {
|
||||||
|
/** 规则是否通过 */
|
||||||
|
success: boolean;
|
||||||
|
/** 错误信息(如果失败) */
|
||||||
|
error?: string;
|
||||||
|
/** 状态修改(规则可以对状态进行修改) */
|
||||||
|
stateUpdates?: Record<string, unknown>;
|
||||||
|
/** 是否阻止命令执行 */
|
||||||
|
blockCommand?: boolean;
|
||||||
|
/** 触发的额外命令 */
|
||||||
|
triggeredCommands?: Command[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证规则
|
||||||
|
* 在命令执行前运行,用于验证命令是否合法
|
||||||
|
*/
|
||||||
|
export interface ValidationRule {
|
||||||
|
/** 规则唯一标识 */
|
||||||
|
id: string;
|
||||||
|
/** 规则名称 */
|
||||||
|
name: string;
|
||||||
|
/** 规则描述 */
|
||||||
|
description?: string;
|
||||||
|
/** 规则优先级(数字越小越先执行) */
|
||||||
|
priority: number;
|
||||||
|
/** 适用的游戏类型 */
|
||||||
|
gameType?: string;
|
||||||
|
/** 适用的命令名称列表 */
|
||||||
|
applicableCommands?: string[];
|
||||||
|
/** 验证函数 */
|
||||||
|
validate: (context: RuleContext) => Promise<RuleResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 效果规则
|
||||||
|
* 在命令执行后运行,用于更新状态或触发额外效果
|
||||||
|
*/
|
||||||
|
export interface EffectRule {
|
||||||
|
/** 规则唯一标识 */
|
||||||
|
id: string;
|
||||||
|
/** 规则名称 */
|
||||||
|
name: string;
|
||||||
|
/** 规则描述 */
|
||||||
|
description?: string;
|
||||||
|
/** 规则优先级(数字越小越先执行) */
|
||||||
|
priority: number;
|
||||||
|
/** 适用的游戏类型 */
|
||||||
|
gameType?: string;
|
||||||
|
/** 适用的命令名称列表 */
|
||||||
|
applicableCommands?: string[];
|
||||||
|
/** 效果函数 */
|
||||||
|
apply: (context: RuleContext) => Promise<RuleResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发规则
|
||||||
|
* 监听特定状态变化并触发相应动作
|
||||||
|
*/
|
||||||
|
export interface TriggerRule {
|
||||||
|
/** 规则唯一标识 */
|
||||||
|
id: string;
|
||||||
|
/** 规则名称 */
|
||||||
|
name: string;
|
||||||
|
/** 规则描述 */
|
||||||
|
description?: string;
|
||||||
|
/** 规则优先级 */
|
||||||
|
priority: number;
|
||||||
|
/** 适用的游戏类型 */
|
||||||
|
gameType?: string;
|
||||||
|
/** 适用的命令名称列表 */
|
||||||
|
applicableCommands?: string[];
|
||||||
|
/** 触发条件 */
|
||||||
|
condition: (context: RuleContext) => Promise<boolean>;
|
||||||
|
/** 触发后的动作 */
|
||||||
|
action: (context: RuleContext) => Promise<RuleResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用规则类型
|
||||||
|
*/
|
||||||
|
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<ValidationRule, 'id' | 'name'> & { 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<EffectRule, 'id' | 'name'> & { 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<TriggerRule, 'id' | 'name'> & { 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;
|
||||||
|
}
|
||||||
|
|
@ -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<RuleEngineExecutionResult> {
|
||||||
|
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<RuleLogEntry[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -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<string, Rule[]>;
|
||||||
|
private globalRules: Rule[];
|
||||||
|
private ruleGroups: Map<string, RuleGroup>;
|
||||||
|
private enabledRules: Set<string>;
|
||||||
|
|
||||||
|
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<string, Rule[]> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
@ -38,7 +38,7 @@ describe('CommandParser', () => {
|
||||||
|
|
||||||
expect(result.commandName).toBe('shuffle');
|
expect(result.commandName).toBe('shuffle');
|
||||||
expect(result.args.positional).toEqual(['discard']);
|
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', () => {
|
it('should parse command with multiple flags', () => {
|
||||||
|
|
@ -70,7 +70,7 @@ describe('CommandParser', () => {
|
||||||
|
|
||||||
expect(result.commandName).toBe('shuffle');
|
expect(result.commandName).toBe('shuffle');
|
||||||
expect(result.args.positional).toEqual(['d1']);
|
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', () => {
|
it('should parse command with string number value', () => {
|
||||||
|
|
|
||||||
|
|
@ -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<typeof createGameState>;
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue