feat: add boop sample

This commit is contained in:
hypercross 2026-04-02 16:53:17 +08:00
parent 06c801e6ae
commit ecb09c01a1
3 changed files with 963 additions and 0 deletions

333
src/samples/boop/index.ts Normal file
View File

@ -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<BoopPart>[],
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<typeof createInitialState>;
const registration = createGameCommandRegistry<BoopState>();
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 <player>', 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 <player> <row:number> <col:number>');
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<BoopState>) {
return host.value.board;
}
export function isCellOccupied(host: Entity<BoopState>, row: number, col: number): boolean {
const board = getBoardRegion(host);
return board.partsMap.value[`${row},${col}`] !== undefined;
}
export function getPartAt(host: Entity<BoopState>, row: number, col: number): Entity<BoopPart> | null {
const board = getBoardRegion(host);
return (board.partsMap.value[`${row},${col}`] as Entity<BoopPart> | undefined) || null;
}
export function placeKitten(host: Entity<BoopState>, 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<BoopState>, placedRow: number, placedCol: number, placedType: PieceType) {
const board = getBoardRegion(host);
const partsMap = board.partsMap.value;
const piecesToBoop: { part: Entity<BoopPart>; dr: number; dc: number }[] = [];
for (const key in partsMap) {
const part = partsMap[key] as Entity<BoopPart>;
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<BoopState>, part: Entity<BoopPart>) {
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<BoopState>, 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<BoopState>, player: PlayerType, lines: number[][][]) {
const allPositions = new Set<string>();
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<BoopPart>[] = [];
for (const key in partsMap) {
const part = partsMap[key] as Entity<BoopPart>;
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<BoopState>): 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;
}

66
src/samples/boop/rules.md Normal file
View File

@ -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:** 1520 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.

564
tests/samples/boop.test.ts Normal file
View File

@ -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<typeof createTestContext>['ctx']): Entity<BoopState> {
return ctx.state;
}
function waitForPrompt(ctx: ReturnType<typeof createTestContext>['ctx']): Promise<PromptEvent> {
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);
});
});