From 49c23d491537bcceb818ead6ca0681ab5aad1d4e Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 15 May 2026 18:02:32 -0700 Subject: [PATCH] =?UTF-8?q?fix(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=90=9B=20complete=20gdscript=20delegation=20for=20researc?= =?UTF-8?q?h=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../p2-43a-followup-gdscript-delegation.md | 40 +++-- src/game/engine/scenes/tests/auto_play.gd | 140 +++++++----------- src/simulator/api-gdext/src/ai.rs | 72 ++++++++- 3 files changed, 149 insertions(+), 103 deletions(-) diff --git a/.project/objectives/p2-43a-followup-gdscript-delegation.md b/.project/objectives/p2-43a-followup-gdscript-delegation.md index 20aa0e25..dd5eba37 100644 --- a/.project/objectives/p2-43a-followup-gdscript-delegation.md +++ b/.project/objectives/p2-43a-followup-gdscript-delegation.md @@ -2,9 +2,9 @@ id: p2-43a-followup-gdscript-delegation title: "Shared infra — wire GdAiController into auto_play.gd so Rail-1 bridges can be one-liners" priority: p3 -status: partial +status: done scope: game1 -updated_at: 2026-05-14 +updated_at: 2026-05-15 parent: p0-26 related: - p2-43a @@ -47,17 +47,28 @@ sweep. in one call. All inline scoring (mercantile blend, cost normalisation, argmax) removed — Rust `mc_ai::tactical::culture_pick` is now the single source of truth for tradition selection. -- [ ] `_pick_research` collapses to a single bridge call. - → BLOCKED on Rust `GdAiController::pick_research` bridge (no `#[func]` - exists yet in `src/simulator/api-gdext/src/ai.rs`). `mc_ai::evaluator::pick_tech` - already implements the scoring; only the GDExtension surface is - missing. Tracked under p0-26. Docstring in `_pick_research` updated to - point at the future one-liner pattern. +- [x] `_pick_research` collapses to a single bridge call. + → `GdAiController::pick_research(available_json, personality_axes_json) -> GString` + added to `src/simulator/api-gdext/src/ai.rs` mirroring the + `pick_culture_tradition` pattern: parses an `AiTechCandidate[]` JSON + + raw 1..=10 axes dict, builds a minimal `AiPlayerState` carrying the + axes, derives `StrategicWeights::from_race_axes`, and delegates to + `mc_ai::evaluator::ScoringEvaluator::pick_tech`. Parse failures / + empty lists return `""` and log via `godot_error!` — the bridge never + silently substitutes a default. `auto_play.gd::_pick_research` now + filters out already-researched + unmet-prereq techs (cheap GDScript + pass that needs live `player.has_tech` access), builds the candidate + list, and hands axes + candidates across the bridge in one + `GameState.get_ai_controller().pick_research(...)` call. All inline + scoring (pillar multipliers, prereq-mult Pass 2, tier-3+ mercantile + penalty, tier-4 unit unlocks bonus) removed — Rust + `mc_ai::evaluator::pick_tech` is now the single source of truth. + Verified `cargo check -p magic-civ-physics-gdext` exit 0. - [x] Any other mirrored `_pick_*` AI helpers in `auto_play.gd` use the controller, no inline scoring shadows remain. → Only `_pick_research` and `_pick_culture_tradition` exist in - `auto_play.gd` (verified by `grep -n "^func _pick_"`). One delegated, - one blocked on a missing Rust bridge (above). + `auto_play.gd` (verified by `grep -n "^func _pick_"`). Both now + delegate to `GdAiController` — no inline scoring shadows remain. - [x] GUT smoke covers controller instantiation under `--headless`. → New `src/game/engine/tests/unit/ai/test_ai_controller_accessor.gd` asserts non-null return, idempotency, and reset semantics. Existing @@ -65,12 +76,13 @@ sweep. already covers method-presence. Stale `test_gd_ai_controller_absent` sentinel removed — it contradicted the EXPECTED_CLASSES entry. -K/N = 4/5 — `_pick_research` collapse blocked on missing Rust bridge. +K/N = 5/5 — all delegation bullets ticked. ## Out of scope - Re-porting any individual `pick_*` function — those Rust modules already exist and have unit tests; this objective only wires them. -- Adding the `GdAiController::pick_research` Rust `#[func]` (tracked in - p0-26 — once it lands, `_pick_research` collapses in a single-line - GDScript follow-up). +- Adding the `GdAiController::pick_research` Rust `#[func]` — landed + inline with this objective rather than deferred to p0-26, since the + scorer (`mc_ai::evaluator::pick_tech`) already existed and the bridge + was a thin shim mirroring `pick_culture_tradition`. diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index a8ab120a..98887cad 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -1247,25 +1247,56 @@ func _play_turn() -> void: func _pick_research(player: RefCounted) -> void: - ## Score available techs: base = 1000/cost; per-pillar personality multiplier; - ## unlocks tier≥4 unit adds ×3; prerequisite of high-value tech adds ×1.5. - ## Personality axes drive per-pillar multipliers so clan research orders diverge: - ## military → aggression (blackhammer rushes military techs) - ## metallurgy → production (ironhold/deepforge prioritise smithing) - ## agriculture → expansion (blackhammer/runesmith expand aggressively) - ## civics → wealth + trade_willingness (goldvein) - ## scholarship → wealth + production blend (goldvein science income) - ## ecology → expansion × 0.5 + production × 0.5 (deepforge tall-empire) + ## p2-43a-followup (5th bullet) / p0-26: scoring logic lives in + ## `mc-ai::evaluator::ScoringEvaluator::pick_tech`; this call site is a + ## thin Rail-1 bridge delegation through the shared `GdAiController` + ## instance held on `GameState`. ## - ## Research scoring belongs in mc-ai::ScoringEvaluator::pick_tech (Rail-1). - ## This test-harness path reads axes inline because no `GdAiController` - ## bridge for `pick_tech` exists yet — when it lands, this body collapses - ## to a single `GameState.get_ai_controller().pick_research(...)` call - ## (same pattern as `_pick_culture_tradition` below). Tracked in p0-26. - var all_techs: Array = DataLoader.get_all_techs() + ## Prereq + already-researched filtering is done in GDScript (cheap, needs + ## live `player.has_tech` access); the Rust scorer receives a pre-filtered + ## `AiTechCandidate` list plus the clan's raw 1..=10 axes and picks the + ## highest-scoring tech. + var ctrl: RefCounted = GameState.get_ai_controller() + if ctrl == null: + push_warning( + "_pick_research: GdAiController unavailable — skipping pick " + + "(player will retry next turn)" + ) + return - # Load clan personality axes (1..=10). Defaults to 5 (neutral) if clan - # is unset so vanilla scoring degrades gracefully to neutral multipliers. + # Build prereq-filtered `AiTechCandidate` list. The Rust struct fields: + # {id, pillar, cost, tier, unlocks_buildings, unlocks_units} + var candidates: Array = [] + for tech: Dictionary in DataLoader.get_all_techs(): + var tid: String = str(tech.get("id", "")) + if tid.is_empty() or player.has_tech(tid): + continue + var met: bool = true + for req: Variant in tech.get("requires", []): + if not player.has_tech(str(req)): + met = false + break + if not met: + continue + var unlocks: Dictionary = tech.get("unlocks", {}) + var unlock_buildings: Array = [] + for bid: Variant in unlocks.get("buildings", []): + unlock_buildings.append(str(bid)) + var unlock_units: Array = [] + for uid: Variant in unlocks.get("units", []): + unlock_units.append(str(uid)) + candidates.append({ + "id": tid, + "pillar": str(tech.get("pillar", "")), + "cost": maxi(int(tech.get("cost", 1)), 1), + "tier": int(tech.get("tier", 0)), + "unlocks_buildings": unlock_buildings, + "unlocks_units": unlock_units, + }) + if candidates.is_empty(): + return + + # Personality axes (raw 1..=10) — bridge normalises internally. var clan_id: String = str(player.get("clan_id") if player.get("clan_id") != null else "") var axes: Dictionary = {} if not clan_id.is_empty(): @@ -1273,82 +1304,15 @@ func _pick_research(player: RefCounted) -> void: if personality != null and not personality.is_empty(): axes = personality.get("strategic_axes", {}) - # Normalise raw 1..=10 axis values to [0, 1] (neutral 5 → 0.44). - var agg: float = _norm_axis(axes, "aggression") - var prod: float = _norm_axis(axes, "production") - var wlth: float = _norm_axis(axes, "wealth") - var trd: float = _norm_axis(axes, "trade_willingness") - var exp: float = _norm_axis(axes, "expansion") + var best_id: String = ctrl.pick_research( + JSON.stringify(candidates), JSON.stringify(axes) + ) - # Per-pillar multiplier derived from clan axes (range 1.0..=2.0). - # Base of 1.0 ensures clans with low axes still research every pillar. - var pillar_mult: Dictionary = { - "military": 1.0 + agg, - "metallurgy": 1.0 + prod, - "agriculture": 1.0 + exp * 0.8, - "civics": 1.0 + (wlth + trd) / 2.0 * 0.7, - "scholarship": 1.0 + (wlth + prod) / 2.0 * 0.6, - "ecology": 1.0 + (exp + prod) / 2.0 * 0.5, - } - - # Pass 1: compute raw score for every tech (ignoring availability). - var raw_score: Dictionary = {} - for tech: Dictionary in all_techs: - var tid: String = str(tech.get("id", "")) - if tid.is_empty(): - continue - var cost: int = maxi(int(tech.get("cost", 1)), 1) - var sc: float = 1000.0 / float(cost) - # Apply personality-driven pillar multiplier (replaces hardcoded x2 for military). - var pillar: String = str(tech.get("pillar", "")) - sc *= float(pillar_mult.get(pillar, 1.0)) - # Tier-3+ penalty for mercantile clans (low aggression AND low production). - # Guards goldvein/runesmith from racing to tier_peak=6 identically to - # ironhold/blackhammer. agg < 0.5 catches raw ≤5; prod < 0.5 catches raw ≤5. - if int(tech.get("tier", 0)) >= 3 and agg < 0.5 and prod < 0.5: - var trade_factor: float = (wlth + trd) / 2.0 # mercantile bias, [0, 1] - sc *= maxf(0.4, 1.0 - trade_factor * 0.6) # up to 60% penalty for full mercantile clans - for uid: Variant in tech.get("unlocks", {}).get("units", []): - var udata: Dictionary = DataLoader.get_unit(str(uid)) - if int(udata.get("tier", 1)) >= 4: - sc *= 3.0 - break - raw_score[tid] = sc - - # Pass 2: prerequisites of any tech scoring >= 20 get a 1.5x boost. - var prereq_mult: Dictionary = {} - for tech: Dictionary in all_techs: - var tid: String = str(tech.get("id", "")) - if not raw_score.has(tid) or float(raw_score[tid]) < 20.0: - continue - for req: Variant in tech.get("requires", []): - var rid: String = str(req) - prereq_mult[rid] = maxf(float(prereq_mult.get(rid, 1.0)), 1.5) - - # Pass 3: pick the highest-scoring available tech. - var best_id: String = "" - var best_score: float = -1.0 - for tech: Dictionary in all_techs: - var tid: String = str(tech.get("id", "")) - if tid.is_empty() or player.has_tech(tid): - continue - var reqs: Array = tech.get("requires", []) - var met: bool = true - for req: Variant in reqs: - if not player.has_tech(str(req)): - met = false - break - if not met: - continue - var score: float = float(raw_score.get(tid, 1.0)) * float(prereq_mult.get(tid, 1.0)) - if score > best_score: - best_score = score - best_id = tid if not best_id.is_empty(): player.researching = best_id player.research_progress = 0 if _turn_count <= 5 or _turn_count % 20 == 0: - print(" Researching: %s (score %.1f)" % [best_id, best_score]) + print(" Researching: %s" % best_id) func _pick_culture_tradition(player: RefCounted) -> void: diff --git a/src/simulator/api-gdext/src/ai.rs b/src/simulator/api-gdext/src/ai.rs index fda40556..b79e2ad8 100644 --- a/src/simulator/api-gdext/src/ai.rs +++ b/src/simulator/api-gdext/src/ai.rs @@ -20,7 +20,7 @@ use godot::prelude::*; use mc_ai::abstract_state::MAX_PLAYERS; use mc_ai::evaluator::{ScoringEvaluator, ScoringWeights}; -use mc_ai::game_state::{AiPlayerState, StrategicWeights}; +use mc_ai::game_state::{AiPlayerState, AiTechCandidate, StrategicWeights}; use mc_ai::mcts::XorShift64; use mc_ai::policy::PersonalityPriors; use mc_ai::tactical::culture_pick::{ @@ -671,6 +671,76 @@ impl GdAiController { None => GString::from(""), } } + + /// p2-43a-followup (5th bullet) / p0-26 — pick the best research tech from + /// a prereq-filtered candidate list. + /// + /// `available_json` is a JSON array of `AiTechCandidate` records + /// (`{"id", "pillar", "cost", "tier", "unlocks_buildings", "unlocks_units"}`). + /// The GDScript caller (`auto_play.gd::_pick_research`) is expected to + /// pre-filter to techs the player has not yet researched and whose + /// prerequisites are met; this bridge does not re-check prereqs. + /// `personality_axes_json` is the raw 1..=10 strategic-axes dict for the + /// clan (the body of `ai_personalities.json[clan_id].strategic_axes`); + /// missing/extra keys fall through to the neutral default of 5. + /// + /// Returns the picked tech id, or an empty string when the list is empty + /// or on parse failure (logged via `godot_error!`). The bridge never + /// silently substitutes a default. + /// + /// Scoring is delegated to [`mc_ai::evaluator::ScoringEvaluator::pick_tech`] + /// — the single source of truth for tech selection (Rail-1). + #[func] + fn pick_research( + &self, + available_json: GString, + personality_axes_json: GString, + ) -> GString { + let avail_src = available_json.to_string(); + let candidates: Vec = match serde_json::from_str::>( + &avail_src, + ) { + Ok(v) => v, + Err(e) => { + godot_error!( + "GdAiController::pick_research available parse error: {}", + e + ); + return GString::from(""); + } + }; + if candidates.is_empty() { + return GString::from(""); + } + + let axes_src = personality_axes_json.to_string(); + let axes: std::collections::HashMap = + match serde_json::from_str::>(&axes_src) { + Ok(a) => a, + Err(e) => { + godot_error!( + "GdAiController::pick_research axes parse error: {}", + e + ); + return GString::from(""); + } + }; + + // Build a minimal AiPlayerState carrying axes; other fields default + // (gold=0, threat_level=0, no luxury bonuses) — matches the + // information the GDScript inline scorer historically had access to. + let mut state = AiPlayerState::default(); + state.strategic_axes = axes.clone(); + state.is_researching = false; + + let strategic = StrategicWeights::from_race_axes(&axes); + let evaluator = ScoringEvaluator::default(); + + match evaluator.pick_tech(&candidates, &state, &strategic) { + Some(id) => GString::from(id), + None => GString::from(""), + } + } } /// JSON-side mirror of [`TraditionCandidate`] — keeps the bridge contract