feat(management): Introduce RustFaunaBridge for fauna state synchronization and management in the game engine

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-08 16:11:49 -07:00
parent f633ab95e5
commit a4d736172f

View file

@ -0,0 +1,133 @@
class_name RustFaunaBridge
extends RefCounted
## GDScript adapter that drives the Rust mc-turn fauna encounter resolver
## from live game state. Iter 7i deliverable.
##
## Architecture:
## 1. The caller hands us a list of live `Unit.gd` instances.
## 2. We pack them into the dict shape `GdGameState::set_player_units_from_dicts`
## accepts (via Unit.to_bridge_dict()).
## 3. We populate a fresh `GdGameState` with the player + units + grid + lairs.
## 4. We call `GdTurnProcessor::step` once.
## 5. We read back `result["fauna_combat_log"]` and walk it; for each death
## event we resolve the (unit_col, unit_row) coordinate back to the live
## `Unit` instance, kill it (set hp=0), and emit
## `EventBus.unit_destroyed(unit, killer)`.
## 6. The bridge is one-shot per call — the caller is responsible for
## not feeding the same encounter twice.
##
## This file is the entire iter 7i GDScript layer for the Rust-fauna
## integration. The next iteration (7j) will call into this from the real
## `world_map` turn loop.
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
## Build a fresh GdGameState containing one player + their units + the grid
## (cloned from a real GdGridState if provided) + a list of stamped lairs.
##
## Returns the GdGameState ready to hand to GdTurnProcessor::step.
##
## `axes`: dict like {"expansion": 2, "production": 5, ...}
## `units`: Array[Unit] — live game units to ingest
## `cities`: Array[Vector2i] — live city positions (must be Array[Vector2i],
## not generic Array; the Rust binding requires
## the typed-array variant for typed param slots)
## `grid_size`: Vector2i — grid dimensions if no `gridstate` is provided
## `gridstate`: optional GdGridState (RefCounted) — if provided, replaces
## the blank grid with a clone of this real one
static func build_state(
axes: Dictionary,
units: Array[Unit],
cities: Array[Vector2i],
grid_size: Vector2i,
gridstate: RefCounted = null,
) -> RefCounted:
var state: RefCounted = ClassDB.instantiate("GdGameState") as RefCounted
if state == null:
push_error("RustFaunaBridge: GdGameState not registered")
return null
if gridstate != null:
state.call("set_grid_from_gridstate", gridstate)
else:
state.call("create_grid", grid_size.x, grid_size.y)
var pi: int = int(state.call("add_empty_player_with_axes", axes))
state.call("set_player_cities_from_array", pi, cities)
var unit_dicts: Array[Dictionary] = []
for u: Unit in units:
unit_dicts.append(u.to_bridge_dict())
state.call("set_player_units_from_dicts", pi, unit_dicts)
return state
## Stamp lairs into the state's grid from a list of [col, row, tier, species_id]
## tuples. Used by proof scenes that don't have a real ecology evolution pass.
static func stamp_lairs(state: RefCounted, lairs: Array) -> int:
if state == null:
return 0
var n: int = 0
for entry: Array in lairs:
var ok: bool = state.call(
"stamp_lair", entry[0], entry[1], entry[2], entry[3]
)
if ok:
n += 1
return n
## Run one Rust step against the prepared state, walk the returned
## fauna_combat_log, and apply each death back to the live Unit array.
## Returns the per-event log as a Dictionary array (so callers can render
## or audit it).
##
## After this call:
## - Dead units have hp=0 (Unit.is_alive() == false)
## - EventBus.unit_destroyed signals have been emitted for each death
## - The caller can sweep `live_units` and remove dead entries from their
## own collections (this method does NOT mutate the input array's length)
static func resolve_fauna_encounters(
processor: RefCounted,
state: RefCounted,
live_units: Array[Unit],
) -> Array:
if processor == null or state == null:
return []
var result: Dictionary = processor.call("step", state)
var log: Array = result.get("fauna_combat_log", [])
# Build a position → live Unit lookup so we can resolve deaths in O(1).
var by_pos: Dictionary = {}
for u: Unit in live_units:
if u != null and u.is_alive():
by_pos[u.position] = u
for ev: Dictionary in log:
if bool(ev.get("unit_survived", true)):
continue
var unit_col: int = int(ev.get("unit_col", -1))
var unit_row: int = int(ev.get("unit_row", -1))
var lair_tier: int = int(ev.get("lair_tier", 0))
var key: Vector2i = Vector2i(unit_col, unit_row)
var dead: Unit = by_pos.get(key, null) as Unit
if dead == null:
continue
# Killing the unit and emitting the signal once per death.
# Pre-existing collections still hold the reference; the caller
# sweeps them after we return.
dead.take_damage(dead.hp) # hp -> 0
EventBus.unit_destroyed.emit(dead, _killer_label(lair_tier))
# Remove from the lookup so a second event at the same coord
# doesn't double-emit.
by_pos.erase(key)
return log
## Synthetic killer label for the EventBus signal. The mc-turn lair
## resolution doesn't carry a real killer entity (lairs aren't `Unit`s),
## so we use a string tag the UI/log layer can interpret.
static func _killer_label(lair_tier: int) -> String:
return "wild_lair_t%d" % lair_tier