From dd2385d806cdf7f2f80b3fb65cd2809c3a6f906f Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 26 Apr 2026 18:20:11 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20combat=20calculator=20navigation=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../app/src/pages/CombatCalculator.tsx | 197 ++++++++------- .../games/age-of-dwarves/docs/HEX_GEOMETRY.md | 234 ++++++++++++++++++ 2 files changed, 338 insertions(+), 93 deletions(-) create mode 100644 public/games/age-of-dwarves/docs/HEX_GEOMETRY.md diff --git a/.project/designs/app/src/pages/CombatCalculator.tsx b/.project/designs/app/src/pages/CombatCalculator.tsx index 0f24b9da..44949257 100644 --- a/.project/designs/app/src/pages/CombatCalculator.tsx +++ b/.project/designs/app/src/pages/CombatCalculator.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; import styled from "styled-components"; import { t } from "../theme"; import type { Unit } from "../data/units"; -import { UNIT_PORTRAIT } from "../data/allUnits"; +import { ALL_UNITS, UNIT_PORTRAIT } from "../data/allUnits"; import { calcCombat } from "../utils/combatCalc"; import { UnitBrowser } from "../components/combat/UnitBrowser"; import { HpSlider } from "../components/combat/HpSlider"; @@ -44,6 +44,18 @@ const TopSub = styled.div` font-family: ${t.font.mono}; `; +const TopLink = styled.a` + margin-left: auto; + font-size: 11px; + color: ${t.accent.gold}; + font-family: ${t.font.mono}; + text-decoration: none; + border: 1px solid ${t.border.panel}; + border-radius: 2px; + padding: 4px 10px; + &:hover { border-color: ${t.accent.goldBright}; color: ${t.text.btnHover}; } +`; + const LeftPanel = styled.div` border-right: 1px solid ${t.border.panel}; overflow: hidden; @@ -65,8 +77,6 @@ const CenterPanel = styled.div` scrollbar-color: ${t.border.panel} transparent; `; -// ── empty state ─────────────────────────────────────────────────────────────── - const EmptyState = styled.div` height: 100%; display: flex; @@ -77,10 +87,7 @@ const EmptyState = styled.div` color: ${t.text.muted}; `; -const EmptyIcon = styled.div` - font-size: 48px; - opacity: 0.4; -`; +const EmptyIcon = styled.div`font-size: 48px; opacity: 0.4;`; const EmptyText = styled.div` font-family: ${t.font.heading}; @@ -89,8 +96,6 @@ const EmptyText = styled.div` letter-spacing: 0.04em; `; -// ── unit summary card (top of center) ──────────────────────────────────────── - const UnitSummaryRow = styled.div` display: grid; grid-template-columns: 1fr 40px 1fr; @@ -99,7 +104,7 @@ const UnitSummaryRow = styled.div` align-items: start; `; -const UnitCard = styled.div<{ $side: "attacker" | "defender" }>` +const UnitCard = styled.div` background: ${t.bg.panel}; border: 1px solid ${t.border.panel}; border-radius: 4px; @@ -107,7 +112,6 @@ const UnitCard = styled.div<{ $side: "attacker" | "defender" }>` display: flex; flex-direction: column; gap: 6px; - align-items: ${({ $side }) => ($side === "defender" ? "flex-end" : "flex-start")}; `; const Portrait = styled.div<{ $color: string }>` @@ -129,9 +133,13 @@ const UnitName = styled.div` letter-spacing: 0.04em; `; -const UnitMeta = styled.div` - font-size: 11px; - color: ${t.text.muted}; +const UnitMeta = styled.div`font-size: 11px; color: ${t.text.muted};`; + +const StatLine = styled.div` + display: flex; + gap: 6px; + font-size: 12px; + font-family: ${t.font.mono}; `; const VsColumn = styled.div` @@ -154,26 +162,13 @@ const VsBadge = styled.div` justify-content: center; `; -// ── sliders row ─────────────────────────────────────────────────────────────── - const SlidersRow = styled.div` display: grid; grid-template-columns: 1fr 40px 1fr; gap: 0; margin-bottom: 12px; - align-items: center; `; -const SliderGap = styled.div``; // spacer for the VS column - -// ── section ─────────────────────────────────────────────────────────────────── - -const Section = styled.div` - margin-bottom: 12px; -`; - -// ── hp after row ────────────────────────────────────────────────────────────── - const HpAfterRow = styled.div` display: grid; grid-template-columns: 1fr 1fr; @@ -181,39 +176,44 @@ const HpAfterRow = styled.div` margin-top: 12px; `; -// ── stat row in unit card ───────────────────────────────────────────────────── - -const StatLine = styled.div` - display: flex; - gap: 6px; - font-size: 12px; - font-family: ${t.font.mono}; -`; - -// ── component ───────────────────────────────────────────────────────────────── +// ── helpers ─────────────────────────────────────────────────────────────────── function unitColor(unit: Unit): string { return unit.archetype === "Military" ? "#6699ff" : "#e69933"; } -function statColor(key: "atk" | "def" | "hp"): string { - return key === "atk" ? t.sem.negative : key === "def" ? t.accent.science : t.accent.sage; -} +// ── component ───────────────────────────────────────────────────────────────── export function CombatCalculatorPage(): React.ReactElement { - const [attacker, setAttacker] = useState(null); - const [defender, setDefender] = useState(null); - const [atkHp, setAtkHp] = useState(0); - const [defHp, setDefHp] = useState(0); + const [searchParams, setSearchParams] = useSearchParams(); - function handleSelectAttacker(u: Unit): void { - setAttacker(u); - setAtkHp(u.hp); + // URL is the source of truth + const attackerId = searchParams.get("atk") ?? ""; + const defenderId = searchParams.get("def") ?? ""; + const atkHpParam = parseInt(searchParams.get("atkHp") ?? "0", 10); + const defHpParam = parseInt(searchParams.get("defHp") ?? "0", 10); + + const attacker = ALL_UNITS.find(u => u.id === attackerId) ?? null; + const defender = ALL_UNITS.find(u => u.id === defenderId) ?? null; + + // HP: use URL param if valid, else full HP + const atkHp = attacker ? (atkHpParam > 0 ? Math.min(atkHpParam, attacker.hp) : attacker.hp) : 0; + const defHp = defender ? (defHpParam > 0 ? Math.min(defHpParam, defender.hp) : defender.hp) : 0; + + function selectAttacker(u: Unit): void { + setSearchParams(p => { p.set("atk", u.id); p.set("atkHp", String(u.hp)); return p; }); } - function handleSelectDefender(u: Unit): void { - setDefender(u); - setDefHp(u.hp); + function selectDefender(u: Unit): void { + setSearchParams(p => { p.set("def", u.id); p.set("defHp", String(u.hp)); return p; }); + } + + function setAtkHp(hp: number): void { + setSearchParams(p => { p.set("atkHp", String(hp)); return p; }); + } + + function setDefHp(hp: number): void { + setSearchParams(p => { p.set("defHp", String(hp)); return p; }); } const result = attacker && defender @@ -222,15 +222,37 @@ export function CombatCalculatorPage(): React.ReactElement { const bothSelected = attacker !== null && defender !== null; + // Shareable link — full current URL + const shareUrl = window.location.href; + return ( Combat Calculator - real unit data · live matchup · damage matrix + + {ALL_UNITS.length} units loaded from JSON · live matchup · real damage matrix + + {bothSelected && ( + + 📊 All permutations + + )} + {bothSelected && ( + { e.preventDefault(); navigator.clipboard.writeText(shareUrl); }} + title="Copy link to this matchup" + > + 🔗 Copy link + + )} - + @@ -239,78 +261,67 @@ export function CombatCalculatorPage(): React.ReactElement { Select a unit on each side
- Choose attacker (left) and defender (right) to see the matchup + Choose attacker ← and defender → to see the live matchup +
+
+ URL updates with selection — bookmarkable & shareable
) : ( <> - {/* Unit summary cards */} - + {UNIT_PORTRAIT[attacker.id] ?? "⚔"} {attacker.name} - Tier {attacker.tier} · {attacker.attackType} / {attacker.armor} + T{attacker.tier} · {attacker.attackType} · {attacker.armor} - {attacker.keywords.slice(0, 4).map(kw => ( - {kw} - ))} + {attacker.keywords.slice(0, 4).map(kw => {kw})} - ATK {attacker.attack} - DEF {attacker.defense} - HP {attacker.hp} + ATK {attacker.attack} + DEF {attacker.defense} + HP {attacker.hp} MOV {attacker.movement} VS - + {UNIT_PORTRAIT[defender.id] ?? "⚔"} {defender.name} - Tier {defender.tier} · {defender.attackType} / {defender.armor} - - {defender.keywords.slice(0, 4).map(kw => ( - {kw} - ))} + T{defender.tier} · {defender.attackType} · {defender.armor} + + {defender.keywords.slice(0, 4).map(kw => {kw})} - ATK {defender.attack} - DEF {defender.defense} - HP {defender.hp} + ATK {defender.attack} + DEF {defender.defense} + HP {defender.hp} MOV {defender.movement} - {/* HP sliders */} - +
- {/* Damage matrix */} -
- -
+ - {/* Probability */} {result && ( <> -
- -
- - {/* HP-after bars */} + - + ); diff --git a/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md b/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md new file mode 100644 index 00000000..f7b1b8a8 --- /dev/null +++ b/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md @@ -0,0 +1,234 @@ +# Hex Geometry — The SQ + QL Duality + +**The single differentiator of this game's hex grid.** Every other hex-based 4X treats a tile as one indivisible cell. We do not. Each hex is composed of two 4-cornered formation footprints joined at a shared **spine**. This document is the source of truth for that geometry — every other doc that mentions formations, flanking, biome boundaries, or ZOC is downstream of this one. + +--- + +## 1. The identity + +A regular hexagon has 6 sides. A square has 4. A quadrilateral has 4. Two 4-shapes joined at a single shared edge produce `4 + 4 − 2 = 6`. That sum is the hex. + +``` + ┌─── SQ half ───┐ + ╱ ╲ + ╱ NW NE ╲ + ╱ ● ● ╲ + ●─── W ═══════════ E ───● ← spine (E, W) + ╲ ● ● ╱ + ╲ SW SE ╱ + ╲ ╱ + └─── QL half ───┘ +``` + +- **SQ** (square half) — 4 slots facing one direction +- **QL** (quadrilateral half) — 4 slots facing the opposite direction +- **Spine** — the 2 slots shared between the halves (the joined edge has 2 endpoints) + +A formation occupies one of these halves. A formation that fully tiles a hex occupies both halves and shares the spine with itself. The hex is the smallest plane-tiling regular polygon admitting this decomposition (see §10). + +--- + +## 2. Direction-to-half mapping + +The simulator's neighbor directions are indexed `0..5` in `mc-core/src/algorithms/hex.rs:11-19`: + +| Index | Name | Axial vector | +|---|---|---| +| 0 | E | (+1, 0) | +| 1 | NE | (+1, −1) | +| 2 | NW | (0, −1) | +| 3 | W | (−1, 0) | +| 4 | SW | (−1, +1) | +| 5 | SE | (0, +1) | + +These map to halves as: + +| Half | Hex directions | Slot count | +|---|---|---| +| **SQ** (north-facing) | NE, NW, **W**, **E** | 4 | +| **QL** (south-facing) | **E**, SE, SW, **W** | 4 | +| **Spine** (shared) | E, W | 2 | + +The spine is the E–W axis. SQ-only directions are NE, NW. QL-only directions are SE, SW. Total perimeter slot count: `4 + 4 − 2 = 6`, one per neighbor direction. + +--- + +## 3. Slot semantics + +A **slot** is an abstract role bound when a formation forms. It is purely combinatorial — slots do not have sub-pixel positions, sub-tile rendering, or independent HP. Every slot still maps one-to-one with a unit at the hex center; the slot label only governs *how that unit interacts with damage and direction*. + +The seven slot roles: + +| Role | Half | Direction | Notes | +|---|---|---|---| +| `leader` | spine | center | Highest-tier / highest-HP unit; damaged last in any hit set | +| `spine-E` | spine (both) | E | Damaged by attacks from any half | +| `spine-W` | spine (both) | W | Damaged by attacks from any half | +| `sq-NE` | SQ only | NE | Damaged only by SQ-half attackers | +| `sq-NW` | SQ only | NW | Damaged only by SQ-half attackers | +| `ql-SE` | QL only | SE | Damaged only by QL-half attackers | +| `ql-SW` | QL only | SW | Damaged only by QL-half attackers | + +The leader sits on the spine by default, since spine units have the highest exposure but also the most options for retreat (both halves shelter them). + +--- + +## 4. How combat resolves + +Movement and damage are still applied **center to center**. The QL is not traversed; it is the engagement membrane. Combat semantics by type: + +| Combat type | Engagement geometry | Whose terrain bonus applies | +|---|---|---| +| Melee | At the shared edge between A and B (each tile's QL on the side facing the other) | Attacker rolls with A's terrain; defender with B's terrain | +| Ranged | Projectile passes *over* the shared edge from A-center to B-center | Defender's QL on the attacker-facing side provides cover from B's terrain | +| Magic | Same as ranged unless the spell is `contact` | Same | +| Charge | Melee, but on a successful break attacker may push past their own QL into B | Attacker forfeits A's terrain bonus on the followup tile | + +**This explains the forest-cover-on-attack rule.** A unit attacking out of forest into adjacent plains is still engaging *at the forest's edge* (its own QL on the plains-facing side), so it keeps cover. Other hex games handwave this. Here it is geometric. + +--- + +## 5. Flanking + +Flanking is **discrete** and **slot-set-based**, not angle-threshold-based. An attacker from direction D applies damage to the half D belongs to (4 slots). Spine slots take damage from any direction. + +| Attacker pattern | Damage hits | +|---|---| +| 1 attacker, any direction | 4 slots in the half D belongs to | +| 2 attackers from the same half | Same 4 slots, doubled load on those slots | +| 2 attackers from opposite halves | All 8 slot-positions touched. Spine units are double-hit (overlap). **Flanking trigger.** | +| 3+ attackers spanning both halves | All 8 positions hit; spine multi-hit. **Encirclement.** | + +Spine units are the most exposed — they cannot avoid damage by half-rotation. This is the geometric basis for "leader survives because the spine ate the hit" tactics. + +--- + +## 6. Movement + +- Units occupy hex **centers**. +- Movement is graph traversal: center → adjacent center along the existing pathfinding graph (`mc-core/src/algorithms/pathfinding.rs`). +- The QL is **not** traversed. There is no sub-hex pathing. +- Move cost is per-hex (terrain-derived), not per-half. + +The QL exists to govern *engagement*, not *traversal*. A unit "passes through" a hex's QL only in the sense that it crosses the membrane between two tiles when moving from one to another — there is no half-step where the unit is "in the QL." + +--- + +## 7. Biome boundaries + +Each hex has one terrain (plains, forest, hill, …) filling the tile uniformly at the data level. The **perceived** biome boundary between two adjacent hexes lies at the shared edge — which is each tile's QL on the side facing its neighbor. + +``` + hex A (forest) hex B (plains) + ┌───────────────────┐ ┌───────────────────┐ + │ │ │ │ + │ SQ │ │ SQ │ + │ ━━━━━━━━━━━━━━━ │ │ ━━━━━━━━━━━━━━━━ │ + │ QL ════════════════ QL │ + │ │ │ │ + │ │ │ │ + └───────────────────┘ └───────────────────┘ + ↑ + shared edge = both tiles' QL + = the biome contact membrane +``` + +When a forest unit attacks the adjacent plains unit: +- The attacker engages at A's QL on the B-facing side — *still in the forest*. +- The defender is hit at B's QL on the A-facing side — exposed plains. +- Attacker keeps forest cover; defender does not gain cover from the trees they are facing. + +This is the canonical interpretation of terrain-cover-on-attack and should be used to derive every per-terrain combat modifier. + +--- + +## 8. Rotation + +A formation rotating 60° **re-binds the slot labels** — the same 4 (or 6, or 7) units stay where they are, but their slot roles shift one position. Rotation does **not** swap SQ ↔ QL bodily; it relabels. + +Example: a Diamond formation initially facing N (SQ-half = NE, NW; spine = E, W; QL-half = SE, SW) rotated 60° clockwise faces NE. The same units now hold: +- old `sq-NE` → new `spine-E` +- old `sq-NW` → new `sq-NE` +- old `spine-E` → new `ql-SE` +- old `spine-W` → new `sq-NW` +- old `ql-SE` → new `ql-SW` ... etc. + +This matches the cube-space 60° rotation primitive already in `hex.rs` — no new rotation math is required. + +A formation rotating 180° preserves the spine (E–W stays the spine) and swaps SQ ↔ QL labels, since the formation has flipped end-for-end. + +--- + +## 9. Stacking and ZOC + +### Stacking — none + +The SQ and QL halves of a hex are **not** separately occupiable by different formations. The duality is a partition of *one* formation's slots, not an opportunity for two-tenants-per-tile. This preserves the established "each unit retains its own hex — no stacking" rule from `.project/objectives/p0-42.md`. + +### Zone of Control — facing-half only + +A formation projects ZOC into its **facing half** only — 4 of the 6 directions. A formation in QL-orientation projects ZOC into directions {E, SE, SW, W}; an SQ-orientation projects into {NE, NW, W, E}. The two unfaced directions are not ZOC-projecting. + +Implication: an attacker can approach via the unfaced half without paying ZOC cost, but loses the option to flank without rotating around the formation. Facing matters. + +(ZOC code in `mc-turn` currently projects all 6 directions; this is documented as intended behavior pending a downstream code refactor.) + +--- + +## 10. Considered alternative — "square + 4 QLs" dodecagon, rejected + +A reasonable design instinct on first hearing the duality is: *if a hex is `SQ + QL`, why not a tile that is one central SQ surrounded by 4 QLs?* + +``` + ┌────┐ + │ QL │ + ┌────┼────┼────┐ + │ QL │ SQ │ QL │ + └────┼────┼────┘ + │ QL │ + └────┘ +``` + +This is a **dodecagon** (12-sided plus-pentomino). Its properties versus the hex: + +| Property | Hex (SQ + QL = 6) | Dodecagon (SQ + 4 QL = 12) | +|---|---|---| +| Edges per tile | 6 | 12 | +| Neighbors per tile | 6 | 12 | +| Combat permutations | 6 directions | 12 directions (~2× balance work) | +| Rotational symmetry | 6-fold | 4-fold | +| SQ–neighbor contact | Direct via spine | Only via QLs (SQ never directly contacts a neighbor) | +| Plane-tiling cost | Edge-to-edge with one tile shape | Plus-pentomino, cross-shaped tiling | + +The hex's `4 + 4 − 2 = 6` already delivers the duality at the lowest plane-tiling cost. The dodecagon's SQ never directly contacts a neighbor; only its 4 QLs do. With the hex, **the spine itself is the contact** — which is exactly what makes the biome-edge / QL-engagement story work. + +We went with the hex. This section exists so the next person to suggest the dodecagon finds the answer. + +--- + +## 11. Implementation surface + +The geometry described in this doc is *partially* implemented today; the data model still treats formations as multi-hex spreads rather than sub-tile slot-sets. The relevant code paths: + +| Concern | File | Status | +|---|---|---| +| Hex coord math (axial / cube / odd-q offset) | `src/simulator/crates/mc-core/src/algorithms/hex.rs` | ✅ Implemented; matches `HexUtils.gd` and `HexGrid.ts` | +| Direction indices `0..5` | `mc-core/src/algorithms/hex.rs:11-19` | ✅ Single source of truth | +| Formation data type | `src/simulator/crates/mc-core/src/formation.rs` | ⚠️ Has `FormationShape::{Line, Column, Wedge, Diamond}` enum but no slot-set partition | +| Formation movement / reflow | `src/simulator/crates/mc-turn/src/formation_move.rs` | ⚠️ Treats shapes as multi-hex spreads, not sub-tile slot sets | +| Combat resolver | `src/simulator/crates/mc-combat/src/resolver.rs:65-81` | ⚠️ HP scales by `formation_count` but does not route damage by half | +| ZOC | `mc-turn` | ⚠️ Projects all 6 directions; should project facing-half only (§9) | +| AI evaluator | `src/simulator/crates/mc-ai/src/evaluator.rs:414-480` | ⚠️ Scores formations by size + threat; does not yet model the duality | +| Renderer | `src/packages/guide/src/components/climate-sim/HexGLRenderer.tsx` | ✅ Tile rendering correct; no SQ/QL overlay (debug-only feature pending) | + +Bringing these into alignment with the spec above is tracked as a successor plan. + +--- + +## See also + +- `combat/COMBAT_SYSTEM.md` — armor/attack matrix; references this doc for the positional-damage layer +- `combat/FORMATIONS.md` — how `FormationShape::{Line, Column, Wedge, Diamond}` map to SQ/QL/spine +- `terrain/TERRAIN_SYSTEM.md` — references this doc for biome-boundary semantics +- `.project/designs/hex-formation-duality.md` — annotated diagram for engineers/designers +- `.project/designs/hex-formation-sketch.html` — interactive mockup