Compare commits

...

3 Commits

Author SHA1 Message Date
hypercross b9105efd03 fix: fix tests 2026-04-04 22:23:15 +08:00
hypercross a02403d2c7 docs: update docs 2026-04-04 22:19:41 +08:00
hypercross e58690c9c6 fix: impl 2026-04-04 22:11:02 +08:00
10 changed files with 605 additions and 199 deletions

View File

@ -42,6 +42,8 @@ const error = host.onInput('play X 1 2');
|---|---|
| [使用 GameHost](docs/game-host.md) | GameHost 生命周期、响应式状态、事件处理 |
| [编写 GameModule](docs/game-module.md) | 定义状态、注册命令、prompt 系统、Part/Region 使用 |
| [棋子、区域与 RNG](docs/parts-regions-rng.md) | Part 创建、池管理、区域操作、RNG 使用 |
| [动画与状态同步](docs/animation-sync.md) | produceAsync 和中断机制 |
| [API 参考](docs/api-reference.md) | 所有导出 API 的完整列表 |
| [开发指南](docs/development.md) | 安装、构建脚本、测试命令 |

View File

@ -6,11 +6,13 @@
|---|---|
| `IGameContext` | 游戏上下文基础接口 |
| `createGameContext(registry, initialState?)` | 创建游戏上下文实例 |
| `createGameCommandRegistry<TState>()` | 创建命令注册表,返回带 `.add()` 的对象 |
| `createGameModule(module)` | 辅助函数,标记一个对象为 GameModule |
| `createGameContextFromModule(module)` | 从 GameModule 创建游戏上下文 |
| `createGameCommandRegistry<TState>()` | 创建命令注册表 |
| `createGameModule(module)` | 辅助函数,标记 GameModule |
| `GameHost<TState>` | 游戏生命周期管理类 |
| `createGameHost(module, options?)` | 从 GameModule 创建 GameHost |
| `GameHostStatus` | 类型: `'created' \| 'running' \| 'disposed'` |
| `GameModule` | 游戏模块类型 |
## 棋子 (Parts)
@ -18,13 +20,17 @@
|---|---|
| `Part<TMeta>` | 游戏棋子类型 |
| `PartTemplate<TMeta>` | 创建棋子的模板类型 |
| `PartPool<TMeta>` | 棋子池 |
| `PartPool<TMeta>` | 棋子池类型 |
| `createPart(template, id)` | 创建单个棋子 |
| `createParts(template, count, idPrefix)` | 批量创建相同棋子 |
| `createPartsFromTable(template, table, idField?)` | 从表格数据创建棋子 |
| `createPartPool(template, count, idPrefix)` | 创建棋子池 |
| `mergePartPools(...pools)` | 合并多个棋子池 |
| `findPartById(parts, id)` | 按 ID 查找棋子 |
| `isCellOccupied(parts, regionId, position)` | 检查格子是否被占用 |
| `getPartAtPosition(parts, regionId, position)` | 获取格子上的棋子 |
| `isCellOccupiedByRegion(region, position)` | O(1) 检查格子占用 |
| `getPartAtPositionInRegion(region, parts, position)` | O(1) 获取棋子 |
| `flip(part)` / `flipTo(part, side)` / `roll(part, rng)` | 翻面/随机面 |
## 区域 (Regions)
@ -35,20 +41,38 @@
| `createRegion(id, axes)` | 创建区域 |
| `applyAlign(region, parts)` | 紧凑排列 |
| `shuffle(region, parts, rng)` | 打乱位置 |
| `moveToRegion(part, sourceRegion?, targetRegion, position?)` | 移动棋子到其他区域 |
| `moveToRegionAll(parts, sourceRegion?, targetRegion, positions?)` | 批量移动 |
| `removeFromRegion(part, region)` | 从区域移除棋子 |
| `moveToRegion(part, sourceRegion?, targetRegion, position?)` | 移动棋子到区域 |
## 命令 (Commands)
| 导出 | 说明 |
|---|---|
| `Command` / `CommandSchema` / `CommandResult` | 命令相关类型 |
| `CommandParamSchema` / `CommandOptionSchema` / `CommandFlagSchema` | 命令参数类型 |
| `parseCommand(input)` | 解析命令字符串 |
| `parseCommandSchema(schema)` | 解析 Schema 字符串 |
| `validateCommand(cmd, schema)` | 验证命令 |
| `Command` / `CommandSchema` / `CommandResult` | 命令相关类型 |
| `PromptEvent` | 玩家输入提示事件 |
| `createRNG(seed?)` | 创建种子 RNG |
| `parseCommandWithSchema(input, schema)` | 解析并应用 Schema |
| `applyCommandSchema(cmd, schema)` | 应用 Schema 到命令 |
### 命令运行器
| 导出 | 说明 |
|---|---|
| `CommandRunner<TContext, TResult>` | 命令运行器类型 |
| `CommandRunnerHandler` | 命令处理器 |
| `CommandRunnerContext` / `CommandRunnerContextExport` | 命令运行器上下文 |
| `CommandRegistry` | 命令注册表类型 |
| `PromptEvent` / `CommandRunnerEvents` | 提示事件类型 |
| `PromptValidator<T>` | 提示验证器类型 |
| `createCommandRegistry()` | 创建命令注册表 |
| `registerCommand(registry, name, handler)` | 注册命令 |
| `unregisterCommand(registry, name)` | 注销命令 |
| `hasCommand(registry, name)` | 检查命令是否存在 |
| `getCommand(registry, name)` | 获取命令 |
| `runCommand(ctx, input)` | 运行命令 |
| `runCommandParsed(ctx, cmd)` | 运行已解析命令 |
| `createCommandRunnerContext(registry, ctx)` | 创建命令运行器上下文 |
## MutableSignal
@ -62,12 +86,21 @@
| 成员 | 说明 |
|---|---|
| `value: T` | 获取当前值(继承自 Signal |
| `value: T` | 获取当前值 |
| `produce(fn: (draft: T) => void): void` | 同步不可变更新 |
| `addInterruption(promise: Promise<void>): void` | 添加中断`produceAsync` 会等待它 |
| `clearInterruptions(): void` | 清除所有未完成的中断 |
| `produceAsync(fn: (draft: T) => void): Promise<void>` | 等待所有 interruption 完成后更新状态 |
| `addInterruption(promise: Promise<void>): void` | 添加中断 |
| `clearInterruptions(): void` | 清除所有中断 |
| `produceAsync(fn: (draft: T) => void): Promise<void>` | 等待中断完成后更新 |
### GameHost 上的中断方法
### GameHost 中断代理
`GameHost` 直接代理了 `addInterruption``clearInterruptions`,供 UI 层使用,无需访问内部 `MutableSignal`。详见 [动画与状态更新同步](./animation-sync.md)。
`GameHost` 直接代理 `addInterruption``clearInterruptions`,供 UI 层使用。
详见 [动画与状态更新同步](./animation-sync.md)。
## 工具
| 导出 | 说明 |
|---|---|
| `RNG` | 随机数生成器接口 |
| `createRNG(seed?)` | 创建种子 RNG |
| `Mulberry32RNG` | Mulberry32 算法实现 |

View File

@ -36,17 +36,52 @@ pool.return(piece); // 放回
pool.remaining(); // 剩余数量
```
## 从表格数据创建 Part
```ts
const parts = createPartsFromTable(
{ regionId: 'board', owner: 'white' },
[
{ id: 'p1', position: [0, 0] },
{ id: 'p2', position: [1, 1] },
],
'id'
);
```
## 查询棋子
```ts
// O(n) 遍历查找
findPartById(state.parts, 'piece-1');
getPartAtPosition(state.parts, 'board', [1, 1]);
isCellOccupied(state.parts, 'board', [1, 1]);
// O(1) 使用 Region.partMap 查找
getPartAtPositionInRegion(board, state.parts, [1, 1]);
isCellOccupiedByRegion(board, [1, 1]);
```
## 区域操作
```ts
import { applyAlign, shuffle, moveToRegion, isCellOccupied } from 'boardgame-core';
import { applyAlign, shuffle, moveToRegion } from 'boardgame-core';
isCellOccupied(state.parts, 'board', [1, 1]);
applyAlign(handRegion, state.parts); // 紧凑排列
shuffle(deckRegion, state.parts, rng); // 打乱
moveToRegion(piece, sourceRegion, targetRegion, [0, 0]);
```
## 翻面与掷骰
```ts
import { flip, flipTo, roll } from 'boardgame-core';
flip(piece); // 翻到下一面
flipTo(piece, 2); // 翻到指定面
roll(piece, rng); // 随机面
```
## RNG
```ts

View File

@ -32,12 +32,15 @@ async function place(game: BoopGame, row: number, col: number, player: PlayerTyp
throw new Error(`No ${type} available in ${player}'s supply`);
}
const partId = part.id;
game.produce(state => {
// 将棋子从supply移动到棋盘
const part = state.pieces[partId];
moveToRegion(part, state.regions[player], state.regions.board, [row, col]);
});
return { row, col, player, type, partId: part.id };
return { row, col, player, type, partId };
}
const placeCommand = registry.register( 'place <row:number> <col:number> <player> <type>', place);
@ -52,12 +55,13 @@ async function boop(game: BoopGame, row: number, col: number, type: PieceType) {
for (const [dr, dc] of getNeighborPositions()) {
const nr = row + dr;
const nc = col + dc;
if (!isInBounds(nr, nc)) continue;
const part = findPartAtPosition(game, nr, nc);
// 从 state 中查找,而不是 game
const part = findPartAtPosition(state, nr, nc);
if (!part) continue;
// 小猫不能推动猫
if (type === 'kitten' && part.type === 'cat') continue;
@ -70,7 +74,7 @@ async function boop(game: BoopGame, row: number, col: number, type: PieceType) {
// 棋子被推出棋盘,返回玩家supply
booped.push(part.id);
moveToRegion(part, state.regions.board, state.regions[part.player]);
} else if (!isCellOccupied(game, newRow, newCol)) {
} else if (!isCellOccupied(state, newRow, newCol)) {
// 新位置为空,移动过去
booped.push(part.id);
moveToRegion(part, state.regions.board, state.regions.board, [newRow, newCol]);
@ -122,21 +126,23 @@ async function checkGraduates(game: BoopGame){
}
const player = whites >= WIN_LENGTH ? 'white' : blacks >= WIN_LENGTH ? 'black' : null;
if(!player) continue;
for(const [row, col] of line){
const part = findPartAtPosition(game, row, col);
part && toUpgrade.add(part.id);
}
}
game.produce(state => {
// 预先收集所有可用的猫(在盒子里的)
for(const partId of toUpgrade){
const part = state.pieces[partId];
const [row, col] = part.position;
const player = part.player;
moveToRegion(part, state.regions.board, null);
const newPart = findPartInRegion(game, '', 'cat');
// 使用下一个可用的猫
const newPart = findPartInRegion(state, '', 'kitten', player);
moveToRegion(newPart || part, null, state.regions[player], [row, col]);
}
});

View File

@ -1,4 +1,13 @@
import {BOARD_SIZE, BoopGame, BoopPart, BoopState, PieceType, WIN_LENGTH} from "@/samples/boop/data";
import {
BOARD_SIZE,
BoopGame,
BoopPart,
BoopState,
PieceType,
PlayerType,
RegionType,
WIN_LENGTH
} from "@/samples/boop/data";
const DIRS = [
[0, 1],
@ -41,14 +50,17 @@ export function* getNeighborPositions(x: number = 0, y: number = 0){
yield [x + dx, y + dy] as PT;
}
export function findPartInRegion(ctx: BoopGame | BoopState, regionId: keyof BoopGame['value']['regions'], type: PieceType): BoopPart | null {
export function findPartInRegion(ctx: BoopGame | BoopState, regionId: keyof BoopGame['value']['regions'], type: PieceType, player?: PlayerType): BoopPart | null {
const state = getState(ctx);
if(!regionId){
return Object.values(state.pieces).find(part => part.type === type && !part.regionId) || null;
return Object.values(state.pieces).find(part => match(regionId, part, type, player)) || null;
}
const id = state.regions[regionId].childIds.find(id => state.pieces[id].type === type);
const id = state.regions[regionId].childIds.find(id => match(regionId, state.pieces[id], type, player));
return id ? state.pieces[id] || null : null;
}
function match(regionId: RegionType, part: BoopPart, type: PieceType, player?: PlayerType){
return regionId === part.regionId && part.type === type && (!player || part.player === player);
}
export function findPartAtPosition(ctx: BoopGame | BoopState, row: number, col: number): BoopPart | null {
const state = getState(ctx);

View File

@ -19,7 +19,7 @@ function createTestHost() {
function waitForPromptEvent(host: GameHost<any>): Promise<PromptEvent> {
return new Promise(resolve => {
host.commands.on('prompt', resolve);
host.context._commands.on('prompt', resolve);
});
}
@ -28,10 +28,10 @@ describe('GameHost', () => {
it('should create host with initial state', () => {
const { host } = createTestHost();
expect(host.state.value.currentPlayer).toBe('X');
expect(host.state.value.winner).toBeNull();
expect(host.state.value.turn).toBe(0);
expect(Object.keys(host.state.value.parts).length).toBe(0);
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.winner).toBeNull();
expect(host.context._state.value.turn).toBe(0);
expect(Object.keys(host.context._state.value.parts).length).toBe(0);
});
it('should have status "created" by default', () => {
@ -59,7 +59,7 @@ describe('GameHost', () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.commands.run('setup');
const runPromise = host.context._commands.run('setup');
const promptEvent = await promptPromise;
expect(promptEvent.schema.name).toBe('play');
@ -81,7 +81,7 @@ describe('GameHost', () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.commands.run('setup');
const runPromise = host.context._commands.run('setup');
const promptEvent = await promptPromise;
@ -106,7 +106,7 @@ describe('GameHost', () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.commands.run('setup');
const runPromise = host.context._commands.run('setup');
const promptEvent = await promptPromise;
const schema = host.activePromptSchema.value;
@ -131,7 +131,7 @@ describe('GameHost', () => {
// First setup - make one move
let promptPromise = waitForPromptEvent(host);
let runPromise = host.commands.run('setup');
let runPromise = host.context._commands.run('setup');
let promptEvent = await promptPromise;
// Make a move
@ -144,7 +144,7 @@ describe('GameHost', () => {
let result = await runPromise;
expect(result.success).toBe(false); // Cancelled
expect(Object.keys(host.state.value.parts).length).toBe(1);
expect(Object.keys(host.context._state.value.parts).length).toBe(1);
// Setup listener before calling setup
const newPromptPromise = waitForPromptEvent(host);
@ -153,10 +153,10 @@ describe('GameHost', () => {
await host.setup('setup');
// State should be back to initial
expect(host.state.value.currentPlayer).toBe('X');
expect(host.state.value.winner).toBeNull();
expect(host.state.value.turn).toBe(0);
expect(Object.keys(host.state.value.parts).length).toBe(0);
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.winner).toBeNull();
expect(host.context._state.value.turn).toBe(0);
expect(Object.keys(host.context._state.value.parts).length).toBe(0);
// New game should be running and prompting
const newPrompt = await newPromptPromise;
@ -168,7 +168,7 @@ describe('GameHost', () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.commands.run('setup');
const runPromise = host.context._commands.run('setup');
await promptPromise;
@ -184,8 +184,8 @@ describe('GameHost', () => {
}
// State should be reset
expect(host.state.value.currentPlayer).toBe('X');
expect(host.state.value.turn).toBe(0);
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.turn).toBe(0);
});
it('should throw error when disposed', async () => {
@ -208,7 +208,7 @@ describe('GameHost', () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.commands.run('setup');
const runPromise = host.context._commands.run('setup');
await promptPromise;
@ -289,12 +289,12 @@ describe('GameHost', () => {
const { host } = createTestHost();
// Initial state
expect(host.state.value.currentPlayer).toBe('X');
expect(host.state.value.turn).toBe(0);
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.turn).toBe(0);
// Make a move
const promptPromise = waitForPromptEvent(host);
const runPromise = host.commands.run('setup');
const runPromise = host.context._commands.run('setup');
const promptEvent = await promptPromise;
promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
@ -307,9 +307,9 @@ describe('GameHost', () => {
const result = await runPromise;
expect(result.success).toBe(false); // Cancelled
expect(host.state.value.currentPlayer).toBe('O');
expect(host.state.value.turn).toBe(1);
expect(Object.keys(host.state.value.parts).length).toBe(1);
expect(host.context._state.value.currentPlayer).toBe('O');
expect(host.context._state.value.turn).toBe(1);
expect(Object.keys(host.context._state.value.parts).length).toBe(1);
});
it('should update activePromptSchema reactively', async () => {
@ -320,7 +320,7 @@ describe('GameHost', () => {
// Start a command that triggers prompt
const promptPromise = waitForPromptEvent(host);
const runPromise = host.commands.run('setup');
const runPromise = host.context._commands.run('setup');
await promptPromise;
@ -330,7 +330,7 @@ describe('GameHost', () => {
// Cancel and wait
const cancelEvent = host.activePromptSchema.value;
host.commands._cancel();
host.context._commands._cancel();
try {
await runPromise;
} catch {
@ -347,10 +347,10 @@ describe('GameHost', () => {
const { host } = createTestHost();
// Initial state
expect(host.state.value.currentPlayer).toBe('X');
expect(host.state.value.winner).toBeNull();
expect(host.state.value.turn).toBe(0);
expect(Object.keys(host.state.value.parts).length).toBe(0);
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.winner).toBeNull();
expect(host.context._state.value.turn).toBe(0);
expect(Object.keys(host.context._state.value.parts).length).toBe(0);
// X wins diagonally: (0,0), (1,1), (2,2)
// O plays: (0,1), (2,1)
@ -364,12 +364,12 @@ describe('GameHost', () => {
// Track prompt events in a queue
const promptEvents: PromptEvent[] = [];
host.commands.on('prompt', (e) => {
host.context._commands.on('prompt', (e) => {
promptEvents.push(e);
});
// Start setup command (runs game loop until completion)
const setupPromise = host.commands.run('setup');
const setupPromise = host.context._commands.run('setup');
for (let i = 0; i < moves.length; i++) {
// Wait until the next prompt event arrives
@ -393,12 +393,12 @@ describe('GameHost', () => {
}
// Final state checks
expect(host.state.value.winner).toBe('X');
expect(host.state.value.currentPlayer).toBe('X');
expect(Object.keys(host.state.value.parts).length).toBe(5);
expect(host.context._state.value.winner).toBe('X');
expect(host.context._state.value.currentPlayer).toBe('X');
expect(Object.keys(host.context._state.value.parts).length).toBe(5);
// Verify winning diagonal
const parts = Object.values(host.state.value.parts);
const parts = Object.values(host.context._state.value.parts);
const xPieces = parts.filter(p => p.player === 'X');
expect(xPieces).toHaveLength(3);
expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([0, 0]))).toBe(true);
@ -415,7 +415,7 @@ describe('GameHost', () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.commands.run('setup');
const runPromise = host.context._commands.run('setup');
const promptEvent = await promptPromise;
expect(promptEvent.currentPlayer).toBe('X');
@ -433,7 +433,7 @@ describe('GameHost', () => {
// First prompt - X's turn
let promptPromise = waitForPromptEvent(host);
let runPromise = host.commands.run('setup');
let runPromise = host.context._commands.run('setup');
let promptEvent = await promptPromise;
expect(promptEvent.currentPlayer).toBe('X');
expect(host.activePromptPlayer.value).toBe('X');

View File

@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { createGameContext, createGameCommand, createGameCommandRegistry } from '@/core/game';
import type { PromptEvent } from '@/utils/command';
import { createGameContext, createGameCommandRegistry, IGameContext } from '@/core/game';
import type { PromptEvent, Command } from '@/utils/command';
type MyState = {
score: number;
@ -9,56 +9,55 @@ type MyState = {
describe('createGameContext', () => {
it('should create a game context with state', () => {
const { registry } = createGameCommandRegistry();
const registry = createGameCommandRegistry();
const ctx = createGameContext(registry);
expect(ctx.state).not.toBeNull();
expect(ctx.state.value).toBeDefined();
expect(ctx._state).not.toBeNull();
expect(ctx._state.value).toBeDefined();
});
it('should wire commands to the context', () => {
const { registry } = createGameCommandRegistry();
const registry = createGameCommandRegistry();
const ctx = createGameContext(registry);
expect(ctx.commands).not.toBeNull();
expect(ctx.commands.registry).toBe(registry);
expect(ctx.commands.context).toBe(ctx.state);
expect(ctx._commands).not.toBeNull();
expect(ctx._commands.registry).toBe(registry);
});
it('should accept initial state as an object', () => {
const { registry } = createGameCommandRegistry<MyState>();
const registry = createGameCommandRegistry<MyState>();
const ctx = createGameContext<MyState>(registry, {
score: 0,
round: 1,
});
expect(ctx.state.value.score).toBe(0);
expect(ctx.state.value.round).toBe(1);
expect(ctx._state.value.score).toBe(0);
expect(ctx._state.value.round).toBe(1);
});
it('should accept initial state as a factory function', () => {
const { registry } = createGameCommandRegistry<MyState>();
const registry = createGameCommandRegistry<MyState>();
const ctx = createGameContext<MyState>(registry, () => ({
score: 10,
round: 3,
}));
expect(ctx.state.value.score).toBe(10);
expect(ctx.state.value.round).toBe(3);
expect(ctx._state.value.score).toBe(10);
expect(ctx._state.value.round).toBe(3);
});
it('should forward prompt events via listener', async () => {
const { registry } = createGameCommandRegistry();
const registry = createGameCommandRegistry();
const ctx = createGameContext(registry);
createGameCommand(registry, 'test <value>', async function () {
return this.prompt('prompt <answer>');
registry.register('test <value>', async function (_ctx, value) {
return this.prompt<string>('prompt <answer>', () => 'ok');
});
const promptPromise = new Promise<PromptEvent>(resolve => {
ctx.commands.on('prompt', resolve);
ctx._commands.on('prompt', resolve);
});
const runPromise = ctx.commands.run('test hello');
const runPromise = ctx.run('test hello');
const promptEvent = await promptPromise;
expect(promptEvent).not.toBeNull();
@ -69,45 +68,43 @@ describe('createGameContext', () => {
const result = await runPromise;
expect(result.success).toBe(true);
if (result.success) {
expect((result.result as any).params[0]).toBe('yes');
}
});
});
describe('createGameCommand', () => {
it('should run a command with access to game context', async () => {
const { registry } = createGameCommandRegistry<{ marker: string }>();
const ctx = createGameContext(registry, { marker: '' });
createGameCommand(registry, 'set-marker <id>', async function (cmd) {
const id = cmd.params[0] as string;
this.context.produce(state => {
const registry = createGameCommandRegistry<{ marker: string }>();
registry.register('set-marker <id>', async function (ctx, id) {
ctx.produce(state => {
state.marker = id;
});
return id;
});
const result = await ctx.commands.run('set-marker board');
const ctx = createGameContext(registry, { marker: '' });
const result = await ctx.run('set-marker board');
if (!result.success) {
console.error('Error:', result.error);
}
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('board');
}
expect(ctx.state.value.marker).toBe('board');
expect(ctx._state.value.marker).toBe('board');
});
it('should run a typed command with extended context', async () => {
const { registry } = createGameCommandRegistry<MyState>();
const registry = createGameCommandRegistry<MyState>();
createGameCommand<MyState, number>(
registry,
registry.register(
'add-score <amount:number>',
async function (cmd) {
const amount = cmd.params[0] as number;
this.context.produce(state => {
async function (ctx, amount) {
ctx.produce(state => {
state.score += amount;
});
return this.context.value.score;
return ctx.value.score;
}
);
@ -116,19 +113,19 @@ describe('createGameCommand', () => {
round: 1,
}));
const result = await ctx.commands.run('add-score 5');
const result = await ctx.run('add-score 5');
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe(5);
}
expect(ctx.state.value.score).toBe(5);
expect(ctx._state.value.score).toBe(5);
});
it('should return error for unknown command', async () => {
const { registry } = createGameCommandRegistry();
const registry = createGameCommandRegistry();
const ctx = createGameContext(registry);
const result = await ctx.commands.run('nonexistent');
const result = await ctx.run('nonexistent');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('nonexistent');

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { createRegion, applyAlign, shuffle, moveToRegion, moveToRegionAll, removeFromRegion, type Region, type RegionAxis } from '@/core/region';
import { createRegion, applyAlign, shuffle, moveToRegion, type Region, type RegionAxis } from '@/core/region';
import { createRNG } from '@/utils/rng';
import { type Part } from '@/core/part';
@ -303,76 +303,4 @@ describe('Region', () => {
expect(part.position).toEqual([3]);
});
});
describe('moveToRegionAll', () => {
it('should move multiple parts to a target region', () => {
const sourceRegion = createRegion('source', [{ name: 'x' }]);
const targetRegion = createRegion('target', [{ name: 'x' }]);
const parts = {
p1: { id: 'p1', regionId: 'source', position: [0] } as Part,
p2: { id: 'p2', regionId: 'source', position: [1] } as Part,
p3: { id: 'p3', regionId: 'source', position: [2] } as Part,
};
sourceRegion.childIds.push('p1', 'p2', 'p3');
sourceRegion.partMap = { '0': 'p1', '1': 'p2', '2': 'p3' };
moveToRegionAll([parts.p1, parts.p2, parts.p3], sourceRegion, targetRegion, [[0], [1], [2]]);
expect(sourceRegion.childIds).toHaveLength(0);
expect(targetRegion.childIds).toHaveLength(3);
expect(parts.p1.position).toEqual([0]);
expect(parts.p2.position).toEqual([1]);
expect(parts.p3.position).toEqual([2]);
});
it('should keep existing positions if no positions provided', () => {
const sourceRegion = createRegion('source', [{ name: 'x' }]);
const targetRegion = createRegion('target', [{ name: 'x' }]);
const parts = {
p1: { id: 'p1', regionId: 'source', position: [5] } as Part,
p2: { id: 'p2', regionId: 'source', position: [8] } as Part,
};
sourceRegion.childIds.push('p1', 'p2');
sourceRegion.partMap = { '5': 'p1', '8': 'p2' };
moveToRegionAll([parts.p1, parts.p2], sourceRegion, targetRegion);
expect(parts.p1.position).toEqual([5]);
expect(parts.p2.position).toEqual([8]);
});
});
describe('removeFromRegion', () => {
it('should remove a part from its region', () => {
const region = createRegion('region1', [{ name: 'x' }]);
const part: Part = { id: 'p1', regionId: 'region1', position: [2] };
const parts: Record<string, Part> = { p1: part };
region.childIds.push('p1');
region.partMap['2'] = 'p1';
expect(region.childIds).toHaveLength(1);
removeFromRegion(part, region);
expect(region.childIds).toHaveLength(0);
});
it('should leave other parts unaffected', () => {
const region = createRegion('region1', [{ name: 'x' }]);
const p1 = { id: 'p1', regionId: 'region1', position: [0] } as Part;
const p2 = { id: 'p2', regionId: 'region1', position: [1] } as Part;
const p3 = { id: 'p3', regionId: 'region1', position: [2] } as Part;
region.childIds.push('p1', 'p2', 'p3');
region.partMap = { '0': 'p1', '1': 'p2', '2': 'p3' };
removeFromRegion(p2, region);
expect(region.childIds).toHaveLength(2);
expect(region.childIds).toEqual(['p1', 'p3']);
});
});
});

View File

@ -141,4 +141,394 @@ describe('Boop Game', () => {
expect(stateAfterWhite.regions.board.partMap['2,2']).toBeDefined();
});
});
describe('Kitten vs Cat Hierarchy', () => {
it('should not boop cats when placing a kitten', async () => {
const { ctx } = createTestContext();
// White places a kitten at 2,2
let promptPromise = waitForPrompt(ctx);
let runPromise = ctx.run('turn white');
let prompt = await promptPromise;
let error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} });
expect(error).toBeNull();
let result = await runPromise;
expect(result.success).toBe(true);
// Manually move white's kitten to box and replace with a cat (for testing)
ctx.produce(state => {
const whiteKitten = state.pieces['white-kitten-1'];
if (whiteKitten && whiteKitten.regionId === 'board') {
whiteKitten.type = 'cat';
}
});
// Black places a kitten at 2,3 (adjacent to the cat)
promptPromise = waitForPrompt(ctx);
runPromise = ctx.run('turn black');
prompt = await promptPromise;
error = prompt.tryCommit({ name: 'play', params: ['black', 2, 3, 'kitten'], options: {}, flags: {} });
expect(error).toBeNull();
result = await runPromise;
expect(result.success).toBe(true);
const state = ctx.value;
// White's cat should still be at 2,2 (not booped)
expect(state.regions.board.partMap['2,2']).toBe('white-kitten-1');
// Black's kitten should be at 2,3
expect(state.regions.board.partMap['2,3']).toBe('black-kitten-1');
});
it('should boop both kittens and cats when placing a cat', async () => {
const { ctx } = createTestContext();
// Manually set up: white cat at 2,3, black cat at 3,2
// First move cats to white and black supplies
ctx.produce(state => {
const whiteCat = state.pieces['white-cat-1'];
const blackCat = state.pieces['black-cat-1'];
if (whiteCat && whiteCat.regionId === '') {
whiteCat.regionId = 'white';
state.regions.white.childIds.push(whiteCat.id);
}
if (blackCat && blackCat.regionId === '') {
blackCat.regionId = 'black';
state.regions.black.childIds.push(blackCat.id);
}
});
// Now move them to the board
ctx.produce(state => {
const whiteCat = state.pieces['white-cat-1'];
const blackCat = state.pieces['black-cat-1'];
if (whiteCat && whiteCat.regionId === 'white') {
whiteCat.regionId = 'board';
whiteCat.position = [2, 3];
state.regions.board.partMap['2,3'] = whiteCat.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== whiteCat.id);
}
if (blackCat && blackCat.regionId === 'black') {
blackCat.regionId = 'board';
blackCat.position = [3, 2];
state.regions.board.partMap['3,2'] = blackCat.id;
state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== blackCat.id);
}
});
// Give white another cat for placement
ctx.produce(state => {
const whiteCat2 = state.pieces['white-cat-2'];
if (whiteCat2 && whiteCat2.regionId === '') {
whiteCat2.regionId = 'white';
state.regions.white.childIds.push(whiteCat2.id);
}
});
// White places a cat at 2,2 (should boop black's cat at 3,2 to 4,2)
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.run('turn white');
const prompt = await promptPromise;
const error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'cat'], options: {}, flags: {} });
expect(error).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
const state = ctx.value;
// Black's cat should have been booped to 4,2
expect(state.regions.board.partMap['4,2']).toBeDefined();
const pieceAt42 = state.pieces[state.regions.board.partMap['4,2']];
expect(pieceAt42?.player).toBe('black');
expect(pieceAt42?.type).toBe('cat');
});
});
describe('Boop Obstructions', () => {
it('should boop pieces to empty positions', async () => {
const { ctx } = createTestContext();
// White places at 2,2
let promptPromise = waitForPrompt(ctx);
let runPromise = ctx.run('turn white');
let prompt = await promptPromise;
let error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} });
expect(error).toBeNull();
let result = await runPromise;
expect(result.success).toBe(true);
// Check board has 1 piece after first placement
let state = ctx.value;
expect(Object.keys(state.regions.board.partMap).length).toBe(1);
// Black places at 3,3
promptPromise = waitForPrompt(ctx);
runPromise = ctx.run('turn black');
prompt = await promptPromise;
error = prompt.tryCommit({ name: 'play', params: ['black', 3, 3, 'kitten'], options: {}, flags: {} });
expect(error).toBeNull();
result = await runPromise;
expect(result.success).toBe(true);
state = ctx.value;
expect(Object.keys(state.regions.board.partMap).length).toBe(2);
// Verify the pieces are on the board (positions may vary due to boop)
const boardPieces = Object.entries(state.regions.board.partMap);
expect(boardPieces.length).toBe(2);
// Find black's piece
const blackPiece = boardPieces.find(([pos, id]) => state.pieces[id]?.player === 'black');
expect(blackPiece).toBeDefined();
});
it('should keep both pieces in place when boop is blocked', async () => {
const { ctx } = createTestContext();
// Setup: place white at 2,2 and 4,4, black at 3,3
await ctx._commands.run('place 2 2 white kitten');
await ctx._commands.run('place 3 3 black kitten');
await ctx._commands.run('place 4 4 white kitten');
const stateBefore = ctx.value;
// Verify setup - 3 pieces on board
const boardPiecesBefore = Object.keys(stateBefore.regions.board.partMap);
expect(boardPiecesBefore.length).toBe(3);
expect(stateBefore.regions.board.partMap['2,2']).toBeDefined();
expect(stateBefore.regions.board.partMap['3,3']).toBeDefined();
expect(stateBefore.regions.board.partMap['4,4']).toBeDefined();
// Black places at 2,3 - should try to boop piece at 3,3 to 4,4
// but 4,4 is occupied, so both should stay
await ctx._commands.run('place 2 3 black kitten');
const state = ctx.value;
// Should now have 4 pieces on board
const boardPiecesAfter = Object.keys(state.regions.board.partMap);
expect(boardPiecesAfter.length).toBe(4);
// 3,3 should still have the same piece (not booped)
expect(state.regions.board.partMap['3,3']).toBeDefined();
// 4,4 should still be occupied
expect(state.regions.board.partMap['4,4']).toBeDefined();
// 2,3 should have black's new piece
expect(state.regions.board.partMap['2,3']).toBeDefined();
});
});
describe('Graduation Mechanic', () => {
it('should graduate three kittens in a row to cats', async () => {
const { ctx } = createTestContext();
// Manually place three white kittens in a row
ctx.produce(state => {
const k1 = state.pieces['white-kitten-1'];
const k2 = state.pieces['white-kitten-2'];
const k3 = state.pieces['white-kitten-3'];
if (k1) {
k1.regionId = 'board';
k1.position = [0, 0];
state.regions.board.partMap['0,0'] = k1.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k1.id);
}
if (k2) {
k2.regionId = 'board';
k2.position = [0, 1];
state.regions.board.partMap['0,1'] = k2.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k2.id);
}
if (k3) {
k3.regionId = 'board';
k3.position = [0, 2];
state.regions.board.partMap['0,2'] = k3.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k3.id);
}
});
const stateBefore = ctx.value;
// Verify three kittens on board
expect(stateBefore.regions.board.partMap['0,0']).toBeDefined();
expect(stateBefore.regions.board.partMap['0,1']).toBeDefined();
expect(stateBefore.regions.board.partMap['0,2']).toBeDefined();
// Count cats in white supply before graduation
const catsInWhiteSupplyBefore = stateBefore.regions.white.childIds.filter(
id => stateBefore.pieces[id].type === 'cat'
);
expect(catsInWhiteSupplyBefore.length).toBe(0);
// Run check-graduates command
const result = await ctx._commands.run('check-graduates');
expect(result.success).toBe(true);
const state = ctx.value;
// The three positions on board should now be empty (kittens removed)
expect(state.regions.board.partMap['0,0']).toBeUndefined();
expect(state.regions.board.partMap['0,1']).toBeUndefined();
expect(state.regions.board.partMap['0,2']).toBeUndefined();
// White's supply should now have 3 cats (graduated)
const catsInWhiteSupply = state.regions.white.childIds.filter(
id => state.pieces[id].type === 'cat'
);
expect(catsInWhiteSupply.length).toBe(3);
// White's supply should have 5 kittens left (8 - 3 graduated)
const kittensInWhiteSupply = state.regions.white.childIds.filter(
id => state.pieces[id].type === 'kitten'
);
expect(kittensInWhiteSupply.length).toBe(5);
});
});
describe('Win Detection', () => {
it('should detect horizontal win with three cats', async () => {
const { ctx } = createTestContext();
// Manually set up a winning scenario for white
ctx.produce(state => {
const k1 = state.pieces['white-kitten-1'];
const k2 = state.pieces['white-kitten-2'];
const k3 = state.pieces['white-kitten-3'];
if (k1) {
k1.type = 'cat';
k1.regionId = 'board';
k1.position = [0, 0];
state.regions.board.partMap['0,0'] = k1.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k1.id);
}
if (k2) {
k2.type = 'cat';
k2.regionId = 'board';
k2.position = [0, 1];
state.regions.board.partMap['0,1'] = k2.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k2.id);
}
if (k3) {
k3.type = 'cat';
k3.regionId = 'board';
k3.position = [0, 2];
state.regions.board.partMap['0,2'] = k3.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k3.id);
}
});
// Run check-win command
const result = await ctx._commands.run('check-win');
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('white');
}
});
it('should detect vertical win with three cats', async () => {
const { ctx } = createTestContext();
// Manually set up a vertical winning scenario for black
ctx.produce(state => {
const k1 = state.pieces['black-kitten-1'];
const k2 = state.pieces['black-kitten-2'];
const k3 = state.pieces['black-kitten-3'];
if (k1) {
k1.type = 'cat';
k1.regionId = 'board';
k1.position = [0, 0];
state.regions.board.partMap['0,0'] = k1.id;
state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== k1.id);
}
if (k2) {
k2.type = 'cat';
k2.regionId = 'board';
k2.position = [1, 0];
state.regions.board.partMap['1,0'] = k2.id;
state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== k2.id);
}
if (k3) {
k3.type = 'cat';
k3.regionId = 'board';
k3.position = [2, 0];
state.regions.board.partMap['2,0'] = k3.id;
state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== k3.id);
}
});
// Run check-win command
const result = await ctx._commands.run('check-win');
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('black');
}
});
it('should detect diagonal win with three cats', async () => {
const { ctx } = createTestContext();
// Manually set up a diagonal winning scenario for white
ctx.produce(state => {
const k1 = state.pieces['white-kitten-1'];
const k2 = state.pieces['white-kitten-2'];
const k3 = state.pieces['white-kitten-3'];
if (k1) {
k1.type = 'cat';
k1.regionId = 'board';
k1.position = [0, 0];
state.regions.board.partMap['0,0'] = k1.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k1.id);
}
if (k2) {
k2.type = 'cat';
k2.regionId = 'board';
k2.position = [1, 1];
state.regions.board.partMap['1,1'] = k2.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k2.id);
}
if (k3) {
k3.type = 'cat';
k3.regionId = 'board';
k3.position = [2, 2];
state.regions.board.partMap['2,2'] = k3.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k3.id);
}
});
// Run check-win command
const result = await ctx._commands.run('check-win');
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('white');
}
});
});
describe('Placing Cats', () => {
it('should allow placing a cat from supply', async () => {
const { ctx } = createTestContext();
// Manually give a cat to white's supply
ctx.produce(state => {
const cat = state.pieces['white-cat-1'];
if (cat && cat.regionId === '') {
cat.regionId = 'white';
state.regions.white.childIds.push(cat.id);
}
});
// White places a cat at 2,2
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.run('turn white');
const prompt = await promptPromise;
const error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'cat'], options: {}, flags: {} });
expect(error).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
const state = ctx.value;
// Cat should be on the board
expect(state.regions.board.partMap['2,2']).toBe('white-cat-1');
// Cat should no longer be in supply
const whiteCatsInSupply = state.regions.white.childIds.filter(id => state.pieces[id].type === 'cat');
expect(whiteCatsInSupply.length).toBe(0);
});
});
});

View File

@ -246,8 +246,8 @@ describe('prompt', () => {
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('choose'),
run: async function () {
const result = await this.prompt('select <card>');
return result.params[0] as string;
const result = await this.prompt('select <card>', (cmd) => cmd.params[0] as string);
return result;
},
};
@ -266,6 +266,9 @@ describe('prompt', () => {
await new Promise((r) => setTimeout(r, 0));
expect(promptEvent).not.toBeNull();
expect(promptEvent!.schema.name).toBe('select');
promptEvent!.cancel('test cleanup');
await runPromise;
});
it('should resolve prompt with valid input', async () => {
@ -274,9 +277,9 @@ describe('prompt', () => {
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('choose'),
run: async function () {
const result = await this.prompt('select <card>');
this.context.log.push(`selected ${result.params[0]}`);
return result.params[0] as string;
const result = await this.prompt('select <card>', (cmd) => cmd.params[0] as string);
this.context.log.push(`selected ${result}`);
return result;
},
};
@ -314,7 +317,7 @@ describe('prompt', () => {
schema: parseCommandSchema('choose'),
run: async function () {
try {
await this.prompt('select <card>');
await this.prompt('select <card>', (cmd) => cmd.params[0] as string);
return 'unexpected success';
} catch (e) {
return (e as Error).message;
@ -353,8 +356,8 @@ describe('prompt', () => {
const pickRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('pick'),
run: async function () {
const result = await this.prompt(schema);
return result.params[0] as string;
const result = await this.prompt(schema, (cmd) => cmd.params[0] as string);
return result;
},
};
@ -390,9 +393,9 @@ describe('prompt', () => {
const multiPromptRunner: CommandRunner<TestContext, string[]> = {
schema: parseCommandSchema('multi'),
run: async function () {
const first = await this.prompt('first <a>');
const second = await this.prompt('second <b>');
return [first.params[0] as string, second.params[0] as string];
const first = await this.prompt('first <a>', (cmd) => cmd.params[0] as string);
const second = await this.prompt('second <b>', (cmd) => cmd.params[0] as string);
return [first, second];
},
};
@ -440,12 +443,12 @@ describe('prompt', () => {
(cmd) => {
const card = cmd.params[0] as string;
if (!['Ace', 'King', 'Queen'].includes(card)) {
return `Invalid card: ${card}. Must be Ace, King, or Queen.`;
throw `Invalid card: ${card}. Must be Ace, King, or Queen.`;
}
return null;
return card;
}
);
return result.params[0] as string;
return result;
},
};
@ -486,7 +489,7 @@ describe('prompt', () => {
schema: parseCommandSchema('choose'),
run: async function () {
try {
await this.prompt('select <card>');
await this.prompt('select <card>', (cmd) => cmd.params[0] as string);
return 'unexpected success';
} catch (e) {
return (e as Error).message;