docs(@projects/@magic-civilization): p3-27 — disease applier complete (o2+lair); ocean-collapse prereqs scoped

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 19:53:20 -04:00
parent 668ab7d152
commit 14c4de8f85
6 changed files with 172 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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