Ports the decisive-war mechanic from the juiced harness auto_play.gd:1109-1189
into the shipping Rust AI so the scripted controller fields it natively
(removing the dependency on the GDScript harness for decisiveness).
Capability (acceptance bullets 1, 2, 4):
- New `mc_ai::tactical::TacticalMemory { locked_target, commitment_turns }` —
the cross-turn persistence channel. Lives on `mc_turn::PlayerState`
(`#[serde(skip)]`, transient), NOT on the per-turn TacticalState snapshot
(which round-trips FFI as JSON — a field there would mean GDScript shadow
state, violating Rail 1) and NOT on the controller (a stateless shared
singleton). Borrowed `&mut` by `dispatch::drive_ai_slot` via mem::take and
threaded through `decide_turn → run_ai_turn → decide_tactical_actions →
decide_movement` — the pure-Rust path p1-29d measures, zero GDScript.
- `TacticalMemory::resolve` folds the auto_play state machine: acquire nearest
target when attacking, HOLD through tactical-score wobble for the commitment
window (hysteresis), PRESS ON to the next objective when the locked target
falls (capture → don't disperse), clear when all enemy cities are gone.
- `decide_movement` computes the army-wide lock once per turn (step 5b) and
every non-adjacent military unit drives on it instead of per-unit greedy
re-targeting — so captures get finished into eliminations.
- Commitment length is personality-derived (`thresholds::commitment_turns`,
aggression 0.6 / grudge 0.4; axis=5 reproduces auto_play's =5) — Rail 2
(ai_personalities.json axes), not a hardcoded constant.
- AiController trait gains `&mut TacticalMemory`; scripted threads it, mod
(wasm/native) + learned + FFI-shim controllers take a documented transient.
Tests (cargo test -p mc-ai green: 278 lib, all integration; workspace --tests
checks clean on apricot):
- 10 `memory::` unit tests: acquire/hold/expire/press-on/clear/determinism.
- 2 `thresholds::commitment_turns` tests (auto_play baseline + personality).
- 1 `movement::army_lock_concentrates_and_persists_across_turns` integration
test: a PERSISTED memory across two real decide_movement calls concentrates
3 units on ONE locked city and holds it through a want_attack=false turn.
- No regression: mc-turn 235/235, mc-player-api 126+, mc-mod-host all green.
Phase 1 is the clean green commit boundary. Objective stays 🟡 partial:
bullets 3 (≥1 elimination measured), 5 (AUTO_PLAY_ALL_AI / asymmetric harness),
6 (p1-29d re-score) are Phase 2 (batch validation on apricot), not yet done.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the read-side civic query the civics UI needs, completing the bridge
split out of p3-05a. `GdGameState::civic(pi, axis) -> Dictionary` returns
`{ choice, anarchy_turns_remaining, in_anarchy }`, riding on GdGameState
next to request_civic_switch/get_anarchy_turns_remaining (the canonical
per-player accessor surface — there is no per-GdPlayer shim in the tree).
`choice` is serialised via serde with quotes stripped (symmetric with the
request_civic_switch parse path): named AxisChoice variants → snake_case,
Anarchy → "anarchy", untagged Custom(id) → id verbatim. Unknown axis or
out-of-range pi → empty Dictionary.
Tests (green headless on apricot.lan):
- engine/tests/unit/civics/test_civic_query_bridge.gd — 7/7
- engine/tests/integration/test_gdextension_contract.gd — civic in the
GdGameState method contract; test_gd_game_state_has_expected_methods passes
- cargo check --workspace green; build-gdext.sh release build green
Objective p3-05a-gdext-bridge: stub → done (4/4 acceptance, cited).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-verified p1-05 and p1-38 follow-up batch acceptance against the fresh
committed-build smoke batch on apricot (20260604_011524/smoke, built
7d77fe728 from origin/main, 10 seeds T300, completed 2026-06-04 08:28).
p1-05: bullet 2 (personality_win_balance) satisfied upstream via p0-02
(done); bullet 1 (luxury variance >=3 distinct/seed) still fails -- no
p0-08 tempo in main (median end-turn is stochastic same-SHA variance, not
a build property), no distinct-luxury-variance extraction tool, scalar
read fails on seeds 1/5/9; bullet 3 out of fence. Stays stub.
p1-38: coupled mode still gated static_terrain in main (7d77fe728 and
5c97ce3f9), run-headless-batch.sh absent, no forest-tile metric emitted,
flip is a forbidden source edit. Un-runnable under no-edit committed-build
fence, not a balance miss. Stays stub.
Per feedback_batch_attribution_discipline + feedback_balance_philosophy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>