From ecb09c01a1b5336dabbb047a8aaaf290bb32bf42 Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 2 Apr 2026 16:53:17 +0800 Subject: [PATCH] feat: add boop sample --- src/samples/boop/index.ts | 333 ++++++++++++++++++++++ src/samples/boop/rules.md | 66 +++++ tests/samples/boop.test.ts | 564 +++++++++++++++++++++++++++++++++++++ 3 files changed, 963 insertions(+) create mode 100644 src/samples/boop/index.ts create mode 100644 src/samples/boop/rules.md create mode 100644 tests/samples/boop.test.ts diff --git a/src/samples/boop/index.ts b/src/samples/boop/index.ts new file mode 100644 index 0000000..bdfb0dd --- /dev/null +++ b/src/samples/boop/index.ts @@ -0,0 +1,333 @@ +import {createGameCommandRegistry, Part, Entity, entity, RegionEntity} from '@/index'; + +const BOARD_SIZE = 6; +const MAX_PIECES_PER_PLAYER = 8; +const WIN_LENGTH = 3; + +const DIRECTIONS = [ + [-1, -1], [-1, 0], [-1, 1], + [0, -1], [0, 1], + [1, -1], [1, 0], [1, 1], +]; + +export type PlayerType = 'white' | 'black'; +export type PieceType = 'kitten' | 'cat'; +export type WinnerType = PlayerType | 'draw' | null; + +type BoopPart = Part & { player: PlayerType; pieceType: PieceType }; + +export function createInitialState() { + return { + board: new RegionEntity('board', { + id: 'board', + axes: [ + { name: 'x', min: 0, max: BOARD_SIZE - 1 }, + { name: 'y', min: 0, max: BOARD_SIZE - 1 }, + ], + children: [], + }), + parts: [] as Entity[], + currentPlayer: 'white' as PlayerType, + winner: null as WinnerType, + whiteKittensInSupply: MAX_PIECES_PER_PLAYER, + blackKittensInSupply: MAX_PIECES_PER_PLAYER, + whiteCatsInSupply: 0, + blackCatsInSupply: 0, + }; +} +export type BoopState = ReturnType; +const registration = createGameCommandRegistry(); +export const registry = registration.registry; + +registration.add('setup', async function() { + const {context} = this; + while (true) { + const currentPlayer = context.value.currentPlayer; + const turnOutput = await this.run<{winner: WinnerType}>(`turn ${currentPlayer}`); + if (!turnOutput.success) throw new Error(turnOutput.error); + + context.produce(state => { + state.winner = turnOutput.result.winner; + if (!state.winner) { + state.currentPlayer = state.currentPlayer === 'white' ? 'black' : 'white'; + } + }); + if (context.value.winner) break; + } + + return context.value; +}); + +registration.add('turn ', async function(cmd) { + const [turnPlayer] = cmd.params as [PlayerType]; + const maxRetries = 50; + let retries = 0; + + while (retries < maxRetries) { + retries++; + const playCmd = await this.prompt('play '); + const [player, row, col] = playCmd.params as [PlayerType, number, number]; + + if (player !== turnPlayer) continue; + if (!isValidMove(row, col)) continue; + if (isCellOccupied(this.context, row, col)) continue; + + const state = this.context.value; + const kittensInSupply = player === 'white' ? state.whiteKittensInSupply : state.blackKittensInSupply; + if (kittensInSupply <= 0) continue; + + placeKitten(this.context, row, col, turnPlayer); + applyBoops(this.context, row, col, 'kitten'); + + const graduatedRows = checkGraduation(this.context, turnPlayer); + if (graduatedRows.length > 0) { + processGraduation(this.context, turnPlayer, graduatedRows); + } + + const winner = checkWinner(this.context); + if (winner) return { winner }; + + return { winner: null }; + } + + throw new Error('Too many invalid attempts'); +}); + +function isValidMove(row: number, col: number): boolean { + return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE; +} + +export function getBoardRegion(host: Entity) { + return host.value.board; +} + +export function isCellOccupied(host: Entity, row: number, col: number): boolean { + const board = getBoardRegion(host); + return board.partsMap.value[`${row},${col}`] !== undefined; +} + +export function getPartAt(host: Entity, row: number, col: number): Entity | null { + const board = getBoardRegion(host); + return (board.partsMap.value[`${row},${col}`] as Entity | undefined) || null; +} + +export function placeKitten(host: Entity, row: number, col: number, player: PlayerType) { + const board = getBoardRegion(host); + const moveNumber = host.value.parts.length + 1; + const piece: BoopPart = { + id: `piece-${player}-${moveNumber}`, + region: board, + position: [row, col], + player, + pieceType: 'kitten', + }; + host.produce(state => { + const e = entity(piece.id, piece); + state.parts.push(e); + if (player === 'white') state.whiteKittensInSupply--; + else state.blackKittensInSupply--; + board.produce(draft => { + draft.children.push(e); + }); + }); +} + +export function applyBoops(host: Entity, placedRow: number, placedCol: number, placedType: PieceType) { + const board = getBoardRegion(host); + const partsMap = board.partsMap.value; + + const piecesToBoop: { part: Entity; dr: number; dc: number }[] = []; + + for (const key in partsMap) { + const part = partsMap[key] as Entity; + const [r, c] = part.value.position; + if (r === placedRow && c === placedCol) continue; + + const dr = Math.sign(r - placedRow); + const dc = Math.sign(c - placedCol); + + if (Math.abs(r - placedRow) <= 1 && Math.abs(c - placedCol) <= 1) { + const booperIsKitten = placedType === 'kitten'; + const targetIsCat = part.value.pieceType === 'cat'; + + if (booperIsKitten && targetIsCat) continue; + + piecesToBoop.push({ part, dr, dc }); + } + } + + for (const { part, dr, dc } of piecesToBoop) { + const [r, c] = part.value.position; + const newRow = r + dr; + const newCol = c + dc; + + if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) { + removePieceFromBoard(host, part); + const player = part.value.player; + host.produce(state => { + if (player === 'white') state.whiteKittensInSupply++; + else state.blackKittensInSupply++; + }); + continue; + } + + if (isCellOccupied(host, newRow, newCol)) continue; + + part.produce(p => { + p.position = [newRow, newCol]; + }); + } +} + +export function removePieceFromBoard(host: Entity, part: Entity) { + const board = getBoardRegion(host); + host.produce(state => { + state.parts = state.parts.filter(p => p.id !== part.id); + board.produce(draft => { + draft.children = draft.children.filter(p => p.id !== part.id); + }); + }); +} + +export function checkGraduation(host: Entity, player: PlayerType): number[][][] { + const parts = host.value.parts.filter(p => p.value.player === player && p.value.pieceType === 'kitten'); + const positions = parts.map(p => p.value.position); + + const winningLines: number[][][] = []; + + for (let r = 0; r < BOARD_SIZE; r++) { + for (let c = 0; c <= BOARD_SIZE - WIN_LENGTH; c++) { + const line = []; + for (let i = 0; i < WIN_LENGTH; i++) { + line.push([r, c + i]); + } + if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) { + winningLines.push(line); + } + } + } + + for (let c = 0; c < BOARD_SIZE; c++) { + for (let r = 0; r <= BOARD_SIZE - WIN_LENGTH; r++) { + const line = []; + for (let i = 0; i < WIN_LENGTH; i++) { + line.push([r + i, c]); + } + if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) { + winningLines.push(line); + } + } + } + + for (let r = 0; r <= BOARD_SIZE - WIN_LENGTH; r++) { + for (let c = 0; c <= BOARD_SIZE - WIN_LENGTH; c++) { + const line = []; + for (let i = 0; i < WIN_LENGTH; i++) { + line.push([r + i, c + i]); + } + if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) { + winningLines.push(line); + } + } + } + + for (let r = WIN_LENGTH - 1; r < BOARD_SIZE; r++) { + for (let c = 0; c <= BOARD_SIZE - WIN_LENGTH; c++) { + const line = []; + for (let i = 0; i < WIN_LENGTH; i++) { + line.push([r - i, c + i]); + } + if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) { + winningLines.push(line); + } + } + } + + return winningLines; +} + +export function processGraduation(host: Entity, player: PlayerType, lines: number[][][]) { + const allPositions = new Set(); + for (const line of lines) { + for (const [r, c] of line) { + allPositions.add(`${r},${c}`); + } + } + + const board = getBoardRegion(host); + const partsMap = board.partsMap.value; + const partsToRemove: Entity[] = []; + + for (const key in partsMap) { + const part = partsMap[key] as Entity; + if (part.value.player === player && part.value.pieceType === 'kitten' && allPositions.has(`${part.value.position[0]},${part.value.position[1]}`)) { + partsToRemove.push(part); + } + } + + for (const part of partsToRemove) { + removePieceFromBoard(host, part); + } + + const count = partsToRemove.length; + host.produce(state => { + const catsInSupply = player === 'white' ? state.whiteCatsInSupply : state.blackCatsInSupply; + if (player === 'white') state.whiteCatsInSupply = catsInSupply + count; + else state.blackCatsInSupply = catsInSupply + count; + }); +} + +export function checkWinner(host: Entity): WinnerType { + for (const player of ['white', 'black'] as PlayerType[]) { + const parts = host.value.parts.filter(p => p.value.player === player && p.value.pieceType === 'cat'); + const positions = parts.map(p => p.value.position); + + if (hasWinningLine(positions)) return player; + } + + const totalParts = host.value.parts.length; + const whiteParts = host.value.parts.filter(p => p.value.player === 'white').length; + const blackParts = host.value.parts.filter(p => p.value.player === 'black').length; + + if (whiteParts >= MAX_PIECES_PER_PLAYER && blackParts >= MAX_PIECES_PER_PLAYER) { + return 'draw'; + } + + return null; +} + +export function hasWinningLine(positions: number[][]): boolean { + for (let r = 0; r < BOARD_SIZE; r++) { + for (let c = 0; c <= BOARD_SIZE - WIN_LENGTH; c++) { + const line = []; + for (let i = 0; i < WIN_LENGTH; i++) line.push([r, c + i]); + if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) return true; + } + } + + for (let c = 0; c < BOARD_SIZE; c++) { + for (let r = 0; r <= BOARD_SIZE - WIN_LENGTH; r++) { + const line = []; + for (let i = 0; i < WIN_LENGTH; i++) line.push([r + i, c]); + if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) return true; + } + } + + for (let r = 0; r <= BOARD_SIZE - WIN_LENGTH; r++) { + for (let c = 0; c <= BOARD_SIZE - WIN_LENGTH; c++) { + const line = []; + for (let i = 0; i < WIN_LENGTH; i++) line.push([r + i, c + i]); + if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) return true; + } + } + + for (let r = WIN_LENGTH - 1; r < BOARD_SIZE; r++) { + for (let c = 0; c <= BOARD_SIZE - WIN_LENGTH; c++) { + const line = []; + for (let i = 0; i < WIN_LENGTH; i++) line.push([r - i, c + i]); + if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) return true; + } + } + + return false; +} diff --git a/src/samples/boop/rules.md b/src/samples/boop/rules.md new file mode 100644 index 0000000..b95c8dd --- /dev/null +++ b/src/samples/boop/rules.md @@ -0,0 +1,66 @@ +# Boop + +## Game Overview + +**"boop."** is a deceptively cute, oh-so-snoozy strategy game. Players compete to place their cats on a quilted bed, pushing other pieces out of the way. + +- **Players:** 2 +- **Ages:** 10+ +- **Play Time:** 15–20 minutes + +## Components + +- 1 Quilted Fabric Board (the "Bed") — 6×6 grid +- 8 White Kittens and 8 White Cats +- 8 Black Kittens and 8 Black Cats + +## Objective + +Be the first player to line up **three Cats** in a row (horizontally, vertically, or diagonally) on the 6×6 grid. + +## Setup + +- Each player takes their 8 Kittens into their personal supply. +- Cats are kept off to the side until a player "graduates" their Kittens. +- The board starts empty. + +## How to Play + +On your turn, perform the following steps: + +### 1. Placing Pieces + +Place one Kitten from your supply onto any empty space on the bed. + +### 2. The "Boop" Mechanic + +Placing a piece causes a **"boop."** Every piece (yours or your opponent's) in the 8 spaces immediately surrounding the piece you just played is pushed one space away from the placed piece. + +- **Chain Reactions:** A "booped" piece does **not** cause another boop. Only the piece being *placed* triggers boops. +- **Obstructions:** If there is a piece behind the piece being booped (i.e., the space it would be pushed into is occupied), the boop does not happen — both pieces stay put. +- **Falling off the Bed:** If a piece is booped off the edge of the 6×6 grid, it is returned to its owner's supply. + +### 3. Kittens vs. Cats (The Hierarchy) + +- **Kittens** can boop other Kittens. +- **Kittens** **cannot** boop Cats. +- **Cats** can boop both Kittens and other Cats. + +## Graduation (Getting Cats) + +To win, you need Cats. You obtain Cats by lining up Kittens: + +1. **Three in a Row:** If you line up three of your Kittens in a row (horizontally, vertically, or diagonally), they "graduate." +2. **The Process:** Remove the three Kittens from the board and return them to the box. Replace them in your personal supply with three **Cats**. +3. **Multiple Rows:** If placing a piece creates multiple rows of three, you graduate all pieces involved in those rows. +4. **The 8-Piece Rule:** If a player has all 8 of their pieces on the board (a mix of Kittens and Cats) and no one has three-in-a-row, the player must graduate one of their Kittens on the board into a Cat to free up a piece. + +## How to Win + +A player wins immediately when they get **three Cats in a row** on the bed (horizontally, vertically, or diagonally). + +> **Note:** If you line up three Cats during a Kitten graduation move (e.g., three Cats are moved into a row because of a Kitten being placed), you also win. + +## Strategy Tips + +Because every move pushes other pieces away, players must think several steps ahead to "trap" their own pieces into a row while knocking their opponent's pieces off the board or out of alignment. diff --git a/tests/samples/boop.test.ts b/tests/samples/boop.test.ts new file mode 100644 index 0000000..c8e23e4 --- /dev/null +++ b/tests/samples/boop.test.ts @@ -0,0 +1,564 @@ +import { describe, it, expect } from 'vitest'; +import { + registry, + checkWinner, + isCellOccupied, + getPartAt, + placeKitten, + applyBoops, + checkGraduation, + processGraduation, + hasWinningLine, + removePieceFromBoard, + createInitialState, + BoopState, + WinnerType, + PlayerType, + getBoardRegion, +} from '@/samples/boop'; +import {Entity} from "@/utils/entity"; +import {createGameContext} from "@/"; +import type { PromptEvent } from '@/utils/command'; + +function createTestContext() { + const ctx = createGameContext(registry, createInitialState); + return { registry, ctx }; +} + +function getState(ctx: ReturnType['ctx']): Entity { + return ctx.state; +} + +function waitForPrompt(ctx: ReturnType['ctx']): Promise { + return new Promise(resolve => { + ctx.commands.on('prompt', resolve); + }); +} + +describe('Boop - helper functions', () => { + describe('isCellOccupied', () => { + it('should return false for empty cell', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + expect(isCellOccupied(state, 3, 3)).toBe(false); + }); + + it('should return true for occupied cell', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + placeKitten(state, 3, 3, 'white'); + + expect(isCellOccupied(state, 3, 3)).toBe(true); + }); + + it('should return false for different cell', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + placeKitten(state, 0, 0, 'white'); + + expect(isCellOccupied(state, 1, 1)).toBe(false); + }); + }); + + describe('getPartAt', () => { + it('should return null for empty cell', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + expect(getPartAt(state, 2, 2)).toBeNull(); + }); + + it('should return the part at occupied cell', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + placeKitten(state, 2, 2, 'black'); + + const part = getPartAt(state, 2, 2); + expect(part).not.toBeNull(); + if (part) { + expect(part.value.player).toBe('black'); + expect(part.value.pieceType).toBe('kitten'); + } + }); + }); + + describe('placeKitten', () => { + it('should add a kitten to the board', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + placeKitten(state, 2, 3, 'white'); + + expect(state.value.parts.length).toBe(1); + expect(state.value.parts[0].value.position).toEqual([2, 3]); + expect(state.value.parts[0].value.player).toBe('white'); + expect(state.value.parts[0].value.pieceType).toBe('kitten'); + }); + + it('should decrement the correct player kitten supply', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 0, 0, 'white'); + expect(state.value.whiteKittensInSupply).toBe(7); + expect(state.value.blackKittensInSupply).toBe(8); + + placeKitten(state, 0, 1, 'black'); + expect(state.value.whiteKittensInSupply).toBe(7); + expect(state.value.blackKittensInSupply).toBe(7); + }); + + it('should add piece to board region children', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + placeKitten(state, 1, 1, 'white'); + + const board = getBoardRegion(state); + expect(board.value.children.length).toBe(1); + }); + + it('should generate unique IDs for pieces', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + placeKitten(state, 0, 0, 'white'); + placeKitten(state, 0, 1, 'black'); + + const ids = state.value.parts.map(p => p.id); + expect(new Set(ids).size).toBe(2); + }); + }); + + describe('applyBoops', () => { + it('should boop adjacent kitten away from placed kitten', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 3, 3, 'black'); + placeKitten(state, 2, 2, 'white'); + + expect(state.value.parts[1].value.position).toEqual([2, 2]); + + applyBoops(state, 3, 3, 'kitten'); + + expect(state.value.parts[1].value.position).toEqual([1, 1]); + }); + + it('should not boop a cat when a kitten is placed', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 3, 3, 'black'); + const whitePart = state.value.parts[0]; + whitePart.produce(p => { + p.pieceType = 'cat'; + }); + + applyBoops(state, 3, 3, 'kitten'); + + expect(whitePart.value.position).toEqual([3, 3]); + }); + + it('should remove piece that is booped off the board', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 0, 0, 'white'); + placeKitten(state, 1, 1, 'black'); + + applyBoops(state, 1, 1, 'kitten'); + + expect(state.value.parts.length).toBe(1); + expect(state.value.parts[0].value.player).toBe('black'); + expect(state.value.whiteKittensInSupply).toBe(8); + }); + + it('should not boop piece if target cell is occupied', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 1, 1, 'white'); + placeKitten(state, 2, 1, 'black'); + placeKitten(state, 0, 1, 'black'); + + applyBoops(state, 0, 1, 'kitten'); + + const whitePart = state.value.parts.find(p => p.value.player === 'white'); + expect(whitePart).toBeDefined(); + if (whitePart) { + expect(whitePart.value.position).toEqual([1, 1]); + } + }); + + it('should boop multiple adjacent pieces', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 3, 3, 'white'); + placeKitten(state, 2, 2, 'black'); + placeKitten(state, 2, 3, 'black'); + + applyBoops(state, 3, 3, 'kitten'); + + expect(state.value.parts[1].value.position).toEqual([1, 1]); + expect(state.value.parts[2].value.position).toEqual([1, 3]); + }); + + it('should not boop the placed piece itself', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 3, 3, 'white'); + + applyBoops(state, 3, 3, 'kitten'); + + expect(state.value.parts[0].value.position).toEqual([3, 3]); + }); + }); + + describe('removePieceFromBoard', () => { + it('should remove piece from parts and board children', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + placeKitten(state, 2, 2, 'white'); + const part = state.value.parts[0]; + + removePieceFromBoard(state, part); + + expect(state.value.parts.length).toBe(0); + const board = getBoardRegion(state); + expect(board.value.children.length).toBe(0); + }); + }); + + describe('checkGraduation', () => { + it('should return empty array when no kittens in a row', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 0, 0, 'white'); + placeKitten(state, 2, 2, 'white'); + + const lines = checkGraduation(state, 'white'); + expect(lines.length).toBe(0); + }); + + it('should detect horizontal line of 3 kittens', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 1, 0, 'white'); + placeKitten(state, 1, 1, 'white'); + placeKitten(state, 1, 2, 'white'); + + const lines = checkGraduation(state, 'white'); + expect(lines.length).toBe(1); + expect(lines[0]).toEqual([[1, 0], [1, 1], [1, 2]]); + }); + + it('should detect vertical line of 3 kittens', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 0, 2, 'white'); + placeKitten(state, 1, 2, 'white'); + placeKitten(state, 2, 2, 'white'); + + const lines = checkGraduation(state, 'white'); + expect(lines.length).toBe(1); + expect(lines[0]).toEqual([[0, 2], [1, 2], [2, 2]]); + }); + + it('should detect diagonal line of 3 kittens', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 0, 0, 'white'); + placeKitten(state, 1, 1, 'white'); + placeKitten(state, 2, 2, 'white'); + + const lines = checkGraduation(state, 'white'); + expect(lines.length).toBe(1); + expect(lines[0]).toEqual([[0, 0], [1, 1], [2, 2]]); + }); + + it('should detect anti-diagonal line of 3 kittens', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 2, 0, 'white'); + placeKitten(state, 1, 1, 'white'); + placeKitten(state, 0, 2, 'white'); + + const lines = checkGraduation(state, 'white'); + expect(lines.length).toBe(1); + expect(lines[0]).toEqual([[2, 0], [1, 1], [0, 2]]); + }); + + it('should not detect line with mixed piece types', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 0, 0, 'white'); + placeKitten(state, 0, 1, 'white'); + placeKitten(state, 0, 2, 'white'); + + state.value.parts[1].produce(p => { + p.pieceType = 'cat'; + }); + + const lines = checkGraduation(state, 'white'); + expect(lines.length).toBe(0); + }); + }); + + describe('processGraduation', () => { + it('should convert kittens to cats and update supply', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 0, 0, 'white'); + placeKitten(state, 0, 1, 'white'); + placeKitten(state, 0, 2, 'white'); + + const lines = checkGraduation(state, 'white'); + expect(lines.length).toBe(1); + + processGraduation(state, 'white', lines); + + expect(state.value.parts.length).toBe(0); + expect(state.value.whiteCatsInSupply).toBe(3); + }); + + it('should only graduate pieces on the winning lines', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 0, 0, 'white'); + placeKitten(state, 0, 1, 'white'); + placeKitten(state, 0, 2, 'white'); + placeKitten(state, 3, 3, 'white'); + + const lines = checkGraduation(state, 'white'); + processGraduation(state, 'white', lines); + + expect(state.value.parts.length).toBe(1); + expect(state.value.parts[0].value.position).toEqual([3, 3]); + expect(state.value.whiteCatsInSupply).toBe(3); + }); + }); + + describe('hasWinningLine', () => { + it('should return false for no line', () => { + expect(hasWinningLine([[0, 0], [1, 1], [3, 3]])).toBe(false); + }); + + it('should return true for horizontal line', () => { + expect(hasWinningLine([[0, 0], [0, 1], [0, 2]])).toBe(true); + }); + + it('should return true for vertical line', () => { + expect(hasWinningLine([[0, 0], [1, 0], [2, 0]])).toBe(true); + }); + + it('should return true for diagonal line', () => { + expect(hasWinningLine([[0, 0], [1, 1], [2, 2]])).toBe(true); + }); + + it('should return true for anti-diagonal line', () => { + expect(hasWinningLine([[2, 0], [1, 1], [0, 2]])).toBe(true); + }); + }); + + describe('checkWinner', () => { + it('should return null for empty board', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + expect(checkWinner(state)).toBeNull(); + }); + + it('should return winner when player has 3 cats in a row', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 0, 0, 'white'); + placeKitten(state, 0, 1, 'white'); + placeKitten(state, 0, 2, 'white'); + + state.value.parts.forEach(p => { + p.produce(part => { + part.pieceType = 'cat'; + }); + }); + + expect(checkWinner(state)).toBe('white'); + }); + + it('should return draw when both players use all pieces', () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + for (let i = 0; i < 8; i++) { + placeKitten(state, i % 6, Math.floor(i / 6) + (i % 2), 'white'); + } + for (let i = 0; i < 8; i++) { + placeKitten(state, i % 6, Math.floor(i / 6) + 3 + (i % 2), 'black'); + } + + const result = checkWinner(state); + expect(result === 'draw' || result === null).toBe(true); + }); + }); +}); + +describe('Boop - game flow', () => { + it('should have setup and turn commands registered', () => { + const { registry: reg } = createTestContext(); + + expect(reg.has('setup')).toBe(true); + expect(reg.has('turn')).toBe(true); + }); + + it('should setup board when setup command runs', async () => { + const { ctx } = createTestContext(); + + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx.commands.run('setup'); + + const promptEvent = await promptPromise; + expect(promptEvent).not.toBeNull(); + expect(promptEvent.schema.name).toBe('play'); + + promptEvent.reject(new Error('test end')); + + const result = await runPromise; + expect(result.success).toBe(false); + }); + + it('should accept valid move via turn command', async () => { + const { ctx } = createTestContext(); + + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); + + const promptEvent = await promptPromise; + expect(promptEvent).not.toBeNull(); + expect(promptEvent.schema.name).toBe('play'); + + promptEvent.resolve({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} }); + + const result = await runPromise; + expect(result.success).toBe(true); + if (result.success) expect(result.result.winner).toBeNull(); + expect(ctx.state.value.parts.length).toBe(1); + expect(ctx.state.value.parts[0].value.position).toEqual([2, 2]); + }); + + it('should reject move for wrong player and re-prompt', async () => { + const { ctx } = createTestContext(); + + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); + + const promptEvent1 = await promptPromise; + promptEvent1.resolve({ name: 'play', params: ['black', 2, 2], options: {}, flags: {} }); + + const promptEvent2 = await waitForPrompt(ctx); + expect(promptEvent2).not.toBeNull(); + + promptEvent2.resolve({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} }); + + const result = await runPromise; + expect(result.success).toBe(true); + if (result.success) expect(result.result.winner).toBeNull(); + }); + + it('should reject move to occupied cell and re-prompt', async () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 2, 2, 'black'); + + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); + + const promptEvent1 = await promptPromise; + promptEvent1.resolve({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} }); + + const promptEvent2 = await waitForPrompt(ctx); + expect(promptEvent2).not.toBeNull(); + + promptEvent2.resolve({ name: 'play', params: ['white', 0, 0], options: {}, flags: {} }); + + const result = await runPromise; + expect(result.success).toBe(true); + if (result.success) expect(result.result.winner).toBeNull(); + }); + + it('should reject move when kitten supply is empty', async () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + state.produce(s => { + s.whiteKittensInSupply = 0; + }); + + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); + + const promptEvent1 = await promptPromise; + promptEvent1.resolve({ name: 'play', params: ['white', 0, 0], options: {}, flags: {} }); + + const promptEvent2 = await waitForPrompt(ctx); + expect(promptEvent2).not.toBeNull(); + + promptEvent2.reject(new Error('test end')); + + const result = await runPromise; + expect(result.success).toBe(false); + }); + + it('should boop adjacent pieces after placement', async () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + let promptPromise = waitForPrompt(ctx); + let runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); + let prompt = await promptPromise; + prompt.resolve({ name: 'play', params: ['white', 3, 3], options: {}, flags: {} }); + let result = await runPromise; + expect(result.success).toBe(true); + expect(state.value.parts.length).toBe(1); + + promptPromise = waitForPrompt(ctx); + runPromise = ctx.commands.run<{winner: WinnerType}>('turn black'); + prompt = await promptPromise; + prompt.resolve({ name: 'play', params: ['black', 2, 2], options: {}, flags: {} }); + result = await runPromise; + expect(result.success).toBe(true); + expect(state.value.parts.length).toBe(2); + + const whitePart = state.value.parts.find(p => p.value.player === 'white'); + expect(whitePart).toBeDefined(); + if (whitePart) { + expect(whitePart.value.position).not.toEqual([3, 3]); + } + }); + + it('should graduate kittens to cats and check for cat win', async () => { + const { ctx } = createTestContext(); + const state = getState(ctx); + + placeKitten(state, 1, 0, 'white'); + placeKitten(state, 1, 1, 'white'); + placeKitten(state, 1, 2, 'white'); + + const lines = checkGraduation(state, 'white'); + expect(lines.length).toBeGreaterThanOrEqual(1); + + processGraduation(state, 'white', lines); + + expect(state.value.parts.length).toBe(0); + expect(state.value.whiteCatsInSupply).toBe(3); + }); +});