diff --git a/src/game/engine/src/autoloads/data_loader.gd b/src/game/engine/src/autoloads/data_loader.gd index 89af0ecf..cb6b72b8 100644 --- a/src/game/engine/src/autoloads/data_loader.gd +++ b/src/game/engine/src/autoloads/data_loader.gd @@ -4,7 +4,7 @@ extends Node ## Ecology accessors are delegated to _ecology (data_loader_ecology.gd). const DATA_CATEGORIES: Array[String] = [ - "terrain", "units", "buildings", "techs", "spells", "races", + "terrain", "units", "buildings", "techs", "culture", "spells", "races", "resources", "keywords", "improvements", "items", "promotions", "magical_promotions", "governments", "disciplines", "eras", "victories", "villages", "wilds", "npc_buildings", "difficulty", "setup", @@ -26,6 +26,7 @@ const _RESOURCES_DIR_MAP: Dictionary = { "homeworlds": "worlds", "resources": "deposits", "techs": "techs", + "culture": "culture", "world_biomes": "ecology/biomes", "world_flora": "ecology/flora", "world_fauna": "ecology/fauna", @@ -331,6 +332,9 @@ func get_all_buildings() -> Array: func get_all_techs() -> Array: return _data["techs"].values() +func get_all_culture() -> Array: + return _data["culture"].values() + func get_all_spells() -> Array: return _data["spells"].values() diff --git a/src/game/engine/src/autoloads/event_bus.gd b/src/game/engine/src/autoloads/event_bus.gd index 9d9819a4..2737f97e 100644 --- a/src/game/engine/src/autoloads/event_bus.gd +++ b/src/game/engine/src/autoloads/event_bus.gd @@ -55,6 +55,14 @@ signal school_locked(player_index: int, schools: Array) ## Drives tutorial step 6 ("open tech tree"). signal tech_tree_opened(player_index: int) +# -- Culture-research signals (parallel to tech) -- +## Emitted when a cultural tradition completes for the given player. +signal culture_researched(tradition_id: String, player_index: int) +## Emitted when a cultural tradition is queued for research. +signal culture_research_started(tradition_id: String, player_index: int) +## Emitted when the player opens the culture tree overlay. +signal culture_tree_opened(player_index: int) + # -- Combat signals -- signal combat_started(attacker: Variant, defender: Variant) signal combat_resolved(attacker: Variant, defender: Variant, result: Dictionary) diff --git a/src/game/engine/src/entities/player.gd b/src/game/engine/src/entities/player.gd index c8977a06..033b4041 100644 --- a/src/game/engine/src/entities/player.gd +++ b/src/game/engine/src/entities/player.gd @@ -80,6 +80,15 @@ var science_per_turn: int = 0 ## opaque handle is needed; callers read this array directly. var researched_techs: Array[String] = [] +# ── Culture research (parallel track to tech) ───────────────────────── +## Currently-researched cultural tradition id, or empty if none. +var researching_tradition: String = "" +## Culture-research progress accumulated toward the current tradition. +var culture_research_progress: int = 0 +## Researched cultural tradition ids — canonical state used to gate +## culture-tier wonders / Golden-Age effects / culture-victory progress. +var researched_traditions: Array[String] = [] + # ── Magic ───────────────────────────────────────────────────────────── ## Unlocked magic school ids (max 2 under current school-lock rules). var schools: Array[String] = [] @@ -132,6 +141,21 @@ func has_tech(tech_id: String) -> bool: return researched_techs.has(tech_id) +## True if `tradition_id` has been researched. Empty is treated as +## "no requirement". +func has_tradition(tradition_id: String) -> bool: + if tradition_id.is_empty(): + return true + return researched_traditions.has(tradition_id) + + +## Mark `tradition_id` as researched. No-op on duplicate. +func add_tradition(tradition_id: String) -> void: + if tradition_id.is_empty() or researched_traditions.has(tradition_id): + return + researched_traditions.append(tradition_id) + + ## Mark `tech_id` as researched and, if it unlocks a magic school, ## append that school to `schools` (capped at 2 under current rules). func add_tech(tech_id: String) -> void: diff --git a/src/game/engine/src/modules/tech/knowledge_web.gd b/src/game/engine/src/modules/tech/knowledge_web.gd new file mode 100644 index 00000000..09b94f99 --- /dev/null +++ b/src/game/engine/src/modules/tech/knowledge_web.gd @@ -0,0 +1,160 @@ +class_name KnowledgeWeb +extends RefCounted +## Shared GDScript wrapper over a Rust knowledge graph (TechWeb / CultureWeb). +## Holds the GDExtension class instance, the per-player progress fields it +## reads from each `Player` entity, and the EventBus signal names it emits +## on research start. Tech and culture paths instantiate this same class +## with different `_config` so the UI scene can drive both. + +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") + +## Configuration accepted by `_init`. Keys (all required): +## gd_class — `"GdTechWeb"` / `"GdCultureWeb"` +## data_loader_method — `"get_all_techs"` / `"get_all_culture"` +## load_method — `"load_from_json"` +## data_method — `"tech_data_json"` / `"tradition_data_json"` +## pillars_method — `"pillars"` +## nodes_by_pillar_method — `"techs_by_pillar"` / `"traditions_by_pillar"` +## prereqs_met_method — `"prereqs_met"` +## researched_field — Player Array[String] field +## researching_field — Player String field +## progress_field — Player int field +## started_signal — EventBus signal name fired by `start_research` + +var _bridge: RefCounted = null +var _config: Dictionary = {} +var _node_count: int = 0 + + +func _init(config: Dictionary) -> void: + _config = config + + +## Lazily instantiate the GDExtension bridge and load the JSON dataset +## from `DataLoader`. Idempotent. +func build() -> void: + if _bridge != null: + return + var class_name_str: String = String(_config["gd_class"]) + if not ClassDB.class_exists(class_name_str): + push_error("KnowledgeWeb: GDExtension class %s not registered" % class_name_str) + return + _bridge = ClassDB.instantiate(class_name_str) as RefCounted + var nodes: Array = DataLoader.call(String(_config["data_loader_method"])) as Array + var json_text: String = JSON.stringify(nodes) + var ok: bool = bool(_bridge.call(String(_config["load_method"]), json_text)) + if not ok: + push_error("KnowledgeWeb: %s.%s failed" % [class_name_str, String(_config["load_method"])]) + _node_count = nodes.size() + + +# ── Graph queries ─────────────────────────────────────────────────────── + +func get_node_count() -> int: + return _node_count + + +func get_pillars() -> Array[String]: + if _bridge == null: + return [] + var raw: PackedStringArray = _bridge.call(String(_config["pillars_method"])) as PackedStringArray + var out: Array[String] = [] + for s: String in raw: + out.append(s) + return out + + +func get_nodes_by_pillar(pillar: String) -> Array[String]: + if _bridge == null: + return [] + var raw: PackedStringArray = _bridge.call( + String(_config["nodes_by_pillar_method"]), pillar + ) as PackedStringArray + var out: Array[String] = [] + for s: String in raw: + out.append(s) + return out + + +func get_data(node_id: String) -> Dictionary: + if _bridge == null: + return {} + var json_text: String = _bridge.call(String(_config["data_method"]), node_id) as String + if json_text.is_empty(): + return {} + var json: JSON = JSON.new() + if json.parse(json_text) != OK or not (json.data is Dictionary): + return {} + return json.data as Dictionary + + +# ── Per-player queries ────────────────────────────────────────────────── + +func is_researched(node_id: String, player_index: int) -> bool: + var researched: Array[String] = _researched_set(player_index) + return researched.has(node_id) + + +func can_research(node_id: String, player_index: int) -> bool: + if _bridge == null or node_id.is_empty(): + return false + var researched: Array[String] = _researched_set(player_index) + if researched.has(node_id): + return false + var prereq_set: PackedStringArray = [] + for r: String in researched: + prereq_set.append(r) + return bool(_bridge.call(String(_config["prereqs_met_method"]), node_id, prereq_set)) + + +func get_current_research(player_index: int) -> String: + var player: PlayerScript = _get_player(player_index) + if player == null: + return "" + return String(player.get(String(_config["researching_field"]))) + + +func get_progress_fraction(player_index: int) -> float: + var current: String = get_current_research(player_index) + if current.is_empty(): + return 0.0 + var data: Dictionary = get_data(current) + var cost: int = int(data.get("cost", 0)) + if cost <= 0: + return 1.0 + var player: PlayerScript = _get_player(player_index) + if player == null: + return 0.0 + var progress: int = int(player.get(String(_config["progress_field"]))) + return clamp(float(progress) / float(cost), 0.0, 1.0) + + +func start_research(node_id: String, player_index: int) -> bool: + if not can_research(node_id, player_index): + return false + var player: PlayerScript = _get_player(player_index) + if player == null: + return false + player.set(String(_config["researching_field"]), node_id) + player.set(String(_config["progress_field"]), 0) + EventBus.emit_signal(String(_config["started_signal"]), node_id, player_index) + return true + + +# ── Internals ─────────────────────────────────────────────────────────── + +func _get_player(player_index: int) -> PlayerScript: + if player_index < 0 or player_index >= GameState.players.size(): + return null + return GameState.players[player_index] as PlayerScript + + +func _researched_set(player_index: int) -> Array[String]: + var player: PlayerScript = _get_player(player_index) + if player == null: + return [] + var raw: Array = player.get(String(_config["researched_field"])) as Array + var out: Array[String] = [] + for v in raw: + out.append(String(v)) + return out diff --git a/src/game/engine/src/modules/tech/tech_web.gd b/src/game/engine/src/modules/tech/tech_web.gd index 6b0f081e..cfc7846d 100644 --- a/src/game/engine/src/modules/tech/tech_web.gd +++ b/src/game/engine/src/modules/tech/tech_web.gd @@ -1,15 +1,106 @@ class_name TechWeb extends RefCounted -## Placeholder TechWeb. Real tech graph/research lives on backlog until the -## mc-tech crate + GdTechWeb binding land. These no-ops keep existing callers -## (turn_manager, tech_tree scene, auto_play) from crashing at runtime. +## Real GDScript wrapper around the `GdTechWeb` GDExtension class. +## Delegates the prerequisite graph and research mechanics to the Rust +## `mc-tech::TechWeb` and exposes the same surface `tech_tree.gd` / +## `tech_tree_proof.gd` rely on (`get_pillars`, `get_techs_by_pillar`, +## `get_tech_data`, `is_researched`, `can_research`, `get_current_research`, +## `get_progress_fraction`, `start_research`). -var _nodes: Dictionary = {} +const KnowledgeWebScript: GDScript = preload( + "res://engine/src/modules/tech/knowledge_web.gd" +) +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const _CONFIG: Dictionary = { + "gd_class": "GdTechWeb", + "data_loader_method": "get_all_techs", + "load_method": "load_from_json", + "data_method": "tech_data_json", + "pillars_method": "pillars", + "nodes_by_pillar_method": "techs_by_pillar", + "prereqs_met_method": "prereqs_met", + "researched_field": "researched_techs", + "researching_field": "researching", + "progress_field": "research_progress", + "started_signal": "tech_research_started", +} + +var _web: KnowledgeWebScript = KnowledgeWebScript.new(_CONFIG) + + +# ── TurnManager-compatible API (build / heritage) ─────────────────────── func build(_techs: Array) -> void: - pass + ## Argument retained for backwards compatibility with the old placeholder; + ## the real load reads directly from `DataLoader.get_all_techs()`. + _web.build() -func apply_heritage_tech(_player: RefCounted) -> void: - pass +func apply_heritage_tech(player: RefCounted) -> void: + ## Grant the player's race origin tech (cost 0 by convention). + if player == null: + return + var p: PlayerScript = player as PlayerScript + if p == null: + return + var data: Dictionary = DataLoader.get_race(p.race_id) as Dictionary + var origin: String = String(data.get("origin_tech", "")) + if origin.is_empty(): + return + p.add_tech(origin) + + +# ── Internal `_nodes` accessor — TurnManager uses it as a "is loaded" probe ─ + +var _nodes: Dictionary: + get: + # Non-empty once `build()` has run. + return {0: 1} if _web.get_node_count() > 0 else {} + + +# ── Graph queries (proxied to KnowledgeWeb) ───────────────────────────── + +func get_pillars() -> Array[String]: + return _web.get_pillars() + + +func get_techs_by_pillar(pillar: String) -> Array[String]: + return _web.get_nodes_by_pillar(pillar) + + +func get_tech_data(tech_id: String) -> Dictionary: + return _web.get_data(tech_id) + + +func get_tech_count() -> int: + return _web.get_node_count() + + +func get_available_techs(player_index: int) -> Array[String]: + var out: Array[String] = [] + for pillar: String in _web.get_pillars(): + for id: String in _web.get_nodes_by_pillar(pillar): + if _web.can_research(id, player_index): + out.append(id) + return out + + +func is_researched(tech_id: String, player_index: int) -> bool: + return _web.is_researched(tech_id, player_index) + + +func can_research(tech_id: String, player_index: int) -> bool: + return _web.can_research(tech_id, player_index) + + +func get_current_research(player_index: int) -> String: + return _web.get_current_research(player_index) + + +func get_progress_fraction(player_index: int) -> float: + return _web.get_progress_fraction(player_index) + + +func start_research(tech_id: String, player_index: int) -> bool: + return _web.start_research(tech_id, player_index) diff --git a/src/simulator/api-gdext/src/ai.rs b/src/simulator/api-gdext/src/ai.rs index dafaaa27..49303a57 100644 --- a/src/simulator/api-gdext/src/ai.rs +++ b/src/simulator/api-gdext/src/ai.rs @@ -961,6 +961,10 @@ mod tests { capital_position: Some((0, 0)), culture_total: 0, culture_pool: mc_culture::CulturePool::default(), + researching_tradition: String::new(), + culture_research_progress: 0, + researched_traditions: Default::default(), + player_culture: None, arcane_lore_pop_deducted: false, traded_luxuries: Default::default(), relations: Default::default(), diff --git a/src/simulator/crates/mc-sim/src/lib.rs b/src/simulator/crates/mc-sim/src/lib.rs index 9b6db0fc..6b8a41a4 100644 --- a/src/simulator/crates/mc-sim/src/lib.rs +++ b/src/simulator/crates/mc-sim/src/lib.rs @@ -181,6 +181,8 @@ impl GameRunner { deposit_yield_table: Default::default(), tech_web: None, tech_web_parsed: None, + culture_web: None, + culture_web_parsed: None, lair_combat_config: Default::default(), victory_config: None, }; diff --git a/src/simulator/tests/integration/tests/pvp_combat_determinism.rs b/src/simulator/tests/integration/tests/pvp_combat_determinism.rs index 3807772c..1a5e3e79 100644 --- a/src/simulator/tests/integration/tests/pvp_combat_determinism.rs +++ b/src/simulator/tests/integration/tests/pvp_combat_determinism.rs @@ -49,7 +49,11 @@ fn make_player(index: u8, unit: MapUnit) -> PlayerState { city_positions: vec![(0, 0)], capital_position: None, culture_total: 0, - culture_pool: mc_culture::CulturePool::default(), + culture_pool: mc_culture::CulturePool::default(), + researching_tradition: String::new(), + culture_research_progress: 0, + researched_traditions: Default::default(), + player_culture: None, arcane_lore_pop_deducted: false, traded_luxuries: Default::default(), relations: Default::default(),