Compare commits

...

5 Commits

Author SHA1 Message Date
hypercross 6b736ab083 fix: more tests 2026-04-07 15:13:10 +08:00
hypercross a5cc584121 fix: onitama test 2026-04-07 15:03:20 +08:00
hypercross beb8088009 refactor: api change to add prompt text 2026-04-07 15:03:15 +08:00
hypercross b3cea805b0 feat: onitama tests 2026-04-07 14:53:50 +08:00
hypercross 4cb9f2dbd6 feat: onitama rules & cards 2026-04-07 14:40:44 +08:00
12 changed files with 1229 additions and 12 deletions

View File

@ -3,7 +3,6 @@ import {
CommandSchema, CommandSchema,
CommandRegistry, CommandRegistry,
PromptEvent, PromptEvent,
parseCommandSchema,
} from '@/utils/command'; } from '@/utils/command';
import {createGameCommandRegistry, createGameContext, IGameContext, PromptDef} from './game'; import {createGameCommandRegistry, createGameContext, IGameContext, PromptDef} from './game';
@ -14,12 +13,14 @@ export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
readonly status: ReadonlySignal<GameHostStatus>; readonly status: ReadonlySignal<GameHostStatus>;
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>; readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
readonly activePromptPlayer: ReadonlySignal<string | null>; readonly activePromptPlayer: ReadonlySignal<string | null>;
readonly activePromptHint: ReadonlySignal<string | null>;
private _context: IGameContext<TState>; private _context: IGameContext<TState>;
private _start: (ctx: IGameContext<TState>) => Promise<TResult>; private _start: (ctx: IGameContext<TState>) => Promise<TResult>;
private _status: Signal<GameHostStatus>; private _status: Signal<GameHostStatus>;
private _activePromptSchema: Signal<CommandSchema | null>; private _activePromptSchema: Signal<CommandSchema | null>;
private _activePromptPlayer: Signal<string | null>; private _activePromptPlayer: Signal<string | null>;
private _activePromptHint: Signal<string | null>;
private _createInitialState: () => TState; private _createInitialState: () => TState;
private _eventListeners: Map<'start' | 'dispose', Set<() => void>>; private _eventListeners: Map<'start' | 'dispose', Set<() => void>>;
private _isDisposed = false; private _isDisposed = false;
@ -45,6 +46,9 @@ export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
this._activePromptPlayer = new Signal<string | null>(null); this._activePromptPlayer = new Signal<string | null>(null);
this.activePromptPlayer = this._activePromptPlayer; this.activePromptPlayer = this._activePromptPlayer;
this._activePromptHint = new Signal<string | null>(null);
this.activePromptHint = this._activePromptHint;
this._setupPromptTracking(); this._setupPromptTracking();
} }
@ -56,17 +60,20 @@ export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
currentPromptEvent = e as PromptEvent; currentPromptEvent = e as PromptEvent;
this._activePromptSchema.value = currentPromptEvent.schema; this._activePromptSchema.value = currentPromptEvent.schema;
this._activePromptPlayer.value = currentPromptEvent.currentPlayer; this._activePromptPlayer.value = currentPromptEvent.currentPlayer;
this._activePromptHint.value = currentPromptEvent.hintText || null;
}); });
this._context._commands.on('promptEnd', () => { this._context._commands.on('promptEnd', () => {
currentPromptEvent = null; currentPromptEvent = null;
this._activePromptSchema.value = null; this._activePromptSchema.value = null;
this._activePromptPlayer.value = null; this._activePromptPlayer.value = null;
this._activePromptHint.value = null;
}); });
// Initial state // Initial state
this._activePromptSchema.value = null; this._activePromptSchema.value = null;
this._activePromptPlayer.value = null; this._activePromptPlayer.value = null;
this._activePromptHint.value = null;
} }
tryInput(input: string): string | null { tryInput(input: string): string | null {
@ -77,7 +84,6 @@ export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
} }
tryAnswerPrompt<TArgs extends any[]>(def: PromptDef<TArgs>, ...args: TArgs){ tryAnswerPrompt<TArgs extends any[]>(def: PromptDef<TArgs>, ...args: TArgs){
if(typeof def.schema === 'string') def.schema = parseCommandSchema(def.schema);
return this._context._commands._tryCommit({ return this._context._commands._tryCommit({
name: def.schema.name, name: def.schema.name,
params: args, params: args,

View File

@ -5,7 +5,7 @@ import {
CommandRunnerContextExport, CommandRunnerContextExport,
CommandSchema, CommandSchema,
createCommandRegistry, createCommandRegistry,
createCommandRunnerContext, createCommandRunnerContext, parseCommandSchema,
} from "@/utils/command"; } from "@/utils/command";
import {PromptValidator} from "@/utils/command/command-runner"; import {PromptValidator} from "@/utils/command/command-runner";
@ -47,7 +47,7 @@ export function createGameContext<TState extends Record<string, unknown> = {} >(
return commands.runParsed<T>(command); return commands.runParsed<T>(command);
}, },
prompt(def, validator, currentPlayer) { prompt(def, validator, currentPlayer) {
return commands.prompt(def.schema, validator, currentPlayer); return commands.prompt(def.schema, validator, def.hintText, currentPlayer);
}, },
_state: state, _state: state,
@ -60,10 +60,12 @@ export function createGameContext<TState extends Record<string, unknown> = {} >(
} }
export type PromptDef<TArgs extends any[]=any[]> = { export type PromptDef<TArgs extends any[]=any[]> = {
schema: CommandSchema | string, schema: CommandSchema,
hintText?: string,
} }
export function createPromptDef<TArgs extends any[]=any[]>(schema: CommandSchema | string): PromptDef<TArgs> { export function createPromptDef<TArgs extends any[]=any[]>(schema: CommandSchema | string, hintText?: string): PromptDef<TArgs> {
return { schema }; schema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
return { schema, hintText };
} }
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() { export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {

View File

@ -0,0 +1,104 @@
# pattern forms a grid of 5x5
# o is the move's starting postion
# x are move end position candidates
# . is space
# first row, first position is dx=-2 dy=2
name,startingPlayer,row,pattern
string,string,number,string
tiger,black,0,..x..
tiger,black,1,.....
tiger,black,2,..o..
tiger,black,3,..x..
tiger,black,4,.....
dragon,red,0,.....
dragon,red,1,x...x
dragon,red,2,..o..
dragon,red,3,.x.x.
dragon,red,4,.....
frog,black,0,.....
frog,black,1,.x...
frog,black,2,x.o..
frog,black,3,...x.
frog,black,4,.....
rabbit,black,0,.....
rabbit,black,1,...x.
rabbit,black,2,..o.x
rabbit,black,3,.x...
rabbit,black,4,.....
crab,black,0,.....
crab,black,1,..x..
crab,black,2,x.o.x
crab,black,3,.....
crab,black,4,.....
elephant,red,0,.....
elephant,red,1,.x.x.
elephant,red,2,.xox.
elephant,red,3,.....
elephant,red,4,.....
goose,black,0,.....
goose,black,1,.x...
goose,black,2,.xox.
goose,black,3,...x.
goose,black,4,.....
rooster,red,0,.....
rooster,red,1,...x.
rooster,red,2,.xox.
rooster,red,3,.x...
rooster,red,4,.....
monkey,black,0,.....
monkey,black,1,.x.x.
monkey,black,2,..o..
monkey,black,3,.x.x.
monkey,black,4,.....
mantis,red,0,.....
mantis,red,1,.x.x.
mantis,red,2,..o..
mantis,red,3,..x..
mantis,red,4,.....
horse,red,0,.....
horse,red,1,..x..
horse,red,2,.xo..
horse,red,3,..x..
horse,red,4,.....
ox,black,0,.....
ox,black,1,..x..
ox,black,2,..ox.
ox,black,3,..x..
ox,black,4,.....
crane,black,0,.....
crane,black,1,..x..
crane,black,2,..o..
crane,black,3,.x.x.
crane,black,4,.....
boar,red,0,.....
boar,red,1,..x..
boar,red,2,.xox.
boar,red,3,.....
boar,red,4,.....
eel,black,0,.....
eel,black,1,.x...
eel,black,2,..ox.
eel,black,3,.x...
eel,black,4,.....
cobra,red,0,.....
cobra,red,1,...x.
cobra,red,2,.xo..
cobra,red,3,...x.
cobra,red,4,.....
1 # pattern forms a grid of 5x5
2 # o is the move's starting postion
3 # x are move end position candidates
4 # . is space
5 # first row, first position is dx=-2 dy=2
6 name,startingPlayer,row,pattern
7 string,string,number,string
8 tiger,black,0,..x..
9 tiger,black,1,.....
10 tiger,black,2,..o..
11 tiger,black,3,..x..
12 tiger,black,4,.....
13 dragon,red,0,.....
14 dragon,red,1,x...x
15 dragon,red,2,..o..
16 dragon,red,3,.x.x.
17 dragon,red,4,.....
18 frog,black,0,.....
19 frog,black,1,.x...
20 frog,black,2,x.o..
21 frog,black,3,...x.
22 frog,black,4,.....
23 rabbit,black,0,.....
24 rabbit,black,1,...x.
25 rabbit,black,2,..o.x
26 rabbit,black,3,.x...
27 rabbit,black,4,.....
28 crab,black,0,.....
29 crab,black,1,..x..
30 crab,black,2,x.o.x
31 crab,black,3,.....
32 crab,black,4,.....
33 elephant,red,0,.....
34 elephant,red,1,.x.x.
35 elephant,red,2,.xox.
36 elephant,red,3,.....
37 elephant,red,4,.....
38 goose,black,0,.....
39 goose,black,1,.x...
40 goose,black,2,.xox.
41 goose,black,3,...x.
42 goose,black,4,.....
43 rooster,red,0,.....
44 rooster,red,1,...x.
45 rooster,red,2,.xox.
46 rooster,red,3,.x...
47 rooster,red,4,.....
48 monkey,black,0,.....
49 monkey,black,1,.x.x.
50 monkey,black,2,..o..
51 monkey,black,3,.x.x.
52 monkey,black,4,.....
53 mantis,red,0,.....
54 mantis,red,1,.x.x.
55 mantis,red,2,..o..
56 mantis,red,3,..x..
57 mantis,red,4,.....
58 horse,red,0,.....
59 horse,red,1,..x..
60 horse,red,2,.xo..
61 horse,red,3,..x..
62 horse,red,4,.....
63 ox,black,0,.....
64 ox,black,1,..x..
65 ox,black,2,..ox.
66 ox,black,3,..x..
67 ox,black,4,.....
68 crane,black,0,.....
69 crane,black,1,..x..
70 crane,black,2,..o..
71 crane,black,3,.x.x.
72 crane,black,4,.....
73 boar,red,0,.....
74 boar,red,1,..x..
75 boar,red,2,.xox.
76 boar,red,3,.....
77 boar,red,4,.....
78 eel,black,0,.....
79 eel,black,1,.x...
80 eel,black,2,..ox.
81 eel,black,3,.x...
82 eel,black,4,.....
83 cobra,red,0,.....
84 cobra,red,1,...x.
85 cobra,red,2,.xo..
86 cobra,red,3,...x.
87 cobra,red,4,.....

9
src/samples/onitama/cards.csv.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
type CardsTable = readonly {
readonly name: string;
readonly startingPlayer: string;
readonly row: number;
readonly pattern: string;
}[];
declare const data: CardsTable;
export default data;

View File

@ -0,0 +1,355 @@
import {
OnitamaGame,
OnitamaState,
PlayerType,
prompts
} from "./types";
import {createGameCommandRegistry} from "@/core/game";
import {moveToRegion} from "@/core/region";
export const registry = createGameCommandRegistry<OnitamaState>();
/**
*
*/
function isInBounds(x: number, y: number): boolean {
return x >= 0 && x < 5 && y >= 0 && y < 5;
}
/**
*
*/
function getPawnAtPosition(state: OnitamaState, x: number, y: number) {
const key = `${x},${y}`;
const pawnId = state.regions.board.partMap[key];
return pawnId ? state.pawns[pawnId] : null;
}
/**
*
*/
function playerHasCard(state: OnitamaState, player: PlayerType, cardName: string): boolean {
const cardList = player === 'red' ? state.redCards : state.blackCards;
return cardList.includes(cardName);
}
/**
*
*/
function isValidMove(state: OnitamaState, cardName: string, fromX: number, fromY: number, toX: number, toY: number, player: PlayerType): string | null {
// 检查玩家是否拥有该卡牌
if (!playerHasCard(state, player, cardName)) {
return `玩家 ${player} 不拥有卡牌 ${cardName}`;
}
// 检查起始位置是否有玩家的棋子
const fromPawn = getPawnAtPosition(state, fromX, fromY);
if (!fromPawn) {
return `位置 (${fromX}, ${fromY}) 没有棋子`;
}
if (fromPawn.owner !== player) {
return `位置 (${fromX}, ${fromY}) 的棋子不属于玩家 ${player}`;
}
// 检查卡牌是否存在
const card = state.cards[cardName];
if (!card) {
return `卡牌 ${cardName} 不存在`;
}
// 计算移动偏移量
const dx = toX - fromX;
const dy = toY - fromY;
// 检查移动是否在卡牌的移动候选项中
const isValid = card.moveCandidates.some(m => m.dx === dx && m.dy === dy);
if (!isValid) {
return `卡牌 ${cardName} 不支持移动 (${dx}, ${dy})`;
}
// 检查目标位置是否在棋盘内
if (!isInBounds(toX, toY)) {
return `目标位置 (${toX}, ${toY}) 超出棋盘范围`;
}
// 检查目标位置是否有己方棋子
const toPawn = getPawnAtPosition(state, toX, toY);
if (toPawn && toPawn.owner === player) {
return `目标位置 (${toX}, ${toY}) 已有己方棋子`;
}
return null;
}
/**
*
*/
async function handleMove(game: OnitamaGame, player: PlayerType, cardName: string, fromX: number, fromY: number, toX: number, toY: number) {
const state = game.value;
// 验证移动
const error = isValidMove(state, cardName, fromX, fromY, toX, toY, player);
if (error) {
throw new Error(error);
}
const capturedPawnId = getPawnAtPosition(state, toX, toY)?.id || null;
await game.produceAsync(state => {
const pawn = state.pawns[getPawnAtPosition(state, fromX, fromY)!.id];
// 如果目标位置有敌方棋子,将其移除(吃掉)
if (capturedPawnId) {
const capturedPawn = state.pawns[capturedPawnId];
moveToRegion(capturedPawn, state.regions.board, null);
}
// 移动棋子到目标位置
moveToRegion(pawn, state.regions.board, state.regions.board, [toX, toY]);
});
// 交换卡牌
await handleSwapCard(game, player, cardName);
return {
from: { x: fromX, y: fromY },
to: { x: toX, y: toY },
card: cardName,
captured: capturedPawnId
};
}
const move = registry.register({
schema: 'move <player> <card:string> <fromX:number> <fromY:number> <toX:number> <toY:number>',
run: handleMove
});
/**
* 使
*/
async function handleSwapCard(game: OnitamaGame, player: PlayerType, usedCard: string) {
await game.produceAsync(state => {
const spareCard = state.spareCard;
const usedCardData = state.cards[usedCard];
const spareCardData = state.cards[spareCard];
// 从玩家手牌中移除使用的卡牌
if (player === 'red') {
state.redCards = state.redCards.filter(c => c !== usedCard);
state.redCards.push(spareCard);
} else {
state.blackCards = state.blackCards.filter(c => c !== usedCard);
state.blackCards.push(spareCard);
}
// 更新卡牌区域
usedCardData.regionId = 'spare';
spareCardData.regionId = player;
// 更新备用卡牌
state.spareCard = usedCard;
});
}
const swapCard = registry.register({
schema: 'swap-card <player> <card:string>',
run: handleSwapCard
});
/**
*
*/
async function handleCheckConquestWin(game: OnitamaGame): Promise<PlayerType | null> {
const state = game.value;
// 红色师父到达 y=4黑色初始位置
const redMaster = state.pawns['red-master'];
if (redMaster && redMaster.regionId === 'board' && redMaster.position[1] === 4) {
return 'red';
}
// 黑色师父到达 y=0红色初始位置
const blackMaster = state.pawns['black-master'];
if (blackMaster && blackMaster.regionId === 'board' && blackMaster.position[1] === 0) {
return 'black';
}
return null;
}
const checkConquestWin = registry.register({
schema: 'check-conquest-win',
run: handleCheckConquestWin
});
/**
*
*/
async function handleCheckCaptureWin(game: OnitamaGame): Promise<PlayerType | null> {
const state = game.value;
// 红色师父不在棋盘上,黑色获胜
const redMaster = state.pawns['red-master'];
if (!redMaster || redMaster.regionId !== 'board') {
return 'black';
}
// 黑色师父不在棋盘上,红色获胜
const blackMaster = state.pawns['black-master'];
if (!blackMaster || blackMaster.regionId !== 'board') {
return 'red';
}
return null;
}
const checkCaptureWin = registry.register({
schema: 'check-capture-win',
run: handleCheckCaptureWin
});
/**
*
*/
async function handleCheckWin(game: OnitamaGame): Promise<PlayerType | null> {
const conquestWinner = await handleCheckConquestWin(game);
if (conquestWinner) {
return conquestWinner;
}
const captureWinner = await handleCheckCaptureWin(game);
if (captureWinner) {
return captureWinner;
}
return null;
}
const checkWin = registry.register({
schema: 'check-win',
run: handleCheckWin
});
/**
*
*/
function getAvailableMoves(state: OnitamaState, player: PlayerType): Array<{card: string, fromX: number, fromY: number, toX: number, toY: number}> {
const moves: Array<{card: string, fromX: number, fromY: number, toX: number, toY: number}> = [];
// 获取玩家的所有卡牌
const cardNames = player === 'red' ? state.redCards : state.blackCards;
// 获取玩家的所有棋子
const playerPawns = Object.values(state.pawns).filter(p => p.owner === player && p.regionId === 'board');
// 对于每张卡牌
for (const cardName of cardNames) {
const card = state.cards[cardName];
// 对于每个棋子
for (const pawn of playerPawns) {
const [fromX, fromY] = pawn.position;
// 对于卡牌的每个移动
for (const move of card.moveCandidates) {
const toX = fromX + move.dx;
const toY = fromY + move.dy;
// 检查移动是否合法
if (isInBounds(toX, toY)) {
const targetPawn = getPawnAtPosition(state, toX, toY);
// 目标位置为空或有敌方棋子
if (!targetPawn || targetPawn.owner !== player) {
moves.push({ card: cardName, fromX, fromY, toX, toY });
}
}
}
}
}
return moves;
}
/**
*
*/
async function handleTurn(game: OnitamaGame, turnPlayer: PlayerType) {
const state = game.value;
const availableMoves = getAvailableMoves(state, turnPlayer);
let moveOutput;
if (availableMoves.length === 0) {
// 没有可用移动,玩家必须交换一张卡牌
const cardToSwap = await game.prompt(
prompts.move,
(player, card, _fromX, _fromY, _toX, _toY) => {
if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
if (!playerHasCard(state, player, card)) {
throw `Player ${player} does not have card ${card}.`;
}
return card;
},
turnPlayer
);
await swapCard(game, turnPlayer, cardToSwap);
moveOutput = { swappedCard: cardToSwap, noMoves: true };
} else {
// 有可用移动,提示玩家选择
moveOutput = await game.prompt(
prompts.move,
(player, card, fromX, fromY, toX, toY) => {
if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
const error = isValidMove(state, card, fromX, fromY, toX, toY, player);
if (error) {
throw error;
}
return { player, card, fromX, fromY, toX, toY };
},
turnPlayer
);
await move(game, moveOutput.player, moveOutput.card, moveOutput.fromX, moveOutput.fromY, moveOutput.toX, moveOutput.toY);
}
// 检查胜利
const winner = await checkWin(game);
await game.produceAsync(state => {
state.winner = winner;
if (!winner) {
state.currentPlayer = state.currentPlayer === 'red' ? 'black' : 'red';
state.turn++;
}
});
return { winner, move: moveOutput };
}
const turn = registry.register({
schema: 'turn <player>',
run: handleTurn
});
/**
*
*/
export async function start(game: OnitamaGame) {
while (true) {
const currentPlayer = game.value.currentPlayer;
const turnOutput = await turn(game, currentPlayer);
if (turnOutput.winner) {
break;
}
}
return game.value;
}

View File

@ -0,0 +1,2 @@
export * from './types';
export * from './commands';

View File

@ -0,0 +1,47 @@
# Onitama
## 游戏目标
玩家在棋盘上移动棋子,达成两种胜利条件当中的一种即可赢得游戏:
- 占领:将师父棋子移动到对手师父棋子的初始位置
- 吃掉:将对手的师父棋子吃掉
## 游戏配件
公共配件:
- 卡牌16张每张标记一位起始玩家和一个移动方式图示
- 棋盘1个5x5
玩家配件:
- 师父棋子1个
- 徒弟棋子4个
## 游戏布置
1. 将所有玩家的棋子放在棋盘的初始位置上:
```
bbBbb
.....
.....
.....
rrRrr
```
其中b表示黑方r表示红方大写表示师父小写表示徒弟。
2. 将所有卡牌洗混每名玩家抓2张放在自己面前。
3. 翻开一张卡牌作为额外卡牌。额外卡牌标记的玩家成为起始玩家。
## 游戏进行
从起始玩家开始,轮流进行以下步骤:
- 选择一张卡牌,按照其描述移动棋子。
- 将选择的卡牌与额外卡牌交换。
移动时,可以选择自己的任意棋子。
移动落点必须是空位或者对手的棋子。
若移动到对手的棋子上,则吃掉该棋子。
若玩家没有任何能够进行的移动,可以任意选择一张卡牌与额外卡牌交换,然后直接结束回合。
之后若有玩家达成胜利条件,游戏结束。

View File

@ -0,0 +1,174 @@
import cards from './cards.csv';
import {Part} from "@/core/part";
import {createRegion, createRegionAxis, Region} from "@/core/region";
import {createPromptDef, IGameContext} from "@/core/game";
export type PlayerType = 'red' | 'black';
export type CardData = {
moveCandidates: {
dx: number,
dy: number,
}[];
startingPlayer: PlayerType
}
export type Card = Part<CardData>;
export type Pawn = Part<{
type: 'master' | 'student',
owner: PlayerType,
}>;
export function createRegions(){
return {
board: createRegion('board', [
createRegionAxis('x', 0, 4),
createRegionAxis('y', 0, 4),
]),
red: createRegion('red', []),
black: createRegion('black', []),
spare: createRegion('spare', []),
}
}
export type RegionType = keyof ReturnType<typeof createRegions>;
export function createGameInfo(){
return {
turn: 0,
currentPlayer: 'red' as PlayerType,
}
}
export function createCards(){
const dataMap = {} as Record<string, CardData>;
for(const entry of cards){
const card = dataMap[entry.name] = dataMap[entry.name] || {
moveCandidates: [],
startingPlayer: entry.startingPlayer,
}
for(let i = 0; i < entry.pattern.length; i++){
if(entry.pattern[i] !== 'x') continue;
const dx = i - 2;
const dy = 2 - entry.row;
card.moveCandidates.push({dx, dy});
}
}
const cardsMap = {} as Record<string, Card>;
for(const [name, data] of Object.entries(dataMap)){
cardsMap[name] = {
id: name,
regionId: '',
position: [],
...data
}
}
return cardsMap;
}
export function createPawns(){
const pawns: Record<string, Pawn> = {};
// Red player: 1 master (R) at y=0, 4 students (r) at y=0
// Position: rrRrr at y=0
pawns['red-master'] = {
id: 'red-master',
regionId: 'board',
position: [2, 0],
type: 'master',
owner: 'red',
};
for(let i = 0; i < 4; i++){
const x = i < 2 ? i : i + 1; // Skip position 2 (master)
pawns[`red-student-${i+1}`] = {
id: `red-student-${i+1}`,
regionId: 'board',
position: [x, 0],
type: 'student',
owner: 'red',
};
}
// Black player: 1 master (B) at y=4, 4 students (b) at y=4
// Position: bbBbb at y=4
pawns['black-master'] = {
id: 'black-master',
regionId: 'board',
position: [2, 4],
type: 'master',
owner: 'black',
};
for(let i = 0; i < 4; i++){
const x = i < 2 ? i : i + 1; // Skip position 2 (master)
pawns[`black-student-${i+1}`] = {
id: `black-student-${i+1}`,
regionId: 'board',
position: [x, 4],
type: 'student',
owner: 'black',
};
}
return pawns;
}
export const prompts = {
move: createPromptDef<[PlayerType, string, number, number, number, number]>(
'move <player> <card> <fromX:number> <fromY:number> <toX:number> <toY:number>',
'选择卡牌并移动棋子'
),
};
export function createInitialState() {
const regions = createRegions();
const pawns = createPawns();
const cards = createCards();
// Distribute cards: 2 to each player, 1 spare
const cardNames = Object.keys(cards);
const shuffled = [...cardNames].sort(() => Math.random() - 0.5);
const redCards = shuffled.slice(0, 2);
const blackCards = shuffled.slice(2, 4);
const spareCard = shuffled[4];
// Set card regions
for(const cardName of redCards){
cards[cardName].regionId = 'red';
regions.red.childIds.push(cardName);
}
for(const cardName of blackCards){
cards[cardName].regionId = 'black';
regions.black.childIds.push(cardName);
}
cards[spareCard].regionId = 'spare';
regions.spare.childIds.push(spareCard);
// Populate board region childIds
for(const pawn of Object.values(pawns)){
if(pawn.regionId === 'board'){
regions.board.childIds.push(pawn.id);
regions.board.partMap[pawn.position.join(',')] = pawn.id;
}
}
// Determine starting player from spare card
const startingPlayer = cards[spareCard].startingPlayer;
return {
regions,
pawns,
cards,
currentPlayer: startingPlayer,
winner: null as PlayerType | null,
spareCard,
redCards,
blackCards,
turn: 0,
};
}
export type OnitamaState = ReturnType<typeof createInitialState>;
export type OnitamaGame = IGameContext<OnitamaState>;

View File

@ -146,8 +146,9 @@ export function createCommandRunnerContext<TContext>(
}; };
const prompt = <TResult,TArgs extends any[]=any[]>( const prompt = <TResult,TArgs extends any[]=any[]>(
schema: CommandSchema | string, schema: CommandSchema,
validator: PromptValidator<TResult,TArgs>, validator: PromptValidator<TResult,TArgs>,
hintText?: string,
currentPlayer?: string | null currentPlayer?: string | null
): Promise<TResult> => { ): Promise<TResult> => {
const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema; const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
@ -174,7 +175,7 @@ export function createCommandRunnerContext<TContext>(
emitPromptEnd(); emitPromptEnd();
reject(new Error(reason ?? 'Cancelled')); reject(new Error(reason ?? 'Cancelled'));
}; };
activePrompt = { schema: resolvedSchema, currentPlayer: currentPlayer ?? null, tryCommit, cancel }; activePrompt = { schema: resolvedSchema, hintText, currentPlayer: currentPlayer ?? null, tryCommit, cancel };
const event: PromptEvent = { schema: resolvedSchema, currentPlayer: currentPlayer ?? null, tryCommit, cancel }; const event: PromptEvent = { schema: resolvedSchema, currentPlayer: currentPlayer ?? null, tryCommit, cancel };
for (const listener of promptListeners) { for (const listener of promptListeners) {
listener(event); listener(event);

View File

@ -2,6 +2,8 @@ import type { Command, CommandSchema } from './types';
export type PromptEvent = { export type PromptEvent = {
schema: CommandSchema; schema: CommandSchema;
/** 提示文本 */
hintText?: string;
/** 当前等待输入的玩家 */ /** 当前等待输入的玩家 */
currentPlayer: string | null; currentPlayer: string | null;
/** /**
@ -35,7 +37,7 @@ export type CommandRunnerContext<TContext> = {
context: TContext; context: TContext;
run: <T=unknown>(input: string) => Promise<CommandResult<T>>; run: <T=unknown>(input: string) => Promise<CommandResult<T>>;
runParsed: <T=unknown>(command: Command) => Promise<CommandResult<T>>; runParsed: <T=unknown>(command: Command) => Promise<CommandResult<T>>;
prompt: <TResult,TArgs extends any[]=any[]>(schema: CommandSchema | string, validator: PromptValidator<TResult,TArgs>, currentPlayer?: string | null) => Promise<TResult>; prompt: <TResult,TArgs extends any[]=any[]>(schema: CommandSchema, validator: PromptValidator<TResult,TArgs>, hintText?: string, currentPlayer?: string | null) => Promise<TResult>;
on: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void; on: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
off: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void; off: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
}; };

View File

@ -136,9 +136,12 @@ describe('createGameCommand', () => {
describe('createPromptDef', () => { describe('createPromptDef', () => {
it('should create a PromptDef with string schema', () => { it('should create a PromptDef with string schema', () => {
const promptDef = createPromptDef<[string, number]>('play <player> <score:number>'); const promptDef = createPromptDef<[string, number]>('play <player> <score:number>');
expect(promptDef).toBeDefined(); expect(promptDef).toBeDefined();
expect(promptDef.schema).toBe('play <player> <score:number>'); expect(promptDef.schema.name).toBe('play');
expect(promptDef.schema.params).toHaveLength(2);
expect(promptDef.schema.params[0].name).toBe('player');
expect(promptDef.schema.params[1].name).toBe('score');
}); });
it('should create a PromptDef with CommandSchema object', () => { it('should create a PromptDef with CommandSchema object', () => {

View File

@ -0,0 +1,512 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
registry,
createInitialState,
OnitamaState,
createPawns,
createCards,
createRegions,
PlayerType,
} from '@/samples/onitama';
import { createGameContext } from '@/core/game';
import type { PromptEvent } from '@/utils/command';
function createTestContext() {
const ctx = createGameContext(registry, createInitialState());
return { registry, ctx };
}
function createDeterministicContext() {
// Create a state with known card distribution for testing
const regions = createRegions();
const pawns = createPawns();
const cards = createCards();
// Populate board region
for(const pawn of Object.values(pawns)){
if(pawn.regionId === 'board'){
regions.board.childIds.push(pawn.id);
regions.board.partMap[pawn.position.join(',')] = pawn.id;
}
}
// Force known card distribution
const redCards = ['tiger', 'dragon'];
const blackCards = ['frog', 'rabbit'];
const spareCard = 'crab';
// Set card regions
cards['tiger'].regionId = 'red';
cards['dragon'].regionId = 'red';
cards['frog'].regionId = 'black';
cards['rabbit'].regionId = 'black';
cards['crab'].regionId = 'spare';
regions.red.childIds = [...redCards];
regions.black.childIds = [...blackCards];
regions.spare.childIds = [spareCard];
const state = {
regions,
pawns,
cards,
currentPlayer: 'red' as PlayerType,
winner: null as PlayerType | null,
spareCard,
redCards,
blackCards,
turn: 0,
};
const ctx = createGameContext(registry, () => state);
return { registry, ctx };
}
function waitForPrompt(ctx: ReturnType<typeof createTestContext>['ctx']): Promise<PromptEvent> {
return new Promise(resolve => {
ctx._commands.on('prompt', resolve);
});
}
describe('Onitama Game', () => {
describe('Setup', () => {
it('should create initial state correctly', () => {
const state = createInitialState();
expect(state.currentPlayer).toBeDefined();
expect(state.winner).toBeNull();
expect(state.regions.board).toBeDefined();
expect(state.regions.red).toBeDefined();
expect(state.regions.black).toBeDefined();
expect(state.regions.spare).toBeDefined();
// Should have 10 pawns (5 per player)
const redPawns = Object.values(state.pawns).filter(p => p.owner === 'red');
const blackPawns = Object.values(state.pawns).filter(p => p.owner === 'black');
expect(redPawns.length).toBe(5);
expect(blackPawns.length).toBe(5);
// Each player should have 1 master and 4 students
const redMaster = redPawns.find(p => p.type === 'master');
const redStudents = redPawns.filter(p => p.type === 'student');
expect(redMaster).toBeDefined();
expect(redStudents.length).toBe(4);
// Master should be at center
expect(redMaster?.position[0]).toBe(2);
expect(redMaster?.position[1]).toBe(0);
// Cards should be distributed: 2 per player + 1 spare
expect(state.redCards.length).toBe(2);
expect(state.blackCards.length).toBe(2);
expect(state.spareCard).toBeDefined();
});
it('should create pawns in correct positions', () => {
const pawns = createPawns();
// Red player at y=0
expect(pawns['red-master'].position).toEqual([2, 0]);
expect(pawns['red-student-1'].position).toEqual([0, 0]);
expect(pawns['red-student-2'].position).toEqual([1, 0]);
expect(pawns['red-student-3'].position).toEqual([3, 0]);
expect(pawns['red-student-4'].position).toEqual([4, 0]);
// Black player at y=4
expect(pawns['black-master'].position).toEqual([2, 4]);
expect(pawns['black-student-1'].position).toEqual([0, 4]);
expect(pawns['black-student-2'].position).toEqual([1, 4]);
expect(pawns['black-student-3'].position).toEqual([3, 4]);
expect(pawns['black-student-4'].position).toEqual([4, 4]);
});
it('should parse cards correctly from CSV', () => {
const cards = createCards();
// Should have 16 unique cards
expect(Object.keys(cards).length).toBe(16);
// Check tiger card moves
const tiger = cards['tiger'];
expect(tiger.moveCandidates).toHaveLength(2);
expect(tiger.moveCandidates).toContainEqual({ dx: 0, dy: 2 });
expect(tiger.moveCandidates).toContainEqual({ dx: 0, dy: -1 });
expect(tiger.startingPlayer).toBe('black');
// Check dragon card moves
const dragon = cards['dragon'];
expect(dragon.moveCandidates.length).toBe(4);
expect(dragon.startingPlayer).toBe('red');
});
it('should create regions correctly', () => {
const regions = createRegions();
expect(regions.board.id).toBe('board');
expect(regions.board.axes).toHaveLength(2);
expect(regions.board.axes[0].max).toBe(4);
expect(regions.board.axes[1].max).toBe(4);
});
});
describe('Move Validation', () => {
it('should validate card ownership', async () => {
const { ctx } = createDeterministicContext();
// Red tries to use a card they don't have
const result = await ctx.run('move red frog 2 0 2 1');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('不拥有卡牌');
}
});
it('should validate pawn ownership', async () => {
const { ctx } = createDeterministicContext();
// Red tries to move a black pawn (at 2,4)
const result = await ctx.run('move red tiger 2 4 2 2');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('不属于玩家');
}
});
it('should validate move is in card pattern', async () => {
const { ctx } = createDeterministicContext();
// Tiger card only allows specific moves, try invalid move
const result = await ctx.run('move red tiger 2 0 3 0');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('不支持移动');
}
});
it('should prevent moving to position with own pawn', async () => {
const { ctx } = createDeterministicContext();
// Tiger allows dy=-2 or dy=1. Try to move to position with own pawn
// Move student from 1,0 to 0,0 (occupied by another red student)
// This requires dx=-1, dy=0 which tiger doesn't support
const result = await ctx.run('move red tiger 1 0 0 0');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('不支持移动');
}
});
});
describe('Move Execution', () => {
it('should move pawn correctly', async () => {
const { ctx } = createDeterministicContext();
// Move red student from 0,0 using tiger card (tiger allows dy=2)
// From y=0, dy=2 goes to y=2
const result = await ctx.run('move red tiger 0 0 0 2');
expect(result.success).toBe(true);
const state = ctx.value;
const pawn = state.pawns['red-student-1'];
expect(pawn.position).toEqual([0, 2]);
});
it('should capture enemy pawn', async () => {
const { ctx } = createDeterministicContext();
// Setup: place black student at 0,2
ctx.produce(state => {
const blackStudent = state.pawns['black-student-1'];
blackStudent.position = [0, 2];
state.regions.board.partMap['0,2'] = blackStudent.id;
});
// Red captures with tiger card
const result = await ctx.run('move red tiger 0 0 0 2');
expect(result.success).toBe(true);
const state = ctx.value;
// Black student should be removed from board
const blackStudent = state.pawns['black-student-1'];
expect(blackStudent.regionId).not.toBe('board');
// Red student should be at 0,2
const redStudent = state.pawns['red-student-1'];
expect(redStudent.position).toEqual([0, 2]);
});
it('should swap card after move', async () => {
const { ctx } = createDeterministicContext();
const cardsBefore = ctx.value;
expect(cardsBefore.redCards).toContain('tiger');
expect(cardsBefore.spareCard).toBe('crab');
// Move using tiger card
await ctx.run('move red tiger 0 0 0 2');
const state = ctx.value;
// Tiger should now be spare, crab should be with red
expect(state.redCards).toContain('crab');
expect(state.redCards).not.toContain('tiger');
expect(state.spareCard).toBe('tiger');
});
});
describe('Win Conditions', () => {
it('should detect conquest win for red (master reaches y=4)', async () => {
const { ctx } = createDeterministicContext();
// Move red master to y=4
ctx.produce(state => {
const redMaster = state.pawns['red-master'];
redMaster.position = [2, 4];
});
const result = await ctx.run('check-win');
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('red');
}
});
it('should detect conquest win for black (master reaches y=0)', async () => {
const { ctx } = createDeterministicContext();
// Move black master to y=0
ctx.produce(state => {
const blackMaster = state.pawns['black-master'];
blackMaster.position = [2, 0];
});
const result = await ctx.run('check-win');
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('black');
}
});
it('should detect capture win when red master is captured', async () => {
const { ctx } = createDeterministicContext();
// Remove red master from board
ctx.produce(state => {
const redMaster = state.pawns['red-master'];
redMaster.regionId = '';
delete state.regions.board.partMap['2,0'];
});
const result = await ctx.run('check-win');
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('black');
}
});
it('should detect capture win when black master is captured', async () => {
const { ctx } = createDeterministicContext();
// Remove black master from board
ctx.produce(state => {
const blackMaster = state.pawns['black-master'];
blackMaster.regionId = '';
delete state.regions.board.partMap['2,4'];
});
const result = await ctx.run('check-win');
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('red');
}
});
});
describe('Card Swap', () => {
it('should swap card between player and spare', async () => {
const { ctx } = createDeterministicContext();
const stateBefore = ctx.value;
expect(stateBefore.redCards).toContain('tiger');
expect(stateBefore.spareCard).toBe('crab');
const result = await ctx.run('swap-card red tiger');
expect(result.success).toBe(true);
const state = ctx.value;
expect(state.redCards).toContain('crab');
expect(state.redCards).not.toContain('tiger');
expect(state.spareCard).toBe('tiger');
});
});
describe('Turn Flow', () => {
it('should switch player after turn', async () => {
const { ctx } = createDeterministicContext();
// Force red to be current player
ctx.produce(state => {
state.currentPlayer = 'red';
});
// Start turn
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.run('turn red');
const promptEvent = await promptPromise;
// Make a valid move - tiger allows dy=2, move student from 0,0 to 0,2
const error = promptEvent.tryCommit({
name: 'move',
params: ['red', 'tiger', 0, 0, 0, 2],
options: {},
flags: {}
});
expect(error).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
const state = ctx.value;
// Should now be black's turn
expect(state.currentPlayer).toBe('black');
expect(state.turn).toBe(1);
});
it('should end game when win condition met', async () => {
const { ctx } = createDeterministicContext();
// Set up winning scenario - move red master to y=2, one step from winning
ctx.produce(state => {
state.currentPlayer = 'red';
const redMaster = state.pawns['red-master'];
// Clear old position
delete state.regions.board.partMap['2,0'];
// Set new position
redMaster.position = [2, 2];
state.regions.board.partMap['2,2'] = redMaster.id;
});
// Red moves master to winning position (y=4)
// Tiger allows dy=2
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.run('turn red');
const promptEvent = await promptPromise;
const error = promptEvent.tryCommit({
name: 'move',
params: ['red', 'tiger', 2, 2, 2, 4],
options: {},
flags: {}
});
expect(error).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
const state = ctx.value;
expect(state.winner).toBe('red');
});
});
describe('Available Moves', () => {
it('should calculate valid moves for player', async () => {
const { ctx } = createDeterministicContext();
// Give red cards and verify they can make moves
ctx.produce(state => {
state.redCards = ['tiger', 'dragon'];
});
// Tiger from 0,0 can go to 0,2 (dy=2)
// Just verify the move is valid without triggering prompt
const result = await ctx.run('move red tiger 0 0 0 2');
expect(result.success).toBe(true);
});
});
describe('No Available Moves', () => {
it('should allow card swap when no moves available', async () => {
const { ctx } = createDeterministicContext();
// Setup a scenario where red has no valid moves
// Move all red pawns to positions where they can't move with available cards
ctx.produce(state => {
// Move all red students to back rank or blocked positions
for (let i = 1; i <= 4; i++) {
const student = state.pawns[`red-student-${i}`];
student.position = [i === 5 ? 4 : i - 1, 1]; // All at y=1
}
state.pawns['red-master'].position = [2, 1];
// Give only cards that move backwards (negative dy)
state.redCards = ['tiger'];
});
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.run('turn red');
const promptEvent = await promptPromise;
// Should prompt for card swap
expect(promptEvent).toBeDefined();
});
});
describe('Edge Cases', () => {
it('should handle capturing master ending the game', async () => {
const { ctx } = createDeterministicContext();
// Setup: black student adjacent to red master
ctx.produce(state => {
state.currentPlayer = 'black';
// Move red master to 1,3
const redMaster = state.pawns['red-master'];
delete state.regions.board.partMap['2,0'];
redMaster.position = [1, 3];
state.regions.board.partMap['1,3'] = redMaster.id;
// Move black student to 2,4
const blackStudent = state.pawns['black-student-1'];
delete state.regions.board.partMap['0,4'];
blackStudent.position = [2, 4];
state.regions.board.partMap['2,4'] = blackStudent.id;
// Set black's card to goose
state.blackCards = ['goose'];
state.regions.black.childIds = ['goose'];
state.cards['goose'].regionId = 'black';
});
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.run('turn black');
const promptEvent = await promptPromise;
// Goose: dx=-1,dy=1; dx=-1,dy=0; dx=1,dy=0; dx=1,dy=-1
// Move from 2,4 to 1,3 (dx=-1, dy=-1) - but goose doesn't support this!
// Move from 2,4 to 3,3 (dx=1, dy=-1) - this matches goose's pattern
// So let's move red master to 3,3 instead
ctx.produce(state => {
const redMaster = state.pawns['red-master'];
delete state.regions.board.partMap['1,3'];
redMaster.position = [3, 3];
state.regions.board.partMap['3,3'] = redMaster.id;
});
// Now move from 2,4 to 3,3 (dx=1, dy=-1) - captures red master
const error = promptEvent.tryCommit({
name: 'move',
params: ['black', 'goose', 2, 4, 3, 3],
options: {},
flags: {}
});
expect(error).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
const state = ctx.value;
// Red master should be removed from board
const redMaster = state.pawns['red-master'];
expect(redMaster.regionId).not.toBe('board');
expect(state.winner).toBe('black');
});
});
});