fix(@projects/@magic-civilization): 🐛 update ai ransom hook status and acceptance criteria

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-13 16:48:32 -07:00
parent f7ad687245
commit 52207e6ab9

View file

@ -2,14 +2,16 @@
id: p2-55d
title: "AI ransom accept/refuse hook in mc-turn start-of-turn"
priority: p2
status: stub
status: partial
scope: game1
category: combat
owner:
created: 2026-05-03
updated_at: 2026-05-03
updated_at: 2026-05-13
blocked_by: []
follow_ups: []
follow_ups:
- "Bridge sets MC_AI_DATA_DIR env var on startup (api-gdext / claude_player_main) — without it the hook silently refuses every offer in production. Currently only mc-turn integration tests pin the var."
- "30-turn raider-vs-merchant smoke run — blocked on the upstream PvP `defender_capturable=false` wiring gap documented at the top of `mc-turn/tests/capture_chronicle_pipeline.rs`."
parent: p2-55
---
@ -21,12 +23,23 @@ This objective wires the call site so AI personalities exercise their `ransom_ac
## Acceptance criteria
- [ ] `mc-turn::processor` start-of-turn phase iterates `state.ransom_queue.pending_for(ai_id)` for each non-human player.
- [ ] For each offer: looks up that AI's `PersonalityPriors`, calls `decide_ransom_response(offer.price, ai_player.gold, &priors)`.
- [ ] On `RansomDecision::Accept` and `ai_player.gold >= offer.price`: deducts gold, restores `MapUnit.owner_id` to original owner, clears `captive_of`, calls `queue.accept(offer.id)`, emits `UnitRansomAccepted` event.
- [ ] On `RansomDecision::Refuse` (or insufficient gold): calls `queue.refuse(offer.id)`, transfers unit ownership to captor, clears `captive_of`, emits `UnitRansomExpired` event.
- [ ] Integration test in `mc-turn/tests/ransom_ai.rs`: spin up 2 AI players (raider + merchant), push an offer, advance one turn, assert the merchant accepted (gold sufficient) while the raider would refuse if they were the owner.
- [ ] 30-turn raider-vs-merchant smoke run produces a non-zero count of `UnitRansomAccepted` events in the chronicle.
- [x] `mc-turn::processor` start-of-turn phase iterates `state.ransom_queue.pending_for(ai_id)` for each non-human player. — `processor::process_ai_ransom_decisions` (called at the tail of `step`, after `recharge_action_points`). Game 1 has no human-player marker in mc-turn so the hook iterates every slot; a future `is_human` gate is documented inline at the call site.
- [x] For each offer: looks up that AI's `PersonalityPriors`, calls `decide_ransom_response(offer.price, ai_player.gold, &priors)`. — Priors resolved via `mc_ai::tactical::ransom_decision::priors_for_clan(clan_id, MC_AI_DATA_DIR)`; decisions delegated to `decide_for_offers` which wraps `decide_ransom_response`. Empty `clan_id` / unset env var falls back to `PersonalityPriors::default()` (silent refuse) without panic.
- [x] On `RansomDecision::Accept` and `ai_player.gold >= offer.price`: deducts gold, clears `captive_of`, calls `queue.accept(offer.id)`, emits `UnitRansomAccepted` event. — `apply_ransom_accept` in processor.rs. (Spec said "restores `MapUnit.owner_id`"; in this codebase units are owned via residency in `PlayerState::units`, and accept keeps them in the owner's vec — `captive_of` is the only field cleared, matching the existing Wave 1 contract.)
- [x] On `RansomDecision::Refuse` (or insufficient gold): calls `queue.refuse(offer.id)`, transfers unit ownership to captor, clears `captive_of`, emits `UnitRansomExpired` event. — `apply_ransom_refuse``apply_refuse_from_offer`. Emits both `UnitRansomExpiredEvent` and `UnitCapturedEvent` plus the `TurnEvent::UnitCaptured` chronicle entry to mirror `process_ransom_expiry`.
- [x] Integration test in `mc-turn/tests/ransom_ai.rs`. — Three tests (`goldvein_merchant_accepts_when_solvent`, `blackhammer_raider_refuses_when_solvent_but_priors_say_no`, `unset_data_dir_falls_back_to_silent_refuse`) all pass; env-var mutation serialised through a `Mutex` so they coexist in the integration-test binary's parallel scheduler.
- [ ] 30-turn raider-vs-merchant smoke run produces a non-zero count of `UnitRansomAccepted` events in the chronicle. — Not yet wired: there is no harness in this crate that runs a 30-turn AI-vs-AI game with `defender_capturable=true` in PvP combat (see the wiring-gap note at the top of `capture_chronicle_pipeline.rs`). The unit-level acceptance bullet above proves the hook fires; the longer smoke run waits on that bridge wiring (tracked as a follow-up on the parent p2-55 closure note).
## Implementation notes (2026-05-13)
- New file `mc-ai/src/tactical/ransom_decision.rs` (the only file-disjoint location available per the objective's edit charter) with 5 lib tests (all green).
- `mc-ai/src/tactical/mod.rs` registers the new submodule (one-line touch — unavoidable for module declaration).
- `mc-turn/src/processor.rs` appends one call line in `step()` and three private associated fns (`process_ai_ransom_decisions`, `apply_ransom_accept`, `apply_ransom_refuse`, `apply_refuse_from_offer`). No edits to `GameState` or to existing phase handlers.
- Priors loader uses env var `MC_AI_DATA_DIR` — the Godot bridge will need to set this on startup for the production path to fire (currently silently refuses without it). That env-var plumbing is the gap holding the last acceptance bullet open.
## Status rationale
5/6 acceptance bullets ticked with cited evidence; status stays `partial` per `objective-integrity.md` rather than `done`. The remaining bullet (30-turn smoke run) depends on the upstream PvP-capture wiring gap noted in `capture_chronicle_pipeline.rs`; it is not a defect in this objective.
## Out of scope