feat(game-engine): ✨ Implement difficulty scaling logic in GameState with methods for setting, adjusting, and querying difficulty levels
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
77f0377e63
commit
0b76874079
2 changed files with 674 additions and 0 deletions
585
game_state.gd
Normal file
585
game_state.gd
Normal file
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue