From 0b76874079e9facb9589ad9a5683c524ff342066 Mon Sep 17 00:00:00 2001 From: autocommit Date: Mon, 27 Apr 2026 01:16:56 -0700 Subject: [PATCH] =?UTF-8?q?feat(game-engine):=20=E2=9C=A8=20Implement=20di?= =?UTF-8?q?fficulty=20scaling=20logic=20in=20GameState=20with=20methods=20?= =?UTF-8?q?for=20setting,=20adjusting,=20and=20querying=20difficulty=20lev?= =?UTF-8?q?els?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- game_state.gd | 585 ++++++++++++++++++++ src/game/engine/src/autoloads/game_state.gd | 89 +++ 2 files changed, 674 insertions(+) create mode 100644 game_state.gd diff --git a/game_state.gd b/game_state.gd new file mode 100644 index 00000000..25b95adf --- /dev/null +++ b/game_state.gd @@ -0,0 +1,585 @@ +extends Node +## Holds all runtime game state. Persisted on save/load. +## Supports multiple map layers (primary + transit layers like Ethereal Plane). + +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd") +const BuildingScript: GDScript = preload("res://engine/src/entities/building.gd") +const _SerializationHelpers: GDScript = preload( + "res://engine/src/autoloads/game_state_serialization_helpers.gd" +) +const PersonalityAssignerScript: GDScript = preload( + "res://engine/src/modules/ai/personality_assigner.gd" +) + +const DEFAULT_SETTINGS: Dictionary = { + "map_size": "small", + "map_type": "continents", + "map_wrap": "sphere", + "difficulty": "normal", + "game_speed": "standard", + "num_players": 4, + "turn_limit": 150, + "mana_density": "normal", + "era_difficulty_correlation": true, +} + +## Player colors for up to 12 players +const PLAYER_COLORS: Array[Color] = [ + Color(0.2, 0.4, 1.0), # Blue + Color(0.9, 0.2, 0.2), # Red + Color(0.2, 0.8, 0.3), # Green + Color(0.9, 0.8, 0.1), # Yellow + Color(0.7, 0.3, 0.9), # Purple + Color(0.9, 0.5, 0.1), # Orange + Color(0.1, 0.8, 0.8), # Cyan + Color(0.8, 0.3, 0.5), # Pink + Color(0.5, 0.4, 0.3), # Brown + Color(0.6, 0.6, 0.6), # Gray + Color(0.4, 0.7, 0.4), # Olive + Color(0.3, 0.3, 0.6), # Navy +] + +## Era definitions loaded from the game pack's eras.json via DataLoader. +## The engine defines no era names — all era content is game-pack-driven. +var era_data: Array = [] + +var current_theme: String = "fantasy" +var layers: Array = [] +var players: Array = [] # Array of Player +var current_player_index: int = 0 +var turn_number: int = 1 +var era: int = 0 +var game_settings: Dictionary = {} +var transit_nodes: Array = [] + +## Victory/defeat Replay button bridge to game_setup. Cleared after consume. +var replay_settings: Dictionary = {} + +## Tracks wonders built: {wonder_id -> player_index} +var wonders_built: Dictionary = {} + +## Per-player Ascension Ritual instances: {player_index -> AscensionRitual} +var ascension_rituals: Dictionary = {} + +## Shared SpellSystem instance (singleton-style, managed here) +var spell_system: RefCounted = null + +## Random seed used to generate this map. Stored so climate and other systems +## can derive per-turn deterministic seeds from it. +var map_seed: int = 0 + +## Central RNG for all GDScript gameplay randomness. Serialized so that loading +## a save reproduces the same random trajectory as the original run. +var game_rng: RandomNumberGenerator = RandomNumberGenerator.new() + +## Difficulty modifier applied to AI production each turn. +## 1.0 = even, <1.0 = AI penalty, >1.0 = AI bonus. +var ai_difficulty_modifier: float = 1.0 + +## Difficulty modifier applied to AI research (science) each turn. +## Separate from production so Easy can penalise production more than research. +var ai_research_modifier: float = 1.0 + +## Per-yield difficulty multipliers (warcouncil p1-29 H4, 2026-04-27). +## "AI still has to acquire resources but gets more for the effort." +## All default 1.0 (Normal baseline). +var ai_gold_modifier: float = 1.0 +var ai_culture_modifier: float = 1.0 +var ai_luxury_modifier: float = 1.0 +## Symmetric player handicap — Easy mode mirrors Hard's AI bonuses onto the +## human player ("you get the bonuses the AI would have on Hard"). All default 1.0. +var player_production_modifier: float = 1.0 +var player_research_modifier: float = 1.0 +var player_gold_modifier: float = 1.0 +var player_culture_modifier: float = 1.0 +var player_luxury_modifier: float = 1.0 +## Linear yield growth per turn — added to the static multiplier per turn. +## Hard=0.003 → at T138 effective_mult ≈ static_mult + 0.414 (so 1.30 base → 1.71 at T138). +## Lets AI scale into mid/late game without overpowering early-game. +var ai_yield_per_turn_growth: float = 0.0 +var player_yield_per_turn_growth: float = 0.0 + +## Gold added to every AI player at game start for the current difficulty tier. +var ai_starting_gold_bonus: int = 0 + +## Extra warrior-class units spawned per AI city at game start. +var ai_extra_starting_units: int = 0 + +## ID of the extra starting unit (e.g. "warrior"). +var ai_extra_unit_id: String = "warrior" + +## Per-player production multiplier override. Key=player_index (int), value=float. +## When non-empty, player-specific value takes precedence over ai_difficulty_modifier. +## Populated by auto_play.gd when AI_DIFFICULTY_P0/P1 env vars are set. +var ai_per_player_production_mult: Dictionary = {} + +## Per-player research multiplier override. Same semantics as ai_per_player_production_mult. +var ai_per_player_research_mult: Dictionary = {} + +## Diplomatic relations between players. +## Key: "min_idx_max_idx", value: "neutral" | "war" | "peace" | "alliance". +var diplomacy: Dictionary = {} + +## Ley line anchor registry. Each entry: {position, strength, school, source, owner} +## position: Vector2i, strength: int 1-5, school: String ("" = neutral), +## source: String ("wellspring"|"mountain"|"wonder"|"terrain"), owner: int (-1 = world) +var ley_anchors: Array = [] + +## Dynamic ley resonance/disruption edges between wonder anchors. +## Rebuilt each climate turn by LeyNetwork.build_network(). +## Array of LeyNetwork.LeyEdge objects — used by renderer for visualization. +var ley_edges: Array = [] + +## NPC buildings on the world map (lairs, villages, ruins). Array of Building. +var npc_buildings: Array = [] +## Spatial index: "col,row" -> Array[Building] for quick tile lookups. +var _npc_buildings_by_tile: Dictionary = {} + + +func initialize_game(settings: Dictionary) -> void: + game_settings = DEFAULT_SETTINGS.duplicate() + for key: String in settings: + game_settings[key] = settings[key] + + turn_number = 1 + era = 0 + var eras_raw: Dictionary = DataLoader.get_data("eras") as Dictionary + era_data = eras_raw.values() if eras_raw != null else [] + era_data.sort_custom( + func(a: Dictionary, b: Dictionary) -> bool: return a.get("id", "") < b.get("id", "") + ) + current_player_index = 0 + players = [] + layers = [] + transit_nodes = [] + ley_anchors = [] + ley_edges = [] + diplomacy = {} + npc_buildings = [] + _npc_buildings_by_tile = {} + + var settings_seed: int = int(game_settings.get("seed", 0)) + if settings_seed != 0: + map_seed = settings_seed + game_rng = RandomNumberGenerator.new() + game_rng.seed = map_seed if map_seed != 0 else hash(Time.get_unix_time_from_system()) + seed(game_rng.seed) + + # Create primary map layer (index 0) + ( + layers + . append( + { + "id": "primary", + "map": null, + "fog": null, + "units": [], + "settlements": [], + } + ) + ) + + +func get_current_player() -> RefCounted: # Returns Player + return get_player(current_player_index) + + +func get_player(index: int) -> RefCounted: # Returns Player + if index < 0 or index >= players.size(): + push_warning("GameState: Invalid player index %d" % index) + return null + var p: Variant = players[index] + if p is PlayerScript: + return p + return null + + +func add_player(player: RefCounted) -> int: # Expects Player + player.index = players.size() + player.color = _player_color_for_index(player.index) + players.append(player) + return player.index + + +func _player_color_for_index(idx: int) -> Color: + ## Route through ThemeAssets so the active palette variant (default or + ## colorblind-safe) wins over the built-in PLAYER_COLORS fallback. + var tree: SceneTree = Engine.get_main_loop() as SceneTree + if tree != null and tree.root != null and tree.root.has_node("ThemeAssets"): + if ThemeAssets.has_palette_color(idx): + return ThemeAssets.get_player_color(idx) + if idx >= 0 and idx < PLAYER_COLORS.size(): + return PLAYER_COLORS[idx] + return Color(0.6, 0.6, 0.6) + + +func create_player( + player_name: String, + race_id: String, + is_human: bool = true, +) -> RefCounted: # Returns Player + ## Create and register a new Player with default state. + var player: RefCounted = PlayerScript.new() + player.player_name = player_name + player.race_id = race_id + player.is_human = is_human + var race_data: Dictionary = DataLoader.get_race(race_id) + var tier: String = str(race_data.get("growth_tier", "")) + if tier != "": + player.growth_tier = tier + add_player(player) + PersonalityAssignerScript.assign(player, game_rng) + return player + + +func get_era_name() -> String: + if era >= 0 and era < era_data.size(): + return era_data[era].get("name", "unknown") + return "unknown" + + +func get_era_count() -> int: + return era_data.size() + + +func apply_ai_difficulty() -> void: + ## Read game_settings["difficulty"] (id) and populate all ai_difficulty_* fields + ## from difficulty.json ai_modifiers. Called after setup finishes. + var diff_id: String = str(game_settings.get("difficulty", "normal")) + var diff_data: Dictionary = DataLoader.get_data("difficulty") as Dictionary + if diff_data == null or diff_data.is_empty(): + return + var entry: Dictionary = diff_data.get(diff_id, {}) as Dictionary + if entry.is_empty(): + return + var mods: Dictionary = entry.get("ai_modifiers", {}) as Dictionary + if mods.is_empty(): + return + ai_difficulty_modifier = float(mods.get("production_mult", 1.0)) + ai_research_modifier = float(mods.get("research_mult", 1.0)) + ai_gold_modifier = float(mods.get("gold_mult", 1.0)) + ai_culture_modifier = float(mods.get("culture_mult", 1.0)) + ai_luxury_modifier = float(mods.get("luxury_mult", 1.0)) + ai_yield_per_turn_growth = float(mods.get("yield_per_turn_growth", 0.0)) + ai_starting_gold_bonus = int(mods.get("starting_gold_bonus", 0)) + ai_extra_starting_units = int(mods.get("extra_starting_units", 0)) + ai_extra_unit_id = str(mods.get("extra_unit_id", "warrior")) + # Player handicap — symmetric inverse (Easy: player gets Hard-AI's bonuses). + var pmods: Dictionary = entry.get("player_modifiers", {}) as Dictionary + player_production_modifier = float(pmods.get("production_mult", 1.0)) + player_research_modifier = float(pmods.get("research_mult", 1.0)) + player_gold_modifier = float(pmods.get("gold_mult", 1.0)) + player_culture_modifier = float(pmods.get("culture_mult", 1.0)) + player_luxury_modifier = float(pmods.get("luxury_mult", 1.0)) + player_yield_per_turn_growth = float(pmods.get("yield_per_turn_growth", 0.0)) + print( + "GameState: difficulty=%s prod=%.2f research=%.2f gold_bonus=%d extra_units=%d" + % [ + diff_id, + ai_difficulty_modifier, + ai_research_modifier, + ai_starting_gold_bonus, + ai_extra_starting_units + ] + ) + + +## Effective per-yield multiplier for a player on the current turn. +## Composes the static difficulty mult with the linear per-turn growth. +## yield_type: "production" | "research" | "gold" | "culture" | "luxury". +## Returns 1.0 for unknown yield types or null player. +## +## Per-player overrides (auto_play.gd batch testing) take precedence over the +## difficulty-derived value for production/research only — gold/culture/luxury +## use the global side (ai_X_modifier vs player_X_modifier). +func get_effective_yield_mult(player: RefCounted, yield_type: String) -> float: + if player == null: + return 1.0 + var idx: int = -1 + if player.get("index") != null: + idx = int(player.index) + var is_human: bool = bool(player.get("is_human") if player.get("is_human") != null else false) + # Per-player override path (production / research only — set by auto_play.gd + # AI_DIFFICULTY_P0/P1 batch testing). + if not is_human: + if yield_type == "production": + var per_p: float = float(ai_per_player_production_mult.get(idx, 0.0)) + if per_p > 0.0: + return per_p + float(turn_number) * ai_yield_per_turn_growth + elif yield_type == "research": + var per_r: float = float(ai_per_player_research_mult.get(idx, 0.0)) + if per_r > 0.0: + return per_r + float(turn_number) * ai_yield_per_turn_growth + # Side-resolution: AI gets ai_X_modifier, human gets player_X_modifier. + var base: float + var growth: float + if is_human: + base = _player_yield_mult_for(yield_type) + growth = player_yield_per_turn_growth + else: + base = _ai_yield_mult_for(yield_type) + growth = ai_yield_per_turn_growth + return base + float(turn_number) * growth + + +func _ai_yield_mult_for(yield_type: String) -> float: + match yield_type: + "production": return ai_difficulty_modifier + "research": return ai_research_modifier + "gold": return ai_gold_modifier + "culture": return ai_culture_modifier + "luxury": return ai_luxury_modifier + _: return 1.0 + + +func _player_yield_mult_for(yield_type: String) -> float: + match yield_type: + "production": return player_production_modifier + "research": return player_research_modifier + "gold": return player_gold_modifier + "culture": return player_culture_modifier + "luxury": return player_luxury_modifier + _: return 1.0 + + +func get_max_event_tier() -> int: + ## Returns the max event tier allowed in the current era. + ## When era_difficulty_correlation is disabled, returns 10 (uncapped). + if not game_settings.get("era_difficulty_correlation", true): + return 10 + if era >= 0 and era < era_data.size(): + return era_data[era].get("max_event_tier", 10) + return 10 + + +func advance_era() -> void: + if era < era_data.size() - 1: + era += 1 + EventBus.era_changed.emit(era, current_player_index) + + +func get_primary_layer() -> Dictionary: + if layers.is_empty(): + return {} + return layers[0] + + +func get_layer(index: int) -> Dictionary: + if index < 0 or index >= layers.size(): + push_warning("GameState: Invalid layer index %d" % index) + return {} + return layers[index] + + +func get_game_map() -> RefCounted: # Returns GameMap + ## Convenience accessor for the primary layer's GameMap. + var primary: Dictionary = get_primary_layer() + if primary.is_empty(): + return null + var map_ref: Variant = primary.get("map") + if map_ref is GameMapScript: + return map_ref + return null + + +## -- NPC building management -- + + +func add_npc_building(building: RefCounted) -> void: + npc_buildings.append(building) + var key: String = "%d,%d" % [building.position.x, building.position.y] + if not _npc_buildings_by_tile.has(key): + _npc_buildings_by_tile[key] = [] + _npc_buildings_by_tile[key].append(building) + + +func remove_npc_building(building: RefCounted) -> void: + npc_buildings.erase(building) + var key: String = "%d,%d" % [building.position.x, building.position.y] + if _npc_buildings_by_tile.has(key): + _npc_buildings_by_tile[key].erase(building) + + +func get_npc_buildings_at(pos: Vector2i) -> Array: + var key: String = "%d,%d" % [pos.x, pos.y] + return _npc_buildings_by_tile.get(key, []) + + +func get_npc_building_at(pos: Vector2i, type_filter: String = "") -> Variant: + ## Returns the first NPC building at pos, optionally filtered by type_id. Null if none. + var buildings: Array = get_npc_buildings_at(pos) + for b: Variant in buildings: + if type_filter == "" or b.type_id == type_filter: + return b + return null + + +func get_all_npc_buildings_of_type(type_id: String) -> Array: + var result: Array = [] + for b: Variant in npc_buildings: + if b.type_id == type_id: + result.append(b) + return result + + +func serialize() -> Dictionary: + var data: Dictionary = { + "current_theme": current_theme, + "turn_number": turn_number, + "era": era, + "current_player_index": current_player_index, + "game_settings": game_settings, + "wonders_built": wonders_built.duplicate(), + "rng_seed": game_rng.seed, + "rng_state": game_rng.state, + "map_seed": map_seed, + "players": [], + "layers": [], + "transit_nodes": transit_nodes, + "ley_anchors": _serialize_ley_anchors(), + "npc_buildings": _serialize_npc_buildings(), + } + + # Serialize ascension rituals: {player_index_str -> ritual_dict} + var rituals_data: Dictionary = {} + for pi: Variant in ascension_rituals: + var ritual: Variant = ascension_rituals[pi] + if ritual != null and ritual.has_method("serialize"): + rituals_data[str(pi)] = ritual.serialize() + data["ascension_rituals"] = rituals_data + + for player: Variant in players: + if player is PlayerScript: + data["players"].append(player.serialize()) + + for layer: Dictionary in layers: + data["layers"].append(_serialize_layer(layer)) + + return data + + +func deserialize(data: Dictionary) -> void: + current_theme = data.get("current_theme", "fantasy") + turn_number = data.get("turn_number", 1) + era = data.get("era", 0) + current_player_index = data.get("current_player_index", 0) + game_settings = data.get("game_settings", DEFAULT_SETTINGS.duplicate()) + transit_nodes = data.get("transit_nodes", []) + wonders_built = data.get("wonders_built", {}).duplicate() + map_seed = data.get("map_seed", 0) as int + + game_rng = RandomNumberGenerator.new() + var saved_rng_seed: int = data.get("rng_seed", 0) as int + var saved_rng_state: int = data.get("rng_state", 0) as int + if saved_rng_seed != 0: + game_rng.seed = saved_rng_seed + if saved_rng_state != 0: + game_rng.state = saved_rng_state + else: + game_rng.seed = map_seed if map_seed != 0 else hash(turn_number) + seed(game_rng.seed) + + _deserialize_ley_anchors(data.get("ley_anchors", [])) + _deserialize_npc_buildings(data.get("npc_buildings", [])) + + # Deserialize ascension rituals + var AscensionRitualScript: GDScript = preload( + "res://engine/src/modules/victory/ascension_ritual.gd" + ) + ascension_rituals = {} + var rituals_raw: Variant = data.get("ascension_rituals", {}) + if rituals_raw is Dictionary: + for key: Variant in rituals_raw: + var ritual_data: Variant = rituals_raw[key] + if ritual_data is Dictionary: + var ritual: RefCounted = AscensionRitualScript.new() + ritual.deserialize(ritual_data) + ascension_rituals[int(str(key))] = ritual + + players = [] + for player_data: Variant in data.get("players", []): + if player_data is Dictionary: + var player: RefCounted = PlayerScript.new() + player.deserialize(player_data) + players.append(player) + + layers = [] + for layer_data: Variant in data.get("layers", []): + if layer_data is Dictionary: + layers.append(_deserialize_layer(layer_data)) + + +func _serialize_layer(layer: Dictionary) -> Dictionary: + return _SerializationHelpers.serialize_layer(layer) + + +func _deserialize_layer(layer_data: Dictionary) -> Dictionary: + return _SerializationHelpers.deserialize_layer(layer_data) + + +## ------------------------------------------------------------------ +## Magic system helpers +## ------------------------------------------------------------------ + + +func get_player_wonders(player_index: int) -> Array: + ## Return list of wonder IDs owned by this player. + var result: Array = [] + for wonder_id: String in wonders_built: + if wonders_built[wonder_id] == player_index: + result.append(wonder_id) + return result + + +func wonder_exists(wonder_id: String) -> bool: + return wonders_built.has(wonder_id) + + +func build_wonder(wonder_id: String, player_index: int) -> bool: + ## First-to-build mechanic. Returns false if already built by anyone. + if wonders_built.has(wonder_id): + return false + wonders_built[wonder_id] = player_index + return true + + +func get_player_era(_player_index: int) -> int: + ## Return the current era (global). Era is shared — not per-player. + return era + 1 # era 0-indexed internally; design spec uses 1-indexed + + +func _serialize_ley_anchors() -> Array: + return _SerializationHelpers.serialize_ley_anchors(ley_anchors) + + +func _deserialize_ley_anchors(raw: Array) -> void: + ley_anchors = _SerializationHelpers.deserialize_ley_anchors(raw) + + +func _serialize_npc_buildings() -> Array: + return _SerializationHelpers.serialize_npc_buildings(npc_buildings) + + +func _deserialize_npc_buildings(raw: Array) -> void: + npc_buildings = [] + _npc_buildings_by_tile = {} + for entry: Variant in raw: + if entry is Dictionary: + var b: RefCounted = BuildingScript.from_dict(entry) + add_npc_building(b) + + +func rebuild_layer_references() -> void: + ## After deserialization, rebuild the primary layer's unit list + ## from all player unit arrays. + var primary: Dictionary = get_primary_layer() + if primary.is_empty(): + return + + var all_units: Array = [] + for player: Variant in players: + if player is PlayerScript: + for unit: Variant in player.units: + all_units.append(unit) + primary["units"] = all_units diff --git a/src/game/engine/src/autoloads/game_state.gd b/src/game/engine/src/autoloads/game_state.gd index d720eedf..25b95adf 100644 --- a/src/game/engine/src/autoloads/game_state.gd +++ b/src/game/engine/src/autoloads/game_state.gd @@ -81,6 +81,25 @@ var ai_difficulty_modifier: float = 1.0 ## Separate from production so Easy can penalise production more than research. var ai_research_modifier: float = 1.0 +## Per-yield difficulty multipliers (warcouncil p1-29 H4, 2026-04-27). +## "AI still has to acquire resources but gets more for the effort." +## All default 1.0 (Normal baseline). +var ai_gold_modifier: float = 1.0 +var ai_culture_modifier: float = 1.0 +var ai_luxury_modifier: float = 1.0 +## Symmetric player handicap — Easy mode mirrors Hard's AI bonuses onto the +## human player ("you get the bonuses the AI would have on Hard"). All default 1.0. +var player_production_modifier: float = 1.0 +var player_research_modifier: float = 1.0 +var player_gold_modifier: float = 1.0 +var player_culture_modifier: float = 1.0 +var player_luxury_modifier: float = 1.0 +## Linear yield growth per turn — added to the static multiplier per turn. +## Hard=0.003 → at T138 effective_mult ≈ static_mult + 0.414 (so 1.30 base → 1.71 at T138). +## Lets AI scale into mid/late game without overpowering early-game. +var ai_yield_per_turn_growth: float = 0.0 +var player_yield_per_turn_growth: float = 0.0 + ## Gold added to every AI player at game start for the current difficulty tier. var ai_starting_gold_bonus: int = 0 @@ -239,9 +258,21 @@ func apply_ai_difficulty() -> void: return ai_difficulty_modifier = float(mods.get("production_mult", 1.0)) ai_research_modifier = float(mods.get("research_mult", 1.0)) + ai_gold_modifier = float(mods.get("gold_mult", 1.0)) + ai_culture_modifier = float(mods.get("culture_mult", 1.0)) + ai_luxury_modifier = float(mods.get("luxury_mult", 1.0)) + ai_yield_per_turn_growth = float(mods.get("yield_per_turn_growth", 0.0)) ai_starting_gold_bonus = int(mods.get("starting_gold_bonus", 0)) ai_extra_starting_units = int(mods.get("extra_starting_units", 0)) ai_extra_unit_id = str(mods.get("extra_unit_id", "warrior")) + # Player handicap — symmetric inverse (Easy: player gets Hard-AI's bonuses). + var pmods: Dictionary = entry.get("player_modifiers", {}) as Dictionary + player_production_modifier = float(pmods.get("production_mult", 1.0)) + player_research_modifier = float(pmods.get("research_mult", 1.0)) + player_gold_modifier = float(pmods.get("gold_mult", 1.0)) + player_culture_modifier = float(pmods.get("culture_mult", 1.0)) + player_luxury_modifier = float(pmods.get("luxury_mult", 1.0)) + player_yield_per_turn_growth = float(pmods.get("yield_per_turn_growth", 0.0)) print( "GameState: difficulty=%s prod=%.2f research=%.2f gold_bonus=%d extra_units=%d" % [ @@ -254,6 +285,64 @@ func apply_ai_difficulty() -> void: ) +## Effective per-yield multiplier for a player on the current turn. +## Composes the static difficulty mult with the linear per-turn growth. +## yield_type: "production" | "research" | "gold" | "culture" | "luxury". +## Returns 1.0 for unknown yield types or null player. +## +## Per-player overrides (auto_play.gd batch testing) take precedence over the +## difficulty-derived value for production/research only — gold/culture/luxury +## use the global side (ai_X_modifier vs player_X_modifier). +func get_effective_yield_mult(player: RefCounted, yield_type: String) -> float: + if player == null: + return 1.0 + var idx: int = -1 + if player.get("index") != null: + idx = int(player.index) + var is_human: bool = bool(player.get("is_human") if player.get("is_human") != null else false) + # Per-player override path (production / research only — set by auto_play.gd + # AI_DIFFICULTY_P0/P1 batch testing). + if not is_human: + if yield_type == "production": + var per_p: float = float(ai_per_player_production_mult.get(idx, 0.0)) + if per_p > 0.0: + return per_p + float(turn_number) * ai_yield_per_turn_growth + elif yield_type == "research": + var per_r: float = float(ai_per_player_research_mult.get(idx, 0.0)) + if per_r > 0.0: + return per_r + float(turn_number) * ai_yield_per_turn_growth + # Side-resolution: AI gets ai_X_modifier, human gets player_X_modifier. + var base: float + var growth: float + if is_human: + base = _player_yield_mult_for(yield_type) + growth = player_yield_per_turn_growth + else: + base = _ai_yield_mult_for(yield_type) + growth = ai_yield_per_turn_growth + return base + float(turn_number) * growth + + +func _ai_yield_mult_for(yield_type: String) -> float: + match yield_type: + "production": return ai_difficulty_modifier + "research": return ai_research_modifier + "gold": return ai_gold_modifier + "culture": return ai_culture_modifier + "luxury": return ai_luxury_modifier + _: return 1.0 + + +func _player_yield_mult_for(yield_type: String) -> float: + match yield_type: + "production": return player_production_modifier + "research": return player_research_modifier + "gold": return player_gold_modifier + "culture": return player_culture_modifier + "luxury": return player_luxury_modifier + _: return 1.0 + + func get_max_event_tier() -> int: ## Returns the max event tier allowed in the current era. ## When era_difficulty_correlation is disabled, returns 10 (uncapped).