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:
@@ -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
|
||||
|
||||
13
apps/web/src/app/arcade/rithmomachia/page.tsx
Normal file
13
apps/web/src/app/arcade/rithmomachia/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
357
apps/web/src/arcade-games/rithmomachia/Provider.tsx
Normal file
357
apps/web/src/arcade-games/rithmomachia/Provider.tsx
Normal 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>
|
||||
}
|
||||
499
apps/web/src/arcade-games/rithmomachia/SPEC.md
Normal file
499
apps/web/src/arcade-games/rithmomachia/SPEC.md
Normal 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 `1–4`
|
||||
* **Black half:** Rows `5–8`
|
||||
|
||||
---
|
||||
|
||||
## 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 2–24)
|
||||
* **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 3–36)
|
||||
* **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 5–8, Black in rows 1–4) 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
|
||||
922
apps/web/src/arcade-games/rithmomachia/Validator.ts
Normal file
922
apps/web/src/arcade-games/rithmomachia/Validator.ts
Normal 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()
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
98
apps/web/src/arcade-games/rithmomachia/index.ts
Normal file
98
apps/web/src/arcade-games/rithmomachia/index.ts
Normal 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,
|
||||
}
|
||||
)
|
||||
312
apps/web/src/arcade-games/rithmomachia/types.ts
Normal file
312
apps/web/src/arcade-games/rithmomachia/types.ts
Normal 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'
|
||||
}
|
||||
305
apps/web/src/arcade-games/rithmomachia/utils/harmonyValidator.ts
Normal file
305
apps/web/src/arcade-games/rithmomachia/utils/harmonyValidator.ts
Normal 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
|
||||
}
|
||||
200
apps/web/src/arcade-games/rithmomachia/utils/pathValidator.ts
Normal file
200
apps/web/src/arcade-games/rithmomachia/utils/pathValidator.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
233
apps/web/src/arcade-games/rithmomachia/utils/pieceSetup.ts
Normal file
233
apps/web/src/arcade-games/rithmomachia/utils/pieceSetup.ts
Normal 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
|
||||
}
|
||||
273
apps/web/src/arcade-games/rithmomachia/utils/relationEngine.ts
Normal file
273
apps/web/src/arcade-games/rithmomachia/utils/relationEngine.ts
Normal 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, ',')
|
||||
}
|
||||
180
apps/web/src/arcade-games/rithmomachia/utils/zobristHash.ts
Normal file
180
apps/web/src/arcade-games/rithmomachia/utils/zobristHash.ts
Normal 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')
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user