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 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'); }); }); });