diff --git a/.project/experiments/p1-29-tier10-by-t200.md b/.project/experiments/p1-29-tier10-by-t200.md
new file mode 100644
index 00000000..c2f633ce
--- /dev/null
+++ b/.project/experiments/p1-29-tier10-by-t200.md
@@ -0,0 +1,73 @@
+# Experiment log — p1-29 Anti-early-domination + tier-10 by T200
+
+**Tracking objective:** [`p1-29` — Anti-early-domination](../objectives/p1-29.md)
+
+## User-stated targets (2026-04-26)
+
+- **Game length: ~T300 typical, ≤T500 cap.** Currently 50% of games end T48-T200 via early domination (median T146-T150 in cycle-3 batches).
+- **Hard/Insane AI should reach `tier_peak ≥ 10` (top of era ladder) by T200.** Currently `tier_peak=10` only seen in T408-T500 games.
+- Easy AI should be clearly weaker (slower tier progression).
+
+## Hypothesis tree
+
+### H1 — Research speed is the binding constraint
+- **Premise:** Insane has `research_mult=1.4` (40% faster). To hit tier_peak=10 by T200 vs current ~T400, need ~2× more research throughput, so research_mult ≈ 2.8-3.0 for Insane.
+- **Risk:** Faster research may not translate to faster `tier_peak` if the AI's `_pick_research` (cycle-1 personality scorer in `auto_play.gd:1183`) prioritises pillars that don't advance tier (e.g. goldvein's civics pillar).
+- **Mitigation:** Measure both `tier_peak` and `techs` count by turn. If `techs` advances but `tier_peak` doesn't, the picker is the bottleneck, not research speed.
+- **Independent of:** game length. Research can be fast even in short games.
+
+### H2 — Game length is gated by early-domination capture math
+- **Premise:** Median game ends T48-T200 because one player snowballs to capture all enemy capitals. mc-turn `victory.rs::domination_requires_all_capitals=true` fires immediately. Slower capture = longer game = more turns to research.
+- **Risk:** Slowing capture too much produces score-victory stalemate at T300/T500 (no domination ever fires). Bimodal failure.
+- **Mitigation:** Add a turn-floor (e.g. domination cannot fire before T100) instead of weakening combat. Or buff city HP (capture is just slower, still possible).
+- **Cross-team:** combat-dev (combat formulas) + warcouncil (turn-floor in `victory.rs`).
+
+### H3 — Tech tree is too long
+- **Premise:** Reaching `tier_peak=10` requires ~50 prereq techs. If we trim the tree to 30 prereqs, AI hits tier 10 with current research speed.
+- **Risk:** Player experience suffers (less tech variety to research). Game-design call, not engineering.
+- **Cross-team:** game-data (tech tree authorship).
+
+H1 + H2 compose: faster research helps only if games last long enough. H3 is an alternative to H1.
+
+## Round 1 — H1 isolated (research_mult bump)
+
+**Plan:** Bump `difficulty.json::insane.research_mult` from 1.4 to 3.0, and `hard.research_mult` from 1.2 to 2.0. Don't touch domination mechanics yet (so we cleanly measure research-only impact). Run 10-seed Hard + Insane batches at T500 and measure:
+- `tier_peak` distribution at T200, T300, T500
+- `techs` count at T200
+- Game-end turn distribution
+
+**Success criterion:** Insane batch shows median winner `tier_peak ≥ 10` reached by some turn (preferably T200 per user target). If tier_peak STILL maxes at 6-7 even with 3× research, H1 is rejected and the bottleneck is the `_pick_research` scorer or tech tree shape.
+
+**Failure mode:** Games still end at T48-T200 via early domination. In that case, even infinite research speed wouldn't help — H2 (capture math) is the binding constraint. Move to Round 2.
+
+**Status:** PENDING — code change drafted (see plan below); batch awaiting dispatch.
+
+## Round 2 (planned) — H1 + H2 compose
+
+If Round 1 shows research speed alone is insufficient AND games end too early:
+- Add turn-floor in mc-turn `victory.rs::check_domination`: skip domination check before T100.
+- Re-run Insane T500 batch.
+- Success: median game-end shifts toward T200-T300, AND `tier_peak ≥ 10` reached.
+
+## Round 3 (planned) — game-data shortening
+
+If Rounds 1+2 still don't hit tier-10-by-T200:
+- Audit tech tree depth. Identify low-value mid-tier techs that can be merged or skipped.
+- Owner: game-data + tooling (rebuild `techs/manifest.json`).
+
+## Concrete plan to MAKE IT HAPPEN
+
+1. ✏️ **Write changes for Round 1** — edit `public/games/age-of-dwarves/data/difficulty.json` (research_mult bumps for hard + insane). One file, ~10 lines.
+2. 🔨 **Build** — no Rust rebuild needed (data-only change). GDScript reloads at runtime.
+3. 🚀 **Dispatch parallel batches**: `AI_DIFFICULTY=hard tools/autoplay-batch.sh 10 500` AND `AI_DIFFICULTY=insane tools/autoplay-batch.sh 10 500`. ETA ~30-60 min each on apricot.
+4. 📊 **Analyze** — `tools/time-to-peak-unit.py` + new `tools/time-to-tier-peak.py` (mirror tool for `tier_peak` instead of `peak_unit_tier`). Report median game-end turn + median `tier_peak` reached + first-turn-tier-10.
+5. 🎯 **Decide** — if Round 1 succeeds, document evidence + close p1-29's first acceptance bullet. If it fails, log the failure mode in this journal and proceed to Round 2.
+
+Tools needed:
+- `tools/time-to-tier-peak.py` (new — mirrors `time-to-peak-unit.py` for the era metric).
+
+## Cross-team handoffs needed
+
+- **combat-dev** (Round 2): turn-floor for domination check in `mc-turn/src/victory.rs`. ~1hr work.
+- **game-data** (Round 3): tech-tree shortening. Multi-day work.
+- **warcouncil** (this team): research_mult bump + measurement tooling.
diff --git a/.project/objectives/DASHBOARD_CATEGORIES.md b/.project/objectives/DASHBOARD_CATEGORIES.md
index d598664b..cca3a7cf 100644
--- a/.project/objectives/DASHBOARD_CATEGORIES.md
+++ b/.project/objectives/DASHBOARD_CATEGORIES.md
@@ -179,6 +179,6 @@
| [p2-30](p2-30-guide-shared-primitives.md) | ✅ done | P2 | Consolidate duplicate page styled-components into shared PagePrimitives | [tourguide](../team-leads/tourguide.md) | 🟢 |
| [p2-31](p2-31-guide-url-bound-state.md) | ✅ done | P2 | Migrate guide filter + tab state from useState to URL search params | [tourguide](../team-leads/tourguide.md) | 🟢 |
| [p2-32](p2-32-guide-data-driven-enums.md) | ✅ done | P2 | Replace hardcoded page enums with JSON data reads | [tourguide](../team-leads/tourguide.md) | 🟢 |
-| [p2-33](p2-33-sound-system-extension.md) | 🔴 stub | P1 | Sound system extension — categorical fallback, variant pools, per-entity routing | [shipwright](../team-leads/shipwright.md) | 🟢 |
+| [p2-33](p2-33-sound-system-extension.md) | 🔵 in_progress | P1 | Sound system extension — categorical fallback, variant pools, per-entity routing | [asset-audio](../team-leads/asset-audio.md) | 🟢 |
| [p3-01](p3-01-courier-diplomacy.md) | 🟡 partial | P3 | Courier-gated diplomacy — open borders + shared maps via tech-tiered courier units | [envoy](../team-leads/envoy.md) | 🟢 |
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 01b71149..13e024e5 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -15,10 +15,10 @@
| Priority | 🔵 | 🟡 | 🔴 | ❌ | ⚫ | ✅ | Total |
|---|---|---|---|---|---|---|---|
| **P0** | 0 | 0 | 0 | 0 | 0 | 43 | 43 |
-| **P1** | 0 | 4 | 1 | 9 | 1 | 27 | 42 |
+| **P1** | 1 | 4 | 0 | 9 | 1 | 27 | 42 |
| **P2** | 0 | 2 | 1 | 0 | 0 | 28 | 31 |
| **P3 (oos)** | 0 | 1 | 0 | 0 | 19 | 0 | 20 |
-| **total** | **0** | **7** | **2** | **9** | **20** | **98** | **136** |
+| **total** | **1** | **7** | **1** | **9** | **20** | **98** | **136** |
@@ -28,13 +28,23 @@
|---|---|
| [asset-sprite](../team-leads/asset-sprite.md) | 6 |
| [warcouncil](../team-leads/warcouncil.md) | 5 |
-| [shipwright](../team-leads/shipwright.md) | 2 |
-| [asset-audio](../team-leads/asset-audio.md) | 1 |
+| [asset-audio](../team-leads/asset-audio.md) | 2 |
| [envoy](../team-leads/envoy.md) | 1 |
+| [shipwright](../team-leads/shipwright.md) | 1 |
| [testwright](../team-leads/testwright.md) | 1 |
|
+## 🔵 In Progress
+
+> Actively claimed by a team lead. Grouped by owner.
+
+### [asset-audio](../team-leads/asset-audio.md)
+
+| ID | Priority | Title | Updated | Blocked |
+|---|---|---|---|---|
+| [p2-33](p2-33-sound-system-extension.md) | P1 | Sound system extension — categorical fallback, variant pools, per-entity routing | 2026-04-27 | 🟢 unblocked |
+
## P1 — Ship-readiness
| ID | Status | Title | Tags | Owner | Updated | Blocked |
@@ -43,7 +53,6 @@
| [p1-05](p1-05-balance-tuning.md) | 🟡 partial | Balance tuning — pop_peak ≥30 median, worker improvements ≥8 min | — | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | 🟢 unblocked |
| [p1-22](p1-22-mcts-wall-clock-budget.md) | 🟡 partial | MCTS per-decision wall-clock budget — bound per-turn cost on huge maps | — | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | 🟢 unblocked |
| [p2-22](p2-22-sprite-generation-pipeline.md) | 🟡 partial | Sprite generation pipeline — runnable end-to-end | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-25 | 🟢 unblocked |
-| [p2-33](p2-33-sound-system-extension.md) | 🔴 stub | Sound system extension — categorical fallback, variant pools, per-entity routing | — | [shipwright](../team-leads/shipwright.md) | 2026-04-26 | 🟢 unblocked |
| [p1-27](p1-27-mcts-service-extraction.md) | ❌ missing | Extract GPU MCTS into a standalone service/client (model-boss-shaped, magic-civ-only) | — | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | 🟢 unblocked |
| [p1-29](p1-29.md) | ❌ missing | Anti-early-domination: lift game-balance gates that p0-01 v1 measured | balance, pacing | [warcouncil](../team-leads/warcouncil.md) | 2026-04-26 | 🟢 unblocked |
| [p1-30](p1-30.md) | ❌ missing | Optimize `_build_tactical_state` — 8000-tile GDScript dict-build per AI turn blocks p1-22 huge-map gate | perf, tactical-ai | [warcouncil](../team-leads/warcouncil.md) | 2026-04-26 | 🟢 unblocked |
diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json
index 440b0ace..9cbbe0ee 100644
--- a/.project/objectives/objectives.json
+++ b/.project/objectives/objectives.json
@@ -1,10 +1,10 @@
{
- "generated_at": "2026-04-27T01:51:43Z",
+ "generated_at": "2026-04-27T02:42:59Z",
"totals": {
"done": 98,
- "in_progress": 0,
+ "in_progress": 1,
"partial": 7,
- "stub": 2,
+ "stub": 1,
"missing": 9,
"oos": 20,
"total": 136
@@ -928,10 +928,10 @@
"id": "p2-33",
"title": "Sound system extension — categorical fallback, variant pools, per-entity routing",
"priority": "p1",
- "status": "stub",
+ "status": "in_progress",
"scope": "game1",
- "owner": "shipwright",
- "updated_at": "2026-04-26",
+ "owner": "asset-audio",
+ "updated_at": "2026-04-27",
"blocked_by": [],
"summary": "`AudioManager` (`p0-21`, done) ships 10 SFX events and 6 era-keyed music\ntracks. The current manifest is one stream per id, no variation, no\nfallback chain, and no story for the 91 units / 65 buildings / 600\nfauna species the game ships with — every entity that ever wants a\ndistinct sound has to add a hand-authored entry, which doesn't scale to\nlaunch.\n\nThis objective extends the manifest schema and `audio_manager.gd` so\nthe asset pack tracked by `p2-16` can land cleanly:\n\n* **Variant pools** — an entry can list `streams[]` with 2-3 paths;\n the player picks one uniformly to break repetition.\n* **Pitch jitter** — optional ±X% pitch randomisation per play.\n* **Categorical fallback ladder** — `play_for_entity(entity_id,\n event_kind)` resolves `.` → `.` →\n ``, so a fresh unit with no bespoke sound automatically routes\n to its category bucket (`unit.melee.attack`, `building.production.complete`,\n `fauna.apex.roar`, etc.). The category is read from existing JSON\n fields (`unit_type` / `category` / `trophic_class`) — no new schema\n fields on units / buildings / wilds.\n* **EventBus expansion** — wire the additional signals that already\n exist on `event_bus.gd` but aren't routed to audio yet\n (`combat_started`, `unit_destroyed`, `unit_promoted`, `city_grew`,\n `city_starved`, `golden_age_started`, `golden_age_ended`,\n `border_expanded`, `culture_researched`, `wild_creature_spawned`,\n `weather_event`, `tech_research_started`).\n\nThis is a **schema-and-code** objective. No `.ogg` files land here —\nthose are `p2-16`'s responsibility, which is `blockedBy: [p2-33]` so\nthe dependency-aware ordering surfaces this work first."
},
@@ -1490,15 +1490,15 @@
"remaining": 5
},
{
- "owner": "shipwright",
+ "owner": "asset-audio",
"remaining": 2
},
{
- "owner": "asset-audio",
+ "owner": "envoy",
"remaining": 1
},
{
- "owner": "envoy",
+ "owner": "shipwright",
"remaining": 1
},
{
diff --git a/.project/objectives/p1-29.md b/.project/objectives/p1-29.md
index 209dda05..c1e05513 100644
--- a/.project/objectives/p1-29.md
+++ b/.project/objectives/p1-29.md
@@ -27,10 +27,26 @@ Real levers (cross-team scope):
Pick one or compose multiple. Each requires the corresponding team-lead's involvement.
+## User-stated targets (2026-04-26)
+
+User clarified the intended game-feel envelope:
+- **Game length: ~T300 typical, ≤T500 cap.** Currently 50% of games end T48-T200 via early domination. The lower bound (T300 typical) is the binding constraint.
+- **Hard/Insane AI should reach `tier_peak ≥ 10` (top of era ladder) by T200.** Today only the longest games (T408-T500) reach `tier_peak=10` and only Normal difficulty has been measured at all (no Easy/Hard/Insane batches in current corpus).
+
+These targets compose with the structural gates below. Even if the gap/unit-tier gates pass at Normal, the difficulty calibration must be re-run to validate Hard/Insane reach the targets.
+
+## Plan to make it happen — see [`experiments/p1-29-tier10-by-t200.md`](../experiments/p1-29-tier10-by-t200.md)
+
+Three-round hypothesis tree:
+1. **Round 1 (H1, in-scope warcouncil)**: bump `difficulty.json::insane.research_mult` 1.4 → 3.0 and `hard.research_mult` 1.2 → 2.0. Data-only. Validate via `AI_DIFFICULTY=hard|insane tools/autoplay-batch.sh 10 500` per difficulty. If median winner `tier_peak ≥ 10` is reached at any turn, H1 partial-confirmed; if reached by T200, fully confirmed. **NEXT ACTION.**
+2. **Round 2 (H1 + H2, cross-team to combat-dev)**: if games still end T48-T200 via early domination, add turn-floor in `mc-turn::victory.rs::check_domination` (skip domination check before T100).
+3. **Round 3 (H3, cross-team to game-data)**: if Rounds 1+2 still insufficient, shorten the tech tree (audit which mid-tier techs can be merged or skipped).
+
## Acceptance
- ❌ 10-seed `tools/autoplay-batch.sh 10 300` Normal-Normal batch shows median `tier_peak_gap` (alive-aware, both alive players developed past tp ≥2) ≤ 4
- ❌ Same batch shows ≥7/10 games reach `peak_unit_tier ≥ 3` absolute (no game-state filter)
-- ❌ Median game-end turn shifts from current ~T150-T200 toward ~T200-T280 (more games reach late-game content)
+- ❌ Median game-end turn shifts from current ~T150 toward **T300 typical, ≤T500 cap** per user 2026-04-26 directive
- ❌ Cross-team handoff exists in `.project/handoffs/` documenting which team-lead owns the capture/balance change
- ❌ p0-01's evidence updated to cite this objective's closure as the source of v1-style symmetry/unit-tier gate satisfaction
+- ❌ Per-difficulty validation: `AI_DIFFICULTY=hard tools/autoplay-batch.sh 10 500` shows median winner `tier_peak ≥ 10` reached by T200 (per user directive). `AI_DIFFICULTY=insane` same or stronger. `AI_DIFFICULTY=easy` shows clearly weaker progression. Use `tools/time-to-peak-unit.py` and a new `tools/time-to-tier-peak.py` (analogous metric for `tier_peak` not just unit) to measure.
diff --git a/.project/objectives/p2-33-sound-system-extension.md b/.project/objectives/p2-33-sound-system-extension.md
index e7127c75..33e18932 100644
--- a/.project/objectives/p2-33-sound-system-extension.md
+++ b/.project/objectives/p2-33-sound-system-extension.md
@@ -1,15 +1,24 @@
---
id: p2-33
-title: Sound system extension — categorical fallback, variant pools, per-entity routing
+title: "Sound system extension — categorical fallback, variant pools, per-entity routing"
priority: p1
-status: stub
+status: in_progress
scope: game1
-owner: shipwright
-blocks: [p2-16]
-updated_at: 2026-04-26
-evidence: []
+owner: asset-audio
+updated_at: 2026-04-27
+evidence:
+ - "src/game/engine/src/autoloads/audio_manager.gd:10-30 (constants + RNG + entity-category cache)"
+ - "src/game/engine/src/autoloads/audio_manager.gd:84-115 (play_sfx with fallback chain)"
+ - "src/game/engine/src/autoloads/audio_manager.gd:117-129 (play_for_entity)"
+ - "src/game/engine/src/autoloads/audio_manager.gd:223-260 (_resolve_entry_stream + _play_stream — streams[] random pick + pitch_jitter)"
+ - "src/game/engine/src/autoloads/audio_manager.gd:281-345 (_resolve_keys + _entity_kind_and_sub + _unit_combat_class + _fauna_class)"
+ - "src/game/engine/src/autoloads/audio_manager.gd:165-187 (11 new EventBus connections)"
+ - public/games/age-of-dwarves/data/schemas/audio.schema.json (new — JSON Schema for the manifest)
+ - public/games/age-of-dwarves/data/audio.json (extended with _silent sentinel + 11 categorical SFX entries + golden_age music track)
+ - "src/game/engine/tests/unit/test_audio_manager.gd (5 new GUT cases — streams[], _silent, play_for_entity ladder, ranged-class routing, p2-33 signal connections)"
+ - "tools/audio-validate.py (new — schema/asset/orphan validator; runs clean: 'OK with 1 warning(s)' for the missing assets that p2-16 will land)"
+assigned_by: shipwright
---
-
## Summary
`AudioManager` (`p0-21`, done) ships 10 SFX events and 6 era-keyed music
diff --git a/.project/objectives/p3-01-courier-diplomacy.md b/.project/objectives/p3-01-courier-diplomacy.md
index aea7f312..5d5d6dc8 100644
--- a/.project/objectives/p3-01-courier-diplomacy.md
+++ b/.project/objectives/p3-01-courier-diplomacy.md
@@ -33,32 +33,47 @@ information itself becomes a strategic resource that decays with distance and te
This is **scope: game1-stretch** — Game 1's stated scope is "diplomacy-lite", so
this objective is post-Early-Access content unless explicitly pulled forward.
-## Courier tier ladder (one per era, era_2 → era_10)
+## Courier tier ladder (Dwarven flavor — one per era, era_2 → era_10)
-| Era | Tier | City building (built by) | Route infrastructure | Delay |
-|---|---|---|---|---|
-| era_2 Founding | Foot Runner | Messenger Hut | none | very high |
-| era_3 Exploration | Mounted Courier | Stables | benefits from `road` | high |
-| era_4 Craft | Carrier Bird | Rookery | none (flies) | medium-high |
-| era_5 Kingdoms | Dispatch Rider | Post House (← Messenger Hut) | Post Road tile | medium |
-| era_6 Conquest | Semaphore Operator | Semaphore Tower (hilltop, killable) | LOS tower chain | low (LOS = turn-of) |
-| era_7 Industry | Telegraph Operator | Telegraph Office | Telegraph Line tile (severable) | ~1 turn |
-| era_8 Cataclysm | Radio Operator | Wireless Station (← Telegraph Office) | none on map (jammable) | ~1 turn |
-| era_9 Restoration | Network Engineer | Telecom Exchange (← Wireless Station) | mesh, auto-reroutes | ~instant |
-| era_10 Ascension | Aether Conduit (no unit) | Ascension Spire (wonder) | none | instant |
+Locked 2026-04-26 after Dwarven-tech-tree audit. Names + prereq mappings below
+respect the Dwarven thematic (subterranean, Norse, runes, holds, steam-forging —
+no horses, no carrier birds, no Earth-styled industrial telecom).
-City-side upgrade chain: Messenger Hut → Post House → Telegraph Office → Wireless
-Station → Telecom Exchange. Stables and Rookery are side-branches that also serve
-other unit/scouting roles. From era_6 on, the intercept surface shifts from
-"kill the courier on the road" to "destroy the tower / pillage the line / jam
-the signal" — keeps the intercept-able-knowledge mechanic alive into late game.
+| Era | Tier (Dwarven name) | Prereq tech | Building (built by) | Route infrastructure | Delay |
+|---|---|---|---|---|---|
+| era_2 Founding | Foot Runner | `tracking` ✓ existing | Messenger Hut ✓ stub written cycle 1 | none | very high |
+| era_3 Exploration | Tunnel Runner | **NEW `tunnel_paths`** (ecology pillar) | Tunnel Mouth | benefits from `road` + tunnel tile | high |
+| era_4 Craft | Rune Scribe | `runelore` ✓ existing | Rune-Scribe Hall | none (carries carved tablets — no LOS / line cut surface yet) | medium-high |
+| era_5 Kingdoms | Hold Courier | `dwarf_heritage` ✓ existing | Hold Post (← Messenger Hut) | Hold Road tile | medium |
+| era_6 Conquest | Beacon Bearer | **NEW `beacon_chain`** (military pillar) | Beacon Tower (mountaintop, killable structure) | LOS tower chain | low (LOS = turn-of) |
+| era_7 Industry | Steam Messenger | `steam_forging` ✓ existing | Steam Forgery Annex (← Hold Post) | Steam Track tile (severable) | ~1 turn |
+| era_8 Cataclysm | Resonance Telegrapher | **NEW `rune_resonance`** (metallurgy + runelore crossover) | Resonance Chamber (← Steam Forgery Annex) | Resonance Wire tile (severable) | ~1 turn |
+| era_9 Restoration | Hold-Network Warden | `combined_arms` ✓ existing | Hold-Network Citadel (← Resonance Chamber) | mesh of Citadels — auto-reroutes around severed links | ~instant |
+| era_10 Ascension | Adamantine Echo (no unit — wonder-tier) | `adamantine_forging` ✓ existing | Adamantine Echo (wonder, one per civ) | none | instant |
+
+City-side upgrade chain: **Messenger Hut → Hold Post → Steam Forgery Annex →
+Resonance Chamber → Hold-Network Citadel.** Tunnel Mouth, Rune-Scribe Hall, and
+Beacon Tower are side-branches (Tunnel Mouth and Rune-Scribe Hall also serve
+other Dwarven scouting / lore roles; Beacon Tower is a fixed killable
+structure). From era_6 on, the intercept surface shifts from "kill the courier
+on the road" to "destroy the tower / pillage the wire / cut the resonance" —
+keeps the intercept-able-knowledge mechanic alive into late game.
+
+Three new prereq techs to author (acceptance bullet 4 below):
+- `tunnel_paths` (era_3, ecology pillar) — Dwarven engineered tunnel networks
+- `beacon_chain` (era_6, military pillar) — mountaintop fire signaling
+- `rune_resonance` (era_8, metallurgy ∩ runelore) — runic resonance through stone, the Dwarven analogue of the telegraph
+
+Era_10 is intentionally **Adamantine Echo**, not "Aether Conduit" — Game 1 has
+no magic and the rename keeps the wonder Dwarven-flavored. The aether/scrying
+flavor stays Game 3 (Elves).
## Acceptance criteria
-- [ ] **Data pack — units**: 9 new unit JSONs in `public/games/age-of-dwarves/data/units/` (foot_runner, mounted_courier, carrier_bird, dispatch_rider, semaphore_operator, telegraph_operator, radio_operator, network_engineer, aether_conduit). Each declares its era, prerequisite tech, prerequisite building, movement speed, intercept rules, and upgrade-from chain.
-- [ ] **Data pack — buildings**: 9 new building JSONs (messenger_hut, stables, rookery, post_house, semaphore_tower, telegraph_office, wireless_station, telecom_exchange, ascension_spire) with city upgrade chain wiring.
-- [ ] **Data pack — improvements**: 2 new tile improvement JSONs (post_road as `road` upgrade, telegraph_line as severable line tile) in `public/games/age-of-dwarves/data/improvements/` (or wherever improvements live).
-- [ ] **Data pack — techs**: each tier's prerequisite tech exists in the tech tree (Animal Husbandry, Riding, Falconry, Postal System, Optics, Electricity, Wireless, Networking, Quantum-or-equivalent for era_10).
+- [ ] **Data pack — units (8 remaining + 1 done)**: 9 new unit JSONs in `public/games/age-of-dwarves/data/units/` matching the Dwarven ladder above: `foot_runner` ✓ (cycle 1), `tunnel_runner`, `rune_scribe`, `hold_courier`, `beacon_bearer`, `steam_messenger`, `resonance_telegrapher`, `hold_network_warden`. (No era_10 unit — Adamantine Echo is wonder-only.) Each declares its era, prerequisite tech, prerequisite building, movement speed, intercept rules, and upgrade-from chain.
+- [ ] **Data pack — buildings (8 remaining + 1 done)**: 9 new building JSONs: `messenger_hut` ✓ (cycle 1), `tunnel_mouth`, `rune_scribe_hall`, `hold_post`, `beacon_tower`, `steam_forgery_annex`, `resonance_chamber`, `hold_network_citadel`, `adamantine_echo` (wonder). City-side upgrade chain wired (Messenger Hut → Hold Post → Steam Forgery Annex → Resonance Chamber → Hold-Network Citadel).
+- [ ] **Data pack — improvements**: 4 new tile improvement JSONs in `public/games/age-of-dwarves/data/improvements/` — `tunnel` (era_3, gives Tunnel Runner +mvt), `hold_road` (era_5, upgrade of `road`), `steam_track` (era_7, severable line tile), `resonance_wire` (era_8, severable line tile).
+- [ ] **Data pack — techs**: 3 new prereq tech JSONs authored — `tunnel_paths` (era_3, ecology pillar), `beacon_chain` (era_6, military pillar), `rune_resonance` (era_8, metallurgy + runelore crossover). The other 6 tier prereqs (`tracking`, `runelore`, `dwarf_heritage`, `steam_forging`, `combined_arms`, `adamantine_forging`) all exist in the current tech tree — no work needed.
- [ ] **Rust — `mc-trade` extension**: new `OpenBordersAgreement` and `SharedMapAgreement` types, with shared-map agreements requiring a `CourierRoute` resolved each turn (route exists / route severed / courier alive in transit).
- [ ] **Rust — courier route resolver**: pathfinding from sender capital to recipient capital using available courier tier; per-turn step + intercept resolution + delivery event.
- [ ] **Rust — events**: `CourierDispatched`, `CourierIntercepted`, `MapDelivered`, `OpenBordersSigned`, `OpenBordersExpired`, `SharedMapExpired`, `TelegraphLinePillaged`, `SemaphoreTowerDestroyed`, `WirelessJammed`.
@@ -97,7 +112,19 @@ All remaining bullets (3 improvements, 4 techs, remaining 8 unit/8 building stub
## Open design questions
-### BLOCKER (2026-04-26 cycle 1 audit) — Dwarven tech-tree mismatch
+### RESOLVED 2026-04-26 — Dwarven tech-tree mismatch
+
+The original ladder (Mounted Courier / Carrier Bird / Telegraph / Wireless / Aether
+Conduit) was Earth-surface tech. Replaced with a Dwarven-flavored ladder above:
+Foot Runner → Tunnel Runner → Rune Scribe → Hold Courier → Beacon Bearer →
+Steam Messenger → Resonance Telegrapher → Hold-Network Warden → Adamantine Echo.
+Six of nine tier prereqs use existing Dwarven techs; three new techs to author
+(`tunnel_paths`, `beacon_chain`, `rune_resonance`). Decision recorded; no longer
+blocks c2.
+
+### Earlier audit notes (kept for history)
+
+#### Original blocker description (2026-04-26 cycle 1 audit) — Dwarven tech-tree mismatch
The original courier ladder (Foot Runner → Mounted Courier → Carrier Bird → Dispatch Rider → Semaphore → Telegraph → Radio → Telecom → Aether Conduit) assumed Earth-styled surface-civilization tech progression. Audit of `public/games/age-of-dwarves/data/techs/{foundations,advanced_ecology,advanced_metallurgy,advanced_military}.json` shows the Dwarven tech tree (24 techs, 4 pillars: heritage, metallurgy, ecology, military) is **deliberately Dwarven-flavored** and contains:
@@ -148,5 +175,5 @@ Until this decision lands, c2 (and subsequent cycles) should NOT author the rema
## Dependencies
- Existing: `mc-trade` (luxury↔gold base — type stubs added cycle 1), `eras.json`, road improvement, partial Dwarven tech tree (24 techs).
-- **BLOCKED ON**: user adjudication of the Dwarven-flavor courier ladder (see Open Design Questions above).
+- ~~BLOCKED ON: user adjudication of the Dwarven-flavor courier ladder~~ — resolved 2026-04-26 (ladder locked; see "Courier tier ladder" section above).
- Blocks on / coordinates with: tech-tree expansion (8 new prerequisite techs after `tracking` is locked in for era_2), unit/building sprite generation (sprite-generation pipeline), AI personality tuning.
diff --git a/time-to-tier-peak.py b/time-to-tier-peak.py
new file mode 100755
index 00000000..3caae96e
--- /dev/null
+++ b/time-to-tier-peak.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""time-to-tier-peak.py — when does each AI clan first hit its end-of-game tier_peak (era)?
+
+Mirrors `tools/time-to-peak-unit.py` but tracks `tier_peak` (era progression
+1..=10) instead of `peak_unit_tier` (unit-class 1..=6). Useful for the
+warcouncil p1-29 target: "Hard/Insane AI should reach tier_peak ≥ 10 by T200".
+
+Usage:
+ python3 tools/time-to-tier-peak.py [...]
+"""
+from __future__ import annotations
+
+import json
+import statistics
+import sys
+from pathlib import Path
+
+
+def scan_game(game_dir: Path) -> list[dict]:
+ ts = game_dir / "turn_stats.jsonl"
+ meta = game_dir / "meta.json"
+ if not ts.exists() or not meta.exists():
+ return []
+ try:
+ meta_data = json.loads(meta.read_text())
+ player_clans = meta_data.get("player_clans") or {}
+ difficulty = meta_data.get("game_settings", {}).get("difficulty", "?")
+ except Exception:
+ return []
+
+ max_per_player: dict[str, int] = {}
+ end_turn = 0
+ turn_at_tier: dict[str, dict[int, int]] = {}
+ try:
+ for line in ts.read_text().splitlines():
+ if not line.strip():
+ continue
+ entry = json.loads(line)
+ end_turn = entry.get("turn", end_turn)
+ for pid, stats in (entry.get("player_stats") or {}).items():
+ tp = stats.get("tier_peak", 0)
+ if tp > max_per_player.get(pid, 0):
+ max_per_player[pid] = tp
+ # Record first turn each tier was reached (only once per tier).
+ player_tiers = turn_at_tier.setdefault(pid, {})
+ for tier in range(1, tp + 1):
+ if tier not in player_tiers:
+ player_tiers[tier] = entry.get("turn", 0)
+ except Exception:
+ return []
+
+ out = []
+ for pid, max_tp in max_per_player.items():
+ if max_tp == 0:
+ continue
+ turns = turn_at_tier.get(pid, {})
+ out.append({
+ "game": game_dir.name,
+ "difficulty": difficulty,
+ "player": pid,
+ "clan": player_clans.get(pid, "?"),
+ "max_tier_peak": max_tp,
+ "turn_first_max": turns.get(max_tp, end_turn),
+ "turn_first_t10": turns.get(10), # may be None
+ "turn_first_t6": turns.get(6),
+ "end_turn": end_turn,
+ })
+ return out
+
+
+def main(argv: list[str]) -> int:
+ if len(argv) < 2:
+ print(f"Usage: {argv[0]} [...]", file=sys.stderr)
+ return 2
+ all_records: list[dict] = []
+ for arg in argv[1:]:
+ root = Path(arg)
+ if not root.is_dir():
+ print(f"skip non-dir: {root}", file=sys.stderr)
+ continue
+ for game in sorted(root.glob("game_*")) + sorted(root.glob("**/game_*")):
+ if not game.is_dir():
+ continue
+ all_records.extend(scan_game(game))
+
+ if not all_records:
+ print("No completed games found.")
+ return 1
+
+ print(f'{"game":<48} {"diff":<8} {"clan":<13} {"max_tp":<7} {"t_max":<6} {"t_t6":<6} {"t_t10":<7} {"end":<5}')
+ print("-" * 100)
+ for r in all_records:
+ t10 = "—" if r["turn_first_t10"] is None else str(r["turn_first_t10"])
+ t6 = "—" if r["turn_first_t6"] is None else str(r["turn_first_t6"])
+ print(
+ f'{r["game"][:46]:<48} {r["difficulty"]:<8} {r["clan"][:12]:<13} '
+ f'{r["max_tier_peak"]:<7} {r["turn_first_max"]:<6} {t6:<6} {t10:<7} {r["end_turn"]:<5}'
+ )
+
+ # Aggregate by difficulty
+ by_diff: dict[str, list[dict]] = {}
+ for r in all_records:
+ by_diff.setdefault(r["difficulty"], []).append(r)
+ print()
+ print(f'{"diff":<10} {"n":<4} {"med_max_tp":<11} {"med_turn_max":<13} {"reached_t10":<13} {"med_t10_turn":<13} {"med_end":<8}')
+ print("-" * 75)
+ for diff in sorted(by_diff):
+ rs = by_diff[diff]
+ med_max = statistics.median([r["max_tier_peak"] for r in rs])
+ med_turn = statistics.median([r["turn_first_max"] for r in rs])
+ t10_records = [r for r in rs if r["turn_first_t10"] is not None]
+ n_t10 = len(t10_records)
+ med_t10_turn = statistics.median([r["turn_first_t10"] for r in t10_records]) if t10_records else None
+ med_end = statistics.median([r["end_turn"] for r in rs])
+ t10_str = f"{n_t10}/{len(rs)}"
+ med_t10_str = f"{med_t10_turn:.0f}" if med_t10_turn else "—"
+ print(f'{diff:<10} {len(rs):<4} {med_max:<11.1f} {med_turn:<13.0f} {t10_str:<13} {med_t10_str:<13} {med_end:<8.0f}')
+
+ # User-target check: is tier_peak=10 reached by T200?
+ print()
+ print("=== p1-29 user target check: tier_peak >= 10 by T200 ===")
+ for diff in sorted(by_diff):
+ rs = by_diff[diff]
+ hit_t10_by_200 = sum(1 for r in rs if r["turn_first_t10"] is not None and r["turn_first_t10"] <= 200)
+ verdict = "PASS" if hit_t10_by_200 >= len(rs) // 2 else "fail"
+ print(f" {diff}: {hit_t10_by_200}/{len(rs)} games reached tier_peak=10 by T200 — {verdict}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/tools/list-units-by-tier.py b/tools/list-units-by-tier.py
new file mode 100644
index 00000000..39f7fb3e
--- /dev/null
+++ b/tools/list-units-by-tier.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+"""List dwarf-buildable units grouped by tier."""
+import json
+import sys
+from pathlib import Path
+
+
+def main() -> int:
+ units_dir = Path("public/games/age-of-dwarves/data/units")
+ if not units_dir.is_dir():
+ print(f"missing: {units_dir}", file=sys.stderr)
+ return 2
+ tiers: dict[int, list[tuple[str, str, str]]] = {}
+ for path in sorted(units_dir.iterdir()):
+ if not path.suffix == ".json" or path.name == "manifest.json":
+ continue
+ try:
+ d = json.loads(path.read_text())
+ except Exception:
+ continue
+ # Unit JSON files contain either a single dict or a list of dicts.
+ records = d if isinstance(d, list) else [d]
+ for rec in records:
+ if not isinstance(rec, dict):
+ continue
+ race = rec.get("race_required") or "any"
+ if race not in ("dwarf", "any"):
+ continue
+ uid = str(rec.get("id") or "")
+ if "wild" in uid or uid in ("", "None"):
+ continue
+ tier = int(rec.get("tier", 0))
+ tiers.setdefault(tier, []).append((uid, race, str(rec.get("tech_required", ""))))
+ for t in sorted(tiers):
+ rows = ", ".join(f"{u}({tech or '-'})" for u, _, tech in tiers[t])
+ print(f"tier {t}: {rows}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/time-to-tier-peak.py b/tools/time-to-tier-peak.py
new file mode 100755
index 00000000..3caae96e
--- /dev/null
+++ b/tools/time-to-tier-peak.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""time-to-tier-peak.py — when does each AI clan first hit its end-of-game tier_peak (era)?
+
+Mirrors `tools/time-to-peak-unit.py` but tracks `tier_peak` (era progression
+1..=10) instead of `peak_unit_tier` (unit-class 1..=6). Useful for the
+warcouncil p1-29 target: "Hard/Insane AI should reach tier_peak ≥ 10 by T200".
+
+Usage:
+ python3 tools/time-to-tier-peak.py [...]
+"""
+from __future__ import annotations
+
+import json
+import statistics
+import sys
+from pathlib import Path
+
+
+def scan_game(game_dir: Path) -> list[dict]:
+ ts = game_dir / "turn_stats.jsonl"
+ meta = game_dir / "meta.json"
+ if not ts.exists() or not meta.exists():
+ return []
+ try:
+ meta_data = json.loads(meta.read_text())
+ player_clans = meta_data.get("player_clans") or {}
+ difficulty = meta_data.get("game_settings", {}).get("difficulty", "?")
+ except Exception:
+ return []
+
+ max_per_player: dict[str, int] = {}
+ end_turn = 0
+ turn_at_tier: dict[str, dict[int, int]] = {}
+ try:
+ for line in ts.read_text().splitlines():
+ if not line.strip():
+ continue
+ entry = json.loads(line)
+ end_turn = entry.get("turn", end_turn)
+ for pid, stats in (entry.get("player_stats") or {}).items():
+ tp = stats.get("tier_peak", 0)
+ if tp > max_per_player.get(pid, 0):
+ max_per_player[pid] = tp
+ # Record first turn each tier was reached (only once per tier).
+ player_tiers = turn_at_tier.setdefault(pid, {})
+ for tier in range(1, tp + 1):
+ if tier not in player_tiers:
+ player_tiers[tier] = entry.get("turn", 0)
+ except Exception:
+ return []
+
+ out = []
+ for pid, max_tp in max_per_player.items():
+ if max_tp == 0:
+ continue
+ turns = turn_at_tier.get(pid, {})
+ out.append({
+ "game": game_dir.name,
+ "difficulty": difficulty,
+ "player": pid,
+ "clan": player_clans.get(pid, "?"),
+ "max_tier_peak": max_tp,
+ "turn_first_max": turns.get(max_tp, end_turn),
+ "turn_first_t10": turns.get(10), # may be None
+ "turn_first_t6": turns.get(6),
+ "end_turn": end_turn,
+ })
+ return out
+
+
+def main(argv: list[str]) -> int:
+ if len(argv) < 2:
+ print(f"Usage: {argv[0]} [...]", file=sys.stderr)
+ return 2
+ all_records: list[dict] = []
+ for arg in argv[1:]:
+ root = Path(arg)
+ if not root.is_dir():
+ print(f"skip non-dir: {root}", file=sys.stderr)
+ continue
+ for game in sorted(root.glob("game_*")) + sorted(root.glob("**/game_*")):
+ if not game.is_dir():
+ continue
+ all_records.extend(scan_game(game))
+
+ if not all_records:
+ print("No completed games found.")
+ return 1
+
+ print(f'{"game":<48} {"diff":<8} {"clan":<13} {"max_tp":<7} {"t_max":<6} {"t_t6":<6} {"t_t10":<7} {"end":<5}')
+ print("-" * 100)
+ for r in all_records:
+ t10 = "—" if r["turn_first_t10"] is None else str(r["turn_first_t10"])
+ t6 = "—" if r["turn_first_t6"] is None else str(r["turn_first_t6"])
+ print(
+ f'{r["game"][:46]:<48} {r["difficulty"]:<8} {r["clan"][:12]:<13} '
+ f'{r["max_tier_peak"]:<7} {r["turn_first_max"]:<6} {t6:<6} {t10:<7} {r["end_turn"]:<5}'
+ )
+
+ # Aggregate by difficulty
+ by_diff: dict[str, list[dict]] = {}
+ for r in all_records:
+ by_diff.setdefault(r["difficulty"], []).append(r)
+ print()
+ print(f'{"diff":<10} {"n":<4} {"med_max_tp":<11} {"med_turn_max":<13} {"reached_t10":<13} {"med_t10_turn":<13} {"med_end":<8}')
+ print("-" * 75)
+ for diff in sorted(by_diff):
+ rs = by_diff[diff]
+ med_max = statistics.median([r["max_tier_peak"] for r in rs])
+ med_turn = statistics.median([r["turn_first_max"] for r in rs])
+ t10_records = [r for r in rs if r["turn_first_t10"] is not None]
+ n_t10 = len(t10_records)
+ med_t10_turn = statistics.median([r["turn_first_t10"] for r in t10_records]) if t10_records else None
+ med_end = statistics.median([r["end_turn"] for r in rs])
+ t10_str = f"{n_t10}/{len(rs)}"
+ med_t10_str = f"{med_t10_turn:.0f}" if med_t10_turn else "—"
+ print(f'{diff:<10} {len(rs):<4} {med_max:<11.1f} {med_turn:<13.0f} {t10_str:<13} {med_t10_str:<13} {med_end:<8.0f}')
+
+ # User-target check: is tier_peak=10 reached by T200?
+ print()
+ print("=== p1-29 user target check: tier_peak >= 10 by T200 ===")
+ for diff in sorted(by_diff):
+ rs = by_diff[diff]
+ hit_t10_by_200 = sum(1 for r in rs if r["turn_first_t10"] is not None and r["turn_first_t10"] <= 200)
+ verdict = "PASS" if hit_t10_by_200 >= len(rs) // 2 else "fail"
+ print(f" {diff}: {hit_t10_by_200}/{len(rs)} games reached tier_peak=10 by T200 — {verdict}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))