feat: onitama tests

This commit is contained in:
hypercross 2026-04-07 14:53:50 +08:00
parent 4cb9f2dbd6
commit b3cea805b0
6 changed files with 1053 additions and 0 deletions

View File

@ -2,7 +2,11 @@
# o is the move's starting postion # o is the move's starting postion
# x are move end position candidates # x are move end position candidates
# . is space # . is space
# first row, first position is dx=-2 dy=2
name,startingPlayer,row,pattern name,startingPlayer,row,pattern
string,string,number,string
tiger,black,0,..x.. tiger,black,0,..x..
tiger,black,1,..... tiger,black,1,.....
tiger,black,2,..o.. tiger,black,2,..o..

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 name,startingPlayer,row,pattern string,string,number,string
8 tiger,black,0,..x..
9 tiger,black,1,.....
10 tiger,black,0,..x.. tiger,black,2,..o..
11 tiger,black,1,..... tiger,black,3,..x..
12 tiger,black,2,..o.. tiger,black,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,173 @@
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

@ -0,0 +1,510 @@
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 2,2
const redMaster = state.pawns['red-master'];
delete state.regions.board.partMap['2,0'];
redMaster.position = [2, 2];
state.regions.board.partMap['2,2'] = 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;
});
// Move from 2,4 to 2,3 (dx=0, dy=-1) - but frog doesn't support this!
// Let's use goose instead which supports dx=-1,dy=-1
ctx.produce(state => {
state.blackCards = ['goose'];
state.regions.black.childIds = ['goose'];
state.cards['goose'].regionId = 'black';
// Move red master to 1,3
const redMaster = state.pawns['red-master'];
delete state.regions.board.partMap['2,3'];
redMaster.position = [1, 3];
state.regions.board.partMap['1,3'] = redMaster.id;
});
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) - captures red master
const error = promptEvent.tryCommit({
name: 'move',
params: ['black', 'goose', 2, 4, 1, 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');
});
});
});