2026-04-02 16:53:17 +08:00
|
|
|
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 };
|
|
|
|
|
|
2026-04-02 17:36:42 +08:00
|
|
|
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 },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 16:53:17 +08:00
|
|
|
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,
|
2026-04-02 17:36:42 +08:00
|
|
|
players: {
|
|
|
|
|
white: createPlayerSupply(),
|
|
|
|
|
black: createPlayerSupply(),
|
|
|
|
|
},
|
2026-04-02 16:53:17 +08:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
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++;
|
2026-04-02 17:36:42 +08:00
|
|
|
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';
|
2026-04-02 16:53:17 +08:00
|
|
|
|
|
|
|
|
if (player !== turnPlayer) continue;
|
|
|
|
|
if (!isValidMove(row, col)) continue;
|
|
|
|
|
if (isCellOccupied(this.context, row, col)) continue;
|
|
|
|
|
|
2026-04-02 17:36:42 +08:00
|
|
|
const supply = this.context.value.players[player][pieceType].supply;
|
|
|
|
|
if (supply <= 0) continue;
|
2026-04-02 16:53:17 +08:00
|
|
|
|
2026-04-02 17:36:42 +08:00
|
|
|
placePiece(this.context, row, col, turnPlayer, pieceType);
|
|
|
|
|
applyBoops(this.context, row, col, pieceType);
|
2026-04-02 16:53:17 +08:00
|
|
|
|
2026-04-02 17:36:42 +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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-04-02 17:36:42 +08:00
|
|
|
const count = host.value.players[player][pieceType].placed + 1;
|
|
|
|
|
|
2026-04-02 16:53:17 +08:00
|
|
|
const piece: BoopPart = {
|
2026-04-02 17:36:42 +08:00
|
|
|
id: `${player}-${pieceType}-${count}`,
|
2026-04-02 16:53:17 +08:00
|
|
|
region: board,
|
|
|
|
|
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 => {
|
2026-04-02 16:53:17 +08:00
|
|
|
const e = entity(piece.id, piece);
|
2026-04-02 17:36:42 +08:00
|
|
|
s.players[player][pieceType].supply--;
|
|
|
|
|
s.players[player][pieceType].placed++;
|
2026-04-02 16:53:17 +08:00
|
|
|
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) {
|
2026-04-02 17:36:42 +08:00
|
|
|
const pt = part.value.pieceType;
|
|
|
|
|
const pl = part.value.player;
|
2026-04-02 16:53:17 +08:00
|
|
|
removePieceFromBoard(host, part);
|
|
|
|
|
host.produce(state => {
|
2026-04-02 17:36:42 +08:00
|
|
|
state.players[pl][pt].supply++;
|
2026-04-02 16:53:17 +08:00
|
|
|
});
|
|
|
|
|
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);
|
2026-04-02 17:36:42 +08:00
|
|
|
board.produce(draft => {
|
|
|
|
|
draft.children = draft.children.filter(p => p.id !== part.id);
|
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 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]}`);
|
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 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 => {
|
2026-04-02 17:36:42 +08:00
|
|
|
state.players[player].cat.supply += count;
|
2026-04-02 16:53:17 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function checkWinner(host: Entity<BoopState>): WinnerType {
|
2026-04-02 17:36:42 +08:00
|
|
|
const board = getBoardRegion(host);
|
|
|
|
|
const partsMap = board.partsMap.value;
|
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: 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-02 16:53:17 +08:00
|
|
|
if (hasWinningLine(positions)) return player;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 17:36:42 +08:00
|
|
|
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;
|
2026-04-02 16:53:17 +08:00
|
|
|
|
2026-04-02 17:36:42 +08:00
|
|
|
if (whiteTotal >= MAX_PIECES_PER_PLAYER && blackTotal >= MAX_PIECES_PER_PLAYER) {
|
2026-04-02 16:53:17 +08:00
|
|
|
return 'draw';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|