diff --git a/src/samples/onitama/cards.csv b/src/samples/onitama/cards.csv index c6eda77..046c020 100644 --- a/src/samples/onitama/cards.csv +++ b/src/samples/onitama/cards.csv @@ -2,7 +2,11 @@ # 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.. diff --git a/src/samples/onitama/cards.csv.d.ts b/src/samples/onitama/cards.csv.d.ts new file mode 100644 index 0000000..ac43342 --- /dev/null +++ b/src/samples/onitama/cards.csv.d.ts @@ -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; diff --git a/src/samples/onitama/commands.ts b/src/samples/onitama/commands.ts new file mode 100644 index 0000000..24d2213 --- /dev/null +++ b/src/samples/onitama/commands.ts @@ -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(); + +/** + * 检查位置是否在棋盘范围内 + */ +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 ', + 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 ', + run: handleSwapCard +}); + +/** + * 检查占领胜利条件:玩家的师父棋子到达对手的初始位置 + */ +async function handleCheckConquestWin(game: OnitamaGame): Promise { + 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 { + 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 { + 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 ', + 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; +} diff --git a/src/samples/onitama/index.ts b/src/samples/onitama/index.ts new file mode 100644 index 0000000..12981c2 --- /dev/null +++ b/src/samples/onitama/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './commands'; \ No newline at end of file diff --git a/src/samples/onitama/types.ts b/src/samples/onitama/types.ts new file mode 100644 index 0000000..044c52d --- /dev/null +++ b/src/samples/onitama/types.ts @@ -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; +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; + +export function createGameInfo(){ + return { + turn: 0, + currentPlayer: 'red' as PlayerType, + } +} + +export function createCards(){ + const dataMap = {} as Record; + + 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; + for(const [name, data] of Object.entries(dataMap)){ + cardsMap[name] = { + id: name, + regionId: '', + position: [], + ...data + } + } + return cardsMap; +} + +export function createPawns(){ + const pawns: Record = {}; + + // 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 ' + ), +}; + +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; +export type OnitamaGame = IGameContext; \ No newline at end of file diff --git a/tests/samples/onitama.test.ts b/tests/samples/onitama.test.ts new file mode 100644 index 0000000..1f0bb94 --- /dev/null +++ b/tests/samples/onitama.test.ts @@ -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['ctx']): Promise { + 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'); + }); + }); +});