diff --git a/src/games/mecha-duel/Board.tsx b/src/games/mecha-duel/Board.tsx new file mode 100644 index 0000000..8f309a7 --- /dev/null +++ b/src/games/mecha-duel/Board.tsx @@ -0,0 +1,339 @@ +import { useState } from 'react'; +import type { BoardProps } from 'boardgame.io/react'; +import type { MechaDuelState, PieceType, Piece } from './Game'; +import { + BOARD_SIZE, + indexToPos, + isValidCommitTarget, + isValidKingStep, +} from './Game'; + +type ActionType = 'commit' | 'execute' | 'kingStep' | null; + +const PIECE_SYMBOLS: Record = { + bishop: 'B', + rook: 'R', + knight: 'N', + queen: 'Q', +}; + +const CELL_SIZE = 50; + +export function Board({ G, ctx, moves, playerID }: BoardProps) { + const [selectedAction, setSelectedAction] = useState(null); + const [selectedPieceId, setSelectedPieceId] = useState(null); + const [selectedExecutePieces, setSelectedExecutePieces] = useState([]); + + const currentPlayerId = (playerID ?? ctx.currentPlayer) as '0' | '1'; + const isMyTurn = ctx.currentPlayer === currentPlayerId; + const kingPos = G.kings[currentPlayerId]; + + // Get player's pieces + const myPieces = G.pieces[currentPlayerId]; + const readyPieces = myPieces.filter((p) => p.state === 'ready'); + const committedPieces = G.committedPieces.filter((cp) => cp.playerId === currentPlayerId); + + // Selected piece for commit + const selectedPiece = selectedPieceId !== null + ? myPieces.find((p) => p.id === selectedPieceId) + : null; + + const handleCellClick = (index: number) => { + if (ctx.gameover || !isMyTurn) return; + + const pos = indexToPos(index); + + if (selectedAction === 'commit' && selectedPiece && kingPos) { + if (isValidCommitTarget(selectedPiece.type, kingPos, pos)) { + moves.commit(selectedPiece.id, pos); + setSelectedAction(null); + setSelectedPieceId(null); + } + } else if (selectedAction === 'kingStep' && kingPos) { + if (isValidKingStep(kingPos, pos)) { + moves.kingStep(pos); + setSelectedAction(null); + } + } + }; + + const handleExecute = () => { + if (selectedExecutePieces.length > 0) { + moves.execute(selectedExecutePieces); + setSelectedAction(null); + setSelectedExecutePieces([]); + } + }; + + const handlePass = () => { + moves.pass(); + setSelectedAction(null); + setSelectedPieceId(null); + setSelectedExecutePieces([]); + }; + + const toggleExecutePiece = (pieceId: number) => { + setSelectedExecutePieces((prev) => + prev.includes(pieceId) + ? prev.filter((id) => id !== pieceId) + : [...prev, pieceId] + ); + }; + + // Calculate valid cells for current action + const getValidCells = (): Set => { + const valid = new Set(); + if (!kingPos) return valid; + + if (selectedAction === 'commit' && selectedPiece) { + for (let i = 0; i < BOARD_SIZE * BOARD_SIZE; i++) { + const pos = indexToPos(i); + if (isValidCommitTarget(selectedPiece.type, kingPos, pos)) { + valid.add(i); + } + } + } else if (selectedAction === 'kingStep') { + for (let i = 0; i < BOARD_SIZE * BOARD_SIZE; i++) { + const pos = indexToPos(i); + if (isValidKingStep(kingPos, pos)) { + valid.add(i); + } + } + } + + return valid; + }; + + const validCells = getValidCells(); + + // Status message + let status: string; + if (ctx.gameover) { + status = `Winner: Player ${ctx.gameover.winner === '0' ? '1' : '2'}`; + } else if (!isMyTurn) { + status = `Waiting for Player ${ctx.currentPlayer === '0' ? '1' : '2'}`; + } else { + status = `Your turn (Player ${currentPlayerId === '0' ? '1' : '2'})`; + } + + // Render cell content + const renderCell = (index: number) => { + const content = G.cells[index]; + const pos = indexToPos(index); + const isValid = validCells.has(index); + + // Check for committed pieces at this position + const committedHere = G.committedPieces.filter( + (cp) => cp.target.x === pos.x && cp.target.y === pos.y + ); + + let cellContent = ''; + let cellColor = '#f5f5dc'; // default tan + let textColor = '#333'; + + if (content === 'king0') { + cellContent = 'K'; + cellColor = '#4a90d9'; + textColor = '#fff'; + } else if (content === 'king1') { + cellContent = 'K'; + cellColor = '#d94a4a'; + textColor = '#fff'; + } else if (content === 'pawn') { + cellContent = 'P'; + cellColor = '#888'; + textColor = '#fff'; + } + + // Show committed pieces + if (committedHere.length > 0) { + const cp = committedHere[0]; + cellContent = PIECE_SYMBOLS[cp.type]; + cellColor = cp.playerId === '0' ? '#7ab8e6' : '#e67a7a'; + textColor = '#000'; + } + + // Highlight valid cells + if (isValid) { + cellColor = '#90EE90'; + } + + const cellStyle: React.CSSProperties = { + width: `${CELL_SIZE}px`, + height: `${CELL_SIZE}px`, + fontSize: '24px', + fontWeight: 'bold', + border: '1px solid #333', + backgroundColor: cellColor, + color: textColor, + cursor: isValid ? 'pointer' : 'default', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }; + + return ( +
handleCellClick(index)} + > + {cellContent} +
+ ); + }; + + const buttonStyle: React.CSSProperties = { + padding: '10px 20px', + margin: '5px', + fontSize: '14px', + cursor: 'pointer', + }; + + const activeButtonStyle: React.CSSProperties = { + ...buttonStyle, + backgroundColor: '#4CAF50', + color: 'white', + }; + + const pieceButtonStyle = (piece: Piece, isSelected: boolean): React.CSSProperties => ({ + padding: '8px 12px', + margin: '3px', + fontSize: '12px', + cursor: piece.state === 'ready' ? 'pointer' : 'default', + backgroundColor: isSelected ? '#4CAF50' : piece.state === 'ready' ? '#fff' : '#ccc', + color: isSelected ? 'white' : '#333', + border: '1px solid #333', + opacity: piece.state === 'exhausted' ? 0.5 : 1, + }); + + // Render rows in reverse order (row 7 at top, row 0 at bottom) + const renderBoard = () => { + const rows = []; + for (let y = BOARD_SIZE - 1; y >= 0; y--) { + const row = []; + for (let x = 0; x < BOARD_SIZE; x++) { + row.push(renderCell(y * BOARD_SIZE + x)); + } + rows.push(
{row}
); + } + return rows; + }; + + return ( +
+

Mecha Duel

+

{status}

+ + {/* Board */} +
+ {renderBoard()} +
+ + {/* Action Buttons */} + {isMyTurn && !ctx.gameover && ( +
+

Actions

+ + + + +
+ )} + + {/* Commit: Select Piece */} + {selectedAction === 'commit' && isMyTurn && ( +
+

Select Piece to Commit

+ {readyPieces.map((piece) => ( + + ))} + {selectedPiece &&

Click a highlighted cell to commit {PIECE_SYMBOLS[selectedPiece.type]}

} +
+ )} + + {/* Execute: Select Pieces */} + {selectedAction === 'execute' && isMyTurn && ( +
+

Select Committed Pieces to Execute

+ {committedPieces.map((cp) => ( + + ))} + {selectedExecutePieces.length > 0 && ( + + )} +
+ )} + + {/* King Step instruction */} + {selectedAction === 'kingStep' && isMyTurn && ( +

Click a highlighted cell to move your King

+ )} + + {/* Piece Supply */} +
+

Your Pieces

+
+ Ready:{' '} + {myPieces.filter((p) => p.state === 'ready').map((p) => PIECE_SYMBOLS[p.type]).join(', ') || 'None'} +
+
+ Committed:{' '} + {myPieces.filter((p) => p.state === 'committed').map((p) => PIECE_SYMBOLS[p.type]).join(', ') || 'None'} +
+
+ Exhausted:{' '} + {myPieces.filter((p) => p.state === 'exhausted').map((p) => PIECE_SYMBOLS[p.type]).join(', ') || 'None'} +
+
+
+ ); +} diff --git a/src/games/mecha-duel/Game.test.ts b/src/games/mecha-duel/Game.test.ts new file mode 100644 index 0000000..ed0b89f --- /dev/null +++ b/src/games/mecha-duel/Game.test.ts @@ -0,0 +1,633 @@ +import { describe, it, expect } from 'vitest'; +import * as Game from './Game'; + +const { + posToIndex, + indexToPos, + isValidPos, + posEquals, + TOTAL_CELLS, + createInitialPieces, + createInitialBoard, +} = Game; + +type CellContent = Game.CellContent; +type MechaDuelState = Game.MechaDuelState; + +// ============================================================================= +// Position Helpers +// ============================================================================= + +describe('posToIndex', () => { + it('converts (0,0) to index 0', () => { + expect(posToIndex({ x: 0, y: 0 })).toBe(0); + }); + + it('converts (7,0) to index 7', () => { + expect(posToIndex({ x: 7, y: 0 })).toBe(7); + }); + + it('converts (0,1) to index 8', () => { + expect(posToIndex({ x: 0, y: 1 })).toBe(8); + }); + + it('converts (4,7) to index 60', () => { + expect(posToIndex({ x: 4, y: 7 })).toBe(60); + }); + + it('converts (7,7) to index 63', () => { + expect(posToIndex({ x: 7, y: 7 })).toBe(63); + }); +}); + +describe('indexToPos', () => { + it('converts index 0 to (0,0)', () => { + expect(indexToPos(0)).toEqual({ x: 0, y: 0 }); + }); + + it('converts index 7 to (7,0)', () => { + expect(indexToPos(7)).toEqual({ x: 7, y: 0 }); + }); + + it('converts index 8 to (0,1)', () => { + expect(indexToPos(8)).toEqual({ x: 0, y: 1 }); + }); + + it('converts index 60 to (4,7)', () => { + expect(indexToPos(60)).toEqual({ x: 4, y: 7 }); + }); + + it('converts index 63 to (7,7)', () => { + expect(indexToPos(63)).toEqual({ x: 7, y: 7 }); + }); +}); + +describe('isValidPos', () => { + it('returns true for (0,0)', () => { + expect(isValidPos({ x: 0, y: 0 })).toBe(true); + }); + + it('returns true for (7,7)', () => { + expect(isValidPos({ x: 7, y: 7 })).toBe(true); + }); + + it('returns true for center position (4,4)', () => { + expect(isValidPos({ x: 4, y: 4 })).toBe(true); + }); + + it('returns false for negative x', () => { + expect(isValidPos({ x: -1, y: 0 })).toBe(false); + }); + + it('returns false for negative y', () => { + expect(isValidPos({ x: 0, y: -1 })).toBe(false); + }); + + it('returns false for x >= BOARD_SIZE', () => { + expect(isValidPos({ x: 8, y: 0 })).toBe(false); + }); + + it('returns false for y >= BOARD_SIZE', () => { + expect(isValidPos({ x: 0, y: 8 })).toBe(false); + }); +}); + +describe('posEquals', () => { + it('returns true for identical positions', () => { + expect(posEquals({ x: 3, y: 4 }, { x: 3, y: 4 })).toBe(true); + }); + + it('returns false for different x', () => { + expect(posEquals({ x: 3, y: 4 }, { x: 4, y: 4 })).toBe(false); + }); + + it('returns false for different y', () => { + expect(posEquals({ x: 3, y: 4 }, { x: 3, y: 5 })).toBe(false); + }); +}); + +// ============================================================================= +// Board Setup +// ============================================================================= + +describe('createInitialBoard', () => { + it('creates a board with correct size', () => { + const { cells } = createInitialBoard(); + expect(cells.length).toBe(TOTAL_CELLS); + }); + + it('places player 0 king at (4,0)', () => { + const { cells, kings } = createInitialBoard(); + expect(kings['0']).toEqual({ x: 4, y: 0 }); + expect(cells[posToIndex({ x: 4, y: 0 })]).toBe('king0'); + }); + + it('places player 1 king at (4,7)', () => { + const { cells, kings } = createInitialBoard(); + expect(kings['1']).toEqual({ x: 4, y: 7 }); + expect(cells[posToIndex({ x: 4, y: 7 })]).toBe('king1'); + }); + + it('places pawns at scenario positions', () => { + const { cells } = createInitialBoard('simple'); + // Simple scenario has pawns at (2,2), (5,2), (2,5), (5,5) + expect(cells[posToIndex({ x: 2, y: 2 })]).toBe('pawn'); + expect(cells[posToIndex({ x: 5, y: 2 })]).toBe('pawn'); + expect(cells[posToIndex({ x: 2, y: 5 })]).toBe('pawn'); + expect(cells[posToIndex({ x: 5, y: 5 })]).toBe('pawn'); + }); +}); + +describe('createInitialPieces', () => { + it('creates 7 pieces', () => { + const pieces = createInitialPieces(); + expect(pieces.length).toBe(7); + }); + + it('creates 2 bishops', () => { + const pieces = createInitialPieces(); + const bishops = pieces.filter((p) => p.type === 'bishop'); + expect(bishops.length).toBe(2); + }); + + it('creates 2 rooks', () => { + const pieces = createInitialPieces(); + const rooks = pieces.filter((p) => p.type === 'rook'); + expect(rooks.length).toBe(2); + }); + + it('creates 2 knights', () => { + const pieces = createInitialPieces(); + const knights = pieces.filter((p) => p.type === 'knight'); + expect(knights.length).toBe(2); + }); + + it('creates 1 queen', () => { + const pieces = createInitialPieces(); + const queens = pieces.filter((p) => p.type === 'queen'); + expect(queens.length).toBe(1); + }); + + it('all pieces start in ready state', () => { + const pieces = createInitialPieces(); + expect(pieces.every((p) => p.state === 'ready')).toBe(true); + }); + + it('all pieces have unique ids', () => { + const pieces = createInitialPieces(); + const ids = pieces.map((p) => p.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(pieces.length); + }); +}); + +// ============================================================================= +// Commit Validation Tests +// These tests will fail until the functions are implemented in Phase 3 +// ============================================================================= + +describe('isValidBishopTarget', () => { + it('allows diagonal squares from King', () => { + const kingPos = { x: 4, y: 4 }; + expect(Game.isValidBishopTarget(kingPos, { x: 5, y: 5 })).toBe(true); + expect(Game.isValidBishopTarget(kingPos, { x: 3, y: 3 })).toBe(true); + expect(Game.isValidBishopTarget(kingPos, { x: 6, y: 2 })).toBe(true); + expect(Game.isValidBishopTarget(kingPos, { x: 2, y: 6 })).toBe(true); + }); + + it('rejects non-diagonal squares', () => { + const kingPos = { x: 4, y: 4 }; + expect(Game.isValidBishopTarget(kingPos, { x: 4, y: 5 })).toBe(false); + expect(Game.isValidBishopTarget(kingPos, { x: 5, y: 4 })).toBe(false); + expect(Game.isValidBishopTarget(kingPos, { x: 5, y: 6 })).toBe(false); + }); + + it('rejects same position as King', () => { + const kingPos = { x: 4, y: 4 }; + expect(Game.isValidBishopTarget(kingPos, { x: 4, y: 4 })).toBe(false); + }); +}); + +describe('isValidRookTarget', () => { + it('allows orthogonal squares from King', () => { + const kingPos = { x: 4, y: 4 }; + expect(Game.isValidRookTarget(kingPos, { x: 4, y: 7 })).toBe(true); + expect(Game.isValidRookTarget(kingPos, { x: 4, y: 0 })).toBe(true); + expect(Game.isValidRookTarget(kingPos, { x: 7, y: 4 })).toBe(true); + expect(Game.isValidRookTarget(kingPos, { x: 0, y: 4 })).toBe(true); + }); + + it('rejects diagonal squares', () => { + const kingPos = { x: 4, y: 4 }; + expect(Game.isValidRookTarget(kingPos, { x: 5, y: 5 })).toBe(false); + expect(Game.isValidRookTarget(kingPos, { x: 3, y: 3 })).toBe(false); + }); + + it('rejects same position as King', () => { + const kingPos = { x: 4, y: 4 }; + expect(Game.isValidRookTarget(kingPos, { x: 4, y: 4 })).toBe(false); + }); +}); + +describe('isValidKnightTarget', () => { + it('allows L-shaped squares from King', () => { + const kingPos = { x: 4, y: 4 }; + expect(Game.isValidKnightTarget(kingPos, { x: 5, y: 6 })).toBe(true); + expect(Game.isValidKnightTarget(kingPos, { x: 6, y: 5 })).toBe(true); + expect(Game.isValidKnightTarget(kingPos, { x: 6, y: 3 })).toBe(true); + expect(Game.isValidKnightTarget(kingPos, { x: 5, y: 2 })).toBe(true); + expect(Game.isValidKnightTarget(kingPos, { x: 3, y: 2 })).toBe(true); + expect(Game.isValidKnightTarget(kingPos, { x: 2, y: 3 })).toBe(true); + expect(Game.isValidKnightTarget(kingPos, { x: 2, y: 5 })).toBe(true); + expect(Game.isValidKnightTarget(kingPos, { x: 3, y: 6 })).toBe(true); + }); + + it('rejects non-L-shaped squares', () => { + const kingPos = { x: 4, y: 4 }; + expect(Game.isValidKnightTarget(kingPos, { x: 5, y: 5 })).toBe(false); + expect(Game.isValidKnightTarget(kingPos, { x: 4, y: 5 })).toBe(false); + expect(Game.isValidKnightTarget(kingPos, { x: 6, y: 6 })).toBe(false); + }); + + it('rejects same position as King', () => { + const kingPos = { x: 4, y: 4 }; + expect(Game.isValidKnightTarget(kingPos, { x: 4, y: 4 })).toBe(false); + }); +}); + +describe('isValidQueenTarget', () => { + it('allows diagonal squares from King', () => { + const kingPos = { x: 4, y: 4 }; + expect(Game.isValidQueenTarget(kingPos, { x: 5, y: 5 })).toBe(true); + expect(Game.isValidQueenTarget(kingPos, { x: 7, y: 1 })).toBe(true); + }); + + it('allows orthogonal squares from King', () => { + const kingPos = { x: 4, y: 4 }; + expect(Game.isValidQueenTarget(kingPos, { x: 4, y: 7 })).toBe(true); + expect(Game.isValidQueenTarget(kingPos, { x: 0, y: 4 })).toBe(true); + }); + + it('rejects non-queen-move squares', () => { + const kingPos = { x: 4, y: 4 }; + expect(Game.isValidQueenTarget(kingPos, { x: 5, y: 6 })).toBe(false); + expect(Game.isValidQueenTarget(kingPos, { x: 6, y: 7 })).toBe(false); + }); + + it('rejects same position as King', () => { + const kingPos = { x: 4, y: 4 }; + expect(Game.isValidQueenTarget(kingPos, { x: 4, y: 4 })).toBe(false); + }); +}); + +// ============================================================================= +// Strike Resolution Tests +// ============================================================================= + +describe('raycast', () => { + function createEmptyBoard(): CellContent[] { + return Array(TOTAL_CELLS).fill(null); + } + + it('returns null when no obstacle in path', () => { + const cells = createEmptyBoard(); + const from = { x: 4, y: 0 }; + const toward = { x: 4, y: 7 }; + expect(Game.raycast(from, toward, cells)).toBe(null); + }); + + it('finds first pawn in path', () => { + const cells = createEmptyBoard(); + cells[posToIndex({ x: 4, y: 3 })] = 'pawn'; + cells[posToIndex({ x: 4, y: 5 })] = 'pawn'; + const from = { x: 4, y: 0 }; + const toward = { x: 4, y: 7 }; + const result = Game.raycast(from, toward, cells); + expect(result).toEqual({ x: 4, y: 3 }); + }); + + it('finds opponent king in path', () => { + const cells = createEmptyBoard(); + cells[posToIndex({ x: 4, y: 5 })] = 'king1'; + const from = { x: 4, y: 0 }; + const toward = { x: 4, y: 7 }; + const result = Game.raycast(from, toward, cells); + expect(result).toEqual({ x: 4, y: 5 }); + }); + + it('works for diagonal directions', () => { + const cells = createEmptyBoard(); + cells[posToIndex({ x: 6, y: 6 })] = 'pawn'; + const from = { x: 4, y: 4 }; + const toward = { x: 7, y: 7 }; + const result = Game.raycast(from, toward, cells); + expect(result).toEqual({ x: 6, y: 6 }); + }); + + it('does not include starting position', () => { + const cells = createEmptyBoard(); + cells[posToIndex({ x: 4, y: 4 })] = 'king0'; + const from = { x: 4, y: 4 }; + const toward = { x: 4, y: 7 }; + const result = Game.raycast(from, toward, cells); + expect(result).toBe(null); + }); +}); + +describe('resolveStrike', () => { + function createTestState(): MechaDuelState { + const { cells, kings } = createInitialBoard(); + return { + cells, + kings, + pieces: { + '0': createInitialPieces(), + '1': createInitialPieces(), + }, + committedPieces: [], + }; + } + + it('destroys first pawn in strike path', () => { + const state = createTestState(); + const pawnPos = { x: 4, y: 3 }; + state.cells[posToIndex(pawnPos)] = 'pawn'; + + const kingPos = state.kings['0']!; + const targetPos = { x: 4, y: 7 }; + const result = Game.resolveStrike(state, kingPos, targetPos); + + expect(result.hit).toEqual(pawnPos); + expect(result.destroyed).toBe('pawn'); + }); + + it('destroys opponent king in strike path', () => { + const state = createTestState(); + const kingPos = state.kings['0']!; + const targetPos = { x: 4, y: 7 }; + + const result = Game.resolveStrike(state, kingPos, targetPos); + + expect(result.hit).toEqual({ x: 4, y: 7 }); + expect(result.destroyed).toBe('king1'); + }); + + it('returns null hit when no target in path', () => { + const state = createTestState(); + const kingPos = state.kings['0']!; + const targetPos = { x: 0, y: 0 }; + + const result = Game.resolveStrike(state, kingPos, targetPos); + + expect(result.hit).toBe(null); + expect(result.destroyed).toBe(null); + }); +}); + +// ============================================================================= +// Knight Resolution Tests +// ============================================================================= + +describe('isValidKnightJump', () => { + it('returns true for valid L-shaped jump', () => { + expect(Game.isValidKnightJump({ x: 4, y: 4 }, { x: 5, y: 6 })).toBe(true); + expect(Game.isValidKnightJump({ x: 4, y: 4 }, { x: 6, y: 5 })).toBe(true); + }); + + it('returns false for non-L-shaped positions', () => { + expect(Game.isValidKnightJump({ x: 4, y: 4 }, { x: 5, y: 5 })).toBe(false); + expect(Game.isValidKnightJump({ x: 4, y: 4 }, { x: 4, y: 6 })).toBe(false); + }); + + it('returns false for same position', () => { + expect(Game.isValidKnightJump({ x: 4, y: 4 }, { x: 4, y: 4 })).toBe(false); + }); +}); + +describe('resolveKnight', () => { + function createTestState(): MechaDuelState { + const { cells, kings } = createInitialBoard(); + return { + cells, + kings, + pieces: { + '0': createInitialPieces(), + '1': createInitialPieces(), + }, + committedPieces: [], + }; + } + + it('moves king to target square', () => { + const state = createTestState(); + const targetPos = { x: 5, y: 2 }; + + const result = Game.resolveKnight(state, '0', targetPos); + + expect(result.success).toBe(true); + expect(result.newKingPos).toEqual(targetPos); + }); + + it('fails if target is not valid knight jump', () => { + const state = createTestState(); + const targetPos = { x: 5, y: 5 }; + + const result = Game.resolveKnight(state, '0', targetPos); + + expect(result.success).toBe(false); + }); + + it('destroys pawn at landing square', () => { + const state = createTestState(); + const targetPos = { x: 5, y: 2 }; + state.cells[posToIndex(targetPos)] = 'pawn'; + + const result = Game.resolveKnight(state, '0', targetPos); + + expect(result.success).toBe(true); + expect(result.destroyed).toBe('pawn'); + }); + + it('destroys opponent king at landing square', () => { + const state = createTestState(); + state.kings['1'] = { x: 5, y: 2 }; + state.cells[posToIndex({ x: 4, y: 7 })] = null; + state.cells[posToIndex({ x: 5, y: 2 })] = 'king1'; + + const result = Game.resolveKnight(state, '0', { x: 5, y: 2 }); + + expect(result.success).toBe(true); + expect(result.destroyed).toBe('king1'); + }); +}); + +// ============================================================================= +// KingStep Resolution Tests +// ============================================================================= + +describe('isValidKingStep', () => { + it('allows orthogonal moves', () => { + expect(Game.isValidKingStep({ x: 4, y: 4 }, { x: 4, y: 5 })).toBe(true); + expect(Game.isValidKingStep({ x: 4, y: 4 }, { x: 5, y: 4 })).toBe(true); + expect(Game.isValidKingStep({ x: 4, y: 4 }, { x: 4, y: 3 })).toBe(true); + expect(Game.isValidKingStep({ x: 4, y: 4 }, { x: 3, y: 4 })).toBe(true); + }); + + it('allows diagonal moves', () => { + expect(Game.isValidKingStep({ x: 4, y: 4 }, { x: 5, y: 5 })).toBe(true); + expect(Game.isValidKingStep({ x: 4, y: 4 }, { x: 3, y: 3 })).toBe(true); + expect(Game.isValidKingStep({ x: 4, y: 4 }, { x: 5, y: 3 })).toBe(true); + expect(Game.isValidKingStep({ x: 4, y: 4 }, { x: 3, y: 5 })).toBe(true); + }); + + it('rejects moves more than 1 square', () => { + expect(Game.isValidKingStep({ x: 4, y: 4 }, { x: 4, y: 6 })).toBe(false); + expect(Game.isValidKingStep({ x: 4, y: 4 }, { x: 6, y: 6 })).toBe(false); + }); + + it('rejects same position', () => { + expect(Game.isValidKingStep({ x: 4, y: 4 }, { x: 4, y: 4 })).toBe(false); + }); + + it('rejects moves off board', () => { + expect(Game.isValidKingStep({ x: 0, y: 0 }, { x: -1, y: 0 })).toBe(false); + expect(Game.isValidKingStep({ x: 7, y: 7 }, { x: 8, y: 7 })).toBe(false); + }); +}); + +describe('resolveKingStep', () => { + function createTestState(): MechaDuelState { + const { cells, kings } = createInitialBoard(); + return { + cells, + kings, + pieces: { + '0': createInitialPieces(), + '1': createInitialPieces(), + }, + committedPieces: [], + }; + } + + it('moves king one square', () => { + const state = createTestState(); + const targetPos = { x: 4, y: 1 }; + + const result = Game.resolveKingStep(state, '0', targetPos); + + expect(result.success).toBe(true); + expect(result.newKingPos).toEqual(targetPos); + }); + + it('destroys pawn when entering pawn square', () => { + const state = createTestState(); + const targetPos = { x: 4, y: 1 }; + state.cells[posToIndex(targetPos)] = 'pawn'; + + const result = Game.resolveKingStep(state, '0', targetPos); + + expect(result.success).toBe(true); + expect(result.destroyed).toBe('pawn'); + }); + + it('destroys opponent king when stepping on', () => { + const state = createTestState(); + state.kings['1'] = { x: 4, y: 1 }; + state.cells[posToIndex({ x: 4, y: 7 })] = null; + state.cells[posToIndex({ x: 4, y: 1 })] = 'king1'; + + const result = Game.resolveKingStep(state, '0', { x: 4, y: 1 }); + + expect(result.success).toBe(true); + expect(result.destroyed).toBe('king1'); + }); + + it('fails for invalid step distance', () => { + const state = createTestState(); + const targetPos = { x: 4, y: 2 }; + + const result = Game.resolveKingStep(state, '0', targetPos); + + expect(result.success).toBe(false); + }); +}); + +// ============================================================================= +// Piece State Transition Tests +// ============================================================================= + +describe('piece state transitions', () => { + it('transitions Ready to Committed', () => { + const piece = { type: 'bishop' as const, state: 'ready' as const, id: 0 }; + const newState = Game.transitionPieceState(piece, 'committed'); + expect(newState).toBe('committed'); + }); + + it('transitions Committed to Exhausted', () => { + const piece = { type: 'bishop' as const, state: 'committed' as const, id: 0 }; + const newState = Game.transitionPieceState(piece, 'exhausted'); + expect(newState).toBe('exhausted'); + }); + + describe('refreshPieces (pass action)', () => { + it('changes Committed pieces to Exhausted', () => { + const pieces = [ + { type: 'bishop' as const, state: 'committed' as const, id: 0 }, + { type: 'rook' as const, state: 'ready' as const, id: 1 }, + ]; + const refreshed = Game.refreshPieces(pieces); + expect(refreshed[0].state).toBe('exhausted'); + }); + + it('changes Exhausted pieces to Ready', () => { + const pieces = [ + { type: 'bishop' as const, state: 'exhausted' as const, id: 0 }, + { type: 'rook' as const, state: 'ready' as const, id: 1 }, + ]; + const refreshed = Game.refreshPieces(pieces); + expect(refreshed[0].state).toBe('ready'); + }); + + it('keeps Ready pieces as Ready', () => { + const pieces = [{ type: 'bishop' as const, state: 'ready' as const, id: 0 }]; + const refreshed = Game.refreshPieces(pieces); + expect(refreshed[0].state).toBe('ready'); + }); + }); +}); + +// ============================================================================= +// Win Condition Tests +// ============================================================================= + +describe('checkWinCondition', () => { + function createTestState(): MechaDuelState { + const { cells, kings } = createInitialBoard(); + return { + cells, + kings, + pieces: { + '0': createInitialPieces(), + '1': createInitialPieces(), + }, + committedPieces: [], + }; + } + + it('returns null when both kings alive', () => { + const state = createTestState(); + expect(Game.checkWinCondition(state)).toBe(null); + }); + + it('returns player 1 as winner when player 0 king destroyed', () => { + const state = createTestState(); + state.kings['0'] = null; + expect(Game.checkWinCondition(state)).toEqual({ winner: '1' }); + }); + + it('returns player 0 as winner when player 1 king destroyed', () => { + const state = createTestState(); + state.kings['1'] = null; + expect(Game.checkWinCondition(state)).toEqual({ winner: '0' }); + }); +}); diff --git a/src/games/mecha-duel/Game.ts b/src/games/mecha-duel/Game.ts new file mode 100644 index 0000000..d0bf343 --- /dev/null +++ b/src/games/mecha-duel/Game.ts @@ -0,0 +1,633 @@ +import type { Game } from 'boardgame.io'; +import { getScenario, defaultScenarioId } from './scenarios'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface Position { + x: number; + y: number; +} + +export type PieceType = 'bishop' | 'rook' | 'knight' | 'queen'; + +export type PieceState = 'ready' | 'committed' | 'exhausted'; + +export interface Piece { + type: PieceType; + state: PieceState; + id: number; +} + +export interface CommittedPiece { + pieceId: number; + playerId: string; + type: PieceType; + target: Position; +} + +export type CellContent = 'pawn' | 'king0' | 'king1' | null; + +export interface MechaDuelState { + cells: CellContent[]; + kings: { + '0': Position | null; + '1': Position | null; + }; + pieces: { + '0': Piece[]; + '1': Piece[]; + }; + committedPieces: CommittedPiece[]; +} + +// ============================================================================= +// Constants +// ============================================================================= + +export const BOARD_SIZE = 8; +export const TOTAL_CELLS = BOARD_SIZE * BOARD_SIZE; + +export const DIRECTIONS = { + orthogonal: [ + { x: 0, y: 1 }, + { x: 0, y: -1 }, + { x: 1, y: 0 }, + { x: -1, y: 0 }, + ], + diagonal: [ + { x: 1, y: 1 }, + { x: 1, y: -1 }, + { x: -1, y: 1 }, + { x: -1, y: -1 }, + ], + all: [ + { x: 0, y: 1 }, + { x: 0, y: -1 }, + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 1, y: 1 }, + { x: 1, y: -1 }, + { x: -1, y: 1 }, + { x: -1, y: -1 }, + ], +}; + +export const KNIGHT_JUMPS = [ + { x: 1, y: 2 }, + { x: 2, y: 1 }, + { x: 2, y: -1 }, + { x: 1, y: -2 }, + { x: -1, y: -2 }, + { x: -2, y: -1 }, + { x: -2, y: 1 }, + { x: -1, y: 2 }, +]; + +// ============================================================================= +// Position Helpers +// ============================================================================= + +export function posToIndex(pos: Position): number { + return pos.y * BOARD_SIZE + pos.x; +} + +export function indexToPos(index: number): Position { + return { + x: index % BOARD_SIZE, + y: Math.floor(index / BOARD_SIZE), + }; +} + +export function isValidPos(pos: Position): boolean { + return pos.x >= 0 && pos.x < BOARD_SIZE && pos.y >= 0 && pos.y < BOARD_SIZE; +} + +export function posEquals(a: Position, b: Position): boolean { + return a.x === b.x && a.y === b.y; +} + +// ============================================================================= +// Piece Helpers +// ============================================================================= + +export function createInitialPieces(): Piece[] { + return [ + { type: 'bishop', state: 'ready', id: 0 }, + { type: 'bishop', state: 'ready', id: 1 }, + { type: 'rook', state: 'ready', id: 2 }, + { type: 'rook', state: 'ready', id: 3 }, + { type: 'knight', state: 'ready', id: 4 }, + { type: 'knight', state: 'ready', id: 5 }, + { type: 'queen', state: 'ready', id: 6 }, + ]; +} + +// ============================================================================= +// Board Setup +// ============================================================================= + +export function createInitialBoard(scenarioId: string = defaultScenarioId): { + cells: CellContent[]; + kings: { '0': Position; '1': Position }; +} { + const scenario = getScenario(scenarioId); + const cells: CellContent[] = Array(TOTAL_CELLS).fill(null); + + // Place pawns + for (const pawnPos of scenario.pawns) { + cells[posToIndex(pawnPos)] = 'pawn'; + } + + // Place kings on the cells + cells[posToIndex(scenario.kingPositions['0'])] = 'king0'; + cells[posToIndex(scenario.kingPositions['1'])] = 'king1'; + + return { + cells, + kings: { + '0': { ...scenario.kingPositions['0'] }, + '1': { ...scenario.kingPositions['1'] }, + }, + }; +} + +// ============================================================================= +// Commit Validation +// ============================================================================= + +export function isValidBishopTarget(kingPos: Position, target: Position): boolean { + if (posEquals(kingPos, target)) return false; + const dx = Math.abs(target.x - kingPos.x); + const dy = Math.abs(target.y - kingPos.y); + return dx === dy && dx > 0; +} + +export function isValidRookTarget(kingPos: Position, target: Position): boolean { + if (posEquals(kingPos, target)) return false; + return (kingPos.x === target.x || kingPos.y === target.y); +} + +export function isValidKnightTarget(kingPos: Position, target: Position): boolean { + if (posEquals(kingPos, target)) return false; + const dx = Math.abs(target.x - kingPos.x); + const dy = Math.abs(target.y - kingPos.y); + return (dx === 1 && dy === 2) || (dx === 2 && dy === 1); +} + +export function isValidQueenTarget(kingPos: Position, target: Position): boolean { + return isValidBishopTarget(kingPos, target) || isValidRookTarget(kingPos, target); +} + +export function isValidCommitTarget( + pieceType: PieceType, + kingPos: Position, + target: Position +): boolean { + switch (pieceType) { + case 'bishop': + return isValidBishopTarget(kingPos, target); + case 'rook': + return isValidRookTarget(kingPos, target); + case 'knight': + return isValidKnightTarget(kingPos, target); + case 'queen': + return isValidQueenTarget(kingPos, target); + } +} + +// ============================================================================= +// Raycast & Strike Resolution +// ============================================================================= + +function getDirection(from: Position, toward: Position): Position | null { + const dx = toward.x - from.x; + const dy = toward.y - from.y; + + if (dx === 0 && dy === 0) return null; + + // Normalize to unit direction + const absDx = Math.abs(dx); + const absDy = Math.abs(dy); + + if (dx === 0) { + return { x: 0, y: dy > 0 ? 1 : -1 }; + } + if (dy === 0) { + return { x: dx > 0 ? 1 : -1, y: 0 }; + } + if (absDx === absDy) { + return { x: dx > 0 ? 1 : -1, y: dy > 0 ? 1 : -1 }; + } + + // Not a valid line direction + return null; +} + +export function raycast( + from: Position, + toward: Position, + cells: CellContent[] +): Position | null { + const dir = getDirection(from, toward); + if (!dir) return null; + + let current = { x: from.x + dir.x, y: from.y + dir.y }; + + while (isValidPos(current)) { + const content = cells[posToIndex(current)]; + if (content !== null) { + return current; + } + current = { x: current.x + dir.x, y: current.y + dir.y }; + } + + return null; +} + +export interface StrikeResult { + hit: Position | null; + destroyed: CellContent; +} + +export function resolveStrike( + state: MechaDuelState, + kingPos: Position, + targetPos: Position +): StrikeResult { + const hitPos = raycast(kingPos, targetPos, state.cells); + if (!hitPos) { + return { hit: null, destroyed: null }; + } + + const destroyed = state.cells[posToIndex(hitPos)]; + return { hit: hitPos, destroyed }; +} + +// ============================================================================= +// Knight Resolution +// ============================================================================= + +export function isValidKnightJump(from: Position, to: Position): boolean { + return isValidKnightTarget(from, to); +} + +export interface KnightResult { + success: boolean; + newKingPos?: Position; + destroyed?: CellContent; +} + +export function resolveKnight( + state: MechaDuelState, + playerId: string, + targetPos: Position +): KnightResult { + const kingPos = state.kings[playerId as '0' | '1']; + if (!kingPos) { + return { success: false }; + } + + if (!isValidKnightJump(kingPos, targetPos)) { + return { success: false }; + } + + const destroyed = state.cells[posToIndex(targetPos)]; + return { + success: true, + newKingPos: targetPos, + destroyed: destroyed ?? undefined, + }; +} + +// ============================================================================= +// KingStep Resolution +// ============================================================================= + +export function isValidKingStep(from: Position, to: Position): boolean { + if (!isValidPos(to)) return false; + if (posEquals(from, to)) return false; + + const dx = Math.abs(to.x - from.x); + const dy = Math.abs(to.y - from.y); + + return dx <= 1 && dy <= 1; +} + +export interface KingStepResult { + success: boolean; + newKingPos?: Position; + destroyed?: CellContent; +} + +export function resolveKingStep( + state: MechaDuelState, + playerId: string, + targetPos: Position +): KingStepResult { + const kingPos = state.kings[playerId as '0' | '1']; + if (!kingPos) { + return { success: false }; + } + + if (!isValidKingStep(kingPos, targetPos)) { + return { success: false }; + } + + const destroyed = state.cells[posToIndex(targetPos)]; + return { + success: true, + newKingPos: targetPos, + destroyed: destroyed ?? undefined, + }; +} + +// ============================================================================= +// Piece State Transitions +// ============================================================================= + +export function transitionPieceState( + _piece: Piece, + newState: PieceState +): PieceState { + return newState; +} + +export function refreshPieces(pieces: Piece[]): Piece[] { + return pieces.map((piece) => { + if (piece.state === 'committed') { + return { ...piece, state: 'exhausted' as PieceState }; + } + if (piece.state === 'exhausted') { + return { ...piece, state: 'ready' as PieceState }; + } + return piece; + }); +} + +function exhaustCommittedPieces(pieces: Piece[]): Piece[] { + return pieces.map((piece) => { + if (piece.state === 'committed') { + return { ...piece, state: 'exhausted' as PieceState }; + } + return piece; + }); +} + +// ============================================================================= +// Win Conditions +// ============================================================================= + +export function checkWinCondition( + state: MechaDuelState +): { winner: string } | null { + if (state.kings['0'] === null) { + return { winner: '1' }; + } + if (state.kings['1'] === null) { + return { winner: '0' }; + } + return null; +} + +// ============================================================================= +// Game Definition +// ============================================================================= + +export const MechaDuel: Game = { + name: 'mecha-duel', + + setup: () => { + const { cells, kings } = createInitialBoard(); + return { + cells, + kings, + pieces: { + '0': createInitialPieces(), + '1': createInitialPieces(), + }, + committedPieces: [], + }; + }, + + turn: { + minMoves: 1, + maxMoves: 1, + }, + + moves: { + commit: ({ G, playerID }, pieceId: number, target: Position) => { + if (!playerID) return; + const pid = playerID as '0' | '1'; + const kingPos = G.kings[pid]; + if (!kingPos) return; + + const piece = G.pieces[pid].find((p) => p.id === pieceId); + if (!piece || piece.state !== 'ready') return; + + if (!isValidCommitTarget(piece.type, kingPos, target)) return; + + // Update piece state + piece.state = 'committed'; + + // Add to committed pieces + G.committedPieces.push({ + pieceId, + playerId: playerID, + type: piece.type, + target, + }); + }, + + execute: ({ G, playerID }, pieceIds: number[]) => { + if (!playerID) return; + if (pieceIds.length === 0) return; + + const pid = playerID as '0' | '1'; + const opponentId = pid === '0' ? '1' : '0'; + + // Get all committed pieces for this player + const playerCommitted = G.committedPieces.filter( + (cp) => cp.playerId === playerID + ); + + // Validate all pieceIds are committed + const toExecute = pieceIds + .map((id) => playerCommitted.find((cp) => cp.pieceId === id)) + .filter((cp): cp is CommittedPiece => cp !== undefined); + + if (toExecute.length !== pieceIds.length) return; + + // Separate strikes and knights + const strikes = toExecute.filter((cp) => cp.type !== 'knight'); + const knights = toExecute.filter((cp) => cp.type === 'knight'); + + // Resolve all strikes first (simultaneously) + for (const strike of strikes) { + const kingPos = G.kings[pid]; + if (!kingPos) continue; + + const result = resolveStrike(G, kingPos, strike.target); + if (result.hit) { + // Destroy the target + G.cells[posToIndex(result.hit)] = null; + // Check if opponent king was destroyed + if (result.destroyed === `king${opponentId}`) { + G.kings[opponentId] = null; + } + } + } + + // Resolve knights sequentially + for (const knight of knights) { + const kingPos = G.kings[pid]; + if (!kingPos) continue; + + const result = resolveKnight(G, playerID, knight.target); + if (result.success && result.newKingPos) { + // Clear old king position + G.cells[posToIndex(kingPos)] = null; + // Check if landing on opponent king + if (result.destroyed === `king${opponentId}`) { + G.kings[opponentId] = null; + } else { + // Clear any pawn at destination + G.cells[posToIndex(result.newKingPos)] = null; + } + // Move king + G.kings[pid] = result.newKingPos; + G.cells[posToIndex(result.newKingPos)] = `king${pid}` as CellContent; + } + } + + // Mark executed pieces as exhausted + for (const pieceId of pieceIds) { + const piece = G.pieces[pid].find((p) => p.id === pieceId); + if (piece) { + piece.state = 'exhausted'; + } + } + + // Mark remaining committed pieces as exhausted + const executedIds = new Set(pieceIds); + for (const cp of playerCommitted) { + if (!executedIds.has(cp.pieceId)) { + const piece = G.pieces[pid].find((p) => p.id === cp.pieceId); + if (piece) { + piece.state = 'exhausted'; + } + } + } + + // Remove all player's committed pieces + G.committedPieces = G.committedPieces.filter( + (cp) => cp.playerId !== playerID + ); + }, + + kingStep: ({ G, playerID }, target: Position) => { + if (!playerID) return; + const pid = playerID as '0' | '1'; + const opponentId = pid === '0' ? '1' : '0'; + const kingPos = G.kings[pid]; + if (!kingPos) return; + + const result = resolveKingStep(G, playerID, target); + if (!result.success || !result.newKingPos) return; + + // Clear old king position + G.cells[posToIndex(kingPos)] = null; + + // Check if stepping on opponent king + if (result.destroyed === `king${opponentId}`) { + G.kings[opponentId] = null; + } + // Clear any pawn at destination + G.cells[posToIndex(result.newKingPos)] = null; + + // Move king + G.kings[pid] = result.newKingPos; + G.cells[posToIndex(result.newKingPos)] = `king${pid}` as CellContent; + + // All committed pieces become exhausted + G.pieces[pid] = exhaustCommittedPieces(G.pieces[pid]); + G.committedPieces = G.committedPieces.filter( + (cp) => cp.playerId !== playerID + ); + }, + + pass: ({ G, playerID }) => { + if (!playerID) return; + const pid = playerID as '0' | '1'; + + // Refresh pieces: committed -> exhausted, exhausted -> ready + G.pieces[pid] = refreshPieces(G.pieces[pid]); + + // Remove all player's committed pieces from board + G.committedPieces = G.committedPieces.filter( + (cp) => cp.playerId !== playerID + ); + }, + }, + + endIf: ({ G }) => { + return checkWinCondition(G); + }, + + ai: { + enumerate: (G: MechaDuelState, ctx) => { + const moves: Array<{ move: string; args: unknown[] }> = []; + const playerID = ctx.currentPlayer as '0' | '1'; + const kingPos = G.kings[playerID]; + + if (!kingPos) return moves; + + const myPieces = G.pieces[playerID]; + const readyPieces = myPieces.filter((p) => p.state === 'ready'); + const committedPieces = G.committedPieces.filter( + (cp) => cp.playerId === playerID + ); + + // Enumerate all valid Commit moves + for (const piece of readyPieces) { + for (let y = 0; y < BOARD_SIZE; y++) { + for (let x = 0; x < BOARD_SIZE; x++) { + const target = { x, y }; + if (isValidCommitTarget(piece.type, kingPos, target)) { + moves.push({ move: 'commit', args: [piece.id, target] }); + } + } + } + } + + // Enumerate Execute combinations (non-empty subsets of committed pieces) + if (committedPieces.length > 0) { + const pieceIds = committedPieces.map((cp) => cp.pieceId); + // Generate all non-empty subsets + for (let mask = 1; mask < (1 << pieceIds.length); mask++) { + const subset: number[] = []; + for (let i = 0; i < pieceIds.length; i++) { + if (mask & (1 << i)) { + subset.push(pieceIds[i]); + } + } + moves.push({ move: 'execute', args: [subset] }); + } + } + + // Enumerate KingStep moves (8 directions) + for (const dir of DIRECTIONS.all) { + const target = { x: kingPos.x + dir.x, y: kingPos.y + dir.y }; + if (isValidKingStep(kingPos, target)) { + moves.push({ move: 'kingStep', args: [target] }); + } + } + + // Pass is always valid + moves.push({ move: 'pass', args: [] }); + + return moves; + }, + }, +}; diff --git a/src/games/mecha-duel/index.ts b/src/games/mecha-duel/index.ts new file mode 100644 index 0000000..9c29dc0 --- /dev/null +++ b/src/games/mecha-duel/index.ts @@ -0,0 +1,2 @@ +export { MechaDuel as game, type MechaDuelState } from './Game'; +export { Board } from './Board'; diff --git a/src/games/mecha-duel/scenarios.ts b/src/games/mecha-duel/scenarios.ts new file mode 100644 index 0000000..bcdbe15 --- /dev/null +++ b/src/games/mecha-duel/scenarios.ts @@ -0,0 +1,51 @@ +import type { Position } from './Game'; + +export interface Scenario { + name: string; + description: string; + pawns: Position[]; + kingPositions: { + '0': Position; + '1': Position; + }; +} + +/** + * Simple scenario with minimal symmetric pawn placement. + * Good for learning the game mechanics. + * + * Layout (8x8): + * Row 7: [ ][ ][ ][ ][K1][ ][ ][ ] + * Row 6: [ ][ ][ ][ ][ ][ ][ ][ ] + * Row 5: [ ][ ][P][ ][ ][P][ ][ ] + * Row 4: [ ][ ][ ][ ][ ][ ][ ][ ] + * Row 3: [ ][ ][ ][ ][ ][ ][ ][ ] + * Row 2: [ ][ ][P][ ][ ][P][ ][ ] + * Row 1: [ ][ ][ ][ ][ ][ ][ ][ ] + * Row 0: [ ][ ][ ][ ][K0][ ][ ][ ] + * 0 1 2 3 4 5 6 7 + */ +export const simpleScenario: Scenario = { + name: 'Simple', + description: 'A basic symmetric layout with minimal pawns', + pawns: [ + { x: 2, y: 2 }, + { x: 5, y: 2 }, + { x: 2, y: 5 }, + { x: 5, y: 5 }, + ], + kingPositions: { + '0': { x: 4, y: 0 }, + '1': { x: 4, y: 7 }, + }, +}; + +export const scenarios: Record = { + simple: simpleScenario, +}; + +export const defaultScenarioId = 'simple'; + +export function getScenario(id: string): Scenario { + return scenarios[id] ?? scenarios[defaultScenarioId]; +} diff --git a/src/registry.ts b/src/registry.ts index abcba54..c85a215 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -8,6 +8,9 @@ import TicTacToeRules from './games/tic-tac-toe/rules.md?raw'; import * as RockPaperScissors from './games/rock-paper-scissors'; import RockPaperScissorsRules from './games/rock-paper-scissors/rules.md?raw'; +import * as MechaDuel from './games/mecha-duel'; +import MechaDuelRules from './games/mecha-duel/rules.md?raw'; + export interface GameDefinition { game: Game; Board: ComponentType; @@ -54,6 +57,15 @@ export const games: Record = { maxPlayers: 2, rules: RockPaperScissorsRules, }, + 'mecha-duel': { + game: MechaDuel.game, + Board: MechaDuel.Board, + name: 'Mecha Duel', + description: 'Strategic mecha combat with committed attacks', + minPlayers: 2, + maxPlayers: 2, + rules: MechaDuelRules, + }, }; export const gameIds = Object.keys(games);