feat(@projects/@magic-civilization): ✨ implement ai controller delegation bridge
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
b95a500744
commit
ccd9e7c99c
13 changed files with 664 additions and 49 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
84
src/game/engine/tests/unit/ai/test_ai_controller_accessor.gd
Normal file
84
src/game/engine/tests/unit/ai/test_ai_controller_accessor.gd
Normal 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())
|
||||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
212
src/simulator/crates/mc-turn/tests/capture_engineer.rs
Normal file
212
src/simulator/crates/mc-turn/tests/capture_engineer.rs
Normal 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
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue