A digital hex-and-counter wargame for the Horse and Musket era (1700–1860), built with SvelteKit and Svelte 5. Derived from Neil Thomas' One-Hour Wargames and Simplicity in Practice systems.
pnpm install # Install dependencies
pnpm dev # Start dev server
pnpm check # Type checking
pnpm test # Run all tests
pnpm format # Auto-format with Prettier
rules/living-rules.md is the authoritative rules document. All implementation should be validated against it.
Each milestone builds on the previous ones. Dependencies are noted where they exist.
unitAt() object-reference comparison bug — now uses coordsEqual() helperMapDeffintion, restGameStore, #clearUnitFLagsMovementAllowance and AttackRange constantsTEST_UNITS placeholder values (hits: 15 → 4)OffsetCoordinates (col/row) for Unit.coordinates — added coordsEqual() to hex.tshex.spec.ts with coordsEqual testsLINE_INFANTRY, LIGHT_INFANTRY, DRAGOONS, LIGHT_HORSE, HORSE, ARTILLERYcore/unitDefinitions.ts — data table with all stats per type (movement, action type, range, hit chance, charge, terrain entry)ActionType enum (MOVE_OR_FIRE, FIRE_AND_MOVE, MOVE_ONLY) and ChargeAbility discriminated unionUnit type: strengthPoints/maxStrengthPoints replace hits; removed movementPoints/inHandToHand; added activatedUnitCounter.svelte with 6 distinguishable NATO-style SVG iconsscenarios.ts; 47 rules-compliance tests in unitDefinitions.spec.tsFacing was implemented at this milestone and later removed in the facing refactor (post-M7). The core/facing.ts module, the HexFacing/FacingZone types, Unit.facing, Unit.facingStepsUsed, UnitDefinition.hasFacing, the Town all-around override, and the rotate UI were all deleted. Units now move and fire in any direction without rotation cost.
core/terrain.ts — TerrainDefinition type and terrainDefinitions data table for all 10 terrain types, plus 4 helper functions: canUnitEnterTerrain, getTerrainCoverModifier, doesTerrainBlockLOS, getTerrainElevationallowedUnitTypes: readonly UnitType[] | null in each definition encodes entry restrictions (null = all units); isImpassable short-circuits canUnitEnterTerrain before allowedUnitTypes is consultedelevation getter to HexCell in hex.ts (HILLTOP → 1, all others → 0); no terrain.ts import needed — logic is inlineterrain.spec.ts (all 60 terrain × unit entry combinations + LOS, cover, elevation, and all definition properties per terrain type); 5 elevation getter tests added to hex.spec.tsPhase enum and advancePhase(); replaced with ActivationStep (AWAITING_ACTIVATION → COMMAND_CHECK → ACTION → CHARGE_RESOLUTION → MORALE_CHECK → ACTIVATION_COMPLETE) and four lifecycle methods on GameStore: activateUnit(id), completeAction(), endActivation(), endPlayerTurn()GameStore state now tracks activationStep and activeUnitId in place of currentPhase; activated flag on each unit is cleared only at game-turn rollover (not at player-turn switch), enforcing once-per-game-turn activationCOMMAND_CHECK, CHARGE_RESOLUTION, and MORALE_CHECK steps are instantaneous auto-pass stubs for M4 — the state machine still enters each step so M7/M9/M10 can slot in real logic without restructuringmoveUnit now gates on activationStep === ACTION and activeUnitId match (not a legacy phase); toggleUnit is a no-op mid-activation and on already-activated unitsGameStore class (alongside the existing initGameStore/getGameStore singleton helpers) so tests can instantiate hermetically with new GameStore(structuredClone(TEST_UNITS), TEST_MAP)+page.svelte replaces the single "Advance Phase" button with four reactively-disabled buttons (Activate Selected / Complete Action / End Activation / End Player Turn) and displays activationStep and activeUnitIdgameStore.spec.ts covering initial state, full activation lifecycle, once-per-turn enforcement, player switch vs game-turn rollover, all guard no-ops, move-step gating, and toggleUnit interaction with activationcore/movement.ts with getValidMoveTargets(unit, grid, units, remainingMP?) — BFS pathfinding respecting terrain entry, stacking, Light Infantry pass-through, enemy-adjacency exclusion, and road bonus (+1 on all-road path); remainingMP parameter enables hex-by-hex multi-step movement for 2-MP unitsrequiresDifficultTerrainCheck / rollDifficultTerrainCheck — units with terrainCheckRequired on a difficult terrain hex must pass ~50% roll to leave; failure exhausts all remaining MP without movinghasMoved: boolean on Unit with movementPointsUsed: number — enables incremental MP trackingvalidMoveTargets derived on GameStore recomputes after each move step with remaining MP; highlights valid hexes in the UI via HexTile.svelte yellow stroke overlaymoveUnit validates against validMoveTargets, looks up step cost, increments movementPointsUsedmovement.spec.ts (range by type, terrain entry, stacking, road bonus, edge cases); new tests in gameStore.spec.ts covering multi-step Dragoon movementcore/los.ts with hasLineOfSight(from, to, grid, units) — pure-geometry LOS via cube-coordinate line tracing (lerp + round-to-nearest-hex)hexDistance ≤ 1) always have LOS; off-grid endpoints return false; units on the firer/target hex never block their own LOSlos.spec.ts covering trivial cases, open-ground LOS at varied distances, all blocking-terrain types (and the non-blockers Marsh/Lake/River/Bridge/Road/Ford), unit blockers, hill elevation rules, hexside boundary ties, and all plunging-fire variantscore/combat.ts with getValidFireTargets(attacker, grid, units) and resolveFireAction(attacker, target, grid, rng?) returning a transparent FireResult (base, cover, long-range modifiers, final hit chance, hit, damage)firedThisActivation: boolean on Unit enforces action-type gating: MOVE_OR_FIRE units (Line Inf, Dragoons, Artillery) cannot move after firing nor fire after moving; FIRE_AND_MOVE (Light Infantry) may do both in either order; MOVE_ONLY (Cavalry) never fire (encoded as firingRange: 0)GameStore.fireAt(targetId, rng?) validates against validFireTargets, applies clamped SP damage to the target, sets the firer's firedThisActivation, and returns the FireResult; flag is cleared in endActivation and #clearActivatedFlags (turn rollover)UnitCounter.svelte gains a fireTarget prop that draws a red ring around eligible targets; +page.svelte computes a fire-target id set from validFireTargets and branches the click handler to store.fireAt(unit.id) when the target is eligiblecombat.spec.ts (range, directional independence, LOS, fired flag, multi-target, base hit chances, cover, long-range, RNG-driven hit/double-damage/miss, second-draw frugality, clamping); new tests in gameStore.spec.ts covering hit/double/miss SP arithmetic, flag set on hit and miss, target gating, MOVE_OR_FIRE vs FIRE_AND_MOVE mutual exclusion, SP-clamp at 0, and endActivation clearing the flagcore/charge.ts with canCharge(attacker, defender), getValidChargeTargets(attacker, grid, units), and resolveCharge(attacker, defender, attackerOrigin, grid, units, rng?) returning a transparent ChargeResult (scores, damage, outcome, retreat coords, advance flag)chargeAt, which applies the post-resolution state atomically — no external observer sees the transient overlapfiredThisActivation gates are honoredattacker_repulsed (delta ≤ 0: 1 hit, attacker bounces, defender unchanged); defender_retreats (delta 1–2: 1 hit, defender retreats); defender_holds (delta 1–2 and defender on difficult terrain → auto-hold per rules; OR no legal retreat hex → extra hit converts mandatory retreat to a hit); defender_eliminated (defender SP ≤ 0 after damage). Delta ≥ 3 → 2 hits + mandatory retreat (auto-hold ignored)core/retreat.ts forward from M9 (was M9-scoped): getRetreatHex(defender, attackerOrigin, grid, units) picks the legal neighbor most aligned with the push vector via cube-coordinate dot product, ties broken by lowest direction index for determinism. Retreat is forced movement — candidates adjacent to other enemies are still legal endpoints'move' action mode (no new action mode). +page.svelte adds a chargeTargetIds derived; the enemy-counter click handler branches selectUnit → fireAt → chargeAt based on which target set the unit is in. UnitCounter.svelte gains a chargeTarget prop rendering an orange ring at a distinct radius from the red fire-target ring so the two never visually collide (in practice only one set is non-empty per mode)chargeAt in gameStore.svelte.ts validates against validChargeTargets, runs the difficult-terrain check on leaving the attacker's hex (same as moveUnit), calls resolveCharge, applies SP damage and coordinate moves atomically, filters eliminated units out, and finishes the activation regardless of outcomeretreat.spec.ts (direction selection per attacker position, blocking by friendly/enemy/terrain/off-map, tie-breaking); 34 tests in charge.spec.ts (eligibility per unit-type matrix, reachability via BFS, terrain entry, all outcome branches, Horse +1, difficult-terrain modifier and auto-hold, no-retreat extra-hits conversion); 12 new tests in gameStore.spec.ts covering charge gating per action mode, advance/bounce coordinate transitions, defender elimination removing the unit, attacker SP damage, and activation lifecycle resetcore/morale.ts with checkMorale(unit, attackerOrigin, grid, units, { leaderAttached, outOfCommand }, rng) → transparent MoraleResult (base/final pass chance, per-modifier breakdown, roll, passed, retreatTo, additionalDamage)clamp(remainingSP / maxSP + 0.15·elite + 0.15·leaderAttached − 0.15·outOfCommand, 0, 1) — ±0.15 step mirrors the cover/long-range modifiers in combat.ts. Pass test is strict roll < finalPassChance so a clamped-to-0 chance never passesgetRetreatHex (M8) AND take +1 SP; if no legal retreat hex, take +1 SP without moving. No cascade — the failed-morale extra hit never triggers another checkelite: boolean to Unit (default false on all TEST_UNITS); outOfCommand and leaderAttached are wired to false from the store until M10 landsFireResult and ChargeResult extended with morale: MoraleResult | null. Morale runs immediately inside fireAt/chargeAt, folded into the same atomic units.map(...) that applies the triggering damage — observers never see an intermediate state. ActivationStep.MORALE_CHECK remains a transient pass-through in #finishActivation()defender_retreats and defender_holds). attacker_repulsed (no defender hit) and defender_eliminated (post-charge SP 0) skip morale. On defender_retreats + morale-fail, the second forced retreat originates from the defender's post-charge hex with source = attacker's post-resolution coords (per literal §9.1)morale.spec.ts (SP ratios, modifier stacking, ±-clamp at 0/1, retreat direction, no-legal-retreat, determinism, transparency); 9 new tests in gameStore.spec.ts covering the fire path (miss / 1-dmg + morale pass / 1-dmg + morale fail with retreat / eliminating-damage skips morale / no-cascade rng count) and the charge path (attacker_repulsed, defender_eliminated, defender_retreats with morale fail overriding coords, defender_retreats with morale pass)core/command.ts with Leader type (id, attachedToUnitId, commandRadius), getAttachedLeader, isInCommand, and transparent resolvers resolveCommandCheck → CommandCheckResult and resolveLeaderCasualty → { result: LeaderCasualtyResult | null, leaders: Leader[] }. Reuses hexDistance from hex.ts:42 for radius and nearest-leader lookups; same-side filter via the host unit's playerroll: 0, finalPassChance: 1); out-of-command units roll vs. base 50%, with a −15% farPenalty when distance to nearest friendly leader > 2× that leader's radius. Pass test is strict roll < finalPassChance. Boundary is inclusive (D ≤ R = in command)max(0, original − 1). Replacement ids carry a -r{n} suffix for traceable lineage. If no leaderless friendly unit exists, the leader vanishes with no replacementleaders: Leader[] state and lastCommandCheck: CommandCheckResult | null to GameStore. Constructor signature is now new GameStore(units, map, leaders) — required; initGameStore and +page.svelte updated accordingly#activate(id, rng) now runs the real check. On fail, the unit's activated flag is set and the activation immediately finishes through CHARGE_RESOLUTION → MORALE_CHECK → ACTIVATION_COMPLETE — no movement, fire, or charge possible. activateUnit(id, rng?) and beginAction(mode, rng?) thread the rng through; beginAction early-returns after a failed command checkfireAt/chargeAt order on damage: leader casualty first, then morale (per design choice). Morale's leaderAttached and outOfCommand inputs reflect post-casualty state. FireResult.leaderCasualty and ChargeResult.{attackerLeaderCasualty, defenderLeaderCasualty} expose the rolls. Attacker casualty is only rolled on attacker_repulsed; defender casualty only when the defender survives the hitchargeAt's .filter(u => u.strengthPoints > 0), leaders whose host was just eliminated are also dropped (no replacement, per §10 — distinct from the §8.3 casualty replacement). M11 will formalize the elimination triggerTEST_LEADERS in scenarios.ts: one per side (1 per 2 units rounded down, min 1). Blue leader on blue-line-inf, red leader on red-horse (rarely a fire/charge target — keeps casualty rolls out of pre-M10 rng sequences). Both with commandRadius: 10 to cover the 6×4 TEST_MAP, so existing tests stay in-command and skip the rollcommand.spec.ts (isInCommand boundary, multi-leader pick-nearest, enemy filter; command check pass/fail with and without far penalty, no-leader fallback; casualty no-leader/fail/pass, radius-1-to-0 degrade, no-candidate path, -r1/-r2 id chain). 14 new tests in gameStore.spec.ts covering in-command/out-of-command activation, wasted activation, turn rollover clearing the activated flag, far-penalty propagation, fire+casualty+replacement, casualty failure no-op, eliminating fire skips casualty/morale, outOfCommand propagation into morale modifiers, charge+defender casualty, charge eliminating leader-host triggers orphan cleanup, and attacker_repulsed triggers attacker casualtycore/elimination.ts with a pure applyEliminations(units, leaders) → { units, leaders, result: EliminationResult } per rules §10. Filters out units at strengthPoints ≤ 0 and drops any leader whose attachedToUnitId is no longer present in the surviving set — with no replacement (distinct from the §8.3 casualty replacement handled by resolveLeaderCasualty). Defensive: also removes leaders attached to ghost ids. Pure and idempotent; iteration order preserved for deterministic test orderingfireAt and chargeAt now call applyEliminations once, atomically, after the damage units.map(...). chargeAt's previous inline .filter(u => u.strengthPoints > 0) plus separate survivingIds-based leader orphan-cleanup was replaced with the helper. fireAt previously left eliminated targets at SP 0 in the array; that gap (which the morale-induced +1 SP can hit) is now closedFireResult and ChargeResult gain eliminatedUnitIds: string[] and eliminatedLeaderIds: string[], complementing the existing leaderCasualty/morale fields. Empty arrays in the common no-elimination case. All four resolveCharge branches and resolveFireAction populate [] by default; the store overwrites at the return siteOmit<ChargeResult, …> base in resolveCharge was extended to also omit the two new fields so each branch must set them explicitlystore.units.find(...) were tightened to assert removal: three pre-existing tests ("clamps target SP at 0", "damage that eliminates → result.morale is null", "fire eliminating the target → no leader casualty/morale") now check .find(...) === undefined and (where applicable) that the attached leader is also gone — verifying §10's no-replacement rule on the fire pathelimination.spec.ts (no eliminations, single unit, unit+leader, leader on different live unit, multiple eliminations in input order, ghost-host defensive cleanup, immutability, idempotence). 6 new tests in gameStore.spec.ts (fire non-lethal empty arrays, fire morale-fail-eliminates, fire double-damage-eliminates, fire eliminates leader host triggers §10 cleanup, charge defender_eliminated surfaces leader id, charge attacker_repulsed at SP 1 eliminates attacker + leader without replacement)Replace hardcoded test data with a scenario system.
Scenario type:
New core/victory.ts:
checkVictoryConditions(scenario, gameState) → result or nullGameStore init takes a Scenario instead of raw units + map.
Files: scenarios.ts (major expansion), new core/victory.ts, gameStore.svelte.ts, maps.ts (absorbed into scenarios)
Tests: Scenario loading. Victory condition evaluation.
Minimal but functional interface for all game mechanics. Build only after core systems work.
Files: +page.svelte, HexTile.svelte, UnitCounter.svelte, new components as needed
M0 Bug Fixes
│
M1 Unit Types
│
├── M2 Facing & Zones (later removed in facing refactor)
│ │
├── M3 Terrain System
│ │
└── M4 Turn & Activation ──┐
│ │
M5 Movement ───────────┘ (needs M3, M4)
│
M6 Line of Sight (needs M3)
│
M7 Firing Combat (needs M6)
│
M8 Charge Combat (needs M3, M5)
│
M9 Morale (needs M7/M8)
│
M10 Command & Control (needs M4)
│
M11 Elimination & Retreat (needs M9, M10)
│
M12 Scenarios & Victory
│
M13 UI Polish
core/ as pure functions — no Svelte imports, no side effects. Accept an RNG parameter where randomness is needed..map(), not mutating in place.core/ module gets a companion .spec.ts with server-side tests before wiring into the store or UI.