magicciv/game_state.gd
2026-04-27 01:16:56 -07:00

585 lines
19 KiB
GDScript

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