feat(@projects/@magic-civilization): ✨ add culture research system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
e5a364c7c1
commit
b2d79811f7
8 changed files with 306 additions and 9 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
160
src/game/engine/src/modules/tech/knowledge_web.gd
Normal file
160
src/game/engine/src/modules/tech/knowledge_web.gd
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue