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:
parent
79db241cef
commit
7475daa7f8
1 changed files with 164 additions and 47 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue