diff --git a/.project/objectives/p0-35-ecology-telemetry-instrumentation.md b/.project/objectives/p0-35-ecology-telemetry-instrumentation.md index 2423fe39..9af1a4f0 100644 --- a/.project/objectives/p0-35-ecology-telemetry-instrumentation.md +++ b/.project/objectives/p0-35-ecology-telemetry-instrumentation.md @@ -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 diff --git a/.project/objectives/p0-36-weather-event-telemetry.md b/.project/objectives/p0-36-weather-event-telemetry.md index 97abb226..aa8795bf 100644 --- a/.project/objectives/p0-36-weather-event-telemetry.md +++ b/.project/objectives/p0-36-weather-event-telemetry.md @@ -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 --- diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index 6fa0d157..0ff97f94 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -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))