fix(simulator): 🔬 load the TechWeb into the headless path so research advances
Research was completely dead in the headless simulation path (the RL trainer, the MCP, and the hotseat driver all use it): the per-turn TurnProcessor's tech_web_parsed was always None, so process_science never advanced anything. 0 techs ever completed despite science_per_turn climbing to 466 — units stayed frozen at tier-1 dwarf_warrior and AI-vs-AI ground to an unbreakable stalemate. The processor already self-drives research (auto-picks the next available tech in topological order once a TechWeb is loaded); the only defect was nothing loaded it. - GameState gains tech_web_json (#[serde(skip)], boot-loaded like the AI catalogs). - apply_end_turn threads it into the fresh per-turn processor via set_tech_web_json before step(). - GdPlayerApi::set_tech_web_json stamps it onto the dispatch state AFTER load_state_json (serde(skip) means it can't ride through gs.to_json()). - The headless harness flattens every public/resources/techs/*.json pillar (prereqs cross pillars, so all load together) and calls the setter at boot. Proven (hotseat self-play, seed 42): techs_done 0 → 10 → 40 → 78 → 109 by turn 120. mc-player-api 132 tests green. Known next layer (separate gap, not this fix): completed tech does not yet translate to better units — production still builds only tier-1 warriors even at 109 techs, despite higher-tier units (steam_golem, iron_sentinel, …) existing in the data. That's a production-picker/catalog issue to chase next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
45ffb44bcf
commit
d6ca9f478d
4 changed files with 88 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -409,6 +409,15 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result<Vec<Event>,
|
|||
// 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -418,6 +418,15 @@ pub struct GameState {
|
|||
/// no-building behaviour.
|
||||
#[serde(skip)]
|
||||
pub ai_building_catalog: Vec<mc_core::tactical_types::TacticalBuildingSpec>,
|
||||
/// 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`.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue