feat(rail-1): wire whole-round Rust turn into live end_turn behind RUST_TURN flag (p3-29)

Phase-2b live swap (default OFF). When RUST_TURN=1, the proven
GdTurnProcessor.step advances the WHOLE round on live state in one call
(sync presentation->inner, step, sync inner->presentation), and the
per-player _process_* loop + round-end ecology/climate/wild/diplomacy
GDScript passes are gated off to avoid double-processing. step's events[]
are translated to EventBus signals (tech/culture/golden-age now; entity-
payload kinds deferred). Default path is byte-for-byte the existing turn.

Render-proof of the ON path (live game plays a turn through the Rust step)
remains the render-gated acceptance item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-28 09:39:14 -04:00
parent 79db241cef
commit 7475daa7f8

View file

@ -61,6 +61,14 @@ var climate_effects: RefCounted = ClimateEffectsScript.new() # ClimateEffects
var diplomacy: RefCounted = DiplomacyScript.new() # Diplomacy — relationship state var diplomacy: RefCounted = DiplomacyScript.new() # Diplomacy — relationship state
var marine_harvest: RefCounted = MarineHarvestScript.new() # MarineHarvest — ocean ecology var marine_harvest: RefCounted = MarineHarvestScript.new() # MarineHarvest — ocean ecology
var _processor: RefCounted = null # TurnProcessor — wired in _ready var _processor: RefCounted = null # TurnProcessor — wired in _ready
## Rail-1 Phase-2b: the proven whole-round Rust turn (`GdTurnProcessor`).
## Instantiated once in _ready when the GDExtension is present. Only used when
## the `RUST_TURN` flag is ON (default OFF) — see `end_turn()` / `next_player()`.
## Null on headless builds without the dylib (callers guard).
var _rust_turn_processor: RefCounted = null # GdTurnProcessor
## Cached once-per-process read of the RUST_TURN flag (mirrors AUTO_PLAY pattern).
## When false (default) the live turn path is the existing per-player one, untouched.
var _rust_turn_enabled: bool = false
## Prologue driver (p0-34). Instantiated when `setup.json:start_turn == -1` ## Prologue driver (p0-34). Instantiated when `setup.json:start_turn == -1`
## (i.e. `prologue` group authored by tribe-data-dev is active). Null when ## (i.e. `prologue` group authored by tribe-data-dev is active). Null when
## the legacy pop-1 founder path is in effect. Reset by world_map._start_game. ## the legacy pop-1 founder path is in effect. Reset by world_map._start_game.
@ -79,6 +87,22 @@ func _ready() -> void:
proc.climate_effects = climate_effects proc.climate_effects = climate_effects
proc.marine_harvest = marine_harvest proc.marine_harvest = marine_harvest
# Rail-1 Phase-2b: cache the RUST_TURN flag once and (when present) build the
# proven whole-round Rust turn processor. Default OFF → the live game path is
# byte-for-byte the existing per-player loop; nothing below runs.
_rust_turn_enabled = EnvConfig.get_bool("RUST_TURN")
if _rust_turn_enabled:
if ClassDB.class_exists("GdTurnProcessor"):
_rust_turn_processor = ClassDB.instantiate("GdTurnProcessor") as RefCounted
if _rust_turn_processor == null:
push_error("TurnManager: GdTurnProcessor registered but instantiate returned null")
else:
# Mirror the headless harness: load authored fauna/ambient encounter
# rates so the in-step fauna pass is live (else encounters are inert).
_rust_turn_processor.call("load_authored_encounter_rates")
else:
push_error("TurnManager: RUST_TURN set but GdTurnProcessor class not registered (gdext build missing?)")
func _on_deposit_discovered(player_index: int, resource_id: String, _pos: Vector2i) -> void: func _on_deposit_discovered(player_index: int, resource_id: String, _pos: Vector2i) -> void:
## Credit one unit of the strategic resource onto the player's ledger. ## Credit one unit of the strategic resource onto the player's ledger.
@ -244,28 +268,42 @@ func end_turn() -> void:
start_turn() start_turn()
return return
var player: RefCounted = GameState.get_current_player() # Player # Rail-1 Phase-2b: when RUST_TURN is ON the proven whole-round `GdTurnProcessor.step`
var game_map: RefCounted = GameState.get_game_map() # GameMap # computes culture/growth/production/economy/research/golden-age/healing/improvements
if player != null and game_map != null: # for ALL players plus the round-end sim glue (fauna/wild/diplomacy/climate/ecology)
# Processing order: culture FIRST so new tiles are available for # in a SINGLE call. So under the flag we SKIP the per-player `_process_*` block below
# citizen assignment during growth. Otherwise new pop can't work # (running both would double-process), and run the Rust step exactly ONCE per round,
# the tile just claimed this turn. # at the round boundary, before `next_player()` rotates the cursor. `next_player()`'s
# 1. Culture (borders) 2. Food (growth) 3. Production # round-end sim glue is correspondingly gated off (see that function). Default OFF →
# 4. Gold (economy) 5. Science 6. Happiness (golden age) # the original per-player path runs untouched.
# 7. Mana 8. Victory 9. Healing 10. Improvements if not _rust_turn_enabled:
var proc: TurnProcessorScript = _processor as TurnProcessorScript var player: RefCounted = GameState.get_current_player() # Player
proc._process_culture(player, game_map) var game_map: RefCounted = GameState.get_game_map() # GameMap
proc._process_culture_research(player) if player != null and game_map != null:
proc._process_growth(player) # Processing order: culture FIRST so new tiles are available for
proc._process_production(player) # citizen assignment during growth. Otherwise new pop can't work
proc._process_economy(player, game_map) # the tile just claimed this turn.
proc._process_research(player) # 1. Culture (borders) 2. Food (growth) 3. Production
proc._process_golden_age(player, game_map) # 4. Gold (economy) 5. Science 6. Happiness (golden age)
proc._process_healing(player) # 7. Mana 8. Victory 9. Healing 10. Improvements
proc._process_city_healing(player) var proc: TurnProcessorScript = _processor as TurnProcessorScript
proc._process_improvements(player) proc._process_culture(player, game_map)
proc._process_loot_decay() proc._process_culture_research(player)
proc._process_government(player) proc._process_growth(player)
proc._process_production(player)
proc._process_economy(player, game_map)
proc._process_research(player)
proc._process_golden_age(player, game_map)
proc._process_healing(player)
proc._process_city_healing(player)
proc._process_improvements(player)
proc._process_loot_decay()
proc._process_government(player)
elif GameState.is_last_in_round():
# Flag ON + last player of the round just ended → advance the WHOLE round
# in Rust on live state: sync presentation→inner, step, sync inner→presentation,
# then translate the returned events into EventBus signals.
_run_rust_round()
EventBus.turn_ended.emit(GameState.turn_number, GameState.current_player_index) EventBus.turn_ended.emit(GameState.turn_number, GameState.current_player_index)
@ -273,6 +311,68 @@ func end_turn() -> void:
next_player() next_player()
## Rail-1 Phase-2b — run the proven whole-round Rust turn on LIVE state.
## Syncs the rich presentation slots DOWN into `inner`, runs `GdTurnProcessor.step`
## (which advances ALL players + round-end sim glue + increments `state.turn`), then
## syncs the results back UP into the presentation slots. Finally translates the
## step's `events` array into the existing EventBus signals so renderers/panels
## re-read the synced state. Called exactly once per round (at the round boundary).
func _run_rust_round() -> void:
if _rust_turn_processor == null:
push_error("TurnManager: _run_rust_round called with null GdTurnProcessor")
return
var gs: RefCounted = GameState.get_gd_state() # GdGameState
if gs == null:
push_error("TurnManager: _run_rust_round — GdGameState is null (gdext missing?)")
return
gs.call("sync_presentation_to_inner")
var result: Dictionary = _rust_turn_processor.call("step", gs)
gs.call("sync_inner_to_presentation")
_emit_rust_turn_events(result.get("events", []) as Array)
# worldsim render hook: nudge the fauna overlay + any turn-number listeners to
# re-read the freshly synced state. The normal `turn_ended` emit follows in
# `end_turn()`; this mirrors the legacy round-end `worldsim_updated` emit.
EventBus.worldsim_updated.emit(GameState.turn_number)
## Translate the `GdTurnProcessor.step` result's `events` array (kind-tagged
## Dictionaries from `replay::event_to_dict`) into existing EventBus signals.
## Best-effort: only the high-value kinds with a faithful, cheap mapping are
## emitted. Entity-payload signals (city_grew/unit_created/…) need a live
## CityScript/Unit lookup that is NOT cheap here, so they are intentionally
## deferred to a later increment (faithful entity mapping) rather than emitting
## null entities into typed signals. State parity (the board advancing via the
## synced slots) is the proof target for this phase; the synced presentation
## slots already carry the new pop/borders/units, so the board is correct and
## only the per-event signal is deferred. Full signal fidelity lands later.
func _emit_rust_turn_events(events: Array) -> void:
for e: Variant in events:
if typeof(e) != TYPE_DICTIONARY:
continue
var d: Dictionary = e as Dictionary
var kind: String = str(d.get("kind", ""))
match kind:
"TechResearched":
EventBus.tech_researched.emit(
str(d.get("tech", "")), int(d.get("clan", 0))
)
"CultureResearched":
EventBus.culture_researched.emit(
str(d.get("tradition", "")), int(d.get("clan", 0))
)
"GoldenAgeStarted":
EventBus.golden_age_started.emit(int(d.get("clan", 0)))
"GoldenAgeEnded":
EventBus.golden_age_ended.emit(int(d.get("clan", 0)))
_:
# Entity-payload and lower-priority kinds (CityGrew,
# CityBordersExpanded, UnitCreated, UnitHealed, UnitKilled,
# CityFounded, CityCaptured, CityBuildingCompleted,
# FloraSuccession, …) are deferred — see the docstring. The
# board is already correct via the synced slots.
pass
func next_player() -> void: func next_player() -> void:
# p3-15: rotation follows GameState.turn_order (randomized once at game start); # p3-15: rotation follows GameState.turn_order (randomized once at game start);
# falls back to sequential when unset (arena / old saves). # falls back to sequential when unset (arena / old saves).
@ -284,21 +384,28 @@ func next_player() -> void:
var phase_events: Array = WorldsimState.worldsim.call("end_player_round_phase", GameState.get_gd_state()) var phase_events: Array = WorldsimState.worldsim.call("end_player_round_phase", GameState.get_gd_state())
_emit_phase_events(phase_events) _emit_phase_events(phase_events)
# All players have taken their turn — run wild creatures, then advance # All players have taken their turn — run wild creatures, then advance
var proc := _processor as TurnProcessorScript var proc: TurnProcessorScript = _processor as TurnProcessorScript
# Iter 7k: optional parallel Rust fauna encounter pass. No-op unless # Rail-1 Phase-2b: under RUST_TURN the whole-round `GdTurnProcessor.step`
# RUST_FAUNA_ENCOUNTERS env flag is set (off by default). # (already run in end_turn at the round boundary) computes fauna encounters,
proc._process_rust_fauna_encounters() # wild-creature behaviour, diplomacy/trade, climate and ecology. Gate the
proc._process_wild_creatures() # GDScript copies of those passes off so they don't double-process. The
# p3-23 revival step 2: evaluate inter-player trades once per full round, # worldsim carve-out below (world-event dispatch / terraform / contamination
# after all players have moved. Sends PlayerTradeInput records (per-player # / worldsim_updated render hook) is NOT covered by `step` and stays live.
# controlled luxuries + strategics + trade_willingness), applies the if not _rust_turn_enabled:
# resulting ledger — traded luxuries feed happiness, strategics gate unit # Iter 7k: optional parallel Rust fauna encounter pass. No-op unless
# builds. process_turn is internally defensive (guards null game_map, # RUST_FAUNA_ENCOUNTERS env flag is set (off by default).
# missing GdTrade extension, unknown resources) so it cannot abort the proc._process_rust_fauna_encounters()
# round loop the way the old empty-stub call did. proc._process_wild_creatures()
(diplomacy as DiplomacyScript).process_turn( # p3-23 revival step 2: evaluate inter-player trades once per full round,
GameState.players, GameState.turn_number, GameState.get_game_map() # after all players have moved. Sends PlayerTradeInput records (per-player
) # controlled luxuries + strategics + trade_willingness), applies the
# resulting ledger — traded luxuries feed happiness, strategics gate unit
# builds. process_turn is internally defensive (guards null game_map,
# missing GdTrade extension, unknown resources) so it cannot abort the
# round loop the way the old empty-stub call did.
(diplomacy as DiplomacyScript).process_turn(
GameState.players, GameState.turn_number, GameState.get_game_map()
)
# DISABLED: EconomyScript.apply_protection_effects — empty stub # DISABLED: EconomyScript.apply_protection_effects — empty stub
# module has no such method; the call aborts next_player and kills # module has no such method; the call aborts next_player and kills
# the arena turn loop. See turn_processor.gd top-of-file out-of-scope # the arena turn loop. See turn_processor.gd top-of-file out-of-scope
@ -311,7 +418,16 @@ func next_player() -> void:
# Climate processing: weather injects deltas, then physics propagates them. # Climate processing: weather injects deltas, then physics propagates them.
# Must run once per full game turn after all players have moved. # Must run once per full game turn after all players have moved.
if game_map_for_climate != null: if game_map_for_climate != null:
proc._process_climate(game_map_for_climate) # Rail-1 Phase-2b: climate physics + ecology tick are now computed by the
# whole-round `GdTurnProcessor.step` (run in end_turn at the round boundary),
# so gate the GDScript copies off under RUST_TURN to avoid double-advancing
# the living-world grid. Flora-succession surfacing also moves to the Rust
# path (step emits FloraSuccession events). The grid is still RESOLVED below
# (it persists across turns) so the worldsim carve-out — world-event dispatch,
# terraform drain, contamination tick, worldsim_updated render hook — keeps
# running, since `step` does NOT cover those (p3-26/p3-27 boundary).
if not _rust_turn_enabled:
proc._process_climate(game_map_for_climate)
# p1-38 Phase B item #7: tick the shared fauna engine after climate # p1-38 Phase B item #7: tick the shared fauna engine after climate
# so populations evolve turn-over-turn (emergence + Lotka-Volterra # so populations evolve turn-over-turn (emergence + Lotka-Volterra
# dynamics). Reuses Climate's GdGridState — same grid both layers # dynamics). Reuses Climate's GdGridState — same grid both layers
@ -320,15 +436,16 @@ func next_player() -> void:
if climate_node != null: if climate_node != null:
var fauna_grid: RefCounted = climate_node.get("_grid") as RefCounted var fauna_grid: RefCounted = climate_node.get("_grid") as RefCounted
if fauna_grid != null: if fauna_grid != null:
EcologyState.tick(fauna_grid, GameState.map_seed + GameState.turn_number) if not _rust_turn_enabled:
# g2-07: surface flora succession transitions captured by the EcologyState.tick(fauna_grid, GameState.map_seed + GameState.turn_number)
# ecology tick into the playable game log. One signal per turn # g2-07: surface flora succession transitions captured by the
# carrying every (tile, species) that crossed a tier this turn. # ecology tick into the playable game log. One signal per turn
var flora_transitions: Array = EcologyState.take_flora_transitions() # carrying every (tile, species) that crossed a tier this turn.
if not flora_transitions.is_empty(): var flora_transitions: Array = EcologyState.take_flora_transitions()
EventBus.flora_succession.emit( if not flora_transitions.is_empty():
GameState.turn_number, flora_transitions EventBus.flora_succession.emit(
) GameState.turn_number, flora_transitions
)
# Increment 3b: world-event dispatch (geological / biological / # Increment 3b: world-event dispatch (geological / biological /
# anomalous) against the SAME live grid, after climate + ecology # anomalous) against the SAME live grid, after climate + ecology
# ticks. Accumulates per-tile eco-damage into WorldsimState's # ticks. Accumulates per-tile eco-damage into WorldsimState's