diff --git a/src/samples/tic-tac-toe.ts b/src/samples/tic-tac-toe.ts index 4ea5b49..74e78b4 100644 --- a/src/samples/tic-tac-toe.ts +++ b/src/samples/tic-tac-toe.ts @@ -1,132 +1,189 @@ -import {Part} from "boardgame-core"; -import {createRegion} from "boardgame-core"; -import {createGameCommandRegistry, createPromptDef, IGameContext} from "boardgame-core"; +import { Part } from "@/core/part"; +import { createRegion } from "@/core/region"; +import { + createGameCommandRegistry, + createPromptDef, + IGameContext, +} from "@/core/game"; const BOARD_SIZE = 3; const MAX_TURNS = BOARD_SIZE * BOARD_SIZE; const WINNING_LINES: number[][][] = [ - [[0, 0], [0, 1], [0, 2]], - [[1, 0], [1, 1], [1, 2]], - [[2, 0], [2, 1], [2, 2]], - [[0, 0], [1, 0], [2, 0]], - [[0, 1], [1, 1], [2, 1]], - [[0, 2], [1, 2], [2, 2]], - [[0, 0], [1, 1], [2, 2]], - [[0, 2], [1, 1], [2, 0]], + [ + [0, 0], + [0, 1], + [0, 2], + ], + [ + [1, 0], + [1, 1], + [1, 2], + ], + [ + [2, 0], + [2, 1], + [2, 2], + ], + [ + [0, 0], + [1, 0], + [2, 0], + ], + [ + [0, 1], + [1, 1], + [2, 1], + ], + [ + [0, 2], + [1, 2], + [2, 2], + ], + [ + [0, 0], + [1, 1], + [2, 2], + ], + [ + [0, 2], + [1, 1], + [2, 0], + ], ]; -export type PlayerType = 'X' | 'O'; -export type WinnerType = PlayerType | 'draw' | null; +export type PlayerType = "X" | "O"; +export type WinnerType = PlayerType | "draw" | null; export type TicTacToePart = Part<{ player: PlayerType }>; export function createInitialState() { - return { - board: createRegion('board', [ - { name: 'x', min: 0, max: BOARD_SIZE - 1 }, - { name: 'y', min: 0, max: BOARD_SIZE - 1 }, - ]), - parts: {} as Record, - currentPlayer: 'X' as PlayerType, - winner: null as WinnerType, - turn: 0, - }; + return { + board: createRegion("board", [ + { name: "x", min: 0, max: BOARD_SIZE - 1 }, + { name: "y", min: 0, max: BOARD_SIZE - 1 }, + ]), + parts: {} as Record, + currentPlayer: "X" as PlayerType, + winner: null as WinnerType, + turn: 0, + }; } export type TicTacToeState = ReturnType; export type TicTacToeGame = IGameContext; export const registry = createGameCommandRegistry(); export const prompts = { - play: createPromptDef<[PlayerType, number, number]>( - 'play ') -} + play: createPromptDef<[PlayerType, number, number]>( + "play ", + ), +}; export async function start(game: TicTacToeGame) { - while (true) { - const currentPlayer = game.value.currentPlayer; - const turnNumber = game.value.turn + 1; - const turnOutput = await turn(game, currentPlayer, turnNumber); + while (true) { + const currentPlayer = game.value.currentPlayer; + const turnNumber = game.value.turn + 1; + const turnOutput = await turn(game, currentPlayer, turnNumber); - game.produce(state => { - state.winner = turnOutput.winner; - if (!state.winner) { - state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X'; - state.turn = turnNumber; - } - }); - if (game.value.winner) break; - } + game.produce((state) => { + state.winner = turnOutput.winner; + if (!state.winner) { + state.currentPlayer = state.currentPlayer === "X" ? "O" : "X"; + state.turn = turnNumber; + } + }); + if (game.value.winner) break; + } - return game.value; + return game.value; } const turn = registry.register({ - schema: 'turn ', - async run(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: number) { - const {player, row, col} = await game.prompt( - prompts.play, - (player, row, col) => { - if (player !== turnPlayer) { - throw `Invalid player: ${player}. Expected ${turnPlayer}.`; - } else if (!isValidMove(row, col)) { - throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`; - } else if (isCellOccupied(game, row, col)) { - throw `Cell (${row}, ${col}) is already occupied.`; - } else { - return { player, row, col }; - } - }, - game.value.currentPlayer - ); + schema: "turn ", + async run(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: number) { + const { player, row, col } = await game.prompt( + prompts.play, + (player, row, col) => { + if (player !== turnPlayer) { + throw `Invalid player: ${player}. Expected ${turnPlayer}.`; + } else if (!isValidMove(row, col)) { + throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`; + } else if (isCellOccupied(game, row, col)) { + throw `Cell (${row}, ${col}) is already occupied.`; + } else { + return { player, row, col }; + } + }, + game.value.currentPlayer, + ); - placePiece(game, row, col, turnPlayer); + placePiece(game, row, col, turnPlayer); - const winner = checkWinner(game); - if (winner) return { winner }; - if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType }; + const winner = checkWinner(game); + if (winner) return { winner }; + if (turnNumber >= MAX_TURNS) return { winner: "draw" as WinnerType }; - return { winner: null }; - } + return { winner: null }; + }, }); function isValidMove(row: number, col: number): boolean { - return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE; + return ( + !isNaN(row) && + !isNaN(col) && + row >= 0 && + row < BOARD_SIZE && + col >= 0 && + col < BOARD_SIZE + ); } -export function isCellOccupied(host: TicTacToeGame, row: number, col: number): boolean { - return !!host.value.board.partMap[`${row},${col}`]; +export function isCellOccupied( + host: TicTacToeGame, + row: number, + col: number, +): boolean { + return !!host.value.board.partMap[`${row},${col}`]; } export function hasWinningLine(positions: number[][]): boolean { - return WINNING_LINES.some(line => - line.every(([r, c]) => - positions.some(([pr, pc]) => pr === r && pc === c) - ) - ); + return WINNING_LINES.some((line) => + line.every(([r, c]) => positions.some(([pr, pc]) => pr === r && pc === c)), + ); } export function checkWinner(host: TicTacToeGame): WinnerType { - const parts = host.value.parts; - const partsArray = Object.values(parts); + const parts = host.value.parts; + const partsArray = Object.values(parts); - const xPositions = partsArray.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position); - const oPositions = partsArray.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position); + const xPositions = partsArray + .filter((p: TicTacToePart) => p.player === "X") + .map((p: TicTacToePart) => p.position); + const oPositions = partsArray + .filter((p: TicTacToePart) => p.player === "O") + .map((p: TicTacToePart) => p.position); - if (hasWinningLine(xPositions)) return 'X'; - if (hasWinningLine(oPositions)) return 'O'; - if (partsArray.length >= MAX_TURNS) return 'draw'; + if (hasWinningLine(xPositions)) return "X"; + if (hasWinningLine(oPositions)) return "O"; + if (partsArray.length >= MAX_TURNS) return "draw"; - return null; + return null; } -export function placePiece(host: TicTacToeGame, row: number, col: number, player: PlayerType) { - const board = host.value.board; - const moveNumber = Object.keys(host.value.parts).length + 1; - const piece: TicTacToePart = { - regionId: 'board', position: [row, col], player, - id: `piece-${player}-${moveNumber}` - } - host.produce(state => { - state.parts[piece.id] = piece; - board.childIds.push(piece.id); - board.partMap[`${row},${col}`] = piece.id; - }); +export function placePiece( + host: TicTacToeGame, + row: number, + col: number, + player: PlayerType, +) { + const board = host.value.board; + const moveNumber = Object.keys(host.value.parts).length + 1; + const piece: TicTacToePart = { + regionId: "board", + position: [row, col], + player, + id: `piece-${player}-${moveNumber}`, + }; + host.produce((state) => { + state.parts[piece.id] = piece; + board.childIds.push(piece.id); + board.partMap[`${row},${col}`] = piece.id; + }); } diff --git a/tests/samples/boop.test.ts b/tests/samples/boop.test.ts index 27fb41f..a92d593 100644 --- a/tests/samples/boop.test.ts +++ b/tests/samples/boop.test.ts @@ -1,671 +1,795 @@ -import { describe, it, expect } from 'vitest'; -import { registry, createInitialState, BoopState } from '@/samples/boop'; -import { createGameContext } from '@/core/game'; -import type { PromptEvent } from '@/utils/command'; +import { describe, it, expect } from "vitest"; +import { registry, createInitialState, BoopState } from "@/samples/boop"; +import { createGameContext } from "@/core/game"; +import type { PromptEvent } from "@/utils/command"; function createTestContext() { - const ctx = createGameContext(registry, createInitialState()); - return { registry, ctx }; + const ctx = createGameContext(registry, createInitialState()); + return { registry, ctx }; } -function waitForPrompt(ctx: ReturnType['ctx']): Promise { - return new Promise(resolve => { - ctx._commands.on('prompt', resolve); - }); +function waitForPrompt( + ctx: ReturnType["ctx"], +): Promise { + return new Promise((resolve) => { + ctx._commands.on("prompt", resolve); + }); } -describe('Boop Game', () => { - describe('Setup', () => { - it('should create initial state correctly', () => { - const state = createInitialState(); +describe("Boop Game", () => { + describe("Setup", () => { + it("should create initial state correctly", () => { + const state = createInitialState(); - expect(state.currentPlayer).toBe('white'); - expect(state.winner).toBeNull(); - expect(state.regions.board).toBeDefined(); - expect(state.regions.white).toBeDefined(); - expect(state.regions.black).toBeDefined(); + expect(state.currentPlayer).toBe("white"); + expect(state.winner).toBeNull(); + expect(state.regions.board).toBeDefined(); + expect(state.regions.white).toBeDefined(); + expect(state.regions.black).toBeDefined(); - // 8 kittens per player - const whiteKittens = Object.values(state.pieces).filter(p => p.player === 'white' && p.type === 'kitten'); - const blackKittens = Object.values(state.pieces).filter(p => p.player === 'black' && p.type === 'kitten'); - expect(whiteKittens.length).toBe(8); - expect(blackKittens.length).toBe(8); + // 8 kittens per player + const whiteKittens = Object.values(state.pieces).filter( + (p) => p.player === "white" && p.type === "kitten", + ); + const blackKittens = Object.values(state.pieces).filter( + (p) => p.player === "black" && p.type === "kitten", + ); + expect(whiteKittens.length).toBe(8); + expect(blackKittens.length).toBe(8); - // 8 cats per player (initially in box) - const whiteCats = Object.values(state.pieces).filter(p => p.player === 'white' && p.type === 'cat'); - const blackCats = Object.values(state.pieces).filter(p => p.player === 'black' && p.type === 'cat'); - expect(whiteCats.length).toBe(8); - expect(blackCats.length).toBe(8); + // 8 cats per player (initially in box) + const whiteCats = Object.values(state.pieces).filter( + (p) => p.player === "white" && p.type === "cat", + ); + const blackCats = Object.values(state.pieces).filter( + (p) => p.player === "black" && p.type === "cat", + ); + expect(whiteCats.length).toBe(8); + expect(blackCats.length).toBe(8); - // All cats should be in box (regionId = '') - whiteCats.forEach(cat => expect(cat.regionId).toBe('')); - blackCats.forEach(cat => expect(cat.regionId).toBe('')); + // All cats should be in box (regionId = '') + whiteCats.forEach((cat) => expect(cat.regionId).toBe("")); + blackCats.forEach((cat) => expect(cat.regionId).toBe("")); - // Kittens should be in player supplies - whiteKittens.forEach(k => expect(k.regionId).toBe('white')); - blackKittens.forEach(k => expect(k.regionId).toBe('black')); - }); + // Kittens should be in player supplies + whiteKittens.forEach((k) => expect(k.regionId).toBe("white")); + blackKittens.forEach((k) => expect(k.regionId).toBe("black")); + }); + }); + + describe("Place and Boop Commands", () => { + it("should place a kitten via play command", async () => { + const { ctx } = createTestContext(); + + // Use turn command instead of setup which runs indefinitely + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx.run("turn white"); + + const promptEvent = await promptPromise; + expect(promptEvent).not.toBeNull(); + expect(promptEvent.schema.name).toBe("play"); + + // Place a kitten at position 2,2 + const error = promptEvent.tryCommit({ + name: "play", + params: ["white", 2, 2, "kitten"], + options: {}, + flags: {}, + }); + expect(error).toBeNull(); + + const result = await runPromise; + expect(result.success).toBe(true); + + const state = ctx.value; + // Should have placed a piece on the board + const boardPieces = Object.keys(state.regions.board.partMap); + expect(boardPieces.length).toBeGreaterThan(0); + + // Should have one less kitten in supply + const whiteSupply = state.regions.white.childIds.filter( + (id) => state.pieces[id].type === "kitten", + ); + expect(whiteSupply.length).toBe(7); + }); + }); + + describe("Boop Mechanics", () => { + it("should boop adjacent pieces away from placement", async () => { + const { ctx } = createTestContext(); + + // White places at 2,2 + let promptPromise = waitForPrompt(ctx); + let runPromise = ctx.run("turn white"); + let promptEvent = await promptPromise; + let error = promptEvent.tryCommit({ + name: "play", + params: ["white", 2, 2, "kitten"], + options: {}, + flags: {}, + }); + expect(error).toBeNull(); + let result = await runPromise; + expect(result.success).toBe(true); + + // Black places at 2,3, which will boop white's piece + promptPromise = waitForPrompt(ctx); + runPromise = ctx.run("turn black"); + promptEvent = await promptPromise; + error = promptEvent.tryCommit({ + name: "play", + params: ["black", 2, 3, "kitten"], + options: {}, + flags: {}, + }); + expect(error).toBeNull(); + result = await runPromise; + expect(result.success).toBe(true); + + const state = ctx.value; + // Check that pieces were placed + const boardPieceCount = Object.keys(state.regions.board.partMap).length; + expect(boardPieceCount).toBeGreaterThanOrEqual(1); }); - describe('Place and Boop Commands', () => { - it('should place a kitten via play command', async () => { - const { ctx } = createTestContext(); + it("should handle pieces being booped off the board", async () => { + const { ctx } = createTestContext(); - // Use turn command instead of setup which runs indefinitely - const promptPromise = waitForPrompt(ctx); - const runPromise = ctx.run('turn white'); + // White places at corner + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx.run("turn white"); + const promptEvent = await promptPromise; + const error = promptEvent.tryCommit({ + name: "play", + params: ["white", 0, 0, "kitten"], + options: {}, + flags: {}, + }); + expect(error).toBeNull(); + const result = await runPromise; + expect(result.success).toBe(true); - const promptEvent = await promptPromise; - expect(promptEvent).not.toBeNull(); - expect(promptEvent.schema.name).toBe('play'); + const state = ctx.value; + // Verify placement + expect(state.regions.board.partMap["0,0"]).toBeDefined(); + }); + }); - // Place a kitten at position 2,2 - const error = promptEvent.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} }); - expect(error).toBeNull(); + describe("Full Game Flow", () => { + it("should play a turn and switch players", async () => { + const { ctx } = createTestContext(); - const result = await runPromise; - expect(result.success).toBe(true); - - const state = ctx.value; - // Should have placed a piece on the board - const boardPieces = Object.keys(state.regions.board.partMap); - expect(boardPieces.length).toBeGreaterThan(0); + // White's turn - place at 2,2 + let promptPromise = waitForPrompt(ctx); + let runPromise = ctx.run("turn white"); + let prompt = await promptPromise; + const error1 = prompt.tryCommit({ + name: "play", + params: ["white", 2, 2, "kitten"], + options: {}, + flags: {}, + }); + expect(error1).toBeNull(); + let result = await runPromise; + expect(result.success).toBe(true); - // Should have one less kitten in supply - const whiteSupply = state.regions.white.childIds.filter(id => state.pieces[id].type === 'kitten'); - expect(whiteSupply.length).toBe(7); - }); + const stateAfterWhite = ctx.value; + // Should have placed a piece + expect(stateAfterWhite.regions.board.partMap["2,2"]).toBeDefined(); + }); + }); + + describe("Kitten vs Cat Hierarchy", () => { + it("should not boop cats when placing a kitten", async () => { + const { ctx } = createTestContext(); + + // White places a kitten at 2,2 + let promptPromise = waitForPrompt(ctx); + let runPromise = ctx.run("turn white"); + let prompt = await promptPromise; + let error = prompt.tryCommit({ + name: "play", + params: ["white", 2, 2, "kitten"], + options: {}, + flags: {}, + }); + expect(error).toBeNull(); + let result = await runPromise; + expect(result.success).toBe(true); + + // Manually move white's kitten to box and replace with a cat (for testing) + ctx.produce((state) => { + const whiteKitten = state.pieces["white-kitten-1"]; + if (whiteKitten && whiteKitten.regionId === "board") { + whiteKitten.type = "cat"; + } + }); + + // Black places a kitten at 2,3 (adjacent to the cat) + promptPromise = waitForPrompt(ctx); + runPromise = ctx.run("turn black"); + prompt = await promptPromise; + error = prompt.tryCommit({ + name: "play", + params: ["black", 2, 3, "kitten"], + options: {}, + flags: {}, + }); + expect(error).toBeNull(); + result = await runPromise; + expect(result.success).toBe(true); + + const state = ctx.value; + // White's cat should still be at 2,2 (not booped) + expect(state.regions.board.partMap["2,2"]).toBe("white-kitten-1"); + // Black's kitten should be at 2,3 + expect(state.regions.board.partMap["2,3"]).toBe("black-kitten-1"); }); - describe('Boop Mechanics', () => { - it('should boop adjacent pieces away from placement', async () => { - const { ctx } = createTestContext(); + it("should boop both kittens and cats when placing a cat", async () => { + const { ctx } = createTestContext(); - // White places at 2,2 - let promptPromise = waitForPrompt(ctx); - let runPromise = ctx.run('turn white'); - let promptEvent = await promptPromise; - let error = promptEvent.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} }); - expect(error).toBeNull(); - let result = await runPromise; - expect(result.success).toBe(true); + // Manually set up: white cat at 2,3, black cat at 3,2 + // First move cats to white and black supplies + ctx.produce((state) => { + const whiteCat = state.pieces["white-cat-1"]; + const blackCat = state.pieces["black-cat-1"]; + if (whiteCat && whiteCat.regionId === "") { + whiteCat.regionId = "white"; + state.regions.white.childIds.push(whiteCat.id); + } + if (blackCat && blackCat.regionId === "") { + blackCat.regionId = "black"; + state.regions.black.childIds.push(blackCat.id); + } + }); - // Black places at 2,3, which will boop white's piece - promptPromise = waitForPrompt(ctx); - runPromise = ctx.run('turn black'); - promptEvent = await promptPromise; - error = promptEvent.tryCommit({ name: 'play', params: ['black', 2, 3, 'kitten'], options: {}, flags: {} }); - expect(error).toBeNull(); - result = await runPromise; - expect(result.success).toBe(true); + // Now move them to the board + ctx.produce((state) => { + const whiteCat = state.pieces["white-cat-1"]; + const blackCat = state.pieces["black-cat-1"]; + if (whiteCat && whiteCat.regionId === "white") { + whiteCat.regionId = "board"; + whiteCat.position = [2, 3]; + state.regions.board.partMap["2,3"] = whiteCat.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== whiteCat.id, + ); + } + if (blackCat && blackCat.regionId === "black") { + blackCat.regionId = "board"; + blackCat.position = [3, 2]; + state.regions.board.partMap["3,2"] = blackCat.id; + state.regions.black.childIds = state.regions.black.childIds.filter( + (id) => id !== blackCat.id, + ); + } + }); - const state = ctx.value; - // Check that pieces were placed - const boardPieceCount = Object.keys(state.regions.board.partMap).length; - expect(boardPieceCount).toBeGreaterThanOrEqual(1); - }); + // Give white another cat for placement + ctx.produce((state) => { + const whiteCat2 = state.pieces["white-cat-2"]; + if (whiteCat2 && whiteCat2.regionId === "") { + whiteCat2.regionId = "white"; + state.regions.white.childIds.push(whiteCat2.id); + } + }); - it('should handle pieces being booped off the board', async () => { - const { ctx } = createTestContext(); + // White places a cat at 2,2 (should boop black's cat at 3,2 to 4,2) + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx.run("turn white"); + const prompt = await promptPromise; + const error = prompt.tryCommit({ + name: "play", + params: ["white", 2, 2, "cat"], + options: {}, + flags: {}, + }); + expect(error).toBeNull(); + const result = await runPromise; + expect(result.success).toBe(true); - // White places at corner - const promptPromise = waitForPrompt(ctx); - const runPromise = ctx.run('turn white'); - const promptEvent = await promptPromise; - const error = promptEvent.tryCommit({ name: 'play', params: ['white', 0, 0, 'kitten'], options: {}, flags: {} }); - expect(error).toBeNull(); - const result = await runPromise; - expect(result.success).toBe(true); + const state = ctx.value; + // Black's cat should have been booped to 4,2 + expect(state.regions.board.partMap["4,2"]).toBeDefined(); + const pieceAt42 = state.pieces[state.regions.board.partMap["4,2"]]; + expect(pieceAt42?.player).toBe("black"); + expect(pieceAt42?.type).toBe("cat"); + }); + }); - const state = ctx.value; - // Verify placement - expect(state.regions.board.partMap['0,0']).toBeDefined(); - }); + describe("Boop Obstructions", () => { + it("should boop pieces to empty positions", async () => { + const { ctx } = createTestContext(); + + // White places at 2,2 + let promptPromise = waitForPrompt(ctx); + let runPromise = ctx.run("turn white"); + let prompt = await promptPromise; + let error = prompt.tryCommit({ + name: "play", + params: ["white", 2, 2, "kitten"], + options: {}, + flags: {}, + }); + expect(error).toBeNull(); + let result = await runPromise; + expect(result.success).toBe(true); + + // Check board has 1 piece after first placement + let state = ctx.value; + expect(Object.keys(state.regions.board.partMap).length).toBe(1); + + // Black places at 3,3 + promptPromise = waitForPrompt(ctx); + runPromise = ctx.run("turn black"); + prompt = await promptPromise; + error = prompt.tryCommit({ + name: "play", + params: ["black", 3, 3, "kitten"], + options: {}, + flags: {}, + }); + expect(error).toBeNull(); + result = await runPromise; + expect(result.success).toBe(true); + + state = ctx.value; + expect(Object.keys(state.regions.board.partMap).length).toBe(2); + + // Verify the pieces are on the board (positions may vary due to boop) + const boardPieces = Object.entries(state.regions.board.partMap); + expect(boardPieces.length).toBe(2); + + // Find black's piece + const blackPiece = boardPieces.find( + ([pos, id]) => state.pieces[id]?.player === "black", + ); + expect(blackPiece).toBeDefined(); }); - describe('Full Game Flow', () => { - it('should play a turn and switch players', async () => { - const { ctx } = createTestContext(); + it("should keep both pieces in place when boop is blocked", async () => { + const { ctx } = createTestContext(); - // White's turn - place at 2,2 - let promptPromise = waitForPrompt(ctx); - let runPromise = ctx.run('turn white'); - let prompt = await promptPromise; - const error1 = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} }); - expect(error1).toBeNull(); - let result = await runPromise; - expect(result.success).toBe(true); + // Setup: place white at 2,2 and 4,4, black at 3,3 + await ctx._commands.run("place 2 2 white kitten"); + await ctx._commands.run("place 3 3 black kitten"); + await ctx._commands.run("place 4 4 white kitten"); - const stateAfterWhite = ctx.value; - // Should have placed a piece - expect(stateAfterWhite.regions.board.partMap['2,2']).toBeDefined(); - }); + const stateBefore = ctx.value; + // Verify setup - 3 pieces on board + const boardPiecesBefore = Object.keys(stateBefore.regions.board.partMap); + expect(boardPiecesBefore.length).toBe(3); + expect(stateBefore.regions.board.partMap["2,2"]).toBeDefined(); + expect(stateBefore.regions.board.partMap["3,3"]).toBeDefined(); + expect(stateBefore.regions.board.partMap["4,4"]).toBeDefined(); + + // Black places at 2,3 - should try to boop piece at 3,3 to 4,4 + // but 4,4 is occupied, so both should stay + await ctx._commands.run("place 2 3 black kitten"); + + const state = ctx.value; + // Should now have 4 pieces on board + const boardPiecesAfter = Object.keys(state.regions.board.partMap); + expect(boardPiecesAfter.length).toBe(4); + // 3,3 should still have the same piece (not booped) + expect(state.regions.board.partMap["3,3"]).toBeDefined(); + // 4,4 should still be occupied + expect(state.regions.board.partMap["4,4"]).toBeDefined(); + // 2,3 should have black's new piece + expect(state.regions.board.partMap["2,3"]).toBeDefined(); + }); + }); + + describe("Graduation Mechanic", () => { + it("should graduate three kittens in a row to cats", async () => { + const { ctx } = createTestContext(); + + // Manually place three white kittens in a row + ctx.produce((state) => { + const k1 = state.pieces["white-kitten-1"]; + const k2 = state.pieces["white-kitten-2"]; + const k3 = state.pieces["white-kitten-3"]; + + if (k1) { + k1.regionId = "board"; + k1.position = [0, 0]; + state.regions.board.partMap["0,0"] = k1.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== k1.id, + ); + } + if (k2) { + k2.regionId = "board"; + k2.position = [0, 1]; + state.regions.board.partMap["0,1"] = k2.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== k2.id, + ); + } + if (k3) { + k3.regionId = "board"; + k3.position = [0, 2]; + state.regions.board.partMap["0,2"] = k3.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== k3.id, + ); + } + }); + + const stateBefore = ctx.value; + // Verify three kittens on board + expect(stateBefore.regions.board.partMap["0,0"]).toBeDefined(); + expect(stateBefore.regions.board.partMap["0,1"]).toBeDefined(); + expect(stateBefore.regions.board.partMap["0,2"]).toBeDefined(); + + // Count cats in white supply before graduation + const catsInWhiteSupplyBefore = stateBefore.regions.white.childIds.filter( + (id) => stateBefore.pieces[id].type === "cat", + ); + expect(catsInWhiteSupplyBefore.length).toBe(0); + + // Run check-graduates command + const result = await ctx._commands.run("check-graduates"); + expect(result.success).toBe(true); + + const state = ctx.value; + // The three positions on board should now be empty (kittens removed) + expect(state.regions.board.partMap["0,0"]).toBeUndefined(); + expect(state.regions.board.partMap["0,1"]).toBeUndefined(); + expect(state.regions.board.partMap["0,2"]).toBeUndefined(); + + // White's supply should now have 3 cats (graduated) + const catsInWhiteSupply = state.regions.white.childIds.filter( + (id) => state.pieces[id].type === "cat", + ); + expect(catsInWhiteSupply.length).toBe(3); + + // White's supply should have 5 kittens left (8 - 3 graduated) + const kittensInWhiteSupply = state.regions.white.childIds.filter( + (id) => state.pieces[id].type === "kitten", + ); + expect(kittensInWhiteSupply.length).toBe(5); + }); + }); + + describe("Win Detection", () => { + it("should detect horizontal win with three cats", async () => { + const { ctx } = createTestContext(); + + // Manually set up a winning scenario for white + ctx.produce((state) => { + const k1 = state.pieces["white-kitten-1"]; + const k2 = state.pieces["white-kitten-2"]; + const k3 = state.pieces["white-kitten-3"]; + + if (k1) { + k1.type = "cat"; + k1.regionId = "board"; + k1.position = [0, 0]; + state.regions.board.partMap["0,0"] = k1.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== k1.id, + ); + } + if (k2) { + k2.type = "cat"; + k2.regionId = "board"; + k2.position = [0, 1]; + state.regions.board.partMap["0,1"] = k2.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== k2.id, + ); + } + if (k3) { + k3.type = "cat"; + k3.regionId = "board"; + k3.position = [0, 2]; + state.regions.board.partMap["0,2"] = k3.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== k3.id, + ); + } + }); + + // Run check-win command + const result = await ctx._commands.run("check-win"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe("white"); + } }); - describe('Kitten vs Cat Hierarchy', () => { - it('should not boop cats when placing a kitten', async () => { - const { ctx } = createTestContext(); + it("should detect vertical win with three cats", async () => { + const { ctx } = createTestContext(); - // White places a kitten at 2,2 - let promptPromise = waitForPrompt(ctx); - let runPromise = ctx.run('turn white'); - let prompt = await promptPromise; - let error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} }); - expect(error).toBeNull(); - let result = await runPromise; - expect(result.success).toBe(true); + // Manually set up a vertical winning scenario for black + ctx.produce((state) => { + const k1 = state.pieces["black-kitten-1"]; + const k2 = state.pieces["black-kitten-2"]; + const k3 = state.pieces["black-kitten-3"]; - // Manually move white's kitten to box and replace with a cat (for testing) - ctx.produce(state => { - const whiteKitten = state.pieces['white-kitten-1']; - if (whiteKitten && whiteKitten.regionId === 'board') { - whiteKitten.type = 'cat'; - } - }); + if (k1) { + k1.type = "cat"; + k1.regionId = "board"; + k1.position = [0, 0]; + state.regions.board.partMap["0,0"] = k1.id; + state.regions.black.childIds = state.regions.black.childIds.filter( + (id) => id !== k1.id, + ); + } + if (k2) { + k2.type = "cat"; + k2.regionId = "board"; + k2.position = [1, 0]; + state.regions.board.partMap["1,0"] = k2.id; + state.regions.black.childIds = state.regions.black.childIds.filter( + (id) => id !== k2.id, + ); + } + if (k3) { + k3.type = "cat"; + k3.regionId = "board"; + k3.position = [2, 0]; + state.regions.board.partMap["2,0"] = k3.id; + state.regions.black.childIds = state.regions.black.childIds.filter( + (id) => id !== k3.id, + ); + } + }); - // Black places a kitten at 2,3 (adjacent to the cat) - promptPromise = waitForPrompt(ctx); - runPromise = ctx.run('turn black'); - prompt = await promptPromise; - error = prompt.tryCommit({ name: 'play', params: ['black', 2, 3, 'kitten'], options: {}, flags: {} }); - expect(error).toBeNull(); - result = await runPromise; - expect(result.success).toBe(true); - - const state = ctx.value; - // White's cat should still be at 2,2 (not booped) - expect(state.regions.board.partMap['2,2']).toBe('white-kitten-1'); - // Black's kitten should be at 2,3 - expect(state.regions.board.partMap['2,3']).toBe('black-kitten-1'); - }); - - it('should boop both kittens and cats when placing a cat', async () => { - const { ctx } = createTestContext(); - - // Manually set up: white cat at 2,3, black cat at 3,2 - // First move cats to white and black supplies - ctx.produce(state => { - const whiteCat = state.pieces['white-cat-1']; - const blackCat = state.pieces['black-cat-1']; - if (whiteCat && whiteCat.regionId === '') { - whiteCat.regionId = 'white'; - state.regions.white.childIds.push(whiteCat.id); - } - if (blackCat && blackCat.regionId === '') { - blackCat.regionId = 'black'; - state.regions.black.childIds.push(blackCat.id); - } - }); - - // Now move them to the board - ctx.produce(state => { - const whiteCat = state.pieces['white-cat-1']; - const blackCat = state.pieces['black-cat-1']; - if (whiteCat && whiteCat.regionId === 'white') { - whiteCat.regionId = 'board'; - whiteCat.position = [2, 3]; - state.regions.board.partMap['2,3'] = whiteCat.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== whiteCat.id); - } - if (blackCat && blackCat.regionId === 'black') { - blackCat.regionId = 'board'; - blackCat.position = [3, 2]; - state.regions.board.partMap['3,2'] = blackCat.id; - state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== blackCat.id); - } - }); - - // Give white another cat for placement - ctx.produce(state => { - const whiteCat2 = state.pieces['white-cat-2']; - if (whiteCat2 && whiteCat2.regionId === '') { - whiteCat2.regionId = 'white'; - state.regions.white.childIds.push(whiteCat2.id); - } - }); - - // White places a cat at 2,2 (should boop black's cat at 3,2 to 4,2) - const promptPromise = waitForPrompt(ctx); - const runPromise = ctx.run('turn white'); - const prompt = await promptPromise; - const error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'cat'], options: {}, flags: {} }); - expect(error).toBeNull(); - const result = await runPromise; - expect(result.success).toBe(true); - - const state = ctx.value; - // Black's cat should have been booped to 4,2 - expect(state.regions.board.partMap['4,2']).toBeDefined(); - const pieceAt42 = state.pieces[state.regions.board.partMap['4,2']]; - expect(pieceAt42?.player).toBe('black'); - expect(pieceAt42?.type).toBe('cat'); - }); + // Run check-win command + const result = await ctx._commands.run("check-win"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe("black"); + } }); - describe('Boop Obstructions', () => { - it('should boop pieces to empty positions', async () => { - const { ctx } = createTestContext(); + it("should detect diagonal win with three cats", async () => { + const { ctx } = createTestContext(); - // White places at 2,2 - let promptPromise = waitForPrompt(ctx); - let runPromise = ctx.run('turn white'); - let prompt = await promptPromise; - let error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} }); - expect(error).toBeNull(); - let result = await runPromise; - expect(result.success).toBe(true); + // Manually set up a diagonal winning scenario for white + ctx.produce((state) => { + const k1 = state.pieces["white-kitten-1"]; + const k2 = state.pieces["white-kitten-2"]; + const k3 = state.pieces["white-kitten-3"]; - // Check board has 1 piece after first placement - let state = ctx.value; - expect(Object.keys(state.regions.board.partMap).length).toBe(1); + if (k1) { + k1.type = "cat"; + k1.regionId = "board"; + k1.position = [0, 0]; + state.regions.board.partMap["0,0"] = k1.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== k1.id, + ); + } + if (k2) { + k2.type = "cat"; + k2.regionId = "board"; + k2.position = [1, 1]; + state.regions.board.partMap["1,1"] = k2.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== k2.id, + ); + } + if (k3) { + k3.type = "cat"; + k3.regionId = "board"; + k3.position = [2, 2]; + state.regions.board.partMap["2,2"] = k3.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== k3.id, + ); + } + }); - // Black places at 3,3 - promptPromise = waitForPrompt(ctx); - runPromise = ctx.run('turn black'); - prompt = await promptPromise; - error = prompt.tryCommit({ name: 'play', params: ['black', 3, 3, 'kitten'], options: {}, flags: {} }); - expect(error).toBeNull(); - result = await runPromise; - expect(result.success).toBe(true); + // Run check-win command + const result = await ctx._commands.run("check-win"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe("white"); + } + }); + }); - state = ctx.value; - expect(Object.keys(state.regions.board.partMap).length).toBe(2); + describe("Placing Cats", () => { + it("should allow placing a cat from supply", async () => { + const { ctx } = createTestContext(); - // Verify the pieces are on the board (positions may vary due to boop) - const boardPieces = Object.entries(state.regions.board.partMap); - expect(boardPieces.length).toBe(2); + // Manually give a cat to white's supply + ctx.produce((state) => { + const cat = state.pieces["white-cat-1"]; + if (cat && cat.regionId === "") { + cat.regionId = "white"; + state.regions.white.childIds.push(cat.id); + } + }); - // Find black's piece - const blackPiece = boardPieces.find(([pos, id]) => state.pieces[id]?.player === 'black'); - expect(blackPiece).toBeDefined(); - }); + // White places a cat at 2,2 + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx.run("turn white"); + const prompt = await promptPromise; + const error = prompt.tryCommit({ + name: "play", + params: ["white", 2, 2, "cat"], + options: {}, + flags: {}, + }); + expect(error).toBeNull(); + const result = await runPromise; + expect(result.success).toBe(true); - it('should keep both pieces in place when boop is blocked', async () => { - const { ctx } = createTestContext(); + const state = ctx.value; + // Cat should be on the board + expect(state.regions.board.partMap["2,2"]).toBe("white-cat-1"); + // Cat should no longer be in supply + const whiteCatsInSupply = state.regions.white.childIds.filter( + (id) => state.pieces[id].type === "cat", + ); + expect(whiteCatsInSupply.length).toBe(0); + }); + }); - // Setup: place white at 2,2 and 4,4, black at 3,3 - await ctx._commands.run('place 2 2 white kitten'); - await ctx._commands.run('place 3 3 black kitten'); - await ctx._commands.run('place 4 4 white kitten'); + describe("Check Full Board", () => { + it("should not trigger when player has fewer than 8 pieces on board", async () => { + const { ctx } = createTestContext(); - const stateBefore = ctx.value; - // Verify setup - 3 pieces on board - const boardPiecesBefore = Object.keys(stateBefore.regions.board.partMap); - expect(boardPiecesBefore.length).toBe(3); - expect(stateBefore.regions.board.partMap['2,2']).toBeDefined(); - expect(stateBefore.regions.board.partMap['3,3']).toBeDefined(); - expect(stateBefore.regions.board.partMap['4,4']).toBeDefined(); + // White places a single kitten + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx.run("turn white"); + const prompt = await promptPromise; + const error = prompt.tryCommit({ + name: "play", + params: ["white", 2, 2, "kitten"], + options: {}, + flags: {}, + }); + expect(error).toBeNull(); + const result = await runPromise; + expect(result.success).toBe(true); - // Black places at 2,3 - should try to boop piece at 3,3 to 4,4 - // but 4,4 is occupied, so both should stay - await ctx._commands.run('place 2 3 black kitten'); - - const state = ctx.value; - // Should now have 4 pieces on board - const boardPiecesAfter = Object.keys(state.regions.board.partMap); - expect(boardPiecesAfter.length).toBe(4); - // 3,3 should still have the same piece (not booped) - expect(state.regions.board.partMap['3,3']).toBeDefined(); - // 4,4 should still be occupied - expect(state.regions.board.partMap['4,4']).toBeDefined(); - // 2,3 should have black's new piece - expect(state.regions.board.partMap['2,3']).toBeDefined(); - }); + // check-full-board should return without prompting + const fullBoardResult = await ctx._commands.run("check-full-board white"); + expect(fullBoardResult.success).toBe(true); }); - describe('Graduation Mechanic', () => { - it('should graduate three kittens in a row to cats', async () => { - const { ctx } = createTestContext(); + it("should force graduation when all 8 pieces are on board", async () => { + const { ctx } = createTestContext(); - // Manually place three white kittens in a row - ctx.produce(state => { - const k1 = state.pieces['white-kitten-1']; - const k2 = state.pieces['white-kitten-2']; - const k3 = state.pieces['white-kitten-3']; - - if (k1) { - k1.regionId = 'board'; - k1.position = [0, 0]; - state.regions.board.partMap['0,0'] = k1.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k1.id); - } - if (k2) { - k2.regionId = 'board'; - k2.position = [0, 1]; - state.regions.board.partMap['0,1'] = k2.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k2.id); - } - if (k3) { - k3.regionId = 'board'; - k3.position = [0, 2]; - state.regions.board.partMap['0,2'] = k3.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k3.id); - } - }); - - const stateBefore = ctx.value; - // Verify three kittens on board - expect(stateBefore.regions.board.partMap['0,0']).toBeDefined(); - expect(stateBefore.regions.board.partMap['0,1']).toBeDefined(); - expect(stateBefore.regions.board.partMap['0,2']).toBeDefined(); - - // Count cats in white supply before graduation - const catsInWhiteSupplyBefore = stateBefore.regions.white.childIds.filter( - id => stateBefore.pieces[id].type === 'cat' + // Manually place all 8 white pieces on the board + ctx.produce((state) => { + for (let i = 1; i <= 8; i++) { + const piece = state.pieces[`white-kitten-${i}`]; + if (piece) { + const row = Math.floor((i - 1) / 4); + const col = (i - 1) % 4; + piece.regionId = "board"; + piece.position = [row, col]; + state.regions.board.partMap[`${row},${col}`] = piece.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== piece.id, ); - expect(catsInWhiteSupplyBefore.length).toBe(0); + } + } + }); - // Run check-graduates command - const result = await ctx._commands.run('check-graduates'); - expect(result.success).toBe(true); + // Verify 8 pieces on board + const stateBefore = ctx.value; + expect(Object.keys(stateBefore.regions.board.partMap).length).toBe(8); - const state = ctx.value; - // The three positions on board should now be empty (kittens removed) - expect(state.regions.board.partMap['0,0']).toBeUndefined(); - expect(state.regions.board.partMap['0,1']).toBeUndefined(); - expect(state.regions.board.partMap['0,2']).toBeUndefined(); + // Run check-full-board - should prompt for piece to graduate + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx._commands.run("check-full-board white"); + const prompt = await promptPromise; + expect(prompt.schema.name).toBe("choose"); - // White's supply should now have 3 cats (graduated) - const catsInWhiteSupply = state.regions.white.childIds.filter( - id => state.pieces[id].type === 'cat' + // Select a piece to graduate + const error = prompt.tryCommit({ + name: "choose", + params: ["white", 0, 0], + options: {}, + flags: {}, + }); + expect(error).toBeNull(); + + const result = await runPromise; + expect(result.success).toBe(true); + + const state = ctx.value; + // Position 0,0 should be empty (piece moved to box) + expect(state.regions.board.partMap["0,0"]).toBeUndefined(); + }); + + it("should not trigger when player has a winner", async () => { + const { ctx } = createTestContext(); + + // Set up a winning state for white + ctx.produce((state) => { + state.winner = "white"; + for (let i = 1; i <= 8; i++) { + const piece = state.pieces[`white-kitten-${i}`]; + if (piece) { + const row = Math.floor((i - 1) / 4); + const col = (i - 1) % 4; + piece.regionId = "board"; + piece.position = [row, col]; + state.regions.board.partMap[`${row},${col}`] = piece.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== piece.id, ); - expect(catsInWhiteSupply.length).toBe(3); + } + } + }); - // White's supply should have 5 kittens left (8 - 3 graduated) - const kittensInWhiteSupply = state.regions.white.childIds.filter( - id => state.pieces[id].type === 'kitten' - ); - expect(kittensInWhiteSupply.length).toBe(5); - }); + // check-full-board should return without prompting + const fullBoardResult = await ctx._commands.run("check-full-board white"); + expect(fullBoardResult.success).toBe(true); }); + }); - describe('Win Detection', () => { - it('should detect horizontal win with three cats', async () => { - const { ctx } = createTestContext(); + describe("Start Command", () => { + it("should run a complete game until there is a winner", async () => { + const { ctx } = createTestContext(); - // Manually set up a winning scenario for white - ctx.produce(state => { - const k1 = state.pieces['white-kitten-1']; - const k2 = state.pieces['white-kitten-2']; - const k3 = state.pieces['white-kitten-3']; + // Set up a quick win scenario + ctx.produce((state) => { + // Place three white cats in a row + const c1 = state.pieces["white-cat-1"]; + const c2 = state.pieces["white-cat-2"]; + const c3 = state.pieces["white-cat-3"]; - if (k1) { - k1.type = 'cat'; - k1.regionId = 'board'; - k1.position = [0, 0]; - state.regions.board.partMap['0,0'] = k1.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k1.id); - } - if (k2) { - k2.type = 'cat'; - k2.regionId = 'board'; - k2.position = [0, 1]; - state.regions.board.partMap['0,1'] = k2.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k2.id); - } - if (k3) { - k3.type = 'cat'; - k3.regionId = 'board'; - k3.position = [0, 2]; - state.regions.board.partMap['0,2'] = k3.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k3.id); - } - }); + if (c1) { + c1.type = "cat"; + c1.regionId = "board"; + c1.position = [0, 0]; + state.regions.board.partMap["0,0"] = c1.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== c1.id, + ); + } + if (c2) { + c2.type = "cat"; + c2.regionId = "board"; + c2.position = [0, 1]; + state.regions.board.partMap["0,1"] = c2.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== c2.id, + ); + } + if (c3) { + c3.type = "cat"; + c3.regionId = "board"; + c3.position = [0, 2]; + state.regions.board.partMap["0,2"] = c3.id; + state.regions.white.childIds = state.regions.white.childIds.filter( + (id) => id !== c3.id, + ); + } + }); - // Run check-win command - const result = await ctx._commands.run('check-win'); - expect(result.success).toBe(true); - if (result.success) { - expect(result.result).toBe('white'); - } - }); + // start() should detect the win and return quickly + // Note: start() runs indefinitely until there's a winner + // Since we already set up a win, it should complete after one turn + const promptPromise = waitForPrompt(ctx); + const startPromise = ctx.run("turn white"); - it('should detect vertical win with three cats', async () => { - const { ctx } = createTestContext(); + const prompt = await promptPromise; + // Complete the turn + const error = prompt.tryCommit({ + name: "play", + params: ["white", 3, 3, "kitten"], + options: {}, + flags: {}, + }); + expect(error).toBeNull(); - // Manually set up a vertical winning scenario for black - ctx.produce(state => { - const k1 = state.pieces['black-kitten-1']; - const k2 = state.pieces['black-kitten-2']; - const k3 = state.pieces['black-kitten-3']; + const result = await startPromise; + expect(result.success).toBe(true); - if (k1) { - k1.type = 'cat'; - k1.regionId = 'board'; - k1.position = [0, 0]; - state.regions.board.partMap['0,0'] = k1.id; - state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== k1.id); - } - if (k2) { - k2.type = 'cat'; - k2.regionId = 'board'; - k2.position = [1, 0]; - state.regions.board.partMap['1,0'] = k2.id; - state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== k2.id); - } - if (k3) { - k3.type = 'cat'; - k3.regionId = 'board'; - k3.position = [2, 0]; - state.regions.board.partMap['2,0'] = k3.id; - state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== k3.id); - } - }); - - // Run check-win command - const result = await ctx._commands.run('check-win'); - expect(result.success).toBe(true); - if (result.success) { - expect(result.result).toBe('black'); - } - }); - - it('should detect diagonal win with three cats', async () => { - const { ctx } = createTestContext(); - - // Manually set up a diagonal winning scenario for white - ctx.produce(state => { - const k1 = state.pieces['white-kitten-1']; - const k2 = state.pieces['white-kitten-2']; - const k3 = state.pieces['white-kitten-3']; - - if (k1) { - k1.type = 'cat'; - k1.regionId = 'board'; - k1.position = [0, 0]; - state.regions.board.partMap['0,0'] = k1.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k1.id); - } - if (k2) { - k2.type = 'cat'; - k2.regionId = 'board'; - k2.position = [1, 1]; - state.regions.board.partMap['1,1'] = k2.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k2.id); - } - if (k3) { - k3.type = 'cat'; - k3.regionId = 'board'; - k3.position = [2, 2]; - state.regions.board.partMap['2,2'] = k3.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k3.id); - } - }); - - // Run check-win command - const result = await ctx._commands.run('check-win'); - expect(result.success).toBe(true); - if (result.success) { - expect(result.result).toBe('white'); - } - }); - }); - - describe('Placing Cats', () => { - it('should allow placing a cat from supply', async () => { - const { ctx } = createTestContext(); - - // Manually give a cat to white's supply - ctx.produce(state => { - const cat = state.pieces['white-cat-1']; - if (cat && cat.regionId === '') { - cat.regionId = 'white'; - state.regions.white.childIds.push(cat.id); - } - }); - - // White places a cat at 2,2 - const promptPromise = waitForPrompt(ctx); - const runPromise = ctx.run('turn white'); - const prompt = await promptPromise; - const error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'cat'], options: {}, flags: {} }); - expect(error).toBeNull(); - const result = await runPromise; - expect(result.success).toBe(true); - - const state = ctx.value; - // Cat should be on the board - expect(state.regions.board.partMap['2,2']).toBe('white-cat-1'); - // Cat should no longer be in supply - const whiteCatsInSupply = state.regions.white.childIds.filter(id => state.pieces[id].type === 'cat'); - expect(whiteCatsInSupply.length).toBe(0); - }); - }); - - describe('Check Full Board', () => { - it('should not trigger when player has fewer than 8 pieces on board', async () => { - const { ctx } = createTestContext(); - - // White places a single kitten - const promptPromise = waitForPrompt(ctx); - const runPromise = ctx.run('turn white'); - const prompt = await promptPromise; - const error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} }); - expect(error).toBeNull(); - const result = await runPromise; - expect(result.success).toBe(true); - - // check-full-board should return without prompting - const fullBoardResult = await ctx._commands.run('check-full-board white'); - expect(fullBoardResult.success).toBe(true); - }); - - it('should force graduation when all 8 pieces are on board', async () => { - const { ctx } = createTestContext(); - - // Manually place all 8 white pieces on the board - ctx.produce(state => { - for (let i = 1; i <= 8; i++) { - const piece = state.pieces[`white-kitten-${i}`]; - if (piece) { - const row = Math.floor((i - 1) / 4); - const col = (i - 1) % 4; - piece.regionId = 'board'; - piece.position = [row, col]; - state.regions.board.partMap[`${row},${col}`] = piece.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== piece.id); - } - } - }); - - // Verify 8 pieces on board - const stateBefore = ctx.value; - expect(Object.keys(stateBefore.regions.board.partMap).length).toBe(8); - - // Run check-full-board - should prompt for piece to graduate - const promptPromise = waitForPrompt(ctx); - const runPromise = ctx._commands.run('check-full-board white'); - const prompt = await promptPromise; - expect(prompt.schema.name).toBe('choose'); - - // Select a piece to graduate - const error = prompt.tryCommit({ name: 'choose', params: ['white', 0, 0], options: {}, flags: {} }); - expect(error).toBeNull(); - - const result = await runPromise; - expect(result.success).toBe(true); - - const state = ctx.value; - // Position 0,0 should be empty (piece moved to box) - expect(state.regions.board.partMap['0,0']).toBeUndefined(); - }); - - it('should not trigger when player has a winner', async () => { - const { ctx } = createTestContext(); - - // Set up a winning state for white - ctx.produce(state => { - state.winner = 'white'; - for (let i = 1; i <= 8; i++) { - const piece = state.pieces[`white-kitten-${i}`]; - if (piece) { - const row = Math.floor((i - 1) / 4); - const col = (i - 1) % 4; - piece.regionId = 'board'; - piece.position = [row, col]; - state.regions.board.partMap[`${row},${col}`] = piece.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== piece.id); - } - } - }); - - // check-full-board should return without prompting - const fullBoardResult = await ctx._commands.run('check-full-board white'); - expect(fullBoardResult.success).toBe(true); - }); - }); - - describe('Start Command', () => { - it('should run a complete game until there is a winner', async () => { - const { ctx } = createTestContext(); - - // Set up a quick win scenario - ctx.produce(state => { - // Place three white cats in a row - const c1 = state.pieces['white-cat-1']; - const c2 = state.pieces['white-cat-2']; - const c3 = state.pieces['white-cat-3']; - - if (c1) { - c1.type = 'cat'; - c1.regionId = 'board'; - c1.position = [0, 0]; - state.regions.board.partMap['0,0'] = c1.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== c1.id); - } - if (c2) { - c2.type = 'cat'; - c2.regionId = 'board'; - c2.position = [0, 1]; - state.regions.board.partMap['0,1'] = c2.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== c2.id); - } - if (c3) { - c3.type = 'cat'; - c3.regionId = 'board'; - c3.position = [0, 2]; - state.regions.board.partMap['0,2'] = c3.id; - state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== c3.id); - } - }); - - // start() should detect the win and return quickly - // Note: start() runs indefinitely until there's a winner - // Since we already set up a win, it should complete after one turn - const promptPromise = waitForPrompt(ctx); - const startPromise = ctx.run('turn white'); - - const prompt = await promptPromise; - // Complete the turn - const error = prompt.tryCommit({ name: 'play', params: ['white', 3, 3, 'kitten'], options: {}, flags: {} }); - expect(error).toBeNull(); - - const result = await startPromise; - expect(result.success).toBe(true); - - // Game should have detected white's win - const state = ctx.value; - // After turn, check-win would find white's existing win - }); + // Game should have detected white's win + const state = ctx.value; + // After turn, check-win would find white's existing win }); + }); });