diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 7f7c0072..98a412fb 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -17,6 +17,8 @@ const BuildableHelperScript = preload("res://engine/scenes/city/city_buildable_h const ImprovementManagerScript = preload( "res://engine/src/modules/management/improvement_manager.gd" ) +const HappinessScript = preload("res://engine/src/modules/empire/happiness.gd") +const ItemSystemScript = preload("res://engine/src/modules/management/item_system.gd") var _improvement_manager: RefCounted = null @@ -111,6 +113,7 @@ func _ready() -> void: EventBus.unit_destroyed.connect(_on_unit_destroyed) EventBus.improvement_started.connect(_on_improvement_started) EventBus.improvement_completed.connect(_on_improvement_completed) + EventBus.loot_dropped.connect(_on_loot_dropped) _improvement_manager = ImprovementManagerScript.new() @@ -256,6 +259,16 @@ func _on_improvement_completed(tile: Vector2i, type: String) -> void: }) +func _on_loot_dropped(player: Variant, creature_type: String, drops: Array) -> void: + var p_idx: int = int(player.get("index")) if player != null and player.get("index") != null else -1 + _append_event({ + "type": "loot_dropped", + "player": p_idx, + "creature": creature_type, + "drops": drops, + }) + + func _ensure_stats(player_index: int) -> void: if not _stats.has(player_index): _stats[player_index] = { @@ -827,6 +840,14 @@ func _next_building(city: Variant, player: Variant, city_count: int, has_founder continue if cid in tech_req and not player.has_tech(tech_req[cid]): continue + if cid in units_set: + var udata: Dictionary = DataLoader.get_unit(cid) + var req_res: String = str(udata.get("requires_resource", "")) + if req_res != "" and req_res != "null": + if not BuildableHelperScript.player_owns_resource( + player, req_res + ): + continue scores[cid] = 0.0 # State gathering @@ -1266,6 +1287,7 @@ func _try_attack_adjacent_lair(unit: Variant, game_map: RefCounted) -> void: if player != null: player.gold += gold_reward unit.gain_xp(xp_reward) + var lair_type_id: String = tile.lair_type tile.lair_type = "" var reward: Dictionary = { "gold": gold_reward, @@ -1277,6 +1299,15 @@ func _try_attack_adjacent_lair(unit: Variant, game_map: RefCounted) -> void: lair_name, lair_tier, norm, gold_reward, xp_reward ]) EventBus.lair_cleared.emit(norm, reward) + if player != null: + var turn_seed: int = GameState.game_rng.seed ^ GameState.turn_number + ItemSystemScript.roll_fauna_drops( + lair_type_id, + player, + turn_seed, + hash(unit.id), + hash(norm), + ) elif not attacker_alive: print(" LAIR ATTACK FAILED: %s killed at %s" % [unit.type_id, norm]) return @@ -1535,6 +1566,7 @@ func _build_player_stats() -> Dictionary: mil += 1 _ensure_stats(idx) var pstat: Dictionary = _stats[idx] + var luxuries: int = HappinessScript._count_unique_luxuries(p, game_map) var happiness: int = int(p.get("happiness")) if p.get("happiness") != null else 0 var gpt: int = int(p.get("gold_per_turn")) if p.get("gold_per_turn") != null else 0 var gold: int = int(p.get("gold")) if p.get("gold") != null else 0 @@ -1556,6 +1588,7 @@ func _build_player_stats() -> Dictionary: "techs": int(p.researched_techs.size()), "tiles": tiles, "buildings": buildings, + "luxuries": luxuries, "happiness": happiness, "food_total": food_total, "production_total": production_total, diff --git a/src/game/engine/scenes/tests/simple_heuristic_ai.gd b/src/game/engine/scenes/tests/simple_heuristic_ai.gd deleted file mode 100644 index 04a53368..00000000 --- a/src/game/engine/scenes/tests/simple_heuristic_ai.gd +++ /dev/null @@ -1,610 +0,0 @@ -class_name SimpleHeuristicAi -extends RefCounted -## Personality-driven heuristic AI for arena-quality 1v1 matches. -## -## This module is the current source of action generation for AI players. -## There is no Rust GdAiController — `mc-ai` exposes scoring weights only. -## The heuristics here are intentionally cheap and deterministic per turn: -## the goal is a screensaver-watchable match, not tournament play. -## -## Personality is derived from race `strategic_axes` (expansion/production/ -## wealth) and can be overridden per-arena-window via env vars -## `AI_ARENA_PERSONALITY_AGGRESSION` and `AI_ARENA_PERSONALITY_EXPANSION`. -## -## Entry point: `process_player(player) -> Array[Dictionary]` — returns -## actions in the shape consumed by `ai_turn_bridge.gd::_apply_action`. - -const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") -const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") -const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") - -const FOUND_MIN_DIST_OWN: int = 4 -## Minimum distance to nearest enemy unit before a founder will settle. -## We only block on "adjacent or same tile" — founding 2 hexes from an -## enemy is fine for screensaver play, and the old value of 3 would -## deadlock founders that spawned near each other (observed in arena -## smoke tests where start placement put both players on tile 0,0). -const FOUND_MIN_DIST_ENEMY: int = 1 -const RETREAT_HP_FRACTION: float = 0.3 -const DEFENSIVE_CHASE_RANGE: int = 4 -const MILITARY_COMBAT_TYPES: Array[String] = [ - "melee", "ranged", "cavalry", "siege", -] -const INF_DISTANCE: int = 1 << 30 - - -## Generate this turn's actions for `player`. Returns an Array of action -## dictionaries; an empty array means "no usable actions this turn". -static func process_player(player: RefCounted) -> Array: - var actions: Array = [] - if player == null: - return actions - - var personality: Dictionary = _resolve_personality(player) - var enemy_units: Array = _collect_enemy_units(player) - var enemy_city_positions: Array[Vector2i] = _collect_enemy_city_positions( - player - ) - - # Units: founders first (expansion), then military. - for idx: int in player.units.size(): - var unit: Variant = player.units[idx] - if unit == null or not unit.is_alive(): - continue - if unit.movement_remaining <= 0: - continue - var action: Dictionary = {} - if unit.can_found_city: - action = _decide_founder_action(idx, unit, player, enemy_units) - elif unit.attack > 0 or unit.ranged_attack > 0: - # Stat-based dispatch — `unit.unit_type` is read from a JSON field - # (`combat_type`) that the current data files don't populate, so it - # would always be empty here. Anything with combat stats and no - # founder flag is treated as a military unit. - action = _decide_military_action( - idx, - unit, - player, - enemy_units, - enemy_city_positions, - personality, - ) - if not action.is_empty(): - actions.append(action) - - # Cities: set production for any empty queues + bombard nearby enemies. - for ci: int in player.cities.size(): - var city: RefCounted = player.cities[ci] - if city == null: - continue - # Bombard: attack nearest enemy within range - if not city.has_bombarded: - var bombard: Dictionary = _decide_city_bombard(ci, city, player) - if not bombard.is_empty(): - actions.append(bombard) - if not city.production_queue.is_empty(): - continue - var prod: Dictionary = _decide_production(ci, player) - if not prod.is_empty(): - actions.append(prod) - - # Research: pick a tech if idle - if player.researching.is_empty(): - var tech_id: String = _pick_next_tech(player) - if not tech_id.is_empty(): - player.researching = tech_id - player.research_progress = 0 - - return actions - - -# ── Personality ────────────────────────────────────────────────────────── - - -static func _resolve_personality(player: RefCounted) -> Dictionary: - ## Pull strategic axes from the player's assigned axes or race JSON, - ## then let env var overrides (AI_ARENA_PERSONALITY_*) take precedence. - var axes: Dictionary = player.strategic_axes - if axes.is_empty(): - var race_data: Dictionary = DataLoader.get_race(player.race_id) - axes = race_data.get("strategic_axes", {}) - - var aggression: int = int(axes.get("expansion", 0)) - var expansion: int = int(axes.get("expansion", 0)) - var production_pref: int = int(axes.get("production", 0)) - var wealth_pref: int = int(axes.get("wealth", 0)) - - var env_agg: String = OS.get_environment("AI_ARENA_PERSONALITY_AGGRESSION") - if not env_agg.is_empty(): - aggression = int(env_agg) - var env_exp: String = OS.get_environment("AI_ARENA_PERSONALITY_EXPANSION") - if not env_exp.is_empty(): - expansion = int(env_exp) - - return { - "aggression": aggression, - "expansion": expansion, - "production": production_pref, - "wealth": wealth_pref, - } - - -# ── Enemy enumeration ──────────────────────────────────────────────────── - - -static func _collect_enemy_units(player: RefCounted) -> Array: - var out: Array = [] - for other: RefCounted in GameState.players: - if not other is PlayerScript: - continue - if other.index == player.index: - continue - for eu: Variant in other.units: - if eu == null or not eu.is_alive(): - continue - out.append(eu) - return out - - -static func _collect_enemy_city_positions( - player: RefCounted -) -> Array[Vector2i]: - var out: Array[Vector2i] = [] - for other: RefCounted in GameState.players: - if not other is PlayerScript: - continue - if other.index == player.index: - continue - for c: RefCounted in other.cities: - if c != null: - out.append(c.position) - return out - - -static func _nearest_enemy_unit(pos: Vector2i, enemies: Array) -> Variant: - var best: Variant = null - var best_dist: int = INF_DISTANCE - for eu: Variant in enemies: - var d: int = HexUtilsScript.hex_distance(pos, eu.position) - if d < best_dist: - best_dist = d - best = eu - return best - - -static func _nearest_position( - pos: Vector2i, candidates: Array[Vector2i] -) -> Vector2i: - var best: Vector2i = pos - var best_dist: int = INF_DISTANCE - for c: Vector2i in candidates: - var d: int = HexUtilsScript.hex_distance(pos, c) - if d < best_dist: - best_dist = d - best = c - return best - - -static func _tile_has_enemy_unit( - pos: Vector2i, enemy_units: Array -) -> bool: - for eu: Variant in enemy_units: - if eu.position == pos: - return true - return false - - -# ── Founder logic ──────────────────────────────────────────────────────── - - -static func _decide_founder_action( - idx: int, unit: Variant, player: RefCounted, enemy_units: Array -) -> Dictionary: - var own_city_positions: Array[Vector2i] = [] - for c: RefCounted in player.cities: - own_city_positions.append(c.position) - - var dist_own: int = _min_distance(unit.position, own_city_positions) - var dist_enemy: int = _min_distance_to_units(unit.position, enemy_units) - - var clear_of_enemies: bool = ( - dist_enemy > FOUND_MIN_DIST_ENEMY or enemy_units.is_empty() - ) - var far_enough_from_own: bool = ( - dist_own >= FOUND_MIN_DIST_OWN or own_city_positions.is_empty() - ) - - if far_enough_from_own and clear_of_enemies: - # Check tile quality — only found if the current tile is decent - var quality: float = _score_city_site(unit.position) - if quality >= 1.0 or dist_own >= FOUND_MIN_DIST_OWN + 3: - # Good site, or we've wandered far enough — settle here - return { - "type": "found_city", - "unit_index": idx, - "city_name": "", - } - - # Otherwise walk toward open space. When enemies are the blocker, - # flee from the nearest one; when own cities crowd us, walk away - # from them. Falling back to a score-by-position prevents the - # vacuously-zero score case that stalls founders with no cities. - var score_fn: Callable - if not clear_of_enemies: - var nearest: Variant = _nearest_enemy_unit(unit.position, enemy_units) - if nearest != null: - score_fn = _score_away_from_pos(nearest.position) - else: - score_fn = _score_away_from_own(own_city_positions) - else: - score_fn = _score_away_from_own(own_city_positions) - return _move_action(idx, unit.position, enemy_units, score_fn) - - -static func _score_away_from_own(own: Array[Vector2i]) -> Callable: - return func(pos: Vector2i) -> float: - return float(_min_distance(pos, own)) - - -# ── Military logic ─────────────────────────────────────────────────────── - - -static func _decide_military_action( - idx: int, - unit: Variant, - player: RefCounted, - enemy_units: Array, - enemy_city_positions: Array[Vector2i], - personality: Dictionary, -) -> Dictionary: - var hp_frac: float = float(unit.hp) / maxf(1.0, float(unit.max_hp)) - var nearest_enemy: Variant = _nearest_enemy_unit(unit.position, enemy_units) - - # Retreat if wounded and a threat is within reach. - if hp_frac <= RETREAT_HP_FRACTION and nearest_enemy != null: - return _move_action( - idx, - unit.position, - enemy_units, - _score_away_from_pos(nearest_enemy.position), - ) - - # Adjacent attack if healthy enough. - if nearest_enemy != null: - var enemy_dist: int = HexUtilsScript.hex_distance( - unit.position, nearest_enemy.position - ) - if enemy_dist == 1: - return { - "type": "attack", - "unit_index": idx, - "target_col": nearest_enemy.position.x, - "target_row": nearest_enemy.position.y, - } - - var aggression: int = int(personality.get("aggression", 0)) - var should_chase: bool = ( - aggression > 0 or enemy_dist <= DEFENSIVE_CHASE_RANGE - ) - if should_chase: - return _move_action( - idx, - unit.position, - enemy_units, - _score_toward_pos(nearest_enemy.position), - ) - - # No visible enemy units — march on the nearest enemy city. - if not enemy_city_positions.is_empty(): - var target_city: Vector2i = _nearest_position( - unit.position, enemy_city_positions - ) - return _move_action( - idx, - unit.position, - enemy_units, - _score_toward_pos(target_city), - ) - - # Defensive fallback: drift back toward our own cities. - if not player.cities.is_empty(): - var home: Vector2i = (player.cities[0] as RefCounted).position - return _move_action( - idx, unit.position, enemy_units, _score_toward_pos(home) - ) - - return {} - - -static func _score_toward_pos(target: Vector2i) -> Callable: - return func(pos: Vector2i) -> float: - return -float(HexUtilsScript.hex_distance(pos, target)) - - -static func _score_away_from_pos(threat: Vector2i) -> Callable: - return func(pos: Vector2i) -> float: - return float(HexUtilsScript.hex_distance(pos, threat)) - - -# ── Production logic ───────────────────────────────────────────────────── - - -static func _decide_city_bombard( - city_index: int, city: Variant, player: Variant -) -> Dictionary: - var primary: Dictionary = GameState.get_primary_layer() - var all_units: Array = primary.get("units", []) - var bombard_range: int = city.get("bombard_range") if city.get("bombard_range") else 2 - for u: Variant in all_units: - if u.get("owner") == player.index: - continue - if not u.is_alive(): - continue - var dist: int = HexUtilsScript.hex_distance(city.position, u.position) - if dist <= bombard_range: - return { - "type": "city_bombard", - "city_index": city_index, - "target_col": u.position.x, - "target_row": u.position.y, - } - return {} - - -static func _decide_production( - city_index: int, player: RefCounted -) -> Dictionary: - var military_count: int = 0 - var settler_count: int = 0 - for u: Variant in player.units: - if u == null or not u.is_alive(): - continue - if u.get("can_found_city") == true: - settler_count += 1 - elif u.unit_type in MILITARY_COMBAT_TYPES: - military_count += 1 - - var city: RefCounted = player.cities[city_index] - var city_count: int = player.cities.size() - - # Priority 1: Build walls if city has none (defense first) - if not city.has_building("walls"): - var wdata: Dictionary = DataLoader.get_building("walls") - if not wdata.is_empty(): - return _prod_building(city_index, "walls") - - # Priority 2: Happiness building when unhappy - if player.happiness < 0: - var hb_id: String = _pick_happiness_building_id(city, player) - if not hb_id.is_empty(): - return _prod_building(city_index, hb_id) - - # Priority 3: Expand — build settler if fewer than 3 cities and no settler in progress - if city_count < 3 and settler_count == 0 and city_index == 0: - return _prod_unit(city_index, "settler") - - # Priority 4: Military — maintain 2 warriors per city - var want_military: bool = military_count < maxi(2, city_count * 2) - if want_military: - var unit_id: String = _pick_military_unit_id() - if not unit_id.is_empty(): - return _prod_unit(city_index, unit_id) - - # Priority 5: Production building (forge boosts future output) - if not city.has_building("forge"): - var fdata: Dictionary = DataLoader.get_building("forge") - if not fdata.is_empty(): - return _prod_building(city_index, "forge") - - # Priority 6: Castle (upgrades walls, enables bombard) - if city.has_building("walls") and not city.has_building("castle"): - var cdata: Dictionary = DataLoader.get_building("castle") - if not cdata.is_empty(): - return _prod_building(city_index, "castle") - - # Priority 7: Any other available building - var building_id: String = _pick_building_id(city) - if not building_id.is_empty(): - return _prod_building(city_index, building_id) - - # Fallback: more military - var fallback_unit: String = _pick_military_unit_id() - if not fallback_unit.is_empty(): - return _prod_unit(city_index, fallback_unit) - return {} - - -static func _prod_unit(city_index: int, unit_id: String) -> Dictionary: - return {"type": "set_production", "city_index": city_index, - "item_type": "unit", "item_id": unit_id} - - -static func _prod_building(city_index: int, building_id: String) -> Dictionary: - return {"type": "set_production", "city_index": city_index, - "item_type": "building", "item_id": building_id} - - -static func _pick_next_tech(player: Variant) -> String: - ## Pick the cheapest available tech the player hasn't researched yet. - ## Respects prerequisites — only considers techs whose requires are all met. - var best_id: String = "" - var best_cost: int = 999999 - for tech: Dictionary in DataLoader.get_all_techs(): - var tid: String = String(tech.get("id", "")) - if tid.is_empty() or player.has_tech(tid): - continue - # Check prerequisites - var reqs: Array = tech.get("requires", []) - var reqs_met: bool = true - for req: Variant in reqs: - if not player.has_tech(String(req)): - reqs_met = false - break - if not reqs_met: - continue - var cost: int = int(tech.get("cost", 999999)) - if cost < best_cost: - best_cost = cost - best_id = tid - return best_id - - -static func _pick_military_unit_id() -> String: - var preferred: String = "warrior" - var data: Dictionary = DataLoader.get_unit(preferred) - if not data.is_empty(): - return preferred - for u: Dictionary in DataLoader.get_all_units(): - if String(u.get("combat_type", "")) == "melee": - return String(u.get("id", "")) - return "" - - -static func _pick_happiness_building_id( - city: RefCounted, player: RefCounted -) -> String: - var existing: Array = Array(city.buildings) - var best_id: String = "" - var best_happiness: int = 0 - for b: Dictionary in DataLoader.get_all_buildings(): - var bid: String = str(b.get("id", "")) - if bid.is_empty() or bid in existing: - continue - if not _can_build(b, player): - continue - var happiness_value: int = _sum_effect(b, "happiness") - if happiness_value > best_happiness: - best_happiness = happiness_value - best_id = bid - return best_id - - -static func _can_build(building_data: Dictionary, player: RefCounted) -> bool: - if building_data.get("wonder_type") != null: - return false - var tech_req: String = str(building_data.get("tech_required", "")) - if not tech_req.is_empty() and not player.has_tech(tech_req): - return false - var culture_req: String = str(building_data.get("culture_required", "")) - if not culture_req.is_empty(): - return false - return true - - -static func _sum_effect(building_data: Dictionary, effect_type: String) -> int: - var total: int = 0 - var effects: Array = building_data.get("effects", []) as Array - for effect: Variant in effects: - if typeof(effect) != TYPE_DICTIONARY: - continue - var ed: Dictionary = effect as Dictionary - if str(ed.get("type", "")) == effect_type: - total += int(ed.get("value", 0)) - return total - - -static func _pick_building_id(city: RefCounted) -> String: - var existing: Array = Array(city.buildings) - # Skip buildings handled by priority logic - var skip: Array = ["walls", "forge", "castle"] - for b: Dictionary in DataLoader.get_all_buildings(): - var bid: String = str(b.get("id", "")) - if bid.is_empty() or bid in existing or bid in skip: - continue - if not str(b.get("tech_required", "")).is_empty(): - continue - if b.get("wonder_type") != null: - continue - return bid - return "" - - -# ── Movement helpers ───────────────────────────────────────────────────── - - -static func _move_action( - idx: int, - origin: Vector2i, - enemy_units: Array, - score_fn: Callable, -) -> Dictionary: - ## Emit a move_unit action toward the best neighbor of `origin`. - ## Neighbors occupied by enemy units are skipped. Returns {} if there - ## is no valid neighbor (caller treats that as "no action"). - var best: Vector2i = origin - var best_score: float = -INF - var found: bool = false - for n: Vector2i in HexUtilsScript.get_neighbors(origin): - if _tile_has_enemy_unit(n, enemy_units): - continue - var s: float = score_fn.call(n) - if not found or s > best_score: - best_score = s - best = n - found = true - if not found or best == origin: - return {} - return { - "type": "move_unit", - "unit_index": idx, - "target_col": best.x, - "target_row": best.y, - } - - -static func _score_city_site(pos: Vector2i) -> float: - ## Score a tile as a potential city site. Higher = better. - ## Considers: tile yields of center + neighbors, resources nearby. - var game_map: RefCounted = GameState.get_game_map() - if game_map == null: - return 0.0 - var score: float = 0.0 - # Center tile yields - var center_tile: Resource = game_map.get_tile(pos) - if center_tile == null: - return 0.0 - # Don't settle on water - var water_biomes: Array = ["ocean", "coast", "deep_ocean", "lake"] - if center_tile.biome_id in water_biomes: - return 0.0 - var center_yields: Dictionary = center_tile.get_yields(-1) - score += float(center_yields.get("food", 0)) * 2.0 - score += float(center_yields.get("production", 0)) * 1.5 - score += float(center_yields.get("trade", 0)) - # Neighbor tiles (ring 1 — first workable tiles) - var neighbors: Array[Vector2i] = HexUtilsScript.get_neighbors(pos) - for n: Vector2i in neighbors: - var norm: Vector2i = HexUtilsScript.normalize_position( - n, game_map.width, game_map.height, game_map.wrap_mode - ) - var tile: Resource = game_map.get_tile(norm) - if tile == null: - continue - if tile.biome_id in water_biomes: - score += 0.5 # Coastal bonus (food from coast) - continue - var t_yields: Dictionary = tile.get_yields(-1) - score += float(t_yields.get("food", 0)) * 0.5 - score += float(t_yields.get("production", 0)) * 0.3 - # Resource bonus - if tile.resource_id != "": - score += 2.0 - return score - - -static func _min_distance(pos: Vector2i, others: Array[Vector2i]) -> int: - var best: int = INF_DISTANCE - for o: Vector2i in others: - var d: int = HexUtilsScript.hex_distance(pos, o) - if d < best: - best = d - return best - - -static func _min_distance_to_units(pos: Vector2i, units: Array) -> int: - var best: int = INF_DISTANCE - for u: Variant in units: - var d: int = HexUtilsScript.hex_distance(pos, u.position) - if d < best: - best = d - return best diff --git a/src/game/engine/src/autoloads/game_state.gd b/src/game/engine/src/autoloads/game_state.gd index 20add948..92422039 100644 --- a/src/game/engine/src/autoloads/game_state.gd +++ b/src/game/engine/src/autoloads/game_state.gd @@ -60,6 +60,10 @@ var spell_system: RefCounted = null ## 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/science each turn (set by AIPlayer). ## 1.0 = even, <1.0 = AI penalty, >1.0 = AI bonus. var ai_difficulty_modifier: float = 1.0 @@ -106,6 +110,10 @@ func initialize_game(settings: Dictionary) -> void: npc_buildings = [] _npc_buildings_by_tile = {} + 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 @@ -259,6 +267,9 @@ func serialize() -> Dictionary: "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, @@ -292,6 +303,19 @@ func deserialize(data: Dictionary) -> void: 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", [])) diff --git a/src/game/engine/src/modules/ai/ai_tactical.gd b/src/game/engine/src/modules/ai/ai_tactical.gd index 8ba3f5d0..27359453 100644 --- a/src/game/engine/src/modules/ai/ai_tactical.gd +++ b/src/game/engine/src/modules/ai/ai_tactical.gd @@ -31,7 +31,7 @@ var _rng: RandomNumberGenerator = RandomNumberGenerator.new() func _init() -> void: _resolver = CombatResolverScript.new() _site_scorer = CityScorerScript.new() - _rng.randomize() + _rng.seed = GameState.game_rng.randi() func process_units( diff --git a/src/game/engine/src/modules/ai/wild_creature_ai.gd b/src/game/engine/src/modules/ai/wild_creature_ai.gd index bf34a2f4..99f44765 100644 --- a/src/game/engine/src/modules/ai/wild_creature_ai.gd +++ b/src/game/engine/src/modules/ai/wild_creature_ai.gd @@ -20,7 +20,7 @@ var _unit_manager: RefCounted func _init(unit_manager: RefCounted) -> void: _unit_manager = unit_manager - _rng.randomize() + _rng.seed = GameState.game_rng.randi() func process_wild_turn(game_map: RefCounted) -> void: