feat(@projects/@magic-civilization): add combat calculator navigation link

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-26 18:20:11 -07:00
parent eba9b32db8
commit dd2385d806
2 changed files with 338 additions and 93 deletions

View file

@ -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<Unit | null>(null);
const [defender, setDefender] = useState<Unit | null>(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 (
<Shell>
<TopBar>
<TopTitle>Combat Calculator</TopTitle>
<TopSub>real unit data · live matchup · damage matrix</TopSub>
<TopSub>
{ALL_UNITS.length} units loaded from JSON · live matchup · real damage matrix
</TopSub>
{bothSelected && (
<TopLink
href="/permutations"
title="See all unit matchup permutations"
>
📊 All permutations
</TopLink>
)}
{bothSelected && (
<TopLink
href={shareUrl}
onClick={e => { e.preventDefault(); navigator.clipboard.writeText(shareUrl); }}
title="Copy link to this matchup"
>
🔗 Copy link
</TopLink>
)}
</TopBar>
<LeftPanel>
<UnitBrowser side="attacker" selected={attacker} onSelect={handleSelectAttacker} />
<UnitBrowser side="attacker" selected={attacker} onSelect={selectAttacker} />
</LeftPanel>
<CenterPanel>
@ -239,78 +261,67 @@ export function CombatCalculatorPage(): React.ReactElement {
<EmptyIcon></EmptyIcon>
<EmptyText>Select a unit on each side</EmptyText>
<div style={{ fontSize: 13, color: t.text.muted }}>
Choose attacker (left) and defender (right) to see the matchup
Choose attacker and defender to see the live matchup
</div>
<div style={{ fontSize: 11, color: t.text.muted, fontFamily: t.font.mono, marginTop: 8 }}>
URL updates with selection bookmarkable & shareable
</div>
</EmptyState>
) : (
<>
{/* Unit summary cards */}
<UnitSummaryRow>
<UnitCard $side="attacker">
<UnitCard>
<Portrait $color={unitColor(attacker)}>{UNIT_PORTRAIT[attacker.id] ?? "⚔"}</Portrait>
<UnitName>{attacker.name}</UnitName>
<UnitMeta>Tier {attacker.tier} · {attacker.attackType} / {attacker.armor}</UnitMeta>
<UnitMeta>T{attacker.tier} · {attacker.attackType} · {attacker.armor}</UnitMeta>
<TagRow>
{attacker.keywords.slice(0, 4).map(kw => (
<Tag key={kw} variant="kw">{kw}</Tag>
))}
{attacker.keywords.slice(0, 4).map(kw => <Tag key={kw} variant="kw">{kw}</Tag>)}
</TagRow>
<StatLine>
<span style={{ color: statColor("atk") }}>ATK {attacker.attack}</span>
<span style={{ color: statColor("def") }}>DEF {attacker.defense}</span>
<span style={{ color: statColor("hp") }}>HP {attacker.hp}</span>
<span style={{ color: t.sem.negative }}>ATK {attacker.attack}</span>
<span style={{ color: t.accent.science }}>DEF {attacker.defense}</span>
<span style={{ color: t.accent.sage }}>HP {attacker.hp}</span>
<span style={{ color: t.accent.gold }}>MOV {attacker.movement}</span>
</StatLine>
</UnitCard>
<VsColumn><VsBadge>VS</VsBadge></VsColumn>
<UnitCard $side="defender">
<UnitCard>
<Portrait $color={unitColor(defender)}>{UNIT_PORTRAIT[defender.id] ?? "⚔"}</Portrait>
<UnitName>{defender.name}</UnitName>
<UnitMeta>Tier {defender.tier} · {defender.attackType} / {defender.armor}</UnitMeta>
<TagRow $align="end">
{defender.keywords.slice(0, 4).map(kw => (
<Tag key={kw} variant="kw">{kw}</Tag>
))}
<UnitMeta>T{defender.tier} · {defender.attackType} · {defender.armor}</UnitMeta>
<TagRow>
{defender.keywords.slice(0, 4).map(kw => <Tag key={kw} variant="kw">{kw}</Tag>)}
</TagRow>
<StatLine>
<span style={{ color: statColor("atk") }}>ATK {defender.attack}</span>
<span style={{ color: statColor("def") }}>DEF {defender.defense}</span>
<span style={{ color: statColor("hp") }}>HP {defender.hp}</span>
<span style={{ color: t.sem.negative }}>ATK {defender.attack}</span>
<span style={{ color: t.accent.science }}>DEF {defender.defense}</span>
<span style={{ color: t.accent.sage }}>HP {defender.hp}</span>
<span style={{ color: t.accent.gold }}>MOV {defender.movement}</span>
</StatLine>
</UnitCard>
</UnitSummaryRow>
{/* HP sliders */}
<SlidersRow>
<HpSlider unit={attacker} currentHp={atkHp} onChange={setAtkHp} />
<SliderGap />
<div />
<HpSlider unit={defender} currentHp={defHp} onChange={setDefHp} />
</SlidersRow>
{/* Damage matrix */}
<Section>
<DamageMatrix
highlightAtk={attacker.attackType}
highlightArmor={defender.armor}
/>
</Section>
<DamageMatrix
highlightAtk={attacker.attackType}
highlightArmor={defender.armor}
/>
{/* Probability */}
{result && (
<>
<Section>
<ProbabilityBar
effAtk={result.effAtk}
effDef={result.effDef}
atkLabel={`${attacker.name} wins`}
defLabel={`${defender.name} survives`}
/>
</Section>
{/* HP-after bars */}
<ProbabilityBar
effAtk={result.effAtk}
effDef={result.effDef}
atkLabel={`${attacker.name} wins`}
defLabel={`${defender.name} survives`}
/>
<HpAfterRow>
<HpAfterBar
label={`${attacker.name} HP after (${atkHp} now)`}
@ -334,7 +345,7 @@ export function CombatCalculatorPage(): React.ReactElement {
</CenterPanel>
<RightPanel>
<UnitBrowser side="defender" selected={defender} onSelect={handleSelectDefender} />
<UnitBrowser side="defender" selected={defender} onSelect={selectDefender} />
</RightPanel>
</Shell>
);

View file

@ -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 EW 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 (EW 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 |
| SQneighbor 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