Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 61 additions & 26 deletions src/games/mecha-duel/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,58 +127,93 @@ export function Board({ G, ctx, moves, playerID }: BoardProps<MechaDuelState>) {
(cp) => cp.target.x === pos.x && cp.target.y === pos.y
);

let cellContent = '';
let cellColor = '#f5f5dc'; // default tan
let textColor = '#333';
// Determine underlying content (pawn or king)
let underlyingSymbol = '';
let underlyingColor = '';

if (content === 'king0') {
cellContent = 'K';
cellColor = '#4a90d9';
textColor = '#fff';
underlyingSymbol = 'K';
underlyingColor = '#4a90d9'; // blue
} else if (content === 'king1') {
cellContent = 'K';
cellColor = '#d94a4a';
textColor = '#fff';
} else if (content === 'pawn') {
cellContent = 'P';
cellColor = '#888';
textColor = '#fff';
underlyingSymbol = 'K';
underlyingColor = '#d94a4a'; // red
} else if (content === 'pawn0') {
underlyingSymbol = 'P';
underlyingColor = '#4a90d9'; // blue (player 0)
} else if (content === 'pawn1') {
underlyingSymbol = 'P';
underlyingColor = '#d94a4a'; // red (player 1)
}

// Show committed pieces
if (committedHere.length > 0) {
const cp = committedHere[0];
cellContent = PIECE_SYMBOLS[cp.type];
cellColor = cp.playerId === '0' ? '#7ab8e6' : '#e67a7a';
textColor = '#000';
}
// Determine committed piece info
const hasCommitted = committedHere.length > 0;
const committedSymbols = committedHere.map((cp) => ({
symbol: PIECE_SYMBOLS[cp.type],
color: cp.playerId === '0' ? '#7ab8e6' : '#e67a7a',
}));

// Highlight valid cells
// Determine cell background
let cellColor = '#f5f5dc'; // default tan
if (isValid) {
cellColor = '#90EE90';
cellColor = '#90EE90'; // green highlight for valid moves
} else if (underlyingColor && !hasCommitted) {
cellColor = underlyingColor;
}

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',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
};

// Render cell content showing all pieces
// If there are committed pieces, show them prominently
// If there's underlying content (pawn/king), show it too
return (
<div
key={index}
style={cellStyle}
onClick={() => handleCellClick(index)}
>
{cellContent}
{/* Underlying content (pawn/king) */}
{underlyingSymbol && (
<div
style={{
fontSize: hasCommitted ? '12px' : '24px',
fontWeight: 'bold',
color: hasCommitted ? underlyingColor : '#fff',
position: hasCommitted ? 'absolute' : 'static',
bottom: hasCommitted ? '2px' : undefined,
right: hasCommitted ? '4px' : undefined,
}}
>
{underlyingSymbol}
</div>
)}
{/* Committed pieces (targeting) */}
{committedSymbols.map((cp, i) => (
<div
key={i}
style={{
fontSize: underlyingSymbol ? '18px' : '24px',
fontWeight: 'bold',
backgroundColor: cp.color,
color: '#000',
padding: '2px 4px',
borderRadius: '3px',
marginBottom: committedSymbols.length > 1 ? '1px' : '0',
}}
>
{cp.symbol}
</div>
))}
</div>
);
};
Expand Down
70 changes: 55 additions & 15 deletions src/games/mecha-duel/Game.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,13 @@ describe('createInitialBoard', () => {
expect(cells[posToIndex({ x: 4, y: 7 })]).toBe('king1');
});

it('places pawns at scenario positions', () => {
it('places pawns at scenario positions with correct colors', () => {
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');
// Simple scenario: player 0 pawns at row 2, player 1 pawns at row 5
expect(cells[posToIndex({ x: 2, y: 2 })]).toBe('pawn0');
expect(cells[posToIndex({ x: 5, y: 2 })]).toBe('pawn0');
expect(cells[posToIndex({ x: 2, y: 5 })]).toBe('pawn1');
expect(cells[posToIndex({ x: 5, y: 5 })]).toBe('pawn1');
});
});

Expand Down Expand Up @@ -298,8 +298,8 @@ describe('raycast', () => {

it('finds first pawn in path', () => {
const cells = createEmptyBoard();
cells[posToIndex({ x: 4, y: 3 })] = 'pawn';
cells[posToIndex({ x: 4, y: 5 })] = 'pawn';
cells[posToIndex({ x: 4, y: 3 })] = 'pawn0';
cells[posToIndex({ x: 4, y: 5 })] = 'pawn1';
const from = { x: 4, y: 0 };
const toward = { x: 4, y: 7 };
const result = Game.raycast(from, toward, cells);
Expand All @@ -317,7 +317,7 @@ describe('raycast', () => {

it('works for diagonal directions', () => {
const cells = createEmptyBoard();
cells[posToIndex({ x: 6, y: 6 })] = 'pawn';
cells[posToIndex({ x: 6, y: 6 })] = 'pawn1';
const from = { x: 4, y: 4 };
const toward = { x: 7, y: 7 };
const result = Game.raycast(from, toward, cells);
Expand Down Expand Up @@ -351,14 +351,14 @@ describe('resolveStrike', () => {
it('destroys first pawn in strike path', () => {
const state = createTestState();
const pawnPos = { x: 4, y: 3 };
state.cells[posToIndex(pawnPos)] = 'pawn';
state.cells[posToIndex(pawnPos)] = 'pawn1'; // enemy 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');
expect(result.destroyed).toBe('pawn1');
});

it('destroys opponent king in strike path', () => {
Expand Down Expand Up @@ -440,12 +440,12 @@ describe('resolveKnight', () => {
it('destroys pawn at landing square', () => {
const state = createTestState();
const targetPos = { x: 5, y: 2 };
state.cells[posToIndex(targetPos)] = 'pawn';
state.cells[posToIndex(targetPos)] = 'pawn1'; // enemy pawn

const result = Game.resolveKnight(state, '0', targetPos);

expect(result.success).toBe(true);
expect(result.destroyed).toBe('pawn');
expect(result.destroyed).toBe('pawn1');
});

it('destroys opponent king at landing square', () => {
Expand Down Expand Up @@ -522,12 +522,12 @@ describe('resolveKingStep', () => {
it('destroys pawn when entering pawn square', () => {
const state = createTestState();
const targetPos = { x: 4, y: 1 };
state.cells[posToIndex(targetPos)] = 'pawn';
state.cells[posToIndex(targetPos)] = 'pawn1'; // enemy pawn

const result = Game.resolveKingStep(state, '0', targetPos);

expect(result.success).toBe(true);
expect(result.destroyed).toBe('pawn');
expect(result.destroyed).toBe('pawn1');
});

it('destroys opponent king when stepping on', () => {
Expand Down Expand Up @@ -630,4 +630,44 @@ describe('checkWinCondition', () => {
state.kings['1'] = null;
expect(Game.checkWinCondition(state)).toEqual({ winner: '0' });
});

it('returns player 1 as winner when all player 0 pawns destroyed', () => {
const state = createTestState();
// Remove all player 0 pawns
for (let i = 0; i < state.cells.length; i++) {
if (state.cells[i] === 'pawn0') {
state.cells[i] = null;
}
}
expect(Game.checkWinCondition(state)).toEqual({ winner: '1' });
});

it('returns player 0 as winner when all player 1 pawns destroyed', () => {
const state = createTestState();
// Remove all player 1 pawns
for (let i = 0; i < state.cells.length; i++) {
if (state.cells[i] === 'pawn1') {
state.cells[i] = null;
}
}
expect(Game.checkWinCondition(state)).toEqual({ winner: '0' });
});
});

describe('countPawns', () => {
it('counts player 0 pawns correctly', () => {
const { cells } = createInitialBoard('simple');
expect(Game.countPawns(cells, '0')).toBe(2);
});

it('counts player 1 pawns correctly', () => {
const { cells } = createInitialBoard('simple');
expect(Game.countPawns(cells, '1')).toBe(2);
});

it('returns 0 when no pawns exist for player', () => {
const cells: CellContent[] = Array(TOTAL_CELLS).fill(null);
expect(Game.countPawns(cells, '0')).toBe(0);
expect(Game.countPawns(cells, '1')).toBe(0);
});
});
26 changes: 22 additions & 4 deletions src/games/mecha-duel/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export interface CommittedPiece {
target: Position;
}

export type CellContent = 'pawn' | 'king0' | 'king1' | null;
export type CellContent = 'pawn0' | 'pawn1' | 'king0' | 'king1' | null;

export interface MechaDuelState {
cells: CellContent[];
Expand Down Expand Up @@ -135,9 +135,9 @@ export function createInitialBoard(scenarioId: string = defaultScenarioId): {
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 pawns with player ownership
for (const pawn of scenario.pawns) {
cells[posToIndex(pawn.position)] = `pawn${pawn.player}` as CellContent;
}

// Place kings on the cells
Expand Down Expand Up @@ -379,15 +379,33 @@ function exhaustCommittedPieces(pieces: Piece[]): Piece[] {
// Win Conditions
// =============================================================================

export function countPawns(cells: CellContent[], playerId: '0' | '1'): number {
const pawnType = `pawn${playerId}` as CellContent;
return cells.filter((cell) => cell === pawnType).length;
}

export function checkWinCondition(
state: MechaDuelState
): { winner: string } | null {
// Primary win condition: opponent's king destroyed
if (state.kings['0'] === null) {
return { winner: '1' };
}
if (state.kings['1'] === null) {
return { winner: '0' };
}

// Secondary win condition: all opponent's pawns destroyed
const player0Pawns = countPawns(state.cells, '0');
const player1Pawns = countPawns(state.cells, '1');

if (player0Pawns === 0) {
return { winner: '1' };
}
if (player1Pawns === 0) {
return { winner: '0' };
}

return null;
}

Expand Down
8 changes: 5 additions & 3 deletions src/games/mecha-duel/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@
## Setup
- 8×8 board
- Each player starts with King on opposite ends (row 0 and row 7)
- Scenario determines pawn placement (static obstacles)
- Each player has pawns on their side of the board (scenario determines placement)
- Each player has piece supply: 2 Bishops, 2 Rooks, 2 Knights, 1 Queen (all start Ready)

## Piece States
- **Ready:** Available to commit
- **Committed:** Placed on board as threat
- **Exhausted:** Recently used, unavailable

## Win Condition
- Opponent's King is destroyed when struck by executed attack or stepped on
## Win Conditions
- **Primary:** Opponent's King is destroyed (struck by executed attack or stepped on)
- **Secondary:** All of opponent's pawns are destroyed
- Opponent resigns

## Turn Structure
Expand Down Expand Up @@ -64,6 +65,7 @@ On your turn, choose ONE action:
- Strike attacks (first piece in the ray path)
- Knight landing on a pawn square
- KingStep entering a pawn square
- Destroying all of an opponent's pawns wins the game

5. **Execution Order:** When executing, all Strikes resolve first (simultaneously), then Knights resolve in the order chosen by the player.

Expand Down
19 changes: 12 additions & 7 deletions src/games/mecha-duel/scenarios.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { Position } from './Game';

export interface PawnPlacement {
position: Position;
player: '0' | '1';
}

export interface Scenario {
name: string;
description: string;
pawns: Position[];
pawns: PawnPlacement[];
kingPositions: {
'0': Position;
'1': Position;
Expand All @@ -17,10 +22,10 @@ export interface Scenario {
* Layout (8x8):
* Row 7: [ ][ ][ ][ ][K1][ ][ ][ ]
* Row 6: [ ][ ][ ][ ][ ][ ][ ][ ]
* Row 5: [ ][ ][P][ ][ ][P][ ][ ]
* Row 5: [ ][ ][p1][ ][ ][p1][ ][ ] (Player 1's pawns)
* Row 4: [ ][ ][ ][ ][ ][ ][ ][ ]
* Row 3: [ ][ ][ ][ ][ ][ ][ ][ ]
* Row 2: [ ][ ][P][ ][ ][P][ ][ ]
* Row 2: [ ][ ][p0][ ][ ][p0][ ][ ] (Player 0's pawns)
* Row 1: [ ][ ][ ][ ][ ][ ][ ][ ]
* Row 0: [ ][ ][ ][ ][K0][ ][ ][ ]
* 0 1 2 3 4 5 6 7
Expand All @@ -29,10 +34,10 @@ 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 },
{ position: { x: 2, y: 2 }, player: '0' },
{ position: { x: 5, y: 2 }, player: '0' },
{ position: { x: 2, y: 5 }, player: '1' },
{ position: { x: 5, y: 5 }, player: '1' },
],
kingPositions: {
'0': { x: 4, y: 0 },
Expand Down