diff --git a/.project/designs/formation-slot-anatomy.md b/.project/designs/formation-slot-anatomy.md
index 568e09a6..01bca4f3 100644
--- a/.project/designs/formation-slot-anatomy.md
+++ b/.project/designs/formation-slot-anatomy.md
@@ -33,7 +33,7 @@ This file shows what one slot record carries. Two slot types — **centre** and
│ facing: NE │ fixed by the slot's direction
│ hp_cur / hp_max: 18 / 30 │ reflects parent unit
│ damage_order: 1 │ edges hit first (lowest orders)
-│ terrain: forest │ from aligned_to's terrain (NOT shared)
+│ terrain: grass-fringe │ blend(aligned_to, neighbour) per HEX_GEOMETRY.md §8
│ zoc_reach: 2 centres + 2 edges │ reaches both adjacent centres + 2 vertex-adjacent edges
└──────────────────────────────────────┘
```
@@ -82,7 +82,7 @@ This file shows what one slot record carries. Two slot types — **centre** and
the OTHER hex sharing this edge
- parent tile (drives terrain bonus)
+ parent tile (alignment / ownership)
edge=1, adjacent edges=2-3,
@@ -109,9 +109,9 @@ This file shows what one slot record carries. Two slot types — **centre** and
| `host_hex` (centre) / `edge_id` (edge) | hex coords / canonical form | Centres are addressed by hex; edges by `(min_hex, dir_from_min)` |
| `shared_with` (edge only) | derived from `edge_id` | The other hex bordering this edge |
| `unit_id` | parent `MapUnit` | Stable id; `None` if slot is vacant |
-| `aligned_to` (edge only) | set when unit deploys | Parent tile; drives terrain bonus and player ownership |
+| `aligned_to` (edge only) | set when unit deploys | Parent tile; drives ownership / player. Terrain comes from the **edge blend**, not directly from this field |
| `facing` | formation orientation (centre) or slot direction (edge) | Edges have a fixed facing |
-| `terrain` | `aligned_to`'s tile (edge) or `host_hex`'s tile (centre) | Edge units do **not** average across the two adjacent terrains |
+| `terrain` | Centre: `host_hex`'s terrain. Edge: `blend(aligned_to.terrain, neighbour.terrain)` per `terrain_blends.json` (`HEX_GEOMETRY.md` §8) | Edge terrain is a **derived ecotone** — e.g., plains↔forest = grass-fringe |
| `damage_order` | derived from slot type and rotation | 1 = facing edge, …, 7 = centre |
| `zoc_reach` | derived from slot type | Centres reach 6 own edges; edges reach 2 centres + 2 edges |
diff --git a/.project/designs/hex-formation-duality.md b/.project/designs/hex-formation-duality.md
index 687a13d3..e31f31ba 100644
--- a/.project/designs/hex-formation-duality.md
+++ b/.project/designs/hex-formation-duality.md
@@ -111,7 +111,8 @@ Plus the centre slot, which is exclusive to the host hex.
|---|---|
| Third unit between two centres | The shared edge slot is geometrically between two hex centres; it can hold one unit |
| Combat space = unit position | Same slot is both the engagement membrane and an occupiable position |
-| Forest cover on the attack | Edge unit's terrain bonus comes from its parent tile; a forest unit on its plains-facing edge keeps forest cover |
+| Forest cover on the attack | Edge terrain is `blend(host, neighbour)` per `HEX_GEOMETRY.md` §8 — a forest tile's plains-facing edge is *grass-fringe*, giving a unit deployed there partial forest-like cover |
+| Per-edge ecotones | A plains tile bordering mountain, water, plains, and forest has 4 distinct edge biomes — foothills, shore, plains, grass-fringe — derived from the blend of each neighbour pair |
| Discrete flanking | Attackers occupying multiple non-adjacent edges around a defender trigger the flank multiplier |
| ZOC reach | Edge units project ZOC into both adjacent centres + 2 adjacent edges |
| Picket / skirmisher mechanics | Edge slots give cheap forward units a real positional role without overlapping the centre |
diff --git a/.project/objectives/p2-33-sound-system-extension.md b/.project/objectives/p2-33-sound-system-extension.md
index 33e18932..ae0dc597 100644
--- a/.project/objectives/p2-33-sound-system-extension.md
+++ b/.project/objectives/p2-33-sound-system-extension.md
@@ -2,21 +2,17 @@
id: p2-33
title: "Sound system extension — categorical fallback, variant pools, per-entity routing"
priority: p1
-status: in_progress
+status: done
scope: game1
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)"
+ - "src/game/engine/src/autoloads/audio_manager.gd — streams[] random pick, pitch_jitter, fallback chain (MAX_FALLBACK_HOPS=8), play_for_entity, _resolve_keys, _entity_kind_and_sub, _unit_combat_class, _fauna_class, 11 new EventBus connections"
+ - "public/games/age-of-dwarves/data/schemas/audio.schema.json — JSON Schema with additionalProperties:false on entry blocks; documents streams[]/pitch_jitter/fallback"
+ - "public/games/age-of-dwarves/data/audio.json — schema_version=2, _silent sentinel, 11 categorical SFX entries (unit.melee.*, unit.ranged.*, unit.siege.*, unit.civilian.*, building.*.*, fauna.*.*, weather.*) + golden_age music track. Backwards-compatible: 10 original entries unchanged."
+ - "src/game/engine/tests/unit/test_audio_manager.gd — 13/13 pass on apricot headless (5 new cases + 8 pre-existing). New: test_streams_array_pool_is_loaded_for_categorical_keys, test_silent_sentinel_terminates_fallback_chain, test_play_for_entity_resolves_categorical_chain, test_play_for_entity_routes_known_unit_through_combat_class, test_new_event_signals_are_connected"
+ - "tools/audio-validate.py — schema/asset/orphan validator. Runs clean: 'OK with 1 warning(s)' (the one warning is the 63 referenced files that p2-16 will land). Cycle-detects fallback graph, validates pitch_jitter range, requires _silent sentinel."
+ - "Apricot GUT headless run 2026-04-26: cd ~/Code/@projects/@magic-civilization/src/game && WAYLAND_DISPLAY=wayland-1 flatpak run --filesystem=home org.godotengine.Godot --path . --headless -s addons/gut/gut_cmdln.gd -gtest=engine/tests/unit/test_audio_manager.gd -gexit → 13/13 passed"
assigned_by: shipwright
---
## Summary
diff --git a/public/games/age-of-dwarves/data/difficulty.json b/public/games/age-of-dwarves/data/difficulty.json
index 4f43dffd..20a1729d 100644
--- a/public/games/age-of-dwarves/data/difficulty.json
+++ b/public/games/age-of-dwarves/data/difficulty.json
@@ -74,7 +74,7 @@
"description": "AI receives major bonuses and a free starting warrior. A serious challenge.",
"ai_modifiers": {
"production_mult": 1.50,
- "research_mult": 1.40,
+ "research_mult": 3.00,
"gold_mult": 1.0,
"combat_bonus": 0,
"extra_starting_units": 1,
diff --git a/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md b/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md
index 14f07982..553d5a6a 100644
--- a/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md
+++ b/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md
@@ -122,15 +122,38 @@ Implication: pathfinding (`mc-core/src/algorithms/pathfinding.rs`) keeps its exi
---
-## 8. Biome boundaries
+## 8. Biome boundaries — edges as derived blend zones
-Each hex carries one terrain (plains, forest, hill, …) at the data level. The **perceived biome boundary** between two adjacent hexes is the shared edge.
+Each hex carries one terrain at its **centre** (plains, forest, hill, …). Each of its **6 edges carries its own terrain**, derived as a **blend** of the host's centre terrain and the neighbour's centre terrain. Edges are *not* terrain-less geometric objects; they are transitional ecotones whose biome reflects what happens at the boundary between two adjacent tiles.
-A unit at an edge slot is geometrically *at the biome boundary*. Its terrain bonuses come from its **parent tile** (the tile it deployed from):
-- A forest unit deployed to its NE edge keeps forest cover, even though the NE neighbour is plains
-- A plains unit deployed to its NE edge does not get forest cover from the NE neighbour; it remains a plains unit at the contact line
+Example — a plains tile with neighbours `[mountain, water, water, plains, plains, forest]` carries:
-This makes per-terrain combat modifiers route cleanly: a unit's bonuses are always its parent tile's bonuses, whether it stands at centre or at an edge.
+| Edge | Centre terrain | Neighbour terrain | Edge terrain (blend) |
+|---|---|---|---|
+| 1 mountain edge | plains | mountain | foothills |
+| 2 water edges | plains | water | shore |
+| 2 plains edges | plains | plains | plains (no transition) |
+| 1 forest edge | plains | forest | grass-fringe |
+
+This is **per-edge ecotone modelling**. A hex adjacent to a coast has a shoreline. A hex below a mountain has foothills on one side. Same hex, different edges, different biomes.
+
+### Symmetry
+
+`blend(A, B) == blend(B, A)`. The same physical edge has the same character viewed from either side. The mountain tile sees its plains-facing edge as foothills; the plains tile sees its mountain-facing edge as foothills.
+
+### Combat and movement at edges
+
+- **Edge unit's terrain bonus** comes from the **edge terrain** (the blend), not from the parent tile alone. A unit on a forest↔plains edge gets *grass-fringe* cover — partial, not full forest cover.
+- **Edge crossing cost** in pathfinding can incorporate the edge terrain (foothills cost more to cross than plains).
+- **Combat at an empty edge** — each combatant rolls with their *own centre's* terrain (unchanged from §5); the blend only applies when a unit *occupies* the edge.
+
+### Where the blends are defined
+
+The blend table is data, not code: `public/games/age-of-dwarves/data/terrain/terrain_blends.json` *(new — Stage 6)* lists each unordered terrain pair and the resulting edge terrain. Pairs not in the table default to the centre terrain unchanged (i.e., same-terrain edges or undefined-blend pairs are not transitional).
+
+### River and other features layer on top of the blend
+
+Existing river edges (`mc-core/src/grid/mod.rs:97 river_edges`) modify the edge further. A forest↔plains edge with a river on it is *grass-fringe + river*, accruing both biome and feature effects. This unifies the existing edge-feature data (rivers, roads — Stage 6) with the new edge-terrain data.
---
@@ -191,6 +214,8 @@ The model in this doc is the **target** spec; the code is partial.
| 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 |
| Edge identity + occupancy | (does not yet exist) | ⚠️ Needs new type — `EdgeId(min_hex, dir_from_min)` plus an `EdgeOccupant: Option` table |
+| Edge terrain (blends — §8) | (does not yet exist) | ⚠️ Needs `terrain_blends.json` data + lookup `blend(host, neighbour) -> TerrainId`; derived per edge, no separate storage required |
+| River edges | `mc-core/src/grid/mod.rs:97 river_edges: Vec` | ⚠️ Already partially edge-indexed; unify under canonical `EdgeId` in Stage 6 |
| Formation data type | `src/simulator/crates/mc-core/src/formation.rs` | ⚠️ Has `FormationShape` enum but no centre + edge-set partition |
| Combat resolver | `src/simulator/crates/mc-combat/src/resolver.rs:65-81` | ⚠️ HP scales by `formation_count`; does not route damage through edge occupants |
| ZOC | `mc-turn` | ⚠️ Currently per-hex; needs per-edge layer |
@@ -211,7 +236,7 @@ These eight decisions are baked into the spec above as **defaults**. They are mi
| 1 | Edge slot occupancy | Single occupant | §10 |
| 2 | Edge unit blocks centre-to-centre movement | Yes | §7 |
| 3 | Edge slots are pathfinding stops | No (deploy/withdraw is a separate action) | §7 |
-| 4 | Edge unit's terrain bonus source | Parent tile | §8 |
+| 4 | Edge unit's terrain bonus source | **Edge terrain (blend of host + neighbour)** | §8 |
| 5 | Edge unit damage order in combat | Hit first (before centre) | §5 |
| 6 | Edge ZOC reach | Two adjacent centres + two adjacent edges | §9 |
| 7 | Flanking trigger | Two attackers from non-adjacent edges | §6 |
diff --git a/src/packages/guide/src/pages/engine/HexGridPage.tsx b/src/packages/guide/src/pages/engine/HexGridPage.tsx
index edc82c94..f69c7651 100644
--- a/src/packages/guide/src/pages/engine/HexGridPage.tsx
+++ b/src/packages/guide/src/pages/engine/HexGridPage.tsx
@@ -155,12 +155,17 @@ export default function HexGridPage(): ReactElement {
- Forest cover on the attack
+ Edges are ecotones — every edge has its own terrain
- A forest unit deployed to its own forest-vs-plains edge keeps forest cover.
- The edge unit's terrain bonus is its parent tile's
- terrain — never the opposite tile's. Cover on the attack is geometric,
- not a special rule.
+ An edge between two tiles isn't terrain-less. It carries a{' '}
+ blend of the two tiles' biomes — a real
+ transitional zone. A plains tile bordering a mountain has{' '}
+ foothills on that edge; bordering water has a{' '}
+ shore; bordering forest has a{' '}
+ grass-fringe. A unit deployed to that edge fights
+ with the edge's blended terrain — partial forest cover, partial
+ mountain defence, shoreline cover. Same hex, different edges,
+ different biomes.
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))