feat(@projects/@magic-civilization): ✨ add edge terrain blending logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
3bcd58f024
commit
a908554b8b
7 changed files with 188 additions and 29 deletions
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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's terrain bonus is its <Strong>parent tile's</Strong>
|
||||
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{' '}
|
||||
<Strong>blend</Strong> of the two tiles' 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'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
132
tools/time-to-tier-peak.py
Executable 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))
|
||||
Loading…
Add table
Reference in a new issue