feat(@projects/@magic-civilization): add culture research system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-26 00:47:24 -07:00
parent e5a364c7c1
commit b2d79811f7
8 changed files with 306 additions and 9 deletions

View file

@ -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()

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -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(),

View file

@ -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,
};

View file

@ -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(),