diff --git a/src/game/engine/scenes/headless/player_api_main.gd b/src/game/engine/scenes/headless/player_api_main.gd index 932d41bb..a441636c 100644 --- a/src/game/engine/scenes/headless/player_api_main.gd +++ b/src/game/engine/scenes/headless/player_api_main.gd @@ -189,6 +189,51 @@ func _hydrate_player_api(num_players: int) -> void: # of truth for both AI paths. _apply_ai_catalogs() + # Load the tech web so the per-turn processor auto-advances research. + # Same `#[serde(skip)]` constraint as the AI catalogs — stamp on + # `GdPlayerApi` AFTER `load_state_json`. Without this the headless path + # never researches: tech frozen at 0, units stuck at tier-1, AI-vs-AI + # stalemates. + _apply_tech_web() + + +## Concatenate every `public/resources/techs/*.json` pillar into one flat +## array and hand it to `GdPlayerApi.set_tech_web_json`. All pillars must load +## together — prerequisites cross pillar files. Mirrors the integer-preserving +## raw-concatenation in `_apply_runtime_units_catalog` (no JSON round-trip, so +## `era`/`tier`/`cost` stay integers the Rust deserialiser accepts). +func _apply_tech_web() -> void: + const TECHS_DIR: String = "res://public/resources/techs" + var dir: DirAccess = DirAccess.open(TECHS_DIR) + if dir == null: + _emit_protocol_error("could not open " + TECHS_DIR + " — research disabled (tier-1 lock)") + return + var raw_parts: PackedStringArray = PackedStringArray() + dir.list_dir_begin() + var fname: String = dir.get_next() + while fname != "": + if ( + not dir.current_is_dir() + and fname.ends_with(".json") + and not fname.ends_with(".schema.json") + ): + var f: FileAccess = FileAccess.open(TECHS_DIR + "/" + fname, FileAccess.READ) + if f != null: + var stripped: String = f.get_as_text().strip_edges() + f.close() + if stripped.length() > 0 and stripped.substr(0, 1) == "[": + var inner: String = stripped.substr(1, stripped.length() - 2).strip_edges() + if inner.length() > 0: + raw_parts.append(inner) + fname = dir.get_next() + dir.list_dir_end() + if raw_parts.is_empty(): + _emit_protocol_error("no tech JSON harvested from " + TECHS_DIR + " — research disabled") + return + var json: String = "[" + ",".join(raw_parts) + "]" + var n: int = int(_api.set_tech_web_json(json)) + _emit_event("tech_web_loaded", {"techs": n}) + ## p2-71c — load the runtime UnitsCatalog (id → UnitStats: base_moves, ## domain) onto `gs` so `add_player_militarist` can construct units with diff --git a/src/simulator/api-gdext/src/player_api.rs b/src/simulator/api-gdext/src/player_api.rs index 8f584912..0200a927 100644 --- a/src/simulator/api-gdext/src/player_api.rs +++ b/src/simulator/api-gdext/src/player_api.rs @@ -164,6 +164,31 @@ impl GdPlayerApi { } } + /// Boot-load the flattened tech-definition JSON (every + /// `public/resources/techs/*.json` pillar concatenated) onto the dispatch + /// `GameState`. `apply_end_turn` threads it into the per-turn + /// `TurnProcessor` so `process_science` auto-advances research in + /// topological order. `tech_web_json` is `#[serde(skip)]`, so — like the + /// AI catalogs — it must be stamped on `GdPlayerApi` AFTER + /// `load_state_json`, not on the pre-serialised `GdGameState`. Validates + /// by parsing into a `TechWeb` (catching cross-pillar prereq / cycle + /// errors at boot) and returns the tech count; 0 on failure. + #[func] + pub fn set_tech_web_json(&mut self, json: GString) -> i64 { + let s = json.to_string(); + match mc_tech::TechWeb::from_json(&s) { + Ok(web) => { + let n = web.tech_count() as i64; + self.state.tech_web_json = s; + n + } + Err(e) => { + godot_error!("GdPlayerApi::set_tech_web_json failed: {e}"); + 0 + } + } + } + /// p2-71 — set the difficulty threshold multiplier projected into /// `TacticalState::difficulty_threshold_mult`. Easy < 1.0, Hard > 1.0, /// Normal == 1.0. Values <= 0.0 are coerced to 1.0 (neutral) inside diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index 064ca7e7..e82a0935 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -409,6 +409,15 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result, // single authored source; `load_authored_encounter_rates` bakes a // build-time copy for this headless path (no GDScript DataLoader). processor.load_authored_encounter_rates(); + // Load the boot-loaded TechWeb so `process_science` auto-advances + // research (topological order) each turn. Without this the fresh + // per-turn processor has `tech_web_parsed: None` and research is frozen + // at 0 techs — units never leave tier-1 and AI-vs-AI stalemates. The + // JSON was already validated at boot by `GdGameState::set_tech_web_json`, + // so a parse error here is non-fatal (proceed research-less). + if !state.tech_web_json.is_empty() { + let _ = processor.set_tech_web_json(&state.tech_web_json); + } if let Some(vc) = victory_config_from_env() { processor.victory_config = Some(vc); } diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index ac3f3af7..53b35ca9 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -418,6 +418,15 @@ pub struct GameState { /// no-building behaviour. #[serde(skip)] pub ai_building_catalog: Vec, + /// Flattened tech-definition JSON (every `public/resources/techs/*.json` + /// pillar concatenated into one array), boot-loaded by the harness via + /// `GdGameState::set_tech_web_json`. Threaded into the per-turn + /// `TurnProcessor` by `mc_player_api::dispatch::apply_end_turn` so + /// `process_science` auto-advances research; empty (default) leaves the + /// headless path research-less (tier-1 fallback). `#[serde(skip)]` for the + /// same boot-loaded-not-save-persisted reason as the catalogs above. + #[serde(skip)] + pub tech_web_json: String, /// p2-71: difficulty multiplier projected into /// `TacticalState::difficulty_threshold_mult`. Defaults to 1.0 /// (normal). Populated by `GdPlayerApi::set_difficulty_threshold_mult`.