boardgame-core/src/samples/boop/index.ts

358 lines
12 KiB
TypeScript
Raw Normal View History

import {createGameCommandRegistry, Part, Entity, createRegion} from '@/index';
2026-04-02 16:53:17 +08:00
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 };
2026-04-02 17:36:42 +08:00
type PieceSupply = { supply: number; placed: number };
2026-04-02 19:46:49 +08:00
type Player = {
id: PlayerType;
2026-04-02 17:36:42 +08:00
kitten: PieceSupply;
cat: PieceSupply;
};
type PlayerData = Record<PlayerType, Player>;
2026-04-02 17:36:42 +08:00
2026-04-02 16:53:17 +08:00
export function createInitialState() {
return {
board: createRegion('board', [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
]),
pieces: [] as BoopPart[],
2026-04-02 16:53:17 +08:00
currentPlayer: 'white' as PlayerType,
winner: null as WinnerType,
2026-04-02 17:36:42 +08:00
players: {
2026-04-02 19:46:49 +08:00
white: createPlayer('white'),
black: createPlayer('black'),
2026-04-02 17:36:42 +08:00
},
2026-04-02 16:53:17 +08:00
};
}
function createPlayer(id: PlayerType): Player {
return {
id,
kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 },
cat: { supply: 0, placed: 0 },
};
}
2026-04-02 16:53:17 +08:00
export type BoopState = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<BoopState>();
export const registry = registration.registry;
export function getPlayer(host: Entity<BoopState>, player: PlayerType): Player {
2026-04-02 19:46:49 +08:00
return host.value.players[player];
}
export function decrementSupply(player: Player, pieceType: PieceType) {
player[pieceType].supply--;
player[pieceType].placed++;
2026-04-02 19:46:49 +08:00
}
export function incrementSupply(player: Player, pieceType: PieceType, count?: number) {
player[pieceType].supply += count ?? 1;
2026-04-02 19:46:49 +08:00
}
2026-04-02 16:53:17 +08:00
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 playCmd = await this.prompt(
'play <player> <row:number> <col:number> [type:string]',
(command) => {
const [player, row, col, type] = command.params as [PlayerType, number, number, PieceType?];
const pieceType = type === 'cat' ? 'cat' : 'kitten';
2026-04-02 16:53:17 +08:00
if (player !== turnPlayer) {
return `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
if (!isValidMove(row, col)) {
return `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
}
if (isCellOccupied(this.context, row, col)) {
return `Cell (${row}, ${col}) is already occupied.`;
}
2026-04-02 16:53:17 +08:00
const playerData = getPlayer(this.context, player);
const supply = playerData[pieceType].supply;
if (supply <= 0) {
return `No ${pieceType}s left in ${player}'s supply.`;
}
return null;
2026-04-02 16:53:17 +08:00
}
);
const [player, row, col, type] = playCmd.params as [PlayerType, number, number, PieceType?];
const pieceType = type === 'cat' ? 'cat' : 'kitten';
2026-04-02 16:53:17 +08:00
placePiece(this.context, row, col, turnPlayer, pieceType);
applyBoops(this.context, row, col, pieceType);
2026-04-02 16:53:17 +08:00
const graduatedLines = checkGraduation(this.context, turnPlayer);
if (graduatedLines.length > 0) {
processGraduation(this.context, turnPlayer, graduatedLines);
2026-04-02 16:53:17 +08:00
}
if (countPiecesOnBoard(this.context, turnPlayer) >= MAX_PIECES_PER_PLAYER) {
const availableKittens = this.context.value.pieces.filter(
p => p.player === turnPlayer && p.pieceType === 'kitten'
);
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.position[0]},${p.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.position[0] === row && p.position[1] === col)!;
removePieceFromBoard(this.context, part);
const playerData = getPlayer(this.context, turnPlayer);
incrementSupply(playerData, 'cat', 1);
}
}
const winner = checkWinner(this.context);
if (winner) return { winner };
return { winner: null };
2026-04-02 16:53:17 +08:00
});
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.partMap[`${row},${col}`] !== undefined;
2026-04-02 16:53:17 +08:00
}
export function getPartAt(host: Entity<BoopState>, row: number, col: number): BoopPart | null {
2026-04-02 16:53:17 +08:00
const board = getBoardRegion(host);
const partId = board.partMap[`${row},${col}`];
if (!partId) return null;
return host.value.pieces.find(p => p.id === partId) || null;
2026-04-02 16:53:17 +08:00
}
2026-04-02 17:36:42 +08:00
export function placePiece(host: Entity<BoopState>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
2026-04-02 16:53:17 +08:00
const board = getBoardRegion(host);
const playerData = getPlayer(host, player);
const count = playerData[pieceType].placed + 1;
2026-04-02 17:36:42 +08:00
2026-04-02 16:53:17 +08:00
const piece: BoopPart = {
2026-04-02 17:36:42 +08:00
id: `${player}-${pieceType}-${count}`,
regionId: 'board',
2026-04-02 16:53:17 +08:00
position: [row, col],
player,
2026-04-02 17:36:42 +08:00
pieceType,
2026-04-02 16:53:17 +08:00
};
2026-04-02 17:36:42 +08:00
host.produce(s => {
s.pieces.push(piece);
board.childIds.push(piece.id);
board.partMap[`${row},${col}`] = piece.id;
2026-04-02 16:53:17 +08:00
});
decrementSupply(playerData, pieceType);
2026-04-02 16:53:17 +08:00
}
export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol: number, placedType: PieceType) {
const board = getBoardRegion(host);
const pieces = host.value.pieces;
2026-04-02 16:53:17 +08:00
const piecesToBoop: { part: BoopPart; dr: number; dc: number }[] = [];
2026-04-02 16:53:17 +08:00
for (const part of pieces) {
const [r, c] = part.position;
2026-04-02 16:53:17 +08:00
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.pieceType === 'cat';
2026-04-02 16:53:17 +08:00
if (booperIsKitten && targetIsCat) continue;
piecesToBoop.push({ part, dr, dc });
}
}
for (const { part, dr, dc } of piecesToBoop) {
const [r, c] = part.position;
2026-04-02 16:53:17 +08:00
const newRow = r + dr;
const newCol = c + dc;
if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) {
const pt = part.pieceType;
const pl = part.player;
const playerData = getPlayer(host, pl);
2026-04-02 16:53:17 +08:00
removePieceFromBoard(host, part);
incrementSupply(playerData, pt);
2026-04-02 16:53:17 +08:00
continue;
}
if (isCellOccupied(host, newRow, newCol)) continue;
part.position = [newRow, newCol];
board.partMap = Object.fromEntries(
board.childIds.map(id => {
const p = pieces.find(x => x.id === id)!;
return [p.position.join(','), id];
})
);
2026-04-02 16:53:17 +08:00
}
}
export function removePieceFromBoard(host: Entity<BoopState>, part: BoopPart) {
2026-04-02 16:53:17 +08:00
const board = getBoardRegion(host);
const playerData = getPlayer(host, part.player);
board.childIds = board.childIds.filter(id => id !== part.id);
delete board.partMap[part.position.join(',')];
host.value.pieces = host.value.pieces.filter(p => p.id !== part.id);
playerData[part.pieceType].placed--;
2026-04-02 16:53:17 +08:00
}
2026-04-02 17:45:03 +08:00
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;
2026-04-02 17:36:42 +08:00
}
}
2026-04-02 17:45:03 +08:00
}
2026-04-02 16:53:17 +08:00
2026-04-02 17:45:03 +08:00
export function* allLines(): Generator<number[][]> {
const seen = new Set<string>();
2026-04-02 16:53:17 +08:00
for (let r = 0; r < BOARD_SIZE; r++) {
2026-04-02 17:45:03 +08:00
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;
}
2026-04-02 16:53:17 +08:00
}
}
}
2026-04-02 17:45:03 +08:00
}
2026-04-02 16:53:17 +08:00
2026-04-02 17:45:03 +08:00
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;
2026-04-02 16:53:17 +08:00
}
2026-04-02 17:45:03 +08:00
return false;
}
2026-04-02 16:53:17 +08:00
2026-04-02 17:45:03 +08:00
export function checkGraduation(host: Entity<BoopState>, player: PlayerType): number[][][] {
const pieces = host.value.pieces;
2026-04-02 17:45:03 +08:00
const posSet = new Set<string>();
for (const part of pieces) {
if (part.player === player && part.pieceType === 'kitten') {
posSet.add(`${part.position[0]},${part.position[1]}`);
2026-04-02 16:53:17 +08:00
}
}
2026-04-02 17:45:03 +08:00
const winningLines: number[][][] = [];
for (const line of allLines()) {
if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) {
winningLines.push(line);
2026-04-02 16:53:17 +08:00
}
}
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 partsToRemove = host.value.pieces.filter(
p => p.player === player && p.pieceType === 'kitten' && allPositions.has(`${p.position[0]},${p.position[1]}`)
);
2026-04-02 16:53:17 +08:00
for (const part of partsToRemove) {
removePieceFromBoard(host, part);
}
const count = partsToRemove.length;
const playerData = getPlayer(host, player);
incrementSupply(playerData, 'cat', count);
2026-04-02 16:53:17 +08:00
}
export function countPiecesOnBoard(host: Entity<BoopState>, player: PlayerType): number {
const pieces = host.value.pieces;
return pieces.filter(p => p.player === player).length;
}
2026-04-02 16:53:17 +08:00
export function checkWinner(host: Entity<BoopState>): WinnerType {
const pieces = host.value.pieces;
2026-04-02 16:53:17 +08:00
2026-04-02 17:36:42 +08:00
for (const player of ['white', 'black'] as PlayerType[]) {
const positions = pieces
.filter(p => p.player === player && p.pieceType === 'cat')
.map(p => p.position);
2026-04-02 16:53:17 +08:00
if (hasWinningLine(positions)) return player;
}
return null;
}