From 7475daa7f8d106ab98d52f8a07fce44828d770a7 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 28 Jun 2026 09:39:14 -0400 Subject: [PATCH] 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 --- src/game/engine/src/autoloads/turn_manager.gd | 211 ++++++++++++++---- 1 file changed, 164 insertions(+), 47 deletions(-) diff --git a/src/game/engine/src/autoloads/turn_manager.gd b/src/game/engine/src/autoloads/turn_manager.gd index 1b5b380c..197e6be8 100644 --- a/src/game/engine/src/autoloads/turn_manager.gd +++ b/src/game/engine/src/autoloads/turn_manager.gd @@ -61,6 +61,14 @@ var climate_effects: RefCounted = ClimateEffectsScript.new() # ClimateEffects var diplomacy: RefCounted = DiplomacyScript.new() # Diplomacy — relationship state var marine_harvest: RefCounted = MarineHarvestScript.new() # MarineHarvest — ocean ecology 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` ## (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. @@ -79,6 +87,22 @@ func _ready() -> void: proc.climate_effects = climate_effects 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: ## Credit one unit of the strategic resource onto the player's ledger. @@ -244,28 +268,42 @@ func end_turn() -> void: start_turn() return - var player: RefCounted = GameState.get_current_player() # Player - var game_map: RefCounted = GameState.get_game_map() # GameMap - if player != null and game_map != null: - # Processing order: culture FIRST so new tiles are available for - # citizen assignment during growth. Otherwise new pop can't work - # the tile just claimed this turn. - # 1. Culture (borders) 2. Food (growth) 3. Production - # 4. Gold (economy) 5. Science 6. Happiness (golden age) - # 7. Mana 8. Victory 9. Healing 10. Improvements - var proc: TurnProcessorScript = _processor as TurnProcessorScript - proc._process_culture(player, game_map) - proc._process_culture_research(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) + # Rail-1 Phase-2b: when RUST_TURN is ON the proven whole-round `GdTurnProcessor.step` + # computes culture/growth/production/economy/research/golden-age/healing/improvements + # for ALL players plus the round-end sim glue (fauna/wild/diplomacy/climate/ecology) + # in a SINGLE call. So under the flag we SKIP the per-player `_process_*` block below + # (running both would double-process), and run the Rust step exactly ONCE per round, + # at the round boundary, before `next_player()` rotates the cursor. `next_player()`'s + # round-end sim glue is correspondingly gated off (see that function). Default OFF → + # the original per-player path runs untouched. + if not _rust_turn_enabled: + var player: RefCounted = GameState.get_current_player() # Player + var game_map: RefCounted = GameState.get_game_map() # GameMap + if player != null and game_map != null: + # Processing order: culture FIRST so new tiles are available for + # citizen assignment during growth. Otherwise new pop can't work + # the tile just claimed this turn. + # 1. Culture (borders) 2. Food (growth) 3. Production + # 4. Gold (economy) 5. Science 6. Happiness (golden age) + # 7. Mana 8. Victory 9. Healing 10. Improvements + var proc: TurnProcessorScript = _processor as TurnProcessorScript + proc._process_culture(player, game_map) + proc._process_culture_research(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) @@ -273,6 +311,68 @@ func end_turn() -> void: 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: # p3-15: rotation follows GameState.turn_order (randomized once at game start); # 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()) _emit_phase_events(phase_events) # All players have taken their turn — run wild creatures, then advance - var proc := _processor as TurnProcessorScript - # Iter 7k: optional parallel Rust fauna encounter pass. No-op unless - # RUST_FAUNA_ENCOUNTERS env flag is set (off by default). - proc._process_rust_fauna_encounters() - proc._process_wild_creatures() - # p3-23 revival step 2: evaluate inter-player trades once per full round, - # 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() - ) + var proc: TurnProcessorScript = _processor as TurnProcessorScript + # Rail-1 Phase-2b: under RUST_TURN the whole-round `GdTurnProcessor.step` + # (already run in end_turn at the round boundary) computes fauna encounters, + # wild-creature behaviour, diplomacy/trade, climate and ecology. Gate the + # GDScript copies of those passes off so they don't double-process. The + # worldsim carve-out below (world-event dispatch / terraform / contamination + # / worldsim_updated render hook) is NOT covered by `step` and stays live. + if not _rust_turn_enabled: + # Iter 7k: optional parallel Rust fauna encounter pass. No-op unless + # RUST_FAUNA_ENCOUNTERS env flag is set (off by default). + proc._process_rust_fauna_encounters() + proc._process_wild_creatures() + # p3-23 revival step 2: evaluate inter-player trades once per full round, + # 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 # 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 @@ -311,7 +418,16 @@ func next_player() -> void: # Climate processing: weather injects deltas, then physics propagates them. # 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) + # 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 # so populations evolve turn-over-turn (emergence + Lotka-Volterra # dynamics). Reuses Climate's GdGridState — same grid both layers @@ -320,15 +436,16 @@ func next_player() -> void: 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) - # g2-07: surface flora succession transitions captured by the - # ecology tick into the playable game log. One signal per turn - # carrying every (tile, species) that crossed a tier this turn. - var flora_transitions: Array = EcologyState.take_flora_transitions() - if not flora_transitions.is_empty(): - EventBus.flora_succession.emit( - GameState.turn_number, flora_transitions - ) + if not _rust_turn_enabled: + EcologyState.tick(fauna_grid, GameState.map_seed + GameState.turn_number) + # g2-07: surface flora succession transitions captured by the + # ecology tick into the playable game log. One signal per turn + # carrying every (tile, species) that crossed a tier this turn. + var flora_transitions: Array = EcologyState.take_flora_transitions() + if not flora_transitions.is_empty(): + EventBus.flora_succession.emit( + GameState.turn_number, flora_transitions + ) # Increment 3b: world-event dispatch (geological / biological / # anomalous) against the SAME live grid, after climate + ecology # ticks. Accumulates per-tile eco-damage into WorldsimState's