fix(@projects/@magic-civilization): 🐛 complete gdscript delegation for research bridge

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-15 18:02:32 -07:00
parent dbeaa971d1
commit 49c23d4915
3 changed files with 149 additions and 103 deletions

View file

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

View file

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

View file

@ -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<AiTechCandidate> = match serde_json::from_str::<Vec<AiTechCandidate>>(
&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<String, i32> =
match serde_json::from_str::<std::collections::HashMap<String, i32>>(&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