From 75fdf14f4d7c0e3c8bb868cda8af09c3b822f940 Mon Sep 17 00:00:00 2001 From: autocommit Date: Sun, 26 Apr 2026 19:55:00 -0700 Subject: [PATCH] =?UTF-8?q?docs(objectives):=20=F0=9F=93=9D=20Update=20obj?= =?UTF-8?q?ectives=20documentation=20and=20tier-related=20scripts=20to=20r?= =?UTF-8?q?eflect=20roadmap=20changes=20and=20data=20structure=20adjustmen?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/experiments/p1-29-tier10-by-t200.md | 73 ++++++++++ .project/objectives/DASHBOARD_CATEGORIES.md | 2 +- .project/objectives/README.md | 19 ++- .project/objectives/objectives.json | 18 +-- .project/objectives/p1-29.md | 18 ++- .../p2-33-sound-system-extension.md | 23 ++- .../objectives/p3-01-courier-diplomacy.md | 73 +++++++--- time-to-tier-peak.py | 132 ++++++++++++++++++ tools/list-units-by-tier.py | 41 ++++++ tools/time-to-tier-peak.py | 132 ++++++++++++++++++ 10 files changed, 485 insertions(+), 46 deletions(-) create mode 100644 .project/experiments/p1-29-tier10-by-t200.md create mode 100755 time-to-tier-peak.py create mode 100644 tools/list-units-by-tier.py create mode 100755 tools/time-to-tier-peak.py 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))