diff --git a/src/game/engine/src/modules/management/rust_fauna_bridge.gd b/src/game/engine/src/modules/management/rust_fauna_bridge.gd new file mode 100644 index 00000000..ff314266 --- /dev/null +++ b/src/game/engine/src/modules/management/rust_fauna_bridge.gd @@ -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