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 PieceSupply = { supply: number; placed: number };
type PlayerSupply = { type Player = {
id: PlayerType;
kitten: PieceSupply; kitten: PieceSupply;
cat: PieceSupply; cat: PieceSupply;
}; };
// TODO refactor this into an Entity type PlayerEntity = Entity<Player>;
function createPlayerSupply(): PlayerSupply {
return { function createPlayer(id: PlayerType): PlayerEntity {
return entity<Player>(id, {
id,
kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 }, kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 },
cat: { supply: 0, placed: 0 }, cat: { supply: 0, placed: 0 },
}; });
} }
export function createInitialState() { export function createInitialState() {
@ -38,8 +41,8 @@ export function createInitialState() {
currentPlayer: 'white' as PlayerType, currentPlayer: 'white' as PlayerType,
winner: null as WinnerType, winner: null as WinnerType,
players: { players: {
white: createPlayerSupply(), white: createPlayer('white'),
black: createPlayerSupply(), black: createPlayer('black'),
}, },
}; };
} }
@ -47,6 +50,24 @@ export type BoopState = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<BoopState>(); const registration = createGameCommandRegistry<BoopState>();
export const registry = registration.registry; 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() { registration.add('setup', async function() {
const {context} = this; const {context} = this;
while (true) { while (true) {
@ -85,7 +106,8 @@ registration.add('turn <player>', async function(cmd) {
return `Cell (${row}, ${col}) is already occupied.`; 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) { if (supply <= 0) {
return `No ${pieceType}s left in ${player}'s supply.`; 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); 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); const winner = checkWinner(this.context);
if (winner) return { winner }; 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) { export function placePiece(host: Entity<BoopState>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
const board = getBoardRegion(host); 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 = { const piece: BoopPart = {
id: `${player}-${pieceType}-${count}`, id: `${player}-${pieceType}-${count}`,
@ -140,12 +193,11 @@ export function placePiece(host: Entity<BoopState>, row: number, col: number, pl
}; };
host.produce(s => { host.produce(s => {
const e = entity(piece.id, piece); const e = entity(piece.id, piece);
s.players[player][pieceType].supply--;
s.players[player][pieceType].placed++;
board.produce(draft => { board.produce(draft => {
draft.children.push(e); draft.children.push(e);
}); });
}); });
decrementSupply(playerEntity, pieceType);
} }
export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol: number, placedType: 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) { if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) {
const pt = part.value.pieceType; const pt = part.value.pieceType;
const pl = part.value.player; const pl = part.value.player;
const playerEntity = getPlayer(host, pl);
removePieceFromBoard(host, part); removePieceFromBoard(host, part);
host.produce(state => { incrementSupply(playerEntity, pt);
state.players[pl][pt].supply++;
});
continue; continue;
} }
@ -197,9 +248,13 @@ export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol
export function removePieceFromBoard(host: Entity<BoopState>, part: Entity<BoopPart>) { export function removePieceFromBoard(host: Entity<BoopState>, part: Entity<BoopPart>) {
const board = getBoardRegion(host); const board = getBoardRegion(host);
const playerEntity = getPlayer(host, part.value.player);
board.produce(draft => { board.produce(draft => {
draft.children = draft.children.filter(p => p.id !== part.id); draft.children = draft.children.filter(p => p.id !== part.id);
}); });
playerEntity.produce(p => {
p[part.value.pieceType].placed--;
});
} }
const DIRECTIONS: [number, number][] = [ const DIRECTIONS: [number, number][] = [
@ -298,9 +353,19 @@ export function processGraduation(host: Entity<BoopState>, player: PlayerType, l
} }
const count = partsToRemove.length; const count = partsToRemove.length;
host.produce(state => { const playerEntity = getPlayer(host, player);
state.players[player].cat.supply += count; 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 { export function checkWinner(host: Entity<BoopState>): WinnerType {
@ -318,13 +383,5 @@ export function checkWinner(host: Entity<BoopState>): WinnerType {
if (hasWinningLine(positions)) return player; 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; return null;
} }

View File

@ -30,7 +30,7 @@ On your turn, perform the following steps:
### 1. Placing Pieces ### 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 ### 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: 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." 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. 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 ## How to Win

View File

@ -130,23 +130,23 @@ describe('Boop - helper functions', () => {
const state = getState(ctx); const state = getState(ctx);
placePiece(state, 0, 0, 'white', 'kitten'); placePiece(state, 0, 0, 'white', 'kitten');
expect(state.value.players.white.kitten.supply).toBe(7); expect(state.value.players.white.value.kitten.supply).toBe(7);
expect(state.value.players.black.kitten.supply).toBe(8); expect(state.value.players.black.value.kitten.supply).toBe(8);
placePiece(state, 0, 1, 'black', 'kitten'); placePiece(state, 0, 1, 'black', 'kitten');
expect(state.value.players.white.kitten.supply).toBe(7); expect(state.value.players.white.value.kitten.supply).toBe(7);
expect(state.value.players.black.kitten.supply).toBe(7); expect(state.value.players.black.value.kitten.supply).toBe(7);
}); });
it('should decrement the correct player cat supply', () => { it('should decrement the correct player cat supply', () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
const state = getState(ctx); const state = getState(ctx);
state.produce(s => { state.produce(s => {
s.players.white.cat.supply = 3; s.players.white.value.cat.supply = 3;
}); });
placePiece(state, 0, 0, 'white', 'cat'); 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', () => { 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).length).toBe(1);
expect(getParts(state)[0].value.player).toBe('black'); 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', () => { it('should not boop piece if target cell is occupied', () => {
@ -367,7 +367,7 @@ describe('Boop - helper functions', () => {
processGraduation(state, 'white', lines); processGraduation(state, 'white', lines);
expect(getParts(state).length).toBe(0); 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', () => { 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).length).toBe(1);
expect(getParts(state)[0].value.position).toEqual([3, 3]); 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 { ctx } = createTestContext();
const state = getState(ctx); const state = getState(ctx);
state.produce(s => { state.value.players.white.value.kitten.supply = 0;
s.players.white.kitten.supply = 0;
});
const promptPromise = waitForPrompt(ctx); const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
@ -598,16 +596,14 @@ describe('Boop - game flow', () => {
processGraduation(state, 'white', lines); processGraduation(state, 'white', lines);
expect(getParts(state).length).toBe(0); 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 () => { it('should accept placing a cat via play command', async () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
const state = getState(ctx); const state = getState(ctx);
state.produce(s => { state.value.players.white.value.cat.supply = 3;
s.players.white.cat.supply = 3;
});
const promptPromise = waitForPrompt(ctx); const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); 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).length).toBe(1);
expect(getParts(state)[0].id).toBe('white-cat-1'); expect(getParts(state)[0].id).toBe('white-cat-1');
expect(getParts(state)[0].value.pieceType).toBe('cat'); 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 () => { it('should reject placing a cat when supply is empty', async () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
const state = getState(ctx); const state = getState(ctx);
state.produce(s => { state.value.players.white.value.cat.supply = 0;
s.players.white.cat.supply = 0;
});
const promptPromise = waitForPrompt(ctx); const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');