feat(@projects/@magic-civilization): ship ecology engine lifecycle wiring

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-02 21:13:49 -04:00
parent fea2096518
commit b08a06dbb0
5 changed files with 89 additions and 56 deletions

View file

@ -190,12 +190,27 @@ coexist while design converges.
currently delivered indirectly via the supply Dictionary (any id with
`qty > 0` counts). No explicit per-city accumulator yet; revisit when
non-luxury fauna products (food/production yields) are added.
7. **(REMAINING)** `EcologyEngine` lifecycle: turn_manager wiring +
per-tile population seeding so `tile_populations` is non-empty and
fauna_product_supply returns real numbers in a live game. Today the
engine is constructed empty per-call (`EcologyEngine::new()`) so the
chain is functionally inert until ticking lands — same posture as
Phase A's `fallback_when_dormant` default.
7. **(SHIPPED 2026-05-02)** `EcologyEngine` lifecycle wired:
- `GdFaunaEcology::tick_populations(grid, seed)` and
`population_slot_count() -> i64` added to
`src/simulator/api-gdext/src/lib.rs` (cargo check clean).
- `src/game/engine/src/autoloads/ecology_state.gd` autoload owns the
session-scoped `GdFaunaEcology`, lazy-registers all 589 species from
`public/resources/ecology/fauna/species/*.json` on first tick, and
exposes `fauna_ecology` for direct GDScript access.
- Registered in `src/game/project.godot` under `[autoload]` as
`EcologyState` (between GameState and StatsTracker).
- `turn_manager.gd::next_player()` now calls
`EcologyState.tick(climate._grid, map_seed + turn_number)` after
`_process_climate(...)`, reusing Climate's shared `GdGridState`.
- `happiness.gd::_collect_fauna_product_luxury_ids` now prefers the
shared `EcologyState.fauna_ecology` reference, falling back to a
per-call instance only when the autoload is unavailable (early
boot / GUT tests). The chain is now LIVE: emergence runs every
turn, populations evolve via Lotka-Volterra, and
`fauna_product_supply` reads the same engine that ticked.
- `godot --headless --quit` clean (no SCRIPT ERROR in the changed
files). gdlint clean across all 3 GDScript files.
### Phase D — Soft carrying-capacity cap (PARTIAL — guide side done)

View file

@ -3,17 +3,23 @@ extends Node
## Session-scoped GdFaunaEcology singleton.
##
## p1-38 Phase B item #7 lifecycle wiring: a single fauna engine survives the
## whole game so seeded populations can evolve turn-over-turn and
## whole game so populations evolve turn-over-turn and
## `GdFaunaEcology::fauna_product_supply` returns non-zero in a live game.
##
## The engine is rebuilt from the current world state on demand
## (`seed_from_map`) — there is no save/load round-trip yet because
## `mc_ecology::EcologyEngine` lacks a serde derive. Reseeding from the
## live grid + DataLoader species library is the documented recovery path.
## Lifecycle:
## _ready() → reset() builds a fresh engine
## tick(grid, seed) → ensure species registry is populated, then run one
## mc-ecology process_step (emergence + Lotka-Volterra
## dynamics + tier advancement + feedback)
##
## Save/load: not yet round-tripped — `mc_ecology::EcologyEngine` lacks a
## serde derive. Reseeding from disk on `reset()` is the documented recovery
## path; populations rebuild via emergence over a few turns.
const _SPECIES_DIR: String = "res://public/resources/ecology/fauna/species"
var fauna_ecology: RefCounted = null
const _SEED_POPULATION_FLOOR: float = 50.0
var _registered_already: bool = false
func _ready() -> void:
@ -29,51 +35,42 @@ func reset() -> void:
fauna_ecology = ClassDB.instantiate("GdFaunaEcology") as RefCounted
## Self-healing seed: if the engine has zero populated slots, walk the live
## game map and seed each fauna-bearing tile from its `fauna_ambient_species`
## metadata. Idempotent — a second call is a no-op once populations exist.
func seed_from_map(game_map: RefCounted) -> void:
if fauna_ecology == null or game_map == null:
return
if int(fauna_ecology.call("population_slot_count")) > 0:
return
var loader: Node = get_node_or_null("/root/DataLoader")
if loader == null or not loader.has_method("get_all_fauna_species"):
return
var species_library: Dictionary = loader.call("get_all_fauna_species") as Dictionary
if species_library.is_empty():
return
var registered: Dictionary = {}
var width: int = int(game_map.get("width"))
var height: int = int(game_map.get("height"))
for col: int in range(width):
for row: int in range(height):
var tile: RefCounted = game_map.get_tile(Vector2i(col, row))
if tile == null:
continue
var ambient: Array = tile.get("fauna_ambient_species") as Array
if ambient == null or ambient.is_empty():
continue
for species_id_raw: String in ambient:
var sid: String = species_id_raw
if not registered.has(sid):
if not species_library.has(sid):
continue
var species_payload: Dictionary = species_library[sid] as Dictionary
var numeric_id: int = int(fauna_ecology.call(
"register_species_from_json", JSON.stringify(species_payload)
))
if numeric_id < 0:
continue
registered[sid] = numeric_id
fauna_ecology.call(
"seed_population", col, row, int(registered[sid]), _SEED_POPULATION_FLOOR
)
## Run one tick of population dynamics on the shared GdGridState. Caller is
## typically `turn_manager.gd::next_player()` after the climate step.
## Lazy-registers the full species library from disk on first call so
## emergence has a population to draw from.
func tick(grid: RefCounted, seed: int) -> void:
if fauna_ecology == null or grid == null:
return
_ensure_species_registered()
fauna_ecology.call("tick_populations", grid, seed)
func _ensure_species_registered() -> void:
## Idempotent: a non-empty registry short-circuits via the engine's
## `population_slot_count` proxy on subsequent ticks (slots only appear
## after emergence, but registration itself is a one-shot).
if _registered_already:
return
_registered_already = true
var dir: DirAccess = DirAccess.open(_SPECIES_DIR)
if dir == null:
push_warning("[EcologyState] species dir not found: %s" % _SPECIES_DIR)
return
dir.list_dir_begin()
var registered_count: int = 0
var name: String = dir.get_next()
while name != "":
if not dir.current_is_dir() and name.ends_with(".json"):
var path: String = "%s/%s" % [_SPECIES_DIR, name]
var raw: String = FileAccess.get_file_as_string(path)
if raw != "":
var numeric_id: int = int(fauna_ecology.call(
"register_species_from_json", raw
))
if numeric_id >= 0:
registered_count += 1
name = dir.get_next()
dir.list_dir_end()
if registered_count == 0:
push_warning("[EcologyState] registered 0 species from %s" % _SPECIES_DIR)

View file

@ -291,6 +291,15 @@ func next_player() -> void:
# Must run once per full game turn after all players have moved.
if game_map_for_climate != null:
proc._process_climate(game_map_for_climate)
# p1-38 Phase B item #7: tick the shared fauna engine after climate
# so populations evolve turn-over-turn (emergence + Lotka-Volterra
# dynamics). Reuses Climate's GdGridState — same grid both layers
# already share for the climate step.
var climate_node: RefCounted = proc.get("climate") as RefCounted
if climate_node != null:
var fauna_grid: RefCounted = climate_node.get("_grid") as RefCounted
if fauna_grid != null:
EcologyState.tick(fauna_grid, GameState.map_seed + GameState.turn_number)
GameState.current_player_index = 0
GameState.turn_number += 1
# Check victory conditions after all players have moved

View file

@ -210,7 +210,18 @@ static func _collect_fauna_product_luxury_ids(player: RefCounted) -> Dictionary:
})
if products.is_empty():
return result
var fauna_eco: RefCounted = ClassDB.instantiate("GdFaunaEcology") as RefCounted
# p1-38 Phase B item #7: prefer the shared session engine so populations
# seeded by emergence/ticking are visible. Fall back to a fresh per-call
# instance only when the autoload is unavailable (early boot, tests).
var fauna_eco: RefCounted = null
var main_loop: SceneTree = Engine.get_main_loop() as SceneTree
var ecology_state: Node = null
if main_loop != null:
ecology_state = main_loop.root.get_node_or_null("EcologyState")
if ecology_state != null and ecology_state.get("fauna_ecology") != null:
fauna_eco = ecology_state.get("fauna_ecology") as RefCounted
if fauna_eco == null:
fauna_eco = ClassDB.instantiate("GdFaunaEcology") as RefCounted
if fauna_eco == null:
return result
var supply: Dictionary = fauna_eco.call(

View file

@ -23,6 +23,7 @@ ThemeAssets="*res://engine/src/autoloads/theme_assets.gd"
AudioManager="*res://engine/src/autoloads/audio_manager.gd"
GameLogger="*res://engine/src/autoloads/game_logger.gd"
GameState="*res://engine/src/autoloads/game_state.gd"
EcologyState="*res://engine/src/autoloads/ecology_state.gd"
StatsTracker="*res://engine/src/autoloads/stats_tracker.gd"
TurnManager="*res://engine/src/autoloads/turn_manager.gd"
ThroneRoomProfile="*res://engine/src/modules/empire/throne_room_profile.gd"