Compare commits
5 Commits
a84aa2426b
...
6b736ab083
| Author | SHA1 | Date |
|---|---|---|
|
|
6b736ab083 | |
|
|
a5cc584121 | |
|
|
beb8088009 | |
|
|
b3cea805b0 | |
|
|
4cb9f2dbd6 |
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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> = {} >() {
|
||||||
|
|
|
||||||
|
|
@ -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,.....
|
||||||
|
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './types';
|
||||||
|
export * from './commands';
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Onitama
|
||||||
|
|
||||||
|
## 游戏目标
|
||||||
|
|
||||||
|
玩家在棋盘上移动棋子,达成两种胜利条件当中的一种即可赢得游戏:
|
||||||
|
- 占领:将师父棋子移动到对手师父棋子的初始位置
|
||||||
|
- 吃掉:将对手的师父棋子吃掉
|
||||||
|
|
||||||
|
## 游戏配件
|
||||||
|
|
||||||
|
公共配件:
|
||||||
|
- 卡牌:16张,每张标记一位起始玩家,和一个移动方式图示
|
||||||
|
- 棋盘:1个,5x5
|
||||||
|
|
||||||
|
玩家配件:
|
||||||
|
- 师父棋子:1个
|
||||||
|
- 徒弟棋子:4个
|
||||||
|
|
||||||
|
## 游戏布置
|
||||||
|
|
||||||
|
1. 将所有玩家的棋子放在棋盘的初始位置上:
|
||||||
|
```
|
||||||
|
bbBbb
|
||||||
|
.....
|
||||||
|
.....
|
||||||
|
.....
|
||||||
|
rrRrr
|
||||||
|
```
|
||||||
|
其中b表示黑方,r表示红方,大写表示师父,小写表示徒弟。
|
||||||
|
|
||||||
|
2. 将所有卡牌洗混,每名玩家抓2张放在自己面前。
|
||||||
|
|
||||||
|
3. 翻开一张卡牌作为额外卡牌。额外卡牌标记的玩家成为起始玩家。
|
||||||
|
|
||||||
|
## 游戏进行
|
||||||
|
|
||||||
|
从起始玩家开始,轮流进行以下步骤:
|
||||||
|
- 选择一张卡牌,按照其描述移动棋子。
|
||||||
|
- 将选择的卡牌与额外卡牌交换。
|
||||||
|
|
||||||
|
移动时,可以选择自己的任意棋子。
|
||||||
|
移动落点必须是空位或者对手的棋子。
|
||||||
|
若移动到对手的棋子上,则吃掉该棋子。
|
||||||
|
|
||||||
|
若玩家没有任何能够进行的移动,可以任意选择一张卡牌与额外卡牌交换,然后直接结束回合。
|
||||||
|
|
||||||
|
之后若有玩家达成胜利条件,游戏结束。
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue