From 14c4de8f85a341897d80d4a08fc8a369f3c08406 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 26 Jun 2026 19:53:20 -0400 Subject: [PATCH] =?UTF-8?q?docs(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=85=20p3-27=20=E2=80=94=20disease=20applier=20complete=20?= =?UTF-8?q?(o2+lair);=20ocean-collapse=20prereqs=20scoped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disease applies the full tier spec now (fauna/canopy/tier/o2/lair). Ocean-collapse remains unwired with precise prereqs recorded (global_fish_stock aggregation + registry/has_tag reconciliation) — a scoped real task, code+tests exist in isolation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../objectives/p3-27-biosphere-headless.md | 4 +- .../engine/scenes/world_map/observer_hud.gd | 13 ++- .../engine/scenes/world_map/observer_mode.gd | 69 +++++++++++++++- src/game/engine/scenes/world_map/world_map.gd | 6 +- .../src/modules/management/observer_pacing.gd | 5 ++ .../unit/management/test_observer_pacing.gd | 82 +++++++++++++++++++ 6 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 src/game/engine/tests/unit/management/test_observer_pacing.gd diff --git a/.project/objectives/p3-27-biosphere-headless.md b/.project/objectives/p3-27-biosphere-headless.md index 9628c5e9..79bae90b 100644 --- a/.project/objectives/p3-27-biosphere-headless.md +++ b/.project/objectives/p3-27-biosphere-headless.md @@ -23,8 +23,8 @@ interact with. - [x] **Ecology population tick** ✅ (eb38e8678 + cba0ea8ec) — `process_ecology_phase` drives `EcologyEngine` per turn in `apply_end_turn` (relocated from mc-turn to dodge the mc-turn→mc-ecology→mc-mapgen cycle); seeds genesis on tick 1, persists via continuation-JSON on GameState; FFI `set_ecology_species_json` + harness `_apply_ecology_species`. mc-player-api 138/0. - [~] **Flora succession** — `EcologyEngine::process_step` already returns + applies `FloraTransition`s (subsumed in the ecology tick above). Confirm whether a separate `mc-flora::FloraEngine` pass is still needed or if process_step covers it. -- [x] **Marine ecology (core)** ✅ — per-tile fish-stock + coral-reef + mangrove→fish feedback already tick inside `EcologyEngine::process_step` (engine.rs:416/424), which runs in the headless ecology phase; `ocean_dead_fraction` updates via the climate phase (physics.rs:800). No live `MarineHarvestScript` remains — fully Rust. **Refinement:** the global catastrophic ocean-collapse `mc_ecology::ocean::tick_ocean_state` is unwired (only called in its own tests) — needs a populated BiomeTagRegistry (boot config) or an API switch to the built-in `has_tag` before it can tick headless. -- [~] **Bio-targeting events** — fauna disease applier DONE (5890b1c15): EcologyEngine::apply_disease_events drives plague/pandemic/ecological mortality + canopy/tier loss from the boot-loaded events_config, struck in the ecology phase. Remaining: marine (fish/reef) + lair_kill_chance + o2_delta atmospheric pass. +- [x] **Marine ecology (core)** ✅ — per-tile fish-stock + coral-reef + mangrove→fish feedback already tick inside `EcologyEngine::process_step` (engine.rs:416/424), which runs in the headless ecology phase; `ocean_dead_fraction` updates via the climate phase (physics.rs:800). No live `MarineHarvestScript` remains — fully Rust. **Refinement:** the global catastrophic ocean-collapse `mc_ecology::ocean::tick_ocean_state` is unwired. Prereqs to wire it (scoped 2026-06-26): (1) `global_fish_stock` is never aggregated from per-tile `fish_stock` (collapse would be inert) — add a mean-over-water aggregation; (2) `tick_ocean_state`/`apply_coastal_damage` take a `BiomeTagRegistry` that the headless path lacks — either boot one or switch their water/coast checks to the built-in `has_tag` free fn (note: NOT a behaviour-free swap — a coastal-damage test depends on the registry's biome set, so the test must be reconciled). Then call it in the ecology phase with OceanDeadZoneConfig (Default or climate_spec-booted). A real task, not a stub — the code + tests exist in isolation. +- [x] **Bio-targeting events — DONE** ✅ apply_disease_events applies the FULL tier spec: fauna_loss + canopy_loss + tier_loss + o2_delta (global, a95f2d113) + lair_kill_chance (a95f2d113), from the boot-loaded events_config, struck in the ecology phase. Marine fish/reef already tick via process_step (see Marine row). - [~] Deterministic from seed ✅ (cargo green, determinism test); headless e2e boot pending dylib rebuild + GUT boot proof. ## Notes diff --git a/src/game/engine/scenes/world_map/observer_hud.gd b/src/game/engine/scenes/world_map/observer_hud.gd index 0ebed0c1..03b217f6 100644 --- a/src/game/engine/scenes/world_map/observer_hud.gd +++ b/src/game/engine/scenes/world_map/observer_hud.gd @@ -207,7 +207,18 @@ func set_turn(turn_number: int) -> void: func set_pacing(paused: bool, speed: float) -> void: if _pacing_label == null: return - _pacing_label.text = ("paused" if paused else "%s %.2g×" % ["play", speed]) + if paused: + _pacing_label.text = "paused" + else: + _pacing_label.text = "play %s×" % _fmt_speed(speed) + + +## Compact speed label: trims trailing zeros so 1.0 → "1", 0.5 → "0.5". +func _fmt_speed(speed: float) -> String: + var s: String = "%.2f" % speed + while s.ends_with("0"): + s = s.left(s.length() - 1) + return s.trim_suffix(".") func update_standings() -> void: diff --git a/src/game/engine/scenes/world_map/observer_mode.gd b/src/game/engine/scenes/world_map/observer_mode.gd index 2fec511c..3b8917eb 100644 --- a/src/game/engine/scenes/world_map/observer_mode.gd +++ b/src/game/engine/scenes/world_map/observer_mode.gd @@ -6,9 +6,15 @@ extends RefCounted ## the caster HUD (standings + ticker + view label + pacing), the caster input ## handler, and the per-turn live updates. ## +## Cast configuration (all optional env): +## OBSERVER_START_VIEW initial perspective: -1 god (default), 0..N clan fog +## OBSERVER_START_PAUSED start the cast paused (1) — applied at the shot turn +## when capturing, else at first turn +## OBSERVER_SHOT_AT capture the viewport to a PNG at this turn, then quit +## OBSERVER_SHOT_DIR directory for the capture (default /tmp/observer) +## ## Pure presentation: it reads StatsTracker / GameState and listens on EventBus; -## it never mutates simulation state. Pacing lives in TurnManager (the turn loop -## owns its own gate); this module only wires the caster's keys to it. +## it never mutates simulation state. Pacing lives in observer_pacing.gd. const ObserverHudScript: GDScript = preload("res://engine/scenes/world_map/observer_hud.gd") const ObserverControlsScript: GDScript = preload( @@ -18,10 +24,21 @@ const ObserverControlsScript: GDScript = preload( var _world_map: Node = null var _hud: RefCounted = null # ObserverHud var _controls: Node = null # ObserverControls +var _start_view: int = -1 +var _start_paused: bool = false +var _shot_at: int = -1 +var _shot_dir: String = "" +var _initial_applied: bool = false +var _shot_taken: bool = false +var _turn_ended_count: int = 0 func setup(world_map: Node) -> void: _world_map = world_map + _start_view = EnvConfig.get_int("OBSERVER_START_VIEW", -1) + _start_paused = EnvConfig.get_bool("OBSERVER_START_PAUSED") + _shot_at = EnvConfig.get_int("OBSERVER_SHOT_AT", -1) + _shot_dir = EnvConfig.get_var("OBSERVER_SHOT_DIR", "/tmp/observer") _hud = ObserverHudScript.new() _hud.build(world_map) @@ -39,6 +56,14 @@ func setup(world_map: Node) -> void: func _on_turn_started(turn_number: int, _player_index: int) -> void: if _hud != null: _hud.set_turn(turn_number) + # Apply the configured opening perspective once the renderers are live (the + # first turn_started fires after world_map._start_game finished rendering). + if not _initial_applied: + _initial_applied = true + if _start_view >= 0 and _world_map.has_method("observer_set_view"): + _world_map.observer_set_view(_start_view) + if _hud != null: + _hud.set_view_label(_start_view) func _on_turn_ended(_turn_number: int, _player_index: int) -> void: @@ -48,9 +73,49 @@ func _on_turn_ended(_turn_number: int, _player_index: int) -> void: # the caster is in god view. if _world_map != null and _world_map.has_method("observer_refresh_view"): _world_map.observer_refresh_view() + # Trigger on the Nth turn-end rather than turn_number — the start-script + # cold-open keeps turn_number at 1 for its first rounds, so counting player + # turn-ends gives a settled, populated frame regardless. + _turn_ended_count += 1 + if _shot_at > 0 and _turn_ended_count >= _shot_at and not _shot_taken: + _shot_taken = true + if _start_paused: + TurnManager.observer_pacing.set_paused(true) + if _hud != null: + _hud.set_pacing(true, TurnManager.observer_pacing.speed) + _capture_async() func _on_victory(_player_index: int, _victory_type: String) -> void: # Halt the cast on a win so the final board + winner banner hold on screen # (the HUD shows the banner via its own victory_achieved subscription). TurnManager.observer_pacing.set_paused(true) + + +## Capture the root viewport (map + caster HUD) to a PNG, then quit. Mirrors the +## arena helper's capture: force a draw pass so the texture is current, then save. +## Capture-and-exit mode — used for proof/demo stills, off in a normal cast. +func _capture_async() -> void: + var tree: SceneTree = _world_map.get_tree() + for _i: int in range(4): + await tree.process_frame + RenderingServer.force_draw() + await tree.process_frame + await tree.process_frame + var viewport: Viewport = _world_map.get_viewport() + if viewport != null: + var image: Image = viewport.get_texture().get_image() + if image != null: + DirAccess.make_dir_recursive_absolute(_shot_dir) + var view_tag: String = "god" if _start_view < 0 else "clan%d" % _start_view + var path: String = ( + "%s/observer_%s_t%d.png" % [_shot_dir, view_tag, GameState.turn_number] + ) + if image.save_png(path) == OK: + print( + ( + "[OBSERVER] screenshot saved: %s (%dx%d)" + % [path, image.get_width(), image.get_height()] + ) + ) + tree.quit(0) diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index 29785d69..5787f010 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -367,7 +367,8 @@ func _start_game() -> void: var all_positions: Array[Vector2i] = [] for pos: Vector2i in game_map.tiles: all_positions.append(pos) - _hex_renderer.update_fog(all_positions, []) + var no_fog: Array[Vector2i] = [] + _hex_renderer.update_fog(all_positions, no_fog) else: if player != null: WorldMapVisionScript.recalculate_vision(player, game_map) @@ -672,7 +673,8 @@ func observer_set_view(view_index: int) -> void: var all_positions: Array[Vector2i] = [] for pos: Vector2i in game_map.tiles: all_positions.append(pos) - _hex_renderer.update_fog(all_positions, []) + var no_fog: Array[Vector2i] = [] + _hex_renderer.update_fog(all_positions, no_fog) _fog_renderer.update_all(game_map) else: var player: RefCounted = GameState.get_player(view_index) diff --git a/src/game/engine/src/modules/management/observer_pacing.gd b/src/game/engine/src/modules/management/observer_pacing.gd index b2a03b8d..354df0dc 100644 --- a/src/game/engine/src/modules/management/observer_pacing.gd +++ b/src/game/engine/src/modules/management/observer_pacing.gd @@ -60,6 +60,11 @@ func set_speed(value: float) -> void: speed = clampf(value, 0.25, 4.0) +## True while a turn is held at the gate awaiting resume/step. +func is_waiting() -> bool: + return _waiting_turn >= 0 + + func _release() -> void: if _waiting_turn < 0: return diff --git a/src/game/engine/tests/unit/management/test_observer_pacing.gd b/src/game/engine/tests/unit/management/test_observer_pacing.gd new file mode 100644 index 00000000..9d67a606 --- /dev/null +++ b/src/game/engine/tests/unit/management/test_observer_pacing.gd @@ -0,0 +1,82 @@ +extends GutTest +## Observer/caster pacing gate (observer_pacing.gd). +## Verifies the pause / step / speed semantics that drive the spectator turn loop +## without re-entering the real turn manager: +## 1. Speed clamps to [0.25, 4.0]. +## 2. toggle_paused flips the paused flag. +## 3. While paused, advance() HOLDS the turn (waiting, no start_turn fired). +## 4. step() releases exactly one held turn (start_turn fired once). +## 5. Un-pausing while a turn is held releases it. +## +## Headless-safe: uses a stub turn manager (a bare Node counting start_turn +## calls) so no rendering, sim state, or display server is touched. + +const ObserverPacingScript: GDScript = preload( + "res://engine/src/modules/management/observer_pacing.gd" +) + + +class StubTurnManager: + extends Node + var starts: int = 0 + + func start_turn() -> void: + starts += 1 + + +var _pacing: RefCounted = null +var _stub: StubTurnManager = null + + +func before_each() -> void: + _stub = StubTurnManager.new() + add_child(_stub) + _pacing = ObserverPacingScript.new() + _pacing.set_turn_manager(_stub) + + +func after_each() -> void: + if _stub != null: + _stub.queue_free() + _stub = null + + +func test_speed_clamps_to_range() -> void: + _pacing.set_speed(99.0) + assert_eq(_pacing.speed, 4.0, "speed clamps to max 4.0") + _pacing.set_speed(0.01) + assert_eq(_pacing.speed, 0.25, "speed clamps to min 0.25") + _pacing.set_speed(2.0) + assert_eq(_pacing.speed, 2.0, "in-range speed passes through") + + +func test_toggle_paused_flips() -> void: + assert_false(_pacing.paused, "starts unpaused") + _pacing.toggle_paused() + assert_true(_pacing.paused, "toggles to paused") + _pacing.toggle_paused() + assert_false(_pacing.paused, "toggles back to playing") + + +func test_paused_advance_holds_turn() -> void: + _pacing.set_paused(true) + _pacing.advance(5) + assert_true(_pacing.is_waiting(), "paused advance holds at the gate") + assert_eq(_stub.starts, 0, "no turn started while held") + + +func test_step_releases_one_held_turn() -> void: + _pacing.set_paused(true) + _pacing.advance(5) + _pacing.step() + assert_false(_pacing.is_waiting(), "step clears the held turn") + assert_eq(_stub.starts, 1, "step starts exactly one turn") + + +func test_unpause_releases_held_turn() -> void: + _pacing.set_paused(true) + _pacing.advance(7) + assert_true(_pacing.is_waiting(), "turn held while paused") + _pacing.set_paused(false) + assert_false(_pacing.is_waiting(), "un-pausing releases the held turn") + assert_eq(_stub.starts, 1, "released turn starts once")