feat(@projects/@magic-civilization): add edge terrain blending logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-26 19:52:21 -07:00
parent 3bcd58f024
commit a908554b8b
7 changed files with 188 additions and 29 deletions

View file

@ -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
<text x="358" y="84" font-size="10" fill="#888">the OTHER hex sharing this edge</text>
<line x1="220" y1="120" x2="350" y2="120" stroke="#888" stroke-dasharray="2,2"/>
<text x="358" y="124" font-size="10" fill="#888">parent tile (drives terrain bonus)</text>
<text x="358" y="124" font-size="10" fill="#888">parent tile (alignment / ownership)</text>
<line x1="220" y1="180" x2="350" y2="180" stroke="#888" stroke-dasharray="2,2"/>
<text x="358" y="184" font-size="10" fill="#888">edge=1, adjacent edges=2-3,</text>
@ -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 |

View file

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

View file

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

View file

@ -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,

View file

@ -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<UnitId>` 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<i32>` | ⚠️ 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 |

View file

@ -155,12 +155,17 @@ export default function HexGridPage(): ReactElement {
</SubCard>
<SubCard>
<SubHeading>Forest cover on the attack</SubHeading>
<SubHeading>Edges are ecotones every edge has its own terrain</SubHeading>
<Prose>
A forest unit deployed to its own forest-vs-plains edge keeps forest cover.
The edge unit&apos;s terrain bonus is its <Strong>parent tile&apos;s</Strong>
terrain never the opposite tile&apos;s. Cover on the attack is geometric,
not a special rule.
An edge between two tiles isn&apos;t terrain-less. It carries a{' '}
<Strong>blend</Strong> of the two tiles&apos; biomes a real
transitional zone. A plains tile bordering a mountain has{' '}
<Strong>foothills</Strong> on that edge; bordering water has a{' '}
<Strong>shore</Strong>; bordering forest has a{' '}
<Strong>grass-fringe</Strong>. A unit deployed to that edge fights
with the edge&apos;s blended terrain partial forest cover, partial
mountain defence, shoreline cover. Same hex, different edges,
different biomes.
</Prose>
</SubCard>

132
tools/time-to-tier-peak.py Executable file
View file

@ -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 <batch-dir> [<batch-dir>...]
"""
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]} <batch-dir> [<batch-dir>...]", 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))