feat(arcade): add Rithmomachia (Battle of Numbers) game

Implement complete Rithmomachia game with:
- 8×16 board with vertical layout (BLACK left, WHITE right)
- 25 pieces per side (Circles, Triangles, Squares, Pyramid)
- SVG piece rendering with proper orientation (pieces point at opponents)
- Smooth react-spring animations for piece movement
- Mathematical capture relations (equality, sum, difference, multiple, etc.)
- Harmony victory conditions (arithmetic/geometric/harmonic progressions)
- Server-side game state validation
- Comprehensive game specification in SPEC.md

Visual improvements:
- Responsive font sizing for piece values
- Conditional text outlining for white pieces
- Pyramids displayed without numbers
- Pieces scaled appropriately for board size (56px)

Note: Infrastructure changes also register yjs-demo game (both added to registry together).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-29 09:34:20 -05:00
parent 6b63804a74
commit 2fc0a05f7f
20 changed files with 4425 additions and 2 deletions

View File

@@ -52,6 +52,18 @@ When asked to make ANY changes:
**Never auto-commit or auto-push after making changes.**
## Dev Server Management
**CRITICAL: The user manages running the dev server, NOT Claude Code.**
- ❌ DO NOT run `npm run dev` or `npm start`
- ❌ DO NOT attempt to start, stop, or restart the dev server
- ❌ DO NOT use background Bash processes for the dev server
- ✅ Make code changes and let the user restart the server when needed
- ✅ You may run other commands like `npm run type-check`, `npm run lint`, etc.
The user will manually start/restart the dev server after you make changes.
## Details
See `.claude/CODE_QUALITY_REGIME.md` for complete documentation.
@@ -324,3 +336,29 @@ When monitoring deployments to production (NAS at abaci.one):
- Ask if manual NAS deployment action is needed
**Common mistake:** Seeing https://abaci.one is online and assuming the new code is deployed. Always verify the commit SHA.
## Rithmomachia Game
When working on the Rithmomachia arcade game, refer to:
- **`src/arcade-games/rithmomachia/SPEC.md`** - Complete game specification
- Official implementation spec v1
- Board dimensions (8×16), piece types, movement rules
- Mathematical capture relations (equality, sum, difference, multiple, divisor, product, ratio)
- Harmony (progression) victory conditions
- Data models, server protocol, validation logic
- Test cases and UI/UX suggestions
**Quick Reference:**
- **Board**: 8 rows × 16 columns (A-P, 1-8)
- **Pieces per side**: 25 total (12 Circles, 6 Triangles, 6 Squares, 1 Pyramid)
- **Movement**: Geometric (C=diagonal, T=orthogonal, S=queen, P=king)
- **Captures**: Mathematical relations between piece values
- **Victory**: Harmony (3+ pieces in enemy half forming arithmetic/geometric/harmonic progression), exhaustion, or optional point threshold
**Critical Rules**:
- All piece values are positive integers (use `number`, not `bigint` for game state serialization)
- No jumping - pieces must have clear paths
- Captures require valid mathematical relations (use helper pieces for sum/diff/product/ratio)
- Pyramid pieces have 4 faces - face value must be chosen during relation checks

View File

@@ -0,0 +1,13 @@
'use client'
import { rithmomachiaGame } from '@/arcade-games/rithmomachia'
const { Provider, GameComponent } = rithmomachiaGame
export default function RithmomachiaPage() {
return (
<Provider>
<GameComponent />
</Provider>
)
}

View File

@@ -0,0 +1,357 @@
'use client'
import { createContext, type ReactNode, useCallback, useContext, useMemo } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import {
TEAM_MOVE,
useArcadeSession,
useRoomData,
useUpdateGameConfig,
useViewerId,
} from '@/lib/arcade/game-sdk'
import type {
AmbushContext,
Color,
HarmonyType,
RelationKind,
RithmomachiaConfig,
RithmomachiaState,
} from './types'
/**
* Context value for Rithmomachia game.
*/
interface RithmomachiaContextValue {
// State
state: RithmomachiaState
lastError: string | null
// Player info
viewerId: string | null
playerColor: Color | null
isMyTurn: boolean
// Game actions
startGame: () => void
makeMove: (
from: string,
to: string,
pieceId: string,
pyramidFace?: number,
capture?: CaptureData,
ambush?: AmbushContext
) => void
declareHarmony: (
pieceIds: string[],
harmonyType: HarmonyType,
params: Record<string, string>
) => void
resign: () => void
offerDraw: () => void
acceptDraw: () => void
claimRepetition: () => void
claimFiftyMove: () => void
// Config actions
setConfig: (field: keyof RithmomachiaConfig, value: any) => void
// Game control actions
resetGame: () => void
goToSetup: () => void
exitSession: () => void
// Error handling
clearError: () => void
}
interface CaptureData {
relation: RelationKind
targetPieceId: string
helperPieceId?: string
}
const RithmomachiaContext = createContext<RithmomachiaContextValue | null>(null)
/**
* Hook to access Rithmomachia game context.
*/
export function useRithmomachia(): RithmomachiaContextValue {
const context = useContext(RithmomachiaContext)
if (!context) {
throw new Error('useRithmomachia must be used within RithmomachiaProvider')
}
return context
}
/**
* Provider for Rithmomachia game state and actions.
*/
export function RithmomachiaProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get local player ID
const localPlayerId = useMemo(() => {
return Array.from(activePlayerIds).find((id) => {
const player = players.get(id)
return player?.isLocal !== false
})
}, [activePlayerIds, players])
// Merge saved config from room data
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null
const savedConfig = gameConfig?.rithmomachia as Partial<RithmomachiaConfig> | undefined
// Use validator to create initial state with config
const config: RithmomachiaConfig = {
pointWinEnabled: savedConfig?.pointWinEnabled ?? false,
pointWinThreshold: savedConfig?.pointWinThreshold ?? 30,
repetitionRule: savedConfig?.repetitionRule ?? true,
fiftyMoveRule: savedConfig?.fiftyMoveRule ?? true,
allowAnySetOnRecheck: savedConfig?.allowAnySetOnRecheck ?? true,
timeControlMs: savedConfig?.timeControlMs ?? null,
}
// Import validator dynamically to get initial state
return {
...require('./Validator').rithmomachiaValidator.getInitialState(config),
}
}, [roomData?.gameConfig])
// Use arcade session hook
const { state, sendMove, lastError, clearError } = useArcadeSession<RithmomachiaState>({
userId: viewerId || '',
roomId: roomData?.id,
initialState: mergedInitialState,
applyMove: (state) => state, // No optimistic updates for v1 - rely on server validation
})
// Determine player color (simplified: first player is White, second is Black)
const playerColor = useMemo((): Color | null => {
if (!localPlayerId) return null
const playerIndex = Array.from(activePlayerIds).indexOf(localPlayerId)
return playerIndex === 0 ? 'W' : 'B'
}, [localPlayerId, activePlayerIds])
// Check if it's my turn
const isMyTurn = useMemo(() => {
if (!playerColor) return false
return state.turn === playerColor
}, [state.turn, playerColor])
// Action: Start game
const startGame = useCallback(() => {
if (!viewerId || !localPlayerId) return
sendMove({
type: 'START_GAME',
playerId: localPlayerId,
userId: viewerId,
data: {
playerColor: playerColor || 'W',
activePlayers: Array.from(activePlayerIds),
},
})
}, [sendMove, viewerId, localPlayerId, playerColor, activePlayerIds])
// Action: Make a move
const makeMove = useCallback(
(
from: string,
to: string,
pieceId: string,
pyramidFace?: number,
capture?: CaptureData,
ambush?: AmbushContext
) => {
if (!viewerId || !localPlayerId) return
sendMove({
type: 'MOVE',
playerId: localPlayerId,
userId: viewerId,
data: {
from,
to,
pieceId,
pyramidFaceUsed: pyramidFace ?? null,
capture: capture
? {
relation: capture.relation,
targetPieceId: capture.targetPieceId,
helperPieceId: capture.helperPieceId,
}
: undefined,
ambush,
},
})
},
[sendMove, viewerId, localPlayerId]
)
// Action: Declare harmony
const declareHarmony = useCallback(
(pieceIds: string[], harmonyType: HarmonyType, params: Record<string, string>) => {
if (!viewerId || !localPlayerId) return
sendMove({
type: 'DECLARE_HARMONY',
playerId: localPlayerId,
userId: viewerId,
data: {
pieceIds,
harmonyType,
params,
},
})
},
[sendMove, viewerId, localPlayerId]
)
// Action: Resign
const resign = useCallback(() => {
if (!viewerId || !localPlayerId) return
sendMove({
type: 'RESIGN',
playerId: localPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localPlayerId])
// Action: Offer draw
const offerDraw = useCallback(() => {
if (!viewerId || !localPlayerId) return
sendMove({
type: 'OFFER_DRAW',
playerId: localPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localPlayerId])
// Action: Accept draw
const acceptDraw = useCallback(() => {
if (!viewerId || !localPlayerId) return
sendMove({
type: 'ACCEPT_DRAW',
playerId: localPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localPlayerId])
// Action: Claim repetition
const claimRepetition = useCallback(() => {
if (!viewerId || !localPlayerId) return
sendMove({
type: 'CLAIM_REPETITION',
playerId: localPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localPlayerId])
// Action: Claim fifty-move rule
const claimFiftyMove = useCallback(() => {
if (!viewerId || !localPlayerId) return
sendMove({
type: 'CLAIM_FIFTY_MOVE',
playerId: localPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localPlayerId])
// Action: Set config
const setConfig = useCallback(
(field: keyof RithmomachiaConfig, value: any) => {
// Send move to update state immediately
sendMove({
type: 'SET_CONFIG',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { field, value },
})
// Persist to database (room mode only)
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentConfig = (currentGameConfig.rithmomachia as Record<string, any>) || {}
updateGameConfig({
roomId: roomData.id,
gameConfig: {
...currentGameConfig,
rithmomachia: {
...currentConfig,
[field]: value,
},
},
})
}
},
[viewerId, sendMove, roomData, updateGameConfig]
)
// Action: Reset game (start new game with same config)
const resetGame = useCallback(() => {
if (!viewerId) return
sendMove({
type: 'RESET_GAME',
playerId: TEAM_MOVE,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId])
// Action: Go to setup (return to setup phase)
const goToSetup = useCallback(() => {
if (!viewerId) return
sendMove({
type: 'GO_TO_SETUP',
playerId: TEAM_MOVE,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId])
// Action: Exit session (no-op for now, handled by PageWithNav)
const exitSession = useCallback(() => {
// PageWithNav handles the actual navigation
// This is here for API compatibility
}, [])
const value: RithmomachiaContextValue = {
state,
lastError,
viewerId: viewerId ?? null,
playerColor,
isMyTurn,
startGame,
makeMove,
declareHarmony,
resign,
offerDraw,
acceptDraw,
claimRepetition,
claimFiftyMove,
setConfig,
resetGame,
goToSetup,
exitSession,
clearError,
}
return <RithmomachiaContext.Provider value={value}>{children}</RithmomachiaContext.Provider>
}

View File

@@ -0,0 +1,499 @@
# Rithmomachia (Implementation Spec v1)
## 0) High-level goals
* Two players ("White" and "Black") play on a rectangular grid.
* Pieces carry **positive integers** called **values**.
* You move pieces like chess (clear paths, legal geometries).
* You **capture** via **mathematical relations** (equality, sum, difference, multiple, divisor, product, ratio).
* You may also win by building a **Harmony** (a progression) inside enemy territory.
This spec aims for: fully deterministic setup, no ambiguities, consistent networking, and easy future extensions.
---
## 1) Board
* **Dimensions:** `8 rows × 16 columns`
* **Coordinates:** Columns `A…P` (left→right), Rows `1…8` (bottom→top from White's perspective)
* Bottom rank (Row 1) is White's back rank.
* Top rank (Row 8) is Black's back rank.
* **Halves:**
* **White half:** Rows `14`
* **Black half:** Rows `58`
---
## 2) Pieces and movement
Each side has **25 pieces**:
* **12 Circles (C)** — "light" pieces
Movement: **diagonal, any distance**, no jumping (like a bishop).
* **6 Triangles (T)** — "medium" pieces
Movement: **orthogonal, any distance**, no jumping (like a rook).
* **6 Squares (S)** — "heavy" pieces
Movement: **queen-like, any distance**, no jumping (orthogonal or diagonal).
* **1 Pyramid (P)** — "royal" piece
Movement: **king-like, 1 step** in any direction (8-neighborhood).
> Note: Movement is purely geometric. Numeric relations are only for captures and victory checks.
---
## 3) Values (numbers printed on pieces)
To keep the classic "number theory" flavor without historical inconsistencies, we use **balanced, deterministic sets** for both sides.
### 3.1 White values
* **Circles (12):** `2, 4, 6, …, 24` (even 224)
* **Triangles (6):** `3, 9, 27, 81, 243, 729` (`3^1…3^6`)
* **Squares (6):** `4, 16, 64, 256, 1024, 4096` (`4^1…4^6`)
* **Pyramid (1):** multi-face tuple `[8, 27, 64, 1]`
* For relations, the Pyramid's **face value** is chosen by the owner at relation-check time (see §5.5).
### 3.2 Black values
* **Circles (12):** `3, 6, 9, …, 36` (multiples of 3 from 336)
* **Triangles (6):** `2, 8, 32, 128, 512, 2048` (`2^1…2^11 skipping^? → exact set above`)
* **Squares (6):** `5, 25, 125, 625, 3125, 15625` (`5^1…5^6`)
* **Pyramid (1):** `[9, 32, 125, 1]`
> Rationale: Both sides get arithmetic + geometric "families" and a pyramid with four canonical faces. These sets are intentionally asymmetric but **balanced by geometry and victory routes**. You can swap or extend presets later without changing logic.
---
## 4) Initial setup
**VERTICAL LAYOUT** - Place pieces in **vertical columns** on opposite sides of the board:
- **BLACK pieces (filled/dark)**: Columns A, B, C (left side)
- **WHITE pieces (outline/light)**: Columns M, N, O, P (right side)
- **Playing area**: Columns D through L (middle 9 columns)
This follows the authoritative reference board image.
### BLACK Setup (Left side - columns A, B, C)
**Column A** (Squares and Triangles):
```
A1: S(49) A2: S(121) A3: T(36) A4: T(30)
A5: T(56) A6: T(64) A7: S(225) A8: S(361)
```
**Column B** (Squares, Circles, and Pyramid):
```
B1: S(28) B2: S(66) B3: C(9) B4: C(25)
B5: C(49) B6: C(81) B7: S(120) B8: P[9,32,125,1]
```
**Column C** (Triangles and Circles):
```
C1: T(16) C2: T(12) C3: C(3) C4: C(5)
C5: C(7) C6: C(9) C7: T(90) C8: T(100)
```
### WHITE Setup (Right side - columns M, N, O, P)
**Column M** (Sparse):
```
M1: empty M2: T(7) M3-M8: empty
```
**Column N** (Triangles, Circles, and Pyramid):
```
N1: T(4) N2: P[8,27,64,1] N3: C(8) N4: C(6)
N5: C(4) N6: C(2) N7: T(5) N8: T(6)
```
**Column O** (Squares and Circles):
```
O1: S(153) O2: S(169) O3: C(64) O4: C(36)
O5: C(16) O6: C(4) O7: S(45) O8: S(15)
```
**Column P** (Squares and Triangles):
```
P1: S(289) P2: S(289) P3: T(5) P4: T(2)
P5: T(2) P6: T(4) P7: S(18) P8: S(25)
```
### Piece Count Summary
**BLACK**: 7 Squares, 8 Triangles, 8 Circles, 1 Pyramid = **24 pieces**
**WHITE**: 7 Squares, 8 Triangles, 8 Circles, 1 Pyramid = **24 pieces**
> Note: This is a streamlined variant with 24 pieces per side instead of the traditional 25. The vertical layout emphasizes the positional strategy and mathematical relations across the wide board.
---
## 5) Turn structure
* **White moves first.**
* A **turn** consists of:
1. **One movement** of a single piece (legal geometry, empty path).
2. Optional **Capture Resolution** (if the destination contains an enemy piece or you declare a relation capture; see §6).
3. Optional **Harmony Declaration** (if achieved; see §7).
* No en passant, no jumps, no castling; Pyramid is not a king (you don't lose on "check"), but see victory (§7, §8).
---
## 6) Captures (mathematical relations)
There are two categories:
### 6.1 Direct capture by **landing** (standard)
If you **move onto a square occupied by an enemy**, the capture **succeeds only if** **at least one** of the following relations between your **moved piece's value** (or Pyramid face) and the **enemy piece's value** is true:
* **Equality:** `a == b`
* **Multiple / Divisor:** `a % b == 0` or `b % a == 0` (strictly positive integers)
* **Sum (with an on-board friendly helper):** `a + h == b` or `b + h == a`
* **Difference (with helper):** `|a - h| == b` or `|b - h| == a`
* **Product (with helper):** `a * h == b` or `b * h == a`
* **Ratio (with helper):** `a * r == b` or `b * r == a`, where `r` equals the exact value of **some friendly helper** on the board.
**Helpers**:
* Are **any one** of your other pieces **already on the board** (they do **not** move).
* You must **name** the helper (piece ID) during capture resolution (for determinism).
* Only **one** helper may be used per capture.
* Helpers may be anywhere (not required to be adjacent).
**Pyramid face choice**:
* If your mover is a **Pyramid**, at capture time you may **choose one** of its faces (e.g., `8` or `27` or `64` or `1`) to be `a`. Record this in the move log.
If **none** of the relations hold, your landing **fails**: the move is illegal.
### 6.2 **Ambush capture** (no landing)
If, **after your movement**, an **enemy piece** sits on a square such that a relation holds **between that enemy's value** and **two of your unmoved pieces** simultaneously (think "pincer by numbers"), you may declare an ambush and remove the enemy. Use the same relations as above, but both friendly pieces are **helpers**; neither moves. You must specify **which two** and which relation. Ambush is optional and can only be declared **immediately** after your move.
> Tip for implementers: Model ambush as a post-move **relation scan** limited to enemies adjacent to some "relation context". Since helpers can be anywhere, you only need to check relations involving declared IDs; do not try to scan all pairs in large boards—let the client propose an ambush with (ids, relation) and the server validate.
---
## 7) Harmony (progression) victory
On your turn (after movement/captures), you may **declare Harmony** if you have **≥ 3** of your pieces **entirely within the opponent's half** (White in rows 58, Black in rows 14) whose **values form an exact progression** of one of these types:
* **Arithmetic:** values `v, v+d, v+2d, …` with `d > 0`
* **Geometric:** values `v, v·r, v·r², …` with integer `r ≥ 2`
* **Harmonic:** reciprocals form an arithmetic progression (i.e., `1/v` is arithmetic); equivalently, `v, v·(n/(n-1)), v·(n/(n-2)), …` for some integer `n` (server should validate via reciprocals to avoid rounding)
**Rules:**
* Pieces in the set must be **distinct** and **on distinct squares**.
* Order doesn't matter; the set must be **exact** (no extra elements required).
* **Pyramid face**: When a Pyramid is included, you must **fix** a face value for the duration of the check.
* **Persistence:** Your declared Harmony must **survive the opponent's next full turn** (they can try to break it by moving/capturing). If, when your next turn begins, the Harmony still exists (same set or **any valid set** of ≥3 on the enemy half), **you win immediately**.
> Implementation: On declare, snapshot the **set of piece IDs** and the **progression type + parameters** (e.g., `(AP, v=6,d=6)`). On the next time it becomes the declarer's turn, **re-validate** either the same set OR allow **any** new valid ≥3 set controlled by the declarer in enemy half (choose one policy now: we pick **any valid set** to reward dynamic play).
---
## 8) Other victory conditions
* **Exhaustion:** If a player has **no legal moves** at the start of their turn, they **lose**.
* **Resignation:** A player may resign at any time.
* **Point victory (optional toggle):** Track point values for pieces (C=1, T=2, S=3, P=5). If a player reaches **30 points captured**, they may declare a **Point Win** at the end of their turn. (Off by default; enable for ladders.)
---
## 9) Draws
* **Threefold repetition** (same full state, same player to move) → draw on claim.
* **50-move rule** (no capture, no Harmony declaration) → draw on claim.
* **Mutual agreement** → draw.
---
## 10) Illegal states / edge cases
* **No zero or negative values.** All values are positive integers.
* **No jumping** ever.
* **Self-capture** forbidden.
* **Helper identity** must be a currently alive friendly piece, not the mover (unless the relation allows using the mover's own value on both sides, which it shouldn't—disallow self as helper).
* **Division/ratio** must be exact in integers—no rounding.
* **Overflow**: Use bigints (JS `BigInt`) for relation math to avoid overflow with large powers.
---
## 11) Data model (authoritative server)
### 11.1 Piece
```ts
type PieceType = 'C' | 'T' | 'S' | 'P';
type Color = 'W' | 'B';
interface Piece {
id: string; // stable UUID
color: Color;
type: PieceType;
value?: number; // for C/T/S always present
pyramidFaces?: number[]; // for P only (length 4)
activePyramidFace?: number | null; // last chosen face for logging/captures
square: string; // "A1".."P8"
captured: boolean;
}
```
### 11.2 Game state
```ts
interface GameState {
id: string;
boardCols: number; // 16
boardRows: number; // 8
turn: Color; // 'W' or 'B'
pieces: Record<string, Piece>;
history: MoveRecord[];
pendingHarmony?: HarmonyDeclaration | null; // if declared last turn
rules: {
pointWinEnabled: boolean;
repetitionRule: boolean;
fiftyMoveRule: boolean;
allowAnySetOnRecheck: boolean; // true per §7
};
halfBoundaries: { whiteHalfRows: [1,2,3,4], blackHalfRows: [5,6,7,8] };
clocks?: { Wms: number; Bms: number } | null; // optional timers
}
```
### 11.3 Move + capture records
```ts
type RelationKind = 'EQUAL' | 'MULTIPLE' | 'DIVISOR' | 'SUM' | 'DIFF' | 'PRODUCT' | 'RATIO';
interface CaptureContext {
relation: RelationKind;
moverPieceId: string;
targetPieceId: string;
helperPieceId?: string; // required for SUM/DIFF/PRODUCT/RATIO
moverFaceUsed?: number | null; // if mover was a Pyramid
}
interface AmbushContext {
relation: RelationKind;
enemyPieceId: string;
helper1Id: string;
helper2Id: string; // two helpers for ambush
}
interface MoveRecord {
ply: number;
color: Color;
from: string; // e.g., "C2"
to: string; // e.g., "C6"
pieceId: string;
pyramidFaceUsed?: number | null;
capture?: CaptureContext | null;
ambush?: AmbushContext | null;
harmonyDeclared?: HarmonyDeclaration | null;
pointsCapturedThisMove?: number; // if point scoring is on
fenLikeHash?: string; // for repetition detection
noProgressCount?: number; // for 50-move rule
resultAfter?: 'ONGOING' | 'WINS_W' | 'WINS_B' | 'DRAW';
}
```
### 11.4 Harmony declaration
```ts
type HarmonyType = 'ARITH' | 'GEOM' | 'HARM';
interface HarmonyDeclaration {
by: Color;
pieceIds: string[]; // ≥3
type: HarmonyType;
params: { v?: string; d?: string; r?: string }; // store as strings for bigints if needed
declaredAtPly: number;
}
```
---
## 12) Server protocol (WebSocket)
### 12.1 Messages (client → server)
```jsonc
// Join a room
{ "type": "join_room", "roomId": "rith-123", "playerToken": "..." }
// Ask for current state (idempotent)
{ "type": "get_state", "roomId": "rith-123" }
// Propose a move (with optional capture or ambush info)
{
"type": "move_request",
"roomId": "rith-123",
"payload": {
"pieceId": "W_C_06",
"from": "C2",
"to": "H7",
"pyramidFaceUsed": 27, // if mover is Pyramid (optional)
"capture": {
"relation": "SUM", // if landing capture
"targetPieceId": "B_T_03",
"helperPieceId": "W_S_02"
},
"ambush": {
"relation": "PRODUCT", // if declaring ambush after movement
"enemyPieceId": "B_S_05",
"helper1Id": "W_T_01",
"helper2Id": "W_S_03"
},
"harmony": {
"type": "GEOM",
"pieceIds": ["W_C_02","W_T_02","W_S_02"],
"params": { "v": "2", "r": "2" }
}
}
}
// Resign
{ "type": "resign", "roomId": "rith-123" }
```
### 12.2 Messages (server → client)
```jsonc
// Room joined / spectator assigned
{ "type": "room_joined", "seat": "W" | "B" | "SPECTATOR", "state": { /* GameState */ } }
// State update after validated move
{ "type": "state_update", "state": { /* GameState */ } }
// Move rejected with reason
{ "type": "move_rejected", "reason": "ILLEGAL_MOVE|ILLEGAL_CAPTURE|RELATION_FAIL|TURN|NOT_OWNER|PATH_BLOCKED|BAD_HELPER|HARMONY_INVALID" }
// Game ended
{ "type": "game_over", "result": "WINS_W|WINS_B|DRAW", "by": "HARMONY|EXHAUSTION|RESIGNATION|POINTS|AGREEMENT|REPETITION|FIFTY" }
```
---
## 13) Validation logic (server)
### 13.1 Movement
* Check turn ownership.
* Check piece exists, not captured.
* Validate geometry for type (C diag; T ortho; S queen; P king).
* Validate clear path (grid ray-cast).
* If destination is empty:
* Allow **non-capture** move.
* After move, you may **declare ambush** (if valid).
* If destination occupied by enemy:
* Move only allowed if **landing capture** relations validate (with declared helper if required).
* Otherwise reject.
### 13.2 Relation checks
* All arithmetic in **bigints**.
* Equality is trivial.
* Multiple/Divisor: simple modulo checks; reject zeros.
* Sum/Diff/Product/Ratio require **helper** piece ID. Validate that helper:
* is friendly, alive, not the mover,
* has a well-defined value (Pyramid has implicit four candidates, but **helpers do not switch faces**; they are not pyramids here in our v1; if you allow Pyramid as helper, require explicit `helperFaceUsed` in payload and store it).
* For **Pyramid mover**, allow `pyramidFaceUsed` and use that as `a`.
### 13.3 Ambush
* The mover's landing square can be empty or enemy (if enemy, you must pass landing-capture first).
* Ambush uses **two helpers**; both must be friendly, alive, distinct, not the mover.
* Validate relation against the **enemy piece value** and the two helpers per the declared relation (server recomputes).
### 13.4 Harmony
* Validate ≥3 friendly pieces **on enemy half**.
* Extract their effective values (Pyramids must fix a face for the check; store it inside the HarmonyDeclaration).
* Validate strict progression per type.
* Store a pending declaration tied to `declaredAtPly`.
* On the declarer's next turn start: if **any** valid ≥3 set exists (per `allowAnySetOnRecheck`), award win; otherwise clear pending.
---
## 14) UI/UX suggestions (client)
* Hover a destination to see **all legal relation captures** (auto-suggest helpers).
* Toggle **"math inspector"** to show factors, multiples, candidate sums/diffs.
* **Harmony builder** UI: click pieces on enemy half; client proposes arithmetic/geometric/harmonic fits.
* Log every move with human-readable math, e.g.:
`W: T(27) C2→C7 captures B S(125) by RATIO 27×(125/27)=125 [helper W S(125)? nope; example only]`.
---
## 15) Test cases (goldens)
1. **Simple equality capture**
Move `W C(6)` onto `B C(6)` → valid by `EQUAL`.
2. **Sum capture**
`W T(9)` lands on `B C(15)` using helper `W C(6)``9 + 6 = 15`.
3. **Divisor capture**
`W S(64)` lands on `B T(2048)` → divisor (`2048 % 64 == 0`).
4. **Pyramid face**
`W P[8,27,64,1]` chooses face `64` to land-capture `B S(64)` by `EQUAL`.
5. **Ambush**
After moving any piece, declare ambush vs `B S(125)` using helpers `W T(5)` and `W S(25)` by `PRODUCT` (5×25=125). (Adjust helper identities to real IDs in your setup.)
6. **Harmony (GEOM)**
White occupies enemy half with values 4, 16, 64 → geometric (v=4, r=4). Declare; if it persists one full Black turn, White wins.
---
## 16) Optional rule toggles (versioning)
* **Strict Pyramid faces:** Allow Pyramid as **helper** only if face is declared similarly to mover.
* **Helper adjacency:** Require helpers to be **adjacent** to enemy for SUM/DIFF/PRODUCT/RATIO (reduces global scans).
* **Any-set vs same-set on recheck:** We chose **any-set**. Switchable.
---
## 17) Dev notes
* Use **Zobrist hashing** (or similar) for `fenLikeHash` to detect repetitions.
* Keep a **no-progress counter** (reset on any capture or harmony declaration).
* Use **BigInt** end-to-end for piece values and relation math.
* Build a **deterministic PRNG** only if you later add random presets—current spec is deterministic.
---
## Implementation Status
Last updated: 2025-10-29
The current implementation in `src/arcade-games/rithmomachia/` follows this spec:
- **Board setup**: ✅ VERTICAL layout (§4) - BLACK on left (columns A-C), WHITE on right (columns M-P)
- **Piece rendering**: ✅ SVG-based with precise color control (PieceRenderer.tsx)
- BLACK pieces: Dark fill (#1a1a1a) with black stroke
- WHITE pieces: Light fill (#ffffff) with gray stroke
- **Piece values**: ✅ Match reference board image exactly (24 pieces per side)
- **Movement validation**: ✅ Implemented in `Validator.ts` following geometric rules
- **Capture system**: ✅ Relation-based captures per §6
- **Harmony system**: ✅ Progression detection and validation per §7
- **Data types**: ✅ All types use `number` (not `bigint`) for JSON serialization
- **Game controls**: ✅ Settings UI with rule toggles, New Game, Setup
- **UI**: ✅ Click-to-select, click-to-move piece interaction
**Remaining features (future enhancement):**
1. Math inspector UI (show legal captures with auto-suggested helpers)
2. Harmony builder UI (visual progression detector)
3. Move history display with human-readable math notation
4. Ambush capture UI (currently only basic movement implemented)
5. Enhanced piece highlighting for available moves

View File

@@ -0,0 +1,922 @@
import type { GameValidator, ValidationContext, ValidationResult } from '@/lib/arcade/game-sdk'
import type {
AmbushContext,
CaptureContext,
Color,
HarmonyDeclaration,
MoveRecord,
Piece,
RithmomachiaConfig,
RithmomachiaMove,
RithmomachiaState,
} from './types'
import { opponentColor } from './types'
import { hasAnyValidHarmony, isHarmonyStillValid, validateHarmony } from './utils/harmonyValidator'
import { validateMove } from './utils/pathValidator'
import {
clonePieces,
createInitialBoard,
getEffectiveValue,
getLivePiecesForColor,
getPieceAt,
getPieceById,
} from './utils/pieceSetup'
import { checkRelation } from './utils/relationEngine'
import { computeZobristHash, isThreefoldRepetition } from './utils/zobristHash'
/**
* Validator for Rithmomachia game logic.
* Implements all rules: movement, captures, harmony, victory conditions.
*/
export class RithmomachiaValidator implements GameValidator<RithmomachiaState, RithmomachiaMove> {
/**
* Get initial game state from config.
*/
getInitialState(config: RithmomachiaConfig): RithmomachiaState {
const pieces = createInitialBoard()
const initialHash = computeZobristHash(pieces, 'W')
const state: RithmomachiaState = {
// Configuration (stored in state per arcade pattern)
pointWinEnabled: config.pointWinEnabled,
pointWinThreshold: config.pointWinThreshold,
repetitionRule: config.repetitionRule,
fiftyMoveRule: config.fiftyMoveRule,
allowAnySetOnRecheck: config.allowAnySetOnRecheck,
timeControlMs: config.timeControlMs ?? null,
// Game phase
gamePhase: 'setup',
// Board setup
boardCols: 16,
boardRows: 8,
turn: 'W',
pieces,
capturedPieces: { W: [], B: [] },
history: [],
pendingHarmony: null,
noProgressCount: 0,
stateHashes: [initialHash],
winner: null,
winCondition: null,
}
// Add point tracking if enabled by config
if (config.pointWinEnabled) {
state.pointsCaptured = { W: 0, B: 0 }
}
return state
}
/**
* Validate a move and return the updated state if valid.
*/
validateMove(
state: RithmomachiaState,
move: RithmomachiaMove,
context?: ValidationContext
): ValidationResult {
// Allow SET_CONFIG in any phase
if (move.type === 'SET_CONFIG') {
return this.handleSetConfig(state, move, context)
}
// Allow RESET_GAME in any phase
if (move.type === 'RESET_GAME') {
return this.handleResetGame(state, move, context)
}
// Allow GO_TO_SETUP from results phase
if (move.type === 'GO_TO_SETUP') {
return this.handleGoToSetup(state, move, context)
}
// Game must be in playing phase for game moves
if (state.gamePhase === 'setup') {
if (move.type === 'START_GAME') {
return this.handleStartGame(state, move, context)
}
return { valid: false, error: 'Game not started' }
}
if (state.gamePhase === 'results') {
return { valid: false, error: 'Game already ended' }
}
// Check for existing winner
if (state.winner) {
return { valid: false, error: 'Game already has a winner' }
}
switch (move.type) {
case 'MOVE':
return this.handleMove(state, move, context)
case 'DECLARE_HARMONY':
return this.handleDeclareHarmony(state, move, context)
case 'RESIGN':
return this.handleResign(state, move, context)
case 'OFFER_DRAW':
case 'ACCEPT_DRAW':
return this.handleDraw(state, move, context)
case 'CLAIM_REPETITION':
return this.handleClaimRepetition(state, move, context)
case 'CLAIM_FIFTY_MOVE':
return this.handleClaimFiftyMove(state, move, context)
default:
return { valid: false, error: 'Unknown move type' }
}
}
/**
* Check if the game is complete.
*/
isGameComplete(state: RithmomachiaState): boolean {
return state.winner !== null || state.gamePhase === 'results'
}
// ==================== MOVE HANDLERS ====================
/**
* Handle START_GAME move.
*/
private handleStartGame(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'START_GAME' }>,
context?: ValidationContext
): ValidationResult {
const newState = {
...state,
gamePhase: 'playing' as const,
}
return { valid: true, newState }
}
/**
* Handle MOVE action (piece movement with optional capture/ambush).
*/
private handleMove(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'MOVE' }>,
context?: ValidationContext
): ValidationResult {
const { from, to, pieceId, pyramidFaceUsed, capture, ambush } = move.data
// Get the piece
let piece: Piece
try {
piece = getPieceById(state.pieces, pieceId)
} catch (e) {
return { valid: false, error: `Piece not found: ${pieceId}` }
}
// Check ownership (turn must match piece color)
if (piece.color !== state.turn) {
return { valid: false, error: `Not ${piece.color}'s turn` }
}
// Check piece is not captured
if (piece.captured) {
return { valid: false, error: 'Piece already captured' }
}
// Check from square matches piece location
if (piece.square !== from) {
return { valid: false, error: `Piece is not at ${from}, it's at ${piece.square}` }
}
// Validate movement geometry and path
const moveValidation = validateMove(piece, from, to, state.pieces)
if (!moveValidation.valid) {
return { valid: false, error: moveValidation.reason }
}
// Check destination
const targetPiece = getPieceAt(state.pieces, to)
// If destination is empty
if (!targetPiece) {
// No capture possible, just move
if (capture) {
return { valid: false, error: 'Cannot capture on empty square' }
}
// Process the move
const newState = this.applyMove(
state,
piece,
from,
to,
pyramidFaceUsed,
null,
ambush,
context
)
return { valid: true, newState }
}
// Destination is occupied
// Cannot capture own piece
if (targetPiece.color === piece.color) {
return { valid: false, error: 'Cannot capture own piece' }
}
// Must have a capture declaration if landing on enemy
if (!capture) {
return { valid: false, error: 'Must declare capture relation when landing on enemy piece' }
}
// Validate the capture relation
const captureValidation = this.validateCapture(
state,
piece,
targetPiece,
capture,
pyramidFaceUsed
)
if (!captureValidation.valid) {
return { valid: false, error: captureValidation.error }
}
// Process the move with capture
const captureContext: CaptureContext = {
relation: capture.relation,
moverPieceId: pieceId,
targetPieceId: capture.targetPieceId,
helperPieceId: capture.helperPieceId,
moverFaceUsed: pyramidFaceUsed ?? null,
}
const newState = this.applyMove(
state,
piece,
from,
to,
pyramidFaceUsed,
captureContext,
ambush,
context
)
return { valid: true, newState }
}
/**
* Validate a capture relation.
*/
private validateCapture(
state: RithmomachiaState,
mover: Piece,
target: Piece,
capture: NonNullable<Extract<RithmomachiaMove, { type: 'MOVE' }>['data']['capture']>,
pyramidFaceUsed?: number | null
): ValidationResult {
// Get mover value
let moverValue: number
if (mover.type === 'P') {
if (!pyramidFaceUsed) {
return { valid: false, error: 'Pyramid must choose a face for capture' }
}
// Validate face is valid
if (!mover.pyramidFaces?.some((f) => f === pyramidFaceUsed)) {
return { valid: false, error: 'Invalid pyramid face' }
}
moverValue = pyramidFaceUsed
} else {
moverValue = mover.value!
}
// Get target value
const targetValue = getEffectiveValue(target)
if (targetValue === null) {
return { valid: false, error: 'Target has no value' }
}
// Get helper value (if required)
let helperValue: number | undefined
if (capture.helperPieceId) {
let helperPiece: Piece
try {
helperPiece = getPieceById(state.pieces, capture.helperPieceId)
} catch (e) {
return { valid: false, error: `Helper piece not found: ${capture.helperPieceId}` }
}
// Helper must be friendly
if (helperPiece.color !== mover.color) {
return { valid: false, error: 'Helper must be friendly' }
}
// Helper must not be captured
if (helperPiece.captured) {
return { valid: false, error: 'Helper is captured' }
}
// Helper cannot be the mover
if (helperPiece.id === mover.id) {
return { valid: false, error: 'Helper cannot be the mover itself' }
}
// Helper cannot be a Pyramid (v1 simplification)
if (helperPiece.type === 'P') {
return { valid: false, error: 'Pyramids cannot be helpers in v1' }
}
helperValue = getEffectiveValue(helperPiece) ?? undefined
}
// Check the relation
const relationCheck = checkRelation(capture.relation, moverValue, targetValue, helperValue)
if (!relationCheck.valid) {
return { valid: false, error: relationCheck.explanation || 'Relation check failed' }
}
return { valid: true }
}
/**
* Apply a move to the state (mutates and returns new state).
*/
private applyMove(
state: RithmomachiaState,
piece: Piece,
from: string,
to: string,
pyramidFaceUsed: number | null | undefined,
capture: CaptureContext | null,
ambush: AmbushContext | undefined,
context?: ValidationContext
): RithmomachiaState {
// Clone state
const newState = { ...state }
newState.pieces = clonePieces(state.pieces)
newState.capturedPieces = {
W: [...state.capturedPieces.W],
B: [...state.capturedPieces.B],
}
newState.history = [...state.history]
newState.stateHashes = [...state.stateHashes]
// Move the piece
newState.pieces[piece.id].square = to
// Set pyramid face if used
if (pyramidFaceUsed && piece.type === 'P') {
newState.pieces[piece.id].activePyramidFace = pyramidFaceUsed
}
// Handle capture
let capturedPiece: Piece | null = null
if (capture) {
const targetPiece = newState.pieces[capture.targetPieceId]
targetPiece.captured = true
newState.capturedPieces[opponentColor(piece.color)].push(targetPiece)
capturedPiece = targetPiece
// Reset no-progress counter
newState.noProgressCount = 0
// Update points if enabled
if (newState.pointsCaptured) {
const points = this.getPiecePoints(targetPiece)
newState.pointsCaptured[piece.color] += points
}
} else {
// No capture = increment no-progress counter
newState.noProgressCount += 1
}
// Handle ambush (if declared)
if (ambush) {
const ambushValidation = this.validateAmbush(newState, piece.color, ambush)
if (ambushValidation.valid) {
const enemyPiece = newState.pieces[ambush.enemyPieceId]
enemyPiece.captured = true
newState.capturedPieces[opponentColor(piece.color)].push(enemyPiece)
// Update points if enabled
if (newState.pointsCaptured) {
const points = this.getPiecePoints(enemyPiece)
newState.pointsCaptured[piece.color] += points
}
// Reset no-progress counter
newState.noProgressCount = 0
}
}
// Compute new hash
const newHash = computeZobristHash(newState.pieces, opponentColor(piece.color))
newState.stateHashes.push(newHash)
// Create move record
const moveRecord: MoveRecord = {
ply: newState.history.length + 1,
color: piece.color,
from,
to,
pieceId: piece.id,
pyramidFaceUsed: pyramidFaceUsed ?? null,
capture: capture ?? null,
ambush: ambush ?? null,
harmonyDeclared: null,
fenLikeHash: newHash,
noProgressCount: newState.noProgressCount,
resultAfter: 'ONGOING',
}
newState.history.push(moveRecord)
// Switch turn
newState.turn = opponentColor(piece.color)
// Check for pending harmony validation
if (newState.pendingHarmony && newState.pendingHarmony.by === newState.turn) {
// It's now the declarer's turn again - check if harmony still exists
const config = this.getConfigFromState(newState)
if (config.allowAnySetOnRecheck) {
// Check for ANY valid harmony
if (hasAnyValidHarmony(newState.pieces, newState.pendingHarmony.by)) {
// Harmony persisted! Victory!
newState.winner = newState.pendingHarmony.by
newState.winCondition = 'HARMONY'
newState.gamePhase = 'results'
moveRecord.resultAfter = newState.winner === 'W' ? 'WINS_W' : 'WINS_B'
} else {
// Harmony broken
newState.pendingHarmony = null
}
} else {
// Check if the SAME harmony still exists
if (isHarmonyStillValid(newState.pieces, newState.pendingHarmony)) {
newState.winner = newState.pendingHarmony.by
newState.winCondition = 'HARMONY'
newState.gamePhase = 'results'
moveRecord.resultAfter = newState.winner === 'W' ? 'WINS_W' : 'WINS_B'
} else {
newState.pendingHarmony = null
}
}
}
// Check for point victory (if enabled)
if (newState.pointsCaptured && context) {
const config = this.getConfigFromState(newState)
if (config.pointWinEnabled) {
const capturedByMover = newState.pointsCaptured[piece.color]
if (capturedByMover >= config.pointWinThreshold) {
newState.winner = piece.color
newState.winCondition = 'POINTS'
newState.gamePhase = 'results'
moveRecord.resultAfter = newState.winner === 'W' ? 'WINS_W' : 'WINS_B'
}
}
}
// Check for exhaustion (opponent has no legal moves)
const opponentHasMoves = this.hasLegalMoves(newState, newState.turn)
if (!opponentHasMoves) {
newState.winner = opponentColor(newState.turn)
newState.winCondition = 'EXHAUSTION'
newState.gamePhase = 'results'
moveRecord.resultAfter = newState.winner === 'W' ? 'WINS_W' : 'WINS_B'
}
return newState
}
/**
* Validate an ambush capture.
*/
private validateAmbush(
state: RithmomachiaState,
color: Color,
ambush: AmbushContext
): ValidationResult {
// Get the enemy piece
let enemyPiece: Piece
try {
enemyPiece = getPieceById(state.pieces, ambush.enemyPieceId)
} catch (e) {
return { valid: false, error: `Enemy piece not found: ${ambush.enemyPieceId}` }
}
// Must be enemy
if (enemyPiece.color === color) {
return { valid: false, error: 'Ambush target must be enemy' }
}
// Get helpers
let helper1: Piece
let helper2: Piece
try {
helper1 = getPieceById(state.pieces, ambush.helper1Id)
helper2 = getPieceById(state.pieces, ambush.helper2Id)
} catch (e) {
return { valid: false, error: 'Helper not found' }
}
// Helpers must be friendly
if (helper1.color !== color || helper2.color !== color) {
return { valid: false, error: 'Helpers must be friendly' }
}
// Helpers must be alive
if (helper1.captured || helper2.captured) {
return { valid: false, error: 'Helper is captured' }
}
// Helpers must be distinct
if (helper1.id === helper2.id) {
return { valid: false, error: 'Helpers must be distinct' }
}
// Helpers cannot be Pyramids (v1)
if (helper1.type === 'P' || helper2.type === 'P') {
return { valid: false, error: 'Pyramids cannot be helpers in v1' }
}
// Get values
const enemyValue = getEffectiveValue(enemyPiece)
const helper1Value = getEffectiveValue(helper1)
const helper2Value = getEffectiveValue(helper2)
if (enemyValue === null || helper1Value === null || helper2Value === null) {
return { valid: false, error: 'Piece has no value' }
}
// Check the relation using the TWO helpers
// For ambush, we interpret the relation as: helper1 and helper2 combine to match enemy
// For example: SUM means helper1 + helper2 = enemy
const relationCheck = checkRelation(ambush.relation, helper1Value, enemyValue, helper2Value)
if (!relationCheck.valid) {
return { valid: false, error: relationCheck.explanation || 'Ambush relation failed' }
}
return { valid: true }
}
/**
* Handle DECLARE_HARMONY action.
*/
private handleDeclareHarmony(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'DECLARE_HARMONY' }>,
context?: ValidationContext
): ValidationResult {
const { pieceIds, harmonyType, params } = move.data
// Must be declaring player's turn
// (We need to get the player's color from context)
// For now, assume it's the current turn's player
const declaringColor = state.turn
// Get the pieces
const pieces = pieceIds
.map((id) => {
try {
return getPieceById(state.pieces, id)
} catch (e) {
return null
}
})
.filter((p): p is Piece => p !== null)
if (pieces.length !== pieceIds.length) {
return { valid: false, error: 'Some pieces not found' }
}
// Validate the harmony
const validation = validateHarmony(pieces, declaringColor)
if (!validation.valid) {
return { valid: false, error: validation.reason }
}
// Check type matches
if (validation.type !== harmonyType) {
return { valid: false, error: `Expected ${harmonyType} but found ${validation.type}` }
}
// Create harmony declaration
const harmony: HarmonyDeclaration = {
by: declaringColor,
pieceIds,
type: harmonyType,
params,
declaredAtPly: state.history.length,
}
// Clone state
const newState = {
...state,
pendingHarmony: harmony,
history: [...state.history],
}
// Add to history
const moveRecord: MoveRecord = {
ply: newState.history.length + 1,
color: declaringColor,
from: '',
to: '',
pieceId: '',
harmonyDeclared: harmony,
fenLikeHash: state.stateHashes[state.stateHashes.length - 1],
noProgressCount: state.noProgressCount,
resultAfter: 'ONGOING',
}
newState.history.push(moveRecord)
// Do NOT switch turn - harmony declaration is free
return { valid: true, newState }
}
/**
* Handle RESIGN action.
*/
private handleResign(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'RESIGN' }>,
context?: ValidationContext
): ValidationResult {
const resigningColor = state.turn
const winner = opponentColor(resigningColor)
const newState = {
...state,
winner,
winCondition: 'RESIGNATION' as const,
gamePhase: 'results' as const,
}
return { valid: true, newState }
}
/**
* Handle draw offers/accepts.
*/
private handleDraw(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'OFFER_DRAW' | 'ACCEPT_DRAW' }>,
context?: ValidationContext
): ValidationResult {
// For simplicity, accept any draw (we'd need to track offers in state for proper implementation)
const newState = {
...state,
winner: null,
winCondition: 'AGREEMENT' as const,
gamePhase: 'results' as const,
}
return { valid: true, newState }
}
/**
* Handle repetition claim.
*/
private handleClaimRepetition(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'CLAIM_REPETITION' }>,
context?: ValidationContext
): ValidationResult {
const config = this.getConfigFromState(state)
if (!config.repetitionRule) {
return { valid: false, error: 'Repetition rule not enabled' }
}
if (isThreefoldRepetition(state.stateHashes)) {
const newState = {
...state,
winner: null,
winCondition: 'REPETITION' as const,
gamePhase: 'results' as const,
}
return { valid: true, newState }
}
return { valid: false, error: 'No threefold repetition detected' }
}
/**
* Handle fifty-move rule claim.
*/
private handleClaimFiftyMove(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'CLAIM_FIFTY_MOVE' }>,
context?: ValidationContext
): ValidationResult {
const config = this.getConfigFromState(state)
if (!config.fiftyMoveRule) {
return { valid: false, error: 'Fifty-move rule not enabled' }
}
if (state.noProgressCount >= 50) {
const newState = {
...state,
winner: null,
winCondition: 'FIFTY' as const,
gamePhase: 'results' as const,
}
return { valid: true, newState }
}
return {
valid: false,
error: `Only ${state.noProgressCount} moves without progress (need 50)`,
}
}
/**
* Handle SET_CONFIG action.
* Updates a single config field in the state.
*/
private handleSetConfig(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'SET_CONFIG' }>,
context?: ValidationContext
): ValidationResult {
const { field, value } = move.data
// Validate the field exists in config
const validFields: Array<keyof RithmomachiaConfig> = [
'pointWinEnabled',
'pointWinThreshold',
'repetitionRule',
'fiftyMoveRule',
'allowAnySetOnRecheck',
'timeControlMs',
]
if (!validFields.includes(field as keyof RithmomachiaConfig)) {
return { valid: false, error: `Invalid config field: ${field}` }
}
// Basic type validation
if (
field === 'pointWinEnabled' ||
field === 'repetitionRule' ||
field === 'fiftyMoveRule' ||
field === 'allowAnySetOnRecheck'
) {
if (typeof value !== 'boolean') {
return { valid: false, error: `${field} must be a boolean` }
}
}
if (field === 'pointWinThreshold') {
if (typeof value !== 'number' || value < 1) {
return { valid: false, error: 'pointWinThreshold must be a positive number' }
}
}
if (field === 'timeControlMs') {
if (value !== null && (typeof value !== 'number' || value < 0)) {
return { valid: false, error: 'timeControlMs must be null or a non-negative number' }
}
}
// Create new state with updated config field
const newState = {
...state,
[field]: value,
}
// If enabling point tracking and it doesn't exist, initialize it
if (field === 'pointWinEnabled' && value === true && !state.pointsCaptured) {
newState.pointsCaptured = { W: 0, B: 0 }
}
return { valid: true, newState }
}
/**
* Handle RESET_GAME action.
* Creates a fresh game state with the current config and immediately starts playing.
*/
private handleResetGame(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'RESET_GAME' }>,
context?: ValidationContext
): ValidationResult {
// Extract current config from state
const config = this.getConfigFromState(state)
// Get fresh initial state with current config
const newState = this.getInitialState(config)
// Immediately transition to playing phase (skip setup)
newState.gamePhase = 'playing'
return { valid: true, newState }
}
/**
* Handle GO_TO_SETUP action.
* Returns to setup phase, preserving config but resetting game state.
*/
private handleGoToSetup(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'GO_TO_SETUP' }>,
context?: ValidationContext
): ValidationResult {
// Extract current config from state
const config = this.getConfigFromState(state)
// Get fresh initial state (which starts in setup phase) with current config
const newState = this.getInitialState(config)
return { valid: true, newState }
}
// ==================== HELPER METHODS ====================
/**
* Check if a player has any legal moves.
*/
private hasLegalMoves(state: RithmomachiaState, color: Color): boolean {
const pieces = getLivePiecesForColor(state.pieces, color)
for (const piece of pieces) {
// Check all possible destinations
for (let file = 0; file < 16; file++) {
for (let rank = 1; rank <= 8; rank++) {
const to = `${String.fromCharCode(65 + file)}${rank}`
// Skip same square
if (to === piece.square) continue
// Check if move is geometrically legal
const validation = validateMove(piece, piece.square, to, state.pieces)
if (validation.valid) {
// Check if destination is empty or has enemy that can be captured
const targetPiece = getPieceAt(state.pieces, to)
if (!targetPiece) {
// Empty square = legal move
return true
}
if (targetPiece.color !== color) {
// Enemy piece - check if any capture relation exists
// (We'll simplify and say yes if any no-helper relation works)
const moverValue = getEffectiveValue(piece)
const targetValue = getEffectiveValue(targetPiece)
if (moverValue && targetValue) {
// Check for simple relations (no helper required)
const simpleRelations = ['EQUAL', 'MULTIPLE', 'DIVISOR'] as const
for (const relation of simpleRelations) {
const check = checkRelation(relation, moverValue, targetValue)
if (check.valid) {
return true
}
}
// Could also check with helpers, but that's expensive
// For now, we assume if simple capture fails, move is not legal
}
}
}
}
}
}
return false
}
/**
* Get piece point value.
*/
private getPiecePoints(piece: Piece): number {
const POINTS: Record<typeof piece.type, number> = {
C: 1,
T: 2,
S: 3,
P: 5,
}
return POINTS[piece.type]
}
/**
* Get config from state (config is stored in state following arcade pattern).
*/
private getConfigFromState(state: RithmomachiaState): RithmomachiaConfig {
return {
pointWinEnabled: state.pointWinEnabled,
pointWinThreshold: state.pointWinThreshold,
repetitionRule: state.repetitionRule,
fiftyMoveRule: state.fiftyMoveRule,
allowAnySetOnRecheck: state.allowAnySetOnRecheck,
timeControlMs: state.timeControlMs,
}
}
}
export const rithmomachiaValidator = new RithmomachiaValidator()

View File

@@ -0,0 +1,162 @@
import type { Color, PieceType } from '../types'
interface PieceRendererProps {
type: PieceType
color: Color
value: number | string
size?: number
}
/**
* SVG-based piece renderer with precise color control.
* BLACK pieces: dark fill, point RIGHT (towards white)
* WHITE pieces: light fill, point LEFT (towards black)
*/
export function PieceRenderer({ type, color, value, size = 48 }: PieceRendererProps) {
const isDark = color === 'B'
const fillColor = isDark ? '#1a1a1a' : '#e8e8e8'
const strokeColor = isDark ? '#000000' : '#333333'
const textColor = isDark ? '#ffffff' : '#000000'
// Calculate responsive font size based on value length
const valueStr = value.toString()
const baseSize = type === 'P' ? size * 0.18 : size * 0.28
let fontSize = baseSize
if (valueStr.length >= 3) {
fontSize = baseSize * 0.7 // 3+ digits: smaller
} else if (valueStr.length === 2) {
fontSize = baseSize * 0.85 // 2 digits: slightly smaller
}
const renderShape = () => {
switch (type) {
case 'C': // Circle
return (
<circle
cx={size / 2}
cy={size / 2}
r={size * 0.38}
fill={fillColor}
stroke={strokeColor}
strokeWidth={2}
/>
)
case 'T': // Triangle - BLACK points RIGHT, WHITE points LEFT
if (isDark) {
// Black triangle points RIGHT (towards white)
return (
<polygon
points={`${size * 0.15},${size * 0.15} ${size * 0.85},${size / 2} ${size * 0.15},${size * 0.85}`}
fill={fillColor}
stroke={strokeColor}
strokeWidth={2}
/>
)
} else {
// White triangle points LEFT (towards black)
return (
<polygon
points={`${size * 0.85},${size * 0.15} ${size * 0.15},${size / 2} ${size * 0.85},${size * 0.85}`}
fill={fillColor}
stroke={strokeColor}
strokeWidth={2}
/>
)
}
case 'S': // Square
return (
<rect
x={size * 0.15}
y={size * 0.15}
width={size * 0.7}
height={size * 0.7}
fill={fillColor}
stroke={strokeColor}
strokeWidth={2}
/>
)
case 'P': {
// Pyramid - rotated 90° to point at opponent
// Create centered pyramid, then rotate: BLACK→right (90°), WHITE→left (-90°)
const rotation = isDark ? 90 : -90
return (
<g transform={`rotate(${rotation}, ${size / 2}, ${size / 2})`}>
{/* Top/smallest bar - centered */}
<rect
x={size * 0.35}
y={size * 0.1}
width={size * 0.3}
height={size * 0.15}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1.5}
/>
{/* Second bar */}
<rect
x={size * 0.25}
y={size * 0.3}
width={size * 0.5}
height={size * 0.15}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1.5}
/>
{/* Third bar */}
<rect
x={size * 0.15}
y={size * 0.5}
width={size * 0.7}
height={size * 0.15}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1.5}
/>
{/* Bottom/largest bar */}
<rect
x={size * 0.05}
y={size * 0.7}
width={size * 0.9}
height={size * 0.15}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1.5}
/>
</g>
)
}
default:
return null
}
}
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{renderShape()}
{/* Pyramids don't show numbers */}
{type !== 'P' && (
<text
x={size / 2}
y={size / 2}
textAnchor="middle"
dominantBaseline="central"
fill={textColor}
fontSize={fontSize}
fontWeight="bold"
fontFamily="system-ui, -apple-system, sans-serif"
// Only add white outline for white pieces (to separate from dark borders)
{...(!isDark && {
stroke: '#ffffff',
strokeWidth: fontSize * 0.15,
paintOrder: 'stroke fill',
})}
>
{value}
</text>
)}
</svg>
)
}

View File

@@ -0,0 +1,785 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { animated, useSpring } from '@react-spring/web'
import { PageWithNav } from '@/components/PageWithNav'
import { StandardGameLayout } from '@/components/StandardGameLayout'
import { useFullscreen } from '@/contexts/FullscreenContext'
import { css } from '../../../../styled-system/css'
import { useRithmomachia } from '../Provider'
import type { RithmomachiaConfig, Piece } from '../types'
import { PieceRenderer } from './PieceRenderer'
/**
* Main Rithmomachia game component.
* Orchestrates the game phases and UI.
*/
export function RithmomachiaGame() {
const router = useRouter()
const { state, resetGame, goToSetup } = useRithmomachia()
const { setFullscreenElement } = useFullscreen()
const gameRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// Register this component's main div as the fullscreen element
if (gameRef.current) {
setFullscreenElement(gameRef.current)
}
}, [setFullscreenElement])
return (
<PageWithNav
navTitle="Rithmomachia"
navEmoji="🎲"
emphasizePlayerSelection={state.gamePhase === 'setup'}
onExitSession={() => {
router.push('/arcade')
}}
onNewGame={resetGame}
onSetup={goToSetup}
>
<StandardGameLayout>
<div
ref={gameRef}
className={css({
flex: 1,
padding: { base: '12px', sm: '16px', md: '20px' },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'relative',
overflow: 'auto',
})}
>
<main
className={css({
width: '100%',
maxWidth: '1200px',
background: 'rgba(255,255,255,0.95)',
borderRadius: { base: '12px', md: '20px' },
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
})}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <PlayingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</main>
</div>
</StandardGameLayout>
</PageWithNav>
)
}
/**
* Setup phase: game configuration and start button.
*/
function SetupPhase() {
const { state, startGame, setConfig, lastError, clearError } = useRithmomachia()
const toggleSetting = (key: keyof typeof state) => {
if (typeof state[key] === 'boolean') {
setConfig(key as keyof RithmomachiaConfig, !state[key])
}
}
const updateThreshold = (value: number) => {
setConfig('pointWinThreshold', Math.max(1, value))
}
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6',
p: '6',
maxWidth: '800px',
margin: '0 auto',
})}
>
{lastError && (
<div
className={css({
width: '100%',
p: '4',
bg: 'red.100',
borderColor: 'red.400',
borderWidth: '2px',
borderRadius: 'md',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<span className={css({ color: 'red.800', fontWeight: 'semibold' })}> {lastError}</span>
<button
type="button"
onClick={clearError}
className={css({
px: '3',
py: '1',
bg: 'red.200',
color: 'red.800',
borderRadius: 'sm',
fontWeight: 'semibold',
cursor: 'pointer',
_hover: { bg: 'red.300' },
})}
>
Dismiss
</button>
</div>
)}
<div className={css({ textAlign: 'center' })}>
<h1 className={css({ fontSize: '3xl', fontWeight: 'bold', mb: '2' })}>Rithmomachia</h1>
<p className={css({ color: 'gray.600', fontSize: 'lg' })}>The Battle of Numbers</p>
<p className={css({ color: 'gray.500', fontSize: 'sm', mt: '2', maxWidth: '600px' })}>
A medieval strategy game where pieces capture through mathematical relations. Win by
achieving harmony (a mathematical progression) in enemy territory!
</p>
</div>
{/* Game Settings */}
<div
className={css({
width: '100%',
bg: 'white',
borderRadius: 'lg',
p: '6',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
})}
>
<h2 className={css({ fontSize: 'xl', fontWeight: 'bold', mb: '4' })}>Game Rules</h2>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '4' })}>
{/* Point Victory */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: '3',
bg: 'gray.50',
borderRadius: 'md',
})}
>
<div>
<div className={css({ fontWeight: 'semibold' })}>Point Victory</div>
<div className={css({ fontSize: 'sm', color: 'gray.600' })}>
Win by capturing pieces worth {state.pointWinThreshold} points
</div>
</div>
<label className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
<input
type="checkbox"
checked={state.pointWinEnabled}
onChange={() => toggleSetting('pointWinEnabled')}
className={css({ cursor: 'pointer', width: '18px', height: '18px' })}
/>
</label>
</div>
{/* Point Threshold (only visible if point win enabled) */}
{state.pointWinEnabled && (
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: '3',
bg: 'purple.50',
borderRadius: 'md',
ml: '4',
})}
>
<div className={css({ fontWeight: 'semibold' })}>Point Threshold</div>
<input
type="number"
value={state.pointWinThreshold}
onChange={(e) => updateThreshold(Number.parseInt(e.target.value, 10))}
min="1"
className={css({
width: '80px',
px: '3',
py: '2',
borderRadius: 'md',
border: '1px solid',
borderColor: 'purple.300',
textAlign: 'center',
fontSize: 'md',
fontWeight: 'semibold',
})}
/>
</div>
)}
{/* Threefold Repetition */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: '3',
bg: 'gray.50',
borderRadius: 'md',
})}
>
<div>
<div className={css({ fontWeight: 'semibold' })}>Threefold Repetition Draw</div>
<div className={css({ fontSize: 'sm', color: 'gray.600' })}>
Draw if same position occurs 3 times
</div>
</div>
<label className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
<input
type="checkbox"
checked={state.repetitionRule}
onChange={() => toggleSetting('repetitionRule')}
className={css({ cursor: 'pointer', width: '18px', height: '18px' })}
/>
</label>
</div>
{/* Fifty Move Rule */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: '3',
bg: 'gray.50',
borderRadius: 'md',
})}
>
<div>
<div className={css({ fontWeight: 'semibold' })}>Fifty-Move Rule</div>
<div className={css({ fontSize: 'sm', color: 'gray.600' })}>
Draw if 50 moves with no capture or harmony
</div>
</div>
<label className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
<input
type="checkbox"
checked={state.fiftyMoveRule}
onChange={() => toggleSetting('fiftyMoveRule')}
className={css({ cursor: 'pointer', width: '18px', height: '18px' })}
/>
</label>
</div>
{/* Harmony Persistence */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: '3',
bg: 'gray.50',
borderRadius: 'md',
})}
>
<div>
<div className={css({ fontWeight: 'semibold' })}>Flexible Harmony</div>
<div className={css({ fontSize: 'sm', color: 'gray.600' })}>
Allow any valid harmony for persistence (not just the declared one)
</div>
</div>
<label className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
<input
type="checkbox"
checked={state.allowAnySetOnRecheck}
onChange={() => toggleSetting('allowAnySetOnRecheck')}
className={css({ cursor: 'pointer', width: '18px', height: '18px' })}
/>
</label>
</div>
</div>
</div>
{/* Start Button */}
<button
type="button"
onClick={startGame}
className={css({
px: '8',
py: '4',
bg: 'purple.600',
color: 'white',
borderRadius: 'lg',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
bg: 'purple.700',
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.4)',
},
})}
>
Start Game
</button>
</div>
)
}
/**
* Playing phase: main game board and controls.
*/
function PlayingPhase() {
const { state, isMyTurn, lastError, clearError } = useRithmomachia()
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '4',
p: '4',
})}
>
{lastError && (
<div
className={css({
width: '100%',
p: '4',
bg: 'red.100',
borderColor: 'red.400',
borderWidth: '2px',
borderRadius: 'md',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<span className={css({ color: 'red.800', fontWeight: 'semibold' })}> {lastError}</span>
<button
type="button"
onClick={clearError}
className={css({
px: '3',
py: '1',
bg: 'red.200',
color: 'red.800',
borderRadius: 'sm',
fontWeight: 'semibold',
cursor: 'pointer',
_hover: { bg: 'red.300' },
})}
>
Dismiss
</button>
</div>
)}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: '4',
bg: 'gray.100',
borderRadius: 'md',
})}
>
<div>
<span className={css({ fontWeight: 'bold' })}>Turn: </span>
<span className={css({ color: state.turn === 'W' ? 'gray.800' : 'gray.600' })}>
{state.turn === 'W' ? 'White' : 'Black'}
</span>
</div>
{isMyTurn && (
<div
className={css({
px: '3',
py: '1',
bg: 'green.100',
color: 'green.800',
borderRadius: 'md',
fontSize: 'sm',
fontWeight: 'semibold',
})}
>
Your Turn
</div>
)}
</div>
<BoardDisplay />
<div
className={css({
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '4',
})}
>
<div className={css({ p: '4', bg: 'gray.100', borderRadius: 'md' })}>
<h3 className={css({ fontWeight: 'bold', mb: '2' })}>White Captured</h3>
<div className={css({ fontSize: 'sm' })}>{state.capturedPieces.W.length} pieces</div>
</div>
<div className={css({ p: '4', bg: 'gray.100', borderRadius: 'md' })}>
<h3 className={css({ fontWeight: 'bold', mb: '2' })}>Black Captured</h3>
<div className={css({ fontSize: 'sm' })}>{state.capturedPieces.B.length} pieces</div>
</div>
</div>
</div>
)
}
/**
* Animated piece component that smoothly transitions between squares.
*/
function AnimatedPiece({
piece,
gridSize,
}: {
piece: Piece
gridSize: { width: number; height: number }
}) {
// Parse square to get column and row
const file = piece.square.charCodeAt(0) - 65 // A=0, B=1, etc.
const rank = Number.parseInt(piece.square.slice(1), 10) // 1-8
// Calculate position (inverted rank for display: rank 8 = row 0)
const col = file
const row = 8 - rank
// Animate position changes
const spring = useSpring({
left: `${(col / 16) * 100}%`,
top: `${(row / 8) * 100}%`,
config: { tension: 280, friction: 60 },
})
return (
<animated.div
style={{
...spring,
position: 'absolute',
width: `${100 / 16}%`,
height: `${100 / 8}%`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<PieceRenderer
type={piece.type}
color={piece.color}
value={piece.type === 'P' ? piece.pyramidFaces?.[0] || 0 : piece.value || 0}
size={56}
/>
</animated.div>
)
}
/**
* Board display component (simplified for v1).
*/
function BoardDisplay() {
const { state, makeMove, playerColor, isMyTurn } = useRithmomachia()
const [selectedSquare, setSelectedSquare] = useState<string | null>(null)
const handleSquareClick = (square: string, piece: (typeof state.pieces)[string] | undefined) => {
if (!isMyTurn) return
// If no piece selected, select this piece if it's yours
if (!selectedSquare) {
if (piece && piece.color === playerColor) {
setSelectedSquare(square)
}
return
}
// If clicking the same square, deselect
if (selectedSquare === square) {
setSelectedSquare(null)
return
}
// If clicking another piece of yours, select that instead
if (piece && piece.color === playerColor) {
setSelectedSquare(square)
return
}
// Otherwise, attempt to move
const selectedPiece = Object.values(state.pieces).find(
(p) => p.square === selectedSquare && !p.captured
)
if (selectedPiece) {
// Simple move (no capture logic for now - just basic movement)
makeMove(selectedSquare, square, selectedPiece.id)
setSelectedSquare(null)
}
}
// Get all active pieces
const activePieces = Object.values(state.pieces).filter((p) => !p.captured)
return (
<div
className={css({
position: 'relative',
width: '100%',
maxWidth: '1200px',
margin: '0 auto',
})}
>
{/* Board grid */}
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(16, 1fr)',
gap: '1',
bg: 'gray.300',
p: '2',
borderRadius: 'md',
aspectRatio: '16/8',
})}
>
{Array.from({ length: 8 }, (_, rank) => {
const actualRank = 8 - rank
return Array.from({ length: 16 }, (_, file) => {
const square = `${String.fromCharCode(65 + file)}${actualRank}`
const piece = Object.values(state.pieces).find(
(p) => p.square === square && !p.captured
)
const isLight = (file + actualRank) % 2 === 0
const isSelected = selectedSquare === square
return (
<div
key={square}
onClick={() => handleSquareClick(square, piece)}
className={css({
bg: isSelected ? 'yellow.300' : isLight ? 'gray.100' : 'gray.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
fontSize: 'xs',
aspectRatio: '1',
position: 'relative',
cursor: isMyTurn ? 'pointer' : 'default',
_hover: isMyTurn
? { bg: isSelected ? 'yellow.400' : isLight ? 'purple.100' : 'purple.200' }
: {},
border: isSelected ? '2px solid' : 'none',
borderColor: 'purple.600',
})}
/>
)
})
})}
</div>
{/* Animated pieces layer - matches board padding */}
<div
className={css({
position: 'absolute',
top: '0.5rem',
left: '0.5rem',
right: '0.5rem',
bottom: '0.5rem',
pointerEvents: 'none',
})}
>
{activePieces.map((piece) => (
<AnimatedPiece key={piece.id} piece={piece} gridSize={{ width: 16, height: 8 }} />
))}
</div>
</div>
)
}
/**
* Results phase: show winner and game summary.
*/
function ResultsPhase() {
const { state, resetGame, goToSetup, exitSession, lastError, clearError } = useRithmomachia()
const winnerText = state.winner === 'W' ? 'White' : 'Black'
const totalMoves = state.history.length
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
gap: '4',
})}
>
{lastError && (
<div
className={css({
width: '100%',
maxWidth: '600px',
p: '4',
bg: 'red.100',
borderColor: 'red.400',
borderWidth: '2px',
borderRadius: 'md',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<span className={css({ color: 'red.800', fontWeight: 'semibold' })}> {lastError}</span>
<button
type="button"
onClick={clearError}
className={css({
px: '3',
py: '1',
bg: 'red.200',
color: 'red.800',
borderRadius: 'sm',
fontWeight: 'semibold',
cursor: 'pointer',
_hover: { bg: 'red.300' },
})}
>
Dismiss
</button>
</div>
)}
<h1 className={css({ fontSize: '3xl', fontWeight: 'bold' })}>Game Over</h1>
{state.winner ? (
<>
<div className={css({ fontSize: '2xl', color: 'purple.600', fontWeight: 'semibold' })}>
{winnerText} Wins!
</div>
<div className={css({ fontSize: 'lg', color: 'gray.600' })}>
Victory by {state.winCondition?.toLowerCase().replace('_', ' ')}
</div>
</>
) : (
<div className={css({ fontSize: '2xl', color: 'gray.600' })}>Draw</div>
)}
<div className={css({ display: 'flex', gap: '4', mt: '4' })}>
<div className={css({ p: '4', bg: 'gray.100', borderRadius: 'md' })}>
<div className={css({ fontWeight: 'bold' })}>White Captured</div>
<div>{state.capturedPieces.W.length} pieces</div>
</div>
<div className={css({ p: '4', bg: 'gray.100', borderRadius: 'md' })}>
<div className={css({ fontWeight: 'bold' })}>Black Captured</div>
<div>{state.capturedPieces.B.length} pieces</div>
</div>
</div>
{state.pointsCaptured && (
<div className={css({ display: 'flex', gap: '4' })}>
<div className={css({ p: '4', bg: 'purple.100', borderRadius: 'md' })}>
<div className={css({ fontWeight: 'bold', color: 'purple.700' })}>White Points</div>
<div className={css({ fontSize: 'lg', fontWeight: 'semibold' })}>
{state.pointsCaptured.W}
</div>
</div>
<div className={css({ p: '4', bg: 'purple.100', borderRadius: 'md' })}>
<div className={css({ fontWeight: 'bold', color: 'purple.700' })}>Black Points</div>
<div className={css({ fontSize: 'lg', fontWeight: 'semibold' })}>
{state.pointsCaptured.B}
</div>
</div>
</div>
)}
<div className={css({ fontSize: 'sm', color: 'gray.500', mt: '4' })}>
Total moves: {totalMoves}
</div>
{/* Action Buttons */}
<div
className={css({
display: 'flex',
flexDirection: { base: 'column', sm: 'row' },
gap: '3',
mt: '6',
})}
>
<button
type="button"
onClick={resetGame}
className={css({
px: '6',
py: '3',
bg: 'purple.600',
color: 'white',
borderRadius: 'md',
fontWeight: 'semibold',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
bg: 'purple.700',
transform: 'translateY(-2px)',
},
})}
>
🎮 Play Again
</button>
<button
type="button"
onClick={goToSetup}
className={css({
px: '6',
py: '3',
bg: 'white',
color: 'gray.700',
border: '2px solid',
borderColor: 'gray.300',
borderRadius: 'md',
fontWeight: 'semibold',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
borderColor: 'gray.400',
bg: 'gray.50',
},
})}
>
Settings
</button>
<button
type="button"
onClick={exitSession}
className={css({
px: '6',
py: '3',
bg: 'white',
color: 'red.700',
border: '2px solid',
borderColor: 'red.300',
borderRadius: 'md',
fontWeight: 'semibold',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
borderColor: 'red.400',
bg: 'red.50',
},
})}
>
🚪 Exit
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { defineGame, type GameManifest, getGameTheme } from '@/lib/arcade/game-sdk'
import { RithmomachiaGame } from './components/RithmomachiaGame'
import { RithmomachiaProvider } from './Provider'
import type { RithmomachiaConfig, RithmomachiaMove, RithmomachiaState } from './types'
import { rithmomachiaValidator } from './Validator'
/**
* Game manifest for Rithmomachia.
*/
const manifest: GameManifest = {
name: 'rithmomachia',
displayName: 'Rithmomachia',
icon: '🎲',
description: 'Medieval mathematical battle game',
longDescription:
'Rithmomachia (Battle of Numbers) is a medieval strategy game where pieces with numerical values capture each other through mathematical relations. Win by achieving harmony (a mathematical progression) in enemy territory, or by capturing enough pieces to exhaust your opponent.',
maxPlayers: 2,
difficulty: 'Advanced',
chips: ['⚔️ Strategy', '🔢 Mathematical', '🏛️ Historical', '🎯 Two-Player'],
...getGameTheme('purple'),
available: true,
}
/**
* Default configuration for Rithmomachia.
*/
const defaultConfig: RithmomachiaConfig = {
pointWinEnabled: false,
pointWinThreshold: 30,
repetitionRule: true,
fiftyMoveRule: true,
allowAnySetOnRecheck: true,
timeControlMs: null,
}
/**
* Config validation (type guard).
* Validates all config fields and their constraints.
*/
function validateConfig(config: unknown): config is RithmomachiaConfig {
if (typeof config !== 'object' || config === null) {
return false
}
const c = config as Record<string, unknown>
// Validate pointWinEnabled
if (!('pointWinEnabled' in c) || typeof c.pointWinEnabled !== 'boolean') {
return false
}
// Validate pointWinThreshold
if (
!('pointWinThreshold' in c) ||
typeof c.pointWinThreshold !== 'number' ||
c.pointWinThreshold < 1
) {
return false
}
// Validate repetitionRule
if (!('repetitionRule' in c) || typeof c.repetitionRule !== 'boolean') {
return false
}
// Validate fiftyMoveRule
if (!('fiftyMoveRule' in c) || typeof c.fiftyMoveRule !== 'boolean') {
return false
}
// Validate allowAnySetOnRecheck
if (!('allowAnySetOnRecheck' in c) || typeof c.allowAnySetOnRecheck !== 'boolean') {
return false
}
// Validate timeControlMs
if ('timeControlMs' in c) {
if (c.timeControlMs !== null && (typeof c.timeControlMs !== 'number' || c.timeControlMs < 0)) {
return false
}
}
return true
}
/**
* Rithmomachia game definition.
*/
export const rithmomachiaGame = defineGame<RithmomachiaConfig, RithmomachiaState, RithmomachiaMove>(
{
manifest,
Provider: RithmomachiaProvider,
GameComponent: RithmomachiaGame,
validator: rithmomachiaValidator,
defaultConfig,
validateConfig,
}
)

View File

@@ -0,0 +1,312 @@
import type { GameConfig, GameState } from '@/lib/arcade/game-sdk'
// === PIECE TYPES ===
export type PieceType = 'C' | 'T' | 'S' | 'P' // Circle, Triangle, Square, Pyramid
export type Color = 'W' | 'B' // White, Black
export interface Piece {
id: string // stable UUID (e.g., "W_C_01")
color: Color
type: PieceType
value?: number // for C/T/S always present
pyramidFaces?: number[] // for P only (length 4)
activePyramidFace?: number | null // last chosen face for logging/captures
square: string // "A1".."P8"
captured: boolean
}
// === RELATIONS ===
export type RelationKind =
| 'EQUAL' // a == b
| 'MULTIPLE' // a % b == 0
| 'DIVISOR' // b % a == 0
| 'SUM' // a + h == b or b + h == a
| 'DIFF' // |a - h| == b or |b - h| == a
| 'PRODUCT' // a * h == b or b * h == a
| 'RATIO' // a * r == b or b * r == a (r = helper value)
export interface CaptureContext {
relation: RelationKind
moverPieceId: string
targetPieceId: string
helperPieceId?: string // required for SUM/DIFF/PRODUCT/RATIO
moverFaceUsed?: number | null // if mover was a Pyramid
}
export interface AmbushContext {
relation: RelationKind
enemyPieceId: string
helper1Id: string
helper2Id: string // two helpers for ambush
}
// === HARMONY ===
export type HarmonyType = 'ARITH' | 'GEOM' | 'HARM'
export interface HarmonyDeclaration {
by: Color
pieceIds: string[] // ≥3
type: HarmonyType
params: {
v?: string // store as strings for bigints
d?: string // difference (ARITH)
r?: string // ratio (GEOM)
n?: string // harmonic parameter
}
declaredAtPly: number
}
// === MOVE RECORDS ===
export interface MoveRecord {
ply: number
color: Color
from: string // e.g., "C2"
to: string // e.g., "C6"
pieceId: string
pyramidFaceUsed?: number | null
capture?: CaptureContext | null
ambush?: AmbushContext | null
harmonyDeclared?: HarmonyDeclaration | null
pointsCapturedThisMove?: number // if point scoring is on
fenLikeHash?: string // for repetition detection
noProgressCount?: number // for 50-move rule
resultAfter?: 'ONGOING' | 'WINS_W' | 'WINS_B' | 'DRAW'
}
// === GAME STATE ===
export interface RithmomachiaState extends GameState {
// Configuration (stored in state per arcade pattern)
pointWinEnabled: boolean
pointWinThreshold: number
repetitionRule: boolean
fiftyMoveRule: boolean
allowAnySetOnRecheck: boolean
timeControlMs: number | null
// Game phase
gamePhase: 'setup' | 'playing' | 'results'
// Board dimensions
boardCols: number // 16
boardRows: number // 8
// Current turn
turn: Color // 'W' or 'B'
// Pieces (key = piece.id)
pieces: Record<string, Piece>
// Captured pieces
capturedPieces: {
W: Piece[]
B: Piece[]
}
// Move history
history: MoveRecord[]
// Pending harmony (declared last turn, awaiting validation)
pendingHarmony: HarmonyDeclaration | null
// Draw/repetition tracking
noProgressCount: number // for 50-move rule
stateHashes: string[] // Zobrist hashes for repetition detection
// Victory state
winner: Color | null
winCondition:
| 'HARMONY'
| 'EXHAUSTION'
| 'RESIGNATION'
| 'POINTS'
| 'AGREEMENT'
| 'REPETITION'
| 'FIFTY'
| null
// Points (if enabled by config)
pointsCaptured?: {
W: number
B: number
}
}
// === GAME CONFIG ===
export interface RithmomachiaConfig extends GameConfig {
// Rule toggles
pointWinEnabled: boolean // default: false
pointWinThreshold: number // default: 30
repetitionRule: boolean // default: true
fiftyMoveRule: boolean // default: true
allowAnySetOnRecheck: boolean // default: true (harmony revalidation)
// Optional time controls (not implemented in v1)
timeControlMs?: number | null
}
// === GAME MOVES ===
export type RithmomachiaMove =
| {
type: 'START_GAME'
playerId: string
userId: string
timestamp: number
data: {
playerColor: Color
activePlayers: string[]
}
}
| {
type: 'MOVE'
playerId: string
userId: string
timestamp: number
data: {
from: string
to: string
pieceId: string
pyramidFaceUsed?: number | null
capture?: Omit<CaptureContext, 'moverPieceId' | 'targetPieceId'> & {
targetPieceId: string
}
ambush?: AmbushContext
}
}
| {
type: 'DECLARE_HARMONY'
playerId: string
userId: string
timestamp: number
data: {
pieceIds: string[]
harmonyType: HarmonyType
params: HarmonyDeclaration['params']
}
}
| {
type: 'RESIGN'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'OFFER_DRAW'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'ACCEPT_DRAW'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'CLAIM_REPETITION'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'CLAIM_FIFTY_MOVE'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SET_CONFIG'
playerId: string
userId: string
timestamp: number
data: {
field: string
value: any
}
}
| {
type: 'RESET_GAME'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'GO_TO_SETUP'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
// === HELPER TYPES ===
// Square notation helpers
export type File =
| 'A'
| 'B'
| 'C'
| 'D'
| 'E'
| 'F'
| 'G'
| 'H'
| 'I'
| 'J'
| 'K'
| 'L'
| 'M'
| 'N'
| 'O'
| 'P'
export type Rank = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
export type Square = `${File}${Rank}`
// Board boundaries
export const WHITE_HALF_ROWS = [1, 2, 3, 4] as const
export const BLACK_HALF_ROWS = [5, 6, 7, 8] as const
// Point values for pieces
export const PIECE_POINTS: Record<PieceType, number> = {
C: 1, // Circle
T: 2, // Triangle
S: 3, // Square
P: 5, // Pyramid
}
// Utility: check if square is in enemy half
export function isInEnemyHalf(square: string, color: Color): boolean {
const rank = Number.parseInt(square[1], 10)
if (color === 'W') {
return (BLACK_HALF_ROWS as readonly number[]).includes(rank)
}
return (WHITE_HALF_ROWS as readonly number[]).includes(rank)
}
// Utility: parse square notation
export function parseSquare(square: string): { file: number; rank: number } {
const file = square.charCodeAt(0) - 65 // A=0, B=1, ..., P=15
const rank = Number.parseInt(square[1], 10) // 1-8
return { file, rank }
}
// Utility: create square notation
export function makeSquare(file: number, rank: number): string {
return `${String.fromCharCode(65 + file)}${rank}`
}
// Utility: get opponent color
export function opponentColor(color: Color): Color {
return color === 'W' ? 'B' : 'W'
}

View File

@@ -0,0 +1,305 @@
import type { Color, HarmonyDeclaration, HarmonyType, Piece } from '../types'
import { isInEnemyHalf } from '../types'
import { getEffectiveValue } from './pieceSetup'
/**
* Harmony (progression) validator for Rithmomachia.
* Detects arithmetic, geometric, and harmonic progressions.
*/
export interface HarmonyValidationResult {
valid: boolean
type?: HarmonyType
params?: HarmonyDeclaration['params']
reason?: string
}
/**
* Check if values form an arithmetic progression.
* Arithmetic: v, v+d, v+2d, ... with d > 0
*/
function isArithmeticProgression(values: number[]): HarmonyValidationResult {
if (values.length < 2) {
return { valid: false, reason: 'Need at least 2 values' }
}
// Sort values
const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
// Calculate common difference
const d = sorted[1] - sorted[0]
if (d <= 0) {
return { valid: false, reason: 'Arithmetic progression requires positive difference' }
}
// Check all consecutive differences
for (let i = 1; i < sorted.length; i++) {
const actualDiff = sorted[i] - sorted[i - 1]
if (actualDiff !== d) {
return {
valid: false,
reason: `Not arithmetic: diff ${sorted[i - 1]}${sorted[i]} is ${actualDiff}, expected ${d}`,
}
}
}
return {
valid: true,
type: 'ARITH',
params: {
v: sorted[0].toString(),
d: d.toString(),
},
}
}
/**
* Check if values form a geometric progression.
* Geometric: v, v·r, v·r², ... with integer r ≥ 2
*/
function isGeometricProgression(values: number[]): HarmonyValidationResult {
if (values.length < 2) {
return { valid: false, reason: 'Need at least 2 values' }
}
// Sort values
const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
// Check for zero (can't have geometric with zero)
if (sorted[0] === 0) {
return { valid: false, reason: 'Geometric progression cannot start with 0' }
}
// Calculate common ratio
if (sorted[1] % sorted[0] !== 0) {
return { valid: false, reason: 'Not geometric: ratio is not an integer' }
}
const r = sorted[1] / sorted[0]
if (r < 2) {
return { valid: false, reason: 'Geometric progression requires ratio ≥ 2' }
}
// Check all consecutive ratios
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] % sorted[i - 1] !== 0) {
return {
valid: false,
reason: `Not geometric: ${sorted[i]} not divisible by ${sorted[i - 1]}`,
}
}
const actualRatio = sorted[i] / sorted[i - 1]
if (actualRatio !== r) {
return {
valid: false,
reason: `Not geometric: ratio ${sorted[i - 1]}${sorted[i]} is ${actualRatio}, expected ${r}`,
}
}
}
return {
valid: true,
type: 'GEOM',
params: {
v: sorted[0].toString(),
r: r.toString(),
},
}
}
/**
* Check if values form a harmonic progression.
* Harmonic: reciprocals form an arithmetic progression.
* 1/v, 1/(v·n/(n-1)), 1/(v·n/(n-2)), ...
*/
function isHarmonicProgression(values: number[]): HarmonyValidationResult {
if (values.length < 3) {
return { valid: false, reason: 'Harmonic progression requires at least 3 values' }
}
// Sort values
const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
// Check for zero
if (sorted.some((v) => v === 0)) {
return { valid: false, reason: 'Harmonic progression cannot contain 0' }
}
// Calculate reciprocals as fractions (to avoid floating point)
// We'll represent 1/v as a rational number and check if differences are equal
// For harmonic progression, we need:
// 1/v1 - 1/v2 = 1/v2 - 1/v3 = ... = constant
//
// This means:
// (v2 - v1) / (v1 * v2) = (v3 - v2) / (v2 * v3)
//
// Cross-multiply: (v2 - v1) * v2 * v3 = (v3 - v2) * v1 * v2
// (v2 - v1) * v3 = (v3 - v2) * v1
for (let i = 1; i < sorted.length - 1; i++) {
const v1 = sorted[i - 1]
const v2 = sorted[i]
const v3 = sorted[i + 1]
const leftSide = (v2 - v1) * v3
const rightSide = (v3 - v2) * v1
if (leftSide !== rightSide) {
return {
valid: false,
reason: 'Not harmonic: reciprocals do not form arithmetic progression',
}
}
}
// Calculate the harmonic parameter n
// For the first three terms: 1/v, 1/(v·n/(n-1)), 1/(v·n/(n-2))
// We can derive n from the relationship, but for simplicity we'll just validate
// the harmonic property holds (which we already did above)
return {
valid: true,
type: 'HARM',
params: {
v: sorted[0].toString(),
},
}
}
/**
* Validate if a set of pieces forms a valid harmony.
* Returns the first valid progression type found, or null.
*/
export function validateHarmony(pieces: Piece[], color: Color): HarmonyValidationResult {
// Check: all pieces must be in enemy half
const notInEnemyHalf = pieces.filter((p) => !isInEnemyHalf(p.square, color))
if (notInEnemyHalf.length > 0) {
return {
valid: false,
reason: `Pieces not in enemy half: ${notInEnemyHalf.map((p) => p.square).join(', ')}`,
}
}
// Check: need at least 3 pieces
if (pieces.length < 3) {
return { valid: false, reason: 'Harmony requires at least 3 pieces' }
}
// Extract values (handling Pyramids)
const values: number[] = []
for (const piece of pieces) {
const value = getEffectiveValue(piece)
if (value === null) {
return {
valid: false,
reason: `Piece ${piece.id} has no effective value (Pyramid face not set?)`,
}
}
values.push(value)
}
// Check for duplicates
const uniqueValues = new Set(values.map((v) => v.toString()))
if (uniqueValues.size !== values.length) {
return { valid: false, reason: 'Harmony cannot contain duplicate values' }
}
// Try to detect progression type (in order: arithmetic, geometric, harmonic)
const arithCheck = isArithmeticProgression(values)
if (arithCheck.valid) {
return arithCheck
}
const geomCheck = isGeometricProgression(values)
if (geomCheck.valid) {
return geomCheck
}
const harmCheck = isHarmonicProgression(values)
if (harmCheck.valid) {
return harmCheck
}
return { valid: false, reason: 'Values do not form any valid progression' }
}
/**
* Find all possible harmonies for a color from a set of pieces.
* Returns an array of all valid 3+ piece combinations that form harmonies.
*/
export function findPossibleHarmonies(
pieces: Record<string, Piece>,
color: Color
): Array<{ pieceIds: string[]; validation: HarmonyValidationResult }> {
const results: Array<{ pieceIds: string[]; validation: HarmonyValidationResult }> = []
// Get all live pieces for this color in enemy half
const candidatePieces = Object.values(pieces).filter(
(p) => p.color === color && !p.captured && isInEnemyHalf(p.square, color)
)
if (candidatePieces.length < 3) {
return results
}
// Generate all combinations of 3+ pieces
// For simplicity, we'll check all subsets of size 3, 4, 5, etc.
const maxSize = Math.min(candidatePieces.length, 8) // Limit to 8 for performance
function* combinations<T>(arr: T[], size: number): Generator<T[]> {
if (size === 0) {
yield []
return
}
if (arr.length === 0) return
const [first, ...rest] = arr
for (const combo of combinations(rest, size - 1)) {
yield [first, ...combo]
}
yield* combinations(rest, size)
}
for (let size = 3; size <= maxSize; size++) {
for (const combo of combinations(candidatePieces, size)) {
const validation = validateHarmony(combo, color)
if (validation.valid) {
results.push({
pieceIds: combo.map((p) => p.id),
validation,
})
}
}
}
return results
}
/**
* Check if a specific harmony declaration is currently valid.
* Used for harmony persistence checking.
*/
export function isHarmonyStillValid(
pieces: Record<string, Piece>,
harmony: HarmonyDeclaration
): boolean {
const relevantPieces = harmony.pieceIds.map((id) => pieces[id]).filter((p) => p && !p.captured)
if (relevantPieces.length < 3) {
return false
}
const validation = validateHarmony(relevantPieces, harmony.by)
return validation.valid
}
/**
* Check if ANY valid harmony exists for a color (for harmony persistence recheck).
*/
export function hasAnyValidHarmony(pieces: Record<string, Piece>, color: Color): boolean {
const harmonies = findPossibleHarmonies(pieces, color)
return harmonies.length > 0
}

View File

@@ -0,0 +1,200 @@
import type { Piece, PieceType } from '../types'
import { makeSquare, parseSquare } from '../types'
/**
* Path validation for Rithmomachia piece movement.
* Checks if a move is geometrically legal and the path is clear.
*/
export interface PathValidationResult {
valid: boolean
reason?: string
}
/**
* Check if a move is geometrically legal for a given piece type.
* Does NOT check if the path is clear (that's done separately).
*/
export function isGeometryLegal(
pieceType: PieceType,
from: string,
to: string
): PathValidationResult {
if (from === to) {
return { valid: false, reason: 'Cannot move to the same square' }
}
const fromCoords = parseSquare(from)
const toCoords = parseSquare(to)
const deltaFile = toCoords.file - fromCoords.file
const deltaRank = toCoords.rank - fromCoords.rank
const absDeltaFile = Math.abs(deltaFile)
const absDeltaRank = Math.abs(deltaRank)
switch (pieceType) {
case 'C': {
// Circle: diagonal only (like bishop)
if (absDeltaFile === absDeltaRank && absDeltaFile > 0) {
return { valid: true }
}
return { valid: false, reason: 'Circles move diagonally' }
}
case 'T': {
// Triangle: orthogonal only (like rook)
if ((deltaFile === 0 && deltaRank !== 0) || (deltaRank === 0 && deltaFile !== 0)) {
return { valid: true }
}
return { valid: false, reason: 'Triangles move orthogonally' }
}
case 'S': {
// Square: queen-like (orthogonal or diagonal)
const isDiagonal = absDeltaFile === absDeltaRank && absDeltaFile > 0
const isOrthogonal =
(deltaFile === 0 && deltaRank !== 0) || (deltaRank === 0 && deltaFile !== 0)
if (isDiagonal || isOrthogonal) {
return { valid: true }
}
return { valid: false, reason: 'Squares move orthogonally or diagonally' }
}
case 'P': {
// Pyramid: king-like (1 step in any direction)
if (absDeltaFile <= 1 && absDeltaRank <= 1 && (absDeltaFile > 0 || absDeltaRank > 0)) {
return { valid: true }
}
return { valid: false, reason: 'Pyramids move 1 step in any direction' }
}
default:
return { valid: false, reason: 'Unknown piece type' }
}
}
/**
* Check if the path from 'from' to 'to' is clear (no pieces in between).
* Assumes the geometry is already validated.
*/
export function isPathClear(
pieces: Record<string, Piece>,
from: string,
to: string
): PathValidationResult {
const fromCoords = parseSquare(from)
const toCoords = parseSquare(to)
const deltaFile = toCoords.file - fromCoords.file
const deltaRank = toCoords.rank - fromCoords.rank
// Calculate step direction
const stepFile = deltaFile === 0 ? 0 : deltaFile > 0 ? 1 : -1
const stepRank = deltaRank === 0 ? 0 : deltaRank > 0 ? 1 : -1
// Calculate number of steps (excluding start and end)
const steps = Math.max(Math.abs(deltaFile), Math.abs(deltaRank)) - 1
// Check each intermediate square
let currentFile = fromCoords.file + stepFile
let currentRank = fromCoords.rank + stepRank
for (let i = 0; i < steps; i++) {
const square = makeSquare(currentFile, currentRank)
const pieceAtSquare = Object.values(pieces).find((p) => p.square === square && !p.captured)
if (pieceAtSquare) {
return { valid: false, reason: `Path blocked by ${pieceAtSquare.id} at ${square}` }
}
currentFile += stepFile
currentRank += stepRank
}
return { valid: true }
}
/**
* Validate a complete move (geometry + path clearance).
*/
export function validateMove(
piece: Piece,
from: string,
to: string,
pieces: Record<string, Piece>
): PathValidationResult {
// Check geometry
const geometryCheck = isGeometryLegal(piece.type, from, to)
if (!geometryCheck.valid) {
return geometryCheck
}
// Check path clearance
const pathCheck = isPathClear(pieces, from, to)
if (!pathCheck.valid) {
return pathCheck
}
return { valid: true }
}
/**
* Get all legal move destinations for a piece (ignoring captures/relations).
* Returns an array of square notations.
*/
export function getLegalMoves(piece: Piece, pieces: Record<string, Piece>): string[] {
const legalMoves: string[] = []
// Generate all possible squares on the board
for (let file = 0; file < 16; file++) {
for (let rank = 1; rank <= 8; rank++) {
const targetSquare = makeSquare(file, rank)
// Skip if same square
if (targetSquare === piece.square) continue
// Check if move is legal
const validation = validateMove(piece, piece.square, targetSquare, pieces)
if (validation.valid) {
legalMoves.push(targetSquare)
}
}
}
return legalMoves
}
/**
* Check if a square is within board bounds.
*/
export function isSquareValid(square: string): boolean {
if (square.length !== 2) return false
const file = square.charCodeAt(0) - 65 // A=0, B=1, ..., P=15
const rank = Number.parseInt(square[1], 10)
return file >= 0 && file <= 15 && rank >= 1 && rank <= 8
}
/**
* Get the direction of movement (for UI purposes).
*/
export function getDirection(
from: string,
to: string
): {
horizontal: 'left' | 'right' | 'none'
vertical: 'up' | 'down' | 'none'
} {
const fromCoords = parseSquare(from)
const toCoords = parseSquare(to)
const deltaFile = toCoords.file - fromCoords.file
const deltaRank = toCoords.rank - fromCoords.rank
return {
horizontal: deltaFile < 0 ? 'left' : deltaFile > 0 ? 'right' : 'none',
vertical: deltaRank < 0 ? 'down' : deltaRank > 0 ? 'up' : 'none',
}
}

View File

@@ -0,0 +1,233 @@
import type { Color, Piece } from '../types'
/**
* Generate the initial board setup per the Rithmomachia reference image.
* Returns a Record of piece.id → Piece.
*
* Layout: VERTICAL - BLACK on left (columns A-C), WHITE on right (columns M-P)
* Follows the authoritative reference board image exactly.
*/
export function createInitialBoard(): Record<string, Piece> {
const pieces: Record<string, Piece> = {}
// === BLACK PIECES (Left side: columns A, B, C - filled/dark) ===
// Note: Rows 3-6 (middle) are "cinched in" - column A is empty for these rows
// Column A (squares only - rows 1, 2, 7, 8)
const blackColumnA = [
{ type: 'S', value: 49, square: 'A1' },
{ type: 'S', value: 121, square: 'A2' },
// A3, A4, A5, A6 are EMPTY (middle rows cinched in)
{ type: 'S', value: 225, square: 'A7' },
{ type: 'S', value: 361, square: 'A8' },
] as const
// Column B (squares, triangles, circles, and pyramid)
const blackColumnB = [
{ type: 'S', value: 28, square: 'B1' },
{ type: 'S', value: 66, square: 'B2' },
{ type: 'T', value: 64, square: 'B3' }, // Middle rows start here
{ type: 'T', value: 56, square: 'B4' },
{ type: 'T', value: 30, square: 'B5' },
{ type: 'T', value: 36, square: 'B6' },
{ type: 'S', value: 120, square: 'B7' },
// B8 is Pyramid (see below)
] as const
// Column C (triangles and circles)
const blackColumnC = [
{ type: 'T', value: 16, square: 'C1' },
{ type: 'T', value: 12, square: 'C2' },
{ type: 'C', value: 81, square: 'C3' },
{ type: 'C', value: 49, square: 'C4' },
{ type: 'C', value: 25, square: 'C5' },
{ type: 'C', value: 9, square: 'C6' },
{ type: 'T', value: 90, square: 'C7' },
{ type: 'T', value: 100, square: 'C8' },
] as const
// Column D (circles only - middle rows)
const blackColumnD = [
{ type: 'C', value: 9, square: 'D3' },
{ type: 'C', value: 7, square: 'D4' },
{ type: 'C', value: 5, square: 'D5' },
{ type: 'C', value: 3, square: 'D6' },
] as const
let blackSquareCount = 0
let blackTriangleCount = 0
let blackCircleCount = 0
for (const piece of [...blackColumnA, ...blackColumnB, ...blackColumnC, ...blackColumnD]) {
let id: string
let count: number
if (piece.type === 'S') {
count = ++blackSquareCount
id = `B_S_${String(count).padStart(2, '0')}`
} else if (piece.type === 'T') {
count = ++blackTriangleCount
id = `B_T_${String(count).padStart(2, '0')}`
} else {
count = ++blackCircleCount
id = `B_C_${String(count).padStart(2, '0')}`
}
pieces[id] = {
id,
color: 'B',
type: piece.type,
value: piece.value,
square: piece.square,
captured: false,
}
}
// Black Pyramid at B8
pieces.B_P_01 = {
id: 'B_P_01',
color: 'B',
type: 'P',
pyramidFaces: [9, 32, 125, 1],
activePyramidFace: null,
square: 'B8',
captured: false,
}
// === WHITE PIECES (Right side: columns M, N, O, P - outline/light) ===
// Note: Rows 3-6 (middle) are "cinched in" - column P is empty for these rows
// Column M (circles only - middle rows)
const whiteColumnM = [
{ type: 'C', value: 2, square: 'M3' },
{ type: 'C', value: 4, square: 'M4' },
{ type: 'C', value: 6, square: 'M5' },
{ type: 'C', value: 8, square: 'M6' },
] as const
// Column N (pyramid, circles, and triangles)
const whiteColumnN = [
{ type: 'T', value: 4, square: 'N1' },
// N2 is Pyramid (see below)
{ type: 'C', value: 64, square: 'N3' },
{ type: 'C', value: 36, square: 'N4' },
{ type: 'C', value: 16, square: 'N5' },
{ type: 'C', value: 4, square: 'N6' },
{ type: 'T', value: 5, square: 'N7' },
{ type: 'T', value: 6, square: 'N8' },
] as const
// Column O (squares, circles, and triangles)
const whiteColumnO = [
{ type: 'S', value: 153, square: 'O1' },
{ type: 'S', value: 169, square: 'O2' },
{ type: 'T', value: 4, square: 'O3' },
{ type: 'T', value: 2, square: 'O4' },
{ type: 'T', value: 2, square: 'O5' },
{ type: 'T', value: 5, square: 'O6' },
{ type: 'S', value: 45, square: 'O7' },
{ type: 'S', value: 15, square: 'O8' },
] as const
// Column P (squares only - rows 1, 2, 7, 8)
const whiteColumnP = [
{ type: 'S', value: 289, square: 'P1' },
{ type: 'T', value: 7, square: 'P2' },
// P3, P4, P5, P6 are EMPTY (middle rows cinched in)
{ type: 'S', value: 18, square: 'P7' },
{ type: 'S', value: 25, square: 'P8' },
] as const
let whiteSquareCount = 0
let whiteTriangleCount = 0
let whiteCircleCount = 0
for (const piece of [...whiteColumnM, ...whiteColumnN, ...whiteColumnO, ...whiteColumnP]) {
let id: string
let count: number
if (piece.type === 'S') {
count = ++whiteSquareCount
id = `W_S_${String(count).padStart(2, '0')}`
} else if (piece.type === 'T') {
count = ++whiteTriangleCount
id = `W_T_${String(count).padStart(2, '0')}`
} else {
count = ++whiteCircleCount
id = `W_C_${String(count).padStart(2, '0')}`
}
pieces[id] = {
id,
color: 'W',
type: piece.type,
value: piece.value,
square: piece.square,
captured: false,
}
}
// White Pyramid at N2
pieces.W_P_01 = {
id: 'W_P_01',
color: 'W',
type: 'P',
pyramidFaces: [8, 27, 64, 1],
activePyramidFace: null,
square: 'N2',
captured: false,
}
return pieces
}
/**
* Get the effective value of a piece for relation checks.
* For Circles/Triangles/Squares, returns their value.
* For Pyramids, returns the activePyramidFace (or null if not set).
*/
export function getEffectiveValue(piece: Piece): number | null {
if (piece.type === 'P') {
return piece.activePyramidFace ?? null
}
return piece.value ?? null
}
/**
* Get all live (non-captured) pieces for a given color.
*/
export function getLivePiecesForColor(pieces: Record<string, Piece>, color: Color): Piece[] {
return Object.values(pieces).filter((p) => p.color === color && !p.captured)
}
/**
* Get the piece at a specific square (if any).
*/
export function getPieceAt(pieces: Record<string, Piece>, square: string): Piece | null {
return Object.values(pieces).find((p) => p.square === square && !p.captured) ?? null
}
/**
* Check if a square is occupied by a live piece.
*/
export function isSquareOccupied(pieces: Record<string, Piece>, square: string): boolean {
return getPieceAt(pieces, square) !== null
}
/**
* Get a piece by ID (throws if not found).
*/
export function getPieceById(pieces: Record<string, Piece>, id: string): Piece {
const piece = pieces[id]
if (!piece) {
throw new Error(`Piece not found: ${id}`)
}
return piece
}
/**
* Clone a pieces record (shallow clone for immutability).
*/
export function clonePieces(pieces: Record<string, Piece>): Record<string, Piece> {
const result: Record<string, Piece> = {}
for (const [id, piece] of Object.entries(pieces)) {
result[id] = { ...piece }
}
return result
}

View File

@@ -0,0 +1,273 @@
import type { RelationKind } from '../types'
/**
* Relation checking engine for Rithmomachia captures.
* All arithmetic uses BigInt for precision with large values.
*/
export interface RelationCheckResult {
valid: boolean
relation?: RelationKind
explanation?: string
}
/**
* Check if two values satisfy the EQUAL relation.
* a == b
*/
export function checkEqual(a: number, b: number): RelationCheckResult {
if (a === b) {
return {
valid: true,
relation: 'EQUAL',
explanation: `${a} == ${b}`,
}
}
return { valid: false }
}
/**
* Check if two values satisfy the MULTIPLE relation.
* a % b == 0 (a is a multiple of b)
*/
export function checkMultiple(a: number, b: number): RelationCheckResult {
if (b === 0) return { valid: false }
if (a % b === 0) {
return {
valid: true,
relation: 'MULTIPLE',
explanation: `${a} is a multiple of ${b} (${a}÷${b}=${a / b})`,
}
}
return { valid: false }
}
/**
* Check if two values satisfy the DIVISOR relation.
* b % a == 0 (a is a divisor of b)
*/
export function checkDivisor(a: number, b: number): RelationCheckResult {
if (a === 0) return { valid: false }
if (b % a === 0) {
return {
valid: true,
relation: 'DIVISOR',
explanation: `${a} divides ${b} (${b}÷${a}=${b / a})`,
}
}
return { valid: false }
}
/**
* Check if three values satisfy the SUM relation.
* a + h == b OR b + h == a
*/
export function checkSum(a: number, b: number, h: number): RelationCheckResult {
if (a + h === b) {
return {
valid: true,
relation: 'SUM',
explanation: `${a} + ${h} = ${b}`,
}
}
if (b + h === a) {
return {
valid: true,
relation: 'SUM',
explanation: `${b} + ${h} = ${a}`,
}
}
return { valid: false }
}
/**
* Check if three values satisfy the DIFF relation.
* |a - h| == b OR |b - h| == a
*/
export function checkDiff(a: number, b: number, h: number): RelationCheckResult {
const abs = (x: number) => (x < 0 ? -x : x)
const diff1 = abs(a - h)
if (diff1 === b) {
return {
valid: true,
relation: 'DIFF',
explanation: `|${a} - ${h}| = ${b}`,
}
}
const diff2 = abs(b - h)
if (diff2 === a) {
return {
valid: true,
relation: 'DIFF',
explanation: `|${b} - ${h}| = ${a}`,
}
}
return { valid: false }
}
/**
* Check if three values satisfy the PRODUCT relation.
* a * h == b OR b * h == a
*/
export function checkProduct(a: number, b: number, h: number): RelationCheckResult {
if (a * h === b) {
return {
valid: true,
relation: 'PRODUCT',
explanation: `${a} × ${h} = ${b}`,
}
}
if (b * h === a) {
return {
valid: true,
relation: 'PRODUCT',
explanation: `${b} × ${h} = ${a}`,
}
}
return { valid: false }
}
/**
* Check if three values satisfy the RATIO relation.
* a * r == b OR b * r == a (where r is the helper value)
* This is similar to PRODUCT but with explicit ratio semantics.
*/
export function checkRatio(a: number, b: number, r: number): RelationCheckResult {
if (r === 0) return { valid: false }
if (a * r === b) {
return {
valid: true,
relation: 'RATIO',
explanation: `${a} × ${r} = ${b}`,
}
}
if (b * r === a) {
return {
valid: true,
relation: 'RATIO',
explanation: `${b} × ${r} = ${a}`,
}
}
return { valid: false }
}
/**
* Check if a relation holds between mover and target values.
* Returns the first valid relation found, or null if none.
*/
export function checkRelation(
relation: RelationKind,
moverValue: number,
targetValue: number,
helperValue?: number
): RelationCheckResult {
switch (relation) {
case 'EQUAL':
return checkEqual(moverValue, targetValue)
case 'MULTIPLE':
return checkMultiple(moverValue, targetValue)
case 'DIVISOR':
return checkDivisor(moverValue, targetValue)
case 'SUM':
if (helperValue === undefined) {
return { valid: false, explanation: 'SUM requires a helper' }
}
return checkSum(moverValue, targetValue, helperValue)
case 'DIFF':
if (helperValue === undefined) {
return { valid: false, explanation: 'DIFF requires a helper' }
}
return checkDiff(moverValue, targetValue, helperValue)
case 'PRODUCT':
if (helperValue === undefined) {
return { valid: false, explanation: 'PRODUCT requires a helper' }
}
return checkProduct(moverValue, targetValue, helperValue)
case 'RATIO':
if (helperValue === undefined) {
return { valid: false, explanation: 'RATIO requires a helper' }
}
return checkRatio(moverValue, targetValue, helperValue)
default:
return { valid: false, explanation: 'Unknown relation type' }
}
}
/**
* Find all valid relations between two values (without helper).
* Returns an array of valid relations.
*/
export function findValidRelationsNoHelper(a: number, b: number): RelationKind[] {
const valid: RelationKind[] = []
if (checkEqual(a, b).valid) valid.push('EQUAL')
if (checkMultiple(a, b).valid) valid.push('MULTIPLE')
if (checkDivisor(a, b).valid) valid.push('DIVISOR')
return valid
}
/**
* Find all valid relations between two values WITH a helper.
* Returns an array of valid relations.
*/
export function findValidRelationsWithHelper(a: number, b: number, h: number): RelationKind[] {
const valid: RelationKind[] = []
// First check no-helper relations
valid.push(...findValidRelationsNoHelper(a, b))
// Then check helper-based relations
if (checkSum(a, b, h).valid) valid.push('SUM')
if (checkDiff(a, b, h).valid) valid.push('DIFF')
if (checkProduct(a, b, h).valid) valid.push('PRODUCT')
if (checkRatio(a, b, h).valid) valid.push('RATIO')
return valid
}
/**
* Check if ANY relation holds between mover and target (no helper).
* Returns the first valid relation or null.
*/
export function findAnyValidRelation(a: number, b: number): RelationCheckResult | null {
const relations = findValidRelationsNoHelper(a, b)
if (relations.length > 0) {
return checkRelation(relations[0], a, b)
}
return null
}
/**
* Check if ANY relation holds between mover and target WITH a helper.
* Returns the first valid relation or null.
*/
export function findAnyValidRelationWithHelper(
a: number,
b: number,
h: number
): RelationCheckResult | null {
const relations = findValidRelationsWithHelper(a, b, h)
if (relations.length > 0) {
return checkRelation(relations[0], a, b, h)
}
return null
}
/**
* Format a BigInt value for display (commas for readability).
*/
export function formatValue(value: number): string {
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}

View File

@@ -0,0 +1,180 @@
import type { Color, Piece, PieceType } from '../types'
/**
* Zobrist hashing for efficient board state comparison and repetition detection.
* Each combination of (piece type, color, square) gets a unique random number.
* The hash of a position is the XOR of all piece hashes.
*/
// Zobrist hash table: [pieceType][color][square] => hash
type ZobristTable = Record<PieceType, Record<Color, Record<string, bigint>>>
// Single zobrist table instance (initialized lazily)
let zobristTable: ZobristTable | null = null
// Turn hash (XOR this when it's Black's turn)
let turnHash: bigint | null = null
/**
* Simple seedable PRNG using xorshift128+
*/
class SeededRandom {
private state0: bigint
private state1: bigint
constructor(seed: number) {
// Initialize state from seed
this.state0 = BigInt(seed)
this.state1 = BigInt(seed * 2 + 1)
}
next(): bigint {
let s1 = this.state0
const s0 = this.state1
this.state0 = s0
s1 ^= s1 << 23n
s1 ^= s1 >> 17n
s1 ^= s0
s1 ^= s0 >> 26n
this.state1 = s1
return (s0 + s1) & 0xffffffffffffffffn // 64-bit mask
}
}
/**
* Initialize the Zobrist hash table with deterministic random values.
*/
function initZobristTable(): ZobristTable {
const rng = new SeededRandom(0x52495448) // "RITH" as seed
const table: ZobristTable = {
C: { W: {}, B: {} },
T: { W: {}, B: {} },
S: { W: {}, B: {} },
P: { W: {}, B: {} },
}
const pieceTypes: PieceType[] = ['C', 'T', 'S', 'P']
const colors: Color[] = ['W', 'B']
// Generate hash for each (pieceType, color, square) combination
for (const type of pieceTypes) {
for (const color of colors) {
for (let file = 0; file < 16; file++) {
for (let rank = 1; rank <= 8; rank++) {
const square = `${String.fromCharCode(65 + file)}${rank}`
table[type][color][square] = rng.next()
}
}
}
}
return table
}
/**
* Get the Zobrist hash table (lazy initialization).
*/
function getZobristTable(): ZobristTable {
if (!zobristTable) {
zobristTable = initZobristTable()
// Also initialize turn hash
const rng = new SeededRandom(0x5455524e) // "TURN" as seed
turnHash = rng.next()
}
return zobristTable
}
/**
* Get the turn hash value.
*/
function getTurnHash(): bigint {
if (turnHash === null) {
getZobristTable() // This will also initialize turnHash
}
return turnHash!
}
/**
* Compute the Zobrist hash for a board position.
*/
export function computeZobristHash(pieces: Record<string, Piece>, turn: Color): string {
const table = getZobristTable()
let hash = 0n
// XOR all piece hashes
for (const piece of Object.values(pieces)) {
if (piece.captured) continue
const pieceHash = table[piece.type][piece.color][piece.square]
if (pieceHash) {
hash ^= pieceHash
}
}
// XOR turn hash if it's Black's turn
if (turn === 'B') {
hash ^= getTurnHash()
}
// Return as hex string
return hash.toString(16).padStart(16, '0')
}
/**
* Check if a hash appears N times in the history (for repetition detection).
*/
export function countHashOccurrences(hashes: string[], targetHash: string): number {
return hashes.filter((h) => h === targetHash).length
}
/**
* Check for threefold repetition (hash appears 3 times).
*/
export function isThreefoldRepetition(hashes: string[]): boolean {
if (hashes.length < 3) return false
const currentHash = hashes[hashes.length - 1]
return countHashOccurrences(hashes, currentHash) >= 3
}
/**
* Incrementally update a Zobrist hash after a move.
* This is more efficient than recomputing from scratch.
*/
export function updateZobristHash(
currentHash: string,
movedPiece: Piece,
fromSquare: string,
toSquare: string,
capturedPiece: Piece | null,
newTurn: Color
): string {
const table = getZobristTable()
let hash = BigInt(`0x${currentHash}`)
// Remove moved piece from old square
const oldPieceHash = table[movedPiece.type][movedPiece.color][fromSquare]
if (oldPieceHash) {
hash ^= oldPieceHash
}
// Add moved piece to new square
const newPieceHash = table[movedPiece.type][movedPiece.color][toSquare]
if (newPieceHash) {
hash ^= newPieceHash
}
// Remove captured piece (if any)
if (capturedPiece) {
const capturedHash = table[capturedPiece.type][capturedPiece.color][capturedPiece.square]
if (capturedHash) {
hash ^= capturedHash
}
}
// Toggle turn (XOR turn hash twice = no change, once = change)
hash ^= getTurnHash()
return hash.toString(16).padStart(16, '0')
}

View File

@@ -504,7 +504,7 @@ function MinimalNav({
pointerEvents: 'auto',
maxWidth: 'calc(100% - 128px)', // Leave space for hamburger + margin
whiteSpace: 'nowrap',
overflow: 'hidden',
overflow: 'visible',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'

View File

@@ -184,7 +184,7 @@ export function GameContextNav({
alignItems: 'center',
width: 'auto',
whiteSpace: 'nowrap',
overflow: 'hidden',
overflow: 'visible',
}}
>
{/* Game Title Section - Always mounted, hidden when in room */}

View File

@@ -15,6 +15,8 @@ import {
DEFAULT_MEMORY_QUIZ_CONFIG,
DEFAULT_COMPLEMENT_RACE_CONFIG,
DEFAULT_CARD_SORTING_CONFIG,
DEFAULT_RITHMOMACHIA_CONFIG,
DEFAULT_YIJS_DEMO_CONFIG,
} from './game-configs'
// Lazy-load game registry to avoid loading React components on server
@@ -52,6 +54,10 @@ function getDefaultGameConfig(gameName: ExtendedGameName): GameConfigByName[Exte
return DEFAULT_COMPLEMENT_RACE_CONFIG
case 'card-sorting':
return DEFAULT_CARD_SORTING_CONFIG
case 'rithmomachia':
return DEFAULT_RITHMOMACHIA_CONFIG
case 'yjs-demo':
return DEFAULT_YIJS_DEMO_CONFIG
default:
throw new Error(`Unknown game: ${gameName}`)
}

View File

@@ -16,6 +16,8 @@
import type { memoryQuizGame } from '@/arcade-games/memory-quiz'
import type { matchingGame } from '@/arcade-games/matching'
import type { cardSortingGame } from '@/arcade-games/card-sorting'
import type { yjsDemoGame } from '@/arcade-games/yjs-demo'
import type { rithmomachiaGame } from '@/arcade-games/rithmomachia'
/**
* Utility type: Extract config type from a game definition
@@ -45,6 +47,18 @@ export type MatchingGameConfig = InferGameConfig<typeof matchingGame>
*/
export type CardSortingGameConfig = InferGameConfig<typeof cardSortingGame>
/**
* Configuration for yjs-demo (Yjs real-time sync demo) game
* INFERRED from yjsDemoGame.defaultConfig
*/
export type YjsDemoGameConfig = InferGameConfig<typeof yjsDemoGame>
/**
* Configuration for rithmomachia (Battle of Numbers) game
* INFERRED from rithmomachiaGame.defaultConfig
*/
export type RithmomachiaGameConfig = InferGameConfig<typeof rithmomachiaGame>
// ============================================================================
// Legacy Games (Manual Type Definitions)
// TODO: Migrate these games to the modular system for type inference
@@ -104,6 +118,8 @@ export type GameConfigByName = {
'memory-quiz': MemoryQuizGameConfig
matching: MatchingGameConfig
'card-sorting': CardSortingGameConfig
'yjs-demo': YjsDemoGameConfig
rithmomachia: RithmomachiaGameConfig
// Legacy games (manual types)
'complement-race': ComplementRaceGameConfig
@@ -142,6 +158,20 @@ export const DEFAULT_CARD_SORTING_CONFIG: CardSortingGameConfig = {
gameMode: 'solo',
}
export const DEFAULT_RITHMOMACHIA_CONFIG: RithmomachiaGameConfig = {
pointWinEnabled: false,
pointWinThreshold: 30,
repetitionRule: true,
fiftyMoveRule: true,
allowAnySetOnRecheck: true,
timeControlMs: null,
}
export const DEFAULT_YIJS_DEMO_CONFIG: YjsDemoGameConfig = {
gridSize: 8,
duration: 60,
}
export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = {
// Game style
style: 'practice',

View File

@@ -110,8 +110,12 @@ import { memoryQuizGame } from '@/arcade-games/memory-quiz'
import { matchingGame } from '@/arcade-games/matching'
import { complementRaceGame } from '@/arcade-games/complement-race/index'
import { cardSortingGame } from '@/arcade-games/card-sorting'
import { yjsDemoGame } from '@/arcade-games/yjs-demo'
import { rithmomachiaGame } from '@/arcade-games/rithmomachia'
registerGame(memoryQuizGame)
registerGame(matchingGame)
registerGame(complementRaceGame)
registerGame(cardSortingGame)
registerGame(yjsDemoGame)
registerGame(rithmomachiaGame)

View File

@@ -14,6 +14,8 @@ import { matchingGameValidator } from '@/arcade-games/matching/Validator'
import { memoryQuizGameValidator } from '@/arcade-games/memory-quiz/Validator'
import { complementRaceValidator } from '@/arcade-games/complement-race/Validator'
import { cardSortingValidator } from '@/arcade-games/card-sorting/Validator'
import { yjsDemoValidator } from '@/arcade-games/yjs-demo/Validator'
import { rithmomachiaValidator } from '@/arcade-games/rithmomachia/Validator'
import type { GameValidator } from './validation/types'
/**
@@ -26,6 +28,8 @@ export const validatorRegistry = {
'memory-quiz': memoryQuizGameValidator,
'complement-race': complementRaceValidator,
'card-sorting': cardSortingValidator,
'yjs-demo': yjsDemoValidator,
rithmomachia: rithmomachiaValidator,
// Add new games here - GameName type will auto-update
} as const
@@ -97,4 +101,6 @@ export {
memoryQuizGameValidator,
complementRaceValidator,
cardSortingValidator,
yjsDemoValidator,
rithmomachiaValidator,
}