diff --git a/.project/objectives/p2-43a-followup-gdscript-delegation.md b/.project/objectives/p2-43a-followup-gdscript-delegation.md index aab6c505..20aa0e25 100644 --- a/.project/objectives/p2-43a-followup-gdscript-delegation.md +++ b/.project/objectives/p2-43a-followup-gdscript-delegation.md @@ -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). diff --git a/public/resources/units/dwarf_ascendant_engineer.json b/public/resources/units/dwarf_ascendant_engineer.json index 5120c486..9bad1f23 100644 --- a/public/resources/units/dwarf_ascendant_engineer.json +++ b/public/resources/units/dwarf_ascendant_engineer.json @@ -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", diff --git a/public/resources/units/dwarf_engineer.json b/public/resources/units/dwarf_engineer.json index 32a5804e..7fc132b8 100644 --- a/public/resources/units/dwarf_engineer.json +++ b/public/resources/units/dwarf_engineer.json @@ -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", diff --git a/public/resources/units/dwarf_grand_engineer.json b/public/resources/units/dwarf_grand_engineer.json index f77f3f7e..ddc88788 100644 --- a/public/resources/units/dwarf_grand_engineer.json +++ b/public/resources/units/dwarf_grand_engineer.json @@ -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", diff --git a/public/resources/units/dwarf_high_engineer.json b/public/resources/units/dwarf_high_engineer.json index b3389161..bb8c72ef 100644 --- a/public/resources/units/dwarf_high_engineer.json +++ b/public/resources/units/dwarf_high_engineer.json @@ -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", diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 6d280cd2..a8ab120a 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -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: diff --git a/src/game/engine/src/autoloads/game_state.gd b/src/game/engine/src/autoloads/game_state.gd index a9f87ebe..2b5d93fe 100644 --- a/src/game/engine/src/autoloads/game_state.gd +++ b/src/game/engine/src/autoloads/game_state.gd @@ -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() diff --git a/src/game/engine/tests/integration/test_gdextension_contract.gd b/src/game/engine/tests/integration/test_gdextension_contract.gd index a93f7334..d6074feb 100644 --- a/src/game/engine/tests/integration/test_gdextension_contract.gd +++ b/src/game/engine/tests/integration/test_gdextension_contract.gd @@ -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: diff --git a/src/game/engine/tests/unit/ai/test_ai_controller_accessor.gd b/src/game/engine/tests/unit/ai/test_ai_controller_accessor.gd new file mode 100644 index 00000000..31714f50 --- /dev/null +++ b/src/game/engine/tests/unit/ai/test_ai_controller_accessor.gd @@ -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()) diff --git a/src/simulator/crates/mc-ai/tests/capture_scoring.rs b/src/simulator/crates/mc-ai/tests/capture_scoring.rs index f5d0c0ff..872c58e9 100644 --- a/src/simulator/crates/mc-ai/tests/capture_scoring.rs +++ b/src/simulator/crates/mc-ai/tests/capture_scoring.rs @@ -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:?}" + ); +} diff --git a/src/simulator/crates/mc-combat/tests/capture.rs b/src/simulator/crates/mc-combat/tests/capture.rs index a1d4d00f..4cd7bba7 100644 --- a/src/simulator/crates/mc-combat/tests/capture.rs +++ b/src/simulator/crates/mc-combat/tests/capture.rs @@ -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(¶ms); + 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(¶ms); + 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"); +} diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index b07e2048..bb2f528e 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -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; diff --git a/src/simulator/crates/mc-turn/tests/capture_engineer.rs b/src/simulator/crates/mc-turn/tests/capture_engineer.rs new file mode 100644 index 00000000..19ae29a2 --- /dev/null +++ b/src/simulator/crates/mc-turn/tests/capture_engineer.rs @@ -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 + ); +}