refactor(engine-specific): ♻️ Restructure autoloaded engine systems to optimize initialization and interaction between DataLoader, EventBus, GameState, TurnManager, and SpriteManifest

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-25 23:53:21 -07:00
parent d8934e0a66
commit 8148272564
5 changed files with 524 additions and 11 deletions

View file

@ -23,6 +23,7 @@ const DATA_CATEGORIES: Array[String] = [
"victories",
"villages",
"wilds",
"npc_buildings",
"difficulty",
"setup",
"ai_personalities",
@ -33,6 +34,11 @@ const DATA_CATEGORIES: Array[String] = [
"ley_line_params",
"social_policies",
"events",
"world_biomes",
"world_flora",
"world_fauna",
"world_ecosystem",
"world_traits",
]
## Categories stored as raw parsed Dictionaries rather than ID-keyed entries.
@ -44,12 +50,43 @@ const RAW_CATEGORIES: Array[String] = [
"ley_line_params",
"social_policies",
"events",
"world_biomes",
"world_flora",
"world_fauna",
"world_ecosystem",
"world_traits",
]
## Mapping from world_* categories to their data subdirectory names.
const _WORLD_DIR_MAP: Dictionary = {
"world_biomes": "world/biomes",
"world_flora": "world/flora",
"world_fauna": "world/fauna",
"world_ecosystem": "world/ecosystem",
"world_traits": "world/traits",
}
# Preload model classes for typed deserialization
const BiomeModelScript = preload("res://engine/src/models/world/biome.gd")
const FloraProfileScript = preload("res://engine/src/models/world/flora_profile.gd")
const TraitSetScript = preload("res://engine/src/models/world/species_traits.gd")
const SubstrateTypeScript = preload("res://engine/src/models/world/substrate.gd")
const MarineFaunaScript = preload("res://engine/src/models/world/marine_fauna.gd")
const LandFaunaScript = preload("res://engine/src/models/world/land_fauna.gd")
const AirFaunaScript = preload("res://engine/src/models/world/air_fauna.gd")
var _active_theme: String = ""
var _data: Dictionary = {}
## Raw parsed data for non-ID-keyed categories (e.g. setup, climate_params).
var _raw: Dictionary = {}
## Typed ecology model instances keyed by id.
var _biomes: Dictionary = {} # id -> BiomeModel
var _flora_profiles: Dictionary = {} # biome_id -> FloraProfile
var _substrates: Dictionary = {} # id -> SubstrateType
## Typed fauna parameter models (single instances, not collections).
var _marine_fauna: Variant = null # MarineFaunaModel
var _land_fauna: Variant = null # LandFaunaModel
var _air_fauna: Variant = null # AirFaunaModel
func _ready() -> void:
@ -57,6 +94,16 @@ func _ready() -> void:
_data[category] = {}
for category: String in RAW_CATEGORIES:
_raw[category] = {}
_clear_typed_caches()
func _clear_typed_caches() -> void:
_biomes.clear()
_flora_profiles.clear()
_substrates.clear()
_marine_fauna = null
_land_fauna = null
_air_fauna = null
func load_theme(theme_id: String) -> void:
@ -65,18 +112,22 @@ func load_theme(theme_id: String) -> void:
_data[category] = {}
for category: String in RAW_CATEGORIES:
_raw[category] = {}
_clear_typed_caches()
var base_path: String = "res://games/%s/data" % theme_id
for category: String in DATA_CATEGORIES:
var dir_path: String = "%s/%s" % [base_path, category]
var file_path: String = "%s/%s.json" % [base_path, category]
# world_* categories use nested subdirectory paths
var subdir: String = _WORLD_DIR_MAP.get(category, category)
var dir_path: String = "%s/%s" % [base_path, subdir]
var file_path: String = "%s/%s.json" % [base_path, subdir]
if DirAccess.dir_exists_absolute(dir_path):
_load_category_dir(category, dir_path)
elif FileAccess.file_exists(file_path):
_load_json_file(category, file_path)
_deserialize_ecology()
_log_load_summary()
@ -386,22 +437,22 @@ func get_terrains_by_flag(flag: String) -> Array:
return results
func get_resources_for_terrain(terrain_id: String) -> Array:
func get_resources_for_terrain(biome_id: String) -> Array:
var results: Array = []
for entry: Dictionary in _data["resources"].values():
var terrains: Array = entry.get("terrains", [])
if terrain_id in terrains:
if biome_id in terrains:
results.append(entry)
return results
func get_improvements_for_terrain(terrain_id: String) -> Array:
func get_improvements_for_terrain(biome_id: String) -> Array:
var results: Array = []
for entry: Dictionary in _data["improvements"].values():
var valid: Variant = entry.get("valid_terrain", [])
if valid is String and valid == "any_land" and terrain_id != "ocean":
if valid is String and valid == "any_land" and biome_id != "ocean":
results.append(entry)
elif valid is Array and terrain_id in valid:
elif valid is Array and biome_id in valid:
results.append(entry)
return results
@ -463,6 +514,126 @@ func get_villages_config() -> Dictionary:
return _get_entry("villages", "villages")
func get_npc_building_type(type_id: String) -> Dictionary:
return _get_entry("npc_buildings", type_id)
func get_all_npc_building_types() -> Array:
return _data.get("npc_buildings", {}).values()
# -- Ecology: typed accessors --
func get_biome(id: String) -> Variant:
## Returns a BiomeModel instance or null if not found.
return _biomes.get(id)
func get_all_biomes() -> Array:
## Returns all BiomeModel instances.
return _biomes.values()
func get_flora_profile(biome_id: String) -> Variant:
## Returns a FloraProfile for the given biome or null if not found.
return _flora_profiles.get(biome_id)
func get_substrate(id: String) -> Variant:
## Returns a SubstrateType instance or null if not found.
return _substrates.get(id)
func get_all_substrates() -> Array:
## Returns all SubstrateType instances.
return _substrates.values()
func get_marine_fauna_params() -> Variant:
## Returns the MarineFaunaModel or null.
return _marine_fauna
func get_land_fauna_params() -> Variant:
## Returns the LandFaunaModel or null.
return _land_fauna
func get_air_fauna_params() -> Variant:
## Returns the AirFaunaModel or null.
return _air_fauna
func get_trait_definitions() -> Dictionary:
## Returns trait_definitions from world/traits/trait_definitions.json.
return _raw.get("world_traits", {}).get("trait_definitions", {})
func get_trait_constraints() -> Array:
## Returns constraint array from world/traits/trait_constraints.json.
var tc: Variant = _raw.get("world_traits", {}).get("trait_constraints", [])
if tc is Array:
return tc
return []
func get_biome_trait_weights(biome_id: String) -> Dictionary:
## Returns per-biome trait probability weights for species generation.
var all_weights: Dictionary = _raw.get("world_traits", {}).get(
"biome_trait_weights", {}
)
return all_weights.get(biome_id, {})
func get_food_web_rules() -> Dictionary:
## Returns food web rules from world/traits/food_web_rules.json.
return _raw.get("world_traits", {}).get("food_web_rules", {})
func get_spawn_rules() -> Array:
## Returns spawn rules array from world/traits/spawn_rules.json.
var sr: Variant = _raw.get("world_traits", {}).get("spawn_rules", [])
if sr is Array:
return sr
return []
func get_flavor_tables() -> Dictionary:
## Returns flavor tables from world/traits/flavor.json.
return _raw.get("world_traits", {}).get("flavor", {})
func get_ecosystem_health_params() -> Dictionary:
## Returns ecosystem health weights and thresholds.
return _raw.get("world_ecosystem", {}).get("health", {})
func get_food_yield_params() -> Dictionary:
## Returns quality-to-yield curves.
return _raw.get("world_ecosystem", {}).get("food_yield", {})
func get_ecosystem_stability_params() -> Dictionary:
## Returns reclassification thresholds.
return _raw.get("world_ecosystem", {}).get("stability", {})
func get_vegetation_params() -> Dictionary:
## Returns flora vegetation growth/decay params.
return _raw.get("world_flora", {}).get("vegetation", {})
func get_succession_params() -> Dictionary:
## Returns flora succession params.
return _raw.get("world_flora", {}).get("succession", {})
func get_desertification_params() -> Dictionary:
## Returns desertification thresholds.
return _raw.get("world_flora", {}).get("desertification", {})
# -- Internal helpers --
@ -483,3 +654,106 @@ func _filter_by_field(category: String, field: String, value: String) -> Array:
if entry.get(field, "") == value:
results.append(entry)
return results
func _deserialize_ecology() -> void:
## Convert raw ecology JSON into typed model instances.
_deserialize_biomes()
_deserialize_flora_profiles()
_deserialize_substrates()
_deserialize_fauna_params()
func _deserialize_biomes() -> void:
var raw_biomes: Dictionary = _raw.get("world_biomes", {})
# biomes.json is loaded as raw category — may have "biomes" key or be flat
var biomes_data: Variant = raw_biomes.get("biomes", raw_biomes)
if biomes_data is Dictionary:
# Could be a dict of biome entries keyed by id
for key: String in biomes_data:
var entry: Variant = biomes_data[key]
if entry is Dictionary:
if not entry.has("id"):
entry["id"] = key
_biomes[entry.get("id", key)] = BiomeModelScript.from_dict(entry)
elif biomes_data is Array:
for entry: Variant in biomes_data:
if entry is Dictionary and entry.has("id"):
_biomes[entry["id"]] = BiomeModelScript.from_dict(entry)
if _biomes.size() > 0:
print("DataLoader: Deserialized %d biome models" % _biomes.size())
func _deserialize_flora_profiles() -> void:
# Flora profiles may come from biomes data (flora_climax fields) or separate files
# First check for explicit flora profile data
var raw_flora: Dictionary = _raw.get("world_flora", {})
# Check each biome for embedded flora profile data
for biome_id: String in _biomes:
var biome: Variant = _biomes[biome_id]
var climax: Dictionary = biome.flora_climax
if climax.get("canopy", 0.0) > 0.0 or climax.get("undergrowth", 0.0) > 0.0:
var profile_data: Dictionary = {
"biome_id": biome_id,
"canopy_climax": climax.get("canopy", 0.0),
"undergrowth_climax": climax.get("undergrowth", 0.0),
"fungi_climax": climax.get("fungi", 0.0),
}
# Merge with vegetation params if available
var veg: Dictionary = raw_flora.get("vegetation", {})
if veg.size() > 0:
profile_data["growth_rate"] = veg.get("growth_rate", 0.02)
profile_data["shade_cap"] = veg.get("shade_cap", 0.7)
profile_data["fungi_undergrowth_threshold"] = veg.get(
"fungi_undergrowth_threshold", 0.3
)
profile_data["fungi_regrowth_bonus"] = veg.get(
"fungi_regrowth_bonus_cap", 2.0
)
_flora_profiles[biome_id] = FloraProfileScript.from_dict(profile_data)
if _flora_profiles.size() > 0:
print(
"DataLoader: Deserialized %d flora profiles" % _flora_profiles.size()
)
func _deserialize_substrates() -> void:
# Substrates may be in world_biomes raw data under "substrates" key
var raw_biomes: Dictionary = _raw.get("world_biomes", {})
var substrates_data: Variant = raw_biomes.get("substrates", null)
if substrates_data == null:
return
if substrates_data is Dictionary:
for key: String in substrates_data:
var entry: Variant = substrates_data[key]
if entry is Dictionary:
if not entry.has("id"):
entry["id"] = key
_substrates[entry.get("id", key)] = SubstrateTypeScript.from_dict(
entry
)
elif substrates_data is Array:
for entry: Variant in substrates_data:
if entry is Dictionary and entry.has("id"):
_substrates[entry["id"]] = SubstrateTypeScript.from_dict(entry)
if _substrates.size() > 0:
print("DataLoader: Deserialized %d substrate types" % _substrates.size())
func _deserialize_fauna_params() -> void:
var raw_fauna: Dictionary = _raw.get("world_fauna", {})
var marine_data: Variant = raw_fauna.get("marine", {})
if marine_data is Dictionary and marine_data.size() > 0:
_marine_fauna = MarineFaunaScript.from_dict(marine_data)
var land_data: Variant = raw_fauna.get("land", {})
if land_data is Dictionary and land_data.size() > 0:
_land_fauna = LandFaunaScript.from_dict(land_data)
var air_data: Variant = raw_fauna.get("air", {})
if air_data is Dictionary and air_data.size() > 0:
_air_fauna = AirFaunaScript.from_dict(air_data)

View file

@ -100,8 +100,6 @@ signal tile_culture_flipped(tile_pos: Vector2i, old_owner: int, new_owner: int)
# -- Climate signals --
signal terrain_transformed(tile: Variant, old_type: String, new_type: String)
signal quality_changed(tile: Variant, old_quality: int, new_quality: int)
signal corruption_spread(tile: Variant)
signal corruption_healed(tile: Variant)
signal wind_recalculated
signal weather_spell_cast(spell_id: String, position: Vector2i)
signal weather_spell_expired(spell_id: String)
@ -146,7 +144,7 @@ signal tile_clicked(axial: Vector2i)
signal climate_phase_changed(new_label: String)
## Request the map renderer to switch its active heatmap overlay.
## mode: "none" | "temperature" | "moisture" | "wind_heatmap" | "weather"
## | "land_value" | "water" | "elevation" | "corruption"
## | "land_value" | "water" | "elevation"
signal map_overlay_changed(mode: String)
## Refresh weather footprint highlights; effects from weather.get_active_effects().
signal weather_effects_updated(effects: Array)
@ -172,6 +170,22 @@ signal marine_creature_spawned(tile_pos: Vector2i, creature_type: String)
## A corrupted marine creature was removed from a tile.
signal marine_creature_cleared(tile_pos: Vector2i, creature_type: String)
# -- Ecology signals --
## A lair's habitat degraded below threshold for too long — converted to ruin.
signal lair_abandoned(pos: Vector2i)
## A lair's habitat is thriving — creatures quality-up faster.
signal lair_thriving(pos: Vector2i, new_tier: int)
## Global ecosystem health score updated.
signal ecosystem_health_updated(score: float)
## Tile's biome reclassified by BiomeClassifier.
signal biome_changed(pos: Vector2i, old_biome: String, new_biome: String)
## An individual creature died (from age, starvation, predation, or player).
signal creature_died(pos: Vector2i, species_name: String, quality: int)
## A new creature was born via reproduction.
signal creature_born(pos: Vector2i, species_name: String)
## Tile crossed Q4 threshold — natural wonder emerged from ecology.
signal landmark_formed(pos: Vector2i, name: String, quality: int)
# -- Natural event signals --
signal natural_event_spawned(event_type: String, position: Vector2i, intensity: float)
signal natural_event_moved(event_type: String, from_pos: Vector2i, to_pos: Vector2i)

View file

@ -4,6 +4,7 @@ extends Node
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 ERA_NAMES: Array[String] = [
"age_of_founding",
@ -80,6 +81,11 @@ var ley_anchors: Array = []
## 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()
@ -95,6 +101,8 @@ func initialize_game(settings: Dictionary) -> void:
ley_anchors = []
ley_edges = []
diplomacy = {}
npc_buildings = []
_npc_buildings_by_tile = {}
# Create primary map layer (index 0)
(
@ -183,6 +191,46 @@ func get_game_map() -> RefCounted: # Returns GameMap
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,
@ -195,6 +243,7 @@ func serialize() -> Dictionary:
"layers": [],
"transit_nodes": transit_nodes,
"ley_anchors": _serialize_ley_anchors(),
"npc_buildings": _serialize_npc_buildings(),
}
# Serialize ascension rituals: {player_index_str -> ritual_dict}
@ -224,6 +273,7 @@ func deserialize(data: Dictionary) -> void:
transit_nodes = data.get("transit_nodes", [])
wonders_built = data.get("wonders_built", {}).duplicate()
_deserialize_ley_anchors(data.get("ley_anchors", []))
_deserialize_npc_buildings(data.get("npc_buildings", []))
# Deserialize ascension rituals
var AscensionRitualScript: GDScript = preload(
@ -358,6 +408,23 @@ func _deserialize_ley_anchors(raw: Array) -> void:
)
func _serialize_npc_buildings() -> Array:
var result: Array = []
for b: Variant in npc_buildings:
if b is BuildingScript:
result.append(b.to_dict())
return result
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.

View file

@ -0,0 +1,148 @@
extends Node
## SQLite-backed sprite manifest for ecology assets.
## Opens sprites.db (read-only) and provides lookup by entity/quality/variant.
## Registered as autoload — warns but does not crash if DB is missing.
var _db: Variant = null # SQLiteDatabase instance (nullable)
var _db_path: String = ""
var _db_available: bool = false
func _ready() -> void:
pass
func open_db(theme_id: String) -> void:
## Open the sprite database for the given game pack.
## Called after DataLoader.load_theme() to know the active pack.
close_db()
_db_path = "res://games/%s/data/sprites.db" % theme_id
if not FileAccess.file_exists(_db_path):
push_warning(
"SpriteManifest: sprites.db not found at %s — sprite lookups will return empty"
% _db_path
)
return
# SQLite GDExtension: open read-only
var sqlite_class: Variant = ClassDB.instantiate("SQLite")
if sqlite_class == null:
push_warning(
"SpriteManifest: SQLite GDExtension not available — sprite lookups disabled"
)
return
_db = sqlite_class
_db.path = _db_path
_db.read_only = true
if not _db.open_db():
push_warning("SpriteManifest: Failed to open %s" % _db_path)
_db = null
return
_db_available = true
print("SpriteManifest: Opened %s" % _db_path)
func close_db() -> void:
if _db != null and _db_available:
_db.close_db()
_db = null
_db_available = false
func is_available() -> bool:
return _db_available
func get_sprite(
entity_type: String,
entity_id: String,
quality: int = -1,
variant: int = 0,
) -> String:
## Returns the asset path for a sprite, or "" if not found.
## quality = -1 means quality-agnostic lookup (e.g. substrate sprites).
if not _db_available:
return ""
var query: String
var params: Array
if quality < 0:
query = (
"SELECT path FROM sprites WHERE entity_type = ? AND entity_id = ? AND variant = ? LIMIT 1"
)
params = [entity_type, entity_id, variant]
else:
query = (
"SELECT path FROM sprites WHERE entity_type = ? AND entity_id = ? AND quality = ? AND variant = ? LIMIT 1"
)
params = [entity_type, entity_id, quality, variant]
if not _db.query_with_bindings(query, params):
return ""
var results: Array = _db.query_result
if results.size() == 0:
return ""
return results[0].get("path", "")
func has_sprite(
entity_type: String, entity_id: String, quality: int = -1
) -> bool:
## Returns true if at least one sprite exists for the given entity.
if not _db_available:
return false
var query: String
var params: Array
if quality < 0:
query = (
"SELECT 1 FROM sprites WHERE entity_type = ? AND entity_id = ? LIMIT 1"
)
params = [entity_type, entity_id]
else:
query = (
"SELECT 1 FROM sprites WHERE entity_type = ? AND entity_id = ? AND quality = ? LIMIT 1"
)
params = [entity_type, entity_id, quality]
if not _db.query_with_bindings(query, params):
return false
return _db.query_result.size() > 0
func validate_sprites() -> Array[String]:
## Pre-flight validation: checks all substrates, biomes x quality, species.
## Returns array of missing sprite descriptions. Empty = all good.
var missing: Array[String] = []
if not _db_available:
missing.append("sprites.db not available at %s" % _db_path)
return missing
# Validate substrate sprites
var substrates: Array = DataLoader.get_all_substrates()
for sub: Variant in substrates:
if not has_sprite("substrate", sub.id):
missing.append("substrate:%s" % sub.id)
# Validate biome sprites across quality ranges
var biomes: Array = DataLoader.get_all_biomes()
for biome: Variant in biomes:
var qr: Vector2i = biome.quality_range
for q in range(qr.x, qr.y + 1):
if not has_sprite("biome", biome.id, q):
missing.append("biome:%s:q%d" % [biome.id, q])
if missing.size() > 0:
push_error(
(
"SpriteManifest: %d missing sprites:\n %s"
% [missing.size(), "\n ".join(missing)]
)
)
return missing

View file

@ -28,6 +28,12 @@ const MarineHarvestScript: GDScript = preload(
const TurnProcessorScript: GDScript = preload(
"res://engine/src/modules/management/turn_processor.gd"
)
const EcosystemScript: GDScript = preload(
"res://engine/src/modules/ecology/ecosystem.gd"
)
const EcologyDBScript: GDScript = preload(
"res://engine/src/modules/ecology/ecology_db.gd"
)
enum Phase {
NONE,
@ -56,6 +62,8 @@ var climate: RefCounted = ClimateScript.new() # Climate — per-tile physics
var climate_effects: RefCounted = ClimateEffectsScript.new() # ClimateEffects — unit damage
var diplomacy: RefCounted = DiplomacyScript.new() # Diplomacy — relationship state
var marine_harvest: RefCounted = MarineHarvestScript.new() # MarineHarvest — ocean ecology
var ecosystem: RefCounted = EcosystemScript.new() # EcosystemOrchestrator — flora+fauna+quality
var ecology_db: RefCounted = EcologyDBScript.new() # EcologyDB — SQLite creature storage
var _processor: RefCounted = null # TurnProcessor — wired in _ready
@ -71,6 +79,8 @@ func _ready() -> void:
proc.climate = climate
proc.climate_effects = climate_effects
proc.marine_harvest = marine_harvest
proc.ecosystem = ecosystem
proc.ecology_db = ecology_db
func _on_tech_research_started(tech_id: String, player_index: int) -> void:
@ -220,7 +230,7 @@ func next_player() -> void:
# Diplomacy tick: decay timed modifiers and agreements once per full turn.
(diplomacy as DiplomacyScript).process_turn()
# Protection building effects: write mitigation fields onto city tiles
# BEFORE climate runs so aerosol_mitigation and corruption_resistance are active.
# BEFORE climate runs so aerosol_mitigation is active.
var game_map_for_climate: RefCounted = GameState.get_game_map()
if game_map_for_climate != null:
EconomyScript.apply_protection_effects(game_map_for_climate, GameState.players)