diff --git a/.project/handoffs/20260504_shipwright-to-shipwright.md b/.project/handoffs/20260504_shipwright-to-shipwright.md new file mode 100644 index 00000000..da4ebbd6 --- /dev/null +++ b/.project/handoffs/20260504_shipwright-to-shipwright.md @@ -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. diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json index 09124a55..d0695d6a 100644 --- a/.project/objectives/objectives.json +++ b/.project/objectives/objectives.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-05-04T11:32:54Z", + "generated_at": "2026-05-04T17:08:38Z", "totals": { "done": 154, "in_progress": 1, diff --git a/.project/objectives/p0-45-turn-processor-consolidation-regression.md b/.project/objectives/p0-45-turn-processor-consolidation-regression.md new file mode 100644 index 00000000..2195779e --- /dev/null +++ b/.project/objectives/p0-45-turn-processor-consolidation-regression.md @@ -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`. diff --git a/.project/objectives/p1-38-biome-economy-coupling.md b/.project/objectives/p1-38-biome-economy-coupling.md index dfb5f8a4..4eb34215 100644 --- a/.project/objectives/p1-38-biome-economy-coupling.md +++ b/.project/objectives/p1-38-biome-economy-coupling.md @@ -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. + diff --git a/src/game/engine/src/entities/turn_processor.gd b/src/game/engine/src/entities/turn_processor.gd deleted file mode 100644 index 9e9bbd71..00000000 --- a/src/game/engine/src/entities/turn_processor.gd +++ /dev/null @@ -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 != "": - 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.()` 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 diff --git a/src/game/engine/src/modules/management/turn_processor.gd b/src/game/engine/src/modules/management/turn_processor.gd index bb89fe36..96d7f338 100644 --- a/src/game/engine/src/modules/management/turn_processor.gd +++ b/src/game/engine/src/modules/management/turn_processor.gd @@ -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: