Compare commits
No commits in common. "d4d428b577b7ef061e3dcb4e4ff87ae02aebafca" and "f4649e0dacbaa39937184c6bc5852092be645d07" have entirely different histories.
d4d428b577
...
f4649e0dac
|
|
@ -25,8 +25,7 @@ npx vitest run -t "should detect horizontal win for X"
|
||||||
|
|
||||||
### Imports
|
### Imports
|
||||||
- Use **double quotes** for local imports, **single quotes** for npm packages
|
- Use **double quotes** for local imports, **single quotes** for npm packages
|
||||||
- Use `@/**/*` for `./src/**/*` import alias
|
- No path aliases — use relative `../` and `./` paths
|
||||||
- Use `@/index` for code in `samples`
|
|
||||||
|
|
||||||
### Formatting
|
### Formatting
|
||||||
- **4-space indentation**
|
- **4-space indentation**
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import {entity, Entity} from "@/utils/entity";
|
import {entity, Entity} from "../utils/entity";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandRegistry,
|
CommandRegistry,
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
createCommandRunnerContext,
|
createCommandRunnerContext,
|
||||||
parseCommandSchema,
|
parseCommandSchema,
|
||||||
registerCommand
|
registerCommand
|
||||||
} from "@/utils/command";
|
} from "../utils/command";
|
||||||
|
|
||||||
export interface IGameContext<TState extends Record<string, unknown> = {} > {
|
export interface IGameContext<TState extends Record<string, unknown> = {} > {
|
||||||
state: Entity<TState>;
|
state: Entity<TState>;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import {Entity} from "@/utils/entity";
|
import {Entity} from "../utils/entity";
|
||||||
import {Region} from "./region";
|
import {Region} from "./region";
|
||||||
import {RNG} from "@/utils/rng";
|
import {RNG} from "../utils/rng";
|
||||||
|
|
||||||
export type Part = {
|
export type Part = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import {batch, computed, ReadonlySignal, SignalOptions} from "@preact/signals-core";
|
import {Entity} from "../utils/entity";
|
||||||
import {Entity} from "@/utils/entity";
|
|
||||||
import {Part} from "./part";
|
import {Part} from "./part";
|
||||||
import {RNG} from "@/utils/rng";
|
import {RNG} from "../utils/rng";
|
||||||
|
|
||||||
export type Region = {
|
export type Region = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -16,26 +15,8 @@ export type RegionAxis = {
|
||||||
align?: 'start' | 'end' | 'center';
|
align?: 'start' | 'end' | 'center';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RegionEntity extends Entity<Region> {
|
|
||||||
public readonly partsMap: ReadonlySignal<Record<string, Entity<Part>>>;
|
|
||||||
|
|
||||||
public constructor(id: string, t?: Region, options?: SignalOptions<Region>) {
|
|
||||||
super(id, t, options);
|
|
||||||
this.partsMap = computed(() => {
|
|
||||||
const result: Record<string, Entity<Part>> = {};
|
|
||||||
for (const child of this.value.children) {
|
|
||||||
const key = child.value.position.join(',');
|
|
||||||
result[key] = child;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyAlign(region: Entity<Region>) {
|
export function applyAlign(region: Entity<Region>) {
|
||||||
batch(() => {
|
|
||||||
region.produce(applyAlignCore);
|
region.produce(applyAlignCore);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyAlignCore(region: Region) {
|
function applyAlignCore(region: Region) {
|
||||||
|
|
@ -93,9 +74,7 @@ function applyAlignCore(region: Region) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shuffle(region: Entity<Region>, rng: RNG) {
|
export function shuffle(region: Entity<Region>, rng: RNG) {
|
||||||
batch(() => {
|
|
||||||
region.produce(region => shuffleCore(region, rng));
|
region.produce(region => shuffleCore(region, rng));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shuffleCore(region: Region, rng: RNG){
|
function shuffleCore(region: Region, rng: RNG){
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export type { Part } from './core/part';
|
||||||
export { flip, flipTo, roll } from './core/part';
|
export { flip, flipTo, roll } from './core/part';
|
||||||
|
|
||||||
export type { Region, RegionAxis } from './core/region';
|
export type { Region, RegionAxis } from './core/region';
|
||||||
export { applyAlign, shuffle, RegionEntity } from './core/region';
|
export { applyAlign, shuffle } from './core/region';
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
||||||
|
|
|
||||||
|
|
@ -1,321 +0,0 @@
|
||||||
import {createGameCommandRegistry, Part, Entity, entity, RegionEntity} from '@/index';
|
|
||||||
|
|
||||||
const BOARD_SIZE = 6;
|
|
||||||
const MAX_PIECES_PER_PLAYER = 8;
|
|
||||||
const WIN_LENGTH = 3;
|
|
||||||
|
|
||||||
export type PlayerType = 'white' | 'black';
|
|
||||||
export type PieceType = 'kitten' | 'cat';
|
|
||||||
export type WinnerType = PlayerType | 'draw' | null;
|
|
||||||
|
|
||||||
type BoopPart = Part & { player: PlayerType; pieceType: PieceType };
|
|
||||||
|
|
||||||
type PieceSupply = { supply: number; placed: number };
|
|
||||||
|
|
||||||
type PlayerSupply = {
|
|
||||||
kitten: PieceSupply;
|
|
||||||
cat: PieceSupply;
|
|
||||||
};
|
|
||||||
|
|
||||||
function createPlayerSupply(): PlayerSupply {
|
|
||||||
return {
|
|
||||||
kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 },
|
|
||||||
cat: { supply: 0, placed: 0 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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: [],
|
|
||||||
}),
|
|
||||||
currentPlayer: 'white' as PlayerType,
|
|
||||||
winner: null as WinnerType,
|
|
||||||
players: {
|
|
||||||
white: createPlayerSupply(),
|
|
||||||
black: createPlayerSupply(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
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> [type:string]');
|
|
||||||
const [player, row, col, type] = playCmd.params as [PlayerType, number, number, PieceType?];
|
|
||||||
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
|
||||||
|
|
||||||
if (player !== turnPlayer) continue;
|
|
||||||
if (!isValidMove(row, col)) continue;
|
|
||||||
if (isCellOccupied(this.context, row, col)) continue;
|
|
||||||
|
|
||||||
const supply = this.context.value.players[player][pieceType].supply;
|
|
||||||
if (supply <= 0) continue;
|
|
||||||
|
|
||||||
placePiece(this.context, row, col, turnPlayer, pieceType);
|
|
||||||
applyBoops(this.context, row, col, pieceType);
|
|
||||||
|
|
||||||
const graduatedLines = checkGraduation(this.context, turnPlayer);
|
|
||||||
if (graduatedLines.length > 0) {
|
|
||||||
processGraduation(this.context, turnPlayer, graduatedLines);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 placePiece(host: Entity<BoopState>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
|
|
||||||
const board = getBoardRegion(host);
|
|
||||||
const count = host.value.players[player][pieceType].placed + 1;
|
|
||||||
|
|
||||||
const piece: BoopPart = {
|
|
||||||
id: `${player}-${pieceType}-${count}`,
|
|
||||||
region: board,
|
|
||||||
position: [row, col],
|
|
||||||
player,
|
|
||||||
pieceType,
|
|
||||||
};
|
|
||||||
host.produce(s => {
|
|
||||||
const e = entity(piece.id, piece);
|
|
||||||
s.players[player][pieceType].supply--;
|
|
||||||
s.players[player][pieceType].placed++;
|
|
||||||
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) {
|
|
||||||
const pt = part.value.pieceType;
|
|
||||||
const pl = part.value.player;
|
|
||||||
removePieceFromBoard(host, part);
|
|
||||||
host.produce(state => {
|
|
||||||
state.players[pl][pt].supply++;
|
|
||||||
});
|
|
||||||
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);
|
|
||||||
board.produce(draft => {
|
|
||||||
draft.children = draft.children.filter(p => p.id !== part.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const DIRECTIONS: [number, number][] = [
|
|
||||||
[0, 1],
|
|
||||||
[1, 0],
|
|
||||||
[1, 1],
|
|
||||||
[1, -1],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function* linesThrough(r: number, c: number): Generator<number[][]> {
|
|
||||||
for (const [dr, dc] of DIRECTIONS) {
|
|
||||||
const minStart = -(WIN_LENGTH - 1);
|
|
||||||
for (let offset = minStart; offset <= 0; offset++) {
|
|
||||||
const startR = r + offset * dr;
|
|
||||||
const startC = c + offset * dc;
|
|
||||||
const endR = startR + (WIN_LENGTH - 1) * dr;
|
|
||||||
const endC = startC + (WIN_LENGTH - 1) * dc;
|
|
||||||
|
|
||||||
if (startR < 0 || startR >= BOARD_SIZE || startC < 0 || startC >= BOARD_SIZE) continue;
|
|
||||||
if (endR < 0 || endR >= BOARD_SIZE || endC < 0 || endC >= BOARD_SIZE) continue;
|
|
||||||
|
|
||||||
const line: number[][] = [];
|
|
||||||
for (let i = 0; i < WIN_LENGTH; i++) {
|
|
||||||
line.push([startR + i * dr, startC + i * dc]);
|
|
||||||
}
|
|
||||||
yield line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function* allLines(): Generator<number[][]> {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (let r = 0; r < BOARD_SIZE; r++) {
|
|
||||||
for (let c = 0; c < BOARD_SIZE; c++) {
|
|
||||||
for (const line of linesThrough(r, c)) {
|
|
||||||
const key = line.map(p => p.join(',')).join(';');
|
|
||||||
if (!seen.has(key)) {
|
|
||||||
seen.add(key);
|
|
||||||
yield line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasWinningLine(positions: number[][]): boolean {
|
|
||||||
const posSet = new Set(positions.map(p => `${p[0]},${p[1]}`));
|
|
||||||
for (const line of allLines()) {
|
|
||||||
if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkGraduation(host: Entity<BoopState>, player: PlayerType): number[][][] {
|
|
||||||
const board = getBoardRegion(host);
|
|
||||||
const partsMap = board.partsMap.value;
|
|
||||||
const posSet = new Set<string>();
|
|
||||||
|
|
||||||
for (const key in partsMap) {
|
|
||||||
const part = partsMap[key] as Entity<BoopPart>;
|
|
||||||
if (part.value.player === player && part.value.pieceType === 'kitten') {
|
|
||||||
posSet.add(`${part.value.position[0]},${part.value.position[1]}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const winningLines: number[][][] = [];
|
|
||||||
for (const line of allLines()) {
|
|
||||||
if (line.every(([lr, lc]) => posSet.has(`${lr},${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 => {
|
|
||||||
state.players[player].cat.supply += count;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkWinner(host: Entity<BoopState>): WinnerType {
|
|
||||||
const board = getBoardRegion(host);
|
|
||||||
const partsMap = board.partsMap.value;
|
|
||||||
|
|
||||||
for (const player of ['white', 'black'] as PlayerType[]) {
|
|
||||||
const positions: number[][] = [];
|
|
||||||
for (const key in partsMap) {
|
|
||||||
const part = partsMap[key] as Entity<BoopPart>;
|
|
||||||
if (part.value.player === player && part.value.pieceType === 'cat') {
|
|
||||||
positions.push(part.value.position);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasWinningLine(positions)) return player;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = host.value;
|
|
||||||
const whiteTotal = MAX_PIECES_PER_PLAYER - state.players.white.kitten.supply + state.players.white.cat.supply;
|
|
||||||
const blackTotal = MAX_PIECES_PER_PLAYER - state.players.black.kitten.supply + state.players.black.cat.supply;
|
|
||||||
|
|
||||||
if (whiteTotal >= MAX_PIECES_PER_PLAYER && blackTotal >= MAX_PIECES_PER_PLAYER) {
|
|
||||||
return 'draw';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import {createGameCommandRegistry, Part, Entity, entity, RegionEntity} from '@/index';
|
import {createGameCommandRegistry} from '../';
|
||||||
|
import type { Part } from '../';
|
||||||
|
import {Entity, entity} from "../";
|
||||||
|
import {Region} from "../";
|
||||||
|
|
||||||
const BOARD_SIZE = 3;
|
const BOARD_SIZE = 3;
|
||||||
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
||||||
|
|
@ -20,7 +23,7 @@ type TicTacToePart = Part & { player: PlayerType };
|
||||||
|
|
||||||
export function createInitialState() {
|
export function createInitialState() {
|
||||||
return {
|
return {
|
||||||
board: new RegionEntity('board', {
|
board: entity<Region>('board', {
|
||||||
id: 'board',
|
id: 'board',
|
||||||
axes: [
|
axes: [
|
||||||
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
||||||
|
|
@ -89,9 +92,15 @@ 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 getBoardRegion(host: Entity<TicTacToeState>) {
|
||||||
|
return host.value.board;
|
||||||
|
}
|
||||||
|
|
||||||
export function isCellOccupied(host: Entity<TicTacToeState>, row: number, col: number): boolean {
|
export function isCellOccupied(host: Entity<TicTacToeState>, row: number, col: number): boolean {
|
||||||
const board = host.value.board;
|
const board = getBoardRegion(host);
|
||||||
return board.partsMap.value[`${row},${col}`] !== undefined;
|
return board.value.children.some(
|
||||||
|
part => part.value.position[0] === row && part.value.position[1] === col
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasWinningLine(positions: number[][]): boolean {
|
export function hasWinningLine(positions: number[][]): boolean {
|
||||||
|
|
@ -116,7 +125,7 @@ export function checkWinner(host: Entity<TicTacToeState>): WinnerType {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function placePiece(host: Entity<TicTacToeState>, row: number, col: number, player: PlayerType) {
|
export function placePiece(host: Entity<TicTacToeState>, row: number, col: number, player: PlayerType) {
|
||||||
const board = host.value.board;
|
const board = getBoardRegion(host);
|
||||||
const moveNumber = host.value.parts.length + 1;
|
const moveNumber = host.value.parts.length + 1;
|
||||||
const piece: TicTacToePart = {
|
const piece: TicTacToePart = {
|
||||||
id: `piece-${player}-${moveNumber}`,
|
id: `piece-${player}-${moveNumber}`,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type {CommandResult, CommandRunner, CommandRunnerContext, PromptEvent} fr
|
||||||
import { parseCommand } from './command-parse';
|
import { parseCommand } from './command-parse';
|
||||||
import { applyCommandSchema } from './command-validate';
|
import { applyCommandSchema } from './command-validate';
|
||||||
import { parseCommandSchema } from './schema-parse';
|
import { parseCommandSchema } from './schema-parse';
|
||||||
import {AsyncQueue} from "@/utils/async-queue";
|
import {AsyncQueue} from "../async-queue";
|
||||||
|
|
||||||
export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>;
|
export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { createGameContext, createGameCommand, createGameCommandRegistry } from '@/core/game';
|
import { createGameContext, createGameCommand, createGameCommandRegistry, IGameContext } from '../../src/core/game';
|
||||||
import type { PromptEvent } from '@/utils/command';
|
import { Entity } from '../../src/utils/entity';
|
||||||
|
import type { PromptEvent } from '../../src/utils/command';
|
||||||
|
|
||||||
type MyState = {
|
type MyState = {
|
||||||
score: number;
|
score: number;
|
||||||
|
|
@ -76,7 +77,7 @@ describe('createGameContext', () => {
|
||||||
|
|
||||||
describe('createGameCommand', () => {
|
describe('createGameCommand', () => {
|
||||||
it('should run a command with access to game context', async () => {
|
it('should run a command with access to game context', async () => {
|
||||||
const { registry } = createGameCommandRegistry<{ marker: string }>();
|
const { registry } = createGameCommandRegistry<Entity<{ marker: string }>>();
|
||||||
const ctx = createGameContext(registry, { marker: '' });
|
const ctx = createGameContext(registry, { marker: '' });
|
||||||
|
|
||||||
createGameCommand(registry, 'set-marker <id>', async function (cmd) {
|
createGameCommand(registry, 'set-marker <id>', async function (cmd) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { applyAlign, shuffle, type Region, type RegionAxis } from '@/core/region';
|
import { applyAlign, shuffle, type Region, type RegionAxis } from '../../src/core/region';
|
||||||
import { createRNG } from '@/utils/rng';
|
import { createRNG } from '../../src/utils/rng';
|
||||||
import { entity, Entity } from '@/utils/entity';
|
import { entity, Entity } from '../../src/utils/entity';
|
||||||
import { type Part } from '@/core/part';
|
import { type Part } from '../../src/core/part';
|
||||||
|
|
||||||
describe('Region', () => {
|
describe('Region', () => {
|
||||||
function createTestRegion(axes: RegionAxis[], parts: Part[]): Entity<Region> {
|
function createTestRegion(axes: RegionAxis[], parts: Part[]): Entity<Region> {
|
||||||
|
|
|
||||||
|
|
@ -1,645 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import {
|
|
||||||
registry,
|
|
||||||
checkWinner,
|
|
||||||
isCellOccupied,
|
|
||||||
getPartAt,
|
|
||||||
placePiece,
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getParts(state: Entity<BoopState>) {
|
|
||||||
return state.value.board.value.children;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
placePiece(state, 3, 3, 'white', 'kitten');
|
|
||||||
|
|
||||||
expect(isCellOccupied(state, 3, 3)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for different cell', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
|
|
||||||
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);
|
|
||||||
placePiece(state, 2, 2, 'black', 'kitten');
|
|
||||||
|
|
||||||
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('placePiece', () => {
|
|
||||||
it('should add a kitten to the board', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
placePiece(state, 2, 3, 'white', 'kitten');
|
|
||||||
|
|
||||||
const parts = getParts(state);
|
|
||||||
expect(parts.length).toBe(1);
|
|
||||||
expect(parts[0].value.position).toEqual([2, 3]);
|
|
||||||
expect(parts[0].value.player).toBe('white');
|
|
||||||
expect(parts[0].value.pieceType).toBe('kitten');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should name piece white-kitten-1', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
|
|
||||||
expect(getParts(state)[0].id).toBe('white-kitten-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should name piece white-kitten-2 for second white kitten', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 1, 'white', 'kitten');
|
|
||||||
|
|
||||||
expect(getParts(state)[1].id).toBe('white-kitten-2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should name piece white-cat-1', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
placePiece(state, 0, 0, 'white', 'cat');
|
|
||||||
|
|
||||||
expect(getParts(state)[0].id).toBe('white-cat-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should decrement the correct player kitten supply', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
expect(state.value.players.white.kitten.supply).toBe(7);
|
|
||||||
expect(state.value.players.black.kitten.supply).toBe(8);
|
|
||||||
|
|
||||||
placePiece(state, 0, 1, 'black', 'kitten');
|
|
||||||
expect(state.value.players.white.kitten.supply).toBe(7);
|
|
||||||
expect(state.value.players.black.kitten.supply).toBe(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should decrement the correct player cat supply', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
state.produce(s => {
|
|
||||||
s.players.white.cat.supply = 3;
|
|
||||||
});
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'cat');
|
|
||||||
expect(state.value.players.white.cat.supply).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should add piece to board region children', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
placePiece(state, 1, 1, 'white', 'kitten');
|
|
||||||
|
|
||||||
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);
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 1, 'black', 'kitten');
|
|
||||||
|
|
||||||
const ids = getParts(state).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);
|
|
||||||
|
|
||||||
placePiece(state, 3, 3, 'black', 'kitten');
|
|
||||||
placePiece(state, 2, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
const whitePart = getParts(state)[1];
|
|
||||||
expect(whitePart.value.position).toEqual([2, 2]);
|
|
||||||
|
|
||||||
applyBoops(state, 3, 3, 'kitten');
|
|
||||||
|
|
||||||
expect(whitePart.value.position).toEqual([1, 1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not boop a cat when a kitten is placed', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 3, 3, 'black', 'kitten');
|
|
||||||
const whitePart = getParts(state)[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);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 1, 'black', 'kitten');
|
|
||||||
|
|
||||||
applyBoops(state, 1, 1, 'kitten');
|
|
||||||
|
|
||||||
expect(getParts(state).length).toBe(1);
|
|
||||||
expect(getParts(state)[0].value.player).toBe('black');
|
|
||||||
expect(state.value.players.white.kitten.supply).toBe(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not boop piece if target cell is occupied', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 1, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 2, 1, 'black', 'kitten');
|
|
||||||
placePiece(state, 0, 1, 'black', 'kitten');
|
|
||||||
|
|
||||||
applyBoops(state, 0, 1, 'kitten');
|
|
||||||
|
|
||||||
const whitePart = getParts(state).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);
|
|
||||||
|
|
||||||
placePiece(state, 3, 3, 'white', 'kitten');
|
|
||||||
placePiece(state, 2, 2, 'black', 'kitten');
|
|
||||||
placePiece(state, 2, 3, 'black', 'kitten');
|
|
||||||
|
|
||||||
applyBoops(state, 3, 3, 'kitten');
|
|
||||||
|
|
||||||
expect(getParts(state)[1].value.position).toEqual([1, 1]);
|
|
||||||
expect(getParts(state)[2].value.position).toEqual([1, 3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not boop the placed piece itself', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 3, 3, 'white', 'kitten');
|
|
||||||
|
|
||||||
applyBoops(state, 3, 3, 'kitten');
|
|
||||||
|
|
||||||
expect(getParts(state)[0].value.position).toEqual([3, 3]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('removePieceFromBoard', () => {
|
|
||||||
it('should remove piece from board children', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
placePiece(state, 2, 2, 'white', 'kitten');
|
|
||||||
const part = getParts(state)[0];
|
|
||||||
|
|
||||||
removePieceFromBoard(state, part);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 2, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
placePiece(state, 1, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
placePiece(state, 0, 2, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 2, 'white', 'kitten');
|
|
||||||
placePiece(state, 2, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 2, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
placePiece(state, 2, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
|
||||||
expect(lines.length).toBe(1);
|
|
||||||
expect(lines[0]).toEqual([[0, 2], [1, 1], [2, 0]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not detect line with mixed piece types', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
getParts(state)[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);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
|
||||||
expect(lines.length).toBe(1);
|
|
||||||
|
|
||||||
processGraduation(state, 'white', lines);
|
|
||||||
|
|
||||||
expect(getParts(state).length).toBe(0);
|
|
||||||
expect(state.value.players.white.cat.supply).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should only graduate pieces on the winning lines', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 0, 2, 'white', 'kitten');
|
|
||||||
placePiece(state, 3, 3, 'white', 'kitten');
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
|
||||||
processGraduation(state, 'white', lines);
|
|
||||||
|
|
||||||
expect(getParts(state).length).toBe(1);
|
|
||||||
expect(getParts(state)[0].value.position).toEqual([3, 3]);
|
|
||||||
expect(state.value.players.white.cat.supply).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);
|
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'cat');
|
|
||||||
placePiece(state, 0, 1, 'white', 'cat');
|
|
||||||
placePiece(state, 0, 2, 'white', '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++) {
|
|
||||||
placePiece(state, i % 6, Math.floor(i / 6) + (i % 2), 'white', 'kitten');
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
placePiece(state, i % 6, Math.floor(i / 6) + 3 + (i % 2), 'black', 'kitten');
|
|
||||||
}
|
|
||||||
|
|
||||||
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(getParts(ctx.state).length).toBe(1);
|
|
||||||
expect(getParts(ctx.state)[0].value.position).toEqual([2, 2]);
|
|
||||||
expect(getParts(ctx.state)[0].id).toBe('white-kitten-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
placePiece(state, 2, 2, 'black', 'kitten');
|
|
||||||
|
|
||||||
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.players.white.kitten.supply = 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(getParts(state).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(getParts(state).length).toBe(2);
|
|
||||||
|
|
||||||
const whitePart = getParts(state).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', () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
placePiece(state, 1, 0, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 1, 'white', 'kitten');
|
|
||||||
placePiece(state, 1, 2, 'white', 'kitten');
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
|
||||||
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
processGraduation(state, 'white', lines);
|
|
||||||
|
|
||||||
expect(getParts(state).length).toBe(0);
|
|
||||||
expect(state.value.players.white.cat.supply).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept placing a cat via play command', async () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
state.produce(s => {
|
|
||||||
s.players.white.cat.supply = 3;
|
|
||||||
});
|
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
|
||||||
|
|
||||||
const promptEvent = await promptPromise;
|
|
||||||
promptEvent.resolve({ name: 'play', params: ['white', 2, 2, 'cat'], options: {}, flags: {} });
|
|
||||||
|
|
||||||
const result = await runPromise;
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(getParts(state).length).toBe(1);
|
|
||||||
expect(getParts(state)[0].id).toBe('white-cat-1');
|
|
||||||
expect(getParts(state)[0].value.pieceType).toBe('cat');
|
|
||||||
expect(state.value.players.white.cat.supply).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject placing a cat when supply is empty', async () => {
|
|
||||||
const { ctx } = createTestContext();
|
|
||||||
const state = getState(ctx);
|
|
||||||
|
|
||||||
state.produce(s => {
|
|
||||||
s.players.white.cat.supply = 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, 'cat'], 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -7,10 +7,10 @@ import {
|
||||||
createInitialState,
|
createInitialState,
|
||||||
TicTacToeState,
|
TicTacToeState,
|
||||||
WinnerType, PlayerType
|
WinnerType, PlayerType
|
||||||
} from '@/samples/tic-tac-toe';
|
} from '../../src/samples/tic-tac-toe';
|
||||||
import {Entity} from "@/utils/entity";
|
import {Entity} from "../../src/utils/entity";
|
||||||
import {createGameContext} from "@/";
|
import {createGameContext} from "../../src";
|
||||||
import type { PromptEvent } from '@/utils/command';
|
import type { PromptEvent } from '../../src/utils/command';
|
||||||
|
|
||||||
function createTestContext() {
|
function createTestContext() {
|
||||||
const ctx = createGameContext(registry, createInitialState);
|
const ctx = createGameContext(registry, createInitialState);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { AsyncQueue } from '@/utils/async-queue';
|
import { AsyncQueue } from '../../src/utils/async-queue';
|
||||||
|
|
||||||
describe('AsyncQueue', () => {
|
describe('AsyncQueue', () => {
|
||||||
describe('push', () => {
|
describe('push', () => {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
validateCommand,
|
validateCommand,
|
||||||
parseCommand,
|
parseCommand,
|
||||||
type CommandSchema,
|
type CommandSchema,
|
||||||
} from '@/utils/command';
|
} from '../../src/utils/command';
|
||||||
|
|
||||||
describe('parseCommandSchema with inline-schema', () => {
|
describe('parseCommandSchema with inline-schema', () => {
|
||||||
it('should parse schema with typed params', () => {
|
it('should parse schema with typed params', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseCommandSchema } from '@/utils/command/schema-parse';
|
import { parseCommandSchema } from '../../src/utils/command/schema-parse';
|
||||||
import {
|
import {
|
||||||
createCommandRegistry,
|
createCommandRegistry,
|
||||||
registerCommand,
|
registerCommand,
|
||||||
|
|
@ -10,8 +10,8 @@ import {
|
||||||
createCommandRunnerContext,
|
createCommandRunnerContext,
|
||||||
type CommandRegistry,
|
type CommandRegistry,
|
||||||
type CommandRunnerContextExport,
|
type CommandRunnerContextExport,
|
||||||
} from '@/utils/command/command-registry';
|
} from '../../src/utils/command/command-registry';
|
||||||
import type { CommandRunner, PromptEvent } from '@/utils/command/command-runner';
|
import type { CommandRunner, PromptEvent } from '../../src/utils/command/command-runner';
|
||||||
|
|
||||||
type TestContext = {
|
type TestContext = {
|
||||||
counter: number;
|
counter: number;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseCommand, parseCommandSchema, validateCommand } from '@/utils/command';
|
import { parseCommand, parseCommandSchema, validateCommand } from '../../src/utils/command';
|
||||||
|
|
||||||
describe('parseCommandSchema', () => {
|
describe('parseCommandSchema', () => {
|
||||||
it('should parse empty schema', () => {
|
it('should parse empty schema', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseCommand, type Command } from '@/utils/command';
|
import { parseCommand, type Command } from '../../src/utils/command';
|
||||||
|
|
||||||
describe('parseCommand', () => {
|
describe('parseCommand', () => {
|
||||||
it('should parse empty string', () => {
|
it('should parse empty string', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { createEntityCollection, Entity, entity } from '@/utils/entity';
|
import { createEntityCollection, Entity, entity } from '../../src/utils/entity';
|
||||||
|
|
||||||
type TestEntity = {
|
type TestEntity = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { createRNG } from '@/utils/rng';
|
import { createRNG } from '../../src/utils/rng';
|
||||||
|
|
||||||
describe('createRNG', () => {
|
describe('createRNG', () => {
|
||||||
it('should create RNG with default seed', () => {
|
it('should create RNG with default seed', () => {
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,7 @@
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src"
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "tests"]
|
"exclude": ["node_modules", "dist", "tests"]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import { defineConfig } from 'tsup';
|
import { defineConfig } from 'tsup';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const srcDir = fileURLToPath(new URL('./src', import.meta.url));
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
entry: ['src/index.ts'],
|
entry: ['src/index.ts'],
|
||||||
|
|
@ -9,9 +6,4 @@ export default defineConfig({
|
||||||
dts: true,
|
dts: true,
|
||||||
clean: true,
|
clean: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
esbuildOptions(options) {
|
|
||||||
options.alias = {
|
|
||||||
'@': srcDir,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,4 @@ export default defineConfig({
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/**/*.test.ts'],
|
include: ['tests/**/*.test.ts'],
|
||||||
},
|
},
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@/': new URL('./src/', import.meta.url).pathname,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue