diff --git a/src/games/tak/Board.tsx b/src/games/tak/Board.tsx new file mode 100644 index 0000000..ec3a7dc --- /dev/null +++ b/src/games/tak/Board.tsx @@ -0,0 +1,526 @@ +import { useState } from 'react'; +import type { BoardProps } from 'boardgame.io/react'; +import type { TakState, Stack, Piece, PieceType } from './Game'; +import { getTopPiece } from './Game'; + +type ActionMode = 'select' | 'place' | 'move'; + +interface MoveState { + fromRow: number; + fromCol: number; + direction: string | null; + picked: number; + drops: number[]; +} + +const BOARD_SIZE = 5; +const DIRECTIONS = ['up', 'down', 'left', 'right']; +const DIRECTION_LABELS: Record = { + up: '\u2191 Up', + down: '\u2193 Down', + left: '\u2190 Left', + right: '\u2192 Right', +}; +const DIRECTION_VECTORS: Record = { + up: [-1, 0], + down: [1, 0], + left: [0, -1], + right: [0, 1], +}; + +export function Board({ G, ctx, moves, playerID }: BoardProps) { + const [mode, setMode] = useState('select'); + const [selectedCell, setSelectedCell] = useState<[number, number] | null>(null); + const [moveState, setMoveState] = useState(null); + + const isMyTurn = playerID === ctx.currentPlayer; + const isOpening = G.turnNumber < 2; + const currentPlayerLabel = ctx.currentPlayer === '0' ? 'White' : 'Black'; + const myLabel = playerID === '0' ? 'White' : 'Black'; + + const resetSelection = () => { + setMode('select'); + setSelectedCell(null); + setMoveState(null); + }; + + const handleCellClick = (row: number, col: number) => { + if (ctx.gameover || !isMyTurn) return; + + const stack = G.board[row][col]; + const top = getTopPiece(stack); + const isEmpty = stack.length === 0; + const isMyStack = top?.owner === playerID; + + if (mode === 'select') { + if (isEmpty) { + // Empty cell - start place mode + setSelectedCell([row, col]); + setMode('place'); + } else if (isMyStack && !isOpening) { + // My stack - start move mode + setSelectedCell([row, col]); + setMode('move'); + setMoveState({ + fromRow: row, + fromCol: col, + direction: null, + picked: 1, + drops: [1], + }); + } + } else if (mode === 'place') { + if (isEmpty) { + setSelectedCell([row, col]); + } else { + resetSelection(); + } + } else if (mode === 'move') { + // Clicking another cell cancels move mode + resetSelection(); + // If clicked on empty or my stack, start fresh + if (isEmpty) { + setSelectedCell([row, col]); + setMode('place'); + } else if (isMyStack) { + setSelectedCell([row, col]); + setMode('move'); + setMoveState({ + fromRow: row, + fromCol: col, + direction: null, + picked: 1, + drops: [1], + }); + } + } + }; + + const handlePlace = (pieceType: PieceType) => { + if (!selectedCell) return; + const [row, col] = selectedCell; + moves.place(row, col, pieceType); + resetSelection(); + }; + + const handleMove = () => { + if (!moveState || !moveState.direction) return; + moves.move(moveState.fromRow, moveState.fromCol, moveState.direction, moveState.drops); + resetSelection(); + }; + + const updateDrops = (picked: number, direction: string) => { + // Default: drop 1 piece on each square + const drops = Array(picked).fill(1); + setMoveState((prev) => + prev ? { ...prev, picked, direction, drops } : null + ); + }; + + const setDropAt = (index: number, value: number) => { + if (!moveState) return; + const newDrops = [...moveState.drops]; + newDrops[index] = value; + setMoveState({ ...moveState, drops: newDrops }); + }; + + const addDropSquare = () => { + if (!moveState) return; + const total = moveState.drops.reduce((a, b) => a + b, 0); + if (total < moveState.picked) { + setMoveState({ + ...moveState, + drops: [...moveState.drops, 1], + }); + } + }; + + const removeDropSquare = () => { + if (!moveState || moveState.drops.length <= 1) return; + const newDrops = moveState.drops.slice(0, -1); + // Ensure total equals picked + const total = newDrops.reduce((a, b) => a + b, 0); + if (total < moveState.picked) { + newDrops[newDrops.length - 1] += moveState.picked - total; + } + setMoveState({ ...moveState, drops: newDrops }); + }; + + const canMoveInDirection = (row: number, col: number, dir: string): boolean => { + const [dr, dc] = DIRECTION_VECTORS[dir]; + const newRow = row + dr; + const newCol = col + dc; + if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) return false; + + const targetStack = G.board[newRow][newCol]; + const targetTop = getTopPiece(targetStack); + if (!targetTop) return true; + if (targetTop.type === 'capstone') return false; + if (targetTop.type === 'standing') { + // Only capstone can flatten + const sourceTop = getTopPiece(G.board[row][col]); + return sourceTop?.type === 'capstone'; + } + return true; + }; + + const renderPiece = (piece: Piece) => { + const color = piece.owner === '0' ? '#f5f5f5' : '#333'; + const borderColor = piece.owner === '0' ? '#999' : '#000'; + const textColor = piece.owner === '0' ? '#333' : '#fff'; + + if (piece.type === 'capstone') { + return ( +
+ C +
+ ); + } + + if (piece.type === 'standing') { + return ( +
+ ); + } + + // Flat stone + return ( +
+ ); + }; + + const renderStack = (stack: Stack, row: number, col: number) => { + const isSelected = selectedCell?.[0] === row && selectedCell?.[1] === col; + + return ( +
handleCellClick(row, col)} + style={{ + width: '70px', + height: '70px', + border: isSelected ? '3px solid #4CAF50' : '1px solid #8B4513', + backgroundColor: (row + col) % 2 === 0 ? '#DEB887' : '#D2691E', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'flex-end', + cursor: 'pointer', + padding: '2px', + boxSizing: 'border-box', + position: 'relative', + }} + > + {stack.length > 0 && ( +
+ {stack.length} +
+ )} +
+ {stack.slice(-3).map((piece, i) => ( +
0 ? '-5px' : '0' }}> + {renderPiece(piece)} +
+ ))} +
+
+ ); + }; + + const renderPlaceControls = () => { + if (mode !== 'place' || !selectedCell) return null; + + const pieces = G.pieces[playerID as '0' | '1']; + const [row, col] = selectedCell; + + return ( +
+

+ Place piece at ({row}, {col}) +

+ {isOpening ? ( +
+

+ Opening move: Place opponent's flat stone +

+ +
+ ) : ( +
+ + + +
+ )} + +
+ ); + }; + + const renderMoveControls = () => { + if (mode !== 'move' || !moveState) return null; + + const stack = G.board[moveState.fromRow][moveState.fromCol]; + const maxPick = Math.min(5, stack.length); + const currentTotal = moveState.drops.reduce((a, b) => a + b, 0); + + return ( +
+

+ Move stack from ({moveState.fromRow}, {moveState.fromCol}) +

+

Stack height: {stack.length}

+ +
+ + +
+ +
+ + +
+ + {moveState.direction && ( +
+ +
+ {moveState.drops.map((drop, i) => ( +
+ Sq{i + 1}: + +
+ ))} +
+
+ + +
+

+ Total: {currentTotal} / {moveState.picked} pieces +

+
+ )} + +
+ + +
+
+ ); + }; + + const renderPieceCount = () => { + return ( +
+
+ White: {G.pieces['0'].flats} flats, {G.pieces['0'].capstones} caps +
+
+ Black: {G.pieces['1'].flats} flats, {G.pieces['1'].capstones} caps +
+
+ ); + }; + + let status: string; + if (ctx.gameover) { + if (ctx.gameover.draw) { + status = "It's a draw!"; + } else { + const winnerLabel = ctx.gameover.winner === '0' ? 'White' : 'Black'; + status = `${winnerLabel} wins!`; + } + } else if (isOpening) { + status = `Opening: ${currentPlayerLabel} places opponent's flat stone`; + } else { + status = `${currentPlayerLabel}'s turn${isMyTurn ? ' (You)' : ''}`; + } + + return ( +
+

Tak

+

{status}

+

You are playing as {myLabel}

+ {renderPieceCount()} + +
+ {G.board.map((row, rowIdx) => + row.map((stack, colIdx) => renderStack(stack, rowIdx, colIdx)) + )} +
+ + {renderPlaceControls()} + {renderMoveControls()} + + {mode === 'select' && !ctx.gameover && isMyTurn && ( +

+ Click an empty cell to place a piece, or click your stack to move it. +

+ )} +
+ ); +} + +const buttonStyle: React.CSSProperties = { + padding: '8px 16px', + fontSize: '14px', + border: 'none', + borderRadius: '4px', + backgroundColor: '#2196F3', + color: 'white', + cursor: 'pointer', +}; + +const selectStyle: React.CSSProperties = { + padding: '5px', + fontSize: '14px', + borderRadius: '4px', +}; diff --git a/src/games/tak/Game.test.ts b/src/games/tak/Game.test.ts new file mode 100644 index 0000000..b386b93 --- /dev/null +++ b/src/games/tak/Game.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect } from 'vitest'; +import { + createEmptyBoard, + getTopPiece, + stackOwner, + canPlaceOn, + canMoveOnto, + isValidPosition, + hasRoad, + countFlats, + hasPiecesToPlace, + isBoardFull, + type Stack, + type Piece, +} from './Game'; + +describe('createEmptyBoard', () => { + it('creates a 5x5 board of empty stacks', () => { + const board = createEmptyBoard(); + expect(board.length).toBe(5); + expect(board[0].length).toBe(5); + for (let row = 0; row < 5; row++) { + for (let col = 0; col < 5; col++) { + expect(board[row][col]).toEqual([]); + } + } + }); +}); + +describe('getTopPiece', () => { + it('returns undefined for empty stack', () => { + expect(getTopPiece([])).toBeUndefined(); + }); + + it('returns the top piece of a stack', () => { + const stack: Stack = [ + { owner: '0', type: 'flat' }, + { owner: '1', type: 'flat' }, + ]; + expect(getTopPiece(stack)).toEqual({ owner: '1', type: 'flat' }); + }); +}); + +describe('stackOwner', () => { + it('returns undefined for empty stack', () => { + expect(stackOwner([])).toBeUndefined(); + }); + + it('returns the owner of the top piece', () => { + const stack: Stack = [ + { owner: '0', type: 'flat' }, + { owner: '1', type: 'flat' }, + ]; + expect(stackOwner(stack)).toBe('1'); + }); +}); + +describe('canPlaceOn', () => { + it('returns true for empty stack', () => { + expect(canPlaceOn([])).toBe(true); + }); + + it('returns true for stack with flat on top', () => { + const stack: Stack = [{ owner: '0', type: 'flat' }]; + expect(canPlaceOn(stack)).toBe(true); + }); + + it('returns false for stack with standing stone on top', () => { + const stack: Stack = [{ owner: '0', type: 'standing' }]; + expect(canPlaceOn(stack)).toBe(false); + }); + + it('returns false for stack with capstone on top', () => { + const stack: Stack = [{ owner: '0', type: 'capstone' }]; + expect(canPlaceOn(stack)).toBe(false); + }); +}); + +describe('canMoveOnto', () => { + it('returns true for empty stack', () => { + const piece: Piece = { owner: '0', type: 'flat' }; + expect(canMoveOnto([], piece)).toBe(true); + }); + + it('returns true for moving onto flat stone', () => { + const stack: Stack = [{ owner: '1', type: 'flat' }]; + const piece: Piece = { owner: '0', type: 'flat' }; + expect(canMoveOnto(stack, piece)).toBe(true); + }); + + it('returns false for moving flat onto standing stone', () => { + const stack: Stack = [{ owner: '1', type: 'standing' }]; + const piece: Piece = { owner: '0', type: 'flat' }; + expect(canMoveOnto(stack, piece)).toBe(false); + }); + + it('returns true for moving capstone onto standing stone (flattening)', () => { + const stack: Stack = [{ owner: '1', type: 'standing' }]; + const piece: Piece = { owner: '0', type: 'capstone' }; + expect(canMoveOnto(stack, piece)).toBe(true); + }); + + it('returns false for moving onto capstone', () => { + const stack: Stack = [{ owner: '1', type: 'capstone' }]; + const piece: Piece = { owner: '0', type: 'flat' }; + expect(canMoveOnto(stack, piece)).toBe(false); + }); +}); + +describe('isValidPosition', () => { + it('returns true for valid positions', () => { + expect(isValidPosition(0, 0)).toBe(true); + expect(isValidPosition(2, 2)).toBe(true); + expect(isValidPosition(4, 4)).toBe(true); + }); + + it('returns false for invalid positions', () => { + expect(isValidPosition(-1, 0)).toBe(false); + expect(isValidPosition(0, -1)).toBe(false); + expect(isValidPosition(5, 0)).toBe(false); + expect(isValidPosition(0, 5)).toBe(false); + }); +}); + +describe('hasRoad', () => { + it('returns false for empty board', () => { + const board = createEmptyBoard(); + expect(hasRoad(board, '0')).toBe(false); + }); + + it('detects top-to-bottom road', () => { + const board = createEmptyBoard(); + // Create a road down column 2 + for (let row = 0; row < 5; row++) { + board[row][2] = [{ owner: '0', type: 'flat' }]; + } + expect(hasRoad(board, '0')).toBe(true); + expect(hasRoad(board, '1')).toBe(false); + }); + + it('detects left-to-right road', () => { + const board = createEmptyBoard(); + // Create a road across row 2 + for (let col = 0; col < 5; col++) { + board[2][col] = [{ owner: '1', type: 'flat' }]; + } + expect(hasRoad(board, '1')).toBe(true); + expect(hasRoad(board, '0')).toBe(false); + }); + + it('detects winding road', () => { + const board = createEmptyBoard(); + // Create a winding path + board[0][0] = [{ owner: '0', type: 'flat' }]; + board[1][0] = [{ owner: '0', type: 'flat' }]; + board[1][1] = [{ owner: '0', type: 'flat' }]; + board[2][1] = [{ owner: '0', type: 'flat' }]; + board[2][2] = [{ owner: '0', type: 'flat' }]; + board[3][2] = [{ owner: '0', type: 'flat' }]; + board[4][2] = [{ owner: '0', type: 'flat' }]; + expect(hasRoad(board, '0')).toBe(true); + }); + + it('standing stones do not count as road', () => { + const board = createEmptyBoard(); + // Create a road with standing stone in middle + for (let row = 0; row < 5; row++) { + if (row === 2) { + board[row][0] = [{ owner: '0', type: 'standing' }]; + } else { + board[row][0] = [{ owner: '0', type: 'flat' }]; + } + } + expect(hasRoad(board, '0')).toBe(false); + }); + + it('capstones count as road', () => { + const board = createEmptyBoard(); + // Create a road with capstone in middle + for (let row = 0; row < 5; row++) { + if (row === 2) { + board[row][0] = [{ owner: '0', type: 'capstone' }]; + } else { + board[row][0] = [{ owner: '0', type: 'flat' }]; + } + } + expect(hasRoad(board, '0')).toBe(true); + }); + + it('does not detect diagonal-only connections', () => { + const board = createEmptyBoard(); + // Create a diagonal path - should NOT be a road + board[0][0] = [{ owner: '0', type: 'flat' }]; + board[1][1] = [{ owner: '0', type: 'flat' }]; + board[2][2] = [{ owner: '0', type: 'flat' }]; + board[3][3] = [{ owner: '0', type: 'flat' }]; + board[4][4] = [{ owner: '0', type: 'flat' }]; + expect(hasRoad(board, '0')).toBe(false); + }); +}); + +describe('countFlats', () => { + it('returns 0 for empty board', () => { + const board = createEmptyBoard(); + expect(countFlats(board, '0')).toBe(0); + }); + + it('counts only visible flat stones', () => { + const board = createEmptyBoard(); + board[0][0] = [{ owner: '0', type: 'flat' }]; + board[0][1] = [{ owner: '0', type: 'flat' }, { owner: '1', type: 'flat' }]; + board[0][2] = [{ owner: '0', type: 'standing' }]; + board[0][3] = [{ owner: '0', type: 'capstone' }]; + + expect(countFlats(board, '0')).toBe(1); // Only the first cell counts + expect(countFlats(board, '1')).toBe(1); // The top of second stack + }); + + it('does not count standing stones or capstones', () => { + const board = createEmptyBoard(); + board[0][0] = [{ owner: '0', type: 'standing' }]; + board[0][1] = [{ owner: '0', type: 'capstone' }]; + expect(countFlats(board, '0')).toBe(0); + }); +}); + +describe('hasPiecesToPlace', () => { + it('returns true when player has pieces', () => { + const pieces = { + '0': { flats: 21, capstones: 1 }, + '1': { flats: 21, capstones: 1 }, + }; + expect(hasPiecesToPlace(pieces, '0')).toBe(true); + }); + + it('returns false when player has no pieces', () => { + const pieces = { + '0': { flats: 0, capstones: 0 }, + '1': { flats: 21, capstones: 1 }, + }; + expect(hasPiecesToPlace(pieces, '0')).toBe(false); + }); + + it('returns true when player only has capstone', () => { + const pieces = { + '0': { flats: 0, capstones: 1 }, + '1': { flats: 21, capstones: 1 }, + }; + expect(hasPiecesToPlace(pieces, '0')).toBe(true); + }); + + it('returns true when player only has flats', () => { + const pieces = { + '0': { flats: 5, capstones: 0 }, + '1': { flats: 21, capstones: 1 }, + }; + expect(hasPiecesToPlace(pieces, '0')).toBe(true); + }); +}); + +describe('isBoardFull', () => { + it('returns false for empty board', () => { + const board = createEmptyBoard(); + expect(isBoardFull(board)).toBe(false); + }); + + it('returns false for partially filled board', () => { + const board = createEmptyBoard(); + board[0][0] = [{ owner: '0', type: 'flat' }]; + expect(isBoardFull(board)).toBe(false); + }); + + it('returns false for board with one empty cell', () => { + const board = createEmptyBoard(); + for (let row = 0; row < 5; row++) { + for (let col = 0; col < 5; col++) { + if (row === 4 && col === 4) continue; + board[row][col] = [{ owner: row % 2 === 0 ? '0' : '1', type: 'flat' }]; + } + } + expect(isBoardFull(board)).toBe(false); + }); + + it('returns true for full board', () => { + const board = createEmptyBoard(); + for (let row = 0; row < 5; row++) { + for (let col = 0; col < 5; col++) { + board[row][col] = [{ owner: row % 2 === 0 ? '0' : '1', type: 'flat' }]; + } + } + expect(isBoardFull(board)).toBe(true); + }); +}); diff --git a/src/games/tak/Game.ts b/src/games/tak/Game.ts new file mode 100644 index 0000000..61086bc --- /dev/null +++ b/src/games/tak/Game.ts @@ -0,0 +1,539 @@ +import type { Game } from 'boardgame.io'; + +// Piece types +export type PieceType = 'flat' | 'standing' | 'capstone'; + +export interface Piece { + owner: string; // '0' or '1' + type: PieceType; +} + +// A cell can have a stack of pieces (bottom to top) +export type Stack = Piece[]; + +export interface TakState { + board: Stack[][]; // 5x5 grid of stacks + pieces: { + '0': { flats: number; capstones: number }; + '1': { flats: number; capstones: number }; + }; + turnNumber: number; // Track turn number for opening rule +} + +const BOARD_SIZE = 5; +const INITIAL_FLATS = 21; +const INITIAL_CAPSTONES = 1; +const CARRY_LIMIT = BOARD_SIZE; + +// Direction vectors for orthogonal movement +const DIRECTIONS: { [key: string]: [number, number] } = { + up: [-1, 0], + down: [1, 0], + left: [0, -1], + right: [0, 1], +}; + +export function createEmptyBoard(): Stack[][] { + return Array(BOARD_SIZE) + .fill(null) + .map(() => + Array(BOARD_SIZE) + .fill(null) + .map(() => []) + ); +} + +export function getTopPiece(stack: Stack): Piece | undefined { + return stack.length > 0 ? stack[stack.length - 1] : undefined; +} + +export function stackOwner(stack: Stack): string | undefined { + const top = getTopPiece(stack); + return top?.owner; +} + +export function canPlaceOn(stack: Stack): boolean { + if (stack.length === 0) return true; + const top = getTopPiece(stack); + // Can only place on empty or flat stones (not standing or capstone) + return top?.type === 'flat'; +} + +export function canMoveOnto(stack: Stack, movingPiece: Piece): boolean { + if (stack.length === 0) return true; + const top = getTopPiece(stack); + if (!top) return true; + + // Capstone can flatten a standing stone when moving alone + if (movingPiece.type === 'capstone' && top.type === 'standing') { + return true; // Will be flattened + } + + // Can move onto flat stones only + return top.type === 'flat'; +} + +export function isValidPosition(row: number, col: number): boolean { + return row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE; +} + +// Check if a player has a road from one edge to the opposite +export function hasRoad(board: Stack[][], player: string): boolean { + // Check for top-to-bottom road + const topBottomRoad = checkRoadTopBottom(board, player); + if (topBottomRoad) return true; + + // Check for left-to-right road + const leftRightRoad = checkRoadLeftRight(board, player); + return leftRightRoad; +} + +function checkRoadTopBottom(board: Stack[][], player: string): boolean { + // Start from top row, try to reach bottom row + const visited = new Set(); + + function dfs(row: number, col: number): boolean { + if (row === BOARD_SIZE - 1) return true; // Reached bottom + + const key = `${row},${col}`; + if (visited.has(key)) return false; + visited.add(key); + + // Check all orthogonal neighbors + for (const [dr, dc] of Object.values(DIRECTIONS)) { + const newRow = row + dr; + const newCol = col + dc; + + if (isValidPosition(newRow, newCol)) { + const stack = board[newRow][newCol]; + const top = getTopPiece(stack); + if (top && top.owner === player && (top.type === 'flat' || top.type === 'capstone')) { + if (dfs(newRow, newCol)) return true; + } + } + } + + return false; + } + + // Try starting from each cell in top row + for (let col = 0; col < BOARD_SIZE; col++) { + const stack = board[0][col]; + const top = getTopPiece(stack); + if (top && top.owner === player && (top.type === 'flat' || top.type === 'capstone')) { + if (dfs(0, col)) return true; + } + } + + return false; +} + +function checkRoadLeftRight(board: Stack[][], player: string): boolean { + // Start from left column, try to reach right column + const visited = new Set(); + + function dfs(row: number, col: number): boolean { + if (col === BOARD_SIZE - 1) return true; // Reached right edge + + const key = `${row},${col}`; + if (visited.has(key)) return false; + visited.add(key); + + // Check all orthogonal neighbors + for (const [dr, dc] of Object.values(DIRECTIONS)) { + const newRow = row + dr; + const newCol = col + dc; + + if (isValidPosition(newRow, newCol)) { + const stack = board[newRow][newCol]; + const top = getTopPiece(stack); + if (top && top.owner === player && (top.type === 'flat' || top.type === 'capstone')) { + if (dfs(newRow, newCol)) return true; + } + } + } + + return false; + } + + // Try starting from each cell in left column + for (let row = 0; row < BOARD_SIZE; row++) { + const stack = board[row][0]; + const top = getTopPiece(stack); + if (top && top.owner === player && (top.type === 'flat' || top.type === 'capstone')) { + if (dfs(row, 0)) return true; + } + } + + return false; +} + +// Count visible flat stones for a player +export function countFlats(board: Stack[][], player: string): number { + let count = 0; + for (let row = 0; row < BOARD_SIZE; row++) { + for (let col = 0; col < BOARD_SIZE; col++) { + const top = getTopPiece(board[row][col]); + if (top && top.owner === player && top.type === 'flat') { + count++; + } + } + } + return count; +} + +// Check if a player has any pieces left to place +export function hasPiecesToPlace(pieces: TakState['pieces'], player: string): boolean { + const p = pieces[player as '0' | '1']; + return p.flats > 0 || p.capstones > 0; +} + +// Check if the board is full +export function isBoardFull(board: Stack[][]): boolean { + for (let row = 0; row < BOARD_SIZE; row++) { + for (let col = 0; col < BOARD_SIZE; col++) { + if (board[row][col].length === 0) return false; + } + } + return true; +} + +// Get the opponent player ID +function opponent(player: string): string { + return player === '0' ? '1' : '0'; +} + +export const Tak: Game = { + name: 'tak', + + setup: () => ({ + board: createEmptyBoard(), + pieces: { + '0': { flats: INITIAL_FLATS, capstones: INITIAL_CAPSTONES }, + '1': { flats: INITIAL_FLATS, capstones: INITIAL_CAPSTONES }, + }, + turnNumber: 0, + }), + + turn: { + minMoves: 1, + maxMoves: 1, + }, + + moves: { + // Place a piece on an empty cell + place: ({ G, playerID }, row: number, col: number, pieceType: PieceType) => { + if (!playerID) return; + if (!isValidPosition(row, col)) return; + + const stack = G.board[row][col]; + if (stack.length > 0) return; // Must be empty + + // First two moves: place opponent's flat stone + if (G.turnNumber < 2) { + const opponentId = opponent(playerID); + if (G.pieces[opponentId as '0' | '1'].flats <= 0) return; + if (pieceType !== 'flat') return; // Must be flat on opening + + G.pieces[opponentId as '0' | '1'].flats--; + stack.push({ owner: opponentId, type: 'flat' }); + G.turnNumber++; + return; + } + + // Normal placement + const pieces = G.pieces[playerID as '0' | '1']; + + if (pieceType === 'capstone') { + if (pieces.capstones <= 0) return; + pieces.capstones--; + } else { + // flat or standing + if (pieces.flats <= 0) return; + pieces.flats--; + } + + stack.push({ owner: playerID, type: pieceType }); + G.turnNumber++; + }, + + // Move a stack + move: ( + { G, playerID }, + fromRow: number, + fromCol: number, + direction: string, + drops: number[] + ) => { + if (!playerID) return; + if (G.turnNumber < 2) return; // Can't move on opening turns + + if (!isValidPosition(fromRow, fromCol)) return; + if (!DIRECTIONS[direction]) return; + + const stack = G.board[fromRow][fromCol]; + if (stack.length === 0) return; + + // Must control the stack (your piece on top) + const top = getTopPiece(stack); + if (!top || top.owner !== playerID) return; + + // Calculate total pieces to pick up + const totalPicked = drops.reduce((a, b) => a + b, 0); + if (totalPicked < 1 || totalPicked > Math.min(CARRY_LIMIT, stack.length)) return; + + // Must drop at least 1 on each square + if (drops.some((d) => d < 1)) return; + + const [dr, dc] = DIRECTIONS[direction]; + + // Validate the entire move path first + let checkRow = fromRow; + let checkCol = fromCol; + for (let i = 0; i < drops.length; i++) { + checkRow += dr; + checkCol += dc; + + if (!isValidPosition(checkRow, checkCol)) return; + + const targetStack = G.board[checkRow][checkCol]; + // Check what piece is being dropped on this square + // The last drop might be just the capstone + const isLastDrop = i === drops.length - 1; + const dropsRemaining = drops.slice(i).reduce((a, b) => a + b, 0); + const movingPieceIsCapstone = top.type === 'capstone' && dropsRemaining === 1; + + // For intermediate drops, check if we can stack + if (!canMoveOnto(targetStack, movingPieceIsCapstone ? top : { owner: playerID, type: 'flat' })) { + // Special case: capstone can flatten wall on last square + const targetTop = getTopPiece(targetStack); + if (isLastDrop && movingPieceIsCapstone && targetTop?.type === 'standing') { + // This is allowed - capstone will flatten it + } else { + return; // Invalid move + } + } + } + + // Execute the move + const pickedPieces = stack.splice(stack.length - totalPicked, totalPicked); + let currentRow = fromRow; + let currentCol = fromCol; + let pieceIndex = 0; + + for (const dropCount of drops) { + currentRow += dr; + currentCol += dc; + + const targetStack = G.board[currentRow][currentCol]; + + // If dropping capstone on a wall, flatten it + const targetTop = getTopPiece(targetStack); + if (targetTop?.type === 'standing') { + targetTop.type = 'flat'; + } + + // Drop pieces + for (let i = 0; i < dropCount; i++) { + targetStack.push(pickedPieces[pieceIndex]); + pieceIndex++; + } + } + + G.turnNumber++; + }, + }, + + endIf: ({ G, ctx }) => { + // Check for road win (current player who just moved) + const justPlayed = ctx.currentPlayer === '0' ? '1' : '0'; + if (hasRoad(G.board, justPlayed)) { + return { winner: justPlayed }; + } + + // Check if opponent also has a road (created by our move) + if (hasRoad(G.board, ctx.currentPlayer)) { + return { winner: ctx.currentPlayer }; + } + + // Check for flat win condition + const player0HasPieces = hasPiecesToPlace(G.pieces, '0'); + const player1HasPieces = hasPiecesToPlace(G.pieces, '1'); + const boardFull = isBoardFull(G.board); + + if (boardFull || !player0HasPieces || !player1HasPieces) { + const flats0 = countFlats(G.board, '0'); + const flats1 = countFlats(G.board, '1'); + + if (flats0 > flats1) return { winner: '0' }; + if (flats1 > flats0) return { winner: '1' }; + return { draw: true }; + } + + return undefined; + }, + + ai: { + enumerate: (G: TakState, ctx?: { currentPlayer?: string }, playerID?: string) => { + const moves: Array<{ move: string; args: unknown[] }> = []; + const player = playerID || ctx?.currentPlayer || '0'; + + // Opening moves: place opponent's flat + if (G.turnNumber < 2) { + for (let row = 0; row < BOARD_SIZE; row++) { + for (let col = 0; col < BOARD_SIZE; col++) { + if (G.board[row][col].length === 0) { + moves.push({ move: 'place', args: [row, col, 'flat'] }); + } + } + } + return moves; + } + + const pieces = G.pieces[player as '0' | '1']; + + // Place moves on empty cells + for (let row = 0; row < BOARD_SIZE; row++) { + for (let col = 0; col < BOARD_SIZE; col++) { + if (G.board[row][col].length === 0) { + if (pieces.flats > 0) { + moves.push({ move: 'place', args: [row, col, 'flat'] }); + moves.push({ move: 'place', args: [row, col, 'standing'] }); + } + if (pieces.capstones > 0) { + moves.push({ move: 'place', args: [row, col, 'capstone'] }); + } + } + } + } + + // Move moves: find all stacks controlled by player + for (let row = 0; row < BOARD_SIZE; row++) { + for (let col = 0; col < BOARD_SIZE; col++) { + const stack = G.board[row][col]; + if (stack.length === 0) continue; + + const top = getTopPiece(stack); + if (!top || top.owner !== player) continue; + + // Try all directions + for (const direction of Object.keys(DIRECTIONS)) { + const [dr, dc] = DIRECTIONS[direction]; + + // Calculate max distance in this direction + let maxDist = 0; + let checkRow = row + dr; + let checkCol = col + dc; + + while (isValidPosition(checkRow, checkCol)) { + const targetStack = G.board[checkRow][checkCol]; + const targetTop = getTopPiece(targetStack); + + if (targetTop) { + if (targetTop.type === 'capstone') break; // Can't move onto capstone + if (targetTop.type === 'standing') { + // Only capstone can flatten, and only as last piece + if (top.type === 'capstone') { + maxDist++; // Can flatten as last move + } + break; + } + } + + maxDist++; + checkRow += dr; + checkCol += dc; + } + + if (maxDist === 0) continue; + + // Generate all valid drop patterns + const maxPick = Math.min(CARRY_LIMIT, stack.length); + const dropPatterns = generateDropPatterns(maxPick, maxDist); + + for (const drops of dropPatterns) { + // Validate this specific pattern + if (isValidDropPattern(G.board, row, col, direction, drops, top.type)) { + moves.push({ move: 'move', args: [row, col, direction, drops] }); + } + } + } + } + } + + return moves; + }, + }, +}; + +// Generate all valid drop patterns for picking up 1..maxPick pieces and dropping across 1..maxDist squares +function generateDropPatterns(maxPick: number, maxDist: number): number[][] { + const patterns: number[][] = []; + + for (let pick = 1; pick <= maxPick; pick++) { + // Generate all ways to partition 'pick' pieces into 1..min(pick, maxDist) squares + generatePartitions(pick, Math.min(pick, maxDist), [], patterns); + } + + return patterns; +} + +// Generate all partitions of 'total' into 'maxParts' parts, each >= 1 +function generatePartitions(total: number, maxParts: number, current: number[], result: number[][]): void { + if (total === 0) { + if (current.length > 0) { + result.push([...current]); + } + return; + } + + if (maxParts === 0) return; + + const minDrop = 1; + const maxDrop = total - (maxParts - 1); // Leave at least 1 for remaining parts + + for (let drop = minDrop; drop <= maxDrop; drop++) { + current.push(drop); + generatePartitions(total - drop, maxParts - 1, current, result); + current.pop(); + } +} + +// Validate a specific drop pattern +function isValidDropPattern( + board: Stack[][], + fromRow: number, + fromCol: number, + direction: string, + drops: number[], + topPieceType: PieceType +): boolean { + const [dr, dc] = DIRECTIONS[direction]; + let row = fromRow; + let col = fromCol; + const totalPicked = drops.reduce((a, b) => a + b, 0); + let remaining = totalPicked; + + for (let i = 0; i < drops.length; i++) { + row += dr; + col += dc; + + if (!isValidPosition(row, col)) return false; + + const targetStack = board[row][col]; + const targetTop = getTopPiece(targetStack); + + remaining -= drops[i]; + const isCapstoneOnTop = topPieceType === 'capstone' && remaining === 0; + + if (targetTop) { + if (targetTop.type === 'capstone') return false; + if (targetTop.type === 'standing') { + // Only capstone can flatten, and only when it's the last piece dropped + if (!isCapstoneOnTop) return false; + } + } + } + + return true; +} diff --git a/src/games/tak/index.ts b/src/games/tak/index.ts new file mode 100644 index 0000000..fb96eaf --- /dev/null +++ b/src/games/tak/index.ts @@ -0,0 +1,16 @@ +import type { GameDefinition } from '../../types'; +import { Tak as game } from './Game'; +import { Board } from './Board'; +import rules from './rules.md?raw'; + +export type { TakState } from './Game'; + +export const definition: GameDefinition = { + game, + Board, + name: 'Tak', + description: 'Abstract strategy game - build a road', + minPlayers: 2, + maxPlayers: 2, + rules, +}; diff --git a/src/games/tak/rules.md b/src/games/tak/rules.md new file mode 100644 index 0000000..681ec82 --- /dev/null +++ b/src/games/tak/rules.md @@ -0,0 +1,68 @@ +# Tak + +## Overview + +Tak is a two-player abstract strategy game designed by James Ernest and Patrick Rothfuss. Players compete to create a "road" - an unbroken line of their flat stones connecting opposite edges of the board. + +## Components + +- **Board**: 5x5 grid (standard size) +- **Flat Stones**: 21 per player - can be placed flat or standing +- **Capstone**: 1 per player - special powerful piece + +## Piece Types + +1. **Flat Stone (F)**: Basic piece. Counts toward roads. Can be stacked upon. +2. **Standing Stone / Wall (S)**: A flat stone placed on its side. Blocks movement and does NOT count toward roads. Cannot be stacked upon (except by Capstone). +3. **Capstone (C)**: The most powerful piece. Counts toward roads. Cannot be stacked upon. Can flatten walls when moving. + +## Setup + +The board starts empty. Player 1 (White) goes first, but with a twist: on the very first turn, each player places one of their opponent's flat stones. + +## Gameplay + +On your turn, you must do ONE of the following: + +### 1. Place a Piece +- Place a flat stone, standing stone, or your capstone on any empty square +- Stones can be placed flat (F) or standing (S) +- Capstones can only be placed once you've placed at least one flat stone + +### 2. Move a Stack +- Pick up a stack you control (your piece on top) +- Move in a straight line (orthogonally - not diagonally) +- Drop at least one piece on each square you pass through +- You can pick up 1 to 5 pieces (carry limit = board size) +- You cannot move onto or over walls or capstones +- **Exception**: A capstone CAN move onto a wall to flatten it (making it a flat stone), but only if the capstone is moving alone + +## Winning + +### Road Win +Create a continuous path of your flat stones and/or capstones connecting: +- Top edge to bottom edge, OR +- Left edge to right edge + +Roads can only travel orthogonally (not diagonally). The road doesn't need to be straight. + +### Flat Win +If the board fills up OR a player runs out of pieces: +- Count visible flat stones (top of stacks count) +- The player with more flat stones wins +- Capstones do NOT count for flat wins + +## Key Rules + +- Walls block all movement except capstone flattening +- Capstones cannot be covered or flattened +- You control a stack if your piece is on top +- The first two moves must place opponent's flat stones +- A road win takes priority and ends the game immediately + +## Strategy Tips + +- Control the center of the board +- Walls are powerful for blocking but don't help build roads +- Save your capstone - it's your most versatile piece +- Threatening multiple road completions forces your opponent to respond