- City bombard: melee attackers take 5-30 damage based on city population and castle bombard bonus (city_str = pop*3 + castle bonus) - Building HP bonuses: when walls/castle complete, increase city max_hp and heal by hp_bonus value from building data - Castle data: added city_bombard_strength: 12, city_bombard_range: 2 Combined with prior commit's city healing (20 HP/turn) and tiered wall penalties (walls=0.75x, castle=0.60x), cities now require sustained multi-turn sieges instead of 1-2 turn captures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
468 lines
19 KiB
GDScript
468 lines
19 KiB
GDScript
# 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.
|
|
## Arena task #2 restored the four visible methods (production, growth,
|
|
## healing, economy). Still known-broken and out of scope: _process_culture,
|
|
## _process_golden_age, _process_loot_decay, _process_spell_system,
|
|
## _process_government — all blocked on empty module stubs.
|
|
## Calls disabled in turn_manager.gd::next_player (Diplomacy.process_turn,
|
|
## EconomyScript.apply_protection_effects) and turn_processor.gd::_process_*
|
|
## until these modules are rebuilt. The `_process_climate` sub-calls into
|
|
## WeatherScript, ClimateEffectsScript, and ClimateScript.process_turn are
|
|
## also disabled — marine_harvest and ecosystem still run. ClimateScript has
|
|
## real-code bugs (int-cast failure + ecological_events arg-count mismatch)
|
|
## that need a proper fix in a follow-up task. Grep for `DISABLED:` to find
|
|
## every 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 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 EcosystemScript: GDScript = preload(
|
|
"res://engine/src/modules/ecology/ecosystem.gd"
|
|
)
|
|
const RustFaunaIntegrationScript: GDScript = preload(
|
|
"res://engine/src/modules/management/rust_fauna_integration.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()
|
|
var ecosystem: RefCounted # EcosystemOrchestrator — set by TurnManager._ready()
|
|
var ecology_db: RefCounted # EcologyDB — 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
|
|
|
|
# Apply difficulty modifier for AI players
|
|
var prod_modifier: float = 1.0
|
|
if player is PlayerScript and not player.is_human:
|
|
prod_modifier = GameState.ai_difficulty_modifier
|
|
|
|
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)
|
|
var prod: int = int(yields.get("production", 1) * 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 {}
|
|
)
|
|
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:
|
|
EventBus.city_unit_completed.emit(city_ref, unit)
|
|
elif item_type == "building":
|
|
c.add_building(item_id)
|
|
_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
|
|
|
|
# Apply difficulty modifier for AI players
|
|
var sci_modifier: float = 1.0
|
|
if player is PlayerScript and not player.is_human:
|
|
sci_modifier = GameState.ai_difficulty_modifier
|
|
|
|
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 yields: Dictionary = city.get_yields(game_map)
|
|
player.research_progress += int(yields.get("science", 0) * 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:
|
|
var all_resources: Array = DataLoader.get_all_resources()
|
|
for res: Dictionary in all_resources:
|
|
if res.get("revealed_by_tech", "") == completed_tech:
|
|
EventBus.resources_revealed.emit(completed_tech, player_index)
|
|
return
|
|
|
|
|
|
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
|
|
|
|
# Note: negative-happiness growth halt is part of the HappinessScript
|
|
# stub rewrite (out of scope). Arena play has no happiness system yet,
|
|
# so all cities grow every turn by default.
|
|
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)
|
|
c.process_growth(tile_json)
|
|
|
|
|
|
func _apply_building_bonuses(city: CityScript, building_id: String) -> void:
|
|
var bdata: Dictionary = DataLoader.get_building(building_id)
|
|
var effects: Array = bdata.get("effects", [])
|
|
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)
|
|
|
|
|
|
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()
|
|
|
|
|
|
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:
|
|
var tile: Resource = game_map.get_tile(unit.position) as Resource
|
|
if tile == null:
|
|
return 10
|
|
|
|
# In-city healing: per-building healing modifiers live in the
|
|
# BuildingScript stub rewrite (out of scope). Use a flat base heal for
|
|
# arena play.
|
|
for city_ref: RefCounted in player.cities:
|
|
if city_ref is CityScript and (city_ref as CityScript).position == unit.position:
|
|
return 20
|
|
|
|
# Territory-based healing
|
|
if tile.owner == player.index:
|
|
return 15
|
|
elif tile.owner == -1:
|
|
return 10
|
|
else:
|
|
return 5
|
|
|
|
|
|
func _process_mana(player: RefCounted, game_map: RefCounted = null) -> void: # Player
|
|
## Recalculate mana_income from city yields (terrain + buildings), then add to pool.
|
|
var new_income: Dictionary = {}
|
|
|
|
if game_map != null:
|
|
for city_ref: RefCounted in player.cities:
|
|
if not city_ref is CityScript:
|
|
continue
|
|
# Task #4: get_yields() takes a tile_yields_json string built via
|
|
# BuildableHelperScript, same pattern as _process_production /
|
|
# _process_economy. Passing the GameMap directly raised
|
|
# "Cannot convert argument 1 from Object to String".
|
|
var tile_yields_json: String = BuildableHelperScript.build_tile_yields_json(
|
|
city_ref, game_map
|
|
)
|
|
var city_yields: Dictionary = city_ref.get_yields(tile_yields_json)
|
|
var mana_entry: Dictionary = city_yields.get("mana", {}) as Dictionary
|
|
for school: String in mana_entry:
|
|
new_income[school] = (new_income.get(school, 0.0) + float(mana_entry[school]))
|
|
|
|
player.mana_income = new_income
|
|
|
|
if player.mana_income.is_empty():
|
|
return
|
|
|
|
for school: String in player.mana_income:
|
|
var income: int = roundi(player.mana_income[school])
|
|
var current: int = player.mana_pool.get(school, 0)
|
|
player.mana_pool[school] = mini(current + income, player.mana_cap)
|
|
|
|
EventBus.mana_changed.emit(player.index, player.mana_pool)
|
|
|
|
|
|
func _process_economy(player: RefCounted, game_map: RefCounted) -> void: # Player, GameMap
|
|
## Minimal screensaver economy: sum gold yields across a player's cities
|
|
## and credit the treasury. The full EconomyScript rewrite (upkeep,
|
|
## deficit disbanding, trade routes) is out of scope for arena task #2.
|
|
if game_map == null:
|
|
return
|
|
var income: int = 0
|
|
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)
|
|
income += int(yields.get("gold", 0))
|
|
player.gold_per_turn = income
|
|
player.gold += income
|
|
|
|
|
|
func _process_culture(_player: RefCounted, _game_map: RefCounted) -> void: # Player, GameMap
|
|
## DISABLED: CultureScript is an empty stub; both `process_turn` and
|
|
## `process_global_culture` raise nonexistent-function errors. See the
|
|
## top-of-file out-of-scope list. Revive once the culture module is rebuilt.
|
|
## Original body:
|
|
## player.culture_per_turn = 0
|
|
## for city in player.cities:
|
|
## if city is CityScript:
|
|
## CultureScript.process_turn(city, game_map, player)
|
|
## CultureScript.process_global_culture(player)
|
|
pass
|
|
|
|
|
|
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:
|
|
## Called when Arcane Lore completes. Transforms the leader into an Archon.
|
|
var ArchonScript: GDScript = preload("res://engine/src/entities/archon.gd")
|
|
var capital: CityScript = null
|
|
for city: RefCounted in player.cities:
|
|
if city is CityScript and (city as CityScript).is_capital:
|
|
capital = city as CityScript
|
|
break
|
|
if capital == null:
|
|
push_warning("TurnProcessor: no capital for player %d" % player.index)
|
|
return
|
|
var is_female: bool = player.gender_preset == "female"
|
|
var archon: RefCounted = ArchonScript.make_high_archon(
|
|
player.index, capital.position, player.player_name, is_female
|
|
)
|
|
capital.set("archon", archon)
|
|
EventBus.archon_created.emit(archon, capital)
|
|
|
|
|
|
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 → weather → climate → climate_effects → ecosystem.
|
|
## DISABLED: WeatherScript and ClimateEffectsScript are empty stubs; their
|
|
## process_turn calls abort next_player and kill the arena turn loop.
|
|
## DISABLED: ClimateScript.process_turn currently raises "Invalid cast to
|
|
## int" inside _sync_tiles_to_grid/_sync_grid_to_tiles and an arg-count
|
|
## mismatch inside ecological_events.process_events (process_drought/
|
|
## process_wildfire/process_marine all expect 8-9 args, fewer are passed).
|
|
## These aborts propagate up to next_player and kill the arena turn loop.
|
|
## See top-of-file out-of-scope list. Revive once climate.gd cast handling
|
|
## and ecological_events signatures are fixed.
|
|
(marine_harvest as MarineHarvestScript).process_turn(game_map, GameState.players)
|
|
# (climate as ClimateScript).ocean_dead_fraction = (
|
|
# (marine_harvest as MarineHarvestScript).ocean_dead_fraction
|
|
# )
|
|
# (weather as WeatherScript).process_turn(game_map)
|
|
# (climate as ClimateScript).process_turn(
|
|
# game_map, GameState.turn_number, GameState.map_seed
|
|
# )
|
|
# (climate_effects as ClimateEffectsScript).process_turn(
|
|
# game_map, weather, GameState.players
|
|
# )
|
|
# Step 5: Ecosystem — flora dynamics, fauna dynamics, quality recomputation
|
|
if ecosystem != null and ecology_db != null:
|
|
(ecosystem as EcosystemScript).process_turn(
|
|
game_map, ecology_db, GameState.map_seed + GameState.turn_number
|
|
)
|
|
|
|
|
|
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
|
|
## Tick pending tile improvements (Engineer build progress).
|
|
if player.pending_improvements.is_empty():
|
|
return
|
|
|
|
var completed_indices: Array[int] = []
|
|
for i: int in range(player.pending_improvements.size()):
|
|
var imp: Variant = player.pending_improvements[i]
|
|
if imp is Dictionary:
|
|
imp["turns_remaining"] = imp.get("turns_remaining", 1) - 1
|
|
if imp["turns_remaining"] <= 0:
|
|
completed_indices.append(i)
|
|
var tile_pos: Vector2i = Vector2i(imp.get("x", 0), imp.get("y", 0))
|
|
EventBus.improvement_completed.emit(tile_pos, imp.get("type", ""))
|
|
|
|
# Remove completed in reverse order to preserve indices
|
|
for i: int in range(completed_indices.size() - 1, -1, -1):
|
|
player.pending_improvements.remove_at(completed_indices[i])
|