diff --git a/src/game/engine/src/modules/management/turn_processor.gd b/src/game/engine/src/modules/management/turn_processor.gd index 9b88b38e..bc940db2 100644 --- a/src/game/engine/src/modules/management/turn_processor.gd +++ b/src/game/engine/src/modules/management/turn_processor.gd @@ -50,16 +50,10 @@ func _process_production(player: RefCounted) -> void: # Player if game_map == null: return - # Per-player override applies to any player (including human in difficulty batches). - # Global ai_difficulty_modifier only applies to non-human players. - var prod_modifier: float = 1.0 - var per_player_prod: float = float( - GameState.ai_per_player_production_mult.get(player.index, 0.0) - ) - if per_player_prod > 0.0: - prod_modifier = per_player_prod - elif player is PlayerScript and not player.is_human: - prod_modifier = GameState.ai_difficulty_modifier + # 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 @@ -154,16 +148,10 @@ func _process_research(player: RefCounted) -> void: # Player if EnvConfig.get_bool("FORCE_UNLIMITED_RESEARCH"): player.research_progress = 999999 - # Per-player override applies to any player (including human in difficulty batches). - # Global ai_research_modifier only applies to non-human players. - var sci_modifier: float = 1.0 - var per_player_sci: float = float( - GameState.ai_per_player_research_mult.get(player.index, 0.0) - ) - if per_player_sci > 0.0: - sci_modifier = per_player_sci - elif player is PlayerScript and not player.is_human: - sci_modifier = GameState.ai_research_modifier + # Effective per-yield mult composes static difficulty handicap + + # linear-per-turn growth (warcouncil p1-29 H4, 2026-04-27). Per-player + # overrides for batch testing still take precedence inside the helper. + var sci_modifier: float = GameState.get_effective_yield_mult(player, "research") player.research_progress += int(player.science_per_turn * sci_modifier) diff --git a/turn_processor.gd b/turn_processor.gd new file mode 100644 index 00000000..bc940db2 --- /dev/null +++ b/turn_processor.gd @@ -0,0 +1,483 @@ +# 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 + if player.researching.is_empty(): + return + + # Debug: instantly complete any queued research/spell. + if EnvConfig.get_bool("FORCE_UNLIMITED_RESEARCH"): + player.research_progress = 999999 + + # Effective per-yield mult composes static difficulty handicap + + # linear-per-turn growth (warcouncil p1-29 H4, 2026-04-27). Per-player + # overrides for batch testing still take precedence inside the helper. + var sci_modifier: float = GameState.get_effective_yield_mult(player, "research") + + player.research_progress += int(player.science_per_turn * sci_modifier) + + # Add science from cities + var game_map: RefCounted = GameState.get_game_map() # GameMap + if game_map != null: + for city: Variant in player.cities: + if city is CityScript: + var tile_json: String = BuildableHelperScript.build_tile_yields_json( + city as CityScript, game_map + ) + var yields: Dictionary = city.get_yields(tile_json) + var building_sci: int = _sum_city_building_effect(city as CityScript, "science") + var sci_pct: float = _sum_city_building_effect_float( + city as CityScript, "science_percent" + ) + player.research_progress += int( + (yields.get("science", 0) + building_sci) * (1.0 + sci_pct) * sci_modifier + ) + + # Check if researching a spell (not a tech) + var spell_data: Dictionary = DataLoader.get_spell(player.researching) + if not spell_data.is_empty(): + var spell_cost: int = spell_data.get("research_cost", 999999) + if player.research_progress >= spell_cost: + var completed_spell: String = player.researching + player.research_progress = 0 + player.researching = "" + var sys: SpellSystemScript = spell_system as SpellSystemScript + sys.research_spell(player.index, completed_spell) + return + + var tech_data: Dictionary = DataLoader.get_tech(player.researching) + var tech_cost: int = tech_data.get("cost", 999999) + + if player.research_progress >= tech_cost: + var completed_tech: String = player.researching + player.research_progress = 0 + player.researching = "" + 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 _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 + + # Very unhappy (< -10): no growth at all. Unhappy (< 0): -50% food surplus. + 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) + 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) + var pre_culture: float = c.get_culture_stored() + var can_expand: bool = c.process_culture(tile_json) + # Apply culture_percent and border_growth_percent bonuses to the + # culture gained this turn. process_culture already added raw culture; + # we top up the stockpile by (raw_gain * total_pct) so wonders scale. + 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 total_pct: float = cult_pct + border_pct + if total_pct > 0.0: + var post_culture: float = c.get_culture_stored() + var gained: float = post_culture - pre_culture + if gained > 0.0: + c.set_culture_stored(post_culture + gained * total_pct) + can_expand = c.get_can_expand() + 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