Compare commits
2 Commits
6740584fc8
...
df9698b67b
| Author | SHA1 | Date |
|---|---|---|
|
|
df9698b67b | |
|
|
ea337acacb |
174
README.md
174
README.md
|
|
@ -6,177 +6,3 @@
|
|||
|
||||
- **响应式状态管理**: 使用 [@preact/signals-core](https://preactjs.com/guide/v10/signals/) 实现细粒度响应式
|
||||
- **类型安全**: 完整的 TypeScript 支持
|
||||
- **核心概念**:
|
||||
- **Part**: 游戏组件(棋子、卡牌、板块)
|
||||
- **Region**: 容器区域(支持 keyed/unkeyed 两种模式)
|
||||
- **Placement**: Part 在 Region 中的引用
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 创建游戏状态
|
||||
|
||||
```typescript
|
||||
import { createGameState } from 'boardgame-core';
|
||||
|
||||
const gameState = createGameState({
|
||||
id: 'game-1',
|
||||
name: 'My Board Game',
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 创建 Parts(游戏组件)
|
||||
|
||||
```typescript
|
||||
import { createMeepleAction, createCardAction, createTileAction } from 'boardgame-core';
|
||||
|
||||
// 创建棋子
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red', {
|
||||
name: 'Player 1',
|
||||
});
|
||||
|
||||
// 创建卡牌
|
||||
const card = createCardAction(gameState, 'card-1', {
|
||||
suit: 'hearts',
|
||||
value: 10,
|
||||
});
|
||||
|
||||
// 创建板块
|
||||
const tile = createTileAction(gameState, 'tile-1', {
|
||||
pattern: 'forest',
|
||||
rotation: 90,
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 创建 Regions(区域)
|
||||
|
||||
```typescript
|
||||
import { createRegionAction, RegionType } from 'boardgame-core';
|
||||
|
||||
// Unkeyed Region - 适用于牌库、弃牌堆等
|
||||
const deck = createRegionAction(gameState, {
|
||||
id: 'deck',
|
||||
type: RegionType.Unkeyed,
|
||||
name: 'Draw Deck',
|
||||
});
|
||||
|
||||
// Keyed Region - 适用于版图、玩家区域等有固定位置的区域
|
||||
const board = createRegionAction(gameState, {
|
||||
id: 'board',
|
||||
type: RegionType.Keyed,
|
||||
name: 'Game Board',
|
||||
});
|
||||
|
||||
// 带容量的区域(如手牌限制)
|
||||
const hand = createRegionAction(gameState, {
|
||||
id: 'hand',
|
||||
type: RegionType.Unkeyed,
|
||||
capacity: 5,
|
||||
});
|
||||
```
|
||||
|
||||
### 4. 创建 Placements(放置)
|
||||
|
||||
```typescript
|
||||
import { createPlacementAction } from 'boardgame-core';
|
||||
|
||||
// 将棋子放置在版图上
|
||||
const placement = createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
position: { x: 3, y: 4 },
|
||||
});
|
||||
```
|
||||
|
||||
### 5. 使用 Actions 操作状态
|
||||
|
||||
```typescript
|
||||
import {
|
||||
movePlacementAction,
|
||||
flipPlacementAction,
|
||||
setSlotAction,
|
||||
updatePartAction,
|
||||
} from 'boardgame-core';
|
||||
|
||||
// 移动 Placement 到另一个区域
|
||||
movePlacementAction(gameState, 'placement-1', 'hand');
|
||||
|
||||
// 翻转卡牌(面朝上/面朝下)
|
||||
flipPlacementAction(gameState, 'placement-1');
|
||||
|
||||
// 在 Keyed Region 中设置槽位
|
||||
setSlotAction(gameState, 'board', 'A1', 'placement-1');
|
||||
|
||||
// 更新 Part 属性
|
||||
updatePartAction(gameState, 'meeple-1', { metadata: { score: 10 } });
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
### Part Actions
|
||||
|
||||
| Action | 描述 |
|
||||
|--------|------|
|
||||
| `createPartAction` | 创建通用 Part |
|
||||
| `createMeepleAction` | 创建棋子 |
|
||||
| `createCardAction` | 创建卡牌 |
|
||||
| `createTileAction` | 创建板块 |
|
||||
| `updatePartAction` | 更新 Part 属性 |
|
||||
| `removePartAction` | 移除 Part |
|
||||
| `getPartAction` | 获取 Part |
|
||||
|
||||
### Region Actions
|
||||
|
||||
| Action | 描述 |
|
||||
|--------|------|
|
||||
| `createRegionAction` | 创建 Region |
|
||||
| `getRegionAction` | 获取 Region |
|
||||
| `removeRegionAction` | 移除 Region |
|
||||
| `addPlacementToRegionAction` | 添加 Placement 到 Unkeyed Region |
|
||||
| `removePlacementFromRegionAction` | 从 Region 移除 Placement |
|
||||
| `setSlotAction` | 设置 Keyed Region 的槽位 |
|
||||
| `getSlotAction` | 获取 Keyed Region 的槽位 |
|
||||
| `clearRegionAction` | 清空 Region |
|
||||
| `getRegionPlacementCountAction` | 获取 Region 中 Placement 数量 |
|
||||
| `isRegionEmptyAction` | 检查 Region 是否为空 |
|
||||
| `isRegionFullAction` | 检查 Region 是否已满 |
|
||||
|
||||
### Placement Actions
|
||||
|
||||
| Action | 描述 |
|
||||
|--------|------|
|
||||
| `createPlacementAction` | 创建 Placement |
|
||||
| `getPlacementAction` | 获取 Placement |
|
||||
| `removePlacementAction` | 移除 Placement |
|
||||
| `movePlacementAction` | 移动 Placement 到另一个 Region |
|
||||
| `updatePlacementPositionAction` | 更新 Placement 位置 |
|
||||
| `updatePlacementRotationAction` | 更新 Placement 旋转角度 |
|
||||
| `flipPlacementAction` | 翻转 Placement |
|
||||
| `updatePlacementPartAction` | 更新 Placement 的 Part 引用 |
|
||||
| `swapPlacementsAction` | 交换两个 Placement 的位置 |
|
||||
| `setPlacementFaceAction` | 设置 Placement 面朝上/下 |
|
||||
| `getPlacementsInRegionAction` | 获取 Region 中的所有 Placements |
|
||||
| `getPlacementsOfPartAction` | 获取 Part 的所有 Placements |
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
npm test # 监视模式
|
||||
npm run test:run # 运行一次
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
{
|
||||
"id": "card-play-001",
|
||||
"name": "Play Card",
|
||||
"description": "Draw a card from deck and play it to discard pile",
|
||||
"steps": [
|
||||
{
|
||||
"action": "createCard",
|
||||
"params": {
|
||||
"id": "card-hearts-10",
|
||||
"suit": "hearts",
|
||||
"value": 10,
|
||||
"name": "10 of Hearts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "createPlacement",
|
||||
"params": {
|
||||
"id": "placement-card-1",
|
||||
"partId": "card-hearts-10",
|
||||
"regionId": "player-hand",
|
||||
"faceUp": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "addPlacementToRegion",
|
||||
"params": {
|
||||
"regionId": "player-hand",
|
||||
"placementId": "placement-card-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "setPlacementFace",
|
||||
"params": {
|
||||
"placementId": "placement-card-1",
|
||||
"faceUp": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "movePlacement",
|
||||
"params": {
|
||||
"placementId": "placement-card-1",
|
||||
"targetRegionId": "discard"
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0",
|
||||
"category": "action",
|
||||
"cardGame": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
# Board Game CLI Commands
|
||||
# 命令格式:<command> <args> [--flags]
|
||||
|
||||
# ========== 游戏设置 ==========
|
||||
|
||||
# 创建游戏区域
|
||||
region board keyed --name="Game Board"
|
||||
region supply unkeyed --name=Supply
|
||||
region discard unkeyed --name="Discard Pile"
|
||||
region hand unkeyed --name=Hand --capacity=5
|
||||
|
||||
# 设置游戏阶段
|
||||
phase setup
|
||||
|
||||
# ========== 创建组件 ==========
|
||||
|
||||
# 创建棋子
|
||||
create meeple red-1 --color=red --name="Red Player 1"
|
||||
create meeple blue-1 --color=blue --name="Blue Player 1"
|
||||
|
||||
# 创建卡牌
|
||||
create card hearts-10 --suit=hearts --value=10
|
||||
create card spades-ace --suit=spades --value=ace
|
||||
|
||||
# 创建板块
|
||||
create tile forest-1 --pattern=forest --rotation=90
|
||||
|
||||
# ========== 放置组件 ==========
|
||||
|
||||
# 放置棋子到版图
|
||||
place red-1 board 3 4 --rotation=0 --faceup=true
|
||||
|
||||
# 放置卡牌到手牌(面朝下)
|
||||
place hearts-10 hand 0 0 --faceup=false
|
||||
|
||||
# ========== 移动和操作 ==========
|
||||
|
||||
# 移动棋子
|
||||
move red-1 board --key=B3
|
||||
|
||||
# 翻转卡牌
|
||||
flip hearts-10
|
||||
|
||||
# 旋转板块
|
||||
rotate forest-1 180
|
||||
|
||||
# 设置位置
|
||||
position red-1 5 2
|
||||
|
||||
# 交换两个棋子
|
||||
swap red-1 blue-1
|
||||
|
||||
# ========== 卡牌操作 ==========
|
||||
|
||||
# 抽牌
|
||||
draw supply 1 --to=hand
|
||||
|
||||
# 洗牌(带种子)
|
||||
shuffle discard --seed=2026
|
||||
|
||||
# 出牌到弃牌堆
|
||||
discard hearts-10 --to=discard
|
||||
|
||||
# ========== 清理 ==========
|
||||
|
||||
# 清空区域
|
||||
clear hand
|
||||
|
||||
# 移除组件
|
||||
remove part red-1
|
||||
remove placement p1
|
||||
remove region temp
|
||||
|
||||
# ========== 帮助 ==========
|
||||
|
||||
# 显示所有命令
|
||||
help
|
||||
|
||||
# 显示特定命令帮助
|
||||
help move
|
||||
help shuffle
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
# Movement Commands Examples
|
||||
# 移动命令示例
|
||||
|
||||
# ========== 基本移动 ==========
|
||||
|
||||
# 简单移动
|
||||
move piece-1 board
|
||||
|
||||
# 移动到指定槽位
|
||||
move piece-1 board --key=A1
|
||||
|
||||
# ========== 位置操作 ==========
|
||||
|
||||
# 设置精确位置
|
||||
position piece-1 0 0
|
||||
position piece-1 3 5
|
||||
position piece-1 -2 4
|
||||
|
||||
# 旋转
|
||||
rotate tile-1 90
|
||||
rotate tile-1 180
|
||||
rotate tile-1 270
|
||||
rotate tile-1 -45
|
||||
|
||||
# ========== 翻转 ==========
|
||||
|
||||
# 翻转(切换面朝上/下)
|
||||
flip card-1
|
||||
flip tile-2
|
||||
|
||||
# ========== 交换 ==========
|
||||
|
||||
# 交换两个棋子位置
|
||||
swap meeple-1 meeple-2
|
||||
|
||||
# ========== 组合移动示例 ==========
|
||||
|
||||
# 移动并旋转
|
||||
move knight-1 board --key=C3
|
||||
rotate knight-1 45
|
||||
|
||||
# 移动并设置位置
|
||||
move bishop-1 board
|
||||
position bishop-1 4 4
|
||||
|
||||
# ========== 高级用法 ==========
|
||||
|
||||
# 移动多个棋子(需要多行)
|
||||
move rook-1 board --key=A1
|
||||
move rook-2 board --key=H1
|
||||
move king-1 board --key=E1
|
||||
move queen-1 board --key=D1
|
||||
|
||||
# 批量翻转
|
||||
flip card-1
|
||||
flip card-2
|
||||
flip card-3
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
{
|
||||
"id": "combo-001",
|
||||
"name": "Combo Move",
|
||||
"description": "A complex combo: place meeple, move it, then flip a card",
|
||||
"steps": [
|
||||
{
|
||||
"action": "createMeeple",
|
||||
"params": {
|
||||
"id": "meeple-blue-1",
|
||||
"color": "blue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "createPlacement",
|
||||
"params": {
|
||||
"id": "placement-blue-1",
|
||||
"partId": "meeple-blue-1",
|
||||
"regionId": "supply",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "movePlacement",
|
||||
"params": {
|
||||
"placementId": "placement-blue-1",
|
||||
"targetRegionId": "board",
|
||||
"key": "A1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "updatePlacementPosition",
|
||||
"params": {
|
||||
"placementId": "placement-blue-1",
|
||||
"position": {
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "createCard",
|
||||
"params": {
|
||||
"id": "card-spades-ace",
|
||||
"suit": "spades",
|
||||
"value": "ace"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "createPlacement",
|
||||
"params": {
|
||||
"id": "placement-card-ace",
|
||||
"partId": "card-spades-ace",
|
||||
"regionId": "discard",
|
||||
"faceUp": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "flipPlacement",
|
||||
"params": {
|
||||
"placementId": "placement-card-ace"
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0",
|
||||
"category": "combo",
|
||||
"difficulty": "medium"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"id": "move-meeple-001",
|
||||
"name": "Move Meeple",
|
||||
"description": "Move a meeple from one position to another on the board",
|
||||
"steps": [
|
||||
{
|
||||
"action": "updatePlacementPosition",
|
||||
"params": {
|
||||
"placementId": "placement-red-1",
|
||||
"position": {
|
||||
"x": 5,
|
||||
"y": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0",
|
||||
"category": "action",
|
||||
"playerCount": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
{
|
||||
"id": "phase-change-001",
|
||||
"name": "Phase Change",
|
||||
"description": "Change the game phase with setup steps",
|
||||
"steps": [
|
||||
{
|
||||
"action": "setPhase",
|
||||
"params": {
|
||||
"phase": "setup"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "createRegion",
|
||||
"params": {
|
||||
"id": "board",
|
||||
"type": "keyed",
|
||||
"name": "Game Board"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "setPhase",
|
||||
"params": {
|
||||
"phase": "main"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "createMeeple",
|
||||
"params": {
|
||||
"id": "meeple-green-1",
|
||||
"color": "green"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "setPhase",
|
||||
"params": {
|
||||
"phase": "end"
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0",
|
||||
"category": "game-flow"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
{
|
||||
"id": "place-meeple-001",
|
||||
"name": "Place Meeple",
|
||||
"description": "Place a meeple on the board at specified position",
|
||||
"steps": [
|
||||
{
|
||||
"action": "createMeeple",
|
||||
"params": {
|
||||
"id": "meeple-red-1",
|
||||
"color": "red",
|
||||
"name": "Red Meeple 1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "createPlacement",
|
||||
"params": {
|
||||
"id": "placement-red-1",
|
||||
"partId": "meeple-red-1",
|
||||
"regionId": "board",
|
||||
"position": {
|
||||
"x": 3,
|
||||
"y": 4
|
||||
},
|
||||
"rotation": 0,
|
||||
"faceUp": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "setSlot",
|
||||
"params": {
|
||||
"regionId": "board",
|
||||
"key": "C4",
|
||||
"placementId": "placement-red-1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0",
|
||||
"category": "action",
|
||||
"playerCount": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
{
|
||||
"id": "setup-game-001",
|
||||
"name": "Setup Game",
|
||||
"description": "Initialize the game board with basic regions and starting components",
|
||||
"steps": [
|
||||
{
|
||||
"action": "createRegion",
|
||||
"params": {
|
||||
"id": "board",
|
||||
"type": "keyed",
|
||||
"name": "Game Board"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "createRegion",
|
||||
"params": {
|
||||
"id": "supply",
|
||||
"type": "unkeyed",
|
||||
"name": "Supply"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "createRegion",
|
||||
"params": {
|
||||
"id": "discard",
|
||||
"type": "unkeyed",
|
||||
"name": "Discard Pile"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "createRegion",
|
||||
"params": {
|
||||
"id": "player-hand",
|
||||
"type": "unkeyed",
|
||||
"name": "Player Hand",
|
||||
"capacity": 5
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0",
|
||||
"category": "setup"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
{
|
||||
"id": "tile-place-001",
|
||||
"name": "Place Tile",
|
||||
"description": "Place a terrain tile on the board with rotation",
|
||||
"steps": [
|
||||
{
|
||||
"action": "createTile",
|
||||
"params": {
|
||||
"id": "tile-forest-1",
|
||||
"pattern": "forest",
|
||||
"rotation": 90,
|
||||
"name": "Forest Tile 1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "createPlacement",
|
||||
"params": {
|
||||
"id": "placement-tile-1",
|
||||
"partId": "tile-forest-1",
|
||||
"regionId": "board",
|
||||
"position": {
|
||||
"x": 2,
|
||||
"y": 3
|
||||
},
|
||||
"rotation": 90,
|
||||
"faceUp": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "setSlot",
|
||||
"params": {
|
||||
"regionId": "board",
|
||||
"key": "B3",
|
||||
"placementId": "placement-tile-1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0",
|
||||
"category": "action",
|
||||
"tileGame": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import type { GameState } from '../core/GameState';
|
||||
import type { Part, MeeplePart, CardPart, TilePart } from '../core/Part';
|
||||
import { PartType } from '../core/Part';
|
||||
|
||||
/**
|
||||
* 创建 Part 并添加到 GameState
|
||||
*/
|
||||
export function createPartAction<T extends Part>(gameState: GameState, part: T): T {
|
||||
gameState.addPart(part);
|
||||
return part;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Meeple 并添加到 GameState
|
||||
*/
|
||||
export function createMeepleAction(
|
||||
gameState: GameState,
|
||||
id: string,
|
||||
color: string,
|
||||
options?: { name?: string; metadata?: Record<string, unknown> }
|
||||
): MeeplePart {
|
||||
const part: MeeplePart = {
|
||||
id,
|
||||
type: PartType.Meeple,
|
||||
color,
|
||||
...options,
|
||||
};
|
||||
gameState.addPart(part);
|
||||
return part;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Card 并添加到 GameState
|
||||
*/
|
||||
export function createCardAction(
|
||||
gameState: GameState,
|
||||
id: string,
|
||||
options?: { suit?: string; value?: number | string; name?: string; metadata?: Record<string, unknown> }
|
||||
): CardPart {
|
||||
const part: CardPart = {
|
||||
id,
|
||||
type: PartType.Card,
|
||||
...options,
|
||||
};
|
||||
gameState.addPart(part);
|
||||
return part;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Tile 并添加到 GameState
|
||||
*/
|
||||
export function createTileAction(
|
||||
gameState: GameState,
|
||||
id: string,
|
||||
options?: { pattern?: string; rotation?: number; name?: string; metadata?: Record<string, unknown> }
|
||||
): TilePart {
|
||||
const part: TilePart = {
|
||||
id,
|
||||
type: PartType.Tile,
|
||||
...options,
|
||||
};
|
||||
gameState.addPart(part);
|
||||
return part;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Part
|
||||
*/
|
||||
export function updatePartAction<T extends Part>(
|
||||
gameState: GameState,
|
||||
partId: string,
|
||||
updates: Partial<T>
|
||||
): void {
|
||||
gameState.updatePart<T>(partId, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 Part
|
||||
*/
|
||||
export function removePartAction(gameState: GameState, partId: string): void {
|
||||
// 先移除所有引用该 Part 的 Placement
|
||||
const placements = gameState.getPlacementsOfPart(partId);
|
||||
for (const placement of placements) {
|
||||
gameState.removePlacement(placement.id);
|
||||
}
|
||||
|
||||
gameState.removePart(partId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Part
|
||||
*/
|
||||
export function getPartAction(gameState: GameState, partId: string): Part | undefined {
|
||||
return gameState.getPart(partId);
|
||||
}
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
import type { GameState } from '../core/GameState';
|
||||
import type { Placement, Position } from '../core/Placement';
|
||||
import type { Part } from '../core/Part';
|
||||
|
||||
/**
|
||||
* 创建 Placement 并添加到 GameState
|
||||
*/
|
||||
export function createPlacementAction(
|
||||
gameState: GameState,
|
||||
options: {
|
||||
id: string;
|
||||
partId: string;
|
||||
regionId: string;
|
||||
position?: Position;
|
||||
rotation?: number;
|
||||
faceUp?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
): Placement {
|
||||
const part = gameState.getPart(options.partId);
|
||||
if (!part) {
|
||||
throw new Error(`Part ${options.partId} not found`);
|
||||
}
|
||||
|
||||
const region = gameState.getRegion(options.regionId);
|
||||
if (!region) {
|
||||
throw new Error(`Region ${options.regionId} not found`);
|
||||
}
|
||||
|
||||
const placement: Placement = {
|
||||
id: options.id,
|
||||
partId: options.partId,
|
||||
regionId: options.regionId,
|
||||
part,
|
||||
position: options.position,
|
||||
rotation: options.rotation ?? 0,
|
||||
faceUp: options.faceUp ?? true,
|
||||
metadata: options.metadata,
|
||||
};
|
||||
|
||||
gameState.addPlacement(placement);
|
||||
return placement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Placement
|
||||
*/
|
||||
export function getPlacementAction(gameState: GameState, placementId: string): Placement | undefined {
|
||||
return gameState.getPlacement(placementId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 Placement
|
||||
*/
|
||||
export function removePlacementAction(gameState: GameState, placementId: string): void {
|
||||
gameState.removePlacement(placementId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动 Placement 到另一个 Region
|
||||
*/
|
||||
export function movePlacementAction(
|
||||
gameState: GameState,
|
||||
placementId: string,
|
||||
targetRegionId: string,
|
||||
key?: string
|
||||
): void {
|
||||
gameState.movePlacement(placementId, targetRegionId, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Placement 的位置
|
||||
*/
|
||||
export function updatePlacementPositionAction(
|
||||
gameState: GameState,
|
||||
placementId: string,
|
||||
position: Position
|
||||
): void {
|
||||
const placement = gameState.getPlacement(placementId);
|
||||
if (!placement) {
|
||||
throw new Error(`Placement ${placementId} not found`);
|
||||
}
|
||||
|
||||
const updated = { ...placement, position };
|
||||
const placements = new Map(gameState.placements.value);
|
||||
placements.set(placementId, updated);
|
||||
gameState.placements.value = placements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Placement 的旋转角度
|
||||
*/
|
||||
export function updatePlacementRotationAction(
|
||||
gameState: GameState,
|
||||
placementId: string,
|
||||
rotation: number
|
||||
): void {
|
||||
const placement = gameState.getPlacement(placementId);
|
||||
if (!placement) {
|
||||
throw new Error(`Placement ${placementId} not found`);
|
||||
}
|
||||
|
||||
const updated = { ...placement, rotation };
|
||||
const placements = new Map(gameState.placements.value);
|
||||
placements.set(placementId, updated);
|
||||
gameState.placements.value = placements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻转 Placement
|
||||
*/
|
||||
export function flipPlacementAction(gameState: GameState, placementId: string): void {
|
||||
const placement = gameState.getPlacement(placementId);
|
||||
if (!placement) {
|
||||
throw new Error(`Placement ${placementId} not found`);
|
||||
}
|
||||
|
||||
const updated = { ...placement, faceUp: !placement.faceUp };
|
||||
const placements = new Map(gameState.placements.value);
|
||||
placements.set(placementId, updated);
|
||||
gameState.placements.value = placements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Placement 的 Part 引用
|
||||
*/
|
||||
export function updatePlacementPartAction(
|
||||
gameState: GameState,
|
||||
placementId: string,
|
||||
part: Part | null
|
||||
): void {
|
||||
gameState.updatePlacementPart(placementId, part);
|
||||
}
|
||||
|
||||
/**
|
||||
* 交换两个 Placement 的位置
|
||||
*/
|
||||
export function swapPlacementsAction(
|
||||
gameState: GameState,
|
||||
placementId1: string,
|
||||
placementId2: string
|
||||
): void {
|
||||
const placement1 = gameState.getPlacement(placementId1);
|
||||
const placement2 = gameState.getPlacement(placementId2);
|
||||
|
||||
if (!placement1) {
|
||||
throw new Error(`Placement ${placementId1} not found`);
|
||||
}
|
||||
if (!placement2) {
|
||||
throw new Error(`Placement ${placementId2} not found`);
|
||||
}
|
||||
|
||||
if (placement1.regionId !== placement2.regionId) {
|
||||
throw new Error('Cannot swap placements in different regions directly');
|
||||
}
|
||||
|
||||
const region = gameState.getRegion(placement1.regionId);
|
||||
if (!region) {
|
||||
throw new Error(`Region ${placement1.regionId} not found`);
|
||||
}
|
||||
|
||||
// 如果是 keyed region,交换 slots
|
||||
if (region.type === 'keyed' && region.slots) {
|
||||
const slots = new Map(region.slots.value);
|
||||
let key1: string | null = null;
|
||||
let key2: string | null = null;
|
||||
|
||||
for (const [key, value] of slots.entries()) {
|
||||
if (value === placementId1) key1 = key;
|
||||
if (value === placementId2) key2 = key;
|
||||
}
|
||||
|
||||
if (key1 && key2) {
|
||||
slots.set(key1, placementId2);
|
||||
slots.set(key2, placementId1);
|
||||
region.slots.value = slots;
|
||||
}
|
||||
} else {
|
||||
// unkeyed region:交换在 placements 列表中的位置
|
||||
const placements = [...region.placements.value];
|
||||
const index1 = placements.indexOf(placementId1);
|
||||
const index2 = placements.indexOf(placementId2);
|
||||
|
||||
if (index1 !== -1 && index2 !== -1) {
|
||||
[placements[index1], placements[index2]] = [placements[index2], placements[index1]];
|
||||
region.placements.value = placements;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Placement 设置为面朝上/面朝下
|
||||
*/
|
||||
export function setPlacementFaceAction(
|
||||
gameState: GameState,
|
||||
placementId: string,
|
||||
faceUp: boolean
|
||||
): void {
|
||||
const placement = gameState.getPlacement(placementId);
|
||||
if (!placement) {
|
||||
throw new Error(`Placement ${placementId} not found`);
|
||||
}
|
||||
|
||||
const updated = { ...placement, faceUp };
|
||||
const placements = new Map(gameState.placements.value);
|
||||
placements.set(placementId, updated);
|
||||
gameState.placements.value = placements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Region 中的所有 Placements
|
||||
*/
|
||||
export function getPlacementsInRegionAction(gameState: GameState, regionId: string): Placement[] {
|
||||
return gameState.getPlacementsInRegion(regionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Part 的所有 Placements
|
||||
*/
|
||||
export function getPlacementsOfPartAction(gameState: GameState, partId: string): Placement[] {
|
||||
return gameState.getPlacementsOfPart(partId);
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
import type { GameState } from '../core/GameState';
|
||||
import type { Region, RegionProperties } from '../core/Region';
|
||||
import { RegionType } from '../core/Region';
|
||||
import type { Placement } from '../core/Placement';
|
||||
import { signal } from '@preact/signals-core';
|
||||
|
||||
/**
|
||||
* 创建 Region 并添加到 GameState
|
||||
*/
|
||||
export function createRegionAction(gameState: GameState, properties: RegionProperties): Region {
|
||||
const region = {
|
||||
...properties,
|
||||
placements: signal<string[]>([]),
|
||||
...(properties.type === RegionType.Keyed ? { slots: signal<Map<string, string | null>>(new Map()) } : {}),
|
||||
} as Region;
|
||||
|
||||
gameState.addRegion(region);
|
||||
return region;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Region
|
||||
*/
|
||||
export function getRegionAction(gameState: GameState, regionId: string): Region | undefined {
|
||||
return gameState.getRegion(regionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 Region
|
||||
*/
|
||||
export function removeRegionAction(gameState: GameState, regionId: string): void {
|
||||
const region = gameState.getRegion(regionId);
|
||||
if (region) {
|
||||
// 先移除所有 Placement
|
||||
const placementIds = [...region.placements.value];
|
||||
for (const placementId of placementIds) {
|
||||
gameState.removePlacement(placementId);
|
||||
}
|
||||
}
|
||||
gameState.removeRegion(regionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 Placement 到 Region (unkeyed)
|
||||
*/
|
||||
export function addPlacementToRegionAction(
|
||||
gameState: GameState,
|
||||
regionId: string,
|
||||
placementId: string
|
||||
): void {
|
||||
const region = gameState.getRegion(regionId);
|
||||
if (!region) {
|
||||
throw new Error(`Region ${regionId} not found`);
|
||||
}
|
||||
|
||||
if (region.slots !== undefined) {
|
||||
throw new Error('Cannot use addPlacementToRegionAction on a keyed region. Use setSlotAction instead.');
|
||||
}
|
||||
|
||||
// 检查容量
|
||||
if (region.capacity !== undefined && region.placements.value.length >= region.capacity) {
|
||||
throw new Error(`Region ${regionId} has reached its capacity of ${region.capacity}`);
|
||||
}
|
||||
|
||||
region.placements.value = [...region.placements.value, placementId];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Region 移除 Placement
|
||||
*/
|
||||
export function removePlacementFromRegionAction(
|
||||
gameState: GameState,
|
||||
regionId: string,
|
||||
placementId: string
|
||||
): void {
|
||||
const region = gameState.getRegion(regionId);
|
||||
if (!region) {
|
||||
throw new Error(`Region ${regionId} not found`);
|
||||
}
|
||||
|
||||
const current = region.placements.value;
|
||||
const index = current.indexOf(placementId);
|
||||
if (index !== -1) {
|
||||
const updated = [...current];
|
||||
updated.splice(index, 1);
|
||||
region.placements.value = updated;
|
||||
}
|
||||
|
||||
// 如果是 keyed region,清理 slot
|
||||
if (region.type === RegionType.Keyed && region.slots) {
|
||||
const slots = new Map(region.slots.value);
|
||||
for (const [key, value] of slots.entries()) {
|
||||
if (value === placementId) {
|
||||
slots.set(key, null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
region.slots.value = slots;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Keyed Region 的槽位
|
||||
*/
|
||||
export function setSlotAction(
|
||||
gameState: GameState,
|
||||
regionId: string,
|
||||
key: string,
|
||||
placementId: string | null
|
||||
): void {
|
||||
const region = gameState.getRegion(regionId);
|
||||
if (!region) {
|
||||
throw new Error(`Region ${regionId} not found`);
|
||||
}
|
||||
|
||||
if (region.type !== RegionType.Keyed || !region.slots) {
|
||||
throw new Error('Cannot use setSlotAction on an unkeyed region.');
|
||||
}
|
||||
|
||||
const slots = new Map(region.slots.value);
|
||||
|
||||
// 如果是放置新 placement,需要更新 placements 列表
|
||||
if (placementId !== null) {
|
||||
const currentPlacements = region.placements.value;
|
||||
if (!currentPlacements.includes(placementId)) {
|
||||
region.placements.value = [...currentPlacements, placementId];
|
||||
}
|
||||
}
|
||||
|
||||
slots.set(key, placementId);
|
||||
region.slots.value = slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Keyed Region 的槽位
|
||||
*/
|
||||
export function getSlotAction(
|
||||
gameState: GameState,
|
||||
regionId: string,
|
||||
key: string
|
||||
): string | null {
|
||||
const region = gameState.getRegion(regionId);
|
||||
if (!region) {
|
||||
throw new Error(`Region ${regionId} not found`);
|
||||
}
|
||||
|
||||
if (region.type !== RegionType.Keyed || !region.slots) {
|
||||
throw new Error('Cannot use getSlotAction on an unkeyed region.');
|
||||
}
|
||||
|
||||
return region.slots.value.get(key) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空 Region
|
||||
*/
|
||||
export function clearRegionAction(gameState: GameState, regionId: string): void {
|
||||
const region = gameState.getRegion(regionId);
|
||||
if (!region) {
|
||||
throw new Error(`Region ${regionId} not found`);
|
||||
}
|
||||
|
||||
// 移除所有 Placement
|
||||
const placementIds = [...region.placements.value];
|
||||
for (const placementId of placementIds) {
|
||||
gameState.removePlacement(placementId);
|
||||
}
|
||||
|
||||
region.placements.value = [];
|
||||
if (region.slots) {
|
||||
region.slots.value = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Region 中 Placement 的数量
|
||||
*/
|
||||
export function getRegionPlacementCountAction(gameState: GameState, regionId: string): number {
|
||||
const region = gameState.getRegion(regionId);
|
||||
if (!region) {
|
||||
return 0;
|
||||
}
|
||||
return region.placements.value.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Region 是否为空
|
||||
*/
|
||||
export function isRegionEmptyAction(gameState: GameState, regionId: string): boolean {
|
||||
const region = gameState.getRegion(regionId);
|
||||
if (!region) {
|
||||
return true;
|
||||
}
|
||||
return region.placements.value.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Region 是否已满
|
||||
*/
|
||||
export function isRegionFullAction(gameState: GameState, regionId: string): boolean {
|
||||
const region = gameState.getRegion(regionId);
|
||||
if (!region) {
|
||||
return false;
|
||||
}
|
||||
if (region.capacity === undefined) {
|
||||
return false;
|
||||
}
|
||||
return region.placements.value.length >= region.capacity;
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
/**
|
||||
* CLI 命令参数
|
||||
*/
|
||||
export interface CliCommandArgs {
|
||||
/** 位置参数 */
|
||||
positional: string[];
|
||||
/** 标志参数 (--key=value 或 --flag) */
|
||||
flags: Record<string, string | boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI 命令定义
|
||||
*/
|
||||
export interface CliCommand {
|
||||
/** 命令名称 */
|
||||
name: string;
|
||||
/** 命令描述 */
|
||||
description: string;
|
||||
/** 使用示例 */
|
||||
usage: string;
|
||||
/** 位置参数定义 */
|
||||
args?: CliCommandArgDef[];
|
||||
/** 标志参数定义 */
|
||||
flags?: CliCommandFlagDef[];
|
||||
/** 命令处理器 */
|
||||
handler: (args: CliCommandArgs) => CliCommandStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置参数定义
|
||||
*/
|
||||
export interface CliCommandArgDef {
|
||||
/** 参数名称 */
|
||||
name: string;
|
||||
/** 参数描述 */
|
||||
description: string;
|
||||
/** 是否必需 */
|
||||
required?: boolean;
|
||||
/** 默认值 */
|
||||
default?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标志参数定义
|
||||
*/
|
||||
export interface CliCommandFlagDef {
|
||||
/** 标志名称 */
|
||||
name: string;
|
||||
/** 标志描述 */
|
||||
description: string;
|
||||
/** 是否必需 */
|
||||
required?: boolean;
|
||||
/** 默认值 */
|
||||
default?: string | boolean;
|
||||
/** 参数类型 */
|
||||
type?: 'string' | 'number' | 'boolean';
|
||||
/** 简写 */
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI 命令执行结果
|
||||
*/
|
||||
export interface CliCommandResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
steps: CliCommandStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI 命令步骤(转换为标准 CommandStep)
|
||||
*/
|
||||
export interface CliCommandStep {
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令解析结果
|
||||
*/
|
||||
export interface ParsedCliCommand {
|
||||
commandName: string;
|
||||
args: CliCommandArgs;
|
||||
raw: string;
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
/**
|
||||
* 命令步骤类型
|
||||
*/
|
||||
export enum CommandActionType {
|
||||
// Part actions
|
||||
CreateMeeple = 'createMeeple',
|
||||
CreateCard = 'createCard',
|
||||
CreateTile = 'createTile',
|
||||
UpdatePart = 'updatePart',
|
||||
RemovePart = 'removePart',
|
||||
|
||||
// Region actions
|
||||
CreateRegion = 'createRegion',
|
||||
RemoveRegion = 'removeRegion',
|
||||
AddPlacementToRegion = 'addPlacementToRegion',
|
||||
RemovePlacementFromRegion = 'removePlacementFromRegion',
|
||||
SetSlot = 'setSlot',
|
||||
ClearRegion = 'clearRegion',
|
||||
|
||||
// Placement actions
|
||||
CreatePlacement = 'createPlacement',
|
||||
RemovePlacement = 'removePlacement',
|
||||
MovePlacement = 'movePlacement',
|
||||
UpdatePlacementPosition = 'updatePlacementPosition',
|
||||
UpdatePlacementRotation = 'updatePlacementRotation',
|
||||
FlipPlacement = 'flipPlacement',
|
||||
SetPlacementFace = 'setPlacementFace',
|
||||
SwapPlacements = 'swapPlacements',
|
||||
|
||||
// Game actions
|
||||
SetPhase = 'setPhase',
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令步骤
|
||||
*/
|
||||
export interface CommandStep {
|
||||
action: CommandActionType;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令执行结果
|
||||
*/
|
||||
export interface CommandExecutionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
executedSteps: number;
|
||||
totalSteps: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令定义
|
||||
*/
|
||||
export interface Command {
|
||||
/** 命令唯一标识 */
|
||||
id: string;
|
||||
/** 命令名称 */
|
||||
name: string;
|
||||
/** 命令描述 */
|
||||
description?: string;
|
||||
/** 命令步骤 */
|
||||
steps: CommandStep[];
|
||||
/** 元数据 */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令日志条目
|
||||
*/
|
||||
export interface CommandLogEntry {
|
||||
/** 时间戳 */
|
||||
timestamp: number;
|
||||
/** 命令 ID */
|
||||
commandId: string;
|
||||
/** 命令名称 */
|
||||
commandName: string;
|
||||
/** 执行结果 */
|
||||
result: CommandExecutionResult;
|
||||
/** 执行的步骤详情 */
|
||||
stepResults: StepResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤执行结果
|
||||
*/
|
||||
export interface StepResult {
|
||||
stepIndex: number;
|
||||
action: CommandActionType;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令状态
|
||||
*/
|
||||
export enum CommandStatus {
|
||||
Pending = 'pending',
|
||||
Executing = 'executing',
|
||||
Completed = 'completed',
|
||||
Failed = 'failed',
|
||||
}
|
||||
|
||||
/**
|
||||
* 待执行命令
|
||||
*/
|
||||
export interface QueuedCommand {
|
||||
id: string;
|
||||
command: Command;
|
||||
status: CommandStatus;
|
||||
queuedAt: number;
|
||||
executedAt?: number;
|
||||
result?: CommandExecutionResult;
|
||||
}
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
import type { GameState } from '../core/GameState';
|
||||
import { PartType } from '../core/Part';
|
||||
import { RegionType } from '../core/Region';
|
||||
import type { Command, CommandStep, CommandExecutionResult, StepResult, CommandActionType } from './Command';
|
||||
import {
|
||||
createMeepleAction,
|
||||
createCardAction,
|
||||
createTileAction,
|
||||
updatePartAction,
|
||||
removePartAction,
|
||||
} from '../actions/part.actions';
|
||||
import {
|
||||
createRegionAction,
|
||||
removeRegionAction,
|
||||
addPlacementToRegionAction,
|
||||
removePlacementFromRegionAction,
|
||||
setSlotAction,
|
||||
clearRegionAction,
|
||||
} from '../actions/region.actions';
|
||||
import {
|
||||
createPlacementAction,
|
||||
removePlacementAction,
|
||||
movePlacementAction,
|
||||
updatePlacementPositionAction,
|
||||
updatePlacementRotationAction,
|
||||
flipPlacementAction,
|
||||
setPlacementFaceAction,
|
||||
swapPlacementsAction,
|
||||
} from '../actions/placement.actions';
|
||||
|
||||
/**
|
||||
* 命令执行器
|
||||
* 负责解析并执行命令中的每一步
|
||||
*/
|
||||
export class CommandExecutor {
|
||||
private gameState: GameState;
|
||||
|
||||
constructor(gameState: GameState) {
|
||||
this.gameState = gameState;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令
|
||||
*/
|
||||
execute(command: Command): CommandExecutionResult {
|
||||
const stepResults: StepResult[] = [];
|
||||
let hasError = false;
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
for (let i = 0; i < command.steps.length; i++) {
|
||||
const step = command.steps[i];
|
||||
const stepResult = this.executeStep(step, i);
|
||||
stepResults.push(stepResult);
|
||||
|
||||
if (!stepResult.success) {
|
||||
hasError = true;
|
||||
errorMessage = stepResult.error;
|
||||
break; // 遇到错误时停止执行
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: !hasError,
|
||||
error: errorMessage,
|
||||
executedSteps: stepResults.filter((s) => s.success).length,
|
||||
totalSteps: command.steps.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个步骤
|
||||
*/
|
||||
private executeStep(step: CommandStep, stepIndex: number): StepResult {
|
||||
try {
|
||||
const { action, params } = step;
|
||||
|
||||
switch (action) {
|
||||
// Part actions
|
||||
case 'createMeeple':
|
||||
this.handleCreateMeeple(params);
|
||||
break;
|
||||
case 'createCard':
|
||||
this.handleCreateCard(params);
|
||||
break;
|
||||
case 'createTile':
|
||||
this.handleCreateTile(params);
|
||||
break;
|
||||
case 'updatePart':
|
||||
this.handleUpdatePart(params);
|
||||
break;
|
||||
case 'removePart':
|
||||
this.handleRemovePart(params);
|
||||
break;
|
||||
|
||||
// Region actions
|
||||
case 'createRegion':
|
||||
this.handleCreateRegion(params);
|
||||
break;
|
||||
case 'removeRegion':
|
||||
this.handleRemoveRegion(params);
|
||||
break;
|
||||
case 'addPlacementToRegion':
|
||||
this.handleAddPlacementToRegion(params);
|
||||
break;
|
||||
case 'removePlacementFromRegion':
|
||||
this.handleRemovePlacementFromRegion(params);
|
||||
break;
|
||||
case 'setSlot':
|
||||
this.handleSetSlot(params);
|
||||
break;
|
||||
case 'clearRegion':
|
||||
this.handleClearRegion(params);
|
||||
break;
|
||||
|
||||
// Placement actions
|
||||
case 'createPlacement':
|
||||
this.handleCreatePlacement(params);
|
||||
break;
|
||||
case 'removePlacement':
|
||||
this.handleRemovePlacement(params);
|
||||
break;
|
||||
case 'movePlacement':
|
||||
this.handleMovePlacement(params);
|
||||
break;
|
||||
case 'updatePlacementPosition':
|
||||
this.handleUpdatePlacementPosition(params);
|
||||
break;
|
||||
case 'updatePlacementRotation':
|
||||
this.handleUpdatePlacementRotation(params);
|
||||
break;
|
||||
case 'flipPlacement':
|
||||
this.handleFlipPlacement(params);
|
||||
break;
|
||||
case 'setPlacementFace':
|
||||
this.handleSetPlacementFace(params);
|
||||
break;
|
||||
case 'swapPlacements':
|
||||
this.handleSwapPlacements(params);
|
||||
break;
|
||||
|
||||
// Game actions
|
||||
case 'setPhase':
|
||||
this.handleSetPhase(params);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${action}`);
|
||||
}
|
||||
|
||||
return {
|
||||
stepIndex,
|
||||
action,
|
||||
success: true,
|
||||
params,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
stepIndex,
|
||||
action: step.action,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
params: step.params,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Part action handlers ==========
|
||||
|
||||
private handleCreateMeeple(params: Record<string, unknown>): void {
|
||||
const { id, color, name, metadata } = params;
|
||||
createMeepleAction(this.gameState, id as string, color as string, {
|
||||
name: name as string,
|
||||
metadata: metadata as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
private handleCreateCard(params: Record<string, unknown>): void {
|
||||
const { id, suit, value, name, metadata } = params;
|
||||
createCardAction(this.gameState, id as string, {
|
||||
suit: suit as string,
|
||||
value: value as number | string,
|
||||
name: name as string,
|
||||
metadata: metadata as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
private handleCreateTile(params: Record<string, unknown>): void {
|
||||
const { id, pattern, rotation, name, metadata } = params;
|
||||
createTileAction(this.gameState, id as string, {
|
||||
pattern: pattern as string,
|
||||
rotation: rotation as number,
|
||||
name: name as string,
|
||||
metadata: metadata as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
private handleUpdatePart(params: Record<string, unknown>): void {
|
||||
const { partId, updates } = params;
|
||||
updatePartAction(this.gameState, partId as string, updates as Record<string, unknown>);
|
||||
}
|
||||
|
||||
private handleRemovePart(params: Record<string, unknown>): void {
|
||||
const { partId } = params;
|
||||
removePartAction(this.gameState, partId as string);
|
||||
}
|
||||
|
||||
// ========== Region action handlers ==========
|
||||
|
||||
private handleCreateRegion(params: Record<string, unknown>): void {
|
||||
const { id, type, name, capacity, metadata } = params;
|
||||
createRegionAction(this.gameState, {
|
||||
id: id as string,
|
||||
type: type as RegionType,
|
||||
name: name as string,
|
||||
capacity: capacity as number,
|
||||
metadata: metadata as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
private handleRemoveRegion(params: Record<string, unknown>): void {
|
||||
const { regionId } = params;
|
||||
removeRegionAction(this.gameState, regionId as string);
|
||||
}
|
||||
|
||||
private handleAddPlacementToRegion(params: Record<string, unknown>): void {
|
||||
const { regionId, placementId } = params;
|
||||
addPlacementToRegionAction(this.gameState, regionId as string, placementId as string);
|
||||
}
|
||||
|
||||
private handleRemovePlacementFromRegion(params: Record<string, unknown>): void {
|
||||
const { regionId, placementId } = params;
|
||||
removePlacementFromRegionAction(this.gameState, regionId as string, placementId as string);
|
||||
}
|
||||
|
||||
private handleSetSlot(params: Record<string, unknown>): void {
|
||||
const { regionId, key, placementId } = params;
|
||||
setSlotAction(this.gameState, regionId as string, key as string, placementId as string | null);
|
||||
}
|
||||
|
||||
private handleClearRegion(params: Record<string, unknown>): void {
|
||||
const { regionId } = params;
|
||||
clearRegionAction(this.gameState, regionId as string);
|
||||
}
|
||||
|
||||
// ========== Placement action handlers ==========
|
||||
|
||||
private handleCreatePlacement(params: Record<string, unknown>): void {
|
||||
const { id, partId, regionId, position, rotation, faceUp, metadata } = params;
|
||||
createPlacementAction(this.gameState, {
|
||||
id: id as string,
|
||||
partId: partId as string,
|
||||
regionId: regionId as string,
|
||||
position: position as { x: number; y: number },
|
||||
rotation: rotation as number,
|
||||
faceUp: faceUp as boolean,
|
||||
metadata: metadata as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
private handleRemovePlacement(params: Record<string, unknown>): void {
|
||||
const { placementId } = params;
|
||||
removePlacementAction(this.gameState, placementId as string);
|
||||
}
|
||||
|
||||
private handleMovePlacement(params: Record<string, unknown>): void {
|
||||
const { placementId, targetRegionId, key } = params;
|
||||
movePlacementAction(this.gameState, placementId as string, targetRegionId as string, key as string);
|
||||
}
|
||||
|
||||
private handleUpdatePlacementPosition(params: Record<string, unknown>): void {
|
||||
const { placementId, position } = params;
|
||||
updatePlacementPositionAction(this.gameState, placementId as string, position as { x: number; y: number });
|
||||
}
|
||||
|
||||
private handleUpdatePlacementRotation(params: Record<string, unknown>): void {
|
||||
const { placementId, rotation } = params;
|
||||
updatePlacementRotationAction(this.gameState, placementId as string, rotation as number);
|
||||
}
|
||||
|
||||
private handleFlipPlacement(params: Record<string, unknown>): void {
|
||||
const { placementId } = params;
|
||||
flipPlacementAction(this.gameState, placementId as string);
|
||||
}
|
||||
|
||||
private handleSetPlacementFace(params: Record<string, unknown>): void {
|
||||
const { placementId, faceUp } = params;
|
||||
setPlacementFaceAction(this.gameState, placementId as string, faceUp as boolean);
|
||||
}
|
||||
|
||||
private handleSwapPlacements(params: Record<string, unknown>): void {
|
||||
const { placementId1, placementId2 } = params;
|
||||
swapPlacementsAction(this.gameState, placementId1 as string, placementId2 as string);
|
||||
}
|
||||
|
||||
// ========== Game action handlers ==========
|
||||
|
||||
private handleSetPhase(params: Record<string, unknown>): void {
|
||||
const { phase } = params;
|
||||
this.gameState.setPhase(phase as string);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
import { signal, Signal } from '@preact/signals-core';
|
||||
import type { Command, CommandLogEntry, CommandExecutionResult, StepResult, QueuedCommand } from './Command';
|
||||
import { CommandStatus } from './Command';
|
||||
|
||||
/**
|
||||
* 命令日志过滤器
|
||||
*/
|
||||
export interface CommandLogFilter {
|
||||
commandId?: string;
|
||||
success?: boolean;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令日志类
|
||||
* 记录所有执行的命令及其结果
|
||||
*/
|
||||
export class CommandLog {
|
||||
/** 日志条目信号 */
|
||||
private entries: Signal<CommandLogEntry[]>;
|
||||
|
||||
/** 待执行队列 */
|
||||
private queue: QueuedCommand[];
|
||||
|
||||
constructor() {
|
||||
this.entries = signal<CommandLogEntry[]>([]);
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录命令执行
|
||||
*/
|
||||
log(
|
||||
command: Command,
|
||||
result: CommandExecutionResult,
|
||||
stepResults: StepResult[]
|
||||
): void {
|
||||
const entry: CommandLogEntry = {
|
||||
timestamp: Date.now(),
|
||||
commandId: command.id,
|
||||
commandName: command.name,
|
||||
result,
|
||||
stepResults,
|
||||
};
|
||||
|
||||
const current = this.entries.value;
|
||||
this.entries.value = [...current, entry];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有日志条目
|
||||
*/
|
||||
getEntries(): CommandLogEntry[] {
|
||||
return this.entries.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志信号
|
||||
*/
|
||||
getEntriesSignal(): Signal<CommandLogEntry[]> {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据过滤器获取日志条目
|
||||
*/
|
||||
getFilteredEntries(filter: CommandLogFilter): CommandLogEntry[] {
|
||||
return this.entries.value.filter((entry) => {
|
||||
if (filter.commandId && entry.commandId !== filter.commandId) {
|
||||
return false;
|
||||
}
|
||||
if (filter.success !== undefined && entry.result.success !== filter.success) {
|
||||
return false;
|
||||
}
|
||||
if (filter.startTime && entry.timestamp < filter.startTime) {
|
||||
return false;
|
||||
}
|
||||
if (filter.endTime && entry.timestamp > filter.endTime) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取命令的执行历史
|
||||
*/
|
||||
getCommandHistory(commandId: string): CommandLogEntry[] {
|
||||
return this.getFilteredEntries({ commandId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取失败的命令
|
||||
*/
|
||||
getFailedCommands(): CommandLogEntry[] {
|
||||
return this.getFilteredEntries({ success: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成功的命令
|
||||
*/
|
||||
getSuccessfulCommands(): CommandLogEntry[] {
|
||||
return this.getFilteredEntries({ success: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
clear(): void {
|
||||
this.entries.value = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出日志为 JSON
|
||||
*/
|
||||
exportToJson(): string {
|
||||
return JSON.stringify(this.entries.value, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志条目数量
|
||||
*/
|
||||
getCount(): number {
|
||||
return this.entries.value.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后一个日志条目
|
||||
*/
|
||||
getLastEntry(): CommandLogEntry | null {
|
||||
const entries = this.entries.value;
|
||||
return entries.length > 0 ? entries[entries.length - 1] : null;
|
||||
}
|
||||
|
||||
// ========== 队列管理 ==========
|
||||
|
||||
/**
|
||||
* 添加命令到队列
|
||||
*/
|
||||
enqueue(command: Command): QueuedCommand {
|
||||
const queued: QueuedCommand = {
|
||||
id: command.id,
|
||||
command,
|
||||
status: CommandStatus.Pending,
|
||||
queuedAt: Date.now(),
|
||||
};
|
||||
this.queue.push(queued);
|
||||
return queued;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从队列中移除命令
|
||||
*/
|
||||
dequeue(): QueuedCommand | null {
|
||||
if (this.queue.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.queue.shift() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列中的所有命令
|
||||
*/
|
||||
getQueue(): QueuedCommand[] {
|
||||
return [...this.queue];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新队列中命令的状态
|
||||
*/
|
||||
updateQueueStatus(commandId: string, status: CommandStatus, result?: CommandExecutionResult): void {
|
||||
const index = this.queue.findIndex((q) => q.command.id === commandId);
|
||||
if (index !== -1) {
|
||||
this.queue[index].status = status;
|
||||
if (status === CommandStatus.Completed || status === CommandStatus.Failed) {
|
||||
this.queue[index].executedAt = Date.now();
|
||||
this.queue[index].result = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空队列
|
||||
*/
|
||||
clearQueue(): void {
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列长度
|
||||
*/
|
||||
getQueueLength(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建命令日志
|
||||
*/
|
||||
export function createCommandLog(): CommandLog {
|
||||
return new CommandLog();
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import type { ParsedCliCommand, CliCommandArgs } from './CliCommand';
|
||||
|
||||
/**
|
||||
* 命令解析错误
|
||||
*/
|
||||
export class CommandParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CommandParseError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI 命令解析器
|
||||
* 解析 CLI 风格的命令字符串
|
||||
*/
|
||||
export class CommandParser {
|
||||
/**
|
||||
* 解析命令字符串
|
||||
* @param input 命令字符串,如 "move card-1 discard --faceup=true"
|
||||
*/
|
||||
parse(input: string): ParsedCliCommand {
|
||||
const trimmed = input.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
throw new CommandParseError('Empty command');
|
||||
}
|
||||
|
||||
const tokens = this.tokenize(trimmed);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
throw new CommandParseError('No command found');
|
||||
}
|
||||
|
||||
const commandName = tokens[0];
|
||||
const args = this.parseArgs(tokens.slice(1));
|
||||
|
||||
return {
|
||||
commandName,
|
||||
args,
|
||||
raw: input,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将命令字符串分词
|
||||
*/
|
||||
private tokenize(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
let quoteChar = '';
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input[i];
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === quoteChar) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
inQuotes = false;
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
} else if (char === '"' || char === "'") {
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
} else if (char === ' ' || char === '\t') {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
if (inQuotes) {
|
||||
throw new CommandParseError('Unclosed quote in command');
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析参数
|
||||
*/
|
||||
private parseArgs(tokens: string[]): CliCommandArgs {
|
||||
const positional: string[] = [];
|
||||
const flags: Record<string, string | boolean> = {};
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.startsWith('--')) {
|
||||
// 长标志 --key=value 或 --flag
|
||||
const flagMatch = token.match(/^--([^=]+)(?:=(.+))?$/);
|
||||
if (flagMatch) {
|
||||
const [, key, value] = flagMatch;
|
||||
flags[key] = value !== undefined ? this.parseFlagValue(value) : true;
|
||||
}
|
||||
} else if (token.startsWith('-') && token.length === 2) {
|
||||
// 短标志 -f 或 -k=v
|
||||
const flagMatch = token.match(/^-([^=]+)(?:=(.+))?$/);
|
||||
if (flagMatch) {
|
||||
const [, key, value] = flagMatch;
|
||||
flags[key] = value !== undefined ? this.parseFlagValue(value) : true;
|
||||
}
|
||||
} else {
|
||||
// 位置参数
|
||||
positional.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return { positional, flags };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析标志值
|
||||
*/
|
||||
private parseFlagValue(value: string): string | boolean {
|
||||
// 布尔值
|
||||
if (value.toLowerCase() === 'true') return true;
|
||||
if (value.toLowerCase() === 'false') return false;
|
||||
|
||||
// 数字转换为字符串
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化命令用于显示
|
||||
*/
|
||||
static formatCommand(name: string, args?: CliCommandArgs): string {
|
||||
if (!args) {
|
||||
return name;
|
||||
}
|
||||
|
||||
const parts = [name];
|
||||
|
||||
// 添加位置参数
|
||||
parts.push(...args.positional);
|
||||
|
||||
// 添加标志参数
|
||||
for (const [key, value] of Object.entries(args.flags)) {
|
||||
if (value === true) {
|
||||
parts.push(`--${key}`);
|
||||
} else {
|
||||
parts.push(`--${key}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建命令解析器
|
||||
*/
|
||||
export function createCommandParser(): CommandParser {
|
||||
return new CommandParser();
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
import type { CliCommand, CliCommandArgs, CliCommandResult, CliCommandStep } from './CliCommand';
|
||||
import { CommandParser } from './CommandParser';
|
||||
|
||||
/**
|
||||
* 命令注册表
|
||||
* 注册和管理 CLI 命令
|
||||
*/
|
||||
export class CommandRegistry {
|
||||
private commands: Map<string, CliCommand>;
|
||||
private parser: CommandParser;
|
||||
|
||||
constructor() {
|
||||
this.commands = new Map();
|
||||
this.parser = new CommandParser();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册命令
|
||||
*/
|
||||
register(command: CliCommand): void {
|
||||
this.commands.set(command.name, command);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册多个命令
|
||||
*/
|
||||
registerAll(commands: CliCommand[]): void {
|
||||
for (const command of commands) {
|
||||
this.register(command);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取命令
|
||||
*/
|
||||
get(name: string): CliCommand | undefined {
|
||||
return this.commands.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查命令是否存在
|
||||
*/
|
||||
has(name: string): boolean {
|
||||
return this.commands.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除命令
|
||||
*/
|
||||
unregister(name: string): void {
|
||||
this.commands.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有命令
|
||||
*/
|
||||
getAll(): CliCommand[] {
|
||||
return Array.from(this.commands.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并执行命令
|
||||
*/
|
||||
execute(input: string): CliCommandResult {
|
||||
try {
|
||||
const parsed = this.parser.parse(input);
|
||||
const command = this.commands.get(parsed.commandName);
|
||||
|
||||
if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown command: ${parsed.commandName}`,
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
const validationError = this.validateArgs(command, parsed.args);
|
||||
if (validationError) {
|
||||
return {
|
||||
success: false,
|
||||
error: validationError,
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 执行命令处理器
|
||||
const steps = command.handler(parsed.args);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
steps,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证参数
|
||||
*/
|
||||
private validateArgs(command: CliCommand, args: CliCommandArgs): string | null {
|
||||
// 验证位置参数
|
||||
if (command.args) {
|
||||
for (const argDef of command.args) {
|
||||
const index = command.args.indexOf(argDef);
|
||||
|
||||
if (argDef.required && index >= args.positional.length) {
|
||||
return `Missing required argument: ${argDef.name}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证标志参数
|
||||
if (command.flags) {
|
||||
for (const flagDef of command.flags) {
|
||||
if (flagDef.required && !(flagDef.name in args.flags)) {
|
||||
// 检查别名
|
||||
const hasAlias = flagDef.alias && args.flags[flagDef.alias];
|
||||
if (!hasAlias) {
|
||||
return `Missing required flag: --${flagDef.name}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成帮助信息
|
||||
*/
|
||||
help(commandName?: string): string {
|
||||
if (commandName) {
|
||||
const command = this.commands.get(commandName);
|
||||
if (!command) {
|
||||
return `Unknown command: ${commandName}`;
|
||||
}
|
||||
return this.formatCommandHelp(command);
|
||||
}
|
||||
|
||||
// 所有命令的帮助
|
||||
const lines = ['Available commands:', ''];
|
||||
for (const command of this.commands.values()) {
|
||||
lines.push(` ${command.name.padEnd(15)} ${command.description}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Use "help <command>" for more information.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化单个命令的帮助
|
||||
*/
|
||||
private formatCommandHelp(command: CliCommand): string {
|
||||
const lines = [
|
||||
`Command: ${command.name}`,
|
||||
`Description: ${command.description}`,
|
||||
`Usage: ${command.usage}`,
|
||||
];
|
||||
|
||||
if (command.args && command.args.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Arguments:');
|
||||
for (const arg of command.args) {
|
||||
const required = arg.required ? '(required)' : '(optional)';
|
||||
lines.push(` ${arg.name.padEnd(15)} ${arg.description} ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (command.flags && command.flags.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Flags:');
|
||||
for (const flag of command.flags) {
|
||||
const alias = flag.alias ? `-${flag.alias}, ` : '';
|
||||
const required = flag.required ? '(required)' : '(optional)';
|
||||
const defaultVal = flag.default !== undefined ? `(default: ${flag.default})` : '';
|
||||
lines.push(` ${alias}--${flag.name.padEnd(12)} ${flag.description} ${required} ${defaultVal}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有命令
|
||||
*/
|
||||
clear(): void {
|
||||
this.commands.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取命令数量
|
||||
*/
|
||||
getCount(): number {
|
||||
return this.commands.size;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建命令注册表
|
||||
*/
|
||||
export function createCommandRegistry(): CommandRegistry {
|
||||
return new CommandRegistry();
|
||||
}
|
||||
|
|
@ -1,485 +0,0 @@
|
|||
import type { CliCommand } from './CliCommand';
|
||||
import { RegionType } from '../core/Region';
|
||||
|
||||
/**
|
||||
* CLI 命令定义集合
|
||||
*/
|
||||
|
||||
/**
|
||||
* move <placementId> <targetRegionId> [--key=slotKey]
|
||||
* 移动 Placement 到另一个区域
|
||||
*/
|
||||
export const moveCommand: CliCommand = {
|
||||
name: 'move',
|
||||
description: 'Move a placement to another region',
|
||||
usage: 'move <placementId> <targetRegionId> [--key=slotKey]',
|
||||
args: [
|
||||
{ name: 'placementId', description: 'The placement ID to move', required: true },
|
||||
{ name: 'targetRegionId', description: 'The target region ID', required: true },
|
||||
],
|
||||
flags: [
|
||||
{ name: 'key', description: 'Slot key for keyed regions', type: 'string' },
|
||||
],
|
||||
handler: (args) => {
|
||||
const [placementId, targetRegionId] = args.positional;
|
||||
return [
|
||||
{
|
||||
action: 'movePlacement',
|
||||
params: {
|
||||
placementId,
|
||||
targetRegionId,
|
||||
key: args.flags.key as string | undefined,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* place <partId> <regionId> [x] [y] [--rotation=0] [--faceup=true]
|
||||
* 创建 Placement 并放置到区域
|
||||
*/
|
||||
export const placeCommand: CliCommand = {
|
||||
name: 'place',
|
||||
description: 'Place a part in a region',
|
||||
usage: 'place <partId> <regionId> [x] [y] [--rotation=0] [--faceup=true]',
|
||||
args: [
|
||||
{ name: 'partId', description: 'The part ID to place', required: true },
|
||||
{ name: 'regionId', description: 'The region ID to place in', required: true },
|
||||
{ name: 'x', description: 'X position', default: '0' },
|
||||
{ name: 'y', description: 'Y position', default: '0' },
|
||||
],
|
||||
flags: [
|
||||
{ name: 'rotation', description: 'Rotation angle', type: 'number', default: '0' },
|
||||
{ name: 'faceup', description: 'Face up or down', type: 'boolean', default: 'true' },
|
||||
],
|
||||
handler: (args) => {
|
||||
const [partId, regionId] = args.positional;
|
||||
const x = parseInt(args.positional[2] || '0', 10);
|
||||
const y = parseInt(args.positional[3] || '0', 10);
|
||||
const rotation = typeof args.flags.rotation === 'string'
|
||||
? parseInt(args.flags.rotation, 10)
|
||||
: 0;
|
||||
|
||||
return [
|
||||
{
|
||||
action: 'createPlacement',
|
||||
params: {
|
||||
id: `placement-${partId}-${Date.now()}`,
|
||||
partId,
|
||||
regionId,
|
||||
position: { x, y },
|
||||
rotation,
|
||||
faceUp: args.flags.faceup === true || args.flags.faceup === 'true',
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* flip <placementId>
|
||||
* 翻转 Placement
|
||||
*/
|
||||
export const flipCommand: CliCommand = {
|
||||
name: 'flip',
|
||||
description: 'Flip a placement face up/down',
|
||||
usage: 'flip <placementId>',
|
||||
args: [
|
||||
{ name: 'placementId', description: 'The placement ID to flip', required: true },
|
||||
],
|
||||
handler: (args) => {
|
||||
const [placementId] = args.positional;
|
||||
return [
|
||||
{
|
||||
action: 'flipPlacement',
|
||||
params: { placementId },
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* create <type> <id> [options...]
|
||||
* 创建 Part(meeple/card/tile)
|
||||
*/
|
||||
export const createCommand: CliCommand = {
|
||||
name: 'create',
|
||||
description: 'Create a part (meeple, card, or tile)',
|
||||
usage: 'create <type> <id> [--color=color] [--suit=suit] [--value=value] [--pattern=pattern]',
|
||||
args: [
|
||||
{ name: 'type', description: 'Part type (meeple, card, tile)', required: true },
|
||||
{ name: 'id', description: 'Part ID', required: true },
|
||||
],
|
||||
flags: [
|
||||
{ name: 'color', description: 'Meeple color', type: 'string' },
|
||||
{ name: 'suit', description: 'Card suit', type: 'string' },
|
||||
{ name: 'value', description: 'Card value', type: 'string' },
|
||||
{ name: 'pattern', description: 'Tile pattern', type: 'string' },
|
||||
{ name: 'rotation', description: 'Tile rotation', type: 'number' },
|
||||
{ name: 'name', description: 'Part name', type: 'string' },
|
||||
],
|
||||
handler: (args) => {
|
||||
const [type, id] = args.positional;
|
||||
const steps = [];
|
||||
|
||||
if (type === 'meeple') {
|
||||
steps.push({
|
||||
action: 'createMeeple',
|
||||
params: {
|
||||
id,
|
||||
color: (args.flags.color as string) || 'red',
|
||||
name: args.flags.name as string,
|
||||
},
|
||||
});
|
||||
} else if (type === 'card') {
|
||||
steps.push({
|
||||
action: 'createCard',
|
||||
params: {
|
||||
id,
|
||||
suit: args.flags.suit as string,
|
||||
value: args.flags.value as string,
|
||||
name: args.flags.name as string,
|
||||
},
|
||||
});
|
||||
} else if (type === 'tile') {
|
||||
const rotation = typeof args.flags.rotation === 'string'
|
||||
? parseInt(args.flags.rotation, 10)
|
||||
: 0;
|
||||
steps.push({
|
||||
action: 'createTile',
|
||||
params: {
|
||||
id,
|
||||
pattern: args.flags.pattern as string,
|
||||
rotation,
|
||||
name: args.flags.name as string,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unknown part type: ${type}`);
|
||||
}
|
||||
|
||||
return steps;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* region <id> <type> [--name=name] [--capacity=n]
|
||||
* 创建 Region
|
||||
*/
|
||||
export const regionCommand: CliCommand = {
|
||||
name: 'region',
|
||||
description: 'Create a region',
|
||||
usage: 'region <id> <type> [--name=name] [--capacity=n]',
|
||||
args: [
|
||||
{ name: 'id', description: 'Region ID', required: true },
|
||||
{ name: 'type', description: 'Region type (keyed/unkeyed)', required: true },
|
||||
],
|
||||
flags: [
|
||||
{ name: 'name', description: 'Region name', type: 'string' },
|
||||
{ name: 'capacity', description: 'Maximum capacity', type: 'number' },
|
||||
],
|
||||
handler: (args) => {
|
||||
const [id, type] = args.positional;
|
||||
const capacity = typeof args.flags.capacity === 'string'
|
||||
? parseInt(args.flags.capacity, 10)
|
||||
: undefined;
|
||||
return [
|
||||
{
|
||||
action: 'createRegion',
|
||||
params: {
|
||||
id,
|
||||
type: type.toLowerCase() === 'keyed' ? RegionType.Keyed : RegionType.Unkeyed,
|
||||
name: args.flags.name as string,
|
||||
capacity,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* draw <deckId> [count] [--to=handId]
|
||||
* 从牌库抽牌
|
||||
*/
|
||||
export const drawCommand: CliCommand = {
|
||||
name: 'draw',
|
||||
description: 'Draw cards from a deck',
|
||||
usage: 'draw <deckId> [count] [--to=handId]',
|
||||
args: [
|
||||
{ name: 'deckId', description: 'Source deck/region ID', required: true },
|
||||
{ name: 'count', description: 'Number of cards to draw', default: '1' },
|
||||
],
|
||||
flags: [
|
||||
{ name: 'to', description: 'Target hand region ID', type: 'string', default: 'hand' },
|
||||
],
|
||||
handler: (args) => {
|
||||
// 注意:这是一个简化版本,实际抽牌需要更复杂的逻辑
|
||||
const [deckId] = args.positional;
|
||||
const count = parseInt(args.positional[1] || '1', 10);
|
||||
const targetHand = args.flags.to as string;
|
||||
|
||||
const steps = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
steps.push({
|
||||
action: 'createCard',
|
||||
params: {
|
||||
id: `card-${deckId}-${Date.now()}-${i}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* shuffle <regionId> [--seed=number]
|
||||
* 洗牌
|
||||
*/
|
||||
export const shuffleCommand: CliCommand = {
|
||||
name: 'shuffle',
|
||||
description: 'Shuffle placements in a region',
|
||||
usage: 'shuffle <regionId> [--seed=number]',
|
||||
args: [
|
||||
{ name: 'regionId', description: 'Region ID to shuffle', required: true },
|
||||
],
|
||||
flags: [
|
||||
{ name: 'seed', description: 'Random seed for reproducibility', type: 'number' },
|
||||
],
|
||||
handler: (args) => {
|
||||
// shuffle 命令需要特殊的执行逻辑,这里返回一个标记步骤
|
||||
// 实际执行时需要在 CommandExecutor 中特殊处理
|
||||
const [regionId] = args.positional;
|
||||
const seed = typeof args.flags.seed === 'string'
|
||||
? parseInt(args.flags.seed, 10)
|
||||
: undefined;
|
||||
return [
|
||||
{
|
||||
action: 'shuffleRegion',
|
||||
params: {
|
||||
regionId,
|
||||
seed,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* discard <placementId> [--to=discardId]
|
||||
* 将 Placement 移到弃牌堆
|
||||
*/
|
||||
export const discardCommand: CliCommand = {
|
||||
name: 'discard',
|
||||
description: 'Move a placement to discard pile',
|
||||
usage: 'discard <placementId> [--to=discardId]',
|
||||
args: [
|
||||
{ name: 'placementId', description: 'Placement ID to discard', required: true },
|
||||
],
|
||||
flags: [
|
||||
{ name: 'to', description: 'Discard region ID', type: 'string', default: 'discard' },
|
||||
],
|
||||
handler: (args) => {
|
||||
const [placementId] = args.positional;
|
||||
const discardId = args.flags.to as string;
|
||||
return [
|
||||
{
|
||||
action: 'movePlacement',
|
||||
params: {
|
||||
placementId,
|
||||
targetRegionId: discardId,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* swap <placementId1> <placementId2>
|
||||
* 交换两个 Placement
|
||||
*/
|
||||
export const swapCommand: CliCommand = {
|
||||
name: 'swap',
|
||||
description: 'Swap two placements',
|
||||
usage: 'swap <placementId1> <placementId2>',
|
||||
args: [
|
||||
{ name: 'placementId1', description: 'First placement ID', required: true },
|
||||
{ name: 'placementId2', description: 'Second placement ID', required: true },
|
||||
],
|
||||
handler: (args) => {
|
||||
const [placementId1, placementId2] = args.positional;
|
||||
return [
|
||||
{
|
||||
action: 'swapPlacements',
|
||||
params: { placementId1, placementId2 },
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* rotate <placementId> <degrees>
|
||||
* 旋转 Placement
|
||||
*/
|
||||
export const rotateCommand: CliCommand = {
|
||||
name: 'rotate',
|
||||
description: 'Rotate a placement',
|
||||
usage: 'rotate <placementId> <degrees>',
|
||||
args: [
|
||||
{ name: 'placementId', description: 'Placement ID to rotate', required: true },
|
||||
{ name: 'degrees', description: 'Rotation angle in degrees', required: true },
|
||||
],
|
||||
handler: (args) => {
|
||||
const [placementId, degreesStr] = args.positional;
|
||||
const degrees = parseInt(degreesStr, 10);
|
||||
return [
|
||||
{
|
||||
action: 'updatePlacementRotation',
|
||||
params: {
|
||||
placementId,
|
||||
rotation: degrees,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* position <placementId> <x> <y>
|
||||
* 设置 Placement 位置
|
||||
*/
|
||||
export const positionCommand: CliCommand = {
|
||||
name: 'position',
|
||||
description: 'Set placement position',
|
||||
usage: 'position <placementId> <x> <y>',
|
||||
args: [
|
||||
{ name: 'placementId', description: 'Placement ID', required: true },
|
||||
{ name: 'x', description: 'X coordinate', required: true },
|
||||
{ name: 'y', description: 'Y coordinate', required: true },
|
||||
],
|
||||
handler: (args) => {
|
||||
const [placementId, xStr, yStr] = args.positional;
|
||||
return [
|
||||
{
|
||||
action: 'updatePlacementPosition',
|
||||
params: {
|
||||
placementId,
|
||||
position: {
|
||||
x: parseInt(xStr, 10),
|
||||
y: parseInt(yStr, 10),
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* phase <phaseName>
|
||||
* 设置游戏阶段
|
||||
*/
|
||||
export const phaseCommand: CliCommand = {
|
||||
name: 'phase',
|
||||
description: 'Set game phase',
|
||||
usage: 'phase <phaseName>',
|
||||
args: [
|
||||
{ name: 'phaseName', description: 'New phase name', required: true },
|
||||
],
|
||||
handler: (args) => {
|
||||
const [phase] = args.positional;
|
||||
return [
|
||||
{
|
||||
action: 'setPhase',
|
||||
params: { phase },
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* clear <regionId>
|
||||
* 清空区域
|
||||
*/
|
||||
export const clearCommand: CliCommand = {
|
||||
name: 'clear',
|
||||
description: 'Clear all placements from a region',
|
||||
usage: 'clear <regionId>',
|
||||
args: [
|
||||
{ name: 'regionId', description: 'Region ID to clear', required: true },
|
||||
],
|
||||
handler: (args) => {
|
||||
const [regionId] = args.positional;
|
||||
return [
|
||||
{
|
||||
action: 'clearRegion',
|
||||
params: { regionId },
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* remove <type> <id>
|
||||
* 移除 Part/Placement/Region
|
||||
*/
|
||||
export const removeCommand: CliCommand = {
|
||||
name: 'remove',
|
||||
description: 'Remove a part, placement, or region',
|
||||
usage: 'remove <type> <id>',
|
||||
args: [
|
||||
{ name: 'type', description: 'Type (part/placement/region)', required: true },
|
||||
{ name: 'id', description: 'ID to remove', required: true },
|
||||
],
|
||||
handler: (args) => {
|
||||
const [type, id] = args.positional;
|
||||
|
||||
if (type === 'part') {
|
||||
return [{ action: 'removePart', params: { partId: id } }];
|
||||
} else if (type === 'placement') {
|
||||
return [{ action: 'removePlacement', params: { placementId: id } }];
|
||||
} else if (type === 'region') {
|
||||
return [{ action: 'removeRegion', params: { regionId: id } }];
|
||||
} else {
|
||||
throw new Error(`Unknown type: ${type}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* help [command]
|
||||
* 显示帮助信息
|
||||
*/
|
||||
export const helpCommand: CliCommand = {
|
||||
name: 'help',
|
||||
description: 'Show help information',
|
||||
usage: 'help [command]',
|
||||
args: [
|
||||
{ name: 'command', description: 'Command name to get help for', required: false },
|
||||
],
|
||||
handler: () => {
|
||||
// help 命令由 CommandRegistry 特殊处理
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 所有 CLI 命令
|
||||
*/
|
||||
export const cliCommands: CliCommand[] = [
|
||||
moveCommand,
|
||||
placeCommand,
|
||||
flipCommand,
|
||||
createCommand,
|
||||
regionCommand,
|
||||
drawCommand,
|
||||
shuffleCommand,
|
||||
discardCommand,
|
||||
swapCommand,
|
||||
rotateCommand,
|
||||
positionCommand,
|
||||
phaseCommand,
|
||||
clearCommand,
|
||||
removeCommand,
|
||||
helpCommand,
|
||||
];
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
import type { Command } from './Command';
|
||||
import { CommandActionType } from './Command';
|
||||
import { RegionType } from '../core/Region';
|
||||
|
||||
/**
|
||||
* 内置命令集合
|
||||
*/
|
||||
|
||||
/**
|
||||
* 设置游戏:创建基础区域
|
||||
*/
|
||||
export const setupGameCommand: Command = {
|
||||
id: 'setup-game',
|
||||
name: 'Setup Game',
|
||||
description: 'Initialize the game with basic regions',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.CreateRegion,
|
||||
params: {
|
||||
id: 'board',
|
||||
type: RegionType.Keyed,
|
||||
name: 'Game Board',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: CommandActionType.CreateRegion,
|
||||
params: {
|
||||
id: 'supply',
|
||||
type: RegionType.Unkeyed,
|
||||
name: 'Supply',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: CommandActionType.CreateRegion,
|
||||
params: {
|
||||
id: 'discard',
|
||||
type: RegionType.Unkeyed,
|
||||
name: 'Discard Pile',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 放置棋子命令
|
||||
*/
|
||||
export const placeMeepleCommand: Command = {
|
||||
id: 'place-meeple',
|
||||
name: 'Place Meeple',
|
||||
description: 'Place a meeple on the board',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.CreateMeeple,
|
||||
params: {
|
||||
id: '${meepleId}',
|
||||
color: '${color}',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: CommandActionType.CreatePlacement,
|
||||
params: {
|
||||
id: '${placementId}',
|
||||
partId: '${meepleId}',
|
||||
regionId: 'board',
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 移动棋子命令
|
||||
*/
|
||||
export const moveMeepleCommand: Command = {
|
||||
id: 'move-meeple',
|
||||
name: 'Move Meeple',
|
||||
description: 'Move a meeple to a new position',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.UpdatePlacementPosition,
|
||||
params: {
|
||||
placementId: '${placementId}',
|
||||
position: { x: '${x}', y: '${y}' },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 抽牌命令
|
||||
*/
|
||||
export const drawCardCommand: Command = {
|
||||
id: 'draw-card',
|
||||
name: 'Draw Card',
|
||||
description: 'Draw a card from the deck',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.CreateRegion,
|
||||
params: {
|
||||
id: 'hand',
|
||||
type: RegionType.Unkeyed,
|
||||
name: 'Hand',
|
||||
capacity: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: CommandActionType.CreateCard,
|
||||
params: {
|
||||
id: '${cardId}',
|
||||
suit: '${suit}',
|
||||
value: '${value}',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: CommandActionType.CreatePlacement,
|
||||
params: {
|
||||
id: '${placementId}',
|
||||
partId: '${cardId}',
|
||||
regionId: 'hand',
|
||||
faceUp: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: CommandActionType.AddPlacementToRegion,
|
||||
params: {
|
||||
regionId: 'hand',
|
||||
placementId: '${placementId}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 出牌命令
|
||||
*/
|
||||
export const playCardCommand: Command = {
|
||||
id: 'play-card',
|
||||
name: 'Play Card',
|
||||
description: 'Play a card from hand',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.SetPlacementFace,
|
||||
params: {
|
||||
placementId: '${placementId}',
|
||||
faceUp: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: CommandActionType.MovePlacement,
|
||||
params: {
|
||||
placementId: '${placementId}',
|
||||
targetRegionId: 'discard',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 放置板块命令
|
||||
*/
|
||||
export const placeTileCommand: Command = {
|
||||
id: 'place-tile',
|
||||
name: 'Place Tile',
|
||||
description: 'Place a tile on the board',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.CreateTile,
|
||||
params: {
|
||||
id: '${tileId}',
|
||||
pattern: '${pattern}',
|
||||
rotation: '${rotation}',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: CommandActionType.CreatePlacement,
|
||||
params: {
|
||||
id: '${placementId}',
|
||||
partId: '${tileId}',
|
||||
regionId: 'board',
|
||||
position: { x: '${x}', y: '${y}' },
|
||||
rotation: '${rotation}',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: CommandActionType.SetSlot,
|
||||
params: {
|
||||
regionId: 'board',
|
||||
key: '${slotKey}',
|
||||
placementId: '${placementId}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 翻转板块命令
|
||||
*/
|
||||
export const flipTileCommand: Command = {
|
||||
id: 'flip-tile',
|
||||
name: 'Flip Tile',
|
||||
description: 'Flip a tile face down or face up',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.FlipPlacement,
|
||||
params: {
|
||||
placementId: '${placementId}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 交换位置命令
|
||||
*/
|
||||
export const swapPlacementsCommand: Command = {
|
||||
id: 'swap-placements',
|
||||
name: 'Swap Placements',
|
||||
description: 'Swap two placements in the same region',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.SwapPlacements,
|
||||
params: {
|
||||
placementId1: '${placementId1}',
|
||||
placementId2: '${placementId2}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置游戏阶段
|
||||
*/
|
||||
export const setPhaseCommand: Command = {
|
||||
id: 'set-phase',
|
||||
name: 'Set Phase',
|
||||
description: 'Set the current game phase',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.SetPhase,
|
||||
params: {
|
||||
phase: '${phase}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空区域命令
|
||||
*/
|
||||
export const clearRegionCommand: Command = {
|
||||
id: 'clear-region',
|
||||
name: 'Clear Region',
|
||||
description: 'Clear all placements from a region',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.ClearRegion,
|
||||
params: {
|
||||
regionId: '${regionId}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 所有内置命令
|
||||
*/
|
||||
export const defaultCommands: Command[] = [
|
||||
setupGameCommand,
|
||||
placeMeepleCommand,
|
||||
moveMeepleCommand,
|
||||
drawCardCommand,
|
||||
playCardCommand,
|
||||
placeTileCommand,
|
||||
flipTileCommand,
|
||||
swapPlacementsCommand,
|
||||
setPhaseCommand,
|
||||
clearRegionCommand,
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据 ID 获取内置命令
|
||||
*/
|
||||
export function getDefaultCommand(commandId: string): Command | undefined {
|
||||
return defaultCommands.find((cmd) => cmd.id === commandId);
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ export const GameContext = createModel((root: Context) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
parts,
|
||||
regions,
|
||||
|
|
@ -35,4 +35,9 @@ export const GameContext = createModel((root: Context) => {
|
|||
popContext,
|
||||
latestContext,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/** 创建游戏上下文实例 */
|
||||
export function createGameContext(root: Context = { type: 'game' }) {
|
||||
return new GameContext(root);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import {RNG} from "../utils/rng";
|
|||
export type Region = Entity & {
|
||||
// aligning axes of the region
|
||||
axes: RegionAxis[];
|
||||
|
||||
|
||||
// current children; expect no overlapped positions
|
||||
children: EntityAccessor<Part>[];
|
||||
}
|
||||
|
|
@ -27,7 +27,49 @@ export type RegionAxis = {
|
|||
*/
|
||||
export function applyAlign(region: Region){
|
||||
for (const axis of region.axes) {
|
||||
// TODO implement this
|
||||
if (region.children.length === 0) continue;
|
||||
|
||||
// 获取当前轴向上的所有位置
|
||||
const positions = region.children.map(accessor => accessor.value.position);
|
||||
|
||||
// 根据当前轴的位置排序 children
|
||||
region.children.sort((a, b) => {
|
||||
const posA = a.value.position[0] ?? 0;
|
||||
const posB = b.value.position[0] ?? 0;
|
||||
return posA - posB;
|
||||
});
|
||||
|
||||
if (axis.align === 'start' && axis.min !== undefined) {
|
||||
// 从 min 开始紧凑排列
|
||||
region.children.forEach((accessor, index) => {
|
||||
const currentPos = accessor.value.position.slice();
|
||||
currentPos[0] = axis.min! + index;
|
||||
accessor.value.position = currentPos;
|
||||
});
|
||||
} else if (axis.align === 'end' && axis.max !== undefined) {
|
||||
// 从 max 开始向前紧凑排列
|
||||
const count = region.children.length;
|
||||
region.children.forEach((accessor, index) => {
|
||||
const currentPos = accessor.value.position.slice();
|
||||
currentPos[0] = axis.max! - (count - 1 - index);
|
||||
accessor.value.position = currentPos;
|
||||
});
|
||||
} else if (axis.align === 'center') {
|
||||
// 居中排列
|
||||
const count = region.children.length;
|
||||
const min = axis.min ?? 0;
|
||||
const max = axis.max ?? count - 1;
|
||||
const range = max - min;
|
||||
const center = min + range / 2;
|
||||
|
||||
region.children.forEach((accessor, index) => {
|
||||
const currentPos = accessor.value.position.slice();
|
||||
// 计算相对于中心的偏移
|
||||
const offset = index - (count - 1) / 2;
|
||||
currentPos[0] = center + offset;
|
||||
accessor.value.position = currentPos;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -37,5 +79,17 @@ export function applyAlign(region: Region){
|
|||
* @param rng
|
||||
*/
|
||||
export function shuffle(region: Region, rng: RNG){
|
||||
// TODO implement this
|
||||
}
|
||||
if (region.children.length <= 1) return;
|
||||
|
||||
// Fisher-Yates 洗牌算法
|
||||
const children = [...region.children];
|
||||
for (let i = children.length - 1; i > 0; i--) {
|
||||
const j = rng.nextInt(i + 1);
|
||||
// 交换位置
|
||||
const posI = children[i].value.position.slice();
|
||||
const posJ = children[j].value.position.slice();
|
||||
|
||||
children[i].value.position = posJ;
|
||||
children[j].value.position = posI;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,24 +9,80 @@ export type RuleContext<T> = Context & {
|
|||
resolution?: T;
|
||||
}
|
||||
|
||||
function invokeRuleContext<T>(pushContext: (context: Context) => void, type: string, rule: Generator<string, T, Command>){
|
||||
/**
|
||||
* 调用规则生成器并管理其上下文
|
||||
* @param pushContext - 用于推送上下文到上下文栈的函数
|
||||
* @param type - 规则类型
|
||||
* @param rule - 规则生成器函数
|
||||
* @returns 规则执行结果
|
||||
*/
|
||||
export function invokeRuleContext<T>(
|
||||
pushContext: (context: Context) => void,
|
||||
type: string,
|
||||
rule: Generator<string, T, Command>
|
||||
): RuleContext<T> {
|
||||
const ctx: RuleContext<T> = {
|
||||
type,
|
||||
actions: [],
|
||||
handledActions: 0,
|
||||
invocations: [],
|
||||
resolution: undefined,
|
||||
}
|
||||
};
|
||||
|
||||
// 执行生成器直到完成或需要等待动作
|
||||
const executeRule = () => {
|
||||
try {
|
||||
const result = rule.next();
|
||||
|
||||
if (result.done) {
|
||||
// 规则执行完成,设置结果
|
||||
ctx.resolution = result.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果生成器 yield 了一个动作类型,等待处理
|
||||
// 这里可以扩展为实际的动作处理逻辑
|
||||
const actionType = result.value;
|
||||
|
||||
// 继续执行直到有动作需要处理或规则完成
|
||||
if (!result.done) {
|
||||
executeRule();
|
||||
}
|
||||
} catch (error) {
|
||||
// 规则执行出错,抛出错误
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 effect 来跟踪响应式依赖
|
||||
const dispose = effect(() => {
|
||||
if(ctx.resolution) {
|
||||
if (ctx.resolution !== undefined) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
executeRule();
|
||||
});
|
||||
|
||||
pushContext(rule);
|
||||
// 将规则上下文推入栈中
|
||||
pushContext(ctx);
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function* rule(){
|
||||
const play: Command = yield 'play';
|
||||
}
|
||||
/**
|
||||
* 创建一个规则生成器辅助函数
|
||||
* @param type - 规则类型
|
||||
* @param fn - 规则逻辑函数
|
||||
*/
|
||||
export function createRule<T>(
|
||||
type: string,
|
||||
fn: (ctx: RuleContext<T>) => Generator<string, T, Command>
|
||||
): Generator<string, T, Command> {
|
||||
return fn({
|
||||
type,
|
||||
actions: [],
|
||||
handledActions: 0,
|
||||
invocations: [],
|
||||
resolution: undefined,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,210 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
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];
|
||||
}
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
/**
|
||||
* 井字棋游戏状态扩展
|
||||
*/
|
||||
|
||||
/**
|
||||
* 玩家类型
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
/**
|
||||
* 井字棋游戏模块
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
216
src/index.ts
216
src/index.ts
|
|
@ -3,211 +3,25 @@
|
|||
* 基于 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 {
|
||||
Part,
|
||||
PartBase,
|
||||
MeeplePart,
|
||||
CardPart,
|
||||
TilePart,
|
||||
PartSignal,
|
||||
} from './core/Part';
|
||||
export type { Context } from './core/context';
|
||||
export { GameContext, createGameContext } from './core/context';
|
||||
|
||||
export { RegionType } from './core/Region';
|
||||
export type { Region, RegionProperties, Slot } from './core/Region';
|
||||
export type { Part } from './core/part';
|
||||
export { flip, flipTo, roll } from './core/part';
|
||||
|
||||
export type { Placement, PlacementProperties, Position, PlacementSignal } from './core/Placement';
|
||||
export type { Region, RegionAxis } from './core/region';
|
||||
export { applyAlign, shuffle } from './core/region';
|
||||
|
||||
export type { GameStateData } from './core/GameState';
|
||||
export type { RuleContext } from './core/rule';
|
||||
export { invokeRuleContext, createRule } from './core/rule';
|
||||
|
||||
// Core classes and functions
|
||||
export {
|
||||
createPart,
|
||||
createMeeple,
|
||||
createCard,
|
||||
createTile,
|
||||
} from './core/Part';
|
||||
// Utils
|
||||
export type { Command } from './utils/command';
|
||||
export { parseCommand } from './utils/command';
|
||||
|
||||
export { createRegion, createRegion as createRegionCore } from './core/Region';
|
||||
export type { Region as RegionClass } from './core/Region';
|
||||
export type { Entity, EntityAccessor } from './utils/entity';
|
||||
export { createEntityCollection } from './utils/entity';
|
||||
|
||||
export { createPlacement } from './core/Placement';
|
||||
|
||||
export { GameState, createGameState } from './core/GameState';
|
||||
|
||||
// Part actions
|
||||
export {
|
||||
createPartAction,
|
||||
createMeepleAction,
|
||||
createCardAction,
|
||||
createTileAction,
|
||||
updatePartAction,
|
||||
removePartAction,
|
||||
getPartAction,
|
||||
} from './actions/part.actions';
|
||||
|
||||
// Region actions
|
||||
export {
|
||||
createRegionAction,
|
||||
getRegionAction,
|
||||
removeRegionAction,
|
||||
addPlacementToRegionAction,
|
||||
removePlacementFromRegionAction,
|
||||
setSlotAction,
|
||||
getSlotAction,
|
||||
clearRegionAction,
|
||||
getRegionPlacementCountAction,
|
||||
isRegionEmptyAction,
|
||||
isRegionFullAction,
|
||||
} from './actions/region.actions';
|
||||
|
||||
// Placement actions
|
||||
export {
|
||||
createPlacementAction,
|
||||
getPlacementAction,
|
||||
removePlacementAction,
|
||||
movePlacementAction,
|
||||
updatePlacementPositionAction,
|
||||
updatePlacementRotationAction,
|
||||
flipPlacementAction,
|
||||
updatePlacementPartAction,
|
||||
swapPlacementsAction,
|
||||
setPlacementFaceAction,
|
||||
getPlacementsInRegionAction,
|
||||
getPlacementsOfPartAction,
|
||||
} from './actions/placement.actions';
|
||||
|
||||
// Commands
|
||||
export {
|
||||
CommandActionType,
|
||||
type Command,
|
||||
type CommandStep,
|
||||
type CommandExecutionResult,
|
||||
type CommandLogEntry,
|
||||
type StepResult,
|
||||
type CommandStatus,
|
||||
type QueuedCommand,
|
||||
} from './commands/Command';
|
||||
|
||||
export { CommandExecutor } from './commands/CommandExecutor';
|
||||
|
||||
export { CommandLog, createCommandLog } from './commands/CommandLog';
|
||||
|
||||
export {
|
||||
setupGameCommand,
|
||||
placeMeepleCommand,
|
||||
moveMeepleCommand,
|
||||
drawCardCommand,
|
||||
playCardCommand,
|
||||
placeTileCommand,
|
||||
flipTileCommand,
|
||||
swapPlacementsCommand,
|
||||
setPhaseCommand,
|
||||
clearRegionCommand,
|
||||
defaultCommands,
|
||||
getDefaultCommand,
|
||||
} from './commands/default.commands';
|
||||
|
||||
// CLI Commands
|
||||
export {
|
||||
type CliCommand,
|
||||
type CliCommandArgs,
|
||||
type CliCommandResult,
|
||||
type CliCommandStep,
|
||||
type ParsedCliCommand,
|
||||
} from './commands/CliCommand';
|
||||
|
||||
export { CommandParser, createCommandParser, CommandParseError } from './commands/CommandParser';
|
||||
|
||||
export { CommandRegistry, createCommandRegistry } from './commands/CommandRegistry';
|
||||
|
||||
export {
|
||||
moveCommand,
|
||||
placeCommand,
|
||||
flipCommand,
|
||||
createCommand,
|
||||
regionCommand,
|
||||
drawCommand,
|
||||
shuffleCommand,
|
||||
discardCommand,
|
||||
swapCommand,
|
||||
rotateCommand,
|
||||
positionCommand,
|
||||
phaseCommand,
|
||||
clearCommand,
|
||||
removeCommand,
|
||||
helpCommand,
|
||||
cliCommands,
|
||||
} from './commands/cli.commands';
|
||||
export type { RNG } from './utils/rng';
|
||||
export { createRNG, Mulberry32RNG } from './utils/rng';
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,378 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
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();
|
||||
}
|
||||
|
|
@ -5,6 +5,64 @@
|
|||
params: string[];
|
||||
}
|
||||
|
||||
// TODO implement this
|
||||
export function parseCommand(input: string): Command {
|
||||
/**
|
||||
* 解析命令行输入字符串为 Command 对象
|
||||
* 支持格式:commandName [params...] [--flags...] [-o value...]
|
||||
*
|
||||
* @example
|
||||
* parseCommand("move meeple1 region1 --force -x 10")
|
||||
* // returns { name: "move", params: ["meeple1", "region1"], flags: { force: true }, options: { x: "10" } }
|
||||
*/
|
||||
export function parseCommand(input: string): Command {
|
||||
const tokens = input.trim().split(/\s+/).filter(Boolean);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return { name: '', flags: {}, options: {}, params: [] };
|
||||
}
|
||||
|
||||
const name = tokens[0];
|
||||
const params: string[] = [];
|
||||
const flags: Record<string, true> = {};
|
||||
const options: Record<string, string> = {};
|
||||
|
||||
let i = 1;
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
|
||||
if (token.startsWith('--') && !/^-?\d+$/.test(token)) {
|
||||
// 长格式标志或选项:--flag 或 --option value
|
||||
const key = token.slice(2);
|
||||
const nextToken = tokens[i + 1];
|
||||
|
||||
// 如果下一个 token 存在且不以 - 开头(或者是负数),则是选项值
|
||||
if (nextToken && (!nextToken.startsWith('-') || /^-\d+$/.test(nextToken))) {
|
||||
options[key] = nextToken;
|
||||
i += 2;
|
||||
} else {
|
||||
// 否则是布尔标志
|
||||
flags[key] = true;
|
||||
i++;
|
||||
}
|
||||
} else if (token.startsWith('-') && token.length > 1 && !/^-?\d+$/.test(token)) {
|
||||
// 短格式标志或选项:-f 或 -o value(但不匹配负数)
|
||||
const key = token.slice(1);
|
||||
const nextToken = tokens[i + 1];
|
||||
|
||||
// 如果下一个 token 存在且不以 - 开头(或者是负数),则是选项值
|
||||
if (nextToken && (!nextToken.startsWith('-') || /^-\d+$/.test(nextToken))) {
|
||||
options[key] = nextToken;
|
||||
i += 2;
|
||||
} else {
|
||||
// 否则是布尔标志
|
||||
flags[key] = true;
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
// 普通参数(包括负数)
|
||||
params.push(token);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return { name, flags, options, params };
|
||||
}
|
||||
|
|
@ -1,12 +1,84 @@
|
|||
export interface RNG {
|
||||
/** 设置随机数种子 */
|
||||
(seed: number): void;
|
||||
|
||||
/** 获取一个[0,1)随机数 */
|
||||
|
||||
/** 获取一个 [0,1) 随机数 */
|
||||
next(max?: number): number;
|
||||
|
||||
/** 获取一个[0,max)随机整数 */
|
||||
|
||||
/** 获取一个 [0,max) 随机整数 */
|
||||
nextInt(max: number): number;
|
||||
}
|
||||
|
||||
// TODO: create a RNG implementation with the alea library
|
||||
/**
|
||||
* 使用 mulberry32 算法实现的伪随机数生成器
|
||||
* 这是一个快速、高质量的 32 位 PRNG
|
||||
*/
|
||||
export function createRNG(seed?: number): RNG {
|
||||
let currentSeed: number = seed ?? 1;
|
||||
|
||||
function rng(seed: number): void {
|
||||
currentSeed = seed;
|
||||
}
|
||||
|
||||
rng.next = function(max?: number): number {
|
||||
let t = (currentSeed += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
const result = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
return max !== undefined ? result * max : result;
|
||||
};
|
||||
|
||||
rng.nextInt = function(max: number): number {
|
||||
return Math.floor(rng.next(max));
|
||||
};
|
||||
|
||||
(rng as any).setSeed = function(seed: number): void {
|
||||
currentSeed = seed;
|
||||
};
|
||||
|
||||
(rng as any).getSeed = function(): number {
|
||||
return currentSeed;
|
||||
};
|
||||
|
||||
return rng;
|
||||
}
|
||||
|
||||
/** Mulberry32RNG 类实现(用于类型兼容) */
|
||||
export class Mulberry32RNG {
|
||||
private seed: number = 1;
|
||||
|
||||
constructor(seed?: number) {
|
||||
if (seed !== undefined) {
|
||||
this.seed = seed;
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置随机数种子 */
|
||||
call(seed: number): void {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
/** 获取一个 [0,1) 随机数 */
|
||||
next(max?: number): number {
|
||||
let t = (this.seed += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
const result = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
return max !== undefined ? result * max : result;
|
||||
}
|
||||
|
||||
/** 获取一个 [0,max) 随机整数 */
|
||||
nextInt(max: number): number {
|
||||
return Math.floor(this.next(max));
|
||||
}
|
||||
|
||||
/** 重新设置种子 */
|
||||
setSeed(seed: number): void {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
/** 获取当前种子 */
|
||||
getSeed(): number {
|
||||
return this.seed;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,329 +0,0 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createGameState } from '../../src/core/GameState';
|
||||
import { CommandExecutor } from '../../src/commands/CommandExecutor';
|
||||
import { Command, CommandActionType } from '../../src/commands/Command';
|
||||
import { RegionType } from '../../src/core/Region';
|
||||
|
||||
describe('CommandExecutor', () => {
|
||||
let gameState: ReturnType<typeof createGameState>;
|
||||
let executor: CommandExecutor;
|
||||
|
||||
beforeEach(() => {
|
||||
gameState = createGameState({ id: 'test-game', name: 'Test Game' });
|
||||
executor = new CommandExecutor(gameState);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute a simple command successfully', () => {
|
||||
const command: Command = {
|
||||
id: 'test-command',
|
||||
name: 'Test Command',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.CreateMeeple,
|
||||
params: { id: 'meeple-1', color: 'red' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.execute(command);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.executedSteps).toBe(1);
|
||||
expect(result.totalSteps).toBe(1);
|
||||
expect(gameState.getPart('meeple-1')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should execute multi-step command', () => {
|
||||
const command: Command = {
|
||||
id: 'setup-command',
|
||||
name: 'Setup Command',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.CreateRegion,
|
||||
params: { id: 'board', type: RegionType.Keyed },
|
||||
},
|
||||
{
|
||||
action: CommandActionType.CreateMeeple,
|
||||
params: { id: 'meeple-1', color: 'blue' },
|
||||
},
|
||||
{
|
||||
action: CommandActionType.CreatePlacement,
|
||||
params: {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.execute(command);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.executedSteps).toBe(3);
|
||||
expect(result.totalSteps).toBe(3);
|
||||
expect(gameState.getRegion('board')).toBeDefined();
|
||||
expect(gameState.getPart('meeple-1')).toBeDefined();
|
||||
expect(gameState.getPlacement('placement-1')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should stop execution on error', () => {
|
||||
const command: Command = {
|
||||
id: 'failing-command',
|
||||
name: 'Failing Command',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.CreateMeeple,
|
||||
params: { id: 'meeple-1', color: 'red' },
|
||||
},
|
||||
{
|
||||
action: CommandActionType.CreatePlacement,
|
||||
params: {
|
||||
id: 'placement-1',
|
||||
partId: 'non-existent',
|
||||
regionId: 'non-existent',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: CommandActionType.CreateMeeple,
|
||||
params: { id: 'meeple-2', color: 'blue' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.execute(command);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.executedSteps).toBe(1);
|
||||
expect(result.totalSteps).toBe(3);
|
||||
expect(result.error).toBeDefined();
|
||||
expect(gameState.getPart('meeple-1')).toBeDefined();
|
||||
expect(gameState.getPart('meeple-2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should execute createCard command', () => {
|
||||
const command: Command = {
|
||||
id: 'create-card',
|
||||
name: 'Create Card',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.CreateCard,
|
||||
params: { id: 'card-1', suit: 'hearts', value: 10 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.execute(command);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const card = gameState.getPart('card-1');
|
||||
expect(card).toBeDefined();
|
||||
expect(card?.type).toBe('card');
|
||||
});
|
||||
|
||||
it('should execute createTile command', () => {
|
||||
const command: Command = {
|
||||
id: 'create-tile',
|
||||
name: 'Create Tile',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.CreateTile,
|
||||
params: { id: 'tile-1', pattern: 'forest', rotation: 90 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.execute(command);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const tile = gameState.getPart('tile-1');
|
||||
expect(tile).toBeDefined();
|
||||
expect(tile?.type).toBe('tile');
|
||||
});
|
||||
|
||||
it('should execute movePlacement command', () => {
|
||||
const setupCommand: Command = {
|
||||
id: 'setup',
|
||||
name: 'Setup',
|
||||
steps: [
|
||||
{ action: CommandActionType.CreateRegion, params: { id: 'board1', type: RegionType.Unkeyed } },
|
||||
{ action: CommandActionType.CreateRegion, params: { id: 'board2', type: RegionType.Unkeyed } },
|
||||
{ action: CommandActionType.CreateMeeple, params: { id: 'm1', color: 'red' } },
|
||||
{ action: CommandActionType.CreatePlacement, params: { id: 'p1', partId: 'm1', regionId: 'board1' } },
|
||||
],
|
||||
};
|
||||
executor.execute(setupCommand);
|
||||
|
||||
const moveCommand: Command = {
|
||||
id: 'move',
|
||||
name: 'Move',
|
||||
steps: [
|
||||
{
|
||||
action: CommandActionType.MovePlacement,
|
||||
params: { placementId: 'p1', targetRegionId: 'board2' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.execute(moveCommand);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const placement = gameState.getPlacement('p1');
|
||||
expect(placement?.regionId).toBe('board2');
|
||||
});
|
||||
|
||||
it('should execute flipPlacement command', () => {
|
||||
const setupCommand: Command = {
|
||||
id: 'setup',
|
||||
name: 'Setup',
|
||||
steps: [
|
||||
{ action: CommandActionType.CreateRegion, params: { id: 'board', type: RegionType.Unkeyed } },
|
||||
{ action: CommandActionType.CreateMeeple, params: { id: 'm1', color: 'red' } },
|
||||
{ action: CommandActionType.CreatePlacement, params: { id: 'p1', partId: 'm1', regionId: 'board', faceUp: true } },
|
||||
],
|
||||
};
|
||||
executor.execute(setupCommand);
|
||||
|
||||
const flipCommand: Command = {
|
||||
id: 'flip',
|
||||
name: 'Flip',
|
||||
steps: [
|
||||
{ action: CommandActionType.FlipPlacement, params: { placementId: 'p1' } },
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.execute(flipCommand);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const placement = gameState.getPlacement('p1');
|
||||
expect(placement?.faceUp).toBe(false);
|
||||
});
|
||||
|
||||
it('should execute setPhase command', () => {
|
||||
const command: Command = {
|
||||
id: 'set-phase',
|
||||
name: 'Set Phase',
|
||||
steps: [
|
||||
{ action: CommandActionType.SetPhase, params: { phase: 'midgame' } },
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.execute(command);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(gameState.data.value.phase).toBe('midgame');
|
||||
});
|
||||
|
||||
it('should execute swapPlacements command', () => {
|
||||
const setupCommand: Command = {
|
||||
id: 'setup',
|
||||
name: 'Setup',
|
||||
steps: [
|
||||
{ action: CommandActionType.CreateRegion, params: { id: 'board', type: RegionType.Unkeyed } },
|
||||
{ action: CommandActionType.CreateMeeple, params: { id: 'm1', color: 'red' } },
|
||||
{ action: CommandActionType.CreateMeeple, params: { id: 'm2', color: 'blue' } },
|
||||
{ action: CommandActionType.CreatePlacement, params: { id: 'p1', partId: 'm1', regionId: 'board' } },
|
||||
{ action: CommandActionType.CreatePlacement, params: { id: 'p2', partId: 'm2', regionId: 'board' } },
|
||||
],
|
||||
};
|
||||
executor.execute(setupCommand);
|
||||
|
||||
const region = gameState.getRegion('board');
|
||||
region!.placements.value = ['p1', 'p2'];
|
||||
|
||||
const swapCommand: Command = {
|
||||
id: 'swap',
|
||||
name: 'Swap',
|
||||
steps: [
|
||||
{ action: CommandActionType.SwapPlacements, params: { placementId1: 'p1', placementId2: 'p2' } },
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.execute(swapCommand);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(region!.placements.value).toEqual(['p2', 'p1']);
|
||||
});
|
||||
|
||||
it('should execute clearRegion command', () => {
|
||||
const setupCommand: Command = {
|
||||
id: 'setup',
|
||||
name: 'Setup',
|
||||
steps: [
|
||||
{ action: CommandActionType.CreateRegion, params: { id: 'board', type: RegionType.Unkeyed } },
|
||||
{ action: CommandActionType.CreateMeeple, params: { id: 'm1', color: 'red' } },
|
||||
{ action: CommandActionType.CreatePlacement, params: { id: 'p1', partId: 'm1', regionId: 'board' } },
|
||||
{ action: CommandActionType.AddPlacementToRegion, params: { regionId: 'board', placementId: 'p1' } },
|
||||
],
|
||||
};
|
||||
executor.execute(setupCommand);
|
||||
|
||||
const clearCommand: Command = {
|
||||
id: 'clear',
|
||||
name: 'Clear',
|
||||
steps: [
|
||||
{ action: CommandActionType.ClearRegion, params: { regionId: 'board' } },
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.execute(clearCommand);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const region = gameState.getRegion('board');
|
||||
expect(region?.placements.value.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should execute updatePart command', () => {
|
||||
const setupCommand: Command = {
|
||||
id: 'setup',
|
||||
name: 'Setup',
|
||||
steps: [
|
||||
{ action: CommandActionType.CreateMeeple, params: { id: 'm1', color: 'red', name: 'Original' } },
|
||||
],
|
||||
};
|
||||
executor.execute(setupCommand);
|
||||
|
||||
const updateCommand: Command = {
|
||||
id: 'update',
|
||||
name: 'Update',
|
||||
steps: [
|
||||
{ action: CommandActionType.UpdatePart, params: { partId: 'm1', updates: { name: 'Updated', color: 'blue' } } },
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.execute(updateCommand);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const part = gameState.getPart('m1');
|
||||
expect(part?.name).toBe('Updated');
|
||||
expect(part?.color).toBe('blue');
|
||||
});
|
||||
|
||||
it('should execute removePart command', () => {
|
||||
const setupCommand: Command = {
|
||||
id: 'setup',
|
||||
name: 'Setup',
|
||||
steps: [
|
||||
{ action: CommandActionType.CreateMeeple, params: { id: 'm1', color: 'red' } },
|
||||
],
|
||||
};
|
||||
executor.execute(setupCommand);
|
||||
|
||||
expect(gameState.getPart('m1')).toBeDefined();
|
||||
|
||||
const removeCommand: Command = {
|
||||
id: 'remove',
|
||||
name: 'Remove',
|
||||
steps: [
|
||||
{ action: CommandActionType.RemovePart, params: { partId: 'm1' } },
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.execute(removeCommand);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(gameState.getPart('m1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CommandLog, createCommandLog } from '../../src/commands/CommandLog';
|
||||
import { Command, CommandActionType, CommandExecutionResult, StepResult } from '../../src/commands/Command';
|
||||
|
||||
describe('CommandLog', () => {
|
||||
let log: CommandLog;
|
||||
|
||||
beforeEach(() => {
|
||||
log = createCommandLog();
|
||||
});
|
||||
|
||||
const sampleCommand: Command = {
|
||||
id: 'test-command',
|
||||
name: 'Test Command',
|
||||
steps: [
|
||||
{ action: CommandActionType.CreateMeeple, params: { id: 'm1', color: 'red' } },
|
||||
],
|
||||
};
|
||||
|
||||
const sampleResult: CommandExecutionResult = {
|
||||
success: true,
|
||||
executedSteps: 1,
|
||||
totalSteps: 1,
|
||||
};
|
||||
|
||||
const sampleStepResults: StepResult[] = [
|
||||
{
|
||||
stepIndex: 0,
|
||||
action: CommandActionType.CreateMeeple,
|
||||
success: true,
|
||||
params: { id: 'm1', color: 'red' },
|
||||
},
|
||||
];
|
||||
|
||||
describe('log', () => {
|
||||
it('should log a command execution', () => {
|
||||
log.log(sampleCommand, sampleResult, sampleStepResults);
|
||||
|
||||
const entries = log.getEntries();
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0].commandId).toBe('test-command');
|
||||
expect(entries[0].commandName).toBe('Test Command');
|
||||
expect(entries[0].result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should add timestamp to log entry', () => {
|
||||
const beforeTime = Date.now();
|
||||
log.log(sampleCommand, sampleResult, sampleStepResults);
|
||||
const afterTime = Date.now();
|
||||
|
||||
const entry = log.getEntries()[0];
|
||||
expect(entry.timestamp).toBeGreaterThanOrEqual(beforeTime);
|
||||
expect(entry.timestamp).toBeLessThanOrEqual(afterTime);
|
||||
});
|
||||
|
||||
it('should log multiple entries', () => {
|
||||
log.log(sampleCommand, sampleResult, sampleStepResults);
|
||||
log.log(sampleCommand, sampleResult, sampleStepResults);
|
||||
log.log(sampleCommand, sampleResult, sampleStepResults);
|
||||
|
||||
expect(log.getEntries().length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilteredEntries', () => {
|
||||
it('should filter by commandId', () => {
|
||||
const command1: Command = { ...sampleCommand, id: 'cmd-1', name: 'Command 1' };
|
||||
const command2: Command = { ...sampleCommand, id: 'cmd-2', name: 'Command 2' };
|
||||
|
||||
log.log(command1, sampleResult, sampleStepResults);
|
||||
log.log(command2, sampleResult, sampleStepResults);
|
||||
log.log(command1, sampleResult, sampleStepResults);
|
||||
|
||||
const filtered = log.getFilteredEntries({ commandId: 'cmd-1' });
|
||||
expect(filtered.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should filter by success status', () => {
|
||||
const successResult: CommandExecutionResult = { success: true, executedSteps: 1, totalSteps: 1 };
|
||||
const failResult: CommandExecutionResult = { success: false, executedSteps: 0, totalSteps: 1, error: 'Failed' };
|
||||
|
||||
log.log(sampleCommand, successResult, sampleStepResults);
|
||||
log.log(sampleCommand, failResult, []);
|
||||
log.log(sampleCommand, successResult, sampleStepResults);
|
||||
|
||||
const failed = log.getFilteredEntries({ success: false });
|
||||
expect(failed.length).toBe(1);
|
||||
|
||||
const successful = log.getFilteredEntries({ success: true });
|
||||
expect(successful.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should filter by time range', () => {
|
||||
const startTime = Date.now();
|
||||
log.log(sampleCommand, sampleResult, sampleStepResults);
|
||||
|
||||
// 模拟时间流逝
|
||||
const midTime = Date.now() + 100;
|
||||
|
||||
// 手动创建一个带时间戳的条目来测试时间过滤
|
||||
const entries = log.getEntries();
|
||||
expect(entries[0].timestamp).toBeGreaterThanOrEqual(startTime);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommandHistory', () => {
|
||||
it('should return history for a specific command', () => {
|
||||
const command1: Command = { ...sampleCommand, id: 'cmd-1', name: 'Command 1' };
|
||||
const command2: Command = { ...sampleCommand, id: 'cmd-2', name: 'Command 2' };
|
||||
|
||||
log.log(command1, sampleResult, sampleStepResults);
|
||||
log.log(command2, sampleResult, sampleStepResults);
|
||||
log.log(command1, sampleResult, sampleStepResults);
|
||||
|
||||
const history = log.getCommandHistory('cmd-1');
|
||||
expect(history.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFailedCommands', () => {
|
||||
it('should return only failed commands', () => {
|
||||
const successResult: CommandExecutionResult = { success: true, executedSteps: 1, totalSteps: 1 };
|
||||
const failResult: CommandExecutionResult = { success: false, executedSteps: 0, totalSteps: 1, error: 'Error' };
|
||||
|
||||
log.log(sampleCommand, successResult, sampleStepResults);
|
||||
log.log(sampleCommand, failResult, []);
|
||||
log.log(sampleCommand, successResult, sampleStepResults);
|
||||
|
||||
const failed = log.getFailedCommands();
|
||||
expect(failed.length).toBe(1);
|
||||
expect(failed[0].result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSuccessfulCommands', () => {
|
||||
it('should return only successful commands', () => {
|
||||
const successResult: CommandExecutionResult = { success: true, executedSteps: 1, totalSteps: 1 };
|
||||
const failResult: CommandExecutionResult = { success: false, executedSteps: 0, totalSteps: 1, error: 'Error' };
|
||||
|
||||
log.log(sampleCommand, successResult, sampleStepResults);
|
||||
log.log(sampleCommand, failResult, []);
|
||||
log.log(sampleCommand, successResult, sampleStepResults);
|
||||
|
||||
const successful = log.getSuccessfulCommands();
|
||||
expect(successful.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all log entries', () => {
|
||||
log.log(sampleCommand, sampleResult, sampleStepResults);
|
||||
log.log(sampleCommand, sampleResult, sampleStepResults);
|
||||
|
||||
expect(log.getEntries().length).toBe(2);
|
||||
|
||||
log.clear();
|
||||
|
||||
expect(log.getEntries().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportToJson', () => {
|
||||
it('should export logs as JSON string', () => {
|
||||
log.log(sampleCommand, sampleResult, sampleStepResults);
|
||||
|
||||
const json = log.exportToJson();
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.length).toBe(1);
|
||||
expect(parsed[0].commandId).toBe('test-command');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCount', () => {
|
||||
it('should return the number of log entries', () => {
|
||||
expect(log.getCount()).toBe(0);
|
||||
|
||||
log.log(sampleCommand, sampleResult, sampleStepResults);
|
||||
expect(log.getCount()).toBe(1);
|
||||
|
||||
log.log(sampleCommand, sampleResult, sampleStepResults);
|
||||
expect(log.getCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLastEntry', () => {
|
||||
it('should return the last log entry', () => {
|
||||
const command1: Command = { ...sampleCommand, id: 'cmd-1', name: 'First' };
|
||||
const command2: Command = { ...sampleCommand, id: 'cmd-2', name: 'Last' };
|
||||
|
||||
log.log(command1, sampleResult, sampleStepResults);
|
||||
log.log(command2, sampleResult, sampleStepResults);
|
||||
|
||||
const lastEntry = log.getLastEntry();
|
||||
expect(lastEntry).not.toBeNull();
|
||||
expect(lastEntry?.commandId).toBe('cmd-2');
|
||||
});
|
||||
|
||||
it('should return null when log is empty', () => {
|
||||
const lastEntry = log.getLastEntry();
|
||||
expect(lastEntry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Queue management', () => {
|
||||
describe('enqueue', () => {
|
||||
it('should add command to queue', () => {
|
||||
const queued = log.enqueue(sampleCommand);
|
||||
|
||||
expect(queued.id).toBe('test-command');
|
||||
expect(log.getQueueLength()).toBe(1);
|
||||
});
|
||||
|
||||
it('should set initial status to Pending', () => {
|
||||
const queued = log.enqueue(sampleCommand);
|
||||
expect(queued.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dequeue', () => {
|
||||
it('should remove and return the first command from queue', () => {
|
||||
const command1: Command = { ...sampleCommand, id: 'cmd-1' };
|
||||
const command2: Command = { ...sampleCommand, id: 'cmd-2' };
|
||||
|
||||
log.enqueue(command1);
|
||||
log.enqueue(command2);
|
||||
|
||||
const dequeued = log.dequeue();
|
||||
expect(dequeued?.command.id).toBe('cmd-1');
|
||||
expect(log.getQueueLength()).toBe(1);
|
||||
});
|
||||
|
||||
it('should return null when queue is empty', () => {
|
||||
const dequeued = log.dequeue();
|
||||
expect(dequeued).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateQueueStatus', () => {
|
||||
it('should update command status in queue', () => {
|
||||
log.enqueue(sampleCommand);
|
||||
|
||||
log.updateQueueStatus('test-command', 'executing');
|
||||
const queue = log.getQueue();
|
||||
expect(queue[0].status).toBe('executing');
|
||||
});
|
||||
|
||||
it('should set executedAt and result when status is Completed', () => {
|
||||
const result: CommandExecutionResult = { success: true, executedSteps: 1, totalSteps: 1 };
|
||||
log.enqueue(sampleCommand);
|
||||
|
||||
const beforeTime = Date.now();
|
||||
log.updateQueueStatus('test-command', 'completed', result);
|
||||
const afterTime = Date.now();
|
||||
|
||||
const queue = log.getQueue();
|
||||
expect(queue[0].status).toBe('completed');
|
||||
expect(queue[0].result).toEqual(result);
|
||||
expect(queue[0].executedAt).toBeGreaterThanOrEqual(beforeTime);
|
||||
expect(queue[0].executedAt).toBeLessThanOrEqual(afterTime);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearQueue', () => {
|
||||
it('should clear all queued commands', () => {
|
||||
log.enqueue(sampleCommand);
|
||||
log.enqueue(sampleCommand);
|
||||
|
||||
expect(log.getQueueLength()).toBe(2);
|
||||
|
||||
log.clearQueue();
|
||||
|
||||
expect(log.getQueueLength()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CommandParser, createCommandParser } from '../../src/commands/CommandParser';
|
||||
import { CommandParseError } from '../../src/commands/CommandParser';
|
||||
|
||||
describe('CommandParser', () => {
|
||||
let parser: CommandParser;
|
||||
|
||||
beforeEach(() => {
|
||||
parser = createCommandParser();
|
||||
});
|
||||
|
||||
describe('parse', () => {
|
||||
it('should parse simple command without args', () => {
|
||||
const result = parser.parse('shuffle');
|
||||
|
||||
expect(result.commandName).toBe('shuffle');
|
||||
expect(result.args.positional).toEqual([]);
|
||||
expect(result.args.flags).toEqual({});
|
||||
});
|
||||
|
||||
it('should parse command with positional args', () => {
|
||||
const result = parser.parse('move card-1 discard');
|
||||
|
||||
expect(result.commandName).toBe('move');
|
||||
expect(result.args.positional).toEqual(['card-1', 'discard']);
|
||||
expect(result.args.flags).toEqual({});
|
||||
});
|
||||
|
||||
it('should parse command with multiple positional args', () => {
|
||||
const result = parser.parse('position p1 3 5');
|
||||
|
||||
expect(result.commandName).toBe('position');
|
||||
expect(result.args.positional).toEqual(['p1', '3', '5']);
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
|
||||
it('should parse command with multiple flags', () => {
|
||||
const result = parser.parse('create meeple m1 --color=red --name=Player1');
|
||||
|
||||
expect(result.commandName).toBe('create');
|
||||
expect(result.args.positional).toEqual(['meeple', 'm1']);
|
||||
expect(result.args.flags).toEqual({ color: 'red', name: 'Player1' });
|
||||
});
|
||||
|
||||
it('should parse command with boolean flag', () => {
|
||||
const result = parser.parse('flip p1 --faceup');
|
||||
|
||||
expect(result.commandName).toBe('flip');
|
||||
expect(result.args.positional).toEqual(['p1']);
|
||||
expect(result.args.flags).toEqual({ faceup: true });
|
||||
});
|
||||
|
||||
it('should parse command with short flag', () => {
|
||||
const result = parser.parse('shuffle d1 -s');
|
||||
|
||||
expect(result.commandName).toBe('shuffle');
|
||||
expect(result.args.positional).toEqual(['d1']);
|
||||
expect(result.args.flags).toEqual({ s: true });
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
|
||||
it('should parse command with string number value', () => {
|
||||
const result = parser.parse('rotate p1 90');
|
||||
|
||||
expect(result.commandName).toBe('rotate');
|
||||
expect(result.args.positional).toEqual(['p1', '90']);
|
||||
});
|
||||
|
||||
it('should parse command with negative number', () => {
|
||||
const result = parser.parse('rotate p1 -45');
|
||||
|
||||
expect(result.commandName).toBe('rotate');
|
||||
expect(result.args.positional).toEqual(['p1', '-45']);
|
||||
});
|
||||
|
||||
it('should parse command with float number', () => {
|
||||
const result = parser.parse('rotate p1 45.5');
|
||||
|
||||
expect(result.commandName).toBe('rotate');
|
||||
expect(result.args.positional).toEqual(['p1', '45.5']);
|
||||
});
|
||||
|
||||
it('should parse command with quoted string', () => {
|
||||
const result = parser.parse('create meeple m1 --name="Red Player"');
|
||||
|
||||
expect(result.commandName).toBe('create');
|
||||
expect(result.args.positional).toEqual(['meeple', 'm1']);
|
||||
expect(result.args.flags).toEqual({ name: 'Red Player' });
|
||||
});
|
||||
|
||||
it('should parse command with single quoted string', () => {
|
||||
const result = parser.parse("create meeple m1 --name='Blue Player'");
|
||||
|
||||
expect(result.commandName).toBe('create');
|
||||
expect(result.args.positional).toEqual(['meeple', 'm1']);
|
||||
expect(result.args.flags).toEqual({ name: 'Blue Player' });
|
||||
});
|
||||
|
||||
it('should handle extra whitespace', () => {
|
||||
const result = parser.parse(' move card-1 discard ');
|
||||
|
||||
expect(result.commandName).toBe('move');
|
||||
expect(result.args.positional).toEqual(['card-1', 'discard']);
|
||||
});
|
||||
|
||||
it('should throw on empty command', () => {
|
||||
expect(() => parser.parse('')).toThrow(CommandParseError);
|
||||
expect(() => parser.parse(' ')).toThrow(CommandParseError);
|
||||
});
|
||||
|
||||
it('should throw on unclosed quote', () => {
|
||||
expect(() => parser.parse('create meeple m1 --name="Red')).toThrow(CommandParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCommand', () => {
|
||||
it('should format simple command', () => {
|
||||
const formatted = CommandParser.formatCommand('shuffle');
|
||||
expect(formatted).toBe('shuffle');
|
||||
});
|
||||
|
||||
it('should format command with positional args', () => {
|
||||
const formatted = CommandParser.formatCommand('move', {
|
||||
positional: ['card-1', 'discard'],
|
||||
flags: {},
|
||||
});
|
||||
expect(formatted).toBe('move card-1 discard');
|
||||
});
|
||||
|
||||
it('should format command with flags', () => {
|
||||
const formatted = CommandParser.formatCommand('shuffle', {
|
||||
positional: ['discard'],
|
||||
flags: { seed: 2026 },
|
||||
});
|
||||
expect(formatted).toBe('shuffle discard --seed=2026');
|
||||
});
|
||||
|
||||
it('should format command with boolean flag', () => {
|
||||
const formatted = CommandParser.formatCommand('flip', {
|
||||
positional: ['p1'],
|
||||
flags: { faceup: true },
|
||||
});
|
||||
expect(formatted).toBe('flip p1 --faceup');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CommandRegistry, createCommandRegistry } from '../../src/commands/CommandRegistry';
|
||||
import type { CliCommand } from '../../src/commands/CliCommand';
|
||||
|
||||
describe('CommandRegistry', () => {
|
||||
let registry: CommandRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = createCommandRegistry();
|
||||
});
|
||||
|
||||
const sampleCommand: CliCommand = {
|
||||
name: 'test',
|
||||
description: 'Test command',
|
||||
usage: 'test <arg>',
|
||||
args: [
|
||||
{ name: 'arg', description: 'Test argument', required: true },
|
||||
],
|
||||
handler: (args) => {
|
||||
return [
|
||||
{
|
||||
action: 'createMeeple',
|
||||
params: { id: args.positional[0], color: 'red' },
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
describe('register', () => {
|
||||
it('should register a command', () => {
|
||||
registry.register(sampleCommand);
|
||||
|
||||
expect(registry.has('test')).toBe(true);
|
||||
expect(registry.get('test')).toBe(sampleCommand);
|
||||
});
|
||||
|
||||
it('should register multiple commands', () => {
|
||||
const cmd1: CliCommand = {
|
||||
name: 'cmd1',
|
||||
description: 'Command 1',
|
||||
usage: 'cmd1',
|
||||
handler: () => [],
|
||||
};
|
||||
const cmd2: CliCommand = {
|
||||
name: 'cmd2',
|
||||
description: 'Command 2',
|
||||
usage: 'cmd2',
|
||||
handler: () => [],
|
||||
};
|
||||
|
||||
registry.registerAll([cmd1, cmd2]);
|
||||
|
||||
expect(registry.has('cmd1')).toBe(true);
|
||||
expect(registry.has('cmd2')).toBe(true);
|
||||
expect(registry.getCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should return undefined for non-existent command', () => {
|
||||
const cmd = registry.get('non-existent');
|
||||
expect(cmd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return existing command', () => {
|
||||
registry.register(sampleCommand);
|
||||
const cmd = registry.get('test');
|
||||
expect(cmd?.name).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregister', () => {
|
||||
it('should remove a command', () => {
|
||||
registry.register(sampleCommand);
|
||||
expect(registry.has('test')).toBe(true);
|
||||
|
||||
registry.unregister('test');
|
||||
expect(registry.has('test')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return all registered commands', () => {
|
||||
registry.register(sampleCommand);
|
||||
|
||||
const cmd2: CliCommand = {
|
||||
name: 'cmd2',
|
||||
description: 'Command 2',
|
||||
usage: 'cmd2',
|
||||
handler: () => [],
|
||||
};
|
||||
registry.register(cmd2);
|
||||
|
||||
const all = registry.getAll();
|
||||
expect(all.length).toBe(2);
|
||||
expect(all.map((c) => c.name)).toEqual(['test', 'cmd2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute a command successfully', () => {
|
||||
registry.register(sampleCommand);
|
||||
|
||||
const result = registry.execute('test meeple-1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.steps.length).toBe(1);
|
||||
expect(result.steps[0].action).toBe('createMeeple');
|
||||
expect(result.steps[0].params).toEqual({ id: 'meeple-1', color: 'red' });
|
||||
});
|
||||
|
||||
it('should return error for unknown command', () => {
|
||||
const result = registry.execute('unknown arg1');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Unknown command');
|
||||
expect(result.steps).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return error for missing required argument', () => {
|
||||
registry.register(sampleCommand);
|
||||
|
||||
const result = registry.execute('test');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Missing required argument');
|
||||
});
|
||||
|
||||
it('should execute command with flags', () => {
|
||||
const cmdWithFlags: CliCommand = {
|
||||
name: 'move',
|
||||
description: 'Move command',
|
||||
usage: 'move <id> [--to=region]',
|
||||
args: [
|
||||
{ name: 'id', description: 'ID', required: true },
|
||||
],
|
||||
flags: [
|
||||
{ name: 'to', description: 'Target', type: 'string' },
|
||||
],
|
||||
handler: (args) => {
|
||||
return [
|
||||
{
|
||||
action: 'movePlacement',
|
||||
params: {
|
||||
placementId: args.positional[0],
|
||||
targetRegionId: args.flags.to,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
registry.register(cmdWithFlags);
|
||||
|
||||
const result = registry.execute('move p1 --to=board');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.steps[0].params).toEqual({
|
||||
placementId: 'p1',
|
||||
targetRegionId: 'board',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle command with optional args', () => {
|
||||
const cmdOptional: CliCommand = {
|
||||
name: 'draw',
|
||||
description: 'Draw cards',
|
||||
usage: 'draw [count]',
|
||||
args: [
|
||||
{ name: 'count', description: 'Count', required: false, default: '1' },
|
||||
],
|
||||
handler: (args) => {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
registry.register(cmdOptional);
|
||||
|
||||
const result = registry.execute('draw');
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('help', () => {
|
||||
beforeEach(() => {
|
||||
registry.register({
|
||||
name: 'move',
|
||||
description: 'Move a placement',
|
||||
usage: 'move <id> <target>',
|
||||
args: [
|
||||
{ name: 'id', description: 'Placement ID', required: true },
|
||||
{ name: 'target', description: 'Target region', required: true },
|
||||
],
|
||||
flags: [
|
||||
{ name: 'key', description: 'Slot key', type: 'string', alias: 'k' },
|
||||
],
|
||||
handler: () => [],
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: 'flip',
|
||||
description: 'Flip a placement',
|
||||
usage: 'flip <id>',
|
||||
args: [
|
||||
{ name: 'id', description: 'Placement ID', required: true },
|
||||
],
|
||||
handler: () => [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should show all commands help', () => {
|
||||
const help = registry.help();
|
||||
|
||||
expect(help).toContain('Available commands');
|
||||
expect(help).toContain('move');
|
||||
expect(help).toContain('flip');
|
||||
expect(help).toContain('help <command>');
|
||||
});
|
||||
|
||||
it('should show specific command help', () => {
|
||||
const help = registry.help('move');
|
||||
|
||||
expect(help).toContain('Command: move');
|
||||
expect(help).toContain('Move a placement');
|
||||
expect(help).toContain('Arguments:');
|
||||
expect(help).toContain('Flags:');
|
||||
});
|
||||
|
||||
it('should show error for unknown command help', () => {
|
||||
const help = registry.help('unknown');
|
||||
expect(help).toContain('Unknown command');
|
||||
});
|
||||
|
||||
it('should show command with alias', () => {
|
||||
const help = registry.help('move');
|
||||
expect(help).toContain('-k,');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all commands', () => {
|
||||
registry.register(sampleCommand);
|
||||
registry.register({
|
||||
name: 'cmd2',
|
||||
description: 'Command 2',
|
||||
usage: 'cmd2',
|
||||
handler: () => [],
|
||||
});
|
||||
|
||||
expect(registry.getCount()).toBe(2);
|
||||
registry.clear();
|
||||
expect(registry.getCount()).toBe(0);
|
||||
expect(registry.getAll()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { applyAlign, shuffle, type Region, type RegionAxis } from '../../src/core/region';
|
||||
import { createRNG } from '../../src/utils/rng';
|
||||
import { createEntityCollection } from '../../src/utils/entity';
|
||||
import { type Part } from '../../src/core/part';
|
||||
|
||||
describe('Region', () => {
|
||||
function createPart(id: string, position: number[]): Part {
|
||||
const collection = createEntityCollection<Part>();
|
||||
const part: Part = {
|
||||
id,
|
||||
sides: 1,
|
||||
side: 0,
|
||||
region: { id: 'region1', value: {} as Region },
|
||||
position: [...position]
|
||||
};
|
||||
collection.add(part);
|
||||
return part;
|
||||
}
|
||||
|
||||
function createRegion(axes: RegionAxis[], parts: Part[]): Region {
|
||||
const region: Region = {
|
||||
id: 'region1',
|
||||
axes: [...axes],
|
||||
children: parts.map(p => ({ id: p.id, value: p }))
|
||||
};
|
||||
return region;
|
||||
}
|
||||
|
||||
describe('applyAlign', () => {
|
||||
it('should do nothing with empty region', () => {
|
||||
const region = createRegion([{ name: 'x', min: 0, align: 'start' }], []);
|
||||
applyAlign(region);
|
||||
expect(region.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should align parts to start', () => {
|
||||
const part1 = createPart('p1', [5, 0]);
|
||||
const part2 = createPart('p2', [7, 0]);
|
||||
const part3 = createPart('p3', [2, 0]);
|
||||
|
||||
const region = createRegion(
|
||||
[{ name: 'x', min: 0, align: 'start' }],
|
||||
[part1, part2, part3]
|
||||
);
|
||||
|
||||
applyAlign(region);
|
||||
|
||||
// 排序后应该是 part3(2), part1(5), part2(7) -> 对齐到 0, 1, 2
|
||||
expect(region.children[0].value.position[0]).toBe(0);
|
||||
expect(region.children[1].value.position[0]).toBe(1);
|
||||
expect(region.children[2].value.position[0]).toBe(2);
|
||||
});
|
||||
|
||||
it('should align parts to start with custom min', () => {
|
||||
const part1 = createPart('p1', [5, 0]);
|
||||
const part2 = createPart('p2', [7, 0]);
|
||||
|
||||
const region = createRegion(
|
||||
[{ name: 'x', min: 10, align: 'start' }],
|
||||
[part1, part2]
|
||||
);
|
||||
|
||||
applyAlign(region);
|
||||
|
||||
expect(region.children[0].value.position[0]).toBe(10);
|
||||
expect(region.children[1].value.position[0]).toBe(11);
|
||||
});
|
||||
|
||||
it('should align parts to end', () => {
|
||||
const part1 = createPart('p1', [2, 0]);
|
||||
const part2 = createPart('p2', [4, 0]);
|
||||
const part3 = createPart('p3', [1, 0]);
|
||||
|
||||
const region = createRegion(
|
||||
[{ name: 'x', max: 10, align: 'end' }],
|
||||
[part1, part2, part3]
|
||||
);
|
||||
|
||||
applyAlign(region);
|
||||
|
||||
// 3 个部分,对齐到 end(max=10),应该是 8, 9, 10
|
||||
expect(region.children[0].value.position[0]).toBe(8);
|
||||
expect(region.children[1].value.position[0]).toBe(9);
|
||||
expect(region.children[2].value.position[0]).toBe(10);
|
||||
});
|
||||
|
||||
it('should align parts to center', () => {
|
||||
const part1 = createPart('p1', [0, 0]);
|
||||
const part2 = createPart('p2', [1, 0]);
|
||||
const part3 = createPart('p3', [2, 0]);
|
||||
|
||||
const region = createRegion(
|
||||
[{ name: 'x', min: 0, max: 10, align: 'center' }],
|
||||
[part1, part2, part3]
|
||||
);
|
||||
|
||||
applyAlign(region);
|
||||
|
||||
// 中心是 5,3 个部分应该是 4, 5, 6
|
||||
expect(region.children[0].value.position[0]).toBe(4);
|
||||
expect(region.children[1].value.position[0]).toBe(5);
|
||||
expect(region.children[2].value.position[0]).toBe(6);
|
||||
});
|
||||
|
||||
it('should handle even count center alignment', () => {
|
||||
const part1 = createPart('p1', [0, 0]);
|
||||
const part2 = createPart('p2', [1, 0]);
|
||||
|
||||
const region = createRegion(
|
||||
[{ name: 'x', min: 0, max: 10, align: 'center' }],
|
||||
[part1, part2]
|
||||
);
|
||||
|
||||
applyAlign(region);
|
||||
|
||||
// 中心是 5,2 个部分应该是 4.5, 5.5
|
||||
expect(region.children[0].value.position[0]).toBe(4.5);
|
||||
expect(region.children[1].value.position[0]).toBe(5.5);
|
||||
});
|
||||
|
||||
it('should sort children by position', () => {
|
||||
const part1 = createPart('p1', [5, 0]);
|
||||
const part2 = createPart('p2', [1, 0]);
|
||||
const part3 = createPart('p3', [3, 0]);
|
||||
|
||||
const region = createRegion(
|
||||
[{ name: 'x', min: 0, align: 'start' }],
|
||||
[part1, part2, part3]
|
||||
);
|
||||
|
||||
applyAlign(region);
|
||||
|
||||
// children 应该按位置排序
|
||||
expect(region.children[0].value.id).toBe('p2');
|
||||
expect(region.children[1].value.id).toBe('p3');
|
||||
expect(region.children[2].value.id).toBe('p1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shuffle', () => {
|
||||
it('should do nothing with empty region', () => {
|
||||
const region = createRegion([], []);
|
||||
const rng = createRNG(42);
|
||||
shuffle(region, rng);
|
||||
expect(region.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should do nothing with single part', () => {
|
||||
const part = createPart('p1', [0, 0]);
|
||||
const region = createRegion([], [part]);
|
||||
const rng = createRNG(42);
|
||||
shuffle(region, rng);
|
||||
expect(region.children[0].value.position).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('should shuffle positions of multiple parts', () => {
|
||||
const part1 = createPart('p1', [0, 0]);
|
||||
const part2 = createPart('p2', [1, 0]);
|
||||
const part3 = createPart('p3', [2, 0]);
|
||||
|
||||
const region = createRegion([], [part1, part2, part3]);
|
||||
const rng = createRNG(42);
|
||||
|
||||
const originalPositions = region.children.map(c => [...c.value.position]);
|
||||
shuffle(region, rng);
|
||||
|
||||
// 位置应该被交换
|
||||
const newPositions = region.children.map(c => c.value.position);
|
||||
|
||||
// 验证所有原始位置仍然存在(只是被交换了)
|
||||
originalPositions.forEach(origPos => {
|
||||
const found = newPositions.some(newPos =>
|
||||
newPos[0] === origPos[0] && newPos[1] === origPos[1]
|
||||
);
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should be deterministic with same seed', () => {
|
||||
const createRegionForTest = () => {
|
||||
const part1 = createPart('p1', [0, 0]);
|
||||
const part2 = createPart('p2', [1, 0]);
|
||||
const part3 = createPart('p3', [2, 0]);
|
||||
return createRegion([], [part1, part2, part3]);
|
||||
};
|
||||
|
||||
const region1 = createRegionForTest();
|
||||
const region2 = createRegionForTest();
|
||||
|
||||
const rng1 = createRNG(42);
|
||||
const rng2 = createRNG(42);
|
||||
|
||||
shuffle(region1, rng1);
|
||||
shuffle(region2, rng2);
|
||||
|
||||
const positions1 = region1.children.map(c => c.value.position);
|
||||
const positions2 = region2.children.map(c => c.value.position);
|
||||
|
||||
expect(positions1).toEqual(positions2);
|
||||
});
|
||||
|
||||
it('should produce different results with different seeds', () => {
|
||||
const part1 = createPart('p1', [0, 0]);
|
||||
const part2 = createPart('p2', [1, 0]);
|
||||
const part3 = createPart('p3', [2, 0]);
|
||||
const part4 = createPart('p4', [3, 0]);
|
||||
const part5 = createPart('p5', [4, 0]);
|
||||
|
||||
const results = new Set<string>();
|
||||
|
||||
// 尝试多个种子,确保大多数产生不同结果
|
||||
for (let seed = 1; seed <= 10; seed++) {
|
||||
const region = createRegion([], [part1, part2, part3, part4, part5]);
|
||||
const rng = createRNG(seed);
|
||||
shuffle(region, rng);
|
||||
|
||||
const positions = JSON.stringify(region.children.map(c => c.value.position));
|
||||
results.add(positions);
|
||||
}
|
||||
|
||||
// 10 个种子中至少应该有 5 个不同的结果
|
||||
expect(results.size).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,319 +0,0 @@
|
|||
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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createGameState } from '../src/core/GameState';
|
||||
import { PartType } from '../src/core/Part';
|
||||
import {
|
||||
createPartAction,
|
||||
createMeepleAction,
|
||||
createCardAction,
|
||||
createTileAction,
|
||||
updatePartAction,
|
||||
removePartAction,
|
||||
getPartAction,
|
||||
} from '../src/actions/part.actions';
|
||||
|
||||
describe('Part Actions', () => {
|
||||
let gameState: ReturnType<typeof createGameState>;
|
||||
|
||||
beforeEach(() => {
|
||||
gameState = createGameState({ id: 'test-game', name: 'Test Game' });
|
||||
});
|
||||
|
||||
describe('createPartAction', () => {
|
||||
it('should create a generic part', () => {
|
||||
const part = createPartAction(gameState, {
|
||||
id: 'part-1',
|
||||
type: PartType.Meeple,
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
expect(part.id).toBe('part-1');
|
||||
expect(part.type).toBe(PartType.Meeple);
|
||||
expect(getPartAction(gameState, 'part-1')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create a part with metadata', () => {
|
||||
const part = createPartAction(gameState, {
|
||||
id: 'part-1',
|
||||
type: PartType.Tile,
|
||||
pattern: 'forest',
|
||||
metadata: { points: 5 },
|
||||
});
|
||||
|
||||
expect(part.metadata).toEqual({ points: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMeepleAction', () => {
|
||||
it('should create a meeple part', () => {
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'blue');
|
||||
|
||||
expect(meeple.id).toBe('meeple-1');
|
||||
expect(meeple.type).toBe(PartType.Meeple);
|
||||
expect(meeple.color).toBe('blue');
|
||||
});
|
||||
|
||||
it('should create a meeple with name', () => {
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'blue', { name: 'Player 1' });
|
||||
|
||||
expect(meeple.name).toBe('Player 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCardAction', () => {
|
||||
it('should create a card part', () => {
|
||||
const card = createCardAction(gameState, 'card-1', { suit: 'hearts', value: 10 });
|
||||
|
||||
expect(card.id).toBe('card-1');
|
||||
expect(card.type).toBe(PartType.Card);
|
||||
expect(card.suit).toBe('hearts');
|
||||
expect(card.value).toBe(10);
|
||||
});
|
||||
|
||||
it('should create a card with string value', () => {
|
||||
const card = createCardAction(gameState, 'card-2', { suit: 'spades', value: 'ace' });
|
||||
|
||||
expect(card.value).toBe('ace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTileAction', () => {
|
||||
it('should create a tile part', () => {
|
||||
const tile = createTileAction(gameState, 'tile-1', { pattern: 'road', rotation: 90 });
|
||||
|
||||
expect(tile.id).toBe('tile-1');
|
||||
expect(tile.type).toBe(PartType.Tile);
|
||||
expect(tile.pattern).toBe('road');
|
||||
expect(tile.rotation).toBe(90);
|
||||
});
|
||||
|
||||
it('should create a tile with default rotation', () => {
|
||||
const tile = createTileAction(gameState, 'tile-2', { pattern: 'city' });
|
||||
|
||||
expect(tile.rotation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePartAction', () => {
|
||||
it('should update part properties', () => {
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
|
||||
updatePartAction(gameState, 'meeple-1', { color: 'green' as string, name: 'Updated' });
|
||||
|
||||
const updated = getPartAction(gameState, 'meeple-1');
|
||||
expect(updated?.color).toBe('green');
|
||||
expect(updated?.name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('should update part metadata', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red', { metadata: { score: 0 } });
|
||||
|
||||
updatePartAction(gameState, 'meeple-1', { metadata: { score: 10 } } as any);
|
||||
|
||||
const updated = getPartAction(gameState, 'meeple-1');
|
||||
expect(updated?.metadata).toEqual({ score: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePartAction', () => {
|
||||
it('should remove a part', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
|
||||
expect(getPartAction(gameState, 'meeple-1')).toBeDefined();
|
||||
|
||||
removePartAction(gameState, 'meeple-1');
|
||||
|
||||
expect(getPartAction(gameState, 'meeple-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove placements referencing the part', () => {
|
||||
// 这个测试会在 placement 测试中更详细地验证
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
removePartAction(gameState, 'meeple-1');
|
||||
|
||||
expect(getPartAction(gameState, 'meeple-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPartAction', () => {
|
||||
it('should return undefined for non-existent part', () => {
|
||||
const part = getPartAction(gameState, 'non-existent');
|
||||
expect(part).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return existing part', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
const part = getPartAction(gameState, 'meeple-1');
|
||||
expect(part?.id).toBe('meeple-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,422 +0,0 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createGameState } from '../src/core/GameState';
|
||||
import { RegionType } from '../src/core/Region';
|
||||
import { createMeepleAction } from '../src/actions/part.actions';
|
||||
import { createRegionAction } from '../src/actions/region.actions';
|
||||
import {
|
||||
createPlacementAction,
|
||||
getPlacementAction,
|
||||
removePlacementAction,
|
||||
movePlacementAction,
|
||||
updatePlacementPositionAction,
|
||||
updatePlacementRotationAction,
|
||||
flipPlacementAction,
|
||||
updatePlacementPartAction,
|
||||
swapPlacementsAction,
|
||||
setPlacementFaceAction,
|
||||
getPlacementsInRegionAction,
|
||||
getPlacementsOfPartAction,
|
||||
} from '../src/actions/placement.actions';
|
||||
|
||||
describe('Placement Actions', () => {
|
||||
let gameState: ReturnType<typeof createGameState>;
|
||||
|
||||
beforeEach(() => {
|
||||
gameState = createGameState({ id: 'test-game', name: 'Test Game' });
|
||||
});
|
||||
|
||||
describe('createPlacementAction', () => {
|
||||
it('should create a placement', () => {
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
|
||||
const placement = createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
});
|
||||
|
||||
expect(placement.id).toBe('placement-1');
|
||||
expect(placement.partId).toBe('meeple-1');
|
||||
expect(placement.regionId).toBe('board');
|
||||
expect(placement.part).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create a placement with position', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
|
||||
const placement = createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
position: { x: 3, y: 4 },
|
||||
});
|
||||
|
||||
expect(placement.position).toEqual({ x: 3, y: 4 });
|
||||
});
|
||||
|
||||
it('should throw if part does not exist', () => {
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
|
||||
expect(() => {
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'non-existent',
|
||||
regionId: 'board',
|
||||
});
|
||||
}).toThrow('Part non-existent not found');
|
||||
});
|
||||
|
||||
it('should throw if region does not exist', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
|
||||
expect(() => {
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'non-existent',
|
||||
});
|
||||
}).toThrow('Region non-existent not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlacementAction', () => {
|
||||
it('should return undefined for non-existent placement', () => {
|
||||
const placement = getPlacementAction(gameState, 'non-existent');
|
||||
expect(placement).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return existing placement', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
});
|
||||
|
||||
const placement = getPlacementAction(gameState, 'placement-1');
|
||||
expect(placement?.id).toBe('placement-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePlacementAction', () => {
|
||||
it('should remove a placement', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
});
|
||||
|
||||
expect(getPlacementAction(gameState, 'placement-1')).toBeDefined();
|
||||
|
||||
removePlacementAction(gameState, 'placement-1');
|
||||
|
||||
expect(getPlacementAction(gameState, 'placement-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove placement from region', () => {
|
||||
const region = createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
});
|
||||
|
||||
region.placements.value = ['placement-1'];
|
||||
removePlacementAction(gameState, 'placement-1');
|
||||
|
||||
expect(region.placements.value).not.toContain('placement-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('movePlacementAction', () => {
|
||||
it('should move placement to another region', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
createRegionAction(gameState, { id: 'supply', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
});
|
||||
|
||||
movePlacementAction(gameState, 'placement-1', 'supply');
|
||||
|
||||
const placement = getPlacementAction(gameState, 'placement-1');
|
||||
expect(placement?.regionId).toBe('supply');
|
||||
});
|
||||
|
||||
it('should move placement to keyed region with key', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Keyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
});
|
||||
|
||||
movePlacementAction(gameState, 'placement-1', 'board', 'B2');
|
||||
|
||||
const slotValue = gameState.regions.value.get('board')?.slots?.value.get('B2');
|
||||
expect(slotValue).toBe('placement-1');
|
||||
});
|
||||
|
||||
it('should throw if key is required but not provided', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Keyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
movePlacementAction(gameState, 'placement-1', 'board');
|
||||
}).toThrow('Key is required for keyed regions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePlacementPositionAction', () => {
|
||||
it('should update placement position', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
updatePlacementPositionAction(gameState, 'placement-1', { x: 5, y: 3 });
|
||||
|
||||
const placement = getPlacementAction(gameState, 'placement-1');
|
||||
expect(placement?.position).toEqual({ x: 5, y: 3 });
|
||||
});
|
||||
|
||||
it('should throw if placement does not exist', () => {
|
||||
expect(() => {
|
||||
updatePlacementPositionAction(gameState, 'non-existent', { x: 1, y: 1 });
|
||||
}).toThrow('Placement non-existent not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePlacementRotationAction', () => {
|
||||
it('should update placement rotation', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
rotation: 0,
|
||||
});
|
||||
|
||||
updatePlacementRotationAction(gameState, 'placement-1', 90);
|
||||
|
||||
const placement = getPlacementAction(gameState, 'placement-1');
|
||||
expect(placement?.rotation).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flipPlacementAction', () => {
|
||||
it('should flip placement faceUp state', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
faceUp: true,
|
||||
});
|
||||
|
||||
flipPlacementAction(gameState, 'placement-1');
|
||||
|
||||
const placement = getPlacementAction(gameState, 'placement-1');
|
||||
expect(placement?.faceUp).toBe(false);
|
||||
|
||||
flipPlacementAction(gameState, 'placement-1');
|
||||
expect(getPlacementAction(gameState, 'placement-1')?.faceUp).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPlacementFaceAction', () => {
|
||||
it('should set placement face up', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
faceUp: false,
|
||||
});
|
||||
|
||||
setPlacementFaceAction(gameState, 'placement-1', true);
|
||||
|
||||
expect(getPlacementAction(gameState, 'placement-1')?.faceUp).toBe(true);
|
||||
});
|
||||
|
||||
it('should set placement face down', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
faceUp: true,
|
||||
});
|
||||
|
||||
setPlacementFaceAction(gameState, 'placement-1', false);
|
||||
|
||||
expect(getPlacementAction(gameState, 'placement-1')?.faceUp).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePlacementPartAction', () => {
|
||||
it('should update the part reference', () => {
|
||||
const meeple1 = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
const meeple2 = createMeepleAction(gameState, 'meeple-2', 'blue');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
});
|
||||
|
||||
updatePlacementPartAction(gameState, 'placement-1', meeple2);
|
||||
|
||||
const placement = getPlacementAction(gameState, 'placement-1');
|
||||
expect(placement?.part?.id).toBe('meeple-2');
|
||||
});
|
||||
|
||||
it('should set part reference to null', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
});
|
||||
|
||||
updatePlacementPartAction(gameState, 'placement-1', null);
|
||||
|
||||
expect(getPlacementAction(gameState, 'placement-1')?.part).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('swapPlacementsAction', () => {
|
||||
it('should swap two placements in unkeyed region', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createMeepleAction(gameState, 'meeple-2', 'blue');
|
||||
const region = createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'p1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
});
|
||||
createPlacementAction(gameState, {
|
||||
id: 'p2',
|
||||
partId: 'meeple-2',
|
||||
regionId: 'board',
|
||||
});
|
||||
|
||||
region.placements.value = ['p1', 'p2'];
|
||||
|
||||
swapPlacementsAction(gameState, 'p1', 'p2');
|
||||
|
||||
expect(region.placements.value).toEqual(['p2', 'p1']);
|
||||
});
|
||||
|
||||
it('should swap two placements in keyed region', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createMeepleAction(gameState, 'meeple-2', 'blue');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Keyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'p1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board',
|
||||
});
|
||||
createPlacementAction(gameState, {
|
||||
id: 'p2',
|
||||
partId: 'meeple-2',
|
||||
regionId: 'board',
|
||||
});
|
||||
|
||||
// 设置初始槽位
|
||||
const region = gameState.getRegion('board');
|
||||
region?.slots?.value.set('A1', 'p1');
|
||||
region?.slots?.value.set('A2', 'p2');
|
||||
|
||||
swapPlacementsAction(gameState, 'p1', 'p2');
|
||||
|
||||
expect(region?.slots?.value.get('A1')).toBe('p2');
|
||||
expect(region?.slots?.value.get('A2')).toBe('p1');
|
||||
});
|
||||
|
||||
it('should throw if placements are in different regions', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board1', type: RegionType.Unkeyed });
|
||||
createRegionAction(gameState, { id: 'board2', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, {
|
||||
id: 'p1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board1',
|
||||
});
|
||||
createPlacementAction(gameState, {
|
||||
id: 'p2',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'board2',
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
swapPlacementsAction(gameState, 'p1', 'p2');
|
||||
}).toThrow('Cannot swap placements in different regions directly');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlacementsInRegionAction', () => {
|
||||
it('should return all placements in a region', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, { id: 'p1', partId: 'meeple-1', regionId: 'board' });
|
||||
createPlacementAction(gameState, { id: 'p2', partId: 'meeple-1', regionId: 'board' });
|
||||
|
||||
const region = gameState.getRegion('board');
|
||||
region!.placements.value = ['p1', 'p2'];
|
||||
|
||||
const placements = getPlacementsInRegionAction(gameState, 'board');
|
||||
expect(placements.length).toBe(2);
|
||||
expect(placements.map((p) => p.id)).toEqual(['p1', 'p2']);
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent region', () => {
|
||||
const placements = getPlacementsInRegionAction(gameState, 'non-existent');
|
||||
expect(placements).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlacementsOfPartAction', () => {
|
||||
it('should return all placements of a part', () => {
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createRegionAction(gameState, { id: 'board1', type: RegionType.Unkeyed });
|
||||
createRegionAction(gameState, { id: 'board2', type: RegionType.Unkeyed });
|
||||
createPlacementAction(gameState, { id: 'p1', partId: 'meeple-1', regionId: 'board1' });
|
||||
createPlacementAction(gameState, { id: 'p2', partId: 'meeple-1', regionId: 'board2' });
|
||||
|
||||
const placements = getPlacementsOfPartAction(gameState, 'meeple-1');
|
||||
expect(placements.length).toBe(2);
|
||||
expect(placements.map((p) => p.partId)).toEqual(['meeple-1', 'meeple-1']);
|
||||
});
|
||||
|
||||
it('should return empty array for part with no placements', () => {
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
|
||||
const placements = getPlacementsOfPartAction(gameState, 'meeple-1');
|
||||
expect(placements).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createGameState } from '../src/core/GameState';
|
||||
import { RegionType } from '../src/core/Region';
|
||||
import {
|
||||
createRegionAction,
|
||||
getRegionAction,
|
||||
removeRegionAction,
|
||||
addPlacementToRegionAction,
|
||||
removePlacementFromRegionAction,
|
||||
setSlotAction,
|
||||
getSlotAction,
|
||||
clearRegionAction,
|
||||
getRegionPlacementCountAction,
|
||||
isRegionEmptyAction,
|
||||
isRegionFullAction,
|
||||
} from '../src/actions/region.actions';
|
||||
import { createMeepleAction } from '../src/actions/part.actions';
|
||||
import { createPlacementAction } from '../src/actions/placement.actions';
|
||||
|
||||
describe('Region Actions', () => {
|
||||
let gameState: ReturnType<typeof createGameState>;
|
||||
|
||||
beforeEach(() => {
|
||||
gameState = createGameState({ id: 'test-game', name: 'Test Game' });
|
||||
});
|
||||
|
||||
describe('createRegionAction', () => {
|
||||
it('should create an unkeyed region', () => {
|
||||
const region = createRegionAction(gameState, {
|
||||
id: 'deck',
|
||||
type: RegionType.Unkeyed,
|
||||
name: 'Draw Deck',
|
||||
});
|
||||
|
||||
expect(region.id).toBe('deck');
|
||||
expect(region.type).toBe(RegionType.Unkeyed);
|
||||
expect(region.slots).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create a keyed region', () => {
|
||||
const region = createRegionAction(gameState, {
|
||||
id: 'board',
|
||||
type: RegionType.Keyed,
|
||||
name: 'Game Board',
|
||||
});
|
||||
|
||||
expect(region.id).toBe('board');
|
||||
expect(region.type).toBe(RegionType.Keyed);
|
||||
expect(region.slots).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create a region with capacity', () => {
|
||||
const region = createRegionAction(gameState, {
|
||||
id: 'hand',
|
||||
type: RegionType.Unkeyed,
|
||||
capacity: 5,
|
||||
});
|
||||
|
||||
expect(region.capacity).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRegionAction', () => {
|
||||
it('should return undefined for non-existent region', () => {
|
||||
const region = getRegionAction(gameState, 'non-existent');
|
||||
expect(region).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return existing region', () => {
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Keyed });
|
||||
const region = getRegionAction(gameState, 'board');
|
||||
expect(region?.id).toBe('board');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeRegionAction', () => {
|
||||
it('should remove a region', () => {
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Keyed });
|
||||
|
||||
expect(getRegionAction(gameState, 'board')).toBeDefined();
|
||||
|
||||
removeRegionAction(gameState, 'board');
|
||||
|
||||
expect(getRegionAction(gameState, 'board')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addPlacementToRegionAction (unkeyed)', () => {
|
||||
it('should add a placement to an unkeyed region', () => {
|
||||
createRegionAction(gameState, { id: 'deck', type: RegionType.Unkeyed });
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
const placement = createPlacementAction(gameState, {
|
||||
id: 'placement-1',
|
||||
partId: 'meeple-1',
|
||||
regionId: 'deck',
|
||||
});
|
||||
|
||||
addPlacementToRegionAction(gameState, 'deck', 'placement-1');
|
||||
|
||||
const region = getRegionAction(gameState, 'deck');
|
||||
expect(region?.placements.value).toContain('placement-1');
|
||||
});
|
||||
|
||||
it('should throw when adding to a keyed region', () => {
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Keyed });
|
||||
|
||||
expect(() => {
|
||||
addPlacementToRegionAction(gameState, 'board', 'placement-1');
|
||||
}).toThrow('Cannot use addPlacementToRegionAction on a keyed region');
|
||||
});
|
||||
|
||||
it('should respect capacity limit', () => {
|
||||
createRegionAction(gameState, { id: 'hand', type: RegionType.Unkeyed, capacity: 2 });
|
||||
createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createMeepleAction(gameState, 'meeple-2', 'blue');
|
||||
createPlacementAction(gameState, { id: 'p1', partId: 'meeple-1', regionId: 'hand' });
|
||||
createPlacementAction(gameState, { id: 'p2', partId: 'meeple-2', regionId: 'hand' });
|
||||
|
||||
addPlacementToRegionAction(gameState, 'hand', 'p1');
|
||||
addPlacementToRegionAction(gameState, 'hand', 'p2');
|
||||
|
||||
expect(() => {
|
||||
addPlacementToRegionAction(gameState, 'hand', 'p3');
|
||||
}).toThrow('has reached its capacity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePlacementFromRegionAction', () => {
|
||||
it('should remove a placement from an unkeyed region', () => {
|
||||
createRegionAction(gameState, { id: 'deck', type: RegionType.Unkeyed });
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createPlacementAction(gameState, { id: 'p1', partId: 'meeple-1', regionId: 'deck' });
|
||||
|
||||
addPlacementToRegionAction(gameState, 'deck', 'p1');
|
||||
removePlacementFromRegionAction(gameState, 'deck', 'p1');
|
||||
|
||||
const region = getRegionAction(gameState, 'deck');
|
||||
expect(region?.placements.value).not.toContain('p1');
|
||||
});
|
||||
|
||||
it('should clear slot in keyed region', () => {
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Keyed });
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createPlacementAction(gameState, { id: 'p1', partId: 'meeple-1', regionId: 'board' });
|
||||
|
||||
setSlotAction(gameState, 'board', 'A1', 'p1');
|
||||
removePlacementFromRegionAction(gameState, 'board', 'p1');
|
||||
|
||||
const slotValue = getSlotAction(gameState, 'board', 'A1');
|
||||
expect(slotValue).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSlotAction (keyed)', () => {
|
||||
it('should set a slot in a keyed region', () => {
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Keyed });
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createPlacementAction(gameState, { id: 'p1', partId: 'meeple-1', regionId: 'board' });
|
||||
|
||||
setSlotAction(gameState, 'board', 'A1', 'p1');
|
||||
|
||||
const slotValue = getSlotAction(gameState, 'board', 'A1');
|
||||
expect(slotValue).toBe('p1');
|
||||
});
|
||||
|
||||
it('should throw when used on unkeyed region', () => {
|
||||
createRegionAction(gameState, { id: 'deck', type: RegionType.Unkeyed });
|
||||
|
||||
expect(() => {
|
||||
setSlotAction(gameState, 'deck', 'slot1', 'p1');
|
||||
}).toThrow('Cannot use setSlotAction on an unkeyed region');
|
||||
});
|
||||
|
||||
it('should add placement to region list when setting slot', () => {
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Keyed });
|
||||
createRegionAction(gameState, { id: 'other', type: RegionType.Unkeyed });
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createPlacementAction(gameState, { id: 'p1', partId: 'meeple-1', regionId: 'other' });
|
||||
|
||||
setSlotAction(gameState, 'board', 'A1', 'p1');
|
||||
|
||||
const region = getRegionAction(gameState, 'board');
|
||||
expect(region?.placements.value).toContain('p1');
|
||||
});
|
||||
|
||||
it('should clear a slot with null', () => {
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Keyed });
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createPlacementAction(gameState, { id: 'p1', partId: 'meeple-1', regionId: 'board' });
|
||||
|
||||
setSlotAction(gameState, 'board', 'A1', 'p1');
|
||||
setSlotAction(gameState, 'board', 'A1', null);
|
||||
|
||||
const slotValue = getSlotAction(gameState, 'board', 'A1');
|
||||
expect(slotValue).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSlotAction', () => {
|
||||
it('should return null for empty slot', () => {
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Keyed });
|
||||
|
||||
const slotValue = getSlotAction(gameState, 'board', 'A1');
|
||||
expect(slotValue).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw when used on unkeyed region', () => {
|
||||
createRegionAction(gameState, { id: 'deck', type: RegionType.Unkeyed });
|
||||
|
||||
expect(() => {
|
||||
getSlotAction(gameState, 'deck', 'slot1');
|
||||
}).toThrow('Cannot use getSlotAction on an unkeyed region');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearRegionAction', () => {
|
||||
it('should clear all placements from unkeyed region', () => {
|
||||
createRegionAction(gameState, { id: 'deck', type: RegionType.Unkeyed });
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createPlacementAction(gameState, { id: 'p1', partId: 'meeple-1', regionId: 'deck' });
|
||||
createPlacementAction(gameState, { id: 'p2', partId: 'meeple-1', regionId: 'deck' });
|
||||
|
||||
addPlacementToRegionAction(gameState, 'deck', 'p1');
|
||||
addPlacementToRegionAction(gameState, 'deck', 'p2');
|
||||
|
||||
clearRegionAction(gameState, 'deck');
|
||||
|
||||
const region = getRegionAction(gameState, 'deck');
|
||||
expect(region?.placements.value.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear all slots in keyed region', () => {
|
||||
createRegionAction(gameState, { id: 'board', type: RegionType.Keyed });
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createPlacementAction(gameState, { id: 'p1', partId: 'meeple-1', regionId: 'board' });
|
||||
|
||||
setSlotAction(gameState, 'board', 'A1', 'p1');
|
||||
setSlotAction(gameState, 'board', 'A2', 'p1');
|
||||
|
||||
clearRegionAction(gameState, 'board');
|
||||
|
||||
const region = getRegionAction(gameState, 'board');
|
||||
expect(region?.placements.value.length).toBe(0);
|
||||
expect(region?.slots?.value.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRegionPlacementCountAction', () => {
|
||||
it('should return the count of placements', () => {
|
||||
createRegionAction(gameState, { id: 'deck', type: RegionType.Unkeyed });
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createPlacementAction(gameState, { id: 'p1', partId: 'meeple-1', regionId: 'deck' });
|
||||
createPlacementAction(gameState, { id: 'p2', partId: 'meeple-1', regionId: 'deck' });
|
||||
|
||||
addPlacementToRegionAction(gameState, 'deck', 'p1');
|
||||
addPlacementToRegionAction(gameState, 'deck', 'p2');
|
||||
|
||||
const count = getRegionPlacementCountAction(gameState, 'deck');
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 0 for non-existent region', () => {
|
||||
const count = getRegionPlacementCountAction(gameState, 'non-existent');
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRegionEmptyAction', () => {
|
||||
it('should return true for empty region', () => {
|
||||
createRegionAction(gameState, { id: 'deck', type: RegionType.Unkeyed });
|
||||
expect(isRegionEmptyAction(gameState, 'deck')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-empty region', () => {
|
||||
createRegionAction(gameState, { id: 'deck', type: RegionType.Unkeyed });
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createPlacementAction(gameState, { id: 'p1', partId: 'meeple-1', regionId: 'deck' });
|
||||
addPlacementToRegionAction(gameState, 'deck', 'p1');
|
||||
|
||||
expect(isRegionEmptyAction(gameState, 'deck')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRegionFullAction', () => {
|
||||
it('should return false for region without capacity', () => {
|
||||
createRegionAction(gameState, { id: 'deck', type: RegionType.Unkeyed });
|
||||
expect(isRegionFullAction(gameState, 'deck')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when at capacity', () => {
|
||||
createRegionAction(gameState, { id: 'hand', type: RegionType.Unkeyed, capacity: 2 });
|
||||
const meeple = createMeepleAction(gameState, 'meeple-1', 'red');
|
||||
createPlacementAction(gameState, { id: 'p1', partId: 'meeple-1', regionId: 'hand' });
|
||||
createPlacementAction(gameState, { id: 'p2', partId: 'meeple-1', regionId: 'hand' });
|
||||
|
||||
addPlacementToRegionAction(gameState, 'hand', 'p1');
|
||||
addPlacementToRegionAction(gameState, 'hand', 'p2');
|
||||
|
||||
expect(isRegionFullAction(gameState, 'hand')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,595 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCommand, type Command } from '../../src/utils/command';
|
||||
|
||||
describe('parseCommand', () => {
|
||||
it('should parse empty string', () => {
|
||||
const result = parseCommand('');
|
||||
expect(result).toEqual({
|
||||
name: '',
|
||||
flags: {},
|
||||
options: {},
|
||||
params: []
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse command name only', () => {
|
||||
const result = parseCommand('move');
|
||||
expect(result).toEqual({
|
||||
name: 'move',
|
||||
flags: {},
|
||||
options: {},
|
||||
params: []
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse command with params', () => {
|
||||
const result = parseCommand('move meeple1 region1');
|
||||
expect(result).toEqual({
|
||||
name: 'move',
|
||||
flags: {},
|
||||
options: {},
|
||||
params: ['meeple1', 'region1']
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse command with long flags', () => {
|
||||
const result = parseCommand('move meeple1 --force --quiet');
|
||||
expect(result).toEqual({
|
||||
name: 'move',
|
||||
flags: { force: true, quiet: true },
|
||||
options: {},
|
||||
params: ['meeple1']
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse command with short flags', () => {
|
||||
const result = parseCommand('move meeple1 -f -q');
|
||||
expect(result).toEqual({
|
||||
name: 'move',
|
||||
flags: { f: true, q: true },
|
||||
options: {},
|
||||
params: ['meeple1']
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse command with long options', () => {
|
||||
const result = parseCommand('move meeple1 --x 10 --y 20');
|
||||
expect(result).toEqual({
|
||||
name: 'move',
|
||||
flags: {},
|
||||
options: { x: '10', y: '20' },
|
||||
params: ['meeple1']
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse command with short options', () => {
|
||||
const result = parseCommand('move meeple1 -x 10 -y 20');
|
||||
expect(result).toEqual({
|
||||
name: 'move',
|
||||
flags: {},
|
||||
options: { x: '10', y: '20' },
|
||||
params: ['meeple1']
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse command with mixed flags和选项', () => {
|
||||
const result = parseCommand('move meeple1 region1 --force -x 10 -q');
|
||||
expect(result).toEqual({
|
||||
name: 'move',
|
||||
flags: { force: true, q: true },
|
||||
options: { x: '10' },
|
||||
params: ['meeple1', 'region1']
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle extra whitespace', () => {
|
||||
const result = parseCommand(' move meeple1 --force ');
|
||||
expect(result).toEqual({
|
||||
name: 'move',
|
||||
flags: { force: true },
|
||||
options: {},
|
||||
params: ['meeple1']
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse complex command', () => {
|
||||
const result = parseCommand('place meeple1 board --x 5 --y 3 --rotate 90 --force');
|
||||
expect(result).toEqual({
|
||||
name: 'place',
|
||||
flags: { force: true },
|
||||
options: { x: '5', y: '3', rotate: '90' },
|
||||
params: ['meeple1', 'board']
|
||||
});
|
||||
});
|
||||
|
||||
it('should treat negative number as option value', () => {
|
||||
const result = parseCommand('set --value -10');
|
||||
expect(result).toEqual({
|
||||
name: 'set',
|
||||
flags: {},
|
||||
options: { value: '-10' },
|
||||
params: []
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { createEntityCollection, type Entity } from '../../src/utils/entity';
|
||||
|
||||
interface TestEntity extends Entity {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
describe('createEntityCollection', () => {
|
||||
it('should create empty collection', () => {
|
||||
const collection = createEntityCollection<TestEntity>();
|
||||
expect(collection.collection.value).toEqual({});
|
||||
});
|
||||
|
||||
it('should add single entity', () => {
|
||||
const collection = createEntityCollection<TestEntity>();
|
||||
const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
||||
|
||||
collection.add(entity);
|
||||
|
||||
expect(collection.collection.value).toHaveProperty('e1');
|
||||
expect(collection.get('e1').value).toEqual(entity);
|
||||
});
|
||||
|
||||
it('should add multiple entities', () => {
|
||||
const collection = createEntityCollection<TestEntity>();
|
||||
const entity1: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
||||
const entity2: TestEntity = { id: 'e2', name: 'Entity 2', value: 20 };
|
||||
const entity3: TestEntity = { id: 'e3', name: 'Entity 3', value: 30 };
|
||||
|
||||
collection.add(entity1, entity2, entity3);
|
||||
|
||||
expect(Object.keys(collection.collection.value)).toHaveLength(3);
|
||||
expect(collection.get('e1').value.name).toBe('Entity 1');
|
||||
expect(collection.get('e2').value.name).toBe('Entity 2');
|
||||
expect(collection.get('e3').value.name).toBe('Entity 3');
|
||||
});
|
||||
|
||||
it('should remove single entity', () => {
|
||||
const collection = createEntityCollection<TestEntity>();
|
||||
const entity1: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
||||
const entity2: TestEntity = { id: 'e2', name: 'Entity 2', value: 20 };
|
||||
|
||||
collection.add(entity1, entity2);
|
||||
collection.remove('e1');
|
||||
|
||||
expect(Object.keys(collection.collection.value)).toHaveLength(1);
|
||||
expect(collection.collection.value).not.toHaveProperty('e1');
|
||||
expect(collection.collection.value).toHaveProperty('e2');
|
||||
});
|
||||
|
||||
it('should remove multiple entities', () => {
|
||||
const collection = createEntityCollection<TestEntity>();
|
||||
const entity1: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
||||
const entity2: TestEntity = { id: 'e2', name: 'Entity 2', value: 20 };
|
||||
const entity3: TestEntity = { id: 'e3', name: 'Entity 3', value: 30 };
|
||||
|
||||
collection.add(entity1, entity2, entity3);
|
||||
collection.remove('e1', 'e3');
|
||||
|
||||
expect(Object.keys(collection.collection.value)).toHaveLength(1);
|
||||
expect(collection.collection.value).toHaveProperty('e2');
|
||||
});
|
||||
|
||||
it('should update entity via accessor', () => {
|
||||
const collection = createEntityCollection<TestEntity>();
|
||||
const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
||||
|
||||
collection.add(entity);
|
||||
|
||||
const accessor = collection.get('e1');
|
||||
accessor.value = { ...entity, value: 100, name: 'Updated' };
|
||||
|
||||
expect(collection.get('e1').value.value).toBe(100);
|
||||
expect(collection.get('e1').value.name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent entity', () => {
|
||||
const collection = createEntityCollection<TestEntity>();
|
||||
|
||||
expect(collection.get('nonexistent').value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should have correct accessor id', () => {
|
||||
const collection = createEntityCollection<TestEntity>();
|
||||
const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
||||
|
||||
collection.add(entity);
|
||||
|
||||
const accessor = collection.get('e1');
|
||||
expect(accessor.id).toBe('e1');
|
||||
});
|
||||
|
||||
it('should handle removing non-existent entity', () => {
|
||||
const collection = createEntityCollection<TestEntity>();
|
||||
const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
||||
|
||||
collection.add(entity);
|
||||
collection.remove('nonexistent');
|
||||
|
||||
expect(Object.keys(collection.collection.value)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should work with reactive updates', () => {
|
||||
const collection = createEntityCollection<TestEntity>();
|
||||
const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
||||
|
||||
collection.add(entity);
|
||||
|
||||
// 验证 accessor 可以正确获取和设置值
|
||||
const accessor = collection.get('e1');
|
||||
expect(accessor.value.value).toBe(10);
|
||||
|
||||
accessor.value = { ...entity, value: 50 };
|
||||
expect(accessor.value.value).toBe(50);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { createRNG } from '../../src/utils/rng';
|
||||
|
||||
describe('createRNG', () => {
|
||||
it('should create RNG with default seed', () => {
|
||||
const rng = createRNG();
|
||||
expect(rng.getSeed()).toBe(1);
|
||||
});
|
||||
|
||||
it('should create RNG with custom seed', () => {
|
||||
const rng = createRNG(12345);
|
||||
expect(rng.getSeed()).toBe(12345);
|
||||
});
|
||||
|
||||
it('should generate numbers in range [0, 1)', () => {
|
||||
const rng = createRNG(42);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const num = rng.next();
|
||||
expect(num).toBeGreaterThanOrEqual(0);
|
||||
expect(num).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should generate numbers with max parameter', () => {
|
||||
const rng = createRNG(42);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const num = rng.next(100);
|
||||
expect(num).toBeGreaterThanOrEqual(0);
|
||||
expect(num).toBeLessThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('should generate integers in range [0, max)', () => {
|
||||
const rng = createRNG(42);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const num = rng.nextInt(10);
|
||||
expect(Number.isInteger(num)).toBe(true);
|
||||
expect(num).toBeGreaterThanOrEqual(0);
|
||||
expect(num).toBeLessThan(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('should be deterministic with same seed', () => {
|
||||
const rng1 = createRNG(12345);
|
||||
const rng2 = createRNG(12345);
|
||||
|
||||
const sequence1 = Array.from({ length: 10 }, () => rng1.next());
|
||||
const sequence2 = Array.from({ length: 10 }, () => rng2.next());
|
||||
|
||||
expect(sequence1).toEqual(sequence2);
|
||||
});
|
||||
|
||||
it('should produce different sequences with different seeds', () => {
|
||||
const rng1 = createRNG(12345);
|
||||
const rng2 = createRNG(54321);
|
||||
|
||||
const sequence1 = Array.from({ length: 10 }, () => rng1.next());
|
||||
const sequence2 = Array.from({ length: 10 }, () => rng2.next());
|
||||
|
||||
expect(sequence1).not.toEqual(sequence2);
|
||||
});
|
||||
|
||||
it('should reset seed with setSeed', () => {
|
||||
const rng = createRNG(42);
|
||||
const firstSequence = Array.from({ length: 5 }, () => rng.next());
|
||||
|
||||
rng.setSeed(42);
|
||||
const secondSequence = Array.from({ length: 5 }, () => rng.next());
|
||||
|
||||
expect(firstSequence).toEqual(secondSequence);
|
||||
});
|
||||
|
||||
it('should work as callable function', () => {
|
||||
const rng = createRNG(42);
|
||||
rng(123);
|
||||
expect(rng.getSeed()).toBe(123);
|
||||
});
|
||||
|
||||
it('should generate uniformly distributed integers', () => {
|
||||
const rng = createRNG(42);
|
||||
const buckets = new Array(10).fill(0);
|
||||
const iterations = 10000;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const num = rng.nextInt(10);
|
||||
buckets[num]++;
|
||||
}
|
||||
|
||||
// 每个桶应该大约有 10% 的值
|
||||
const expected = iterations / 10;
|
||||
const tolerance = expected * 0.3; // 30% 容差
|
||||
|
||||
buckets.forEach((count, index) => {
|
||||
expect(count).toBeGreaterThan(expected - tolerance);
|
||||
expect(count).toBeLessThan(expected + tolerance);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue