Compare commits

...

2 Commits

Author SHA1 Message Date
hyper 94e021974f refactor: choose a kitten to graduate 2026-04-02 19:56:34 +08:00
hyper 793c7d834b refactor: update boop implementation 2026-04-02 19:46:49 +08:00
3 changed files with 99 additions and 48 deletions

View File

@ -12,17 +12,20 @@ type BoopPart = Part & { player: PlayerType; pieceType: PieceType };
type PieceSupply = { supply: number; placed: number };
type PlayerSupply = {
type Player = {
id: PlayerType;
kitten: PieceSupply;
cat: PieceSupply;
};
// TODO refactor this into an Entity
function createPlayerSupply(): PlayerSupply {
return {
type PlayerEntity = Entity<Player>;
function createPlayer(id: PlayerType): PlayerEntity {
return entity<Player>(id, {
id,
kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 },
cat: { supply: 0, placed: 0 },
};
});
}
export function createInitialState() {
@ -38,8 +41,8 @@ export function createInitialState() {
currentPlayer: 'white' as PlayerType,
winner: null as WinnerType,
players: {
white: createPlayerSupply(),
black: createPlayerSupply(),
white: createPlayer('white'),
black: createPlayer('black'),
},
};
}
@ -47,6 +50,24 @@ export type BoopState = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<BoopState>();
export const registry = registration.registry;
// Player Entity helper functions
export function getPlayer(host: Entity<BoopState>, player: PlayerType): PlayerEntity {
return host.value.players[player];
}
export function decrementSupply(player: PlayerEntity, pieceType: PieceType) {
player.produce(p => {
p[pieceType].supply--;
p[pieceType].placed++;
});
}
export function incrementSupply(player: PlayerEntity, pieceType: PieceType, count?: number) {
player.produce(p => {
p[pieceType].supply += count ?? 1;
});
}
registration.add('setup', async function() {
const {context} = this;
while (true) {
@ -85,7 +106,8 @@ registration.add('turn <player>', async function(cmd) {
return `Cell (${row}, ${col}) is already occupied.`;
}
const supply = this.context.value.players[player][pieceType].supply;
const playerEntity = getPlayer(this.context, player);
const supply = playerEntity.value[pieceType].supply;
if (supply <= 0) {
return `No ${pieceType}s left in ${player}'s supply.`;
}
@ -103,6 +125,36 @@ registration.add('turn <player>', async function(cmd) {
processGraduation(this.context, turnPlayer, graduatedLines);
}
if (countPiecesOnBoard(this.context, turnPlayer) >= MAX_PIECES_PER_PLAYER) {
const board = getBoardRegion(this.context);
const partsMap = board.partsMap.value;
const availableKittens: Entity<BoopPart>[] = [];
for (const key in partsMap) {
const part = partsMap[key] as Entity<BoopPart>;
if (part.value.player === turnPlayer && part.value.pieceType === 'kitten') {
availableKittens.push(part);
}
}
if (availableKittens.length > 0) {
const graduateCmd = await this.prompt(
'graduate <row:number> <col:number>',
(command) => {
const [row, col] = command.params as [number, number];
const posKey = `${row},${col}`;
const part = availableKittens.find(p => `${p.value.position[0]},${p.value.position[1]}` === posKey);
if (!part) return `No kitten at (${row}, ${col}).`;
return null;
}
);
const [row, col] = graduateCmd.params as [number, number];
const part = availableKittens.find(p => p.value.position[0] === row && p.value.position[1] === col)!;
removePieceFromBoard(this.context, part);
const playerEntity = getPlayer(this.context, turnPlayer);
incrementSupply(playerEntity, 'cat', 1);
}
}
const winner = checkWinner(this.context);
if (winner) return { winner };
@ -129,7 +181,8 @@ export function getPartAt(host: Entity<BoopState>, row: number, col: number): En
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 playerEntity = getPlayer(host, player);
const count = playerEntity.value[pieceType].placed + 1;
const piece: BoopPart = {
id: `${player}-${pieceType}-${count}`,
@ -140,12 +193,11 @@ export function placePiece(host: Entity<BoopState>, row: number, col: number, pl
};
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);
});
});
decrementSupply(playerEntity, pieceType);
}
export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol: number, placedType: PieceType) {
@ -180,10 +232,9 @@ export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol
if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) {
const pt = part.value.pieceType;
const pl = part.value.player;
const playerEntity = getPlayer(host, pl);
removePieceFromBoard(host, part);
host.produce(state => {
state.players[pl][pt].supply++;
});
incrementSupply(playerEntity, pt);
continue;
}
@ -197,9 +248,13 @@ export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol
export function removePieceFromBoard(host: Entity<BoopState>, part: Entity<BoopPart>) {
const board = getBoardRegion(host);
const playerEntity = getPlayer(host, part.value.player);
board.produce(draft => {
draft.children = draft.children.filter(p => p.id !== part.id);
});
playerEntity.produce(p => {
p[part.value.pieceType].placed--;
});
}
const DIRECTIONS: [number, number][] = [
@ -298,9 +353,19 @@ export function processGraduation(host: Entity<BoopState>, player: PlayerType, l
}
const count = partsToRemove.length;
host.produce(state => {
state.players[player].cat.supply += count;
});
const playerEntity = getPlayer(host, player);
incrementSupply(playerEntity, 'cat', count);
}
export function countPiecesOnBoard(host: Entity<BoopState>, player: PlayerType): number {
const board = getBoardRegion(host);
const partsMap = board.partsMap.value;
let count = 0;
for (const key in partsMap) {
const part = partsMap[key] as Entity<BoopPart>;
if (part.value.player === player) count++;
}
return count;
}
export function checkWinner(host: Entity<BoopState>): WinnerType {
@ -318,13 +383,5 @@ export function checkWinner(host: Entity<BoopState>): WinnerType {
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;
}

View File

@ -30,7 +30,7 @@ On your turn, perform the following steps:
### 1. Placing Pieces
Place one Kitten from your supply onto any empty space on the bed.
Place one piece (Kitten or Cat) from your supply onto any empty space on the bed.
### 2. The "Boop" Mechanic
@ -51,9 +51,9 @@ Placing a piece causes a **"boop."** Every piece (yours or your opponent's) in t
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**.
2. **The Process:** Remove the three Kittens from the board and return them to the box. Add three **Cats** to your personal supply.
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.
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 choose one of their Kittens on the board to graduate into a Cat to free up a piece.
## How to Win

View File

@ -130,23 +130,23 @@ describe('Boop - helper functions', () => {
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);
expect(state.value.players.white.value.kitten.supply).toBe(7);
expect(state.value.players.black.value.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);
expect(state.value.players.white.value.kitten.supply).toBe(7);
expect(state.value.players.black.value.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;
s.players.white.value.cat.supply = 3;
});
placePiece(state, 0, 0, 'white', 'cat');
expect(state.value.players.white.cat.supply).toBe(2);
expect(state.value.players.white.value.cat.supply).toBe(2);
});
it('should add piece to board region children', () => {
@ -211,7 +211,7 @@ describe('Boop - helper functions', () => {
expect(getParts(state).length).toBe(1);
expect(getParts(state)[0].value.player).toBe('black');
expect(state.value.players.white.kitten.supply).toBe(8);
expect(state.value.players.white.value.kitten.supply).toBe(8);
});
it('should not boop piece if target cell is occupied', () => {
@ -367,7 +367,7 @@ describe('Boop - helper functions', () => {
processGraduation(state, 'white', lines);
expect(getParts(state).length).toBe(0);
expect(state.value.players.white.cat.supply).toBe(3);
expect(state.value.players.white.value.cat.supply).toBe(3);
});
it('should only graduate pieces on the winning lines', () => {
@ -384,7 +384,7 @@ describe('Boop - helper functions', () => {
expect(getParts(state).length).toBe(1);
expect(getParts(state)[0].value.position).toEqual([3, 3]);
expect(state.value.players.white.cat.supply).toBe(3);
expect(state.value.players.white.value.cat.supply).toBe(3);
});
});
@ -537,9 +537,7 @@ describe('Boop - game flow', () => {
const { ctx } = createTestContext();
const state = getState(ctx);
state.produce(s => {
s.players.white.kitten.supply = 0;
});
state.value.players.white.value.kitten.supply = 0;
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
@ -598,16 +596,14 @@ describe('Boop - game flow', () => {
processGraduation(state, 'white', lines);
expect(getParts(state).length).toBe(0);
expect(state.value.players.white.cat.supply).toBe(3);
expect(state.value.players.white.value.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;
});
state.value.players.white.value.cat.supply = 3;
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
@ -621,16 +617,14 @@ describe('Boop - game flow', () => {
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);
expect(state.value.players.white.value.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;
});
state.value.players.white.value.cat.supply = 0;
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');