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))