fix(@projects/@magic-civilization): 🐛 resolve turn-processor culture research regression
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
8852f44738
commit
b0975255d8
6 changed files with 206 additions and 564 deletions
27
.project/handoffs/20260504_shipwright-to-shipwright.md
Normal file
27
.project/handoffs/20260504_shipwright-to-shipwright.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Handoff: shipwright → shipwright
|
||||
|
||||
- Date: 2026-05-04T17:08:34.301Z
|
||||
- From: shipwright
|
||||
- To: shipwright
|
||||
|
||||
---
|
||||
|
||||
## p1-38 closure attempt 2026-05-04 — SIGN-OFF WITHHELD, status remains `partial`
|
||||
|
||||
Ran the recipe end-to-end on apricot SHA `6944573c0` (origin/main):
|
||||
|
||||
- Worktree + JSON flip (`fallback_when_dormant: "static_terrain" → "coupled"`) in `~/.cache/mc-src-p1_38_coupled_20260504_124853`.
|
||||
- `build-gdext.sh x86_64-unknown-linux-gnu` clean (release).
|
||||
- 10-seed T300 batch with `AI_USE_MCTS=true AI_GPU_ROLLOUT=false PARALLEL=10 RAYON_NUM_THREADS=6` → 10/10 E2E pass.
|
||||
- Control batch at same SHA without the flip (`p1_38_control_20260504_125801`) → 10/10 E2E pass.
|
||||
|
||||
**Both batches are byte-identical at the gameplay level.** P0 cities=1 / pop_peak=1, P1 never founds, gold flat at starting 20, mil=0, mcts_action=Heuristic across all 20 player-turns. p016b baseline median pop_peak=69.
|
||||
|
||||
**Root cause** (game.log seed 1):
|
||||
```
|
||||
SCRIPT ERROR: Invalid call. Nonexistent function '_process_culture_research' in base 'RefCounted (turn_processor.gd)'.
|
||||
```
|
||||
|
||||
Turn processing aborts every turn → AI/economy never runs → coupled vs control are observationally identical at the floor. Phase A coupling is non-destructive but the bullet's positive criterion (forest-heavy seeds ≥+10% lift) is unmeasurable on a harness producing pop_peak=1. **Self-sign-off withheld.**
|
||||
|
||||
**Filed as harness regression**, not biome-coupling defect. Worktrees removed. Bullet stays ❌; objective stays `partial`. Recommend a separate `p?-XX-turn-processor-culture-research-regression` objective; once that lands, p1-38 closure can be re-attempted with the same recipe.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"generated_at": "2026-05-04T11:32:54Z",
|
||||
"generated_at": "2026-05-04T17:08:38Z",
|
||||
"totals": {
|
||||
"done": 154,
|
||||
"in_progress": 1,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
id: p0-45
|
||||
title: Turn processor consolidation — entities/ duplicate caused T1 SCRIPT ERROR halt
|
||||
priority: p0
|
||||
status: stub
|
||||
scope: game1
|
||||
category: ui
|
||||
owner: unassigned
|
||||
created: 2026-05-04
|
||||
updated_at: 2026-05-04
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Cycle-4 p2-43 (cultural-tradition research) added `_process_culture_research(player)`
|
||||
to `src/game/engine/src/entities/turn_processor.gd` — the WRONG file. Runtime
|
||||
turn loop preloads `src/game/engine/src/modules/management/turn_processor.gd`
|
||||
(see `src/game/engine/src/autoloads/turn_manager.gd:29`). The autoload calls
|
||||
`proc._process_culture_research(player)` at `turn_manager.gd:247`; management/'s
|
||||
instance lacks the method, so every batch halts at T1 with:
|
||||
|
||||
```
|
||||
SCRIPT ERROR: Invalid call. Nonexistent function '_process_culture_research'
|
||||
in base 'RefCounted (turn_processor.gd)'.
|
||||
```
|
||||
|
||||
Cause: two parallel `turn_processor.gd` files violated the Zero Tech Debt rail.
|
||||
entities/ was older (missing cycle-4 p1-29 catch-up research dynamics + city
|
||||
occupation_mult) but had the new culture function — proving it was an unused
|
||||
stale fork.
|
||||
|
||||
## Fix
|
||||
|
||||
- Cherry-pick `_process_culture_research` from entities/ into modules/management/
|
||||
(one canonical site, after `_process_culture`).
|
||||
- Delete entities/turn_processor.gd entirely (no shim, no rename).
|
||||
- Verify no test/proof scene referenced the entities/ path.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] entities/turn_processor.gd deleted.
|
||||
- [ ] modules/management/turn_processor.gd has `_process_culture_research`.
|
||||
- [ ] 1-seed apricot batch reaches T100 without SCRIPT ERROR (pop_peak > 1, mil > 0).
|
||||
- [ ] No test/proof scene references `res://engine/src/entities/turn_processor.gd`.
|
||||
|
|
@ -7,10 +7,10 @@ scope: game1
|
|||
owner: shipwright
|
||||
updated_at: 2026-05-04
|
||||
evidence:
|
||||
- public/games/age-of-dwarves/data/balance/ecology_yields.json (fallback_when_dormant=static_terrain; no env-var override exists)
|
||||
- "src/simulator/crates/mc-city/src/biome_yield.rs:52 (EcologyYieldsConfig::coupled reads JSON only)"
|
||||
- .project/objectives/p1-38-biome-economy-coupling.md (2026-05-03 closure attempt section)
|
||||
- ".project/team-leads/shipwright.md (owner: shipwright; sign-off required for default flip)"
|
||||
- "apricot:/var/home/lilith/.cache/mc-batches/p1_38_coupled_20260504_124853/smoke/ (10/10 E2E pass, fallback_when_dormant=coupled, SHA 6944573c0)"
|
||||
- "apricot:/var/home/lilith/.cache/mc-batches/p1_38_control_20260504_125801/smoke/ (10/10 E2E pass, control with static_terrain default, same SHA)"
|
||||
- ".project/objectives/p1-38-biome-economy-coupling.md (2026-05-04 closure attempt section: parity table, harness regression cite)"
|
||||
- "apricot:/var/home/lilith/.cache/mc-batches/p1_38_coupled_20260504_124853/smoke/game_20260504_095253_seed1/game.log (SCRIPT ERROR: Nonexistent function '_process_culture_research' in turn_processor.gd — aborts turn processing, degenerate gameplay state for both COUPLED and CONTROL batches: P0 pop_peak=1 vs p016b baseline median 69)"
|
||||
---
|
||||
## Summary
|
||||
|
||||
|
|
@ -442,3 +442,70 @@ Recommended next-picker recipe (Shipwright):
|
|||
- Flip in place — no `fallback_when_dormant_v2` field; extend the existing schema.
|
||||
|
||||
Bullets remaining: 1.
|
||||
|
||||
### 2026-05-04 closure attempt (shipwright) — BLOCKED on harness regression
|
||||
|
||||
Recipe from the 2026-05-03 game-systems hand-off was followed end-to-end:
|
||||
|
||||
1. `git worktree add --detach origin/main` → SHA `6944573c0` on apricot at
|
||||
`~/.cache/mc-src-p1_38_coupled_20260504_124853`.
|
||||
2. Flipped `public/games/age-of-dwarves/data/balance/ecology_yields.json`
|
||||
`fallback_when_dormant: "static_terrain" → "coupled"` in the worktree only
|
||||
(not committed).
|
||||
3. `bash build-gdext.sh x86_64-unknown-linux-gnu` clean (release, 2m55s,
|
||||
17 warnings unchanged from main).
|
||||
4. `--editor --quit` pre-pass: class cache populated.
|
||||
5. `tools/autoplay-batch.sh 10 300` with `AI_USE_MCTS=true AI_GPU_ROLLOUT=false PARALLEL=10 RAYON_NUM_THREADS=6`.
|
||||
6. **Control run** at same SHA with the JSON unchanged
|
||||
(`~/.cache/mc-batches/p1_38_control_20260504_125801`) for parity reference.
|
||||
|
||||
**Result: bullet cannot be evaluated — harness regression on `origin/main` blocks it.**
|
||||
|
||||
Per-player final-turn medians (10 seeds, T300):
|
||||
|
||||
| metric | COUPLED | CONTROL | p016b baseline |
|
||||
|-------------------|----------|----------|----------------|
|
||||
| P0 cities (med) | 1 | 1 | (not captured) |
|
||||
| P0 pop_peak (med) | 1 | 1 | **69** |
|
||||
| P0 gold (med) | 20 | 20 | (not captured) |
|
||||
| P0 mil (med) | 0 | 0 | (not captured) |
|
||||
| P1 cities (med) | 0 | 0 | (not captured) |
|
||||
| P1 pop_peak (med) | 0 | 0 | (not captured) |
|
||||
| AI mcts_action | Heuristic only | Heuristic only | MCTS expected |
|
||||
|
||||
Both batches passed the 10/10 E2E gate (turn_stats.jsonl produced for each
|
||||
seed) but the gameplay state is degenerate: P0 founds one city, no growth,
|
||||
no military, no economy; P1 never founds. `mcts_action="Heuristic"` across
|
||||
all 20 player-turns despite `AI_USE_MCTS=true`.
|
||||
|
||||
**Root cause** (cited from
|
||||
`~/.cache/mc-batches/p1_38_coupled_20260504_124853/smoke/game_20260504_095253_seed1/game.log`):
|
||||
|
||||
```
|
||||
SCRIPT ERROR: Invalid call. Nonexistent function '_process_culture_research'
|
||||
in base 'RefCounted (turn_processor.gd)'.
|
||||
```
|
||||
|
||||
Turn processing aborts on this error every turn, so AI/economy/MCTS never
|
||||
runs. This is unrelated to Phase A biome coupling — the JSON flip is
|
||||
functionally a no-op on a harness this degraded, which is why coupled and
|
||||
control batches produce byte-identical aggregate stats.
|
||||
|
||||
**Coupled mode is non-destructive at this floor**, but the acceptance
|
||||
bullet requires a *positive* demonstration of expected divergence
|
||||
(forest-heavy seeds ≥+10% lift over baseline median 69) which cannot be
|
||||
measured against a harness producing `pop_peak=1`.
|
||||
|
||||
Evidence dirs (apricot):
|
||||
- `/var/home/lilith/.cache/mc-batches/p1_38_coupled_20260504_124853/smoke/`
|
||||
- `/var/home/lilith/.cache/mc-batches/p1_38_control_20260504_125801/smoke/`
|
||||
|
||||
Worktrees removed via `git worktree remove --force` post-batch.
|
||||
|
||||
**Status remains `partial`. Sign-off withheld.** This bullet should
|
||||
re-enter the queue only after the `_process_culture_research` regression
|
||||
is fixed — that belongs to a separate harness/turn_processor objective
|
||||
(likely under `p1-05` or a fresh `p?-XX-turn-processor-regression`),
|
||||
not to `p1-38`. The Phase A coupling code itself is unchanged and ready;
|
||||
it's only the regression batch that this attempt could not produce.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,559 +0,0 @@
|
|||
# gdlint: disable=no-elif-return,no-else-return,max-returns,class-definitions-order
|
||||
extends RefCounted
|
||||
## End-of-turn processing. Per-player and global _process_* logic.
|
||||
## DISABLED stubs: _process_culture, _process_golden_age, _process_loot_decay,
|
||||
## _process_spell_system, _process_government — blocked on empty module stubs.
|
||||
## `_process_climate` runs the full marine_harvest→climate→weather→effects chain.
|
||||
## Grep for `DISABLED:` to find every remaining guarded call site.
|
||||
|
||||
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
|
||||
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
|
||||
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
|
||||
const CultureScript: GDScript = preload("res://engine/src/modules/empire/culture.gd")
|
||||
const EconomyScript: GDScript = preload("res://engine/src/modules/empire/economy.gd")
|
||||
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
|
||||
const HappinessScript: GDScript = preload("res://engine/src/modules/empire/happiness.gd")
|
||||
const AscensionRitualScript: GDScript = preload(
|
||||
"res://engine/src/modules/victory/ascension_ritual.gd"
|
||||
)
|
||||
const GovernmentScript: GDScript = preload("res://engine/src/modules/empire/government.gd")
|
||||
const ItemSystemScript: GDScript = preload("res://engine/src/modules/management/item_system.gd")
|
||||
const SpellSystemScript: GDScript = preload("res://engine/src/modules/magic/spell_system.gd")
|
||||
const BuildableHelperScript: GDScript = preload("res://engine/scenes/city/city_buildable_helper.gd")
|
||||
const MarineHarvestScript: GDScript = preload("res://engine/src/modules/events/marine_harvest.gd")
|
||||
const ClimateScript: GDScript = preload("res://engine/src/modules/climate/climate.gd")
|
||||
const ClimateEffectsScript: GDScript = preload(
|
||||
"res://engine/src/modules/climate/climate_effects.gd"
|
||||
)
|
||||
const WeatherScript: GDScript = preload("res://engine/src/modules/climate/weather.gd")
|
||||
const RustFaunaIntegrationScript: GDScript = preload(
|
||||
"res://engine/src/modules/management/rust_fauna_integration.gd"
|
||||
)
|
||||
const TurnProcessorHelpersScript: GDScript = preload(
|
||||
"res://engine/src/modules/management/turn_processor_helpers.gd"
|
||||
)
|
||||
const TurnProcessorCityHelpersScript: GDScript = preload(
|
||||
"res://engine/src/modules/management/turn_processor_city_helpers.gd"
|
||||
)
|
||||
|
||||
var unit_manager: RefCounted # UnitManager — set by TurnManager._ready()
|
||||
var spell_system: RefCounted # SpellSystem — set by TurnManager._ready()
|
||||
var wild_ai: RefCounted # WildCreatureAI — set via TurnManager.set_wild_creature_ai()
|
||||
var weather: RefCounted # Weather — set by TurnManager._ready()
|
||||
var climate: RefCounted # Climate — set by TurnManager._ready()
|
||||
var climate_effects: RefCounted # ClimateEffects — set by TurnManager._ready()
|
||||
var marine_harvest: RefCounted # MarineHarvest — set by TurnManager._ready()
|
||||
|
||||
|
||||
func _process_production(player: RefCounted) -> void: # Player
|
||||
var game_map: RefCounted = GameState.get_game_map() # GameMap
|
||||
if game_map == null:
|
||||
return
|
||||
|
||||
# Effective per-yield mult composes static difficulty handicap +
|
||||
# linear-per-turn growth (warcouncil p1-29 H4). Per-player overrides
|
||||
# (batch testing) handled inside the helper.
|
||||
var prod_modifier: float = GameState.get_effective_yield_mult(player, "production")
|
||||
# Unhappy penalty: -25% production when happiness < 0
|
||||
if player.happiness < 0:
|
||||
prod_modifier *= 0.75
|
||||
# Golden Age: +20% production
|
||||
if player.golden_age_active:
|
||||
prod_modifier *= 1.2
|
||||
|
||||
for city_ref: RefCounted in player.cities:
|
||||
if not city_ref is CityScript:
|
||||
continue
|
||||
var c: CityScript = city_ref as CityScript
|
||||
var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map)
|
||||
var yields: Dictionary = c.get_yields(tile_json)
|
||||
# Add building production bonuses (forge +2, barracks +1, etc.)
|
||||
var building_prod: int = _sum_city_building_effect(c, "production")
|
||||
# production_from_hills — +N prod per worked hills tile (first_mineshaft etc.).
|
||||
var prod_hills: int = _sum_city_building_effect(c, "production_from_hills")
|
||||
if prod_hills > 0:
|
||||
for tile_pos: Vector2i in c.get_worked_tiles():
|
||||
var tile: Resource = game_map.get_tile(tile_pos)
|
||||
if tile != null and tile.biome_id == "hills":
|
||||
building_prod += prod_hills
|
||||
var prod_pct: float = _sum_city_building_effect_float(c, "production_percent")
|
||||
var prod: int = int(
|
||||
(yields.get("production", 1) + building_prod) * (1.0 + prod_pct) * prod_modifier
|
||||
)
|
||||
# Capture current item before apply_production pops it on completion.
|
||||
var current: Dictionary = (
|
||||
c.production_queue.front() as Dictionary if not c.production_queue.is_empty() else {}
|
||||
)
|
||||
# Strategic resource gate: territory-ownership check (non-consumable model).
|
||||
# A player can build a resource-gated unit if they own at least one tile
|
||||
# with that resource — no slot depletion on build (Civ-6 style).
|
||||
var pre_type: String = current.get("type", "")
|
||||
var pre_id: String = current.get("id", "")
|
||||
if pre_type == "unit" and pre_id != "":
|
||||
var udata_pre: Dictionary = DataLoader.get_unit(pre_id)
|
||||
var req_pre: String = str(udata_pre.get("requires_resource", ""))
|
||||
if req_pre != "" and req_pre != "null" and req_pre != "<null>":
|
||||
if not _player_owns_resource(player, req_pre):
|
||||
EventBus.strategic_gate_rejected.emit(
|
||||
player.index, c.city_name, pre_id, req_pre
|
||||
)
|
||||
continue
|
||||
if not c.apply_production(prod):
|
||||
continue
|
||||
var item_type: String = current.get("type", "")
|
||||
var item_id: String = current.get("id", "")
|
||||
|
||||
if item_type == "unit":
|
||||
var unit: UnitScript = _spawn_unit(item_id, player, c.position)
|
||||
if unit != null:
|
||||
var xp_bonus: int = _sum_city_building_effect(c, "unit_xp_start_home_city")
|
||||
if xp_bonus > 0:
|
||||
unit.gain_xp(xp_bonus)
|
||||
EventBus.city_unit_completed.emit(city_ref, unit)
|
||||
elif item_type == "building":
|
||||
var tile_pos: Vector2i = current.get("tile_pos", Vector2i(-1, -1)) as Vector2i
|
||||
c.add_building_at(item_id, tile_pos)
|
||||
_apply_building_bonuses(c, item_id)
|
||||
EventBus.city_building_completed.emit(city_ref, item_id)
|
||||
elif item_type == "item":
|
||||
var i_data: Dictionary = DataLoader.get_item(item_id)
|
||||
if not i_data.is_empty():
|
||||
var charges: int = i_data.get("charges", -1)
|
||||
if c.get("item_stockpile") is Array:
|
||||
(
|
||||
c
|
||||
. item_stockpile
|
||||
. append(
|
||||
{
|
||||
"item_id": item_id,
|
||||
"charges_remaining": charges,
|
||||
}
|
||||
)
|
||||
)
|
||||
# Deduct mana cost on completion.
|
||||
var mana_cost: Dictionary = i_data.get("cost_mana", {}) as Dictionary
|
||||
if not mana_cost.is_empty():
|
||||
var color: String = mana_cost.get("color", "")
|
||||
var amount: int = mana_cost.get("amount", 0)
|
||||
if color != "" and amount > 0:
|
||||
player.mana_pool[color] = (player.mana_pool.get(color, 0.0) - float(amount))
|
||||
EventBus.item_produced.emit(city_ref, item_id)
|
||||
|
||||
|
||||
func _process_research(player: RefCounted) -> void: # Player
|
||||
## Rail-1: science accumulation + spell/tech completion check delegated
|
||||
## to Rust `GdTechWeb::process_research` (warcouncil p1-39 port,
|
||||
## 2026-04-27). GDScript only assembles input JSON + applies
|
||||
## completion side-effects (school_locked emit, _form_high_archon,
|
||||
## tech_researched signal, resource reveals).
|
||||
if player.researching.is_empty():
|
||||
return
|
||||
|
||||
# Per-yield difficulty multiplier (composed by GameState).
|
||||
var sci_modifier: float = GameState.get_effective_yield_mult(player, "research")
|
||||
|
||||
# Per-city science yields the Rust side will sum.
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
var yields_arr: Array = []
|
||||
if game_map != null:
|
||||
for city: Variant in player.cities:
|
||||
if not city is CityScript:
|
||||
continue
|
||||
var c: CityScript = city as CityScript
|
||||
var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map)
|
||||
var ys: Dictionary = c.get_yields(tile_json)
|
||||
yields_arr.append({
|
||||
"science": int(ys.get("science", 0)),
|
||||
"building_science": _sum_city_building_effect(c, "science"),
|
||||
"science_percent": _sum_city_building_effect_float(c, "science_percent"),
|
||||
})
|
||||
|
||||
# Player input. spell_cost is set when researching a spell so Rust runs
|
||||
# the cheap counter branch (no TechWeb lookup).
|
||||
var spell_data: Dictionary = DataLoader.get_spell(player.researching)
|
||||
var researching_spell: bool = not spell_data.is_empty()
|
||||
var researched_arr: Array = Array(player.researched_techs) if player.researched_techs != null else []
|
||||
var player_dict: Dictionary = {
|
||||
"researching": str(player.researching),
|
||||
"research_progress": int(player.research_progress),
|
||||
"science_per_turn": int(player.science_per_turn),
|
||||
"researched_techs": researched_arr,
|
||||
"instant_complete": EnvConfig.get_bool("FORCE_UNLIMITED_RESEARCH"),
|
||||
}
|
||||
if researching_spell:
|
||||
player_dict["spell_cost"] = int(spell_data.get("research_cost", 999999))
|
||||
|
||||
var tw: RefCounted = TurnManager.get_tech_web()
|
||||
if tw == null:
|
||||
return
|
||||
var result: Dictionary = tw.process_research(
|
||||
JSON.stringify(player_dict), JSON.stringify(yields_arr), sci_modifier
|
||||
)
|
||||
if result.is_empty():
|
||||
return
|
||||
var err: String = str(result.get("error", ""))
|
||||
if not err.is_empty():
|
||||
push_warning("p1-39 _process_research: " + err)
|
||||
return
|
||||
player.research_progress = int(result.get("new_progress", 0))
|
||||
player.researching = str(result.get("new_researching", ""))
|
||||
|
||||
var completed_spell: String = str(result.get("completed_spell", ""))
|
||||
if not completed_spell.is_empty():
|
||||
var sys: SpellSystemScript = spell_system as SpellSystemScript
|
||||
sys.research_spell(player.index, completed_spell)
|
||||
return
|
||||
|
||||
var completed_tech: String = str(result.get("completed_tech", ""))
|
||||
if not completed_tech.is_empty():
|
||||
var old_school_count: int = player.schools.size()
|
||||
player.add_tech(completed_tech)
|
||||
# Arcane Lore completion: transform leader into High Archon
|
||||
if completed_tech == "arcane_lore":
|
||||
_form_high_archon(player)
|
||||
# Emit school_locked when the 2nd school is entered for the first time.
|
||||
if player.schools.size() == 2 and old_school_count < 2:
|
||||
EventBus.school_locked.emit(player.index, player.schools.duplicate())
|
||||
EventBus.tech_researched.emit(completed_tech, player.index)
|
||||
_check_resource_reveals(completed_tech, player.index)
|
||||
|
||||
|
||||
func _process_culture_research(player: RefCounted) -> void: # Player
|
||||
## p2-43: per-turn cultural-tradition research accumulator. Mirrors
|
||||
## `_process_research` shape — assemble player JSON + per-turn culture,
|
||||
## delegate completion math to Rust `GdCultureWeb::process_culture_research`,
|
||||
## then apply side-effects (`player.add_tradition`, EventBus emit).
|
||||
if String(player.researching_tradition).is_empty():
|
||||
return
|
||||
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
if game_map == null:
|
||||
return
|
||||
|
||||
# Sum per-city culture yield (mirrors what process_culture_with_modifier
|
||||
# would consume) so the Rust accumulator gets the same per-turn pool the
|
||||
# border-expansion phase uses.
|
||||
var per_turn_culture: float = 0.0
|
||||
for city: Variant in player.cities:
|
||||
if not city is CityScript:
|
||||
continue
|
||||
var c: CityScript = city as CityScript
|
||||
var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map)
|
||||
var ys: Dictionary = c.get_yields(tile_json)
|
||||
var base: float = float(ys.get("culture", 0))
|
||||
var building_culture: float = float(_sum_city_building_effect(c, "culture"))
|
||||
var cult_pct: float = _sum_city_building_effect_float(c, "culture_percent")
|
||||
per_turn_culture += (base + building_culture) * (1.0 + cult_pct)
|
||||
|
||||
var difficulty_mult: float = GameState.get_effective_yield_mult(player, "culture")
|
||||
if player.golden_age_active:
|
||||
difficulty_mult *= 1.2
|
||||
|
||||
var researched_arr: Array = (
|
||||
Array(player.researched_traditions) if player.researched_traditions != null else []
|
||||
)
|
||||
var player_dict: Dictionary = {
|
||||
"researching": String(player.researching_tradition),
|
||||
"research_progress": int(player.culture_research_progress),
|
||||
"researched_traditions": researched_arr,
|
||||
"instant_complete": EnvConfig.get_bool("FORCE_UNLIMITED_RESEARCH"),
|
||||
}
|
||||
|
||||
var cw: RefCounted = TurnManager.get_culture_web()
|
||||
if cw == null:
|
||||
return
|
||||
var result: Dictionary = cw.process_research(
|
||||
JSON.stringify(player_dict), per_turn_culture, difficulty_mult
|
||||
)
|
||||
if result.is_empty():
|
||||
return
|
||||
var err: String = String(result.get("error", ""))
|
||||
if not err.is_empty():
|
||||
push_warning("p2-43 _process_culture_research: " + err)
|
||||
return
|
||||
|
||||
player.culture_research_progress = int(result.get("new_progress", 0))
|
||||
player.researching_tradition = String(result.get("new_researching", ""))
|
||||
|
||||
var completed: String = String(result.get("completed_tradition", ""))
|
||||
if not completed.is_empty():
|
||||
player.add_tradition(completed)
|
||||
EventBus.culture_researched.emit(completed, player.index)
|
||||
|
||||
|
||||
func _check_resource_reveals(completed_tech: String, player_index: int) -> void:
|
||||
TurnProcessorHelpersScript._check_resource_reveals(completed_tech, player_index)
|
||||
|
||||
|
||||
func _spawn_unit(type_id: String, player: RefCounted, pos: Vector2i) -> UnitScript:
|
||||
## Minimal unit spawn used when a city finishes building a unit.
|
||||
## `UnitManager` does not own a `create_unit` method in the current engine;
|
||||
## the world-map spawner uses the same pattern (new Unit + register into
|
||||
## player.units and the primary layer). Kept inline here so arena matches
|
||||
## do not depend on an out-of-scope unit-manager rewrite.
|
||||
var unit: UnitScript = UnitScript.new(type_id, player.index, pos)
|
||||
if unit == null:
|
||||
return null
|
||||
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)
|
||||
player.units.append(unit)
|
||||
var primary: Dictionary = GameState.get_primary_layer()
|
||||
var layer_units: Array = primary.get("units", [])
|
||||
layer_units.append(unit)
|
||||
primary["units"] = layer_units
|
||||
EventBus.unit_created.emit(unit, player.index)
|
||||
return unit
|
||||
|
||||
|
||||
func _process_growth(player: RefCounted) -> void: # Player
|
||||
var game_map: RefCounted = GameState.get_game_map() # GameMap
|
||||
if game_map == null:
|
||||
return
|
||||
|
||||
# Apply the Happy / Golden Age +25% growth bonus; preserve the < -10 halt.
|
||||
# The 0.5× Unhappy tier from mc_happiness::get_growth_modifier is held back
|
||||
# pending balance-lead sign-off — flipping it would re-tune p1-05 bands.
|
||||
var growth_modifier: float = 1.25 if player.happiness > 0 else 1.0
|
||||
var skip_growth: bool = player.happiness < -10
|
||||
for city: Variant in player.cities:
|
||||
if not city is CityScript:
|
||||
continue
|
||||
var c: CityScript = city as CityScript
|
||||
if skip_growth:
|
||||
continue
|
||||
var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map)
|
||||
var prev_pop: int = c.population
|
||||
# Small cities prioritize food growth; pop 4+ uses balanced Default focus
|
||||
if c.population < 4 and c.has_method("set_focus"):
|
||||
c.set_focus("food")
|
||||
c.process_growth(tile_json, growth_modifier)
|
||||
if c.population != prev_pop:
|
||||
# Re-assign citizens to tiles after growth or starvation
|
||||
c.auto_assign_citizens(tile_json)
|
||||
EventBus.city_grew.emit(c, c.population)
|
||||
|
||||
|
||||
func _sum_city_building_effect(city: CityScript, effect_type: String) -> int:
|
||||
return TurnProcessorCityHelpersScript.sum_city_building_effect(city, effect_type)
|
||||
|
||||
|
||||
func _sum_city_building_effect_float(city: CityScript, effect_type: String) -> float:
|
||||
return TurnProcessorCityHelpersScript.sum_city_building_effect_float(city, effect_type)
|
||||
|
||||
|
||||
func _apply_building_bonuses(city: CityScript, building_id: String) -> void:
|
||||
var bdata: Dictionary = DataLoader.get_building(building_id)
|
||||
var effects: Array = bdata.get("effects", [])
|
||||
var owner_player: RefCounted = GameState.get_player(city.owner) if city.owner >= 0 else null
|
||||
for effect: Dictionary in effects:
|
||||
var etype: String = effect.get("type", "")
|
||||
var value: int = int(effect.get("value", 0))
|
||||
if etype == "hp_bonus" and value > 0:
|
||||
city.set_max_hp(city.max_hp + value)
|
||||
city.heal(value)
|
||||
elif etype == "city_hp" and value > 0 and owner_player != null:
|
||||
# Empire-wide max HP bump from mundane wonder (iron_bulwark +100).
|
||||
for other_ref: Variant in owner_player.cities:
|
||||
if other_ref is CityScript:
|
||||
var other: CityScript = other_ref as CityScript
|
||||
other.set_max_hp(other.max_hp + value)
|
||||
other.heal(value)
|
||||
elif etype == "free_tech" and value > 0 and owner_player != null:
|
||||
_grant_free_tech(owner_player, value)
|
||||
elif etype == "free_golden_age_on_build" and value > 0 and owner_player != null:
|
||||
owner_player.golden_age_active = true
|
||||
owner_player.golden_age_turns = HappinessScript.GOLDEN_AGE_DURATION
|
||||
EventBus.golden_age_started.emit(owner_player.index)
|
||||
|
||||
|
||||
func _grant_free_tech(player: RefCounted, count: int) -> void:
|
||||
TurnProcessorCityHelpersScript.grant_free_tech(player, count)
|
||||
|
||||
|
||||
func _process_city_healing(player: RefCounted) -> void:
|
||||
for city_ref: Variant in player.cities:
|
||||
if city_ref is CityScript:
|
||||
(city_ref as CityScript).heal_per_turn(GameState.turn_number)
|
||||
|
||||
|
||||
func _process_healing(player: RefCounted) -> void: # Player
|
||||
var game_map: RefCounted = GameState.get_game_map() # GameMap
|
||||
if game_map == null:
|
||||
return
|
||||
|
||||
for unit: Variant in player.units:
|
||||
if not unit is UnitScript:
|
||||
continue
|
||||
|
||||
# Note: terrain power effects (TerrainAffinityScript) and dead-zone
|
||||
# damage for summoned units are gated on subsystem rewrites that are
|
||||
# out of scope for arena task #2. Arena healing is purely:
|
||||
# "if the unit did not move or attack this turn, heal a little".
|
||||
if unit.hp >= unit.max_hp:
|
||||
continue
|
||||
if unit.movement_remaining < unit.get_movement() or unit.has_attacked:
|
||||
continue
|
||||
|
||||
var heal_amount: int = _get_healing_rate(unit, player, game_map)
|
||||
if heal_amount > 0:
|
||||
unit.heal(heal_amount)
|
||||
EventBus.unit_healed.emit(unit, heal_amount)
|
||||
|
||||
|
||||
func _get_healing_rate(unit: RefCounted, player: RefCounted, game_map: RefCounted) -> int:
|
||||
return TurnProcessorHelpersScript._get_healing_rate(unit, player, game_map)
|
||||
|
||||
|
||||
func _process_mana(player: RefCounted, game_map: RefCounted = null) -> void: # Player
|
||||
TurnProcessorHelpersScript.process_mana(player, game_map)
|
||||
|
||||
|
||||
func _process_economy(player: RefCounted, game_map: RefCounted) -> void: # Player, GameMap
|
||||
## Delegate per-turn gold income, upkeep, golden-age bonus, and
|
||||
## insolvency-driven unit disbanding to `Economy.process_turn`, which
|
||||
## marshals inputs into the Rust `GdEconomy` bridge (mc-economy). Rail-1:
|
||||
## no simulation logic in GDScript.
|
||||
EconomyScript.process_turn(player, game_map)
|
||||
|
||||
|
||||
func _process_culture(player: RefCounted, game_map: RefCounted) -> void:
|
||||
## Expand city borders using Rust's expand_borders() method. Culture
|
||||
## accumulation happens inside Rust's process_growth() each turn.
|
||||
if game_map == null:
|
||||
return
|
||||
for city_ref: Variant in player.cities:
|
||||
if not city_ref is CityScript:
|
||||
continue
|
||||
var c: CityScript = city_ref as CityScript
|
||||
var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map)
|
||||
# Rail-1 culture port (p1-39). R7/R8 divergence was a stale GDExtension
|
||||
# binary on apricot — process_culture_with_modifier didn't exist in the
|
||||
# deployed .so, GDScript silently errored, culture never accumulated.
|
||||
# Rust math is identical to the pre-port GDScript path.
|
||||
var cult_pct: float = _sum_city_building_effect_float(c, "culture_percent")
|
||||
var border_pct: float = _sum_city_building_effect_float(c, "border_growth_percent")
|
||||
var difficulty_cult_mult: float = GameState.get_effective_yield_mult(player, "culture")
|
||||
var total_pct: float = cult_pct + border_pct + (difficulty_cult_mult - 1.0)
|
||||
var can_expand: bool = c.process_culture_with_modifier(tile_json, total_pct)
|
||||
if not can_expand:
|
||||
continue
|
||||
# Build candidates JSON for Rust border expansion
|
||||
var candidates_json: String = _build_border_candidates_json(c, game_map, player)
|
||||
var claimed: Vector2i = c.expand_borders(candidates_json)
|
||||
if claimed != Vector2i(-1, -1):
|
||||
# Re-run citizen assignment so the new tile can be worked immediately
|
||||
var fresh_tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map)
|
||||
c.auto_assign_citizens(fresh_tile_json)
|
||||
var tile: Resource = game_map.get_tile(claimed)
|
||||
if tile != null:
|
||||
tile.owner = player.index
|
||||
EventBus.city_border_expanded.emit(c, claimed)
|
||||
|
||||
|
||||
func _build_border_candidates_json(
|
||||
city: CityScript, game_map: RefCounted, player: RefCounted
|
||||
) -> String:
|
||||
return TurnProcessorCityHelpersScript.build_border_candidates_json(city, game_map, player)
|
||||
|
||||
|
||||
func _process_golden_age(player: RefCounted, game_map: RefCounted) -> void: # Player, GameMap
|
||||
## Delegates to HappinessScript.process_turn, which wraps the mc-happiness
|
||||
## Rust crate through GdHappiness (GDExtension). Method name kept so
|
||||
## turn_manager.gd's existing call site does not need to change.
|
||||
HappinessScript.process_turn(player, game_map)
|
||||
|
||||
|
||||
func _process_wild_creatures() -> void:
|
||||
## Run wild creature AI for all owner==-1 units once per game turn.
|
||||
if wild_ai == null:
|
||||
return
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
if game_map == null:
|
||||
return
|
||||
wild_ai.process_wild_turn(game_map)
|
||||
|
||||
|
||||
func _process_rust_fauna_encounters() -> void:
|
||||
## Iter 7k: gated parallel Rust fauna encounter pass.
|
||||
## Delegates to `RustFaunaIntegration.run_all_players()`, which handles
|
||||
## the env-flag check, lair enumeration, and per-player bridge calls.
|
||||
## Kept as a method on `TurnProcessor` so `turn_manager.gd` can call it
|
||||
## through the same `proc.<method>()` pattern as the other turn phases.
|
||||
RustFaunaIntegrationScript.run_all_players()
|
||||
|
||||
|
||||
func _process_spell_system(_player: RefCounted) -> void: # Player
|
||||
## DISABLED: SpellSystem has no `overworld_queue` property on its current
|
||||
## stub; the first access aborts the method. Pending-summon spawning also
|
||||
## depends on a `unit_manager.create_unit` that does not exist. See the
|
||||
## top-of-file out-of-scope list. Revive once SpellSystem is rebuilt and
|
||||
## the unit-manager spawn helper ships.
|
||||
pass
|
||||
|
||||
|
||||
func _process_ascension(player: RefCounted) -> void: # Player
|
||||
if not player.ascension_active:
|
||||
return
|
||||
var ritual: AscensionRitualScript = (
|
||||
GameState.ascension_rituals.get(player.index, null) as AscensionRitualScript
|
||||
)
|
||||
if ritual == null:
|
||||
return
|
||||
var sys: SpellSystemScript = spell_system as SpellSystemScript
|
||||
ritual.tick(player, sys)
|
||||
|
||||
|
||||
func _form_high_archon(player: RefCounted) -> void:
|
||||
TurnProcessorHelpersScript.form_high_archon(player)
|
||||
|
||||
|
||||
func _process_government(_player: RefCounted) -> void: # Player
|
||||
## DISABLED: GovernmentScript is an empty stub with no `process_anarchy`.
|
||||
## See top-of-file out-of-scope list. Revive once government is rebuilt.
|
||||
## Original body: GovernmentScript.process_anarchy(player)
|
||||
pass
|
||||
|
||||
|
||||
func _process_climate(game_map: RefCounted) -> void: # GameMap
|
||||
## Order: marine_harvest → climate → weather → climate_effects.
|
||||
## * `marine_harvest` seeds ocean_dead_fraction consumed by climate.
|
||||
## * `climate` runs GdEcologyPhysics + GdClimatePhysics + ecological events
|
||||
## (restored by p0-31) and leaves the shared GdGridState populated.
|
||||
## * `weather` (p0-32) reads that grid to derive this turn's storm /
|
||||
## heat_wave / blizzard events via GdWeatherPhysics.
|
||||
## * `climate_effects` (p0-32) applies those events back onto tiles + units
|
||||
## via GdClimateEffectsPhysics.
|
||||
(marine_harvest as MarineHarvestScript).process_turn(game_map, GameState.players)
|
||||
(climate as ClimateScript).ocean_dead_fraction = (
|
||||
(marine_harvest as MarineHarvestScript).ocean_dead_fraction
|
||||
)
|
||||
(climate as ClimateScript).process_turn(game_map, GameState.turn_number, GameState.map_seed)
|
||||
(weather as WeatherScript).process_turn(game_map)
|
||||
(climate_effects as ClimateEffectsScript).process_turn(
|
||||
game_map, weather, GameState.players
|
||||
)
|
||||
|
||||
|
||||
func _process_loot_decay() -> void:
|
||||
## DISABLED: ItemSystemScript.process_loot_decay expects a different
|
||||
## GameMap type than the live RefCounted GameMap wrapper, so the call
|
||||
## raises a type-mismatch error. See top-of-file out-of-scope list.
|
||||
## Revive once ItemSystem is updated to the current GameMap signature.
|
||||
pass
|
||||
|
||||
|
||||
func _process_improvements(player: RefCounted) -> void: # Player
|
||||
TurnProcessorHelpersScript.process_improvements(player)
|
||||
|
||||
|
||||
static func _player_owns_resource(player: RefCounted, resource_id: String) -> bool:
|
||||
var gm: RefCounted = GameState.get_game_map()
|
||||
if gm == null:
|
||||
return false
|
||||
for city: Variant in player.cities:
|
||||
for tile_pos: Variant in city.get_owned_tiles():
|
||||
var tile: RefCounted = gm.get_tile(tile_pos as Vector2i)
|
||||
if tile != null and str(tile.get("resource_id")) == resource_id:
|
||||
return true
|
||||
return false
|
||||
|
|
@ -399,6 +399,69 @@ func _process_culture(player: RefCounted, game_map: RefCounted) -> void:
|
|||
EventBus.city_border_expanded.emit(c, claimed)
|
||||
|
||||
|
||||
func _process_culture_research(player: RefCounted) -> void: # Player
|
||||
## p2-43: per-turn cultural-tradition research accumulator. Mirrors
|
||||
## `_process_research` shape — assemble player JSON + per-turn culture,
|
||||
## delegate completion math to Rust `GdCultureWeb::process_culture_research`,
|
||||
## then apply side-effects (`player.add_tradition`, EventBus emit).
|
||||
if String(player.researching_tradition).is_empty():
|
||||
return
|
||||
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
if game_map == null:
|
||||
return
|
||||
|
||||
# Sum per-city culture yield (mirrors what process_culture_with_modifier
|
||||
# would consume) so the Rust accumulator gets the same per-turn pool the
|
||||
# border-expansion phase uses.
|
||||
var per_turn_culture: float = 0.0
|
||||
for city: Variant in player.cities:
|
||||
if not city is CityScript:
|
||||
continue
|
||||
var c: CityScript = city as CityScript
|
||||
var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map)
|
||||
var ys: Dictionary = c.get_yields(tile_json)
|
||||
var base: float = float(ys.get("culture", 0))
|
||||
var building_culture: float = float(_sum_city_building_effect(c, "culture"))
|
||||
var cult_pct: float = _sum_city_building_effect_float(c, "culture_percent")
|
||||
per_turn_culture += (base + building_culture) * (1.0 + cult_pct)
|
||||
|
||||
var difficulty_mult: float = GameState.get_effective_yield_mult(player, "culture")
|
||||
if player.golden_age_active:
|
||||
difficulty_mult *= 1.2
|
||||
|
||||
var researched_arr: Array = (
|
||||
Array(player.researched_traditions) if player.researched_traditions != null else []
|
||||
)
|
||||
var player_dict: Dictionary = {
|
||||
"researching": String(player.researching_tradition),
|
||||
"research_progress": int(player.culture_research_progress),
|
||||
"researched_traditions": researched_arr,
|
||||
"instant_complete": EnvConfig.get_bool("FORCE_UNLIMITED_RESEARCH"),
|
||||
}
|
||||
|
||||
var cw: RefCounted = TurnManager.get_culture_web()
|
||||
if cw == null:
|
||||
return
|
||||
var result: Dictionary = cw.process_research(
|
||||
JSON.stringify(player_dict), per_turn_culture, difficulty_mult
|
||||
)
|
||||
if result.is_empty():
|
||||
return
|
||||
var err: String = String(result.get("error", ""))
|
||||
if not err.is_empty():
|
||||
push_warning("p2-43 _process_culture_research: " + err)
|
||||
return
|
||||
|
||||
player.culture_research_progress = int(result.get("new_progress", 0))
|
||||
player.researching_tradition = String(result.get("new_researching", ""))
|
||||
|
||||
var completed: String = String(result.get("completed_tradition", ""))
|
||||
if not completed.is_empty():
|
||||
player.add_tradition(completed)
|
||||
EventBus.culture_researched.emit(completed, player.index)
|
||||
|
||||
|
||||
func _build_border_candidates_json(
|
||||
city: CityScript, game_map: RefCounted, player: RefCounted
|
||||
) -> String:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue