feat(engine): route live unit spawn/death through the presentation_units slot

Rail-1 Phase-1 increment 1 (wiring) — every site that brings a Unit into or out
of the world now flips the authoritative Rust slot alongside the player/layer
lists, so the proxy resolves a live MapUnit.

Spawn sites → `spawn_into_slot()`:
- world_map_units.register_unit (the central chokepoint: starting units via
  spawn_starting_units, the prologue tribe via _on_prologue_tribe_converged, and
  any future caller)
- turn_processor._spawn_unit (city-built unit)
- wild_creature_ai (lair spawns → wilds row; now constructs via the populating
  ctor so stats come from JSON)

Death / consumption sites → `remove_from_slot()` (index-shift-safe; snapshots
final pos/hp into the local mirror first so unit_destroyed subscribers — loot,
chronicle — still read the unit as it died):
- world_map_units.remove_unit
- combat_utils.handle_unit_death + _destroy_high_archon
- economy upkeep disband
- ai_turn_bridge_dispatch settler-consumed-on-found
- prologue_driver tribe-consumed-into-capital

No live unit-CAPTURE path exists in Game 1 (units die, they are not captured);
`transfer_to_owner` is wired on the proxy for parity but no site converts to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-28 01:54:58 -04:00
parent 2ad4b7bed6
commit b28e25f554
7 changed files with 32 additions and 3 deletions

View file

@ -38,6 +38,12 @@ func create_unit(type_id: String, owner_index: int, pos: Vector2i) -> RefCounted
func register_unit(unit: RefCounted, player: RefCounted) -> void:
# Rail-1: a unit enters the world here — push it into the authoritative
# `presentation_units` slot before it lands in the player/layer lists, so all
# downstream position/hp/movement/posture reads route to Rust. Idempotent if
# the unit was already spawned (e.g. a load path).
if unit is UnitScript:
unit.spawn_into_slot()
player.units.append(unit)
var primary: Dictionary = GameState.get_primary_layer()
var layer_units: Array = primary.get("units", [])
@ -46,6 +52,10 @@ func register_unit(unit: RefCounted, player: RefCounted) -> void:
func remove_unit(unit: RefCounted, player: RefCounted) -> void:
# Rail-1: drop the unit's entry from the authoritative slot (index-shift-safe)
# before unlinking it from the player/layer lists.
if unit is UnitScript:
unit.remove_from_slot()
player.units.erase(unit)
var primary: Dictionary = GameState.get_primary_layer()
var layer_units: Array = primary.get("units", [])

View file

@ -228,6 +228,9 @@ static func dispatch_found_city(
GameState.turn_number,
)
player.cities.append(city)
# Rail-1: the settler is consumed founding the city — drop its slot entry.
if settler is Unit:
settler.remove_from_slot()
player.units.erase(settler)
var primary: Dictionary = GameState.get_primary_layer()
primary.get("units", []).erase(settler)

View file

@ -72,15 +72,16 @@ func spawn_initial_creatures(game_map: RefCounted) -> void:
continue
var b_pos: Vector2i = _building_pos(b)
var unit: RefCounted = UnitScript.new()
var unit: RefCounted = UnitScript.new(unit_type_id, -1, b_pos)
unit.id = "wild_%d" % _rng.randi()
unit.type_id = unit_type_id
unit.owner = -1
unit.position = b_pos
unit.max_hp = unit_data.get("hp", 8)
unit.hp = unit.max_hp
unit.movement_remaining = unit_data.get("movement", 2)
unit.has_attacked = false
# Rail-1: wild creatures land in the dedicated wilds row of the
# authoritative `presentation_units` slot (owner -1 → `wilds_pi()`).
unit.spawn_into_slot()
primary_layer.get("units", []).append(unit)
EventBus.wild_creature_spawned.emit(unit, b_pos)

View file

@ -89,6 +89,12 @@ static func handle_unit_death(unit: RefCounted, killer: RefCounted, all_units: A
if unit.owner == -1 and killer != null and killer is UnitScript and killer.owner >= 0:
_roll_wild_creature_loot(unit, killer)
# Rail-1: drop the dead unit's entry from the authoritative `presentation_units`
# slot. `remove_from_slot` snapshots its final position/hp into the unit's
# local mirror first, so the `unit_destroyed` subscribers (loot, chronicle)
# still read the unit as it died rather than safe defaults.
unit.remove_from_slot()
all_units.erase(unit)
if unit.owner >= 0 and unit.owner < GameState.players.size():
@ -195,6 +201,7 @@ static func _destroy_high_archon(player: RefCounted, all_units: Array) -> void:
for unit: RefCounted in player.units:
if unit is UnitScript and unit.type_id == "high_archon":
unit.hp = 0
unit.remove_from_slot()
all_units.erase(unit)
player.units.erase(unit)
var primary_layer: Dictionary = GameState.get_primary_layer()

View file

@ -170,6 +170,8 @@ static func _disband_cheapest(player: RefCounted, count: int) -> void:
break
if victim == null:
return
# Rail-1: drop the disbanded unit from the authoritative slot.
victim.remove_from_slot()
player.units.erase(victim)
var primary: Dictionary = GameState.get_primary_layer()
primary.get("units", []).erase(victim)

View file

@ -184,6 +184,9 @@ func found_capital_for_tribe(
city.owner = pid
city.found_with_population(city_name, q, r, true, 1, pop)
player.cities.append(city)
# Rail-1: the tribe unit is consumed into the capital — drop its slot entry.
if tribe_unit is Unit:
tribe_unit.remove_from_slot()
player.units.erase(tribe_unit)
var primary: Dictionary = GameState.get_primary_layer()
var layer_units: Array = primary.get("units", [])

View file

@ -230,6 +230,9 @@ func _spawn_unit(type_id: String, player: RefCounted, pos: Vector2i) -> UnitScri
unit.id = "unit_p%d_%d_%d_%d" % [player.index, pos.x, pos.y, GameState.turn_number]
var data: Dictionary = DataLoader.get_unit(type_id)
unit.display_name = data.get("name", type_id)
# Rail-1: push the unit into the authoritative `presentation_units` slot. All
# subsequent reads/writes of position/hp/movement/posture route to Rust.
unit.spawn_into_slot()
player.units.append(unit)
var primary: Dictionary = GameState.get_primary_layer()
var layer_units: Array = primary.get("units", [])