Initial commit: boardgame-core with build fixes

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
hyper 2026-03-31 18:01:57 +08:00
commit d27948fbfc
39 changed files with 8545 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
dist/

182
README.md Normal file
View File

@ -0,0 +1,182 @@
# boardgame-core
基于 Preact Signals 的桌游状态管理库。
## 特性
- **响应式状态管理**: 使用 [@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

51
commands/card.play.json Normal file
View File

@ -0,0 +1,51 @@
{
"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
}
}

81
commands/cli/game.cli Normal file
View File

@ -0,0 +1,81 @@
# 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

57
commands/cli/moves.cli Normal file
View File

@ -0,0 +1,57 @@
# 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

72
commands/combo.move.json Normal file
View File

@ -0,0 +1,72 @@
{
"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"
}
}

22
commands/move.meeple.json Normal file
View File

@ -0,0 +1,22 @@
{
"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
}
}

View File

@ -0,0 +1,44 @@
{
"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"
}
}

View File

@ -0,0 +1,42 @@
{
"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
}
}

44
commands/setup.game.json Normal file
View File

@ -0,0 +1,44 @@
{
"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"
}
}

43
commands/tile.place.json Normal file
View File

@ -0,0 +1,43 @@
{
"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
}
}

2767
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "boardgame-core",
"version": "1.0.0",
"description": "A state management library for board games using Preact Signals",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsup",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@preact/signals-core": "^1.5.1",
"boardgame-core": "file:"
},
"devDependencies": {
"tsup": "^8.0.2",
"typescript": "^5.3.3",
"vitest": "^1.3.1"
},
"keywords": [
"boardgame",
"state-management",
"preact",
"signals"
],
"license": "MIT"
}

View File

@ -0,0 +1,95 @@
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);
}

View File

@ -0,0 +1,222 @@
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);
}

View File

@ -0,0 +1,209 @@
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;
}

View File

@ -0,0 +1,86 @@
/**
* 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;
}

115
src/commands/Command.ts Normal file
View File

@ -0,0 +1,115 @@
/**
*
*/
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;
}

View File

@ -0,0 +1,301 @@
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);
}
}

203
src/commands/CommandLog.ts Normal file
View File

@ -0,0 +1,203 @@
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();
}

View File

@ -0,0 +1,163 @@
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();
}

View File

@ -0,0 +1,209 @@
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();
}

View File

@ -0,0 +1,485 @@
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...]
* Partmeeple/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,
];

View File

@ -0,0 +1,285 @@
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);
}

312
src/core/GameState.ts Normal file
View File

@ -0,0 +1,312 @@
import { signal, Signal, computed } from '@preact/signals-core';
import type { Part } from './Part';
import type { Placement } from './Placement';
import type { Region } from './Region';
/**
*
*/
export interface GameStateData {
id: string;
name: string;
phase?: string;
metadata?: Record<string, unknown>;
}
/**
*
* Parts, Regions, Placements
*/
export class GameState {
/** 游戏基本信息 */
data: Signal<GameStateData>;
/** Parts 存储 */
parts: Signal<Map<string, Part>>;
/** Regions 存储 */
regions: Signal<Map<string, Region>>;
/** Placements 存储 */
placements: Signal<Map<string, Placement>>;
constructor(gameData: GameStateData) {
this.data = signal(gameData);
this.parts = signal(new Map());
this.regions = signal(new Map());
this.placements = signal(new Map());
}
// ========== Part 相关方法 ==========
/**
* Part
*/
addPart(part: Part): void {
const parts = new Map(this.parts.value);
parts.set(part.id, part);
this.parts.value = parts;
}
/**
* Part
*/
getPart(partId: string): Part | undefined {
return this.parts.value.get(partId);
}
/**
* Part
*/
removePart(partId: string): void {
const parts = new Map(this.parts.value);
parts.delete(partId);
this.parts.value = parts;
}
/**
* Part
*/
updatePart<T extends Part>(partId: string, updates: Partial<T>): void {
const part = this.parts.value.get(partId);
if (part) {
const updated = { ...part, ...updates } as T;
const parts = new Map(this.parts.value);
parts.set(partId, updated);
this.parts.value = parts;
}
}
// ========== Region 相关方法 ==========
/**
* Region
*/
addRegion(region: Region): void {
const regions = new Map(this.regions.value);
regions.set(region.id, region);
this.regions.value = regions;
}
/**
* Region
*/
getRegion(regionId: string): Region | undefined {
return this.regions.value.get(regionId);
}
/**
* Region
*/
removeRegion(regionId: string): void {
const regions = new Map(this.regions.value);
regions.delete(regionId);
this.regions.value = regions;
}
// ========== Placement 相关方法 ==========
/**
* Placement
*/
addPlacement(placement: Placement): void {
const placements = new Map(this.placements.value);
placements.set(placement.id, placement);
this.placements.value = placements;
}
/**
* Placement
*/
getPlacement(placementId: string): Placement | undefined {
return this.placements.value.get(placementId);
}
/**
* Placement
*/
removePlacement(placementId: string): void {
const placement = this.placements.value.get(placementId);
if (placement) {
// 从 Region 中移除
const region = this.regions.value.get(placement.regionId);
if (region) {
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 === '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;
}
}
}
const placements = new Map(this.placements.value);
placements.delete(placementId);
this.placements.value = placements;
}
/**
* Placement
*/
updatePlacement(placementId: string, updates: Partial<Placement>): void {
const placement = this.placements.value.get(placementId);
if (placement) {
const updated = { ...placement, ...updates };
const placements = new Map(this.placements.value);
placements.set(placementId, updated);
this.placements.value = placements;
}
}
/**
* Placement Part
*/
updatePlacementPart(placementId: string, part: Part | null): void {
const placement = this.placements.value.get(placementId);
if (placement) {
const updated = { ...placement, part };
const placements = new Map(this.placements.value);
placements.set(placementId, updated);
this.placements.value = placements;
}
}
/**
* Placement Region
*/
movePlacement(placementId: string, targetRegionId: string, key?: string): void {
const placement = this.placements.value.get(placementId);
if (!placement) {
throw new Error(`Placement ${placementId} not found`);
}
const sourceRegion = this.regions.value.get(placement.regionId);
const targetRegion = this.regions.value.get(targetRegionId);
if (!targetRegion) {
throw new Error(`Region ${targetRegionId} not found`);
}
// 从源 Region 移除
if (sourceRegion) {
const current = sourceRegion.placements.value;
const index = current.indexOf(placementId);
if (index !== -1) {
const updated = [...current];
updated.splice(index, 1);
sourceRegion.placements.value = updated;
}
// 清理源 keyed region 的 slot
if (sourceRegion.type === 'keyed' && sourceRegion.slots) {
const slots = new Map(sourceRegion.slots.value);
for (const [k, value] of slots.entries()) {
if (value === placementId) {
slots.set(k, null);
break;
}
}
sourceRegion.slots.value = slots;
}
}
// 添加到目标 Region
if (targetRegion.type === 'keyed') {
if (key === undefined) {
throw new Error('Key is required for keyed regions');
}
if (targetRegion.slots) {
const slots = new Map(targetRegion.slots.value);
slots.set(key, placementId);
targetRegion.slots.value = slots;
}
}
const targetPlacements = [...targetRegion.placements.value, placementId];
targetRegion.placements.value = targetPlacements;
// 更新 Placement 的 regionId
const updated = { ...placement, regionId: targetRegionId };
if (key !== undefined) {
updated.metadata = { ...updated.metadata, key };
}
const placements = new Map(this.placements.value);
placements.set(placementId, updated);
this.placements.value = placements;
}
// ========== 计算属性 ==========
/**
* Region Placements
*/
getPlacementsInRegion(regionId: string): Placement[] {
const region = this.regions.value.get(regionId);
if (!region) {
return [];
}
const placementIds = region.placements.value;
return placementIds
.map((id) => this.placements.value.get(id))
.filter((p): p is Placement => p !== undefined);
}
/**
* Part Placements
*/
getPlacementsOfPart(partId: string): Placement[] {
const allPlacements = Array.from(this.placements.value.values());
return allPlacements.filter((p) => p.partId === partId);
}
/**
* Region Placement
*/
createPlacementCountSignal(regionId: string): Signal<number> {
const region = this.regions.value.get(regionId);
if (!region) {
return signal(0);
}
return computed(() => region.placements.value.length);
}
// ========== 游戏状态管理 ==========
/**
*
*/
setPhase(phase: string): void {
this.data.value = { ...this.data.value, phase };
}
/**
*
*/
updateMetadata(updates: Record<string, unknown>): void {
this.data.value = {
...this.data.value,
metadata: { ...this.data.value.metadata, ...updates },
};
}
}
/**
*
*/
export function createGameState(data: GameStateData): GameState {
return new GameState(data);
}

103
src/core/Part.ts Normal file
View File

@ -0,0 +1,103 @@
import { signal } from '@preact/signals-core';
/**
* Part
*/
export enum PartType {
Meeple = 'meeple',
Card = 'card',
Tile = 'tile',
}
/**
* Part
*/
export interface PartBase {
id: string;
type: PartType;
name?: string;
metadata?: Record<string, unknown>;
}
/**
* Meeple
*/
export interface MeeplePart extends PartBase {
type: PartType.Meeple;
color: string;
}
/**
* Card
*/
export interface CardPart extends PartBase {
type: PartType.Card;
suit?: string;
value?: number | string;
}
/**
* Tile
*/
export interface TilePart extends PartBase {
type: PartType.Tile;
pattern?: string;
rotation?: number;
}
/**
* Part
*/
export type Part = MeeplePart | CardPart | TilePart;
/**
* Part
*/
export type PartSignal = ReturnType<typeof signal<Part>>;
/**
* Part
*/
export function createPart<T extends Part>(part: T): T {
return part;
}
/**
* Meeple Part
*/
export function createMeeple(id: string, color: string, options?: { name?: string; metadata?: Record<string, unknown> }): MeeplePart {
return {
id,
type: PartType.Meeple,
color,
...options,
};
}
/**
* Card Part
*/
export function createCard(
id: string,
options?: { suit?: string; value?: number | string; name?: string; metadata?: Record<string, unknown> }
): CardPart {
return {
id,
type: PartType.Card,
...options,
};
}
/**
* Tile Part
*/
export function createTile(
id: string,
options?: { pattern?: string; rotation?: number; name?: string; metadata?: Record<string, unknown> }
): TilePart {
return {
id,
type: PartType.Tile,
...options,
};
}

88
src/core/Placement.ts Normal file
View File

@ -0,0 +1,88 @@
import { signal, Signal } from '@preact/signals-core';
import type { Part } from './Part';
/**
* Placement
*/
export interface Position {
x: number;
y: number;
}
/**
* Placement
*/
export interface PlacementProperties {
id: string;
partId: string;
regionId: string;
position?: Position;
rotation?: number;
faceUp?: boolean;
metadata?: Record<string, unknown>;
}
/**
* Placement
*/
export interface Placement extends PlacementProperties {
part: Part | null;
}
/**
* Placement
*/
export type PlacementSignal = Signal<Placement>;
/**
* Placement
*/
export function createPlacement(properties: {
id: string;
partId: string;
regionId: string;
part: Part;
position?: Position;
rotation?: number;
faceUp?: boolean;
metadata?: Record<string, unknown>;
}): Placement {
return {
id: properties.id,
partId: properties.partId,
regionId: properties.regionId,
part: properties.part,
position: properties.position,
rotation: properties.rotation ?? 0,
faceUp: properties.faceUp ?? true,
metadata: properties.metadata,
};
}
/**
* Placement Part
*/
export function updatePlacementPart(placement: Placement, part: Part | null): void {
placement.part = part;
}
/**
* Placement
*/
export function updatePlacementPosition(placement: Placement, position: Position): void {
placement.position = position;
}
/**
* Placement
*/
export function updatePlacementRotation(placement: Placement, rotation: number): void {
placement.rotation = rotation;
}
/**
* Placement
*/
export function flipPlacement(placement: Placement): void {
placement.faceUp = !placement.faceUp;
}

155
src/core/Region.ts Normal file
View File

@ -0,0 +1,155 @@
import { signal, Signal } from '@preact/signals-core';
import type { Placement } from './Placement';
/**
* Region
*/
export enum RegionType {
/**
* Keyed Region - key
*
*/
Keyed = 'keyed',
/**
* Unkeyed Region -
*
*/
Unkeyed = 'unkeyed',
}
/**
* Region
*/
export interface RegionProperties {
id: string;
type: RegionType;
name?: string;
capacity?: number;
metadata?: Record<string, unknown>;
}
/**
* Keyed Region
*/
export interface Slot {
key: string;
placementId: string | null;
}
/**
* Region
*/
export interface Region extends RegionProperties {
placements: Signal<string[]>; // Placement ID 列表
slots?: Signal<Map<string, string | null>>; // Keyed Region 专用key -> placementId
}
/**
* Region
*/
export function createRegion(properties: RegionProperties): Region {
const region: Region = {
...properties,
placements: signal<string[]>([]),
};
if (properties.type === RegionType.Keyed) {
region.slots = signal<Map<string, string | null>>(new Map());
}
return region;
}
/**
* Placement ID Region (unkeyed)
*/
export function addPlacementToRegion(region: Region, placementId: string): void {
if (region.type === RegionType.Keyed) {
throw new Error('Cannot use addPlacementToRegion on a keyed region. Use setSlot instead.');
}
const current = region.placements.value;
if (region.capacity !== undefined && current.length >= region.capacity) {
throw new Error(`Region ${region.id} has reached its capacity of ${region.capacity}`);
}
region.placements.value = [...current, placementId];
}
/**
* Region Placement ID
*/
export function removePlacementFromRegion(region: Region, placementId: string): void {
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
*/
export function setSlot(region: Region, key: string, placementId: string | null): void {
if (region.type !== RegionType.Keyed || !region.slots) {
throw new Error('Cannot use setSlot 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 getSlot(region: Region, key: string): string | null {
if (region.type !== RegionType.Keyed || !region.slots) {
throw new Error('Cannot use getSlot on an unkeyed region.');
}
return region.slots.value.get(key) ?? null;
}
/**
* Region
*/
export function clearRegion(region: Region): void {
region.placements.value = [];
if (region.slots) {
region.slots.value = new Map();
}
}
/**
* Region Placement
*/
export function getPlacementCount(region: Region): number {
return region.placements.value.length;
}
/**
* Region
*/
export function isRegionEmpty(region: Region): boolean {
return region.placements.value.length === 0;
}
/**
* Region
*/
export function isRegionFull(region: Region): boolean {
if (region.capacity === undefined) {
return false;
}
return region.placements.value.length >= region.capacity;
}

142
src/index.ts Normal file
View File

@ -0,0 +1,142 @@
/**
* boardgame-core
* Preact Signals
*/
// Core types
export { PartType } from './core/Part';
export type {
Part,
PartBase,
MeeplePart,
CardPart,
TilePart,
PartSignal,
} from './core/Part';
export { RegionType } from './core/Region';
export type { Region, RegionProperties, Slot } from './core/Region';
export type { Placement, PlacementProperties, Position, PlacementSignal } from './core/Placement';
export type { GameStateData } from './core/GameState';
// Core classes and functions
export {
createPart,
createMeeple,
createCard,
createTile,
} from './core/Part';
export { createRegion, createRegion as createRegionCore } from './core/Region';
export type { Region as RegionClass } from './core/Region';
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';

View File

@ -0,0 +1,329 @@
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();
});
});
});

View File

@ -0,0 +1,278 @@
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);
});
});
});
});

View File

@ -0,0 +1,160 @@
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');
});
});
});

View File

@ -0,0 +1,254 @@
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([]);
});
});
});

149
tests/part.actions.test.ts Normal file
View File

@ -0,0 +1,149 @@
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');
});
});
});

View File

@ -0,0 +1,422 @@
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([]);
});
});
});

View File

@ -0,0 +1,302 @@
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);
});
});
});

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}

9
tsup.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
});

9
vitest.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
},
});