feat(@projects/@magic-civilization): implement ai controller delegation bridge

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-14 21:57:51 -07:00
parent b95a500744
commit ccd9e7c99c
13 changed files with 664 additions and 49 deletions

View file

@ -2,7 +2,7 @@
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: stub
status: partial
scope: game1
updated_at: 2026-05-14
parent: p0-26
@ -29,16 +29,48 @@ sweep.
## Acceptance
- [ ] `auto_play.gd` holds a `GdAiController` instance (or equivalent
- [x] `auto_play.gd` holds a `GdAiController` instance (or equivalent
autoload-style accessor) reachable from each `_pick_*` site.
- [ ] `_pick_culture_tradition` collapses to a single bridge call
`GameState.get_ai_controller()` + `reset_ai_controllers()` +
`_ai_map_initialized` field added to
`src/game/engine/src/autoloads/game_state.gd`. Lazy-instantiates on
first call, returns the shared `GdAiController` RefCounted, cleared on
game-reset. This is also the accessor `ai_turn_bridge.gd` (lines
214, 297, 306, 315, 324, 342) already calls — the autoload addition
fixes a latent runtime-null gap as well as enabling the bullet below.
- [x] `_pick_culture_tradition` collapses to a single bridge call
(closes p2-43a's remaining delegation bullet).
`src/game/engine/scenes/tests/auto_play.gd::_pick_culture_tradition`
now builds a `{id, cost}` candidate list from
`CultureWeb.get_available_traditions/get_tradition_data` and hands axes
+ candidates to `GameState.get_ai_controller().pick_culture_tradition(...)`
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.
- [ ] Any other mirrored `_pick_*` AI helpers in `auto_play.gd` use the
→ 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] Any other mirrored `_pick_*` AI helpers in `auto_play.gd` use the
controller, no inline scoring shadows remain.
- [ ] GUT smoke covers controller instantiation under `--headless`.
→ 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).
- [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
`test_gdextension_contract.gd::EXPECTED_CLASSES["GdAiController"]`
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.
## 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).

View file

@ -55,6 +55,8 @@
},
"tier": 8,
"action_point_capacity": 16,
"capturable": true,
"ransom_multiplier": 5.0,
"great_person_class": "great_engineer",
"great_person_gpp_type": "engineering",
"great_person_produces": "wonder_hurry",

View file

@ -70,6 +70,8 @@
},
"tier": 1,
"action_point_capacity": 6,
"capturable": true,
"ransom_multiplier": 3.0,
"great_person_class": "great_engineer",
"great_person_gpp_type": "engineering",
"great_person_produces": "wonder_hurry",

View file

@ -55,6 +55,8 @@
},
"tier": 3,
"action_point_capacity": 14,
"capturable": true,
"ransom_multiplier": 4.0,
"great_person_class": "great_engineer",
"great_person_gpp_type": "engineering",
"great_person_produces": "wonder_hurry",

View file

@ -53,6 +53,8 @@
},
"tier": 2,
"action_point_capacity": 10,
"capturable": true,
"ransom_multiplier": 3.5,
"great_person_class": "great_engineer",
"great_person_gpp_type": "engineering",
"great_person_produces": "wonder_hurry",

View file

@ -1258,8 +1258,10 @@ func _pick_research(player: RefCounted) -> void:
## ecology → expansion × 0.5 + production × 0.5 (deepforge tall-empire)
##
## Research scoring belongs in mc-ai::ScoringEvaluator::pick_tech (Rail-1).
## This test-harness path reads axes inline; wiring through GdAiController
## requires the tactical bridge to emit research actions (tracked in p0-26).
## 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()
# Load clan personality axes (1..=10). Defaults to 5 (neutral) if clan
@ -1350,26 +1352,19 @@ func _pick_research(player: RefCounted) -> void:
func _pick_culture_tradition(player: RefCounted) -> void:
## p2-43a: scoring logic ported to `mc-ai::tactical::culture_pick`.
## p2-43a + p2-43a-followup: scoring logic lives in
## `mc-ai::tactical::culture_pick::pick_culture_tradition`; this call
## site is a thin Rail-1 bridge delegation through the shared
## `GdAiController` instance held on `GameState`.
##
## The canonical scoring function is
## `mc_ai::tactical::culture_pick::pick_culture_tradition` and the bridge
## is `GdAiController::pick_culture_tradition(available_json, axes_json)`.
## This test-harness path matches the Rust scoring inline because
## `auto_play.gd` does not hold a `GdAiController` instance (per
## CLAUDE.md § 'AI exception': `ClassDB.instantiate('GdAiController')` is
## blocked). Production AI consumers route through the bridge — same
## pattern as `_pick_research` above. Any change to the scoring formula
## here MUST be mirrored in `culture_pick.rs` (and vice versa).
##
## Prereq filtering delegates to `CultureWeb.get_available_traditions(...)`
## (Rust GDExt) — never reimplement the prereq graph in GDScript.
## Prereq filtering still delegates to `CultureWeb.get_available_traditions`
## (Rust GDExt) — the GDScript caller builds the `{id, cost}` candidate
## list the Rust scorer expects, then hands axes + candidates across the
## bridge in a single `pick_culture_tradition` call.
if not String(player.researching_tradition).is_empty():
return
var tm: Node = get_node_or_null("/root/TurnManager")
if tm == null:
return
if not tm.has_method("get_culture_web"):
if tm == null or not tm.has_method("get_culture_web"):
return
var culture_web: CultureWeb = tm.get_culture_web() as CultureWeb
if culture_web == null:
@ -1378,34 +1373,41 @@ func _pick_culture_tradition(player: RefCounted) -> void:
if available.is_empty():
return
# Mercantile blend from clan personality axes (defaults to neutral 0.44).
var ctrl: RefCounted = GameState.get_ai_controller()
if ctrl == null:
push_warning(
"_pick_culture_tradition: GdAiController unavailable — skipping pick "
+ "(player will retry next turn)"
)
return
# Build the `{id, cost}` candidate list the Rust scorer expects.
var candidates: Array = []
for tid: String in available:
var data: Dictionary = culture_web.get_tradition_data(tid)
if data.is_empty():
continue
candidates.append({"id": tid, "cost": maxi(int(data.get("cost", 1)), 1)})
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():
var personality: Dictionary = DataLoader.get_ai_personality(clan_id)
if personality != null and not personality.is_empty():
axes = personality.get("strategic_axes", {})
var wlth: float = _norm_axis(axes, "wealth")
var trd: float = _norm_axis(axes, "trade_willingness")
var mercantile_mult: float = 1.0 + (wlth + trd) / 2.0 * 0.5
var best_id: String = ""
var best_score: float = -1.0
for tid: String in available:
var data: Dictionary = culture_web.get_tradition_data(tid)
if data.is_empty():
continue
var cost: int = maxi(int(data.get("cost", 1)), 1)
var sc: float = (1000.0 / float(cost)) * mercantile_mult
if sc > best_score:
best_score = sc
best_id = tid
var best_id: String = ctrl.pick_culture_tradition(
JSON.stringify(candidates), JSON.stringify(axes)
)
if not best_id.is_empty():
player.researching_tradition = best_id
player.culture_research_progress = 0
if _turn_count <= 5 or _turn_count % 20 == 0:
print(" Tradition: %s (score %.1f)" % [best_id, best_score])
print(" Tradition: %s" % best_id)
static func _norm_axis(axes: Dictionary, key: String) -> float:

View file

@ -113,6 +113,27 @@ var _npc_buildings_by_tile: Dictionary = {}
## handle and `state_changed` drives renderer redraw.
var _gd_state: RefCounted = null # GdGameState (RefCounted from gdext)
## p2-43a-followup — shared `GdAiController` instance used by every Rail-1
## bridge call site (`ai_turn_bridge.gd` tactical loop, `auto_play.gd`
## `_pick_*` heuristic delegations, etc.). Held on the autoload so any
## consumer can collapse to a one-liner without re-instantiating the
## controller per call (instantiation is cheap but the controller carries
## per-game state — cached tile map, per-player scoring weights — that must
## be shared across consumers).
##
## `null` until the first `get_ai_controller()` call; cleared by
## `reset_ai_controllers()` on game-reset so the next game starts with a
## fresh cached map. RefCounted lifetime → freed when the last reference
## drops, so clearing this field is sufficient.
var _ai_controller: RefCounted = null # GdAiController (RefCounted from gdext)
## p1-30 — set to `true` after `ai_turn_bridge._init_ai_map()` has pushed
## the full tile catalog into the cached `GdAiController`. Tracked on the
## autoload (rather than the controller) so the bridge can early-exit
## tile-mutation signal handlers before the map is initialized without
## crossing the GDExtension boundary.
var _ai_map_initialized: bool = false
func _ensure_gd_state() -> void:
## Idempotent — instantiates _gd_state if not yet allocated.
@ -123,6 +144,39 @@ func _ensure_gd_state() -> void:
push_error("GameState: GdGameState class not registered (gdext build missing?)")
func get_ai_controller() -> RefCounted:
## p2-43a-followup — lazy accessor for the shared `GdAiController` Rail-1
## bridge instance. Returns `null` when the GDExtension is not loaded
## (e.g. headless tests that never built the dylib) — callers MUST
## null-check before invoking methods on the result.
##
## Idempotent: subsequent calls return the same instance until
## `reset_ai_controllers()` clears the field. This is the canonical
## entry point — never `ClassDB.instantiate("GdAiController")` ad-hoc
## from a caller, or per-game state (cached map, per-player weights)
## gets lost.
if _ai_controller != null:
return _ai_controller
if not ClassDB.class_exists("GdAiController"):
return null
_ai_controller = ClassDB.instantiate("GdAiController") as RefCounted
if _ai_controller == null:
push_error(
"GameState: GdAiController class registered but instantiate returned null"
)
return _ai_controller
func reset_ai_controllers() -> void:
## Drop the shared `GdAiController` reference so the next
## `get_ai_controller()` call builds a fresh instance with no cached
## tile map and no per-player weights. Invoked on game-reset
## (new-game / load-game) so stale state from a prior session does
## not leak across games.
_ai_controller = null
_ai_map_initialized = false
func _ready() -> void:
_ensure_gd_state()

View file

@ -141,8 +141,7 @@ func _exists_failure_message(class_name_str: String) -> String:
"ClassDB.class_exists(\"%s\") must return true. " % class_name_str
+ "If this fails, either (a) the api-gdext GDExtension didn't rebuild "
+ "and load (run src/simulator/build-gdext.sh), or (b) the class was "
+ "renamed/deleted in src/simulator/api-gdext/src/. "
+ "See CLAUDE.md § 'AI exception' for the canonical GdAiController case."
+ "renamed/deleted in src/simulator/api-gdext/src/."
)
@ -344,14 +343,11 @@ func test_gd_mc_tree_controller_has_expected_methods() -> void:
# ---------------------------------------------------------------------------
func test_gd_ai_controller_absent() -> void:
## Per CLAUDE.md: "any code attempting ClassDB.instantiate('GdAiController')
## would return null". Locks in the documented state so a future accidental
## reintroduction is caught rather than silently changing AI routing.
assert_false(
ClassDB.class_exists("GdAiController"),
ABSENT_CLASSES["GdAiController"],
)
# p2-43a-followup — `test_gd_ai_controller_absent` sentinel removed: the
# `GdAiController` class is registered, instantiable, and live behind
# `GameState.get_ai_controller()`. Method-presence coverage lives in
# `EXPECTED_CLASSES["GdAiController"]` above; instantiation coverage lives
# in `tests/unit/ai/test_ai_controller_accessor.gd`.
func test_gd_tech_web_absent() -> void:

View file

@ -0,0 +1,84 @@
extends GutTest
## p2-43a-followup — coverage for the shared `GdAiController` accessor on
## `GameState`. The accessor is the canonical entry point every Rail-1 bridge
## call site uses (`ai_turn_bridge.gd` tactical loop,
## `auto_play.gd::_pick_culture_tradition`, future `_pick_research`, etc.), so
## breaking it silently regresses every AI consumer at once.
##
## Verifies:
## 1. `get_ai_controller()` returns a non-null `RefCounted` when the
## `GdAiController` GDExtension class is registered.
## 2. The accessor is idempotent — repeated calls return the same instance
## (per-game state like the cached tile map must persist across consumers).
## 3. `reset_ai_controllers()` drops the cached instance so the next call
## builds a fresh controller (used by new-game / load-game flows).
## 4. `_ai_map_initialized` is reset alongside the controller.
func before_each() -> void:
GameState.reset_ai_controllers()
func after_all() -> void:
GameState.reset_ai_controllers()
func test_accessor_returns_non_null_when_class_registered() -> void:
if not ClassDB.class_exists("GdAiController"):
# Headless runs without the dylib built — the accessor is still safe
# (returns null), but we can't assert instantiation succeeded.
pending("GdAiController GDExtension not loaded; skipping live check")
return
var ctrl: RefCounted = GameState.get_ai_controller()
assert_not_null(
ctrl,
"GameState.get_ai_controller() must return a live RefCounted when "
+ "GdAiController is registered. If this fails, the lazy-instantiate "
+ "path in game_state.gd is broken — every Rail-1 bridge call site "
+ "(ai_turn_bridge, auto_play._pick_*) will hit the null-guard."
)
func test_accessor_is_idempotent() -> void:
if not ClassDB.class_exists("GdAiController"):
pending("GdAiController GDExtension not loaded; skipping live check")
return
var a: RefCounted = GameState.get_ai_controller()
var b: RefCounted = GameState.get_ai_controller()
assert_eq(
a, b,
"Repeated get_ai_controller() calls must return the SAME instance — "
+ "per-game state (cached tile map, per-player scoring weights) lives "
+ "on the controller and must be shared across consumers."
)
func test_reset_drops_cached_instance() -> void:
if not ClassDB.class_exists("GdAiController"):
pending("GdAiController GDExtension not loaded; skipping live check")
return
var first: RefCounted = GameState.get_ai_controller()
assert_not_null(first)
GameState.reset_ai_controllers()
# After reset, the field is null and a subsequent fetch builds a NEW
# controller — we can't strictly assert object inequality (the RefCounted
# allocator may legitimately reuse the freed slot), but we can assert
# the map-initialized flag is cleared, which is the load-bearing
# observable for the bridge.
assert_false(
GameState._ai_map_initialized,
"reset_ai_controllers() must clear _ai_map_initialized so the next "
+ "AI turn re-pushes the tile catalog into the fresh controller."
)
var refreshed: RefCounted = GameState.get_ai_controller()
assert_not_null(refreshed)
func test_accessor_returns_null_when_class_absent() -> void:
## Sanity guard — when the GDExtension is missing, the accessor must
## return null rather than push_error-ing on every call. Bridges already
## null-check the return value.
if ClassDB.class_exists("GdAiController"):
pending("GdAiController is registered in this build — null-path covered by mock-free tests")
return
assert_null(GameState.get_ai_controller())

View file

@ -235,3 +235,112 @@ fn ransom_decision_refuses_when_rich_but_unwilling() {
let dec = decide_ransom_response(100, 1_000, &priors);
assert_eq!(dec, RansomDecision::Refuse);
}
// ── p2-55a — Engineer (Great Person) scoring ───────────────────────────────
//
// `score_capture_postures` takes `defender_build_cost` and
// `defender_ransom_multiplier` as plain primitives, so the Engineer premium
// flows through automatically once the bridge passes the engineer JSON
// values (cost 70, ransom_multiplier 3.0). These tests pin three properties:
//
// 1. A merchant captor against a rich opponent picks Ransom for an engineer
// (matches existing worker assertion but stresses the higher multiplier
// raises the Ransom expected value).
// 2. Engineer Ransom score strictly exceeds Worker Ransom score under
// otherwise-identical inputs — the premium reaches the scoring layer.
// 3. A high-cost (top-tier) engineer still routes through cleanly; the
// function is monotonic in build_cost and never returns Prompt.
#[test]
fn merchant_picks_ransom_for_an_engineer_against_rich_opponent() {
// dwarf_engineer: build_cost 70, ransom_multiplier 3.0 → ransom_price 210.
// Opponent with 1000g → accept_prob = 0.85 × 0.9 = 0.765.
// Ransom score ≈ 210 × 0.765 ≈ 160.6. Capture score ≈ 70 × 1.2 = 84.
// Destroy ≈ 0.5 × 70 × 0.4 + 4 = 18. Ransom wins.
let balance = CombatBalance::default();
let me = merchant();
let opponent = merchant();
let (posture, score) = score_capture_postures(
/* build_cost */ 70,
/* ransom_mult */ Some(3.0),
/* opponent_gold */ 1_000,
&opponent,
&me,
/* own_worker_count */ 4,
&balance,
);
assert!(score > 0.0);
assert_eq!(
posture,
PostureResolution::Ransom,
"merchant captor with rich opponent should ransom an engineer, got {posture:?}"
);
}
#[test]
fn engineer_ransom_score_exceeds_worker_ransom_score_at_equal_cost() {
// Same captor / opponent / gold / build_cost — only the multiplier
// changes. Engineer (3.0) must produce a strictly higher Ransom expected
// value than Worker (2.0). Drive the scorer through Ransom-favouring
// priors so both paths return Ransom, then compare scores.
let balance = CombatBalance::default();
let me = merchant();
let opponent = merchant();
let opponent_gold = 1_000;
let build_cost = 70;
let (worker_posture, worker_score) = score_capture_postures(
build_cost,
Some(2.0),
opponent_gold,
&opponent,
&me,
4,
&balance,
);
let (eng_posture, eng_score) = score_capture_postures(
build_cost,
Some(3.0),
opponent_gold,
&opponent,
&me,
4,
&balance,
);
assert_eq!(worker_posture, PostureResolution::Ransom);
assert_eq!(eng_posture, PostureResolution::Ransom);
assert!(
eng_score > worker_score,
"engineer ransom score ({eng_score}) must exceed worker ransom score ({worker_score})"
);
}
#[test]
fn ascendant_engineer_high_multiplier_routes_to_ransom_over_destroy() {
// dwarf_ascendant_engineer: build_cost 330, ransom_multiplier 5.0. The
// Great-Person premium drives the Ransom expected value (1650 × 0.85 ×
// threshold) well above the Destroy denial value (0.6 × 330 ×
// destroy_aggression + base_xp). With a rich merchant opponent (high
// threshold + plenty of gold) and a captor who doesn't especially want
// the unit themselves, Ransom must win — this is the central economic
// story of the engineer-multiplier bump: GPs are too valuable to destroy
// when the opponent will pay handsomely.
let balance = CombatBalance::default();
let me = merchant();
let opponent = merchant();
let (posture, score) = score_capture_postures(
330,
Some(5.0),
/* opponent_gold */ 2_000,
&opponent,
&me,
/* own_worker_count */ 4,
&balance,
);
assert!(score > 0.0);
assert_eq!(
posture,
PostureResolution::Ransom,
"ascendant engineer with rich opponent should Ransom, got {posture:?}"
);
}

View file

@ -34,6 +34,21 @@ fn worker() -> UnitStats {
}
}
/// Dwarf engineer combat stats — mirrors `dwarf_engineer.json` (HP 30, no
/// attack). The Great-Person "premium" lives in JSON via `cost` (70) and
/// `ransom_multiplier` (3.0); the resolver consumes those as plain primitives.
fn engineer() -> UnitStats {
UnitStats {
hp: 30,
max_hp: 30,
attack: 0,
defense: 0,
ranged_attack: 0,
range: 0,
movement: 2,
}
}
#[test]
fn capture_posture_clamps_hp_and_suppresses_xp() {
let params = CombatParams {
@ -170,3 +185,85 @@ fn capturable_defender_surviving_hit_still_survives() {
assert_eq!(result.ransom_price, 0, "no ransom for survivors");
}
}
// ── p2-55a — Engineer (Great Person) capture math ──────────────────────────
//
// Engineers are Great People. Their build cost is higher than a worker (70 vs
// 70) but the strategic-value premium comes through the per-unit
// `ransom_multiplier`. The resolver itself is unit-type-agnostic — the
// premium is supplied via the JSON-driven `defender_ransom_multiplier`
// primitive on `CombatParams`. These tests pin that the math flows through
// for engineer-tier multipliers and that a captured engineer carries the
// expected ransom premium over a worker baseline.
#[test]
fn engineer_ransom_price_uses_great_person_multiplier() {
// dwarf_engineer.json: cost=70, ransom_multiplier=3.0 → ransom 210.
let params = CombatParams {
attacker: warrior(),
defender: engineer(),
combat_type: CombatType::Melee,
defender_capturable: true,
posture_resolution: PostureResolution::Ransom,
defender_build_cost: 70,
defender_ransom_multiplier: 3.0,
..CombatParams::default()
};
let result = CombatResolver::resolve(&params);
assert_eq!(result.defender_outcome, CombatOutcome::RansomOffered);
assert_eq!(
result.ransom_price, 210,
"engineer ransom price = build_cost (70) × engineer multiplier (3.0)"
);
}
#[test]
fn engineer_ransom_premium_exceeds_worker_for_same_build_cost() {
// Same build_cost (70); only the multiplier changes. The engineer must
// cost the opponent strictly more to ransom back than a worker would.
let worker_params = CombatParams {
attacker: warrior(),
defender: worker(),
combat_type: CombatType::Melee,
defender_capturable: true,
posture_resolution: PostureResolution::Ransom,
defender_build_cost: 70,
defender_ransom_multiplier: 2.0, // worker.json baseline
..CombatParams::default()
};
let worker_result = CombatResolver::resolve(&worker_params);
let eng_params = CombatParams {
defender_ransom_multiplier: 3.0, // dwarf_engineer.json
..worker_params
};
let eng_result = CombatResolver::resolve(&eng_params);
assert!(
eng_result.ransom_price > worker_result.ransom_price,
"engineer ransom ({}) must exceed worker ransom ({}) at equal build_cost",
eng_result.ransom_price,
worker_result.ransom_price,
);
}
#[test]
fn ascendant_engineer_capture_clamps_hp_and_suppresses_xp() {
// Top-tier engineer (cost 330) — verifies the capture path holds for the
// full upgrade chain, not just the tier-1 dwarf_engineer.
let params = CombatParams {
attacker: warrior(),
defender: engineer(),
combat_type: CombatType::Melee,
defender_capturable: true,
posture_resolution: PostureResolution::Capture,
defender_build_cost: 330, // dwarf_ascendant_engineer.json cost
defender_ransom_multiplier: 5.0, // ascendant multiplier
..CombatParams::default()
};
let result = CombatResolver::resolve(&params);
assert_eq!(result.defender_outcome, CombatOutcome::Captured);
assert_eq!(result.defender_hp, 1);
assert_eq!(result.attacker_xp, 0, "no XP for capturing a Great Person");
assert_eq!(result.ransom_price, 0, "Capture posture suppresses ransom price");
}

View file

@ -2453,6 +2453,17 @@ impl TurnProcessor {
moved.current_action = None;
moved.patrol_order = None;
moved.formation_id = None;
// p2-55a: captured Specialists (Engineers, Pioneers — any unit with
// an `action_points` pool) lose their stored readiness. The captor
// does NOT inherit a partially-charged Great-Person action; AP must
// recharge from zero under the new owner. This also defines the
// outcome for an Engineer captured mid-build-action — any in-progress
// continuous action was already cleared via `current_action = None`
// above; the AP reset prevents the captor from immediately firing the
// next discrete GP action with the prior owner's accumulated charge.
if let Some(ap) = moved.action_points.as_mut() {
ap.current = 0;
}
let captor_state = &mut state.players[captor as usize];
captor_state.units.push(moved);
@ -2644,6 +2655,11 @@ impl TurnProcessor {
moved.current_action = None;
moved.patrol_order = None;
moved.formation_id = None;
// p2-55a: captured Specialists lose stored AP readiness. See
// `transfer_captured_unit` for the policy rationale.
if let Some(ap) = moved.action_points.as_mut() {
ap.current = 0;
}
let unit_kind = moved.unit_id.clone();
let captor_idx = offer.captor as usize;
@ -2733,6 +2749,11 @@ impl TurnProcessor {
moved.current_action = None;
moved.patrol_order = None;
moved.formation_id = None;
// p2-55a: captured Specialists lose stored AP readiness. See
// `transfer_captured_unit` for the policy rationale.
if let Some(ap) = moved.action_points.as_mut() {
ap.current = 0;
}
let unit_kind = moved.unit_id.clone();
let captor_idx = offer.captor as usize;

View file

@ -0,0 +1,212 @@
//! p2-55a — Engineer (Great Person) capture mechanics.
//!
//! Engineers extend the p2-55 civilian-capture surface. Their distinct
//! strategic value (multi-turn Great-Person actions per p2-53i) is encoded
//! as a higher `ransom_multiplier` in the unit JSONs (3.0 for the tier-1
//! `dwarf_engineer`, escalating to 5.0 for the ascendant tier — versus the
//! 2.0 baseline workers and founders carry).
//!
//! This test pins the engineer-specific behaviour layered on top of the
//! generic capture pipeline:
//!
//! 1. A captured engineer flips ownership like any civilian — same event
//! plumbing as a worker.
//! 2. A captured engineer's `action_points` pool resets `current` to 0 on
//! ownership transfer. The captor does NOT inherit a partially-charged
//! Great-Person action; AP must recharge from zero under the new owner.
//! This is the policy answer to "what happens to an Engineer captured
//! mid-build-action": any continuous action was already cleared, and AP
//! readiness is reset so the captor cannot immediately fire the next
//! discrete GP action.
//! 3. The ransom price for a ransomed engineer follows
//! `build_cost × ransom_multiplier`, surfacing the engineer premium
//! directly into the offer.
use mc_core::units::ActionPoints;
use mc_replay::TurnEvent;
use mc_turn::{
capture::CapturePosture, AttackRequest, GameState, MapUnit, PlayerState, TurnProcessor,
};
use mc_units::{UnitStats as CatalogUnitStats, UnitsCatalog};
const ENGINEER_ID: u32 = 800;
fn build_engineer_catalog() -> UnitsCatalog {
let mut cat = UnitsCatalog::new();
cat.insert(CatalogUnitStats {
id: "dwarf_warrior".into(),
base_moves: 2,
domain: "land".into(),
action_point_capacity: None,
capturable: false,
ransom_multiplier: 2.0,
build_cost: 0,
});
// dwarf_engineer.json shape — capturable, premium ransom multiplier,
// AP capacity 6 (tier-1 specialist ladder).
cat.insert(CatalogUnitStats {
id: "dwarf_engineer".into(),
base_moves: 2,
domain: "land".into(),
action_point_capacity: Some(6),
capturable: true,
ransom_multiplier: 3.0,
build_cost: 70,
});
cat
}
fn fixture_with_posture(posture: CapturePosture, eng_ap_current: u8) -> GameState {
let mut state = GameState {
turn: 0,
players: vec![
PlayerState {
player_index: 0,
default_civilian_posture: posture,
..Default::default()
},
PlayerState {
player_index: 1,
..Default::default()
},
],
grid: None,
..Default::default()
};
state.units_catalog = build_engineer_catalog();
// Attacker — high attack so the lethal-blow gate trips on the first swing.
state.players[0].units.push(MapUnit {
id: 301,
col: 10,
row: 10,
hp: 60,
max_hp: 60,
attack: 50,
defense: 10,
unit_id: "dwarf_warrior".into(),
..Default::default()
});
// Defender — engineer with hp=1 (force the lethal blow) and a partially
// charged AP pool so we can observe the reset on capture.
state.players[1].units.push(MapUnit {
id: ENGINEER_ID,
col: 10,
row: 10,
hp: 1,
max_hp: 30,
attack: 0,
defense: 0,
unit_id: "dwarf_engineer".into(),
action_points: Some(ActionPoints {
current: eng_ap_current,
capacity: 6,
}),
..Default::default()
});
state.pending_pvp_attacks.push(AttackRequest {
attacker_player: 0,
attacker_unit: 0,
defender_player: 1,
defender_unit: 0,
});
state
}
#[test]
fn captured_engineer_flips_owner_and_emits_unit_captured_event() {
let mut state = fixture_with_posture(CapturePosture::Capture, /* ap.current */ 4);
let processor = TurnProcessor::new(500);
let result = processor.step(&mut state);
assert_eq!(
state.players[1]
.units
.iter()
.filter(|u| u.id == ENGINEER_ID)
.count(),
0,
"captured engineer must leave prior owner's units vec"
);
let moved = state.players[0]
.units
.iter()
.find(|u| u.id == ENGINEER_ID)
.expect("engineer must be re-owned by captor");
assert_eq!(moved.unit_id, "dwarf_engineer");
let captured_count = result
.events_emitted
.iter()
.filter(
|e| matches!(e, TurnEvent::UnitCaptured { unit_id, .. } if *unit_id == ENGINEER_ID),
)
.count();
assert_eq!(
captured_count, 1,
"exactly one TurnEvent::UnitCaptured for the engineer; got {:?}",
result.events_emitted
);
}
#[test]
fn captured_engineer_resets_action_points_to_zero() {
// Engineer carried 4/6 AP at capture time. Under the new owner, AP must
// be 0/6 — the captor does NOT inherit a partially-charged GP action.
let mut state = fixture_with_posture(CapturePosture::Capture, /* ap.current */ 4);
let processor = TurnProcessor::new(500);
let _ = processor.step(&mut state);
let moved = state.players[0]
.units
.iter()
.find(|u| u.id == ENGINEER_ID)
.expect("captured engineer must land in captor's units vec");
let ap = moved
.action_points
.expect("engineer keeps its AP pool after capture (capacity intact)");
assert_eq!(
ap.current, 0,
"p2-55a: captured engineer's AP.current must reset to 0; got {ap:?}"
);
assert_eq!(
ap.capacity, 6,
"AP capacity is per-unit-type and survives capture"
);
}
#[test]
fn engineer_ransom_offer_uses_engineer_multiplier() {
let mut state = fixture_with_posture(CapturePosture::Ransom, /* ap.current */ 3);
let processor = TurnProcessor::new(500);
let result = processor.step(&mut state);
let captive = state.players[1]
.units
.iter()
.find(|u| u.id == ENGINEER_ID)
.expect("ransomed engineer stays in prior-owner vec until accept/refuse/expire");
assert_eq!(
captive.captive_of,
Some(0),
"captive_of must point at captor while offer is open"
);
let offer = result
.ransom_offers_created
.iter()
.find(|o| o.unit_id == ENGINEER_ID)
.expect("UnitRansomOfferedEvent on TurnResult.ransom_offers_created");
assert_eq!(
offer.price, 210,
"engineer ransom price = build_cost (70) × ransom_multiplier (3.0); got {}",
offer.price
);
assert!(
offer.price > 140,
"engineer ransom must exceed worker baseline (140); got {}",
offer.price
);
}