Compare commits
3 Commits
28782aaf9b
...
b9105efd03
| Author | SHA1 | Date |
|---|---|---|
|
|
b9105efd03 | |
|
|
a02403d2c7 | |
|
|
e58690c9c6 |
|
|
@ -42,6 +42,8 @@ const error = host.onInput('play X 1 2');
|
||||||
|---|---|
|
|---|---|
|
||||||
| [使用 GameHost](docs/game-host.md) | GameHost 生命周期、响应式状态、事件处理 |
|
| [使用 GameHost](docs/game-host.md) | GameHost 生命周期、响应式状态、事件处理 |
|
||||||
| [编写 GameModule](docs/game-module.md) | 定义状态、注册命令、prompt 系统、Part/Region 使用 |
|
| [编写 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 的完整列表 |
|
| [API 参考](docs/api-reference.md) | 所有导出 API 的完整列表 |
|
||||||
| [开发指南](docs/development.md) | 安装、构建脚本、测试命令 |
|
| [开发指南](docs/development.md) | 安装、构建脚本、测试命令 |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@
|
||||||
|---|---|
|
|---|---|
|
||||||
| `IGameContext` | 游戏上下文基础接口 |
|
| `IGameContext` | 游戏上下文基础接口 |
|
||||||
| `createGameContext(registry, initialState?)` | 创建游戏上下文实例 |
|
| `createGameContext(registry, initialState?)` | 创建游戏上下文实例 |
|
||||||
| `createGameCommandRegistry<TState>()` | 创建命令注册表,返回带 `.add()` 的对象 |
|
| `createGameContextFromModule(module)` | 从 GameModule 创建游戏上下文 |
|
||||||
| `createGameModule(module)` | 辅助函数,标记一个对象为 GameModule |
|
| `createGameCommandRegistry<TState>()` | 创建命令注册表 |
|
||||||
|
| `createGameModule(module)` | 辅助函数,标记 GameModule |
|
||||||
| `GameHost<TState>` | 游戏生命周期管理类 |
|
| `GameHost<TState>` | 游戏生命周期管理类 |
|
||||||
| `createGameHost(module, options?)` | 从 GameModule 创建 GameHost |
|
| `createGameHost(module, options?)` | 从 GameModule 创建 GameHost |
|
||||||
| `GameHostStatus` | 类型: `'created' \| 'running' \| 'disposed'` |
|
| `GameHostStatus` | 类型: `'created' \| 'running' \| 'disposed'` |
|
||||||
|
| `GameModule` | 游戏模块类型 |
|
||||||
|
|
||||||
## 棋子 (Parts)
|
## 棋子 (Parts)
|
||||||
|
|
||||||
|
|
@ -18,13 +20,17 @@
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Part<TMeta>` | 游戏棋子类型 |
|
| `Part<TMeta>` | 游戏棋子类型 |
|
||||||
| `PartTemplate<TMeta>` | 创建棋子的模板类型 |
|
| `PartTemplate<TMeta>` | 创建棋子的模板类型 |
|
||||||
| `PartPool<TMeta>` | 棋子池 |
|
| `PartPool<TMeta>` | 棋子池类型 |
|
||||||
| `createPart(template, id)` | 创建单个棋子 |
|
| `createPart(template, id)` | 创建单个棋子 |
|
||||||
| `createParts(template, count, idPrefix)` | 批量创建相同棋子 |
|
| `createParts(template, count, idPrefix)` | 批量创建相同棋子 |
|
||||||
|
| `createPartsFromTable(template, table, idField?)` | 从表格数据创建棋子 |
|
||||||
| `createPartPool(template, count, idPrefix)` | 创建棋子池 |
|
| `createPartPool(template, count, idPrefix)` | 创建棋子池 |
|
||||||
| `mergePartPools(...pools)` | 合并多个棋子池 |
|
| `mergePartPools(...pools)` | 合并多个棋子池 |
|
||||||
| `findPartById(parts, id)` | 按 ID 查找棋子 |
|
| `findPartById(parts, id)` | 按 ID 查找棋子 |
|
||||||
| `isCellOccupied(parts, regionId, position)` | 检查格子是否被占用 |
|
| `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)` | 翻面/随机面 |
|
| `flip(part)` / `flipTo(part, side)` / `roll(part, rng)` | 翻面/随机面 |
|
||||||
|
|
||||||
## 区域 (Regions)
|
## 区域 (Regions)
|
||||||
|
|
@ -35,20 +41,38 @@
|
||||||
| `createRegion(id, axes)` | 创建区域 |
|
| `createRegion(id, axes)` | 创建区域 |
|
||||||
| `applyAlign(region, parts)` | 紧凑排列 |
|
| `applyAlign(region, parts)` | 紧凑排列 |
|
||||||
| `shuffle(region, parts, rng)` | 打乱位置 |
|
| `shuffle(region, parts, rng)` | 打乱位置 |
|
||||||
| `moveToRegion(part, sourceRegion?, targetRegion, position?)` | 移动棋子到其他区域 |
|
| `moveToRegion(part, sourceRegion?, targetRegion, position?)` | 移动棋子到区域 |
|
||||||
| `moveToRegionAll(parts, sourceRegion?, targetRegion, positions?)` | 批量移动 |
|
|
||||||
| `removeFromRegion(part, region)` | 从区域移除棋子 |
|
|
||||||
|
|
||||||
## 命令 (Commands)
|
## 命令 (Commands)
|
||||||
|
|
||||||
| 导出 | 说明 |
|
| 导出 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `Command` / `CommandSchema` / `CommandResult` | 命令相关类型 |
|
||||||
|
| `CommandParamSchema` / `CommandOptionSchema` / `CommandFlagSchema` | 命令参数类型 |
|
||||||
| `parseCommand(input)` | 解析命令字符串 |
|
| `parseCommand(input)` | 解析命令字符串 |
|
||||||
| `parseCommandSchema(schema)` | 解析 Schema 字符串 |
|
| `parseCommandSchema(schema)` | 解析 Schema 字符串 |
|
||||||
| `validateCommand(cmd, schema)` | 验证命令 |
|
| `validateCommand(cmd, schema)` | 验证命令 |
|
||||||
| `Command` / `CommandSchema` / `CommandResult` | 命令相关类型 |
|
| `parseCommandWithSchema(input, schema)` | 解析并应用 Schema |
|
||||||
| `PromptEvent` | 玩家输入提示事件 |
|
| `applyCommandSchema(cmd, schema)` | 应用 Schema 到命令 |
|
||||||
| `createRNG(seed?)` | 创建种子 RNG |
|
|
||||||
|
### 命令运行器
|
||||||
|
|
||||||
|
| 导出 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `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
|
## MutableSignal
|
||||||
|
|
||||||
|
|
@ -62,12 +86,21 @@
|
||||||
|
|
||||||
| 成员 | 说明 |
|
| 成员 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `value: T` | 获取当前值(继承自 Signal) |
|
| `value: T` | 获取当前值 |
|
||||||
| `produce(fn: (draft: T) => void): void` | 同步不可变更新 |
|
| `produce(fn: (draft: T) => void): void` | 同步不可变更新 |
|
||||||
| `addInterruption(promise: Promise<void>): void` | 添加中断,`produceAsync` 会等待它 |
|
| `addInterruption(promise: Promise<void>): void` | 添加中断 |
|
||||||
| `clearInterruptions(): void` | 清除所有未完成的中断 |
|
| `clearInterruptions(): void` | 清除所有中断 |
|
||||||
| `produceAsync(fn: (draft: T) => void): Promise<void>` | 等待所有 interruption 完成后更新状态 |
|
| `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 算法实现 |
|
||||||
|
|
|
||||||
|
|
@ -36,17 +36,52 @@ pool.return(piece); // 放回
|
||||||
pool.remaining(); // 剩余数量
|
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
|
```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); // 紧凑排列
|
applyAlign(handRegion, state.parts); // 紧凑排列
|
||||||
shuffle(deckRegion, state.parts, rng); // 打乱
|
shuffle(deckRegion, state.parts, rng); // 打乱
|
||||||
moveToRegion(piece, sourceRegion, targetRegion, [0, 0]);
|
moveToRegion(piece, sourceRegion, targetRegion, [0, 0]);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 翻面与掷骰
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { flip, flipTo, roll } from 'boardgame-core';
|
||||||
|
|
||||||
|
flip(piece); // 翻到下一面
|
||||||
|
flipTo(piece, 2); // 翻到指定面
|
||||||
|
roll(piece, rng); // 随机面
|
||||||
|
```
|
||||||
|
|
||||||
## RNG
|
## RNG
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
throw new Error(`No ${type} available in ${player}'s supply`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const partId = part.id;
|
||||||
|
|
||||||
game.produce(state => {
|
game.produce(state => {
|
||||||
// 将棋子从supply移动到棋盘
|
// 将棋子从supply移动到棋盘
|
||||||
|
const part = state.pieces[partId];
|
||||||
moveToRegion(part, state.regions[player], state.regions.board, [row, col]);
|
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);
|
const placeCommand = registry.register( 'place <row:number> <col:number> <player> <type>', place);
|
||||||
|
|
||||||
|
|
@ -55,7 +58,8 @@ async function boop(game: BoopGame, row: number, col: number, type: PieceType) {
|
||||||
|
|
||||||
if (!isInBounds(nr, nc)) continue;
|
if (!isInBounds(nr, nc)) continue;
|
||||||
|
|
||||||
const part = findPartAtPosition(game, nr, nc);
|
// 从 state 中查找,而不是 game
|
||||||
|
const part = findPartAtPosition(state, nr, nc);
|
||||||
if (!part) continue;
|
if (!part) continue;
|
||||||
|
|
||||||
// 小猫不能推动猫
|
// 小猫不能推动猫
|
||||||
|
|
@ -70,7 +74,7 @@ async function boop(game: BoopGame, row: number, col: number, type: PieceType) {
|
||||||
// 棋子被推出棋盘,返回玩家supply
|
// 棋子被推出棋盘,返回玩家supply
|
||||||
booped.push(part.id);
|
booped.push(part.id);
|
||||||
moveToRegion(part, state.regions.board, state.regions[part.player]);
|
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);
|
booped.push(part.id);
|
||||||
moveToRegion(part, state.regions.board, state.regions.board, [newRow, newCol]);
|
moveToRegion(part, state.regions.board, state.regions.board, [newRow, newCol]);
|
||||||
|
|
@ -130,13 +134,15 @@ async function checkGraduates(game: BoopGame){
|
||||||
}
|
}
|
||||||
|
|
||||||
game.produce(state => {
|
game.produce(state => {
|
||||||
|
// 预先收集所有可用的猫(在盒子里的)
|
||||||
for(const partId of toUpgrade){
|
for(const partId of toUpgrade){
|
||||||
const part = state.pieces[partId];
|
const part = state.pieces[partId];
|
||||||
const [row, col] = part.position;
|
const [row, col] = part.position;
|
||||||
const player = part.player;
|
const player = part.player;
|
||||||
moveToRegion(part, state.regions.board, null);
|
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]);
|
moveToRegion(newPart || part, null, state.regions[player], [row, col]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
const DIRS = [
|
||||||
[0, 1],
|
[0, 1],
|
||||||
|
|
@ -41,14 +50,17 @@ export function* getNeighborPositions(x: number = 0, y: number = 0){
|
||||||
yield [x + dx, y + dy] as PT;
|
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);
|
const state = getState(ctx);
|
||||||
if(!regionId){
|
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;
|
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 {
|
export function findPartAtPosition(ctx: BoopGame | BoopState, row: number, col: number): BoopPart | null {
|
||||||
const state = getState(ctx);
|
const state = getState(ctx);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ function createTestHost() {
|
||||||
|
|
||||||
function waitForPromptEvent(host: GameHost<any>): Promise<PromptEvent> {
|
function waitForPromptEvent(host: GameHost<any>): Promise<PromptEvent> {
|
||||||
return new Promise(resolve => {
|
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', () => {
|
it('should create host with initial state', () => {
|
||||||
const { host } = createTestHost();
|
const { host } = createTestHost();
|
||||||
|
|
||||||
expect(host.state.value.currentPlayer).toBe('X');
|
expect(host.context._state.value.currentPlayer).toBe('X');
|
||||||
expect(host.state.value.winner).toBeNull();
|
expect(host.context._state.value.winner).toBeNull();
|
||||||
expect(host.state.value.turn).toBe(0);
|
expect(host.context._state.value.turn).toBe(0);
|
||||||
expect(Object.keys(host.state.value.parts).length).toBe(0);
|
expect(Object.keys(host.context._state.value.parts).length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have status "created" by default', () => {
|
it('should have status "created" by default', () => {
|
||||||
|
|
@ -59,7 +59,7 @@ describe('GameHost', () => {
|
||||||
const { host } = createTestHost();
|
const { host } = createTestHost();
|
||||||
|
|
||||||
const promptPromise = waitForPromptEvent(host);
|
const promptPromise = waitForPromptEvent(host);
|
||||||
const runPromise = host.commands.run('setup');
|
const runPromise = host.context._commands.run('setup');
|
||||||
|
|
||||||
const promptEvent = await promptPromise;
|
const promptEvent = await promptPromise;
|
||||||
expect(promptEvent.schema.name).toBe('play');
|
expect(promptEvent.schema.name).toBe('play');
|
||||||
|
|
@ -81,7 +81,7 @@ describe('GameHost', () => {
|
||||||
const { host } = createTestHost();
|
const { host } = createTestHost();
|
||||||
|
|
||||||
const promptPromise = waitForPromptEvent(host);
|
const promptPromise = waitForPromptEvent(host);
|
||||||
const runPromise = host.commands.run('setup');
|
const runPromise = host.context._commands.run('setup');
|
||||||
|
|
||||||
const promptEvent = await promptPromise;
|
const promptEvent = await promptPromise;
|
||||||
|
|
||||||
|
|
@ -106,7 +106,7 @@ describe('GameHost', () => {
|
||||||
const { host } = createTestHost();
|
const { host } = createTestHost();
|
||||||
|
|
||||||
const promptPromise = waitForPromptEvent(host);
|
const promptPromise = waitForPromptEvent(host);
|
||||||
const runPromise = host.commands.run('setup');
|
const runPromise = host.context._commands.run('setup');
|
||||||
|
|
||||||
const promptEvent = await promptPromise;
|
const promptEvent = await promptPromise;
|
||||||
const schema = host.activePromptSchema.value;
|
const schema = host.activePromptSchema.value;
|
||||||
|
|
@ -131,7 +131,7 @@ describe('GameHost', () => {
|
||||||
|
|
||||||
// First setup - make one move
|
// First setup - make one move
|
||||||
let promptPromise = waitForPromptEvent(host);
|
let promptPromise = waitForPromptEvent(host);
|
||||||
let runPromise = host.commands.run('setup');
|
let runPromise = host.context._commands.run('setup');
|
||||||
let promptEvent = await promptPromise;
|
let promptEvent = await promptPromise;
|
||||||
|
|
||||||
// Make a move
|
// Make a move
|
||||||
|
|
@ -144,7 +144,7 @@ describe('GameHost', () => {
|
||||||
|
|
||||||
let result = await runPromise;
|
let result = await runPromise;
|
||||||
expect(result.success).toBe(false); // Cancelled
|
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
|
// Setup listener before calling setup
|
||||||
const newPromptPromise = waitForPromptEvent(host);
|
const newPromptPromise = waitForPromptEvent(host);
|
||||||
|
|
@ -153,10 +153,10 @@ describe('GameHost', () => {
|
||||||
await host.setup('setup');
|
await host.setup('setup');
|
||||||
|
|
||||||
// State should be back to initial
|
// State should be back to initial
|
||||||
expect(host.state.value.currentPlayer).toBe('X');
|
expect(host.context._state.value.currentPlayer).toBe('X');
|
||||||
expect(host.state.value.winner).toBeNull();
|
expect(host.context._state.value.winner).toBeNull();
|
||||||
expect(host.state.value.turn).toBe(0);
|
expect(host.context._state.value.turn).toBe(0);
|
||||||
expect(Object.keys(host.state.value.parts).length).toBe(0);
|
expect(Object.keys(host.context._state.value.parts).length).toBe(0);
|
||||||
|
|
||||||
// New game should be running and prompting
|
// New game should be running and prompting
|
||||||
const newPrompt = await newPromptPromise;
|
const newPrompt = await newPromptPromise;
|
||||||
|
|
@ -168,7 +168,7 @@ describe('GameHost', () => {
|
||||||
const { host } = createTestHost();
|
const { host } = createTestHost();
|
||||||
|
|
||||||
const promptPromise = waitForPromptEvent(host);
|
const promptPromise = waitForPromptEvent(host);
|
||||||
const runPromise = host.commands.run('setup');
|
const runPromise = host.context._commands.run('setup');
|
||||||
|
|
||||||
await promptPromise;
|
await promptPromise;
|
||||||
|
|
||||||
|
|
@ -184,8 +184,8 @@ describe('GameHost', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// State should be reset
|
// State should be reset
|
||||||
expect(host.state.value.currentPlayer).toBe('X');
|
expect(host.context._state.value.currentPlayer).toBe('X');
|
||||||
expect(host.state.value.turn).toBe(0);
|
expect(host.context._state.value.turn).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error when disposed', async () => {
|
it('should throw error when disposed', async () => {
|
||||||
|
|
@ -208,7 +208,7 @@ describe('GameHost', () => {
|
||||||
const { host } = createTestHost();
|
const { host } = createTestHost();
|
||||||
|
|
||||||
const promptPromise = waitForPromptEvent(host);
|
const promptPromise = waitForPromptEvent(host);
|
||||||
const runPromise = host.commands.run('setup');
|
const runPromise = host.context._commands.run('setup');
|
||||||
|
|
||||||
await promptPromise;
|
await promptPromise;
|
||||||
|
|
||||||
|
|
@ -289,12 +289,12 @@ describe('GameHost', () => {
|
||||||
const { host } = createTestHost();
|
const { host } = createTestHost();
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
expect(host.state.value.currentPlayer).toBe('X');
|
expect(host.context._state.value.currentPlayer).toBe('X');
|
||||||
expect(host.state.value.turn).toBe(0);
|
expect(host.context._state.value.turn).toBe(0);
|
||||||
|
|
||||||
// Make a move
|
// Make a move
|
||||||
const promptPromise = waitForPromptEvent(host);
|
const promptPromise = waitForPromptEvent(host);
|
||||||
const runPromise = host.commands.run('setup');
|
const runPromise = host.context._commands.run('setup');
|
||||||
|
|
||||||
const promptEvent = await promptPromise;
|
const promptEvent = await promptPromise;
|
||||||
promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
|
promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
|
||||||
|
|
@ -307,9 +307,9 @@ describe('GameHost', () => {
|
||||||
const result = await runPromise;
|
const result = await runPromise;
|
||||||
expect(result.success).toBe(false); // Cancelled
|
expect(result.success).toBe(false); // Cancelled
|
||||||
|
|
||||||
expect(host.state.value.currentPlayer).toBe('O');
|
expect(host.context._state.value.currentPlayer).toBe('O');
|
||||||
expect(host.state.value.turn).toBe(1);
|
expect(host.context._state.value.turn).toBe(1);
|
||||||
expect(Object.keys(host.state.value.parts).length).toBe(1);
|
expect(Object.keys(host.context._state.value.parts).length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update activePromptSchema reactively', async () => {
|
it('should update activePromptSchema reactively', async () => {
|
||||||
|
|
@ -320,7 +320,7 @@ describe('GameHost', () => {
|
||||||
|
|
||||||
// Start a command that triggers prompt
|
// Start a command that triggers prompt
|
||||||
const promptPromise = waitForPromptEvent(host);
|
const promptPromise = waitForPromptEvent(host);
|
||||||
const runPromise = host.commands.run('setup');
|
const runPromise = host.context._commands.run('setup');
|
||||||
|
|
||||||
await promptPromise;
|
await promptPromise;
|
||||||
|
|
||||||
|
|
@ -330,7 +330,7 @@ describe('GameHost', () => {
|
||||||
|
|
||||||
// Cancel and wait
|
// Cancel and wait
|
||||||
const cancelEvent = host.activePromptSchema.value;
|
const cancelEvent = host.activePromptSchema.value;
|
||||||
host.commands._cancel();
|
host.context._commands._cancel();
|
||||||
try {
|
try {
|
||||||
await runPromise;
|
await runPromise;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -347,10 +347,10 @@ describe('GameHost', () => {
|
||||||
const { host } = createTestHost();
|
const { host } = createTestHost();
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
expect(host.state.value.currentPlayer).toBe('X');
|
expect(host.context._state.value.currentPlayer).toBe('X');
|
||||||
expect(host.state.value.winner).toBeNull();
|
expect(host.context._state.value.winner).toBeNull();
|
||||||
expect(host.state.value.turn).toBe(0);
|
expect(host.context._state.value.turn).toBe(0);
|
||||||
expect(Object.keys(host.state.value.parts).length).toBe(0);
|
expect(Object.keys(host.context._state.value.parts).length).toBe(0);
|
||||||
|
|
||||||
// X wins diagonally: (0,0), (1,1), (2,2)
|
// X wins diagonally: (0,0), (1,1), (2,2)
|
||||||
// O plays: (0,1), (2,1)
|
// O plays: (0,1), (2,1)
|
||||||
|
|
@ -364,12 +364,12 @@ describe('GameHost', () => {
|
||||||
|
|
||||||
// Track prompt events in a queue
|
// Track prompt events in a queue
|
||||||
const promptEvents: PromptEvent[] = [];
|
const promptEvents: PromptEvent[] = [];
|
||||||
host.commands.on('prompt', (e) => {
|
host.context._commands.on('prompt', (e) => {
|
||||||
promptEvents.push(e);
|
promptEvents.push(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start setup command (runs game loop until completion)
|
// 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++) {
|
for (let i = 0; i < moves.length; i++) {
|
||||||
// Wait until the next prompt event arrives
|
// Wait until the next prompt event arrives
|
||||||
|
|
@ -393,12 +393,12 @@ describe('GameHost', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final state checks
|
// Final state checks
|
||||||
expect(host.state.value.winner).toBe('X');
|
expect(host.context._state.value.winner).toBe('X');
|
||||||
expect(host.state.value.currentPlayer).toBe('X');
|
expect(host.context._state.value.currentPlayer).toBe('X');
|
||||||
expect(Object.keys(host.state.value.parts).length).toBe(5);
|
expect(Object.keys(host.context._state.value.parts).length).toBe(5);
|
||||||
|
|
||||||
// Verify winning diagonal
|
// 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');
|
const xPieces = parts.filter(p => p.player === 'X');
|
||||||
expect(xPieces).toHaveLength(3);
|
expect(xPieces).toHaveLength(3);
|
||||||
expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([0, 0]))).toBe(true);
|
expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([0, 0]))).toBe(true);
|
||||||
|
|
@ -415,7 +415,7 @@ describe('GameHost', () => {
|
||||||
const { host } = createTestHost();
|
const { host } = createTestHost();
|
||||||
|
|
||||||
const promptPromise = waitForPromptEvent(host);
|
const promptPromise = waitForPromptEvent(host);
|
||||||
const runPromise = host.commands.run('setup');
|
const runPromise = host.context._commands.run('setup');
|
||||||
|
|
||||||
const promptEvent = await promptPromise;
|
const promptEvent = await promptPromise;
|
||||||
expect(promptEvent.currentPlayer).toBe('X');
|
expect(promptEvent.currentPlayer).toBe('X');
|
||||||
|
|
@ -433,7 +433,7 @@ describe('GameHost', () => {
|
||||||
|
|
||||||
// First prompt - X's turn
|
// First prompt - X's turn
|
||||||
let promptPromise = waitForPromptEvent(host);
|
let promptPromise = waitForPromptEvent(host);
|
||||||
let runPromise = host.commands.run('setup');
|
let runPromise = host.context._commands.run('setup');
|
||||||
let promptEvent = await promptPromise;
|
let promptEvent = await promptPromise;
|
||||||
expect(promptEvent.currentPlayer).toBe('X');
|
expect(promptEvent.currentPlayer).toBe('X');
|
||||||
expect(host.activePromptPlayer.value).toBe('X');
|
expect(host.activePromptPlayer.value).toBe('X');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { createGameContext, createGameCommand, createGameCommandRegistry } from '@/core/game';
|
import { createGameContext, createGameCommandRegistry, IGameContext } from '@/core/game';
|
||||||
import type { PromptEvent } from '@/utils/command';
|
import type { PromptEvent, Command } from '@/utils/command';
|
||||||
|
|
||||||
type MyState = {
|
type MyState = {
|
||||||
score: number;
|
score: number;
|
||||||
|
|
@ -9,56 +9,55 @@ type MyState = {
|
||||||
|
|
||||||
describe('createGameContext', () => {
|
describe('createGameContext', () => {
|
||||||
it('should create a game context with state', () => {
|
it('should create a game context with state', () => {
|
||||||
const { registry } = createGameCommandRegistry();
|
const registry = createGameCommandRegistry();
|
||||||
const ctx = createGameContext(registry);
|
const ctx = createGameContext(registry);
|
||||||
|
|
||||||
expect(ctx.state).not.toBeNull();
|
expect(ctx._state).not.toBeNull();
|
||||||
expect(ctx.state.value).toBeDefined();
|
expect(ctx._state.value).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should wire commands to the context', () => {
|
it('should wire commands to the context', () => {
|
||||||
const { registry } = createGameCommandRegistry();
|
const registry = createGameCommandRegistry();
|
||||||
const ctx = createGameContext(registry);
|
const ctx = createGameContext(registry);
|
||||||
|
|
||||||
expect(ctx.commands).not.toBeNull();
|
expect(ctx._commands).not.toBeNull();
|
||||||
expect(ctx.commands.registry).toBe(registry);
|
expect(ctx._commands.registry).toBe(registry);
|
||||||
expect(ctx.commands.context).toBe(ctx.state);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept initial state as an object', () => {
|
it('should accept initial state as an object', () => {
|
||||||
const { registry } = createGameCommandRegistry<MyState>();
|
const registry = createGameCommandRegistry<MyState>();
|
||||||
const ctx = createGameContext<MyState>(registry, {
|
const ctx = createGameContext<MyState>(registry, {
|
||||||
score: 0,
|
score: 0,
|
||||||
round: 1,
|
round: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(ctx.state.value.score).toBe(0);
|
expect(ctx._state.value.score).toBe(0);
|
||||||
expect(ctx.state.value.round).toBe(1);
|
expect(ctx._state.value.round).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept initial state as a factory function', () => {
|
it('should accept initial state as a factory function', () => {
|
||||||
const { registry } = createGameCommandRegistry<MyState>();
|
const registry = createGameCommandRegistry<MyState>();
|
||||||
const ctx = createGameContext<MyState>(registry, () => ({
|
const ctx = createGameContext<MyState>(registry, () => ({
|
||||||
score: 10,
|
score: 10,
|
||||||
round: 3,
|
round: 3,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(ctx.state.value.score).toBe(10);
|
expect(ctx._state.value.score).toBe(10);
|
||||||
expect(ctx.state.value.round).toBe(3);
|
expect(ctx._state.value.round).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should forward prompt events via listener', async () => {
|
it('should forward prompt events via listener', async () => {
|
||||||
const { registry } = createGameCommandRegistry();
|
const registry = createGameCommandRegistry();
|
||||||
const ctx = createGameContext(registry);
|
const ctx = createGameContext(registry);
|
||||||
|
|
||||||
createGameCommand(registry, 'test <value>', async function () {
|
registry.register('test <value>', async function (_ctx, value) {
|
||||||
return this.prompt('prompt <answer>');
|
return this.prompt<string>('prompt <answer>', () => 'ok');
|
||||||
});
|
});
|
||||||
|
|
||||||
const promptPromise = new Promise<PromptEvent>(resolve => {
|
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;
|
const promptEvent = await promptPromise;
|
||||||
expect(promptEvent).not.toBeNull();
|
expect(promptEvent).not.toBeNull();
|
||||||
|
|
@ -69,45 +68,43 @@ describe('createGameContext', () => {
|
||||||
|
|
||||||
const result = await runPromise;
|
const result = await runPromise;
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
|
||||||
expect((result.result as any).params[0]).toBe('yes');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createGameCommand', () => {
|
describe('createGameCommand', () => {
|
||||||
it('should run a command with access to game context', async () => {
|
it('should run a command with access to game context', async () => {
|
||||||
const { registry } = createGameCommandRegistry<{ marker: string }>();
|
const registry = createGameCommandRegistry<{ marker: string }>();
|
||||||
const ctx = createGameContext(registry, { marker: '' });
|
|
||||||
|
|
||||||
createGameCommand(registry, 'set-marker <id>', async function (cmd) {
|
registry.register('set-marker <id>', async function (ctx, id) {
|
||||||
const id = cmd.params[0] as string;
|
ctx.produce(state => {
|
||||||
this.context.produce(state => {
|
|
||||||
state.marker = id;
|
state.marker = id;
|
||||||
});
|
});
|
||||||
return 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);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
expect(result.result).toBe('board');
|
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 () => {
|
it('should run a typed command with extended context', async () => {
|
||||||
const { registry } = createGameCommandRegistry<MyState>();
|
const registry = createGameCommandRegistry<MyState>();
|
||||||
|
|
||||||
createGameCommand<MyState, number>(
|
registry.register(
|
||||||
registry,
|
|
||||||
'add-score <amount:number>',
|
'add-score <amount:number>',
|
||||||
async function (cmd) {
|
async function (ctx, amount) {
|
||||||
const amount = cmd.params[0] as number;
|
ctx.produce(state => {
|
||||||
this.context.produce(state => {
|
|
||||||
state.score += amount;
|
state.score += amount;
|
||||||
});
|
});
|
||||||
return this.context.value.score;
|
return ctx.value.score;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -116,19 +113,19 @@ describe('createGameCommand', () => {
|
||||||
round: 1,
|
round: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const result = await ctx.commands.run('add-score 5');
|
const result = await ctx.run('add-score 5');
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
expect(result.result).toBe(5);
|
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 () => {
|
it('should return error for unknown command', async () => {
|
||||||
const { registry } = createGameCommandRegistry();
|
const registry = createGameCommandRegistry();
|
||||||
const ctx = createGameContext(registry);
|
const ctx = createGameContext(registry);
|
||||||
|
|
||||||
const result = await ctx.commands.run('nonexistent');
|
const result = await ctx.run('nonexistent');
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
expect(result.error).toContain('nonexistent');
|
expect(result.error).toContain('nonexistent');
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
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 { createRNG } from '@/utils/rng';
|
||||||
import { type Part } from '@/core/part';
|
import { type Part } from '@/core/part';
|
||||||
|
|
||||||
|
|
@ -303,76 +303,4 @@ describe('Region', () => {
|
||||||
expect(part.position).toEqual([3]);
|
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']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -141,4 +141,394 @@ describe('Boop Game', () => {
|
||||||
expect(stateAfterWhite.regions.board.partMap['2,2']).toBeDefined();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -246,8 +246,8 @@ describe('prompt', () => {
|
||||||
const chooseRunner: CommandRunner<TestContext, string> = {
|
const chooseRunner: CommandRunner<TestContext, string> = {
|
||||||
schema: parseCommandSchema('choose'),
|
schema: parseCommandSchema('choose'),
|
||||||
run: async function () {
|
run: async function () {
|
||||||
const result = await this.prompt('select <card>');
|
const result = await this.prompt('select <card>', (cmd) => cmd.params[0] as string);
|
||||||
return result.params[0] as string;
|
return result;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -266,6 +266,9 @@ describe('prompt', () => {
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
expect(promptEvent).not.toBeNull();
|
expect(promptEvent).not.toBeNull();
|
||||||
expect(promptEvent!.schema.name).toBe('select');
|
expect(promptEvent!.schema.name).toBe('select');
|
||||||
|
|
||||||
|
promptEvent!.cancel('test cleanup');
|
||||||
|
await runPromise;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should resolve prompt with valid input', async () => {
|
it('should resolve prompt with valid input', async () => {
|
||||||
|
|
@ -274,9 +277,9 @@ describe('prompt', () => {
|
||||||
const chooseRunner: CommandRunner<TestContext, string> = {
|
const chooseRunner: CommandRunner<TestContext, string> = {
|
||||||
schema: parseCommandSchema('choose'),
|
schema: parseCommandSchema('choose'),
|
||||||
run: async function () {
|
run: async function () {
|
||||||
const result = await this.prompt('select <card>');
|
const result = await this.prompt('select <card>', (cmd) => cmd.params[0] as string);
|
||||||
this.context.log.push(`selected ${result.params[0]}`);
|
this.context.log.push(`selected ${result}`);
|
||||||
return result.params[0] as string;
|
return result;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -314,7 +317,7 @@ describe('prompt', () => {
|
||||||
schema: parseCommandSchema('choose'),
|
schema: parseCommandSchema('choose'),
|
||||||
run: async function () {
|
run: async function () {
|
||||||
try {
|
try {
|
||||||
await this.prompt('select <card>');
|
await this.prompt('select <card>', (cmd) => cmd.params[0] as string);
|
||||||
return 'unexpected success';
|
return 'unexpected success';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (e as Error).message;
|
return (e as Error).message;
|
||||||
|
|
@ -353,8 +356,8 @@ describe('prompt', () => {
|
||||||
const pickRunner: CommandRunner<TestContext, string> = {
|
const pickRunner: CommandRunner<TestContext, string> = {
|
||||||
schema: parseCommandSchema('pick'),
|
schema: parseCommandSchema('pick'),
|
||||||
run: async function () {
|
run: async function () {
|
||||||
const result = await this.prompt(schema);
|
const result = await this.prompt(schema, (cmd) => cmd.params[0] as string);
|
||||||
return result.params[0] as string;
|
return result;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -390,9 +393,9 @@ describe('prompt', () => {
|
||||||
const multiPromptRunner: CommandRunner<TestContext, string[]> = {
|
const multiPromptRunner: CommandRunner<TestContext, string[]> = {
|
||||||
schema: parseCommandSchema('multi'),
|
schema: parseCommandSchema('multi'),
|
||||||
run: async function () {
|
run: async function () {
|
||||||
const first = await this.prompt('first <a>');
|
const first = await this.prompt('first <a>', (cmd) => cmd.params[0] as string);
|
||||||
const second = await this.prompt('second <b>');
|
const second = await this.prompt('second <b>', (cmd) => cmd.params[0] as string);
|
||||||
return [first.params[0] as string, second.params[0] as string];
|
return [first, second];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -440,12 +443,12 @@ describe('prompt', () => {
|
||||||
(cmd) => {
|
(cmd) => {
|
||||||
const card = cmd.params[0] as string;
|
const card = cmd.params[0] as string;
|
||||||
if (!['Ace', 'King', 'Queen'].includes(card)) {
|
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'),
|
schema: parseCommandSchema('choose'),
|
||||||
run: async function () {
|
run: async function () {
|
||||||
try {
|
try {
|
||||||
await this.prompt('select <card>');
|
await this.prompt('select <card>', (cmd) => cmd.params[0] as string);
|
||||||
return 'unexpected success';
|
return 'unexpected success';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (e as Error).message;
|
return (e as Error).message;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue