feat(rithmomachia): Update harmony system to classical three-piece proportions

Updated harmony validation to match classical Rithmomachia rules using
three-piece proportions (A-M-B structure) with integer arithmetic formulas:

- SPEC.md §7: Rewrote harmony section with classical three-piece rules
  - AP (Arithmetic): 2M = A + B (middle is arithmetic mean)
  - GP (Geometric): M² = A·B (middle is geometric mean)
  - HP (Harmonic): 2AB = M(A+B) (middle is harmonic mean)
  - Added layout constraints: adjacent, equalSpacing, collinear
  - Added common harmony triads reference list

- harmonyValidator.ts: Complete rewrite for three-piece validation
  - Collinearity checking (straight line requirement)
  - Spatial middle piece detection
  - Integer proportion formulas (no division needed)
  - Layout modes: adjacent (all distance 1), equalSpacing (equal 1-2), collinear (any)
  - Fixed coordinate access: use file/rank instead of col/row

- types.ts: Updated HarmonyDeclaration.params structure
  - Changed from v/d/r/n fields to a/m/b (first/middle/last values)
  - Matches classical three-piece proportion structure

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-10-30 21:44:43 -05:00
parent 67df4d244c
commit 08c97620f5
4 changed files with 805 additions and 685 deletions

View File

@ -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 58, Black in rows 14) 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 58, Black in rows 14) 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: AMB)
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 AMB
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 6812 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).
---

View File

@ -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
}

View File

@ -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<string, Piece>,
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<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,
})
// 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<string, Piece>,
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<string, Piece>, color: Color): boolean {
const harmonies = findPossibleHarmonies(pieces, color)
export function hasAnyValidHarmony(
pieces: Record<string, Piece>,
color: Color,
layoutMode: HarmonyLayoutMode = 'adjacent'
): boolean {
const harmonies = findPossibleHarmonies(pieces, color, layoutMode)
return harmonies.length > 0
}