From 57504e0629cfc62e3e4e2d7cef53afd5e0b99095 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 9 May 2026 01:59:17 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20ransom=20event=20handling=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/objectives/objectives.json | 2 +- .../objectives/p2-55e-richer-ransom-events.md | 6 +- src/simulator/api-gdext/src/lib.rs | 55 +++++++++++++++++++ .../crates/mc-turn/src/game_state.rs | 17 ++++++ src/simulator/crates/mc-turn/src/processor.rs | 12 ++++ 5 files changed, 88 insertions(+), 4 deletions(-) diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json index e314b0f2..7ab60d1c 100644 --- a/.project/objectives/objectives.json +++ b/.project/objectives/objectives.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-05-09T08:52:30Z", + "generated_at": "2026-05-09T08:57:49Z", "totals": { "done": 177, "in_progress": 1, diff --git a/.project/objectives/p2-55e-richer-ransom-events.md b/.project/objectives/p2-55e-richer-ransom-events.md index f7112cee..d01a1699 100644 --- a/.project/objectives/p2-55e-richer-ransom-events.md +++ b/.project/objectives/p2-55e-richer-ransom-events.md @@ -23,9 +23,9 @@ Chronicle currently distinguishes "expired-then-captured" from "fresh capture" b - [x] New event types in `mc-turn::combat_event`: `UnitRansomAcceptedEvent { offer_id, unit_id, captor, owner, price_paid, turn }`, `UnitRansomExpiredEvent { offer_id, unit_id, captor, prior_owner, turn }`. ✓ Both authored at `src/simulator/crates/mc-turn/src/combat_event.rs:84-114` (`#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]`). `cargo check -p mc-turn` clean. - [x] `TurnResult` gains `ransom_offers_accepted: Vec` and `ransom_offers_expired: Vec`. ✓ Added at `combat_event.rs:189-198` with `#[serde(default)]` for save migration. -- [ ] `mc-turn::process_ransom_expiry` pushes `UnitRansomExpiredEvent` instead of (or in addition to) `UnitCapturedEvent` for the conversion. -- [ ] `accept_ransom_offer` / `refuse_ransom_offer` push the corresponding event into `pending_capture_events` so the next `step()` drains it onto TurnResult (instead of relying solely on the method return dict). -- [ ] api-gdext bridge surfaces both as `Array[Dictionary]` on the `step()` result. +- [x] `mc-turn::process_ransom_expiry` pushes `UnitRansomExpiredEvent` in addition to the existing `UnitCapturedEvent` for the conversion. ✓ `processor.rs:2244-2253` adds the expired event after the capture event. +- [x] `accept_ransom_offer` / `refuse_ransom_offer` push the corresponding event into `pending_capture_events.ransom_offers_accepted` / `_expired` so the next `step()` drains them onto TurnResult. ✓ `api-gdext/src/lib.rs:3791-3804` (accept) and `:3894-3905` (refuse). `PendingCaptureEvents.drain_into` updated at `mc-turn/src/game_state.rs:407-414` to drain both new vecs onto TurnResult. +- [-] ◐ api-gdext bridge surfaces both as `Array[Dictionary]` on the `step()` result. Bridge writes through `pending_capture_events` and they arrive on `TurnResult.ransom_offers_accepted` / `_expired` already; the `step()` Dictionary projection of those Vecs into `Array[Dictionary]` shape is the remaining piece (mirror of `units_captured` dict shape). - [ ] godot-engine chronicle subscriber reads from these new arrays directly; cross-reference workaround removed. - [ ] Test in `mc-turn/tests/ransom.rs` asserts both events appear in `TurnResult` after the corresponding state transitions. diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 5c6ad5f7..3f4995c2 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -3788,6 +3788,20 @@ impl GdGameState { } let _ = self.inner.ransom_queue.accept(preview.id); + // p2-55e: push typed event so next step() drains it into + // TurnResult.ransom_offers_accepted (chronicle reads from there). + self.inner + .pending_capture_events + .ransom_offers_accepted + .push(::mc_turn::combat_event::UnitRansomAcceptedEvent { + turn: self.inner.turn, + offer_id: preview.id, + unit_id: preview.unit_id, + captor: preview.captor, + owner: preview.owner, + price_paid: preview.price, + }); + out.set("success", true); out.set("gold_paid", preview.price as i64); out.set("unit_id", preview.unit_id as i64); @@ -3877,6 +3891,20 @@ impl GdGameState { let _ = self.inner.ransom_queue.refuse(preview.id); + // p2-55e: push typed event so next step() drains it into + // TurnResult.ransom_offers_expired. Refuse and time-out share the + // expired event variant (both convert ownership to captor). + self.inner + .pending_capture_events + .ransom_offers_expired + .push(::mc_turn::combat_event::UnitRansomExpiredEvent { + turn: self.inner.turn, + offer_id: preview.id, + unit_id: preview.unit_id, + captor: preview.captor, + prior_owner: preview.owner, + }); + out.set("success", true); out.set("unit_id", preview.unit_id as i64); out.set("new_owner", preview.captor as i64); @@ -4287,6 +4315,33 @@ fn turn_result_to_dict(result: &mc_turn::TurnResult, post_turn: u32) -> Dictiona } d.set("civilians_destroyed", destroyed); + // p2-55e: typed accept/expire surfaces. Chronicle subscriber reads these + // directly (no prior-turn cross-reference required). + let mut accepted: Array = Array::new(); + for ev in &result.ransom_offers_accepted { + let mut e = Dictionary::new(); + e.set("turn", ev.turn as i64); + e.set("offer_id", ev.offer_id as i64); + e.set("unit_id", ev.unit_id as i64); + e.set("captor", ev.captor as i64); + e.set("owner", ev.owner as i64); + e.set("price_paid", ev.price_paid as i64); + accepted.push(&e); + } + d.set("ransom_offers_accepted", accepted); + + let mut expired: Array = Array::new(); + for ev in &result.ransom_offers_expired { + let mut e = Dictionary::new(); + e.set("turn", ev.turn as i64); + e.set("offer_id", ev.offer_id as i64); + e.set("unit_id", ev.unit_id as i64); + e.set("captor", ev.captor as i64); + e.set("prior_owner", ev.prior_owner as i64); + expired.push(&e); + } + d.set("ransom_offers_expired", expired); + d } diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index 972ede90..b45cd859 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -354,6 +354,13 @@ pub struct PendingCaptureEvents { pub units_captured: Vec, pub ransom_offers_created: Vec, pub civilians_destroyed: Vec, + /// p2-55e: ransom offers paid out (gold deducted, ownership restored). + /// Bridge accept_ransom_offer pushes here; drain_into surfaces onto + /// `TurnResult.ransom_offers_accepted`. + pub ransom_offers_accepted: Vec, + /// p2-55e: ransom offers refused (manual refuse) or expired (timeout). + /// `process_ransom_expiry` and bridge `refuse_ransom_offer` push here. + pub ransom_offers_expired: Vec, } impl PendingCaptureEvents { @@ -399,12 +406,22 @@ impl PendingCaptureEvents { result.units_captured.append(&mut self.units_captured); result.ransom_offers_created.append(&mut self.ransom_offers_created); result.civilians_destroyed.append(&mut self.civilians_destroyed); + // p2-55e: drain accepted/expired into TurnResult so chronicle reads + // them directly without prior-turn cross-reference. + result + .ransom_offers_accepted + .append(&mut self.ransom_offers_accepted); + result + .ransom_offers_expired + .append(&mut self.ransom_offers_expired); } pub fn is_empty(&self) -> bool { self.units_captured.is_empty() && self.ransom_offers_created.is_empty() && self.civilians_destroyed.is_empty() + && self.ransom_offers_accepted.is_empty() + && self.ransom_offers_expired.is_empty() } } diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 30d45f7a..64b55397 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -2240,6 +2240,18 @@ impl TurnProcessor { row, unit_kind, }); + // p2-55e: surface the expiry as a typed first-class event so + // chronicle / AI memory can read it directly instead of + // cross-referencing the prior turn's `ransom_offers_created`. + result + .ransom_offers_expired + .push(crate::combat_event::UnitRansomExpiredEvent { + turn: state.turn, + offer_id: offer.id, + unit_id: offer.unit_id, + captor: offer.captor, + prior_owner: offer.owner, + }); } }