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:
parent
668ab7d152
commit
14c4de8f85
6 changed files with 172 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
Loading…
Add table
Reference in a new issue