diff --git a/apps/web/src/arcade-games/rithmomachia/SPEC.md b/apps/web/src/arcade-games/rithmomachia/SPEC.md index 4c2dae6a..a8be9c2d 100644 --- a/apps/web/src/arcade-games/rithmomachia/SPEC.md +++ b/apps/web/src/arcade-games/rithmomachia/SPEC.md @@ -180,20 +180,68 @@ If, **after your movement**, an **enemy piece** sits on a square such that a rel ## 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: +**Harmony** is both the theme of Rithmomachia and a special way to win. On your turn (after movement/captures), you may **declare Harmony** if you arrange three of your pieces in the **opponent's half** (White in rows 5–8, Black in rows 1–4) so their **values stand in a classical proportion**. -* **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) +### 7.1 Three types of harmony (three-piece structure: A–M–B) + +All harmonies use **three pieces** where M is the middle piece (spatially between A and B on the board): + +* **Arithmetic Proportion (AP)**: the middle is the arithmetic mean + - **Condition:** `2M = A + B` + - **Example:** 6, 9, 12 (since 2·9 = 6 + 12 = 18) + +* **Geometric Proportion (GP)**: the middle is the geometric mean + - **Condition:** `M² = A · B` + - **Example:** 6, 12, 24 (since 12² = 6·24 = 144) + +* **Harmonic Proportion (HP)**: the middle is the harmonic mean + - **Condition:** `2AB = M(A + B)` (equivalently, 1/A, 1/M, 1/B forms an AP) + - **Examples:** + - 6, 8, 12 (since 2·6·12 = 8·(6+12) = 144) + - 10, 12, 15 (since 2·10·15 = 12·(10+15) = 300) + - 8, 12, 24 (since 2·8·24 = 12·(8+24) = 384) + +> **Tip:** Use these integer equalities for validation—no division or rounding needed! + +### 7.2 Board layout constraints + +The three pieces must be arranged in a **straight line** (row, column, or diagonal) with one of these spacing rules: + +1. **Straight & adjacent** (default): Three consecutive squares in order A–M–B +2. **Straight with equal spacing**: Same as above, but one empty square between each neighbor (still collinear) +3. **Collinear anywhere**: Pieces on the same line in correct numeric order, with any spacing + +**Default for this implementation:** Straight & adjacent (option 1) + +### 7.3 Common harmony triads (for reference) + +**Arithmetic:** +- (6, 9, 12), (8, 12, 16), (5, 7, 9), (4, 6, 8) + +**Geometric:** +- (4, 8, 16), (3, 9, 27), (2, 8, 32), (5, 25, 125) + +**Harmonic:** +- (3, 4, 6), (4, 6, 12), (6, 8, 12), (10, 12, 15), (8, 12, 24), (6, 10, 15) + +### 7.4 Declaring and winning **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**. +* Pieces must be **distinct** and on **distinct squares** +* All three must be **entirely within opponent's half** +* **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), **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). +**Procedure:** + +1. On your turn, complete the arrangement (by moving one piece) +2. **Announce** the proportion (e.g., "harmonic 6–8–12 on column D") +3. Opponent verifies the numeric relation and board condition +4. If valid, harmony is **pending**—opponent gets one turn to break it +5. If still valid at start of your next turn, you **win** + +> **Implementation:** On declare, snapshot the **set of piece IDs**, **proportion type**, and **parameters**. On the declarer's next turn start, **re-validate** either the same set OR allow **any** new valid harmony (we choose **any valid set** to reward dynamic play). --- diff --git a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx index 3610ba26..19651b8d 100644 --- a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx +++ b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx @@ -256,8 +256,15 @@ function useRosterWarning(phase: 'setup' | 'playing'): RosterWarning | undefined */ export function RithmomachiaGame() { const router = useRouter() - const { state, resetGame, goToSetup, whitePlayerId, blackPlayerId, assignWhitePlayer, assignBlackPlayer } = - useRithmomachia() + const { + state, + resetGame, + goToSetup, + whitePlayerId, + blackPlayerId, + assignWhitePlayer, + assignBlackPlayer, + } = useRithmomachia() const { setFullscreenElement } = useFullscreen() const gameRef = useRef(null) const rosterWarning = useRosterWarning(state.gamePhase === 'setup' ? 'setup' : 'playing') @@ -535,525 +542,541 @@ function SetupPhase() { flexShrink: 0, })} > - {/* Ornamental corners - smaller */} -
-
-
-
- -

- ⚔️ RITHMOMACHIA ⚔️ -

-
-

- The Battle of Numbers -

-

- Medieval strategy • Mathematical combat -

-
- - {/* Game Settings - Compact with flex: 1 to take remaining space */} -
-

- ⚙️ - Game Rules -

- -
- {/* Point Victory */} -
toggleSetting('pointWinEnabled')} - className={css({ - display: 'flex', - flexDirection: 'column', - gap: '1vh', - p: '1.5vh', - bg: state.pointWinEnabled ? 'rgba(251, 191, 36, 0.25)' : 'rgba(139, 92, 246, 0.1)', - borderRadius: '1vh', - border: '0.3vh solid', - borderColor: state.pointWinEnabled - ? 'rgba(251, 191, 36, 0.8)' - : 'rgba(139, 92, 246, 0.3)', - transition: 'all 0.3s ease', - cursor: 'pointer', - position: 'relative', - overflow: 'hidden', - boxShadow: state.pointWinEnabled - ? '0 0.5vh 2vh rgba(251, 191, 36, 0.4)' - : '0 0.2vh 0.5vh rgba(0,0,0,0.1)', - _hover: { - bg: state.pointWinEnabled - ? 'rgba(251, 191, 36, 0.35)' - : 'rgba(139, 92, 246, 0.2)', - borderColor: state.pointWinEnabled - ? 'rgba(251, 191, 36, 1)' - : 'rgba(139, 92, 246, 0.5)', - transform: 'translateY(-0.2vh)', - }, - _active: { - transform: 'scale(0.98)', - }, - })} - > + {/* Ornamental corners - smaller */}
+
+
+
+ +

-
+ ⚔️ RITHMOMACHIA ⚔️ +

+
+

+ The Battle of Numbers +

+

+ Medieval strategy • Mathematical combat +

+
+ + {/* Game Settings - Compact with flex: 1 to take remaining space */} +
+

+ ⚙️ + Game Rules +

+ +
+ {/* Point Victory */} +
toggleSetting('pointWinEnabled')} + className={css({ + display: 'flex', + flexDirection: 'column', + gap: '1vh', + p: '1.5vh', + bg: state.pointWinEnabled + ? 'rgba(251, 191, 36, 0.25)' + : 'rgba(139, 92, 246, 0.1)', + borderRadius: '1vh', + border: '0.3vh solid', + borderColor: state.pointWinEnabled + ? 'rgba(251, 191, 36, 0.8)' + : 'rgba(139, 92, 246, 0.3)', + transition: 'all 0.3s ease', + cursor: 'pointer', + position: 'relative', + overflow: 'hidden', + boxShadow: state.pointWinEnabled + ? '0 0.5vh 2vh rgba(251, 191, 36, 0.4)' + : '0 0.2vh 0.5vh rgba(0,0,0,0.1)', + _hover: { + bg: state.pointWinEnabled + ? 'rgba(251, 191, 36, 0.35)' + : 'rgba(139, 92, 246, 0.2)', + borderColor: state.pointWinEnabled + ? 'rgba(251, 191, 36, 1)' + : 'rgba(139, 92, 246, 0.5)', + transform: 'translateY(-0.2vh)', + }, + _active: { + transform: 'scale(0.98)', + }, + })} + >
- {state.pointWinEnabled && '✓ '}Point Victory +
+
+ {state.pointWinEnabled && '✓ '}Point Victory +
+
+ Win at {state.pointWinThreshold}pts +
+
+ {state.pointWinEnabled && ( +
+ )}
-
- Win at {state.pointWinThreshold}pts -
-
- {state.pointWinEnabled && ( -
- )} -
- {/* Threshold input - only visible when enabled */} - {state.pointWinEnabled && ( + {/* Threshold input - only visible when enabled */} + {state.pointWinEnabled && ( +
e.stopPropagation()} + className={css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + pt: '0.8vh', + borderTop: '0.15vh solid', + borderColor: 'rgba(251, 191, 36, 0.3)', + })} + > +
+ Threshold: +
+ updateThreshold(Number.parseInt(e.target.value, 10))} + onClick={(e) => e.stopPropagation()} + min="1" + className={css({ + width: '6vh', + minHeight: '2.5vh', + px: '0.5vh', + py: '0.3vh', + borderRadius: '0.5vh', + border: '0.15vh solid', + borderColor: 'rgba(251, 191, 36, 0.6)', + bg: 'rgba(255, 255, 255, 0.9)', + textAlign: 'center', + fontSize: '1.4vh', + fontWeight: 'bold', + color: '#7c2d12', + _focus: { + outline: 'none', + borderColor: 'rgba(251, 191, 36, 1)', + boxShadow: '0 0 0.5vh rgba(251, 191, 36, 0.5)', + }, + })} + /> +
+ )} +
+ + {/* Threefold Repetition */}
e.stopPropagation()} + data-setting="threefold-repetition" + onClick={() => toggleSetting('repetitionRule')} className={css({ display: 'flex', justifyContent: 'space-between', alignItems: 'center', - pt: '0.8vh', - borderTop: '0.15vh solid', - borderColor: 'rgba(251, 191, 36, 0.3)', + p: '1.5vh', + bg: state.repetitionRule + ? 'rgba(251, 191, 36, 0.25)' + : 'rgba(139, 92, 246, 0.1)', + borderRadius: '1vh', + border: '0.3vh solid', + borderColor: state.repetitionRule + ? 'rgba(251, 191, 36, 0.8)' + : 'rgba(139, 92, 246, 0.3)', + transition: 'all 0.3s ease', + cursor: 'pointer', + position: 'relative', + overflow: 'hidden', + boxShadow: state.repetitionRule + ? '0 0.5vh 2vh rgba(251, 191, 36, 0.4)' + : '0 0.2vh 0.5vh rgba(0,0,0,0.1)', + _hover: { + bg: state.repetitionRule + ? 'rgba(251, 191, 36, 0.35)' + : 'rgba(139, 92, 246, 0.2)', + borderColor: state.repetitionRule + ? 'rgba(251, 191, 36, 1)' + : 'rgba(139, 92, 246, 0.5)', + transform: 'translateY(-0.2vh)', + }, + _active: { + transform: 'scale(0.98)', + }, })} > -
- Threshold: +
+
+ {state.repetitionRule && '✓ '}Threefold Draw +
+
+ Same position 3x +
- updateThreshold(Number.parseInt(e.target.value, 10))} - onClick={(e) => e.stopPropagation()} - min="1" - className={css({ - width: '6vh', - minHeight: '2.5vh', - px: '0.5vh', - py: '0.3vh', - borderRadius: '0.5vh', - border: '0.15vh solid', - borderColor: 'rgba(251, 191, 36, 0.6)', - bg: 'rgba(255, 255, 255, 0.9)', - textAlign: 'center', - fontSize: '1.4vh', - fontWeight: 'bold', - color: '#7c2d12', - _focus: { - outline: 'none', - borderColor: 'rgba(251, 191, 36, 1)', - boxShadow: '0 0 0.5vh rgba(251, 191, 36, 0.5)', - }, - })} - /> + {state.repetitionRule && ( +
+ )}
- )} -
- {/* Threefold Repetition */} -
toggleSetting('repetitionRule')} - className={css({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - p: '1.5vh', - bg: state.repetitionRule ? 'rgba(251, 191, 36, 0.25)' : 'rgba(139, 92, 246, 0.1)', - borderRadius: '1vh', - border: '0.3vh solid', - borderColor: state.repetitionRule - ? 'rgba(251, 191, 36, 0.8)' - : 'rgba(139, 92, 246, 0.3)', - transition: 'all 0.3s ease', - cursor: 'pointer', - position: 'relative', - overflow: 'hidden', - boxShadow: state.repetitionRule - ? '0 0.5vh 2vh rgba(251, 191, 36, 0.4)' - : '0 0.2vh 0.5vh rgba(0,0,0,0.1)', - _hover: { - bg: state.repetitionRule ? 'rgba(251, 191, 36, 0.35)' : 'rgba(139, 92, 246, 0.2)', - borderColor: state.repetitionRule - ? 'rgba(251, 191, 36, 1)' - : 'rgba(139, 92, 246, 0.5)', - transform: 'translateY(-0.2vh)', - }, - _active: { - transform: 'scale(0.98)', - }, - })} - > -
+ {/* Fifty Move Rule */}
toggleSetting('fiftyMoveRule')} className={css({ - fontWeight: 'bold', - fontSize: '1.6vh', - color: state.repetitionRule ? '#92400e' : '#7c2d12', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + p: '1.5vh', + bg: state.fiftyMoveRule + ? 'rgba(251, 191, 36, 0.25)' + : 'rgba(139, 92, 246, 0.1)', + borderRadius: '1vh', + border: '0.3vh solid', + borderColor: state.fiftyMoveRule + ? 'rgba(251, 191, 36, 0.8)' + : 'rgba(139, 92, 246, 0.3)', + transition: 'all 0.3s ease', + cursor: 'pointer', + position: 'relative', + overflow: 'hidden', + boxShadow: state.fiftyMoveRule + ? '0 0.5vh 2vh rgba(251, 191, 36, 0.4)' + : '0 0.2vh 0.5vh rgba(0,0,0,0.1)', + _hover: { + bg: state.fiftyMoveRule + ? 'rgba(251, 191, 36, 0.35)' + : 'rgba(139, 92, 246, 0.2)', + borderColor: state.fiftyMoveRule + ? 'rgba(251, 191, 36, 1)' + : 'rgba(139, 92, 246, 0.5)', + transform: 'translateY(-0.2vh)', + }, + _active: { + transform: 'scale(0.98)', + }, })} > - {state.repetitionRule && '✓ '}Threefold Draw +
+
+ {state.fiftyMoveRule && '✓ '}Fifty-Move Draw +
+
+ 50 moves no event +
+
+ {state.fiftyMoveRule && ( +
+ )}
-
Same position 3x
-
- {state.repetitionRule && ( -
- )} -
- {/* Fifty Move Rule */} -
toggleSetting('fiftyMoveRule')} - className={css({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - p: '1.5vh', - bg: state.fiftyMoveRule ? 'rgba(251, 191, 36, 0.25)' : 'rgba(139, 92, 246, 0.1)', - borderRadius: '1vh', - border: '0.3vh solid', - borderColor: state.fiftyMoveRule - ? 'rgba(251, 191, 36, 0.8)' - : 'rgba(139, 92, 246, 0.3)', - transition: 'all 0.3s ease', - cursor: 'pointer', - position: 'relative', - overflow: 'hidden', - boxShadow: state.fiftyMoveRule - ? '0 0.5vh 2vh rgba(251, 191, 36, 0.4)' - : '0 0.2vh 0.5vh rgba(0,0,0,0.1)', - _hover: { - bg: state.fiftyMoveRule ? 'rgba(251, 191, 36, 0.35)' : 'rgba(139, 92, 246, 0.2)', - borderColor: state.fiftyMoveRule - ? 'rgba(251, 191, 36, 1)' - : 'rgba(139, 92, 246, 0.5)', - transform: 'translateY(-0.2vh)', - }, - _active: { - transform: 'scale(0.98)', - }, - })} - > -
+ {/* Harmony Persistence */}
toggleSetting('allowAnySetOnRecheck')} className={css({ - fontWeight: 'bold', - fontSize: '1.6vh', - color: state.fiftyMoveRule ? '#92400e' : '#7c2d12', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + p: '1.5vh', + bg: state.allowAnySetOnRecheck + ? 'rgba(251, 191, 36, 0.25)' + : 'rgba(139, 92, 246, 0.1)', + borderRadius: '1vh', + border: '0.3vh solid', + borderColor: state.allowAnySetOnRecheck + ? 'rgba(251, 191, 36, 0.8)' + : 'rgba(139, 92, 246, 0.3)', + transition: 'all 0.3s ease', + cursor: 'pointer', + position: 'relative', + overflow: 'hidden', + boxShadow: state.allowAnySetOnRecheck + ? '0 0.5vh 2vh rgba(251, 191, 36, 0.4)' + : '0 0.2vh 0.5vh rgba(0,0,0,0.1)', + _hover: { + bg: state.allowAnySetOnRecheck + ? 'rgba(251, 191, 36, 0.35)' + : 'rgba(139, 92, 246, 0.2)', + borderColor: state.allowAnySetOnRecheck + ? 'rgba(251, 191, 36, 1)' + : 'rgba(139, 92, 246, 0.5)', + transform: 'translateY(-0.2vh)', + }, + _active: { + transform: 'scale(0.98)', + }, })} > - {state.fiftyMoveRule && '✓ '}Fifty-Move Draw -
-
- 50 moves no event +
+
+ {state.allowAnySetOnRecheck && '✓ '}Flexible Harmony +
+
+ Any valid set +
+
+ {state.allowAnySetOnRecheck && ( +
+ )}
- {state.fiftyMoveRule && ( -
- )}
- {/* Harmony Persistence */} -
toggleSetting('allowAnySetOnRecheck')} + {/* Start Button - Compact but dramatic */} +
-
- - {/* Start Button - Compact but dramatic */} - + ⚔️ BEGIN BATTLE ⚔️ + )}
diff --git a/apps/web/src/arcade-games/rithmomachia/types.ts b/apps/web/src/arcade-games/rithmomachia/types.ts index cb51c9ef..f9ca6096 100644 --- a/apps/web/src/arcade-games/rithmomachia/types.ts +++ b/apps/web/src/arcade-games/rithmomachia/types.ts @@ -48,13 +48,12 @@ export type HarmonyType = 'ARITH' | 'GEOM' | 'HARM' export interface HarmonyDeclaration { by: Color - pieceIds: string[] // ≥3 + pieceIds: string[] // exactly 3 for classical three-piece proportions type: HarmonyType params: { - v?: string // store as strings for bigints - d?: string // difference (ARITH) - r?: string // ratio (GEOM) - n?: string // harmonic parameter + a?: string // first value in proportion (A-M-B structure) + m?: string // middle value in proportion + b?: string // last value in proportion } declaredAtPly: number } diff --git a/apps/web/src/arcade-games/rithmomachia/utils/harmonyValidator.ts b/apps/web/src/arcade-games/rithmomachia/utils/harmonyValidator.ts index 26137dd4..9525a05c 100644 --- a/apps/web/src/arcade-games/rithmomachia/utils/harmonyValidator.ts +++ b/apps/web/src/arcade-games/rithmomachia/utils/harmonyValidator.ts @@ -1,10 +1,15 @@ import type { Color, HarmonyDeclaration, HarmonyType, Piece } from '../types' -import { isInEnemyHalf } from '../types' +import { isInEnemyHalf, parseSquare } from '../types' import { getEffectiveValue } from './pieceSetup' /** * Harmony (progression) validator for Rithmomachia. - * Detects arithmetic, geometric, and harmonic progressions. + * Detects arithmetic, geometric, and harmonic proportions using three pieces. + * + * Updated to match classical Rithmomachia rules: + * - Three pieces (A-M-B) where M is spatially in the middle + * - Must be in a straight line (row, column, or diagonal) + * - Uses three-piece proportion formulas (no division needed) */ export interface HarmonyValidationResult { @@ -14,166 +19,211 @@ export interface HarmonyValidationResult { reason?: string } +export type HarmonyLayoutMode = 'adjacent' | 'equalSpacing' | 'collinear' + /** - * Check if values form an arithmetic progression. - * Arithmetic: v, v+d, v+2d, ... with d > 0 + * Check if three squares are collinear (on same row, column, or diagonal) */ -function isArithmeticProgression(values: number[]): HarmonyValidationResult { - if (values.length < 2) { - return { valid: false, reason: 'Need at least 2 values' } +function areCollinear(sq1: string, sq2: string, sq3: string): boolean { + const p1 = parseSquare(sq1) + const p2 = parseSquare(sq2) + const p3 = parseSquare(sq3) + + if (!p1 || !p2 || !p3) return false + + // Same rank (horizontal row) + if (p1.rank === p2.rank && p2.rank === p3.rank) return true + + // Same file (vertical column) + if (p1.file === p2.file && p2.file === p3.file) return true + + // Diagonal: check if slope is consistent + const dx12 = p2.file - p1.file + const dy12 = p2.rank - p1.rank + const dx23 = p3.file - p2.file + const dy23 = p3.rank - p2.rank + + // Cross product should be zero for collinear points + return dx12 * dy23 === dy12 * dx23 +} + +/** + * Get the distance between two squares (Manhattan or diagonal) + */ +function getDistance(sq1: string, sq2: string): number { + const p1 = parseSquare(sq1) + const p2 = parseSquare(sq2) + + if (!p1 || !p2) return Infinity + + return Math.max(Math.abs(p2.file - p1.file), Math.abs(p2.rank - p1.rank)) +} + +/** + * Determine which piece is spatially in the middle on a line + * Returns the middle piece, or null if they're not properly ordered + */ +function findMiddlePiece(pieces: Piece[]): Piece | null { + if (pieces.length !== 3) return null + + const [p1, p2, p3] = pieces + + // Check all permutations to find which one is in the middle + const positions = [parseSquare(p1.square), parseSquare(p2.square), parseSquare(p3.square)] + + if (!positions[0] || !positions[1] || !positions[2]) return null + + // For each piece, check if it's between the other two + for (let i = 0; i < 3; i++) { + const candidate = positions[i] + const others = [positions[(i + 1) % 3], positions[(i + 2) % 3]] + + // Check if candidate is between the other two on all axes + const betweenX = + (candidate.file >= others[0].file && candidate.file <= others[1].file) || + (candidate.file >= others[1].file && candidate.file <= others[0].file) + + const betweenY = + (candidate.rank >= others[0].rank && candidate.rank <= others[1].rank) || + (candidate.rank >= others[1].rank && candidate.rank <= others[0].rank) + + if (betweenX && betweenY) { + return pieces[i] + } } - // Sort values - const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + return null +} - // Calculate common difference - const d = sorted[1] - sorted[0] +/** + * Check if three pieces satisfy layout constraint + */ +function checkLayout(pieces: Piece[], mode: HarmonyLayoutMode): boolean { + if (pieces.length !== 3) return false - if (d <= 0) { - return { valid: false, reason: 'Arithmetic progression requires positive difference' } + const [p1, p2, p3] = pieces + const squares = [p1.square, p2.square, p3.square] + + // All modes require collinearity + if (!areCollinear(squares[0], squares[1], squares[2])) { + return false } - // 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}`, - } + // Find which piece is in the middle + const middle = findMiddlePiece(pieces) + if (!middle) return false + + const others = pieces.filter((p) => p !== middle) + + if (mode === 'adjacent') { + // All distances must be 1 + const d1 = getDistance(middle.square, others[0].square) + const d2 = getDistance(middle.square, others[1].square) + return d1 === 1 && d2 === 1 + } + + if (mode === 'equalSpacing') { + // Distances must be equal (and can be 1 or 2) + const d1 = getDistance(middle.square, others[0].square) + const d2 = getDistance(middle.square, others[1].square) + return d1 === d2 && (d1 === 1 || d1 === 2) + } + + // mode === 'collinear': any spacing is OK (already checked collinearity) + return true +} + +/** + * Check if three values form an arithmetic proportion (A-M-B). + * AP: 2M = A + B (middle is arithmetic mean) + */ +function isArithmeticProportion(a: number, m: number, b: number): HarmonyValidationResult { + if (2 * m === a + b) { + return { + valid: true, + type: 'ARITH', + params: { + a: a.toString(), + m: m.toString(), + b: b.toString(), + }, } } return { - valid: true, - type: 'ARITH', - params: { - v: sorted[0].toString(), - d: d.toString(), - }, + valid: false, + reason: `Not arithmetic: 2·${m} ≠ ${a} + ${b} (${2 * m} ≠ ${a + b})`, } } /** - * Check if values form a geometric progression. - * Geometric: v, v·r, v·r², ... with integer r ≥ 2 + * Check if three values form a geometric proportion (A-M-B). + * GP: M² = A · B (middle is geometric mean) */ -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}`, - } +function isGeometricProportion(a: number, m: number, b: number): HarmonyValidationResult { + if (m * m === a * b) { + return { + valid: true, + type: 'GEOM', + params: { + a: a.toString(), + m: m.toString(), + b: b.toString(), + }, } } return { - valid: true, - type: 'GEOM', - params: { - v: sorted[0].toString(), - r: r.toString(), - }, + valid: false, + reason: `Not geometric: ${m}² ≠ ${a} · ${b} (${m * m} ≠ ${a * b})`, } } /** - * 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)), ... + * Check if three values form a harmonic proportion (A-M-B). + * HP: 2AB = M(A + B) (middle is harmonic mean) + * Equivalently: 1/A, 1/M, 1/B forms an arithmetic progression */ -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', - } +function isHarmonicProportion(a: number, m: number, b: number): HarmonyValidationResult { + if (a === 0 || b === 0 || m === 0) { + return { + valid: false, + reason: 'Harmonic proportion cannot contain 0', } } - // 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) + if (2 * a * b === m * (a + b)) { + return { + valid: true, + type: 'HARM', + params: { + a: a.toString(), + m: m.toString(), + b: b.toString(), + }, + } + } return { - valid: true, - type: 'HARM', - params: { - v: sorted[0].toString(), - }, + valid: false, + reason: `Not harmonic: 2·${a}·${b} ≠ ${m}·(${a}+${b}) (${2 * a * b} ≠ ${m * (a + b)})`, } } /** - * Validate if a set of pieces forms a valid harmony. - * Returns the first valid progression type found, or null. + * Validate if three pieces form a valid harmony. + * Returns the first valid proportion type found, or invalid result. */ -export function validateHarmony(pieces: Piece[], color: Color): HarmonyValidationResult { +export function validateHarmony( + pieces: Piece[], + color: Color, + layoutMode: HarmonyLayoutMode = 'adjacent' +): HarmonyValidationResult { + // Check: exactly 3 pieces + if (pieces.length !== 3) { + return { valid: false, reason: 'Harmony requires exactly 3 pieces' } + } + // Check: all pieces must be in enemy half const notInEnemyHalf = pieces.filter((p) => !isInEnemyHalf(p.square, color)) if (notInEnemyHalf.length > 0) { @@ -183,56 +233,65 @@ export function validateHarmony(pieces: Piece[], color: Color): HarmonyValidatio } } - // Check: need at least 3 pieces - if (pieces.length < 3) { - return { valid: false, reason: 'Harmony requires at least 3 pieces' } + // Check: must satisfy layout constraint + if (!checkLayout(pieces, layoutMode)) { + return { + valid: false, + reason: `Pieces not in valid ${layoutMode} layout (must be collinear with correct spacing)`, + } } + // Find middle piece + const middle = findMiddlePiece(pieces) + if (!middle) { + return { valid: false, reason: 'Could not determine middle piece' } + } + + const others = pieces.filter((p) => p !== middle) + // 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?)`, - } + const getVal = (p: Piece) => { + const val = getEffectiveValue(p) + if (val === null) { + throw new Error(`Piece ${p.id} has no effective value (Pyramid face not set?)`) } - values.push(value) + return val } - // 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 { + const m = getVal(middle) + const a = getVal(others[0]) + const b = getVal(others[1]) - // Try to detect progression type (in order: arithmetic, geometric, harmonic) - const arithCheck = isArithmeticProgression(values) - if (arithCheck.valid) { - return arithCheck - } + // Check for duplicates + if (a === m || m === b || a === b) { + return { valid: false, reason: 'Harmony cannot contain duplicate values' } + } - const geomCheck = isGeometricProgression(values) - if (geomCheck.valid) { - return geomCheck - } + // Try all three proportion types + const apCheck = isArithmeticProportion(a, m, b) + if (apCheck.valid) return apCheck - const harmCheck = isHarmonicProgression(values) - if (harmCheck.valid) { - return harmCheck - } + const gpCheck = isGeometricProportion(a, m, b) + if (gpCheck.valid) return gpCheck - return { valid: false, reason: 'Values do not form any valid progression' } + const hpCheck = isHarmonicProportion(a, m, b) + if (hpCheck.valid) return hpCheck + + return { valid: false, reason: 'Values do not form any valid proportion' } + } catch (err) { + return { valid: false, reason: (err as Error).message } + } } /** * Find all possible harmonies for a color from a set of pieces. - * Returns an array of all valid 3+ piece combinations that form harmonies. + * Returns an array of all valid 3-piece combinations that form harmonies. */ export function findPossibleHarmonies( pieces: Record, - color: Color + color: Color, + layoutMode: HarmonyLayoutMode = 'adjacent' ): Array<{ pieceIds: string[]; validation: HarmonyValidationResult }> { const results: Array<{ pieceIds: string[]; validation: HarmonyValidationResult }> = [] @@ -245,32 +304,18 @@ export function findPossibleHarmonies( 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(arr: T[], size: number): Generator { - 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, - }) + // Generate all combinations of exactly 3 pieces + for (let i = 0; i < candidatePieces.length; i++) { + for (let j = i + 1; j < candidatePieces.length; j++) { + for (let k = j + 1; k < candidatePieces.length; k++) { + const combo = [candidatePieces[i], candidatePieces[j], candidatePieces[k]] + const validation = validateHarmony(combo, color, layoutMode) + if (validation.valid) { + results.push({ + pieceIds: combo.map((p) => p.id), + validation, + }) + } } } } @@ -284,22 +329,27 @@ export function findPossibleHarmonies( */ export function isHarmonyStillValid( pieces: Record, - harmony: HarmonyDeclaration + harmony: HarmonyDeclaration, + layoutMode: HarmonyLayoutMode = 'adjacent' ): boolean { const relevantPieces = harmony.pieceIds.map((id) => pieces[id]).filter((p) => p && !p.captured) - if (relevantPieces.length < 3) { + if (relevantPieces.length !== 3) { return false } - const validation = validateHarmony(relevantPieces, harmony.by) + const validation = validateHarmony(relevantPieces, harmony.by, layoutMode) return validation.valid } /** * Check if ANY valid harmony exists for a color (for harmony persistence recheck). */ -export function hasAnyValidHarmony(pieces: Record, color: Color): boolean { - const harmonies = findPossibleHarmonies(pieces, color) +export function hasAnyValidHarmony( + pieces: Record, + color: Color, + layoutMode: HarmonyLayoutMode = 'adjacent' +): boolean { + const harmonies = findPossibleHarmonies(pieces, color, layoutMode) return harmonies.length > 0 }