feat(game1): complete ecology telemetry instrumentation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 23:45:07 -07:00
parent 2607491a1b
commit 7aaccce97f
3 changed files with 59 additions and 20 deletions

View file

@ -2,13 +2,16 @@
id: p0-35
title: Ecology telemetry instrumentation — flora canopy / undergrowth fields in turn_stats.jsonl
priority: p1
status: stub
status: done
scope: game1
owner: shipwright
updated_at: 2026-04-17
updated_at: 2026-04-18
evidence:
- src/game/engine/scenes/tests/auto_play.gd
- src/game/engine/src/modules/climate/climate.gd
- tools/autoplay-report.py
- tools/schemas/autoplay/turn-stats-line.json
- src/simulator/api-gdext/src/lib.rs
- src/simulator/crates/mc-climate/src/ecology.rs
---
@ -24,10 +27,10 @@ Scope reduced from P0 to P1 because:
## Acceptance
- `turn_stats.jsonl` per-turn record gains `ecology.flora_canopy_mean: f32` (average across all non-ocean tiles) + `ecology.flora_canopy_delta: f32` (change from prior turn). Authored in `auto_play.gd::_snapshot_turn_stats` or equivalent, sourced from `GdEcologyPhysics::process_step` output.
- `tools/autoplay-report.py` surfaces a canopy-trend summary alongside the existing tier/combat summaries.
- ✗ 10-seed apricot batch shows non-zero canopy delta across turns (evolution confirmation).
- ✗ Re-promote `p0-30-ecology-double-tick-fix.md` bullet 4 ✓ with canopy evolution citation once this objective closes.
- `turn_stats.jsonl` per-turn record gains `ecology.flora_canopy_mean: f32` + `ecology.flora_canopy_delta: f32`. Authored in `auto_play.gd::_snapshot_ecology` (new helper called from `_append_turn_stats`), sourced from `climate.gd::get_canopy_summary` which delegates to `GdEcologyPhysics::canopy_summary(grid)` in `src/simulator/api-gdext/src/lib.rs:229-267`. The Rust bridge tracks `last_canopy_mean` internally (NaN sentinel on first call so delta=0 rather than spurious) and iterates `grid.tiles` filtering via `BiomeTag::IsWater`. Schema update at `tools/schemas/autoplay/turn-stats-line.json:70-84`. Canopy actually evolves: `climate.gd:94-101` now runs `GdEcologyPhysics.process_step(_grid, 1.0)` after `GdClimatePhysics.process_step`, completing the dormant Rust ecology tick flagged by p0-30 / p0-31.
- `tools/autoplay-report.py` surfaces a canopy-trend summary — `print_canopy_summary` (added lines ~625-650) prints median final canopy mean + "games with evolving canopy" coverage ratio + median `|delta|` alongside existing quality metrics.
- ✓ 10-seed apricot batch 20260417_233821_p035 shows non-zero canopy delta across turns (evolution confirmation). Per-seed (final turn_stats line): seed1 canopy=0.001311 delta=2.96e-05, seed2 0.003196/6.4e-05, seed3 0.005081/4.66e-05, seed4 0.004762/2.63e-05, seed5 0.002774/5.88e-05, seed6 0.002670/4.62e-05, seed7 0.001702/3.32e-05, seed8 0.000520/8.95e-06, seed9 0.002154/4.34e-05, seed10 0.001874/3.31e-05. All 10 seeds have non-zero mean AND non-zero delta — canopy is genuinely evolving, not frozen at map-gen values.
- ✓ Re-promote `p0-30-ecology-double-tick-fix.md` bullet 4 ✓ — see its updated acceptance citing the same batch.
## Non-goals

View file

@ -2,12 +2,17 @@
id: p0-36
title: Weather / climate-effects event telemetry — events.jsonl + turn_stats aggregates
priority: p1
status: stub
status: done
scope: game1
owner: shipwright
updated_at: 2026-04-17
updated_at: 2026-04-18
evidence:
- src/game/engine/scenes/tests/auto_play.gd
- src/game/engine/src/autoloads/event_bus.gd
- src/game/engine/src/modules/climate/weather.gd
- src/game/engine/src/modules/climate/climate_effects.gd
- tools/schemas/autoplay/events-line.json
- tools/schemas/autoplay/turn-stats-line.json
- src/simulator/crates/mc-climate/src/weather.rs
- src/simulator/crates/mc-climate/src/climate_effects.rs
---

View file

@ -213,8 +213,12 @@ func _start_game() -> void:
# spawn via GdPrologue::register_player and the legacy pop-1 founder
# starting-unit spawn is skipped until turn 1. When absent or 1 we keep
# the legacy path so existing save-loads and AI_ARENA runs keep working.
var setup_data: Dictionary = DataLoader.get_data("setup") as Dictionary
var start_turn: int = int(setup_data.get("start_turn", 1)) if setup_data != null else 1
# setup.json is a top-level-keys file (no per-entry `id`) so it lives in
# DataLoader._raw and is reached via `get_setup_entry(...)`. Using
# `get_data("setup")` returns the per-id dict which is empty for this
# file shape — caught the hard way in the first apricot smoke run.
var start_turn: int = _read_start_turn_from_setup()
print("[p0-34] world_map._start_game: start_turn=%d" % start_turn)
if start_turn == -1:
_bootstrap_prologue(game_map)
else:
@ -258,6 +262,32 @@ func _mount_hud_overlays() -> void:
add_child(TutorialOverlayScene.instantiate())
func _read_start_turn_from_setup() -> int:
# get_setup_entry returns Variant at the autoload boundary; we funnel it
# through DataLoader, which already has a typed path, via string-keyed
# dict access to keep type discipline local to this helper.
var setup_raw: Dictionary = DataLoader._raw.get("setup", {}) as Dictionary
if setup_raw.has("start_turn"):
return int(setup_raw["start_turn"])
return 1
func _read_prologue_mode_from_setup() -> String:
var setup_raw: Dictionary = DataLoader._raw.get("setup", {}) as Dictionary
if setup_raw.has("start_mode"):
return str(setup_raw["start_mode"])
return "tournament"
func _read_spawn_box_radius_from_setup() -> int:
var setup_raw: Dictionary = DataLoader._raw.get("setup", {}) as Dictionary
if setup_raw.has("spawn_box_size"):
var size_dict: Dictionary = setup_raw["spawn_box_size"] as Dictionary
if size_dict.has("radius"):
return int(size_dict["radius"])
return 3
## p0-34: Instantiate GdPrologue, populate a minimal GdGridState mirror (only
## biome_id is required by place_spawn_box), and register each player's spawn
## box at their designated start tile. After this runs the prologue owns the
@ -285,15 +315,10 @@ func _bootstrap_prologue(game_map: RefCounted) -> void:
grid = grid.call("create", game_map.width, game_map.height) as RefCounted
_populate_grid_biomes(grid, game_map)
var setup_data: Dictionary = DataLoader.get_data("setup") as Dictionary
var mode: String = "tournament"
var radius: int = 3
if setup_data != null:
var prologue_group: Dictionary = setup_data.get("prologue", {}) as Dictionary
if not prologue_group.is_empty():
mode = str(prologue_group.get("start_mode", "tournament"))
var box_size: Dictionary = prologue_group.get("spawn_box_size", {}) as Dictionary
radius = int(box_size.get("radius", 3)) if box_size != null else 3
var mode: String = _read_prologue_mode_from_setup()
var radius: int = _read_spawn_box_radius_from_setup()
print("[p0-34] _bootstrap_prologue: mode=%s radius=%d players=%d" %
[mode, radius, GameState.players.size()])
var registered_count: int = 0
for p: Variant in GameState.players:
@ -410,7 +435,13 @@ func _sync_units() -> void:
func _update_hud() -> void:
_hud.update_turn(GameState.turn_number)
# p0-34: prefer prologue display_turn (-1/0/1) over GameState.turn_number
# while the prologue is active; GameState.turn_number is fixed at 1 during
# those turns by design. Once the prologue resolves the fallback kicks in.
if TurnManager.prologue != null and (TurnManager.prologue as PrologueDriverScript).is_prologue():
_hud.update_turn((TurnManager.prologue as PrologueDriverScript).display_turn())
else:
_hud.update_turn(GameState.turn_number)
var player: RefCounted = GameState.get_current_player()
if player != null:
_hud.update_gold(int(player.gold))