feat(mc-player-api): Introduce message types and dispatch logic for player API communication

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-19 22:46:08 -07:00
parent 7d4f3ba480
commit 69133d5a4e
3 changed files with 61 additions and 6 deletions

View file

@ -81,6 +81,15 @@ pub struct CommsState {
/// map each turn.
#[serde(default)]
pub vision_shares: BTreeMap<u64, crate::heartbeat::VisionShareLink>,
/// Phase 6 — side-channel buffer for chronicle events emitted from
/// non-end-of-turn paths (e.g. `PlayerAction::NameSeatOfPower`).
/// Drained by `mc-player-api`'s end-of-turn pass into the replay
/// archive. Skipped by serde — never persists across saves; if a
/// save is taken mid-turn the events will be re-emitted on resume.
/// Carries serde-encoded `mc_replay::TurnEvent` strings to avoid a
/// reverse mc-comms → mc-replay dep.
#[serde(skip)]
pub pending_chronicle_json: Vec<String>,
}
impl CommsState {

View file

@ -59,6 +59,22 @@ const DEFAULT_COURIER_ERA_TIER: u8 = 2;
pub fn dispatch_war_declaration(state: &mut GameState, sender: PlayerId, target: PlayerId) {
let mut sink: Vec<TurnEvent> = Vec::new();
dispatch_war_declaration_with_events(state, sender, target, &mut sink);
// Phase 6 — flush dispatched-event into the side-channel so
// production action handlers (PlayerAction::DeclareWar via
// `apply_declare_war`) surface the event without threading a vec
// through `apply_action`'s wire-event return type.
flush_to_pending_chronicle(&mut state.comms, sink);
}
/// Phase 6 — serialise a batch of `TurnEvent`s into
/// `CommsState.pending_chronicle_json`. Drained by
/// `run_end_of_turn_comms_passes` at the next end-of-turn pass.
fn flush_to_pending_chronicle(comms: &mut mc_comms::CommsState, events: Vec<TurnEvent>) {
for ev in events {
if let Ok(json) = serde_json::to_string(&ev) {
comms.pending_chronicle_json.push(json);
}
}
}
/// Like [`dispatch_war_declaration`] but appends an
@ -87,6 +103,7 @@ pub fn dispatch_war_declaration_with_events(
pub fn dispatch_peace_proposal(state: &mut GameState, sender: PlayerId, target: PlayerId) {
let mut sink: Vec<TurnEvent> = Vec::new();
dispatch_peace_proposal_with_events(state, sender, target, &mut sink);
flush_to_pending_chronicle(&mut state.comms, sink);
}
/// Event-emitting variant of [`dispatch_peace_proposal`].
@ -120,14 +137,17 @@ pub fn dispatch_treaty_offer(
if (sender as usize) >= state.players.len() || (target as usize) >= state.players.len() {
return;
}
dispatch_envelope(
let mut sink: Vec<TurnEvent> = Vec::new();
dispatch_envelope_with_events(
state,
sender,
target,
Payload::TreatyOffer {
agreement_kind: agreement_kind.to_string(),
},
&mut sink,
);
flush_to_pending_chronicle(&mut state.comms, sink);
}
/// Dispatch a treaty-accept envelope. The `agreement_kind` carries the
@ -145,14 +165,17 @@ pub fn dispatch_treaty_accept(
if (sender as usize) >= state.players.len() || (target as usize) >= state.players.len() {
return;
}
dispatch_envelope(
let mut sink: Vec<TurnEvent> = Vec::new();
dispatch_envelope_with_events(
state,
sender,
target,
Payload::TreatyAccept {
agreement_kind: agreement_kind.to_string(),
},
&mut sink,
);
flush_to_pending_chronicle(&mut state.comms, sink);
}
/// Dispatch a treaty-decline envelope.
@ -168,14 +191,17 @@ pub fn dispatch_treaty_decline(
if (sender as usize) >= state.players.len() || (target as usize) >= state.players.len() {
return;
}
dispatch_envelope(
let mut sink: Vec<TurnEvent> = Vec::new();
dispatch_envelope_with_events(
state,
sender,
target,
Payload::TreatyDecline {
agreement_kind: agreement_kind.to_string(),
},
&mut sink,
);
flush_to_pending_chronicle(&mut state.comms, sink);
}
/// Allocate + populate + stash a generic envelope. Shared by every

View file

@ -314,9 +314,20 @@ fn apply_name_seat_of_power(
let pos = player_state.city_positions[idx];
player_state.capital_position = Some(pos);
let _ = mc_comms::blackout::end_blackout(&mut state.comms, player);
// No wire-event counterpart for `CapitalBlackoutEnded` yet, but the
// replay archive consumer reads it via the side-channel like the
// other Phase 3 events.
// Phase 6 — emit `CapitalBlackoutEnded` into the side-channel
// `pending_chronicle_json` buffer on `CommsState`. The next
// `run_end_of_turn_comms_passes` drains it into the replay
// archive. Stored as a JSON string so this crate (mc-player-api)
// doesn't need to thread a TurnEvent vector across the `apply_action`
// return type (which returns wire `Event`s, not chronicle ones).
let ended_event = mc_replay::TurnEvent::CapitalBlackoutEnded {
turn: state.turn,
player: mc_replay::ClanId(player as u32),
new_capital_city_id: mc_replay::CityName(city_id.to_string()),
};
if let Ok(json) = serde_json::to_string(&ended_event) {
state.comms.pending_chronicle_json.push(json);
}
Ok(Vec::new())
}
@ -441,6 +452,15 @@ pub(crate) fn run_end_of_turn_comms_passes(
let mut events: Vec<mc_replay::TurnEvent> = Vec::new();
let turn_now = state.turn;
// 0. Drain the side-channel chronicle buffer (events emitted from
// non-end-of-turn paths such as `PlayerAction::NameSeatOfPower`).
let pending: Vec<String> = std::mem::take(&mut state.comms.pending_chronicle_json);
for json in pending {
if let Ok(ev) = serde_json::from_str::<mc_replay::TurnEvent>(&json) {
events.push(ev);
}
}
// 1. Drive envelopes through delivery / interception.
let _ = crate::comms_dispatch::step_comms_with_events(state, &mut events);