From 3191ed65ef6acfac3db83c2f184db0241a698105 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 20:22:04 -0700 Subject: [PATCH 01/19] feat(engine): add resolve-all consent protocol --- crates/engine/src/ai_support/candidates.rs | 87 ++++++++- crates/engine/src/ai_support/context.rs | 46 ++++- crates/engine/src/ai_support/mod.rs | 5 +- crates/engine/src/game/elimination.rs | 5 + crates/engine/src/game/engine.rs | 178 +++++++++++++++++- crates/engine/src/game/interaction.rs | 36 +++- crates/engine/src/game/turn_control.rs | 63 +++++++ crates/engine/src/game/visibility.rs | 4 + .../engine/src/types/action_stable_order.rs | 37 ++++ crates/engine/src/types/actions.rs | 37 +++- crates/engine/src/types/game_state.rs | 91 +++++++++ crates/engine/tests/integration/main.rs | 1 + .../tests/integration/resolve_all_consent.rs | 169 +++++++++++++++++ 13 files changed, 740 insertions(+), 19 deletions(-) create mode 100644 crates/engine/tests/integration/resolve_all_consent.rs diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 5853fe3380..1f10626dd8 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -11,6 +11,7 @@ use crate::game::mana_sources; use crate::types::ability::{ChoiceType, CounterCostSelection, TargetRef}; use crate::types::actions::{ CastChoice, GameAction, LearnOption, MulliganChoice, OutsideGameSelection, + ResolveAllConsentDecision, }; use crate::types::card::LayoutKind; use crate::types::card_type::CoreType; @@ -381,6 +382,36 @@ fn permute_into( /// constructing `GameAction::Concede { player_id }` directly. pub fn candidate_actions_exact(state: &GameState) -> Vec { match &state.waiting_for { + WaitingFor::ResolveAllConsent { + epoch, + representative, + } => { + let mut actions = vec![ + candidate( + GameAction::RespondResolveAllConsent { + epoch: *epoch, + decision: ResolveAllConsentDecision::Grant, + }, + TacticalClass::Selection, + Some(*representative), + ), + candidate( + GameAction::RespondResolveAllConsent { + epoch: *epoch, + decision: ResolveAllConsentDecision::Decline, + }, + TacticalClass::Selection, + Some(*representative), + ), + ]; + append_resolve_all_revocations(state, *epoch, &mut actions); + actions + } + WaitingFor::ResolveAllReady { epoch } => { + let mut actions = Vec::new(); + append_resolve_all_revocations(state, *epoch, &mut actions); + actions + } WaitingFor::MeldPairChoice { player, choices } => choices .iter() .map(|choice| { @@ -848,6 +879,35 @@ pub fn candidate_actions_exact(state: &GameState) -> Vec { } } +fn append_resolve_all_revocations( + state: &GameState, + epoch: u64, + actions: &mut Vec, +) { + let Some(run) = state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == epoch) + else { + return; + }; + actions.extend( + run.participants + .iter() + .filter(|participant| participant.granted) + .map(|participant| { + candidate( + GameAction::RevokeResolveAllConsent { + epoch, + representative: participant.representative, + }, + TacticalClass::Selection, + Some(participant.representative), + ) + }), + ); +} + pub fn candidate_actions_broad(state: &GameState) -> Vec { candidate_actions_broad_with_probe(state, None) } @@ -3538,9 +3598,30 @@ fn semantic_candidate_actions_with_probe( fn authorize_candidate_actors(state: &GameState, actions: &mut [CandidateAction]) { for action in actions { - action.metadata.actor = action.metadata.actor.map(|player| { - crate::game::turn_control::authorized_submitter_for_player(state, player) - }); + action.metadata.actor = match &action.action { + GameAction::RespondResolveAllConsent { epoch, .. } => match &state.waiting_for { + WaitingFor::ResolveAllConsent { + epoch: active_epoch, + representative, + } if *epoch == *active_epoch => state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == *active_epoch) + .and_then(|run| run.authorized_submitter_for(*representative)), + _ => None, + }, + GameAction::RevokeResolveAllConsent { + epoch, + representative, + } => crate::game::turn_control::resolve_all_granted_submitter( + state, + *epoch, + *representative, + ), + _ => action.metadata.actor.map(|player| { + crate::game::turn_control::authorized_submitter_for_player(state, player) + }), + }; } } diff --git a/crates/engine/src/ai_support/context.rs b/crates/engine/src/ai_support/context.rs index abfe280528..22ae575201 100644 --- a/crates/engine/src/ai_support/context.rs +++ b/crates/engine/src/ai_support/context.rs @@ -35,7 +35,9 @@ impl AiDecisionContract { pub fn issue(state: &GameState, semantic_owner: PlayerId) -> Self { Self { semantic_owner, - authorized_actor: turn_control::authorized_submitter_for_player(state, semantic_owner), + authorized_actor: resolve_all_frozen_actor(state, semantic_owner).unwrap_or_else( + || turn_control::authorized_submitter_for_player(state, semantic_owner), + ), state_revision: state.state_revision, // The engine's candidate enumerator is the authoritative finite // domain for this prompt. Combat and search continuations remain @@ -60,13 +62,29 @@ impl AiDecisionContract { /// opaque proposal token (WASM/server), because a restored state resets its /// serialized revision. pub fn permits(&self, state: &GameState, actor: PlayerId, action: &GameAction) -> bool { + let semantic_owner_is_active = state + .waiting_for + .acting_players() + .contains(&self.semantic_owner) + || matches!( + action, + GameAction::RevokeResolveAllConsent { representative, .. } + if *representative == self.semantic_owner + ); + let authorized_actor = match action { + GameAction::RevokeResolveAllConsent { + epoch, + representative, + } => turn_control::resolve_all_granted_submitter(state, *epoch, *representative), + _ => Some(turn_control::authorized_submitter_for_player( + state, + self.semantic_owner, + )), + }; self.state_revision == state.state_revision - && state - .waiting_for - .acting_players() - .contains(&self.semantic_owner) + && semantic_owner_is_active && self.authorized_actor == actor - && turn_control::authorized_submitter_for_player(state, self.semantic_owner) == actor + && authorized_actor == Some(actor) && self.contains_action(state, action) } @@ -102,6 +120,22 @@ impl AiDecisionContract { } } +fn resolve_all_frozen_actor(state: &GameState, representative: PlayerId) -> Option { + let epoch = match &state.waiting_for { + WaitingFor::ResolveAllConsent { epoch, .. } | WaitingFor::ResolveAllReady { epoch } => { + *epoch + } + _ => return None, + }; + turn_control::resolve_all_granted_submitter(state, epoch, representative).or_else(|| { + state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == epoch) + .and_then(|run| run.authorized_submitter_for(representative)) + }) +} + pub(crate) fn target_selection_requires_reducer_validation(state: &GameState) -> bool { // CR 601.2c + CR 601.2e-h + CR 602.2b: selecting a target can complete // target declaration and immediately check legality and pay the proposed diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 10907bceb0..93a57e89e6 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -1316,7 +1316,10 @@ fn classify_flat_priority_action(action: &GameAction) -> FlatPriorityActionClass | GameAction::DeclareShortcut { .. } | GameAction::RespondToShortcut { .. } | GameAction::DeclineShortcut - | GameAction::PrecastCopyShortcut { .. } => FlatPriorityActionClass::Other, + | GameAction::PrecastCopyShortcut { .. } + | GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } => FlatPriorityActionClass::Other, } } diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 42e8a9e1a0..2e07be8160 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -827,6 +827,11 @@ fn do_eliminate( super::turn_control::recompute_active_player_control(state); } + // A consent run freezes canonical representatives and submitters. Player + // elimination changes that topology, so discard the run rather than + // allowing a stale prompt or Ready state to authorize anyone. + super::turn_control::invalidate_resolve_all_consent(state); + // CR 800.4a + CR 800.4b: a departing searcher/zone owner invalidates its // live session, while a departing latched controller ends only that // controller's decision/knowledge role and falls back to the searcher. diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 72101f92af..9e062efd30 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -6,14 +6,16 @@ use crate::types::ability::{EffectKind, KeywordAction, TargetRef}; #[cfg(test)] use crate::types::ability::{EffectScope, TapStateChange}; use crate::types::actions::{ - DebugAction, GameAction, MayTriggerAutoChoiceOp, PriorityYieldOp, TriggerOrderTemplateOp, + DebugAction, GameAction, MayTriggerAutoChoiceOp, PriorityYieldOp, ResolveAllConsentDecision, + TriggerOrderTemplateOp, }; use crate::types::events::{BendingType, ContestRound, GameEvent, ManaTapState}; use crate::types::game_state::{ ActionResult, AssistState, AutoMayChoice, AutoPassMode, AutoPassRequest, CastOfferKind, CastingVariant, ConvokeMode, CostResume, GameState, LandPlayRecord, LoopDetectionMode, ManaAbilityResume, MayTriggerAutoChoiceKey, PayCostKind, PendingCostMoveResume, - PendingCounterPostAction, PendingEffectResolved, RetargetScope, StackEntry, StackEntryKind, + PendingCounterPostAction, PendingEffectResolved, ResolveAllConsentParticipant, + ResolveAllConsentRun, ResolveAllPrioritySnapshot, RetargetScope, StackEntry, StackEntryKind, WaitingFor, }; use crate::types::identifiers::{CardId, DelayedTriggerOrigin, ObjectId, ObjectIncarnationRef}; @@ -6391,6 +6393,16 @@ fn check_actor_authorization( { return Ok(()); } + if let GameAction::RevokeResolveAllConsent { + epoch, + representative, + } = action + { + return (turn_control::resolve_all_granted_submitter(state, *epoch, *representative) + == Some(actor)) + .then_some(()) + .ok_or(EngineError::WrongPlayer); + } // CR 103.5: For simultaneous-decision states (MulliganDecision, // OpeningHandBottomCards), authorize against the full pending set so any // pending player may submit in any order. Falls back to single-player @@ -7572,6 +7584,142 @@ fn finalize_copy_retarget( Ok(()) } +fn begin_resolve_all_consent( + state: &mut GameState, + priority_player: PlayerId, + max_resolutions: u32, +) -> Result { + if state.priority_player + != turn_control::authorized_submitter_for_player(state, priority_player) + { + return Err(EngineError::NotYourPriority); + } + let current_representative = + super::topology::priority_pass_representative(state, priority_player); + let mut representatives = super::topology::priority_pass_participants(state); + let Some(current_index) = representatives + .iter() + .position(|representative| *representative == current_representative) + else { + return Err(EngineError::ActionNotAllowed( + "Resolve All requires a live priority representative".to_string(), + )); + }; + representatives.rotate_left(current_index); + + let epoch = state.next_resolve_all_consent_epoch; + let next_epoch = epoch.checked_add(1).ok_or_else(|| { + EngineError::ActionNotAllowed("Resolve All consent epoch space exhausted".to_string()) + })?; + state.next_resolve_all_consent_epoch = next_epoch; + state.resolve_all_consent_run = Some(ResolveAllConsentRun { + epoch, + max_resolutions, + priority_snapshot: ResolveAllPrioritySnapshot { + waiting_player: priority_player, + priority_player: state.priority_player, + priority_pass_count: state.priority_pass_count, + priority_passes: state.priority_passes.clone(), + }, + participants: representatives + .into_iter() + .map(|representative| ResolveAllConsentParticipant { + representative, + authorized_submitter: turn_control::authorized_submitter_for_player( + state, + representative, + ), + granted: representative == current_representative, + }) + .collect(), + }); + + resolve_all_consent_waiting_for(state).ok_or_else(|| { + EngineError::ActionNotAllowed( + "Resolve All requires at least one representative".to_string(), + ) + }) +} + +fn resolve_all_consent_waiting_for(state: &GameState) -> Option { + let run = state.resolve_all_consent_run.as_ref()?; + Some( + run.next_pending_representative() + .map(|representative| WaitingFor::ResolveAllConsent { + epoch: run.epoch, + representative, + }) + .unwrap_or(WaitingFor::ResolveAllReady { epoch: run.epoch }), + ) +} + +fn restore_resolve_all_priority_snapshot(state: &mut GameState) -> Result { + let run = state.resolve_all_consent_run.take().ok_or_else(|| { + EngineError::InvalidAction("Resolve All consent is not active".to_string()) + })?; + let snapshot = run.priority_snapshot; + state.priority_player = snapshot.priority_player; + state.priority_pass_count = snapshot.priority_pass_count; + state.priority_passes = snapshot.priority_passes; + Ok(WaitingFor::Priority { + player: snapshot.waiting_player, + }) +} + +fn respond_resolve_all_consent( + state: &mut GameState, + epoch: u64, + representative: PlayerId, + response_epoch: u64, + decision: ResolveAllConsentDecision, +) -> Result { + if epoch != response_epoch { + return Err(EngineError::InvalidAction( + "Resolve All consent epoch is stale".to_string(), + )); + } + { + let run = state.resolve_all_consent_run.as_mut().ok_or_else(|| { + EngineError::InvalidAction("Resolve All consent is not active".to_string()) + })?; + if run.epoch != epoch || run.next_pending_representative() != Some(representative) { + return Err(EngineError::InvalidAction( + "Resolve All consent response is no longer pending".to_string(), + )); + } + if matches!(decision, ResolveAllConsentDecision::Grant) { + let participant = run + .participants + .iter_mut() + .find(|participant| participant.representative == representative) + .expect("pending Resolve All representative must be a participant"); + participant.granted = true; + } + } + if matches!(decision, ResolveAllConsentDecision::Decline) { + return restore_resolve_all_priority_snapshot(state); + } + resolve_all_consent_waiting_for(state) + .ok_or_else(|| EngineError::InvalidAction("Resolve All consent is not active".to_string())) +} + +fn revoke_resolve_all_consent( + state: &mut GameState, + epoch: u64, + representative: PlayerId, +) -> Result { + let active = state + .resolve_all_consent_run + .as_ref() + .is_some_and(|run| run.epoch == epoch && run.is_granted(representative)); + if !active { + return Err(EngineError::InvalidAction( + "Resolve All consent revocation is stale".to_string(), + )); + } + restore_resolve_all_priority_snapshot(state) +} + fn apply_action( state: &mut GameState, actor: PlayerId, @@ -7996,6 +8144,32 @@ fn apply_action( log_entries: vec![], }); } + (WaitingFor::Priority { player }, GameAction::BeginResolveAll { max_resolutions }) => { + begin_resolve_all_consent(state, *player, max_resolutions)? + } + ( + WaitingFor::ResolveAllConsent { + epoch, + representative, + }, + GameAction::RespondResolveAllConsent { + epoch: response_epoch, + decision, + }, + ) => respond_resolve_all_consent( + state, + *epoch, + *representative, + response_epoch, + decision, + )?, + ( + WaitingFor::ResolveAllConsent { .. } | WaitingFor::ResolveAllReady { .. }, + GameAction::RevokeResolveAllConsent { + epoch, + representative, + }, + ) => revoke_resolve_all_consent(state, epoch, representative)?, (WaitingFor::Priority { player }, GameAction::PlayLand { object_id, card_id }) => { if state.priority_player != turn_control::authorized_submitter_for_player(state, *player) diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 6e9e4d0c9f..c93bbe4295 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -244,7 +244,9 @@ fn human_response_model(waiting_for: &WaitingFor, semantic_owner: PlayerId) -> H } WaitingFor::OutsideGameChoice { .. } => HumanResponseModel::OutsideSelection, WaitingFor::NamedChoice { .. } => HumanResponseModel::TextChoice, - WaitingFor::RespondToShortcut { .. } => HumanResponseModel::ShortcutReply, + WaitingFor::RespondToShortcut { .. } | WaitingFor::ResolveAllConsent { .. } => { + HumanResponseModel::ShortcutReply + } WaitingFor::PrecastCopyShortcutOffer { .. } | WaitingFor::RespondToPrecastCopyShortcut { .. } | WaitingFor::CommanderZoneChoice { .. } @@ -254,7 +256,8 @@ fn human_response_model(waiting_for: &WaitingFor, semantic_owner: PlayerId) -> H HumanResponseModel::DirectChoices } WaitingFor::LoopShortcut { .. } => HumanResponseModel::LoopShortcut, - WaitingFor::Priority { .. } + WaitingFor::ResolveAllReady { .. } + | WaitingFor::Priority { .. } | WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } | WaitingFor::EntryAttackTargetChoice { .. } @@ -329,6 +332,7 @@ fn human_response_model(waiting_for: &WaitingFor, semantic_owner: PlayerId) -> H fn classify_waiting_for(waiting_for: &WaitingFor) -> WaitingClassification { let (code, simultaneous, slot_kind) = match waiting_for { WaitingFor::GameOver { .. } => (InteractionWaitingForCode::Terminal, None, None), + WaitingFor::ResolveAllReady { .. } => (InteractionWaitingForCode::Shortcut, None, None), WaitingFor::MulliganDecision { .. } => ( InteractionWaitingForCode::Mulligan, Some(SimultaneousDecisionKind::Mulligan), @@ -431,7 +435,9 @@ fn classify_waiting_for(waiting_for: &WaitingFor) -> WaitingClassification { None, Some(InteractionSlotKind::Single), ), - WaitingFor::LoopShortcut { .. } | WaitingFor::RespondToShortcut { .. } => ( + WaitingFor::LoopShortcut { .. } + | WaitingFor::RespondToShortcut { .. } + | WaitingFor::ResolveAllConsent { .. } => ( InteractionWaitingForCode::Shortcut, None, Some(InteractionSlotKind::Single), @@ -5102,6 +5108,27 @@ fn project_action_payload( }; surfaces.push(InteractionPresentationSurface::ShortcutResponse { response }); } + GameAction::BeginResolveAll { .. } => { + surfaces.push(InteractionPresentationSurface::ShortcutResponse { + response: InteractionShortcutResponseCode::Propose, + }); + } + GameAction::RespondResolveAllConsent { decision, .. } => { + let response = match decision { + crate::types::actions::ResolveAllConsentDecision::Grant => { + InteractionShortcutResponseCode::Accept + } + crate::types::actions::ResolveAllConsentDecision::Decline => { + InteractionShortcutResponseCode::Decline + } + }; + surfaces.push(InteractionPresentationSurface::ShortcutResponse { response }); + } + GameAction::RevokeResolveAllConsent { .. } => { + surfaces.push(InteractionPresentationSurface::ShortcutResponse { + response: InteractionShortcutResponseCode::Decline, + }); + } // CR 116.2c: two live pay-to-end permissions are two distinct // candidates, so the group key must reach the surface list or they // project identically. The permanent whose resolution installed the @@ -5341,6 +5368,9 @@ fn action_code(action: &GameAction) -> InteractionActionCode { GameAction::RespondToShortcut { .. } => InteractionActionCode::RespondToShortcut, GameAction::DeclineShortcut => InteractionActionCode::DeclineShortcut, GameAction::PrecastCopyShortcut { .. } => InteractionActionCode::PrecastCopyShortcut, + GameAction::BeginResolveAll { .. } => InteractionActionCode::DeclareShortcut, + GameAction::RespondResolveAllConsent { .. } => InteractionActionCode::RespondToShortcut, + GameAction::RevokeResolveAllConsent { .. } => InteractionActionCode::DeclineShortcut, GameAction::Debug(_) => InteractionActionCode::Debug, } } diff --git a/crates/engine/src/game/turn_control.rs b/crates/engine/src/game/turn_control.rs index cc8531bfaf..f408b5942b 100644 --- a/crates/engine/src/game/turn_control.rs +++ b/crates/engine/src/game/turn_control.rs @@ -352,6 +352,26 @@ fn search_decision_authority( } pub fn authorized_submitter_for_player(state: &GameState, semantic_player: PlayerId) -> PlayerId { + // Resolve All consent freezes the submitting authority at proposal time. + // This must win over live turn control: otherwise a control effect that + // changes while a representative is queued could redirect an already-issued + // response to a different actor. + if let WaitingFor::ResolveAllConsent { + epoch, + representative, + } = &state.waiting_for + { + if *representative == semantic_player { + if let Some(submitter) = state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == *epoch) + .and_then(|run| run.authorized_submitter_for(*representative)) + { + return submitter; + } + } + } match search_decision_authority(state, semantic_player) { Some(ActiveSearchDecisionAuthority::LatchedController { controller }) => controller, Some(ActiveSearchDecisionAuthority::SearcherFallback) => semantic_player, @@ -359,6 +379,49 @@ pub fn authorized_submitter_for_player(state: &GameState, semantic_player: Playe } } +/// Returns the frozen submitter who may revoke one granted Resolve All consent. +/// Revoke is valid while a later representative is queued and after the run is +/// Ready, so it cannot be expressed through the ordinary single-actor prompt. +pub fn resolve_all_granted_submitter( + state: &GameState, + epoch: u64, + representative: PlayerId, +) -> Option { + matches!( + &state.waiting_for, + WaitingFor::ResolveAllConsent { epoch: active, .. } + | WaitingFor::ResolveAllReady { epoch: active } + if *active == epoch + ) + .then(|| state.resolve_all_consent_run.as_ref()) + .flatten() + .filter(|run| run.epoch == epoch && run.is_granted(representative)) + .and_then(|run| run.authorized_submitter_for(representative)) +} + +/// Drops an active Resolve All consent run when player topology changes. A +/// frozen representative set is no longer meaningful after elimination, so +/// restart ordinary priority from a living representative instead of trying to +/// repair the proposal in place. +pub fn invalidate_resolve_all_consent(state: &mut GameState) { + if state.resolve_all_consent_run.take().is_none() { + return; + } + let preferred = super::topology::priority_pass_representative(state, state.active_player); + let player = super::players::is_alive(state, preferred) + .then_some(preferred) + .or_else(|| { + super::topology::priority_pass_participants(state) + .first() + .copied() + }) + .unwrap_or(preferred); + state.waiting_for = WaitingFor::Priority { player }; + state.priority_player = authorized_submitter_for_player(state, player); + state.priority_pass_count = 0; + state.priority_passes.clear(); +} + /// CR 723.4: A controlled player and the player controlling them may see the /// controlled player's private information while that control applies. pub fn decision_audience_for_player(state: &GameState, semantic_player: PlayerId) -> Vec { diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 22edde1c34..6aa57d3ec3 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -238,6 +238,10 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState filtered.interaction_generation = 0; filtered.next_interaction_serial = "1".to_string(); filtered.active_interaction_slots.clear(); + // Resolve All consent's frozen authority and priority restoration snapshot + // are server-private. The public WaitingFor state is sufficient to render + // the current consent or ready status. + filtered.resolve_all_consent_run = None; // Product knowledge is projection authority, never transport payload. Its // effect is applied below before hidden cards are redacted; viewers receive // identities they learned, not the audience facts or library epochs behind diff --git a/crates/engine/src/types/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs index 5ee9a358d4..448dd678d2 100644 --- a/crates/engine/src/types/action_stable_order.rs +++ b/crates/engine/src/types/action_stable_order.rs @@ -809,6 +809,43 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering { .then_with(|| cmp_val(a1, b1)) .then_with(|| cmp_val(a2, b2)) } + GameAction::BeginResolveAll { + max_resolutions: a0, + } => { + let GameAction::BeginResolveAll { + max_resolutions: b0, + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::RespondResolveAllConsent { + epoch: a0, + decision: a1, + } => { + let GameAction::RespondResolveAllConsent { + epoch: b0, + decision: b1, + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::RevokeResolveAllConsent { + epoch: a0, + representative: a1, + } => { + let GameAction::RevokeResolveAllConsent { + epoch: b0, + representative: b1, + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } GameAction::DiscoverChoice { choice: a0 } => { let GameAction::DiscoverChoice { choice: b0 } = b else { unreachable!("cmp_payload: same-variant invariant"); diff --git a/crates/engine/src/types/actions.rs b/crates/engine/src/types/actions.rs index 6513cb966e..b3922af953 100644 --- a/crates/engine/src/types/actions.rs +++ b/crates/engine/src/types/actions.rs @@ -941,15 +941,41 @@ pub enum GameAction { /// display them verbatim and echo them back; dispatch revalidates `group` /// against live state and never trusts either echoed value. /// - /// Appended at the END of this enum on purpose: `GameActionKind` derives - /// `PartialOrd, Ord`, so a mid-enum insertion would renumber later - /// discriminants and shift `cmp_stable` ordering (AI candidate ordering and - /// replay determinism). + /// Kept after the existing action variants so their derived + /// `GameActionKind` ordering remains stable for deterministic replay. EndContinuousEffect { group: crate::types::game_state::EndEffectGroupId, source_name: String, cost: crate::types::mana::ManaCost, }, + /// Begins the table-consent protocol for the forthcoming Resolve All batch. + /// Phase 1 only records unanimous consent; it deliberately does not drive + /// priority or resolve the batch. + BeginResolveAll { + max_resolutions: u32, + }, + /// Answers the currently queued Resolve All consent prompt. `epoch` makes + /// delayed transport submissions fail closed rather than answering a newer + /// proposal. + RespondResolveAllConsent { + epoch: u64, + decision: ResolveAllConsentDecision, + }, + /// Withdraws a representative's prior Resolve All consent while the exact + /// epoch remains active. This is intentionally available from Ready as + /// well as while another representative is queued. + RevokeResolveAllConsent { + epoch: u64, + representative: PlayerId, + }, +} + +/// One representative's explicit Resolve All decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub enum ResolveAllConsentDecision { + Grant, + Decline, } /// CR 117.3d: The mutation a `GameAction::SetPriorityYield` performs on the @@ -1775,6 +1801,9 @@ impl GameAction { | GameAction::RespondToShortcut { .. } | GameAction::DeclineShortcut | GameAction::PrecastCopyShortcut { .. } + | GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } // CR 116.2c: the payload names a continuous-effect GROUP, not a // permanent — a global action with no source object (frontend // Pattern A). The Licid that installed the effect is not addressed diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 26cce430a9..bf9527dfe6 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -85,6 +85,10 @@ fn initial_delayed_trigger_instance_id() -> u64 { 1 } +fn initial_resolve_all_consent_epoch() -> u64 { + 1 +} + fn default_interaction_serial() -> String { "1".to_string() } @@ -1904,6 +1908,58 @@ pub struct PriorityYield { pub target: YieldTarget, } +/// Exact priority state restored if a Resolve All consent run is declined, +/// revoked, or invalidated before it becomes actionable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolveAllPrioritySnapshot { + pub waiting_player: PlayerId, + pub priority_player: PlayerId, + pub priority_pass_count: u8, + pub priority_passes: BTreeSet, +} + +/// One canonical priority representative and the submitter authorized for that +/// representative when a Resolve All consent run began. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolveAllConsentParticipant { + pub representative: PlayerId, + pub authorized_submitter: PlayerId, + pub granted: bool, +} + +/// Server-authoritative state behind the public Resolve All consent prompts. +/// The frozen submitters prevent turn-control changes from rebinding a queued +/// response or a later revocation to a different person. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolveAllConsentRun { + pub epoch: u64, + pub max_resolutions: u32, + pub priority_snapshot: ResolveAllPrioritySnapshot, + pub participants: Vec, +} + +impl ResolveAllConsentRun { + pub fn authorized_submitter_for(&self, representative: PlayerId) -> Option { + self.participants + .iter() + .find(|participant| participant.representative == representative) + .map(|participant| participant.authorized_submitter) + } + + pub fn is_granted(&self, representative: PlayerId) -> bool { + self.participants + .iter() + .any(|participant| participant.representative == representative && participant.granted) + } + + pub fn next_pending_representative(&self) -> Option { + self.participants + .iter() + .find(|participant| !participant.granted) + .map(|participant| participant.representative) + } +} + /// CR 609.7a: A source of damage chosen while creating a prevention or /// replacement effect. The original filter is retained so property-based /// choices such as "red source of your choice" recheck source qualities when @@ -10624,6 +10680,17 @@ pub enum WaitingFor { Priority { player: PlayerId, }, + /// Public Resolve All consent prompt. The protocol details and frozen + /// submitter ledger remain in `GameState::resolve_all_consent_run`. + ResolveAllConsent { + epoch: u64, + representative: PlayerId, + }, + /// Every canonical representative granted the same Resolve All epoch. + /// Phase 1 deliberately keeps this state inert; a later phase consumes it. + ResolveAllReady { + epoch: u64, + }, /// CR 608.2d + CR 701.42: choose the exact pair of current battlefield /// referents the meld instruction will exile. Candidate identity is frozen /// in the tuples; the physical meld-card check intentionally happens later. @@ -12954,6 +13021,8 @@ impl WaitingFor { pub fn variant_name(&self) -> &'static str { match self { WaitingFor::Priority { .. } => "Priority", + WaitingFor::ResolveAllConsent { .. } => "ResolveAllConsent", + WaitingFor::ResolveAllReady { .. } => "ResolveAllReady", WaitingFor::MeldPairChoice { .. } => "MeldPairChoice", WaitingFor::MeldAttackTargetChoice { .. } => "MeldAttackTargetChoice", WaitingFor::EntryAttackTargetChoice { .. } => "EntryAttackTargetChoice", @@ -13109,6 +13178,10 @@ impl WaitingFor { } } WaitingFor::Priority { player } + | WaitingFor::ResolveAllConsent { + representative: player, + .. + } | WaitingFor::MeldPairChoice { player, .. } | WaitingFor::MeldAttackTargetChoice { player, .. } | WaitingFor::EntryAttackTargetChoice { player, .. } @@ -15103,6 +15176,13 @@ declare_game_state! { // Game flow pub waiting_for: WaitingFor, + /// Persisted allocation source for Resolve All consent epochs. Starts at + /// one for legacy saves and is minted only by `BeginResolveAll`. + #[serde(default = "initial_resolve_all_consent_epoch")] + pub next_resolve_all_consent_epoch: u64, + /// Private protocol ledger behind the public consent/ready waiting states. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolve_all_consent_run: Option, /// Trusted interaction capability scope. Viewer-filtered copies always /// redact this field; only the engine uses it to mint opaque decision IDs. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -21033,6 +21113,8 @@ impl GameState { waiting_for: WaitingFor::Priority { player: starting_player, }, + next_resolve_all_consent_epoch: initial_resolve_all_consent_epoch(), + resolve_all_consent_run: None, interaction_session_id: None, interaction_generation: 0, next_interaction_serial: default_interaction_serial(), @@ -22864,6 +22946,8 @@ fn _gamestate_partition_is_total(s: &GameState) { rng: _, combat: _, waiting_for: _, + next_resolve_all_consent_epoch: _, + resolve_all_consent_run: _, interaction_session_id: _, interaction_generation: _, next_interaction_serial: _, @@ -23210,6 +23294,8 @@ impl PartialEq for GameState { && self.rng_seed == other.rng_seed && self.combat == other.combat && self.waiting_for == other.waiting_for + && self.next_resolve_all_consent_epoch == other.next_resolve_all_consent_epoch + && self.resolve_all_consent_run == other.resolve_all_consent_run && self.lands_played_this_turn == other.lands_played_this_turn && self.max_lands_per_turn == other.max_lands_per_turn && self.priority_pass_count == other.priority_pass_count @@ -29103,6 +29189,11 @@ mod tests { variants.push(Box::new(WaitingFor::Priority { player: PlayerId(0), })); + variants.push(Box::new(WaitingFor::ResolveAllConsent { + epoch: 1, + representative: PlayerId(0), + })); + variants.push(Box::new(WaitingFor::ResolveAllReady { epoch: 1 })); variants.push(Box::new(WaitingFor::MulliganDecision { pending: vec![MulliganDecisionEntry { player: PlayerId(0), diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index d9cfb8b21e..bcf32855b6 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -937,6 +937,7 @@ mod render_silent_cant_cast; mod replacement_mill_double_application; mod repro_pilot_crew; mod retarget_prompt_softlock; +mod resolve_all_consent; mod revealed_card_type_disjunction_518; mod rhys_evermore_remove_counters; mod riot_control_regression; diff --git a/crates/engine/tests/integration/resolve_all_consent.rs b/crates/engine/tests/integration/resolve_all_consent.rs new file mode 100644 index 0000000000..b6ef0facbc --- /dev/null +++ b/crates/engine/tests/integration/resolve_all_consent.rs @@ -0,0 +1,169 @@ +//! Phase-1 protocol coverage for explicit Resolve All consent. + +use engine::ai_support::candidates::candidate_actions; +use engine::game::engine::apply; +use engine::game::visibility::filter_state_for_viewer; +use engine::types::actions::{GameAction, ResolveAllConsentDecision}; +use engine::types::game_state::{GameState, WaitingFor}; +use engine::types::player::PlayerId; + +const P0: PlayerId = PlayerId(0); +const P1: PlayerId = PlayerId(1); + +fn begin(state: &mut GameState) -> u64 { + apply( + state, + P0, + GameAction::BeginResolveAll { max_resolutions: 7 }, + ) + .expect("priority holder may begin Resolve All consent"); + match &state.waiting_for { + WaitingFor::ResolveAllConsent { + epoch, + representative, + } => { + assert_eq!( + *representative, P1, + "initiator grants before the queue opens" + ); + *epoch + } + ref other => panic!("expected queued consent, got {other:?}"), + } +} + +#[test] +fn consent_queue_reaches_inert_ready_only_after_every_representative_grants() { + let mut state = GameState::new_two_player(42); + let epoch = begin(&mut state); + + apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("queued representative may grant"); + assert!(matches!( + &state.waiting_for, + WaitingFor::ResolveAllReady { epoch: ready_epoch } if *ready_epoch == epoch + )); + assert!(apply(&mut state, P1, GameAction::PassPriority).is_err()); + assert!(matches!( + &state.waiting_for, + WaitingFor::ResolveAllReady { epoch: ready_epoch } if *ready_epoch == epoch + )); +} + +#[test] +fn stale_epoch_and_decline_restore_the_exact_priority_snapshot() { + let mut state = GameState::new_two_player(43); + state.priority_pass_count = 3; + state.priority_passes.insert(P0); + let epoch = begin(&mut state); + + assert!(apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch: epoch + 1, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .is_err()); + apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Decline, + }, + ) + .expect("queued representative may decline"); + + assert!(matches!(&state.waiting_for, WaitingFor::Priority { player } if *player == P0)); + assert_eq!(state.priority_player, P0); + assert_eq!(state.priority_pass_count, 3); + assert!(state.priority_passes.contains(&P0)); + assert!(state.resolve_all_consent_run.is_none()); + assert!(apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .is_err()); +} + +#[test] +fn queued_response_and_candidate_keep_the_frozen_submitter_after_control_changes() { + let mut state = GameState::new_two_player(44); + let epoch = begin(&mut state); + state.active_player = P1; + state.turn_decision_controller = Some(P0); + + let candidates = candidate_actions(&state); + assert!(candidates.iter().any(|candidate| { + matches!( + candidate.action, + GameAction::RespondResolveAllConsent { + epoch: candidate_epoch, + decision: ResolveAllConsentDecision::Grant, + } if candidate_epoch == epoch + ) && candidate.metadata.actor == Some(P1) + })); + apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("frozen submitter, not the new live controller, answers the prompt"); +} + +#[test] +fn granted_representative_can_revoke_off_queue_and_private_run_is_not_visible() { + let mut state = GameState::new_two_player(45); + let epoch = begin(&mut state); + apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("reach ready state"); + + let view = filter_state_for_viewer(&state, P1); + assert!(matches!(&view.waiting_for, WaitingFor::ResolveAllReady { epoch: e } if *e == epoch)); + assert!(view.resolve_all_consent_run.is_none()); + + let candidates = candidate_actions(&state); + assert!(candidates.iter().any(|candidate| { + matches!( + candidate.action, + GameAction::RevokeResolveAllConsent { + epoch: candidate_epoch, + representative: P0, + } if candidate_epoch == epoch + ) && candidate.metadata.actor == Some(P0) + })); + apply( + &mut state, + P0, + GameAction::RevokeResolveAllConsent { + epoch, + representative: P0, + }, + ) + .expect("a granted representative may revoke from Ready"); + assert!(matches!(&state.waiting_for, WaitingFor::Priority { player } if *player == P0)); + assert!(state.resolve_all_consent_run.is_none()); +} From b295b96ae840dd5aaa20813fd0cae443b93c0c6d Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 20:32:53 -0700 Subject: [PATCH 02/19] fix(engine): expose resolve-all consent actions --- crates/engine/src/ai_support/mod.rs | 49 +++++++- crates/engine/src/game/interaction.rs | 115 ++++++++++++------ .../tests/integration/resolve_all_consent.rs | 81 +++++++++++- 3 files changed, 208 insertions(+), 37 deletions(-) diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 93a57e89e6..47dd11886a 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -2332,7 +2332,7 @@ pub fn mana_payment_shortcut_actions( } /// Returns `legal_actions_full` scoped to a specific viewer. Empty tuple if -/// `viewer` is not the player currently expected to act. +/// `viewer` has no current action authority. /// /// CR 117.1 — "which player can take actions at any given time is determined by /// a system of priority. The player with priority may cast spells, activate @@ -2343,8 +2343,12 @@ pub fn mana_payment_shortcut_actions( /// This is the single engine-side authority for "what does player X need to /// know" and exists to keep game-logic gating out of transport adapters. The /// P2P multiplayer host broadcasts a filtered state + legal-actions payload -/// per guest; only the acting guest needs a populated legal-actions map. +/// per guest; the Resolve All consent protocol additionally exposes each +/// frozen grantor's own revocation while a different representative is queued. pub fn legal_actions_for_viewer(state: &GameState, viewer: PlayerId) -> LegalActionsFull { + if let Some(actions) = resolve_all_actions_for_viewer(state, viewer) { + return (actions, HashMap::new(), HashMap::new()); + } // CR 103.5: For simultaneous-decision states (MulliganDecision, // OpeningHandBottomCards), every pending player has a // legal action set, so guests in a multiplayer mulligan can see and submit @@ -2367,6 +2371,47 @@ pub fn legal_actions_for_viewer(state: &GameState, viewer: PlayerId) -> LegalAct } } +fn resolve_all_actions_for_viewer(state: &GameState, viewer: PlayerId) -> Option> { + let epoch = match &state.waiting_for { + WaitingFor::ResolveAllConsent { epoch, .. } | WaitingFor::ResolveAllReady { epoch } => { + *epoch + } + _ => return None, + }; + let run = state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == epoch)?; + let mut actions = Vec::new(); + if let WaitingFor::ResolveAllConsent { representative, .. } = &state.waiting_for { + if run.authorized_submitter_for(*representative) == Some(viewer) { + actions.extend([ + GameAction::RespondResolveAllConsent { + epoch, + decision: crate::types::actions::ResolveAllConsentDecision::Grant, + }, + GameAction::RespondResolveAllConsent { + epoch, + decision: crate::types::actions::ResolveAllConsentDecision::Decline, + }, + ]); + } + } + actions.extend(run.participants.iter().filter_map(|participant| { + (participant.granted + && crate::game::turn_control::resolve_all_granted_submitter( + state, + epoch, + participant.representative, + ) == Some(viewer)) + .then_some(GameAction::RevokeResolveAllConsent { + epoch, + representative: participant.representative, + }) + })); + Some(actions) +} + /// Non-fatal diagnostic describing a wedged decision point. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index c93bbe4295..2febf59809 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -244,8 +244,11 @@ fn human_response_model(waiting_for: &WaitingFor, semantic_owner: PlayerId) -> H } WaitingFor::OutsideGameChoice { .. } => HumanResponseModel::OutsideSelection, WaitingFor::NamedChoice { .. } => HumanResponseModel::TextChoice, - WaitingFor::RespondToShortcut { .. } | WaitingFor::ResolveAllConsent { .. } => { - HumanResponseModel::ShortcutReply + WaitingFor::RespondToShortcut { .. } => HumanResponseModel::ShortcutReply, + // Resolve All consent has a finite, engine-authored Grant/Decline or + // Revoke domain. It is not a CR 732 shortcut-reply protocol. + WaitingFor::ResolveAllConsent { .. } | WaitingFor::ResolveAllReady { .. } => { + HumanResponseModel::ExactCandidates(AuditedExactCandidates) } WaitingFor::PrecastCopyShortcutOffer { .. } | WaitingFor::RespondToPrecastCopyShortcut { .. } @@ -256,8 +259,7 @@ fn human_response_model(waiting_for: &WaitingFor, semantic_owner: PlayerId) -> H HumanResponseModel::DirectChoices } WaitingFor::LoopShortcut { .. } => HumanResponseModel::LoopShortcut, - WaitingFor::ResolveAllReady { .. } - | WaitingFor::Priority { .. } + WaitingFor::Priority { .. } | WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } | WaitingFor::EntryAttackTargetChoice { .. } @@ -332,7 +334,6 @@ fn human_response_model(waiting_for: &WaitingFor, semantic_owner: PlayerId) -> H fn classify_waiting_for(waiting_for: &WaitingFor) -> WaitingClassification { let (code, simultaneous, slot_kind) = match waiting_for { WaitingFor::GameOver { .. } => (InteractionWaitingForCode::Terminal, None, None), - WaitingFor::ResolveAllReady { .. } => (InteractionWaitingForCode::Shortcut, None, None), WaitingFor::MulliganDecision { .. } => ( InteractionWaitingForCode::Mulligan, Some(SimultaneousDecisionKind::Mulligan), @@ -437,7 +438,8 @@ fn classify_waiting_for(waiting_for: &WaitingFor) -> WaitingClassification { ), WaitingFor::LoopShortcut { .. } | WaitingFor::RespondToShortcut { .. } - | WaitingFor::ResolveAllConsent { .. } => ( + | WaitingFor::ResolveAllConsent { .. } + | WaitingFor::ResolveAllReady { .. } => ( InteractionWaitingForCode::Shortcut, None, Some(InteractionSlotKind::Single), @@ -577,16 +579,70 @@ fn waiting_for_kind(waiting_for: &WaitingFor) -> InteractionWaitingForKind { } } -fn semantic_slots(waiting_for: &WaitingFor) -> Vec<(PlayerId, InteractionSlotKind)> { - let classification = classify_waiting_for(waiting_for); +fn semantic_slots(state: &GameState) -> Vec<(PlayerId, InteractionSlotKind)> { + let classification = classify_waiting_for(&state.waiting_for); let Some(slot_kind) = classification.slot_kind else { return Vec::new(); }; - waiting_for - .acting_players() - .into_iter() - .map(|player| (player, slot_kind)) - .collect() + match &state.waiting_for { + WaitingFor::ResolveAllConsent { + epoch, + representative, + } => state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == *epoch) + .map(|run| { + run.participants + .iter() + .filter(|participant| { + participant.granted && participant.representative != *representative + }) + .map(|participant| (participant.representative, slot_kind)) + .chain(std::iter::once((*representative, slot_kind))) + .collect() + }) + .unwrap_or_default(), + WaitingFor::ResolveAllReady { epoch } => state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == *epoch) + .map(|run| { + run.participants + .iter() + .filter(|participant| participant.granted) + .map(|participant| (participant.representative, slot_kind)) + .collect() + }) + .unwrap_or_default(), + _ => state + .waiting_for + .acting_players() + .into_iter() + .map(|player| (player, slot_kind)) + .collect(), + } +} + +fn interaction_submitter_for_owner(state: &GameState, semantic_owner: PlayerId) -> PlayerId { + let frozen = match &state.waiting_for { + WaitingFor::ResolveAllConsent { epoch, .. } | WaitingFor::ResolveAllReady { epoch } => { + turn_control::resolve_all_granted_submitter(state, *epoch, semantic_owner) + } + _ => None, + }; + frozen.unwrap_or_else(|| turn_control::authorized_submitter_for_player(state, semantic_owner)) +} + +fn interaction_authorized_submitters(state: &GameState) -> Vec { + let mut submitters = Vec::new(); + for (owner, _) in semantic_slots(state) { + let submitter = interaction_submitter_for_owner(state, owner); + if !submitters.contains(&submitter) { + submitters.push(submitter); + } + } + submitters } fn interaction_serial_is_valid(value: &str) -> bool { @@ -657,7 +713,7 @@ fn allocate_interaction_ids( } fn bind_all_current_slots(state: &mut GameState) -> bool { - let semantic = semantic_slots(&state.waiting_for); + let semantic = semantic_slots(state); let Some((ids, generation, serial)) = allocate_interaction_ids(state, semantic.len()) else { return false; }; @@ -747,7 +803,7 @@ pub(crate) fn ensure_interaction_authority(state: &mut GameState) { state.active_interaction_slots.clear(); return; } - let expected = semantic_slots(&state.waiting_for); + let expected = semantic_slots(state); let matches = expected.len() == state.active_interaction_slots.len() && expected.iter().all(|(owner, kind)| { state @@ -763,16 +819,10 @@ pub(crate) fn ensure_interaction_authority(state: &mut GameState) { } pub(crate) fn semantic_owner_for_actor(state: &GameState, actor: PlayerId) -> Option { - let acting = state.waiting_for.acting_players(); - acting - .iter() - .copied() - .find(|owner| *owner == actor) - .or_else(|| { - acting - .into_iter() - .find(|owner| turn_control::authorized_submitter_for_player(state, *owner) == actor) - }) + semantic_slots(state) + .into_iter() + .map(|(owner, _)| owner) + .find(|owner| interaction_submitter_for_owner(state, *owner) == actor) } pub(crate) fn action_preserves_interaction(action: &GameAction) -> bool { @@ -810,7 +860,7 @@ pub(crate) fn rebind_interaction_slots_after_action( }); } let prior = classify_waiting_for(previous_waiting); - let next = semantic_slots(&state.waiting_for); + let next = semantic_slots(state); let preserve_other_simultaneous = prior.simultaneous.is_some(); let mut rebound = Vec::with_capacity(next.len()); let mut needs_id = Vec::new(); @@ -865,7 +915,7 @@ pub(crate) fn debug_assert_interaction_consistency(state: &GameState) { if !interaction_serial_is_valid(&state.next_interaction_serial) { return; } - let expected = semantic_slots(&state.waiting_for); + let expected = semantic_slots(state); debug_assert_eq!(expected.len(), state.active_interaction_slots.len()); let mut ids = HashSet::new(); for (owner, kind) in expected { @@ -7455,7 +7505,7 @@ pub fn derive_viewer_interaction( // is a value here, not a silently emptied map: the finalizer is the only // place allowed to decide what an unbounded projection becomes. let attachment_views = attachment_views_for_viewer(filtered_state); - let authorized_submitters = turn_control::authorized_submitters(authoritative_state); + let authorized_submitters = interaction_authorized_submitters(authoritative_state); let can_submit = authorized_submitters.contains(&viewer); let kind = waiting_for_kind(&authoritative_state.waiting_for); if kind.terminal { @@ -7542,10 +7592,8 @@ pub fn derive_viewer_interaction( .active_interaction_slots .iter() .filter(|slot| { - turn_control::authorized_submitter_for_player( - authoritative_state, - PlayerId(slot.semantic_owner), - ) == viewer + interaction_submitter_for_owner(authoritative_state, PlayerId(slot.semantic_owner)) + == viewer }) .collect(); if slots.len() > MAX_INTERACTION_LIST_LEN { @@ -8402,8 +8450,7 @@ fn slot_for_submission<'a>( .iter() .find(|slot| slot.interaction_id == *interaction_id) .ok_or(InteractionReasonCode::StaleInteraction)?; - let authorized = - turn_control::authorized_submitter_for_player(state, PlayerId(slot.semantic_owner)); + let authorized = interaction_submitter_for_owner(state, PlayerId(slot.semantic_owner)); if authorized != actor { return Err(InteractionReasonCode::NotAuthorized); } diff --git a/crates/engine/tests/integration/resolve_all_consent.rs b/crates/engine/tests/integration/resolve_all_consent.rs index b6ef0facbc..56bc0d5e3c 100644 --- a/crates/engine/tests/integration/resolve_all_consent.rs +++ b/crates/engine/tests/integration/resolve_all_consent.rs @@ -1,10 +1,17 @@ //! Phase-1 protocol coverage for explicit Resolve All consent. -use engine::ai_support::candidates::candidate_actions; +use engine::ai_support::{candidates::candidate_actions, legal_actions_for_viewer}; use engine::game::engine::apply; +use engine::game::interaction::{ + bind_interaction_authority, derive_viewer_interaction, resolve_interaction_response, +}; use engine::game::visibility::filter_state_for_viewer; use engine::types::actions::{GameAction, ResolveAllConsentDecision}; use engine::types::game_state::{GameState, WaitingFor}; +use engine::types::interaction::{ + InteractionOpportunityResponse, InteractionResponse, InteractionSessionId, + InteractionSubmission, +}; use engine::types::player::PlayerId; const P0: PlayerId = PlayerId(0); @@ -167,3 +174,75 @@ fn granted_representative_can_revoke_off_queue_and_private_run_is_not_visible() assert!(matches!(&state.waiting_for, WaitingFor::Priority { player } if *player == P0)); assert!(state.resolve_all_consent_run.is_none()); } + +#[test] +fn transport_surfaces_only_each_grantors_own_revoke_and_uses_exact_consent_choices() { + let mut state = GameState::new_two_player(46); + let epoch = begin(&mut state); + + let p0_actions = legal_actions_for_viewer(&state, P0).0; + assert_eq!( + p0_actions, + vec![GameAction::RevokeResolveAllConsent { + epoch, + representative: P0, + }], + "an off-prompt grantor receives only its own frozen revoke" + ); + let p1_actions = legal_actions_for_viewer(&state, P1).0; + assert!(p1_actions + .iter() + .all(|action| { !matches!(action, GameAction::RevokeResolveAllConsent { .. }) })); + assert!(p1_actions.iter().any(|action| { + matches!( + action, + GameAction::RespondResolveAllConsent { + epoch: action_epoch, + decision: ResolveAllConsentDecision::Grant, + } if *action_epoch == epoch + ) + })); + + bind_interaction_authority(&mut state, InteractionSessionId("resolve-all".to_string())) + .expect("consent slots bind for each authorized owner"); + let p0_view = derive_viewer_interaction(&state, &filter_state_for_viewer(&state, P0), P0); + let p1_view = derive_viewer_interaction(&state, &filter_state_for_viewer(&state, P1), P1); + assert!(p0_view.can_submit); + assert!(p1_view.can_submit); + assert_eq!(p0_view.opportunities.len(), 1); + assert_eq!(p1_view.opportunities.len(), 1); + + let InteractionOpportunityResponse::ExactChoices { choices } = + &p0_view.opportunities[0].response + else { + panic!("off-prompt revoke must use an exact choice, not the CR 732 reply schema"); + }; + let choice_id = choices + .first() + .expect("grantor has one revoke choice") + .id + .clone(); + let action = resolve_interaction_response( + &state, + P0, + &InteractionSubmission { + interaction_id: p0_view.opportunities[0].interaction_id.clone(), + response: InteractionResponse::Choose { choice_id }, + }, + ) + .expect("transport may materialize the off-prompt revoke"); + assert_eq!( + action, + GameAction::RevokeResolveAllConsent { + epoch, + representative: P0, + } + ); + + let InteractionOpportunityResponse::ExactChoices { choices } = + &p1_view.opportunities[0].response + else { + panic!("queued consent must use bounded exact grant/decline choices"); + }; + assert_eq!(choices.len(), 2); +} From 7a68bee5bc8d0f7fbdaf9c1a5b90b57a79f5c079 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 20:37:29 -0700 Subject: [PATCH 03/19] fix(engine): materialize ready consent revocations --- crates/engine/src/ai_support/mod.rs | 14 ++++++ .../tests/integration/resolve_all_consent.rs | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 47dd11886a..755a36b252 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -249,6 +249,20 @@ pub(crate) fn structurally_valid_tap_for_convoke_payment( } fn cheap_reject_candidate(state: &GameState, action: &GameAction) -> bool { + // Ready intentionally has no current acting player, but an active consent + // grantor may still revoke. The candidate pipeline retains the frozen actor + // and its SimulationFilter checks that authority before admitting it. + if let GameAction::RevokeResolveAllConsent { + epoch, + representative, + } = action + { + if crate::game::turn_control::resolve_all_granted_submitter(state, *epoch, *representative) + .is_some() + { + return false; + } + } // CR 103.5 / TL:R 906.6a: For simultaneous-decision states // `acting_player()` is None when multiple players are pending. The // Priority-branch check below only fires for the Priority variant, so we diff --git a/crates/engine/tests/integration/resolve_all_consent.rs b/crates/engine/tests/integration/resolve_all_consent.rs index 56bc0d5e3c..1a766ae82b 100644 --- a/crates/engine/tests/integration/resolve_all_consent.rs +++ b/crates/engine/tests/integration/resolve_all_consent.rs @@ -246,3 +246,49 @@ fn transport_surfaces_only_each_grantors_own_revoke_and_uses_exact_consent_choic }; assert_eq!(choices.len(), 2); } + +#[test] +fn ready_state_transport_materializes_each_grantors_frozen_revoke() { + let mut state = GameState::new_two_player(47); + let epoch = begin(&mut state); + apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("the final grant reaches Ready"); + + bind_interaction_authority( + &mut state, + InteractionSessionId("resolve-all-ready".to_string()), + ) + .expect("Ready binds one slot per frozen grantor"); + let p0_view = derive_viewer_interaction(&state, &filter_state_for_viewer(&state, P0), P0); + let InteractionOpportunityResponse::ExactChoices { choices } = + &p0_view.opportunities[0].response + else { + panic!("Ready revoke must remain an exact choice"); + }; + assert_eq!(choices.len(), 1); + let action = resolve_interaction_response( + &state, + P0, + &InteractionSubmission { + interaction_id: p0_view.opportunities[0].interaction_id.clone(), + response: InteractionResponse::Choose { + choice_id: choices[0].id.clone(), + }, + }, + ) + .expect("Ready has no acting player, but its frozen grantor may still revoke"); + assert_eq!( + action, + GameAction::RevokeResolveAllConsent { + epoch, + representative: P0, + } + ); +} From c8ca2a24cc9ef78e152ba8fdf048bafe54e55bc6 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 20:55:34 -0700 Subject: [PATCH 04/19] feat(resolve-all): collapse authorized safe prefixes --- client/src/adapter/types.ts | 8 + .../modal/ResolveAllConsentModal.tsx | 65 +++++ client/src/game/controllers/aiController.ts | 9 +- client/src/game/dispatch.ts | 107 ++++---- client/src/i18n/locales/en/game.json | 7 + client/src/pages/GamePage.tsx | 2 + crates/engine-wasm/src/lib.rs | 16 +- .../engine/src/game/engine_resolve_batch.rs | 238 +++++++++++++++++- crates/engine/src/game/stack.rs | 16 +- crates/server-core/src/session.rs | 68 +---- 10 files changed, 404 insertions(+), 132 deletions(-) create mode 100644 client/src/components/modal/ResolveAllConsentModal.tsx diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 8495461144..f7f8474869 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1692,6 +1692,8 @@ export type MulliganDecisionPhase = export type WaitingFor = | { type: "Priority"; data: { player: PlayerId } } + | { type: "ResolveAllConsent"; data: { epoch: number; representative: PlayerId } } + | { type: "ResolveAllReady"; data: { epoch: number } } | { type: "MeldPairChoice"; data: { player: PlayerId; choices: MeldSelection[] } } | { type: "MeldAttackTargetChoice"; data: { player: PlayerId; context: MeldSelection; valid_targets: AttackTarget[] } } | { type: "EntryAttackTargetChoice"; data: { player: PlayerId; object_id: ObjectId; valid_targets: AttackTarget[] } } @@ -2247,6 +2249,12 @@ export type PrecastCopyShortcutResponse = export type GameAction = | { type: "PassPriority" } + | { type: "BeginResolveAll"; data: { max_resolutions: number } } + | { + type: "RespondResolveAllConsent"; + data: { epoch: number; decision: { type: "Grant" } | { type: "Decline" } }; + } + | { type: "RevokeResolveAllConsent"; data: { epoch: number; representative: PlayerId } } | { type: "ChooseMeldPair"; data: { source_id: ObjectId; partner_id: ObjectId } } | { type: "ChooseEntryAttackTarget"; data: { target: AttackTarget } } | { type: "RollPlanarDie" } diff --git a/client/src/components/modal/ResolveAllConsentModal.tsx b/client/src/components/modal/ResolveAllConsentModal.tsx new file mode 100644 index 0000000000..e287e7bb80 --- /dev/null +++ b/client/src/components/modal/ResolveAllConsentModal.tsx @@ -0,0 +1,65 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; + +import { dispatchResolveAll } from "../../game/dispatch.ts"; +import { useGameDispatch } from "../../hooks/useGameDispatch.ts"; +import { useGameStore } from "../../stores/gameStore.ts"; +import { DialogShell } from "./DialogShell.tsx"; + +/** + * Engine-authored Resolve All consent prompt. The browser only presents the + * representative and echoes a finite Grant/Decline action; authorization and + * the subsequent safe prefix stay entirely in the engine. + */ +export function ResolveAllConsentModal({ playerId }: { playerId: number }) { + const { t } = useTranslation("game"); + const waitingFor = useGameStore((s) => s.waitingFor); + const dispatch = useGameDispatch(); + + const visible = + waitingFor?.type === "ResolveAllConsent" && + waitingFor.data.representative === playerId; + + const respond = useCallback( + async (decision: "Grant" | "Decline") => { + if (waitingFor?.type !== "ResolveAllConsent") return; + await dispatch({ + type: "RespondResolveAllConsent", + data: { epoch: waitingFor.data.epoch, decision: { type: decision } }, + }); + if ( + decision === "Grant" && + useGameStore.getState().gameState?.waiting_for?.type === "ResolveAllReady" + ) { + await dispatchResolveAll(playerId, []); + } + }, + [dispatch, playerId, waitingFor], + ); + + if (!visible) return null; + + return ( + +
+ + +
+
+ ); +} diff --git a/client/src/game/controllers/aiController.ts b/client/src/game/controllers/aiController.ts index 6a5b0a18df..5e52b5a5a1 100644 --- a/client/src/game/controllers/aiController.ts +++ b/client/src/game/controllers/aiController.ts @@ -5,7 +5,7 @@ import { AdapterError, AdapterErrorCode } from "../../adapter/types"; import { pressureMultiplier } from "../../utils/stackPressure"; import { effectiveStackPressure } from "../../utils/stackThroughput"; import { debugLog } from "../debugLog"; -import { dispatchAiActionProposal } from "../dispatch"; +import { dispatchAiActionProposal, dispatchResolveAll } from "../dispatch"; import { attemptStateRehydrate, isEnginePanic, notifyEngineLost, routePanic } from "../engineRecovery"; import type { OpponentController } from "./types"; @@ -391,6 +391,13 @@ export function createAIController(config: AIControllerConfig): AIController { if (!isAttemptCurrent(attempt)) return; const submission = await dispatchAiActionProposal(proposal); if (!isAttemptCurrent(attempt)) return; + if ( + proposal.action.type === "RespondResolveAllConsent" && + proposal.action.data.decision.type === "Grant" && + useGameStore.getState().gameState?.waiting_for?.type === "ResolveAllReady" + ) { + void dispatchResolveAll(playerId, config.seats); + } // The proposal boundary returns a tagged stale result without mutating // the store. That is a normal race, not a failed AI decision: leave // the counters untouched and let the final scheduler re-query the diff --git a/client/src/game/dispatch.ts b/client/src/game/dispatch.ts index 74b272d087..dcb84a5820 100644 --- a/client/src/game/dispatch.ts +++ b/client/src/game/dispatch.ts @@ -966,8 +966,8 @@ const BATCH_CHUNK_SIZE = 5; // thread responsive, while this still lets the overlay update during truly // pathological stacks. const BATCH_CHUNK_INSTANT = 5_000; -const BATCH_CHUNK_BASE_DELAY_MS = 150; let batchResolveInProgress = false; +let pendingResolveAllSeats: { playerId: number; difficulty: string }[] | null = null; export async function dispatchResolveAll( requester: number, @@ -979,6 +979,7 @@ export async function dispatchResolveAll( debugLog("dispatchResolveAll: no adapter"); return; } + const waitingFor = useGameStore.getState().gameState?.waiting_for; if ( !batchAdapter.resolveAll || (aiSeats.length === 0 && batchAdapter.resolveAllUsesServerAi !== true) @@ -998,8 +999,27 @@ export async function dispatchResolveAll( return; } + // Resolve All starts as an ordinary engine action so every representative + // receives the explicit Phase-1 Grant/Decline prompt. A later invocation + // after Ready consumes that already-issued authorization; it never starts a + // second run or asks a future AI decision speculatively. + if (waitingFor?.type !== "ResolveAllReady") { + pendingResolveAllSeats = aiSeats; + const stackLen = useGameStore.getState().gameState?.stack.length ?? 0; + const maxResolutions = + stackPressureFromLength(stackLen) === "Instant" + ? BATCH_CHUNK_INSTANT + : BATCH_CHUNK_SIZE; + await dispatchAction( + { type: "BeginResolveAll", data: { max_resolutions: maxResolutions } }, + requester, + ); + return; + } + + const resolvedSeats = pendingResolveAllSeats ?? aiSeats; + batchResolveInProgress = true; - const multiplier = usePreferencesStore.getState().animationSpeedMultiplier; const { setIsResolvingAll, setResolutionProgress } = useGameStore.getState(); setIsResolvingAll(true); // Storm-origin denominator: latched from the FIRST chunk's `total` because @@ -1010,70 +1030,30 @@ export async function dispatchResolveAll( let resolvedSoFar = 0; try { - for (;;) { - // Re-evaluate pressure each iteration: a storm shrinks as it drains, so - // it eventually drops back to the animated 5-at-a-time path near the end. - const stackLen = useGameStore.getState().gameState?.stack.length ?? 0; - const instant = stackPressureFromLength(stackLen) === "Instant"; - const chunkSize = instant ? BATCH_CHUNK_INSTANT : BATCH_CHUNK_SIZE; - - const batchResult: BatchResolveResult = await batchAdapter.resolveAll( - requester, aiSeats, chunkSize, - ); - - if (latchedTotal === 0) latchedTotal = batchResult.total; - resolvedSoFar += batchResult.itemsResolved; - // Keep the throughput tracker warm so a storm draining below Instant keeps - // its animated tail fast instead of snapping back to full pacing. - // `itemsResolved` is a net-shrink count (can lag the true gross when a - // resolution spawns triggers) — an acceptable under-count here since the - // batch path is already depth-gated, where the depth axis dominates pacing. - if (batchResult.itemsResolved > 0) recordStackResolutions(batchResult.itemsResolved); - // Surface progress only for a genuine storm (trivial multi-item resolves - // drain too fast to render). Clamp to the latched total: `itemsResolved` - // is a net-shrink count that can lag the true gross when a resolution - // spawns triggers, so clamping keeps the bar monotonic and lets it - // complete. `resolved`/`total` are engine-provided — no frontend derivation. - if (latchedTotal >= STACK_PRESSURE_ELEVATED) { - setResolutionProgress({ - resolved: Math.min(resolvedSoFar, latchedTotal), - total: latchedTotal, - }); - } - - // One atomic pair per chunk, committed through the single authority. The - // store's `waitingFor` therefore comes from the snapshot's own state, not - // from `batchResult.waitingFor` — the pair must stay self-consistent. - // Equivalent or fresher: worker FIFO or ordered WebSocket state updates - // guarantee this snapshot reflects at least the chunk's end state. - const snapshot = await batchAdapter.getSnapshot(); - useGameStore.getState().commitEngineSnapshot(snapshot); - - // Anything other than Priority ends the drain — GameOver included, since - // the drain only continues while this seat keeps receiving priority. - const done = - batchResult.itemsResolved === 0 || - snapshot.state.stack.length === 0 || - snapshot.state.waiting_for.type !== "Priority"; - if (done) break; - - if (instant) { - // Yield one frame so the resolution-progress overlay repaints between - // chunks. This rAF is the load-bearing progress fix — without it, - // back-to-back Instant chunks never let the browser paint, producing - // the "wait, then N vanish at once" symptom. - await new Promise((r) => requestAnimationFrame(() => r())); - continue; - } + const stackLen = useGameStore.getState().gameState?.stack.length ?? 0; + const maxResolutions = + stackPressureFromLength(stackLen) === "Instant" + ? BATCH_CHUNK_INSTANT + : BATCH_CHUNK_SIZE; + const batchResult: BatchResolveResult = await batchAdapter.resolveAll( + requester, resolvedSeats, maxResolutions, + ); - const chunkDelay = Math.round(BATCH_CHUNK_BASE_DELAY_MS * multiplier); - if (chunkDelay > 0) { - await new Promise((r) => setTimeout(r, chunkDelay)); - } else { - await new Promise((r) => requestAnimationFrame(() => r())); - } + if (latchedTotal === 0) latchedTotal = batchResult.total; + resolvedSoFar += batchResult.itemsResolved; + if (batchResult.itemsResolved > 0) recordStackResolutions(batchResult.itemsResolved); + if (latchedTotal >= STACK_PRESSURE_ELEVATED) { + setResolutionProgress({ + resolved: Math.min(resolvedSoFar, latchedTotal), + total: latchedTotal, + }); } + // The Ready authorization is one run only. Commit one atomic snapshot + // after its proved prefix, then return control to ordinary priority. + const snapshot = await batchAdapter.getSnapshot(); + useGameStore.getState().commitEngineSnapshot(snapshot); + const { gameId, adapter } = useGameStore.getState(); const newState = useGameStore.getState().gameState; if (gameId && adapter && newState) { @@ -1087,5 +1067,6 @@ export async function dispatchResolveAll( batchResolveInProgress = false; setIsResolvingAll(false); setResolutionProgress(null); + pendingResolveAllSeats = null; } } diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index cfc73722f6..7e57452b0a 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -55,6 +55,13 @@ "collapse": "Collapse resolving progress", "expand": "Expand resolving progress" }, + "resolveAllConsent": { + "eyebrow": "Resolve All", + "title": "Approve fast resolution?", + "subtitle": "All players must approve before the engine resolves a safe stack prefix.", + "grant": "Approve", + "decline": "Keep priority" + }, "debugCreate": { "copies": "Copies", "tokenPower": "Power", diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index 576d2086a3..33d5266d1f 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -100,6 +100,7 @@ import { RespondToPrecastCopyShortcutModal, } from "../components/modal/PrecastCopyShortcutModal.tsx"; import { ReplacementModal } from "../components/modal/ReplacementModal.tsx"; +import { ResolveAllConsentModal } from "../components/modal/ResolveAllConsentModal.tsx"; import { TriggerOrderModal } from "../components/modal/TriggerOrderModal.tsx"; import { PeekTab } from "../components/modal/DialogShell.tsx"; import { PeekRestoreTab } from "../components/modal/DialogHost.tsx"; @@ -1868,6 +1869,7 @@ function GamePageContent({ canActForWaitingState && } {waitingFor?.type === "ReplacementChoice" && canActForWaitingState && } + {waitingFor?.type === "OrderTriggers" && canActForWaitingState && } diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 29c7d0b149..4a70f004a0 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -14,8 +14,8 @@ use engine::ai_support::{ use engine::database::legality::{any_ai_difficulty_is_cedh, validate_cedh_bracket}; use engine::database::{CardDatabase, CardSearchQuery}; use engine::game::engine::{ - apply, apply_for_simulation, resolve_all_fast_forward, ResolveAllCallbackDecision, - ResolveAllFastForwardResult as BatchResolveResult, + apply, apply_for_simulation, resolve_all_fast_forward, resolve_all_ready_prefix, + ResolveAllCallbackDecision, ResolveAllFastForwardResult as BatchResolveResult, }; use engine::game::interaction::{bind_interaction_authority, submit_interaction}; use engine::game::preview::{compute_preview_diff, preview_auto_payment_sources}; @@ -3097,8 +3097,16 @@ pub fn resolve_all( let requester = PlayerId(requester); with_state_mut(|state| { - let mut rng = rand::rng(); - let mut result = resolve_all_inner(state, requester, &ai_seats, max_resolutions, &mut rng); + // Phase 2 consumes only the already-issued, unanimous consent run. + // AI consent is answered through ordinary engine candidates before this + // call; Resolve All must never ask an AI about a speculative future + // priority window. Keep the legacy payload parse as a wire-compatible + // boundary while the consent action owns the authoritative cap. + let _ = (ai_seats, max_resolutions); + if !matches!(&state.waiting_for, WaitingFor::ResolveAllReady { .. }) { + return Err(JsValue::from_str("Resolve All consent is not ready")); + } + let mut result = resolve_all_ready_prefix(state, requester); // A Resolve All burst applies real actions directly via // `apply_action_boundary_with_stack_limit` (bypassing `submit_action`, // which is the only other place REPLAY_LOG is appended to) — without diff --git a/crates/engine/src/game/engine_resolve_batch.rs b/crates/engine/src/game/engine_resolve_batch.rs index 168ced5004..6a16b3c47a 100644 --- a/crates/engine/src/game/engine_resolve_batch.rs +++ b/crates/engine/src/game/engine_resolve_batch.rs @@ -3,13 +3,13 @@ use serde::{Deserialize, Serialize}; use crate::ai_support::AiDecisionContract; use crate::types::actions::GameAction; use crate::types::events::GameEvent; -use crate::types::game_state::{GameState, WaitingFor}; +use crate::types::game_state::{GameState, ResolveAllConsentRun, WaitingFor}; use crate::types::log::GameLogEntry; use crate::types::player::PlayerId; use super::engine::{apply_action_boundary_with_stack_limit, PublicFinalizeMode}; use super::public_state::finalize_display_state; -use super::{topology, turn_control}; +use super::{stack, topology, turn_control}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -49,6 +49,184 @@ pub enum ResolveAllCallbackDecision { Stop, } +/// Resolves the greatest prefix which has already received every priority +/// representative's explicit, run-scoped Resolve All consent. +/// +/// Unlike the legacy callback fast-forward below, this path never asks an AI +/// (or any future priority holder) whether to pass. Consent is the sole +/// authority. Each prospective resolution is materialized on a clone through +/// a complete, ordinary priority cycle, with `Some(1)` preventing any of the +/// existing stack batchers from consuming more than its one stack entry. The +/// clone is committed only when it settles to an unchanged-topology Priority +/// checkpoint with exactly that entry removed. This is intentionally a +/// greatest-safe-prefix proof, not loop detection or state equality. +pub fn resolve_all_ready_prefix( + state: &mut GameState, + requester: PlayerId, +) -> ResolveAllFastForwardResult { + let total = state.stack.len() as u32; + let mut events = Vec::new(); + let mut log_entries = Vec::new(); + let mut recorded_actions = Vec::new(); + let mut items_resolved = 0; + + let Some(run) = ready_consent_run(state, requester).cloned() else { + if matches!(&state.waiting_for, WaitingFor::ResolveAllReady { .. }) { + turn_control::invalidate_resolve_all_consent(state); + finalize_display_state(state); + } + return ResolveAllFastForwardResult { + events, + waiting_for: state.waiting_for.clone(), + log_entries, + items_resolved, + total, + recorded_actions, + }; + }; + + // Ready is deliberately inert. Materialize the saved priority checkpoint + // only inside this Resolve All consumer; ordinary actions can never pass + // through Ready. + state.waiting_for = WaitingFor::Priority { + player: run.priority_snapshot.waiting_player, + }; + + let resolution_cap = if run.max_resolutions == 0 { + u32::MAX + } else { + run.max_resolutions + }; + + while items_resolved < resolution_cap && !state.stack.is_empty() { + let mut proof = state.clone(); + let stack_before = proof.stack.len(); + let Some((boundary, mut actions)) = materialize_one_consented_resolution(&mut proof, &run) + else { + break; + }; + + // CR 117.5 + CR 704.3 + CR 603.3b: do not collapse across any new + // checkpoint work. In particular, if item N causes a shuffle/dies/etc. + // trigger, this refuses N and leaves it on the live stack for ordinary + // priority, while earlier committed entries remain collapsed. + if stack_resolved_count(&boundary.events) != 1 + || proof.stack.len().saturating_add(1) != stack_before + || !matches!(proof.waiting_for, WaitingFor::Priority { .. }) + || !stack::priority_checkpoint_is_settled(&proof) + || !consent_authorization_matches(&proof, &run) + { + break; + } + + items_resolved += 1; + events.extend(boundary.events); + log_entries.extend(boundary.log_entries); + recorded_actions.append(&mut actions); + *state = proof; + } + + // Authorization is one run only. Once the proved prefix ends (including + // a zero-length or cap boundary), return the remaining stack to ordinary + // priority; no later stack entry inherits this consent. + state.resolve_all_consent_run = None; + finalize_display_state(state); + + ResolveAllFastForwardResult { + events, + waiting_for: state.waiting_for.clone(), + log_entries, + items_resolved, + total, + recorded_actions, + } +} + +/// Validates the frozen Phase-1 consent against the live topology before the +/// Ready state is materialized. A changed controller, eliminated player, or +/// stale requester fails closed without invoking a speculative callback. +fn ready_consent_run(state: &GameState, requester: PlayerId) -> Option<&ResolveAllConsentRun> { + let WaitingFor::ResolveAllReady { epoch } = &state.waiting_for else { + return None; + }; + let run = state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == *epoch && run.participants.iter().all(|p| p.granted))?; + (run.participants + .iter() + .any(|participant| participant.authorized_submitter == requester) + && state.priority_player == run.priority_snapshot.priority_player + && state.priority_pass_count == run.priority_snapshot.priority_pass_count + && state.priority_passes == run.priority_snapshot.priority_passes + && consent_authorization_matches(state, run)) + .then_some(run) +} + +fn consent_authorization_matches(state: &GameState, run: &ResolveAllConsentRun) -> bool { + let representatives = topology::priority_pass_participants(state); + representatives.len() == run.participants.len() + && representatives + .iter() + .zip(&run.participants) + .all(|(live, frozen)| { + *live == frozen.representative + && turn_control::authorized_submitter_for_player(state, *live) + == frozen.authorized_submitter + }) +} + +/// Performs exactly one actual priority cycle on a proof clone. Every seeded +/// pass is recorded in application order so replay can submit the same normal +/// `PassPriority` actions without a hidden batch-only transition. +fn materialize_one_consented_resolution( + state: &mut GameState, + run: &ResolveAllConsentRun, +) -> Option<( + crate::types::game_state::ActionResult, + Vec<(PlayerId, GameAction)>, +)> { + let WaitingFor::Priority { player } = &state.waiting_for else { + return None; + }; + if !consent_authorization_matches(state, run) { + return None; + } + let actor = turn_control::authorized_submitter_for_player(state, *player); + let mut recorded = Vec::new(); + seed_remaining_consented_priority_passes(state, *player, &mut recorded)?; + let boundary = apply_action_boundary_with_stack_limit( + state, + actor, + *player, + GameAction::PassPriority, + PublicFinalizeMode::DeferredDisplay, + Some(1), + ) + .ok()?; + recorded.insert(0, (actor, GameAction::PassPriority)); + Some((boundary, recorded)) +} + +fn seed_remaining_consented_priority_passes( + state: &mut GameState, + current_seat: PlayerId, + recorded: &mut Vec<(PlayerId, GameAction)>, +) -> Option<()> { + let current_rep = topology::priority_pass_representative(state, current_seat); + let participants = topology::priority_pass_participants(state); + let current_idx = participants.iter().position(|seat| *seat == current_rep)?; + for offset in 1..participants.len() { + let representative = participants[(current_idx + offset) % participants.len()]; + if !state.priority_passes.contains(&representative) { + let actor = turn_control::authorized_submitter_for_player(state, representative); + state.priority_passes.insert(representative); + recorded.push((actor, GameAction::PassPriority)); + } + } + Some(()) +} + enum PriorityCycleFastForward { Seeded, CannotSeed, @@ -285,6 +463,7 @@ mod tests { AbilityCost, AbilityDefinition, AbilityKind, CopyRetargetPermission, Effect, ManaContribution, ManaProduction, ResolvedAbility, TargetFilter, }; + use crate::types::actions::ResolveAllConsentDecision; use crate::types::card_type::{CardType, CoreType}; use crate::types::format::FormatConfig; use crate::types::game_state::{PublicStateDirty, StackEntry, StackEntryKind}; @@ -643,4 +822,59 @@ mod tests { assert_eq!(state.public_state_dirty, PublicStateDirty::default()); assert!(!state.objects[&land_id].has_mana_ability); } + + fn ready_state(stack: Vec) -> GameState { + let mut state = priority_state(PlayerId(0), stack); + super::super::engine::apply( + &mut state, + PlayerId(0), + GameAction::BeginResolveAll { max_resolutions: 0 }, + ) + .expect("priority holder begins the consent run"); + let WaitingFor::ResolveAllConsent { epoch, .. } = &state.waiting_for else { + panic!("second representative should be queued"); + }; + super::super::engine::apply( + &mut state, + PlayerId(1), + GameAction::RespondResolveAllConsent { + epoch: *epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("second representative grants"); + assert!(matches!( + &state.waiting_for, + WaitingFor::ResolveAllReady { .. } + )); + state + } + + #[test] + fn ready_consent_commits_the_greatest_settled_prefix_and_records_passes() { + // The lower self-copy creates a new stack object. It is deliberately + // left for ordinary priority, while both safe entries above it commit. + let mut state = ready_state(vec![ + self_copy_entry(1, PlayerId(0)), + no_op_entry(2, PlayerId(0)), + no_op_entry(3, PlayerId(0)), + ]); + + let result = resolve_all_ready_prefix(&mut state, PlayerId(0)); + + assert_eq!(result.items_resolved, 2); + assert_eq!(state.stack.len(), 1, "unsafe item remains on the stack"); + assert!(matches!(state.waiting_for, WaitingFor::Priority { .. })); + assert!(state.resolve_all_consent_run.is_none()); + assert_eq!( + result.recorded_actions, + vec![ + (PlayerId(0), GameAction::PassPriority), + (PlayerId(1), GameAction::PassPriority), + (PlayerId(0), GameAction::PassPriority), + (PlayerId(1), GameAction::PassPriority), + ], + "the collapsed prefix remains reproducible through ordinary actions" + ); + } } diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index bd58e67dce..a83830bfe6 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -2917,7 +2917,7 @@ fn resolve_proven_inert_trigger_batch( run_len: u32, pipeline_invariant: Option, ) -> Option { - if !inert_trigger_batch_state_is_settled(state) { + if !priority_checkpoint_is_settled(state) { return None; } @@ -2966,7 +2966,7 @@ fn resolve_proven_inert_trigger_batch( || counters_after_resolution .is_some_and(|before| battlefield_counter_snapshot(&proof) != before) || initial_len.saturating_sub(proof.stack.len()) != expected_consumed - || !inert_trigger_batch_state_is_settled(&proof) + || !priority_checkpoint_is_settled(&proof) { return None; } @@ -3025,7 +3025,11 @@ fn consumed_trigger_event_occurrences( .collect() } -fn inert_trigger_batch_state_is_settled(state: &GameState) -> bool { +/// True when resolution has reached a full priority checkpoint with no latent +/// trigger, replacement, or continuation work. Batch consumers that prove a +/// sequence on a clone share this boundary rather than inferring safety from +/// stack depth alone. +pub(crate) fn priority_checkpoint_is_settled(state: &GameState) -> bool { state.pending_replacement.is_none() && state.pending_trigger.is_none() && state.pending_trigger_event_batch.is_empty() @@ -7490,8 +7494,8 @@ mod tests { // Driver internals under test (the stack module). use super::super::{ batch_run_len, effects, fixed_controller_gain_life_run_len, - fixed_opponent_lose_life_run_len, inert_trigger_batch_state_is_settled, - observers_are_batch_safe, resolve_next, resolve_next_with_limit, resolve_top, + fixed_opponent_lose_life_run_len, observers_are_batch_safe, + priority_checkpoint_is_settled, resolve_next, resolve_next_with_limit, resolve_top, self_counter_run_len, }; // Test fixtures from the parent `tests` module. @@ -8160,7 +8164,7 @@ mod tests { }); assert!( - !inert_trigger_batch_state_is_settled(&state), + !priority_checkpoint_is_settled(&state), "an active resolution frame makes a skipped priority checkpoint observable" ); } diff --git a/crates/server-core/src/session.rs b/crates/server-core/src/session.rs index f9aebd2542..bdfabe9250 100644 --- a/crates/server-core/src/session.rs +++ b/crates/server-core/src/session.rs @@ -2,15 +2,11 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use engine::ai_support::{ - auto_pass_recommended, legal_actions_full as engine_legal_actions_full, AiDecisionContract, -}; +use engine::ai_support::{auto_pass_recommended, legal_actions_full as engine_legal_actions_full}; use engine::database::legality::{validate_cedh_bracket, CedhBracketError}; use engine::database::CardDatabase; use engine::game::deck_loading::{DeckPayload, PlayerDeckPayload}; -use engine::game::engine::{ - apply, resolve_all_fast_forward, start_game, ResolveAllCallbackDecision, -}; +use engine::game::engine::{apply, resolve_all_ready_prefix, start_game}; use engine::game::interaction::{bind_interaction_authority, submit_interaction}; use engine::game::layers::flush_layers; use engine::game::match_flow::apply_trusted_match_forfeit; @@ -33,7 +29,6 @@ use engine::types::mana::ManaCost; use engine::types::match_config::MatchConfig; use engine::types::match_config::MatchForfeitCause; use engine::types::player::PlayerId; -use phase_ai::choose_action_with_session; use phase_ai::config::{AiConfig, AiDifficulty, Platform}; use phase_ai::session::AiSession; use rand::{Rng, SeedableRng}; @@ -1603,65 +1598,26 @@ impl SessionManager { ); } - // This is a priority shortcut, never an authorization bypass. A human - // may start it only while they currently hold the engine's priority. - if acting_player(&session.state) != Some(requester) { - return Err("Resolve All requires your priority".to_string()); + if !matches!( + &session.state.waiting_for, + engine::types::game_state::WaitingFor::ResolveAllReady { .. } + ) { + return Err("Resolve All consent is not ready".to_string()); } session.state.log_player_names = session.display_names.clone(); flush_layers(&mut session.state); - let ai_seats = session.ai_seats.clone(); - let ai_configs = session.ai_configs.clone(); - let ai_session = Arc::clone( - session - .ai_session - .get_or_insert_with(|| AiSession::arc_from_game(&session.state)), - ); let pre_action_state = session.state.clone(); - let mut rng = rand::rng(); - let batch = resolve_all_fast_forward( - &mut session.state, - requester, - max_resolutions, - |state, actor| { - if !ai_seats.contains(&actor) { - return ResolveAllCallbackDecision::Stop; - } - let Some(config) = ai_configs.get(&actor) else { - return ResolveAllCallbackDecision::Stop; - }; - let Some(semantic_owner) = state - .waiting_for - .acting_player() - .or_else(|| state.waiting_for.acting_players().first().copied()) - else { - return ResolveAllCallbackDecision::Stop; - }; - let contract = AiDecisionContract::issue(state, semantic_owner); - match choose_action_with_session( - state, - semantic_owner, - config, - &mut rng, - &ai_session, - ) { - Some(action) if contract.permits(state, actor, &action) => { - ResolveAllCallbackDecision::Proposal { contract, action } - } - Some(_) | None => ResolveAllCallbackDecision::Stop, - } - }, - ); + // The cap is frozen into the consent run by BeginResolveAll. The wire + // argument remains range-checked above for protocol compatibility but + // cannot enlarge or replace that explicit authorization. + let _ = max_resolutions; + let batch = resolve_all_ready_prefix(&mut session.state, requester); let summary = ResolveAllSummary { items_resolved: batch.items_resolved, total: batch.total, }; - if batch.recorded_actions.is_empty() { - return Ok((None, summary)); - } - session.push_takeback_state(requester, pre_action_state); let (legal_actions, spell_costs, by_object) = engine_legal_actions_full(&session.state); let auto_pass = auto_pass_recommended(&session.state, &legal_actions); From 25f0d07c0dc3923f7860fa4fb4bd11a109a5ea99 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 20:59:50 -0700 Subject: [PATCH 05/19] fix(resolve-all): consume ready consent locally --- .../game/__tests__/dispatchResolveAll.test.ts | 28 +++++++++++++++++++ client/src/game/dispatch.ts | 7 ++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/client/src/game/__tests__/dispatchResolveAll.test.ts b/client/src/game/__tests__/dispatchResolveAll.test.ts index fe364ff8a0..c4ce3cd2a3 100644 --- a/client/src/game/__tests__/dispatchResolveAll.test.ts +++ b/client/src/game/__tests__/dispatchResolveAll.test.ts @@ -18,6 +18,13 @@ function stateWithStack(len: number): GameState { }); } +function readyStateWithStack(len: number): GameState { + return buildGameState({ + waiting_for: { type: "ResolveAllReady", data: { epoch: 1 } }, + stack: Array.from({ length: len }, (_, index) => buildStackEntry({ id: index + 1 })), + }); +} + function chunk(itemsResolved: number, total: number): BatchResolveResult { return { events: [], waitingFor: priorityWf, logEntries: [], itemsResolved, total }; } @@ -169,6 +176,27 @@ describe("dispatchResolveAll progress", () => { ); }); + it("consumes Ready consent before considering the empty-AI fallback", async () => { + const resolveAll = vi.fn().mockResolvedValue(chunk(1, 2)); + const submitAction = vi.fn(); + const getState = vi.fn().mockResolvedValue(stateWithStack(1)); + useGameStore.setState({ + gameState: readyStateWithStack(2), + adapter: { + resolveAll, + submitAction, + getState, + getLegalActions: vi.fn().mockResolvedValue({ actions: [], autoPassRecommended: false }), + getSnapshot: snapshotVia(getState), + } as never, + }); + + await dispatchResolveAll(0, []); + + expect(resolveAll).toHaveBeenCalledWith(0, [], 5); + expect(submitAction).not.toHaveBeenCalled(); + }); + it("uses an empty AI-seat list when the adapter delegates native AI ownership to its server", async () => { const resolveAll = vi.fn().mockResolvedValue(chunk(0, 2)); const getState = vi.fn().mockResolvedValue(stateWithStack(0)); diff --git a/client/src/game/dispatch.ts b/client/src/game/dispatch.ts index dcb84a5820..1da1567d7d 100644 --- a/client/src/game/dispatch.ts +++ b/client/src/game/dispatch.ts @@ -980,7 +980,12 @@ export async function dispatchResolveAll( return; } const waitingFor = useGameStore.getState().gameState?.waiting_for; - if ( + if (waitingFor?.type === "ResolveAllReady") { + if (!batchAdapter.resolveAll) { + debugLog("dispatchResolveAll: consent is Ready but the adapter cannot consume it"); + return; + } + } else if ( !batchAdapter.resolveAll || (aiSeats.length === 0 && batchAdapter.resolveAllUsesServerAi !== true) ) { From 28397a995d2156a7f564ac8b0c8e8dcf92b5d15c Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 10:18:37 -0700 Subject: [PATCH 06/19] fix(resolve-all): resume after AI consent grant --- .../__tests__/aiController.test.ts | 181 +++++++++++++++++- client/src/game/controllers/aiController.ts | 22 ++- 2 files changed, 198 insertions(+), 5 deletions(-) diff --git a/client/src/game/controllers/__tests__/aiController.test.ts b/client/src/game/controllers/__tests__/aiController.test.ts index e31af29724..abb497212f 100644 --- a/client/src/game/controllers/__tests__/aiController.test.ts +++ b/client/src/game/controllers/__tests__/aiController.test.ts @@ -14,14 +14,20 @@ const dispatchMocks = vi.hoisted(() => ({ dispatchAiActionProposal: vi.fn< (proposal: AiActionProposal) => Promise<{ status: "applied" | "stale" }> >(), + dispatchResolveAll: vi.fn< + (requester: number, seats: { playerId: number; difficulty: string }[]) => Promise + >(), })); -const { dispatchAiActionProposal } = dispatchMocks; +const { dispatchAiActionProposal, dispatchResolveAll } = dispatchMocks; const notifyEngineLost = vi.fn(); const attemptStateRehydrate = vi.fn(async () => false); const isEnginePanic = vi.fn<(error: unknown) => boolean>(() => false); const routePanic = vi.fn<(reason: string, panic?: string) => Promise>(async () => {}); -vi.mock("../../dispatch", () => ({ dispatchAiActionProposal: dispatchMocks.dispatchAiActionProposal })); +vi.mock("../../dispatch", () => ({ + dispatchAiActionProposal: dispatchMocks.dispatchAiActionProposal, + dispatchResolveAll: dispatchMocks.dispatchResolveAll, +})); vi.mock("../../engineRecovery", () => ({ attemptStateRehydrate: () => attemptStateRehydrate(), isEnginePanic: (error: unknown) => isEnginePanic(error), @@ -88,6 +94,8 @@ beforeEach(() => { // of nested retries to fit inside that same window. randomSpy = vi.spyOn(Math, "random").mockReturnValue(1); dispatchAiActionProposal.mockReset(); + dispatchResolveAll.mockReset(); + dispatchResolveAll.mockResolvedValue(undefined); notifyEngineLost.mockReset(); attemptStateRehydrate.mockReset(); attemptStateRehydrate.mockResolvedValue(false); @@ -250,6 +258,175 @@ describe("AI proposal controller", () => { controller.dispose(); }); + it("starts Resolve All with the engine-issued actor after an AI representative grants consent", async () => { + const consent = { + type: "ResolveAllConsent", + data: { epoch: 7, representative: 1 }, + } as WaitingFor; + const ready = { type: "ResolveAllReady", data: { epoch: 7 } } as WaitingFor; + const state = buildGameState({ waiting_for: consent, priority_player: 0, stack: [] }); + storeState.gameState = state; + storeState.waitingFor = consent; + const issued: AiActionProposal = { + token: "engine-bound-consent", + semanticOwner: 1, + actor: 0, + action: { + type: "RespondResolveAllConsent", + data: { epoch: 7, decision: { type: "Grant" } }, + }, + }; + const getAiActionProposal = vi.fn(async () => issued); + storeState.adapter = { getAiActionProposal }; + dispatchAiActionProposal.mockImplementation(async () => { + storeState.gameState = { ...state, waiting_for: ready }; + storeState.waitingFor = ready; + storeSubscriber?.(); + return { status: "applied" }; + }); + + const seats = [{ playerId: 1, difficulty: "Medium" }]; + const controller = createAIController({ seats }); + controller.start(); + await runOnce(); + + expect(getAiActionProposal).toHaveBeenCalledWith("Medium", 1); + expect(dispatchAiActionProposal).toHaveBeenCalledWith(issued); + expect(dispatchResolveAll).toHaveBeenCalledWith(0, seats); + controller.dispose(); + }); + + it("does not act when the local human is the Resolve All consent representative", async () => { + const consent = { + type: "ResolveAllConsent", + data: { epoch: 7, representative: 0 }, + } as WaitingFor; + const state = buildGameState({ waiting_for: consent, priority_player: 1, stack: [] }); + const getAiActionProposal = vi.fn(async () => proposal(PASS)); + storeState.gameState = state; + storeState.waitingFor = consent; + storeState.adapter = { getAiActionProposal }; + + const controller = createAIController({ seats: [{ playerId: 1, difficulty: "Medium" }] }); + controller.start(); + await runOnce(); + + expect(getAiActionProposal).not.toHaveBeenCalled(); + expect(dispatchAiActionProposal).not.toHaveBeenCalled(); + expect(dispatchResolveAll).not.toHaveBeenCalled(); + controller.dispose(); + }); + + it("does not start Resolve All when the Ready epoch differs from the submitted consent", async () => { + const consent = { + type: "ResolveAllConsent", + data: { epoch: 7, representative: 1 }, + } as WaitingFor; + const ready = { type: "ResolveAllReady", data: { epoch: 8 } } as WaitingFor; + const state = buildGameState({ waiting_for: consent, priority_player: 0, stack: [] }); + storeState.gameState = state; + storeState.waitingFor = consent; + storeState.adapter = { + getAiActionProposal: vi.fn(async () => ({ + token: "engine-bound-consent", + semanticOwner: 1, + actor: 0, + action: { + type: "RespondResolveAllConsent", + data: { epoch: 7, decision: { type: "Grant" } }, + }, + })), + }; + dispatchAiActionProposal.mockImplementation(async () => { + storeState.gameState = { ...state, waiting_for: ready }; + storeState.waitingFor = ready; + storeSubscriber?.(); + return { status: "applied" }; + }); + + const controller = createAIController({ seats: [{ playerId: 1, difficulty: "Medium" }] }); + controller.start(); + await runOnce(); + + expect(dispatchResolveAll).not.toHaveBeenCalled(); + controller.dispose(); + }); + + it("does not start Resolve All for a stale consent submission even if Ready coincides", async () => { + const consent = { + type: "ResolveAllConsent", + data: { epoch: 7, representative: 1 }, + } as WaitingFor; + const ready = { type: "ResolveAllReady", data: { epoch: 7 } } as WaitingFor; + const state = buildGameState({ waiting_for: consent, priority_player: 0, stack: [] }); + storeState.gameState = state; + storeState.waitingFor = consent; + storeState.adapter = { + getAiActionProposal: vi.fn(async () => ({ + token: "engine-bound-consent", + semanticOwner: 1, + actor: 0, + action: { + type: "RespondResolveAllConsent", + data: { epoch: 7, decision: { type: "Grant" } }, + }, + })), + }; + dispatchAiActionProposal.mockImplementation(async () => { + storeState.gameState = { ...state, waiting_for: ready }; + storeState.waitingFor = ready; + storeSubscriber?.(); + return { status: "stale" }; + }); + + const controller = createAIController({ seats: [{ playerId: 1, difficulty: "Medium" }] }); + controller.start(); + await runOnce(); + + expect(dispatchResolveAll).not.toHaveBeenCalled(); + controller.dispose(); + }); + + it("does not start Resolve All after a pending consent submission is superseded by a new session", async () => { + const consent = { + type: "ResolveAllConsent", + data: { epoch: 7, representative: 1 }, + } as WaitingFor; + const ready = { type: "ResolveAllReady", data: { epoch: 7 } } as WaitingFor; + const state = buildGameState({ waiting_for: consent, priority_player: 0, stack: [] }); + const pendingSubmission = deferred<{ status: "applied" | "stale" }>(); + storeState.gameState = state; + storeState.waitingFor = consent; + storeState.adapter = { + getAiActionProposal: vi.fn(async () => ({ + token: "engine-bound-consent", + semanticOwner: 1, + actor: 0, + action: { + type: "RespondResolveAllConsent", + data: { epoch: 7, decision: { type: "Grant" } }, + }, + })), + }; + dispatchAiActionProposal.mockReturnValue(pendingSubmission.promise); + + const controller = createAIController({ seats: [{ playerId: 1, difficulty: "Medium" }] }); + controller.start(); + await runOnce(); + + storeState.gameSessionGeneration += 1; + storeSubscriber?.(); + storeState.gameState = { ...state, waiting_for: ready }; + storeState.waitingFor = ready; + storeSubscriber?.(); + pendingSubmission.resolve({ status: "applied" }); + await Promise.resolve(); + await Promise.resolve(); + + expect(dispatchResolveAll).not.toHaveBeenCalled(); + controller.dispose(); + }); + it("does not retry state recovery after a newer game session supersedes the attempt", async () => { const pendingFailure = deferred(); const getAiActionProposal = vi diff --git a/client/src/game/controllers/aiController.ts b/client/src/game/controllers/aiController.ts index 5e52b5a5a1..25458710dc 100644 --- a/client/src/game/controllers/aiController.ts +++ b/client/src/game/controllers/aiController.ts @@ -83,6 +83,7 @@ export function createAIController(config: AIControllerConfig): AIController { waitingForFingerprint: string; playerId: number; isPriority: boolean; + resolveAllConsentEpoch: number | null; } let currentAttempt: AIAttempt | null = null; @@ -154,6 +155,11 @@ export function createAIController(config: AIControllerConfig): AIController { ) { return null; } + if (waitingFor.type === "ResolveAllConsent") { + return waitingFor.data.representative === PLAYER_ID + ? null + : waitingFor.data.representative; + } if ( !("data" in waitingFor) || !waitingFor.data || @@ -174,6 +180,8 @@ export function createAIController(config: AIControllerConfig): AIController { waitingForFingerprint: waitingForFingerprint(waitingFor), playerId, isPriority: waitingFor.type === "Priority", + resolveAllConsentEpoch: + waitingFor.type === "ResolveAllConsent" ? waitingFor.data.epoch : null, }; currentAttempt = attempt; pending = true; @@ -390,14 +398,22 @@ export function createAIController(config: AIControllerConfig): AIController { // the authority boundary. if (!isAttemptCurrent(attempt)) return; const submission = await dispatchAiActionProposal(proposal); - if (!isAttemptCurrent(attempt)) return; + const store = useGameStore.getState(); + const waitingForAfterSubmission = store.gameState?.waiting_for ?? null; if ( + active && + submission.status === "applied" && + attempt.resolveAllConsentEpoch !== null && proposal.action.type === "RespondResolveAllConsent" && proposal.action.data.decision.type === "Grant" && - useGameStore.getState().gameState?.waiting_for?.type === "ResolveAllReady" + proposal.action.data.epoch === attempt.resolveAllConsentEpoch && + store.gameSessionGeneration === attempt.gameSessionGeneration && + waitingForAfterSubmission?.type === "ResolveAllReady" && + waitingForAfterSubmission.data.epoch === attempt.resolveAllConsentEpoch ) { - void dispatchResolveAll(playerId, config.seats); + void dispatchResolveAll(proposal.actor, config.seats); } + if (!isAttemptCurrent(attempt)) return; // The proposal boundary returns a tagged stale result without mutating // the store. That is a normal race, not a failed AI decision: leave // the counters untouched and let the final scheduler re-query the From 1e2761ff5fd646a97005b6f811d23a0f6308f598 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 10:39:04 -0700 Subject: [PATCH 07/19] test(resolve-all): type consent proposals --- client/src/game/controllers/__tests__/aiController.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/game/controllers/__tests__/aiController.test.ts b/client/src/game/controllers/__tests__/aiController.test.ts index abb497212f..1d9ac26138 100644 --- a/client/src/game/controllers/__tests__/aiController.test.ts +++ b/client/src/game/controllers/__tests__/aiController.test.ts @@ -327,7 +327,7 @@ describe("AI proposal controller", () => { storeState.gameState = state; storeState.waitingFor = consent; storeState.adapter = { - getAiActionProposal: vi.fn(async () => ({ + getAiActionProposal: vi.fn<() => Promise>(async () => ({ token: "engine-bound-consent", semanticOwner: 1, actor: 0, @@ -362,7 +362,7 @@ describe("AI proposal controller", () => { storeState.gameState = state; storeState.waitingFor = consent; storeState.adapter = { - getAiActionProposal: vi.fn(async () => ({ + getAiActionProposal: vi.fn<() => Promise>(async () => ({ token: "engine-bound-consent", semanticOwner: 1, actor: 0, @@ -398,7 +398,7 @@ describe("AI proposal controller", () => { storeState.gameState = state; storeState.waitingFor = consent; storeState.adapter = { - getAiActionProposal: vi.fn(async () => ({ + getAiActionProposal: vi.fn<() => Promise>(async () => ({ token: "engine-bound-consent", semanticOwner: 1, actor: 0, From ffa3ea9dea18d8792dfb8dfa32d0734d88d96e1c Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 11:03:10 -0700 Subject: [PATCH 08/19] test(engine): order resolve-all consent module --- crates/engine/tests/integration/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index bcf32855b6..ca1b5668f2 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -936,8 +936,8 @@ mod relic_of_progenitus_6446; mod render_silent_cant_cast; mod replacement_mill_double_application; mod repro_pilot_crew; -mod retarget_prompt_softlock; mod resolve_all_consent; +mod retarget_prompt_softlock; mod revealed_card_type_disjunction_518; mod rhys_evermore_remove_counters; mod riot_control_regression; From 7ab15a3c59c6aaa3d25e34e451b97e6e53182ed1 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 11:07:25 -0700 Subject: [PATCH 09/19] fix(resolve-all): cover consent waiting states --- crates/engine/src/ai_support/candidates.rs | 4 +++- crates/engine/src/game/engine_resolve_batch.rs | 7 ++++--- crates/engine/src/game/interaction.rs | 1 + crates/engine/src/types/game_state.rs | 1 + 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 1f10626dd8..0809076934 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -917,7 +917,9 @@ pub fn candidate_actions_broad_with_probe( probe: Option<&casting::PriorityCastProbe>, ) -> Vec { let actions = match &state.waiting_for { - WaitingFor::MeldPairChoice { .. } + WaitingFor::ResolveAllConsent { .. } + | WaitingFor::ResolveAllReady { .. } + | WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } | WaitingFor::EntryAttackTargetChoice { .. } => candidate_actions_exact(state), WaitingFor::Priority { player } => priority_actions_with_probe(state, *player, probe), diff --git a/crates/engine/src/game/engine_resolve_batch.rs b/crates/engine/src/game/engine_resolve_batch.rs index 6a16b3c47a..2991fa2bb5 100644 --- a/crates/engine/src/game/engine_resolve_batch.rs +++ b/crates/engine/src/game/engine_resolve_batch.rs @@ -189,16 +189,17 @@ fn materialize_one_consented_resolution( let WaitingFor::Priority { player } = &state.waiting_for else { return None; }; + let player = *player; if !consent_authorization_matches(state, run) { return None; } - let actor = turn_control::authorized_submitter_for_player(state, *player); + let actor = turn_control::authorized_submitter_for_player(state, player); let mut recorded = Vec::new(); - seed_remaining_consented_priority_passes(state, *player, &mut recorded)?; + seed_remaining_consented_priority_passes(state, player, &mut recorded)?; let boundary = apply_action_boundary_with_stack_limit( state, actor, - *player, + player, GameAction::PassPriority, PublicFinalizeMode::DeferredDisplay, Some(1), diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 2febf59809..ea293be5a6 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -3085,6 +3085,7 @@ fn selection_projection( } Ok(match waiting_for { + WaitingFor::ResolveAllConsent { .. } | WaitingFor::ResolveAllReady { .. } => None, WaitingFor::OpeningHandBottomCards { pending, .. } => pending .iter() .find(|entry| entry.player == semantic_owner) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index bf9527dfe6..a480666ac4 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -13177,6 +13177,7 @@ impl WaitingFor { None } } + WaitingFor::ResolveAllReady { .. } => None, WaitingFor::Priority { player } | WaitingFor::ResolveAllConsent { representative: player, From 80e678513c924f8f794f733998d2c7cc99a61c53 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 11:18:57 -0700 Subject: [PATCH 10/19] fix(resolve-all): wire consent across decision surfaces --- .../waiting-for-handler-parity.test.ts | 15 +++--- .../game/__tests__/dispatchResolveAll.test.ts | 46 ++++++------------- client/src/game/waitingForRegistry.ts | 2 + client/src/i18n/locales/de/game.json | 7 +++ client/src/i18n/locales/es/game.json | 7 +++ client/src/i18n/locales/fr/game.json | 7 +++ client/src/i18n/locales/it/game.json | 7 +++ client/src/i18n/locales/pl/game.json | 7 +++ client/src/i18n/locales/pt/game.json | 7 +++ crates/engine/src/game/scenario.rs | 2 + crates/phase-ai/src/decision_kind.rs | 4 +- .../phase-ai/src/policies/discard_payoff.rs | 3 ++ crates/phase-ai/src/policies/draw_payoff.rs | 3 ++ crates/phase-ai/src/search.rs | 16 +++++++ 14 files changed, 92 insertions(+), 41 deletions(-) diff --git a/client/src/adapter/__tests__/waiting-for-handler-parity.test.ts b/client/src/adapter/__tests__/waiting-for-handler-parity.test.ts index f783bdaf92..5a4bd0464d 100644 --- a/client/src/adapter/__tests__/waiting-for-handler-parity.test.ts +++ b/client/src/adapter/__tests__/waiting-for-handler-parity.test.ts @@ -11,20 +11,17 @@ import { repoRoot, rustEnumVariants } from "./rustEnumVariants"; * Engine `WaitingFor` variants that are never surfaced to a human and so * legitimately have no frontend UI handler. * - * This set is intentionally EMPTY. An audit of `WaitingFor::acting_player()` - * (crates/engine/src/types/game_state.rs) shows every variant routes its - * authorization to a human player — single-pending mulligan variants resolve - * to the one pending player, `VoteChoice` to `actor.resolve(player)`, - * `AssistPayment` to `chosen`, and every remaining variant to `Some(*player)`. - * The sole exception is `GameOver`, which returns `None` (terminal lifecycle - * state) and is already present in `HANDLED_WAITING_FOR_TYPES`. No - * internal-only, never-player-facing variant exists today. + * `ResolveAllReady` is deliberately inert: `WaitingFor::acting_player()` in + * `crates/engine/src/types/game_state.rs` returns `None` for it, and the + * consent submitter consumes the already-authorized run through + * `dispatchResolveAll`. `GameOver` likewise returns `None` but remains in + * `HANDLED_WAITING_FOR_TYPES` because it has a rendered terminal screen. * * If a future variant is genuinely never player-facing, add it here WITH a * cited `acting_player()` reference proving it returns `None`. */ const INTERNAL_NEVER_PLAYER_FACING: ReadonlySet = - new Set([]); + new Set(["ResolveAllReady"]); describe("WaitingFor handler parity", () => { it("registers both interactive meld waiting states", () => { diff --git a/client/src/game/__tests__/dispatchResolveAll.test.ts b/client/src/game/__tests__/dispatchResolveAll.test.ts index c4ce3cd2a3..7f71fc8748 100644 --- a/client/src/game/__tests__/dispatchResolveAll.test.ts +++ b/client/src/game/__tests__/dispatchResolveAll.test.ts @@ -50,10 +50,10 @@ describe("dispatchResolveAll progress", () => { progressCalls = []; usePreferencesStore.setState({ animationSpeedMultiplier: 1.0 }); useAppNotificationStore.setState({ notification: null, expiresAt: 0 }); - // Stack length read at each iteration start to classify pressure; keep it - // in the "Instant" band (>=100) so the rAF-yield branch is exercised. + // Keep the stack in the Instant pressure band so the ready consumer uses + // the engine's larger bounded-prefix cap. useGameStore.setState({ - gameState: stateWithStack(200), + gameState: readyStateWithStack(200), resolutionProgress: null, isResolvingAll: false, // Capture every setResolutionProgress call for assertions. @@ -68,21 +68,12 @@ describe("dispatchResolveAll progress", () => { vi.restoreAllMocks(); }); - it("latches the first chunk's total, accumulates + clamps the numerator, and clears at the end", async () => { - // Per-chunk `total` SHRINKS (engine reports remaining stack); the latch must - // keep the first chunk's 200. itemsResolved sums 80+80+80=240 > 200 → clamp. - const resolveAll = vi - .fn() - .mockResolvedValueOnce(chunk(80, 200)) - .mockResolvedValueOnce(chunk(80, 150)) - .mockResolvedValueOnce(chunk(80, 100)); + it("reports the engine-proved prefix once and clears progress at the end", async () => { + const resolveAll = vi.fn().mockResolvedValueOnce(chunk(80, 200)); - // getState reports the board after each chunk; the 3rd empties the stack → done. - const getState = vi - .fn<() => Promise>() - .mockResolvedValueOnce(stateWithStack(200)) - .mockResolvedValueOnce(stateWithStack(200)) - .mockResolvedValueOnce(stateWithStack(0)); + // The engine resolves the entire proved prefix in one bounded call, then + // supplies one authoritative post-prefix snapshot. + const getState = vi.fn<() => Promise>().mockResolvedValueOnce(stateWithStack(120)); const rafSpy = vi .spyOn(globalThis, "requestAnimationFrame") @@ -104,25 +95,18 @@ describe("dispatchResolveAll progress", () => { // to the SetAutoPass fallback instead of the batch drain under test. await dispatchResolveAll(0, [{ playerId: 1, difficulty: "Medium" }]); - // Three progress updates: total latched at 200 throughout; resolved - // accumulates 80 -> 160 -> clamped 200. - expect(progressCalls.slice(0, 3)).toEqual([ - { resolved: 80, total: 200 }, - { resolved: 160, total: 200 }, - { resolved: 200, total: 200 }, // min(240, 200) clamp - ]); + expect(resolveAll).toHaveBeenCalledTimes(1); + expect(progressCalls).toEqual([{ resolved: 80, total: 200 }, null]); // Final call clears progress. expect(progressCalls[progressCalls.length - 1]).toBeNull(); expect(useGameStore.getState().resolutionProgress).toBeNull(); expect(useGameStore.getState().isResolvingAll).toBe(false); - // rAF yield fired between the instant chunks (the load-bearing repaint fix): - // 2 yields between 3 chunks. - expect(rafSpy).toHaveBeenCalledTimes(2); + expect(rafSpy).not.toHaveBeenCalled(); }); it("uses responsive instant chunks for giant stacks and marks Resolve All busy", async () => { - useGameStore.setState({ gameState: stateWithStack(19192) }); + useGameStore.setState({ gameState: readyStateWithStack(19192) }); const resolveAll = vi.fn(async (_requester, _aiSeats, maxResolutions) => { expect(useGameStore.getState().isResolvingAll).toBe(true); @@ -202,7 +186,7 @@ describe("dispatchResolveAll progress", () => { const getState = vi.fn().mockResolvedValue(stateWithStack(0)); const submitAction = vi.fn(); useGameStore.setState({ - gameState: stateWithStack(2), + gameState: readyStateWithStack(2), adapter: { resolveAll, resolveAllUsesServerAi: true, @@ -231,7 +215,7 @@ describe("dispatchResolveAll progress", () => { ); const getState = vi.fn().mockResolvedValue(stateWithStack(2)); useGameStore.setState({ - gameState: stateWithStack(2), + gameState: readyStateWithStack(2), adapter: { resolveAll, resolveAllUsesServerAi: true, @@ -254,7 +238,7 @@ describe("dispatchResolveAll progress", () => { .mockRejectedValue(new Error("batch snapshot rejected")); const getState = vi.fn().mockResolvedValue(stateWithStack(2)); useGameStore.setState({ - gameState: stateWithStack(2), + gameState: readyStateWithStack(2), adapter: { resolveAll, resolveAllUsesServerAi: true, diff --git a/client/src/game/waitingForRegistry.ts b/client/src/game/waitingForRegistry.ts index 5b3c74094d..bbad7104f4 100644 --- a/client/src/game/waitingForRegistry.ts +++ b/client/src/game/waitingForRegistry.ts @@ -33,6 +33,8 @@ export const HANDLED_WAITING_FOR_TYPES: ReadonlySet = new Set([ // Active priority — passes via PassButton / mana payment / cast. "Priority", + // ResolveAllConsentModal presents the engine-issued Grant/Decline prompt. + "ResolveAllConsent", // CR 701.42 / CR 508.4: meld pair and attacking-entry destination dialogs. "MeldPairChoice", "MeldAttackTargetChoice", diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index df4f1d69a6..ad51f34b0f 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -49,6 +49,13 @@ "collapse": "Auflösungsfortschritt einklappen", "expand": "Auflösungsfortschritt ausklappen" }, + "resolveAllConsent": { + "eyebrow": "Alles auflösen", + "title": "Schnelles Auflösen genehmigen?", + "subtitle": "Alle Spieler müssen zustimmen, bevor die Engine einen sicheren Stapelpräfix auflöst.", + "grant": "Genehmigen", + "decline": "Priorität behalten" + }, "debugCreate": { "copies": "Kopien", "tokenPower": "Power", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index a721912f4c..da54a18715 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -49,6 +49,13 @@ "collapse": "Contraer progreso de resolución", "expand": "Expandir progreso de resolución" }, + "resolveAllConsent": { + "eyebrow": "Resolver todo", + "title": "¿Aprobar la resolución rápida?", + "subtitle": "Todos los jugadores deben aprobar antes de que el motor resuelva un prefijo seguro de la pila.", + "grant": "Aprobar", + "decline": "Conservar prioridad" + }, "debugCreate": { "copies": "Copias", "tokenPower": "Power", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 3149b0ebd3..f6af513c1c 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -49,6 +49,13 @@ "collapse": "Réduire la progression de résolution", "expand": "Développer la progression de résolution" }, + "resolveAllConsent": { + "eyebrow": "Tout résoudre", + "title": "Approuver la résolution rapide ?", + "subtitle": "Tous les joueurs doivent approuver avant que le moteur ne résolve un préfixe sûr de la pile.", + "grant": "Approuver", + "decline": "Garder la priorité" + }, "debugCreate": { "copies": "Copies", "tokenPower": "Power", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 4bda263127..58dad4225c 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -49,6 +49,13 @@ "collapse": "Comprimi avanzamento risoluzione", "expand": "Espandi avanzamento risoluzione" }, + "resolveAllConsent": { + "eyebrow": "Risolvi tutto", + "title": "Approvare la risoluzione rapida?", + "subtitle": "Tutti i giocatori devono approvare prima che il motore risolva un prefisso sicuro della pila.", + "grant": "Approva", + "decline": "Mantieni priorità" + }, "debugCreate": { "copies": "Copie", "tokenPower": "Power", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 6c22be57ad..24d96ae94c 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -49,6 +49,13 @@ "collapse": "Zwiń postęp rozwiązywania", "expand": "Rozwiń postęp rozwiązywania" }, + "resolveAllConsent": { + "eyebrow": "Rozpatrz wszystko", + "title": "Zatwierdzić szybkie rozpatrywanie?", + "subtitle": "Wszyscy gracze muszą zatwierdzić, zanim silnik rozpatrzy bezpieczny prefiks stosu.", + "grant": "Zatwierdź", + "decline": "Zachowaj priorytet" + }, "debugCreate": { "copies": "Kopie", "tokenPower": "Power", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 0d3a4afdaf..9d8ad0022e 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -49,6 +49,13 @@ "collapse": "Recolher progresso de resolução", "expand": "Expandir progresso de resolução" }, + "resolveAllConsent": { + "eyebrow": "Resolver tudo", + "title": "Aprovar a resolução rápida?", + "subtitle": "Todos os jogadores precisam aprovar antes que o mecanismo resolva um prefixo seguro da pilha.", + "grant": "Aprovar", + "decline": "Manter prioridade" + }, "debugCreate": { "copies": "Cópias", "tokenPower": "Power", diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index bf85063a23..731f63bb67 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -1846,6 +1846,8 @@ impl GameRunner { pub fn waiting_for_kind(&self) -> &'static str { match &self.state.waiting_for { WaitingFor::Priority { .. } => "Priority", + WaitingFor::ResolveAllConsent { .. } => "ResolveAllConsent", + WaitingFor::ResolveAllReady { .. } => "ResolveAllReady", WaitingFor::MeldPairChoice { .. } => "MeldPairChoice", WaitingFor::MeldAttackTargetChoice { .. } => "MeldAttackTargetChoice", WaitingFor::EntryAttackTargetChoice { .. } => "EntryAttackTargetChoice", diff --git a/crates/phase-ai/src/decision_kind.rs b/crates/phase-ai/src/decision_kind.rs index dfdf41dbba..a7b4b4c208 100644 --- a/crates/phase-ai/src/decision_kind.rs +++ b/crates/phase-ai/src/decision_kind.rs @@ -73,7 +73,9 @@ pub fn classify(waiting_for: &WaitingFor, action: &GameAction) -> DecisionKind { // All other WaitingFor states are mechanical/forced choices that no // tactical policy currently routes on. Map them to ActivateAbility as // the catch-all bucket so policies that explicitly opt in still run. - WaitingFor::ReplacementChoice { .. } + WaitingFor::ResolveAllConsent { .. } + | WaitingFor::ResolveAllReady { .. } + | WaitingFor::ReplacementChoice { .. } | WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } | WaitingFor::EntryAttackTargetChoice { .. } diff --git a/crates/phase-ai/src/policies/discard_payoff.rs b/crates/phase-ai/src/policies/discard_payoff.rs index c213f9199b..8e51205135 100644 --- a/crates/phase-ai/src/policies/discard_payoff.rs +++ b/crates/phase-ai/src/policies/discard_payoff.rs @@ -180,6 +180,9 @@ fn candidate_discards_controller(ctx: &PolicyContext<'_>) -> bool { // this match at compile time and forces an intentional classification // instead of silently bypassing the discard payoff (CR 701.9). GameAction::PassPriority + | GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } | GameAction::ChooseMeldPair { .. } | GameAction::ChooseEntryAttackTarget { .. } | GameAction::PlayLand { .. } diff --git a/crates/phase-ai/src/policies/draw_payoff.rs b/crates/phase-ai/src/policies/draw_payoff.rs index a769b7b4b7..29fe95603d 100644 --- a/crates/phase-ai/src/policies/draw_payoff.rs +++ b/crates/phase-ai/src/policies/draw_payoff.rs @@ -207,6 +207,9 @@ fn candidate_draws_structurally(ctx: &PolicyContext<'_>) -> bool { // this match at compile time and forces an intentional classification // instead of silently bypassing the draw payoff (CR 121.1). GameAction::PassPriority + | GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } | GameAction::ChooseMeldPair { .. } | GameAction::ChooseEntryAttackTarget { .. } | GameAction::PlayLand { .. } diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index c7d08fb1ab..d5395c014f 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -1232,6 +1232,22 @@ pub fn fallback_action( // Terminal — no action possible. WaitingFor::GameOver { .. } => None, + // Resolve All is opt-in. If no policy selected one of the engine-issued + // consent actions, decline the shortcut rather than leave its + // representative's decision unanswered. + WaitingFor::ResolveAllConsent { .. } => issued(|action| { + matches!( + action, + GameAction::RespondResolveAllConsent { + decision: engine::types::actions::ResolveAllConsentDecision::Decline, + .. + } + ) + }), + // Ready has no acting player; the authorized frontend consumer starts + // the bounded prefix drain. + WaitingFor::ResolveAllReady { .. } => None, + // Priority is the only state where PassPriority is valid. WaitingFor::Priority { .. } => Some(GameAction::PassPriority), From accaba940c2957df4500c502f25180f32e77a84e Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 12:03:20 -0700 Subject: [PATCH 11/19] fix(resolve-all): reconcile downstream consumers --- crates/engine-wasm/src/lib.rs | 147 ++---------------- .../engine/src/game/engine_resolve_batch.rs | 7 +- .../tests/integration/resolve_all_consent.rs | 2 +- crates/manabrew-compat/src/lib.rs | 7 + .../src/game_action_payload_guard.rs | 3 + crates/server-core/src/session.rs | 3 +- 6 files changed, 26 insertions(+), 143 deletions(-) diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 4a70f004a0..ef890d1971 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -13,10 +13,8 @@ use engine::ai_support::{ }; use engine::database::legality::{any_ai_difficulty_is_cedh, validate_cedh_bracket}; use engine::database::{CardDatabase, CardSearchQuery}; -use engine::game::engine::{ - apply, apply_for_simulation, resolve_all_fast_forward, resolve_all_ready_prefix, - ResolveAllCallbackDecision, ResolveAllFastForwardResult as BatchResolveResult, -}; +use engine::game::engine::{apply, apply_for_simulation}; +use engine::game::engine_resolve_batch::resolve_all_ready_prefix; use engine::game::interaction::{bind_interaction_authority, submit_interaction}; use engine::game::preview::{compute_preview_diff, preview_auto_payment_sources}; use engine::game::{ @@ -2175,8 +2173,8 @@ pub fn restore_game_state(json_str: &str) -> Result<(), JsValue> { /// The natively-callable body of [`restore_game_state`]. /// -/// Split for the same reason — and in the same shape — as `resolve_all_inner` -/// and `scored_candidates_inner`: the `#[wasm_bindgen]` shell may only run on +/// Split for the same reason — and in the same shape — as `scored_candidates_inner`: +/// the `#[wasm_bindgen]` shell may only run on /// wasm32. Off-target, `JsValue::from_str` panics inside a function that cannot /// unwind, so a shell that merely RETURNS an error aborts the whole process with /// SIGABRT instead of failing the test. A native test that calls the shell is @@ -2801,7 +2799,7 @@ pub fn get_ai_tactical_action_proposal_with_diagnostics( /// Split out of [`get_ai_scored_candidates`] so native tests can drive the real /// scoring path: the `#[wasm_bindgen]` shell returns through `to_js`, which calls /// the real `JSON.parse` binding and panics outside a wasm32 runtime (same reason -/// `resolve_all_inner` exists). +/// `scored_candidates_inner` exists). fn scored_candidates_inner( state: &mut GameState, difficulty: AiDifficulty, @@ -3035,6 +3033,10 @@ pub fn submit_ai_action_proposal(token: &str, actor: u8, action: JsValue) -> JsV /// - AI declines to pass priority /// - Game ends /// - Safety cap reached (prevents infinite loops from cascading triggers) +#[expect( + dead_code, + reason = "the legacy Resolve All wire payload remains validated for compatibility, but unanimous engine consent now owns seat decisions" +)] #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct AiSeatConfig { @@ -3042,49 +3044,6 @@ struct AiSeatConfig { difficulty: String, } -fn resolve_all_inner( - state: &mut GameState, - requester: PlayerId, - ai_seats: &[AiSeatConfig], - max_resolutions: u32, - rng: &mut impl Rng, -) -> BatchResolveResult { - // The first AI decision in the fast-forward loop can run before any - // `apply()` (which would flush internally); flush up front so it sees - // precise derived state + presence index. No-op when layers are clean. - engine::game::layers::flush_layers(state); - let session = ai_session_for(state); - resolve_all_fast_forward(state, requester, max_resolutions, |state, actor| { - if let Some(seat) = ai_seats - .iter() - .find(|seat| PlayerId(seat.player_id) == actor) - { - let ai_difficulty = AiDifficulty::from_label(&seat.difficulty); - let config = - create_config_for_players(ai_difficulty, Platform::Wasm, state.players.len() as u8); - // Seeding asks whether a later seat will pass before the live - // `WaitingFor` advances to that seat. Give the AI the exact - // future priority prompt on a clone; its contract otherwise has - // an empty candidate domain and it cannot select PassPriority. - let mut decision_state = state.clone(); - decision_state.waiting_for = WaitingFor::Priority { player: actor }; - decision_state.priority_player = actor; - match choose_action_with_session(&decision_state, actor, &config, rng, &session) { - // `seed_remaining_priority_cycle_passes` asks about future - // priority seats before `WaitingFor` advances to them. A - // priority pass is the sole raw action the batch accepts, so - // it remains valid without fabricating a future contract. - Some(GameAction::PassPriority) => { - ResolveAllCallbackDecision::Action(GameAction::PassPriority) - } - Some(_) | None => ResolveAllCallbackDecision::Stop, - } - } else { - ResolveAllCallbackDecision::Stop - } - }) -} - #[wasm_bindgen] pub fn resolve_all( requester: u8, @@ -3267,94 +3226,6 @@ mod bracket_estimate_tests { } } -#[cfg(test)] -mod resolve_all_tests { - use super::*; - use engine::types::ability::{Effect, ResolvedAbility}; - use engine::types::game_state::{StackEntry, StackEntryKind, WaitingFor}; - use engine::types::identifiers::ObjectId; - - fn no_op_entry(id: u64, controller: PlayerId) -> StackEntry { - let object_id = ObjectId(id); - StackEntry { - id: object_id, - source_id: object_id, - controller, - kind: StackEntryKind::ActivatedAbility { - source_id: object_id, - ability: Box::new(ResolvedAbility::new( - Effect::NoOp, - vec![], - object_id, - controller, - )), - }, - } - } - - fn priority_state(semantic_seat: PlayerId, stack: Vec) -> GameState { - let mut state = GameState::new_two_player(7); - state.waiting_for = WaitingFor::Priority { - player: semantic_seat, - }; - state.priority_player = semantic_seat; - state.stack = stack.into_iter().collect(); - state - } - - #[test] - fn resolve_all_tls_production_path_substitute_routes_controlled_priority() { - let mut state = priority_state(PlayerId(1), vec![no_op_entry(1, PlayerId(1))]); - state.active_player = PlayerId(1); - state.turn_decision_controller = Some(PlayerId(0)); - state.priority_player = PlayerId(0); - state.priority_passes.insert(PlayerId(0)); - GAME_STATE.with(|cell| cell.set(Some(state))); - - let ai_seats: Vec = serde_json::from_str("[]").unwrap(); - let result = with_state_mut(|state| { - let mut rng = ChaCha20Rng::seed_from_u64(13); - resolve_all_inner(state, PlayerId(0), &ai_seats, 0, &mut rng) - }) - .unwrap(); - - assert_eq!(result.items_resolved, 1); - with_state(|state| assert!(state.stack.is_empty())).unwrap(); - clear_game_state(); - } - - #[test] - fn resolve_all_tls_production_path_seeds_ai_priority_passes() { - let mut state = GameState::new(FormatConfig::free_for_all(), 3, 7); - state.waiting_for = WaitingFor::Priority { - player: PlayerId(0), - }; - state.priority_player = PlayerId(0); - state.stack.push_back(no_op_entry(1, PlayerId(2))); - GAME_STATE.with(|cell| cell.set(Some(state))); - - let ai_seats = vec![ - AiSeatConfig { - player_id: 1, - difficulty: "Medium".to_string(), - }, - AiSeatConfig { - player_id: 2, - difficulty: "Medium".to_string(), - }, - ]; - let result = with_state_mut(|state| { - let mut rng = ChaCha20Rng::seed_from_u64(13); - resolve_all_inner(state, PlayerId(0), &ai_seats, 0, &mut rng) - }) - .unwrap(); - - assert_eq!(result.items_resolved, 1); - with_state(|state| assert!(state.stack.is_empty())).unwrap(); - clear_game_state(); - } -} - #[cfg(all(test, target_arch = "wasm32"))] mod tests { use super::*; diff --git a/crates/engine/src/game/engine_resolve_batch.rs b/crates/engine/src/game/engine_resolve_batch.rs index 2991fa2bb5..a41934ab55 100644 --- a/crates/engine/src/game/engine_resolve_batch.rs +++ b/crates/engine/src/game/engine_resolve_batch.rs @@ -832,14 +832,15 @@ mod tests { GameAction::BeginResolveAll { max_resolutions: 0 }, ) .expect("priority holder begins the consent run"); - let WaitingFor::ResolveAllConsent { epoch, .. } = &state.waiting_for else { - panic!("second representative should be queued"); + let epoch = match &state.waiting_for { + WaitingFor::ResolveAllConsent { epoch, .. } => *epoch, + _ => panic!("second representative should be queued"), }; super::super::engine::apply( &mut state, PlayerId(1), GameAction::RespondResolveAllConsent { - epoch: *epoch, + epoch, decision: ResolveAllConsentDecision::Grant, }, ) diff --git a/crates/engine/tests/integration/resolve_all_consent.rs b/crates/engine/tests/integration/resolve_all_consent.rs index 1a766ae82b..dbec57831c 100644 --- a/crates/engine/tests/integration/resolve_all_consent.rs +++ b/crates/engine/tests/integration/resolve_all_consent.rs @@ -1,6 +1,6 @@ //! Phase-1 protocol coverage for explicit Resolve All consent. -use engine::ai_support::{candidates::candidate_actions, legal_actions_for_viewer}; +use engine::ai_support::{candidate_actions, legal_actions_for_viewer}; use engine::game::engine::apply; use engine::game::interaction::{ bind_interaction_authority, derive_viewer_interaction, resolve_interaction_response, diff --git a/crates/manabrew-compat/src/lib.rs b/crates/manabrew-compat/src/lib.rs index 7f5ac20826..07c0d1b30f 100644 --- a/crates/manabrew-compat/src/lib.rs +++ b/crates/manabrew-compat/src/lib.rs @@ -2577,6 +2577,13 @@ pub fn convert_available_action( | GameAction::CancelCast | GameAction::BackToManaPayment | GameAction::Concede { .. } => AvailableActionConversion::Skip, + // The upstream protocol has no consent-shortcut action family. Do not + // advertise an action it cannot round-trip; surface the fidelity gap. + GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } => { + AvailableActionConversion::Unsupported("local.resolve-all-unsupported") + } GameAction::DeclareAttackers { .. } => AvailableActionConversion::Skip, GameAction::DeclareBlockers { .. } => AvailableActionConversion::Skip, GameAction::ChooseUntap { .. } => { diff --git a/crates/server-core/src/game_action_payload_guard.rs b/crates/server-core/src/game_action_payload_guard.rs index f3e4e72aed..22b1eb17d1 100644 --- a/crates/server-core/src/game_action_payload_guard.rs +++ b/crates/server-core/src/game_action_payload_guard.rs @@ -647,6 +647,9 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { guard_debug_action_payload(debug_action)?; } GameAction::PassPriority + | GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } | GameAction::PlayLand { .. } | GameAction::Foretell { .. } | GameAction::ActivateAbility { .. } diff --git a/crates/server-core/src/session.rs b/crates/server-core/src/session.rs index bdfabe9250..fa0b37a19e 100644 --- a/crates/server-core/src/session.rs +++ b/crates/server-core/src/session.rs @@ -6,7 +6,8 @@ use engine::ai_support::{auto_pass_recommended, legal_actions_full as engine_leg use engine::database::legality::{validate_cedh_bracket, CedhBracketError}; use engine::database::CardDatabase; use engine::game::deck_loading::{DeckPayload, PlayerDeckPayload}; -use engine::game::engine::{apply, resolve_all_ready_prefix, start_game}; +use engine::game::engine::{apply, start_game}; +use engine::game::engine_resolve_batch::resolve_all_ready_prefix; use engine::game::interaction::{bind_interaction_authority, submit_interaction}; use engine::game::layers::flush_layers; use engine::game::match_flow::apply_trusted_match_forfeit; From 4a9f5c0c9c10d88a24d31fc5ec5a0e605fa82943 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 12:24:00 -0700 Subject: [PATCH 12/19] fix(resolve-all): deduplicate consent candidates --- crates/engine/src/ai_support/candidates.rs | 11 ++++++++++- crates/engine/src/game/engine.rs | 3 ++- crates/engine/src/game/engine_resolve_batch.rs | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 0809076934..590065afed 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -3574,7 +3574,16 @@ fn semantic_candidate_actions_with_probe( probe: Option<&casting::PriorityCastProbe>, ) -> Vec { let mut actions = candidate_actions_exact(state); - actions.extend(candidate_actions_broad_with_probe(state, probe)); + // Resolve All consent is wholly represented by its finite exact domain. + // The broad enumerator delegates these same states to `candidate_actions_exact` + // for broad-only callers, so composing both here would expose every + // Grant, Decline, and Revoke choice twice. + if !matches!( + &state.waiting_for, + WaitingFor::ResolveAllConsent { .. } | WaitingFor::ResolveAllReady { .. } + ) { + actions.extend(candidate_actions_broad_with_probe(state, probe)); + } let has_pending_cast = state.waiting_for.has_pending_cast() || (matches!(state.waiting_for, WaitingFor::DistributeAmong { .. }) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 9e062efd30..1cdd0a55fe 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -15199,6 +15199,7 @@ mod priority_reducer_census_tests { "ActivateManaSource", "ActivateNinjutsu", "ActivateStation", + "BeginResolveAll", "CastPreparedCopy", "CastSpell", "CastSpellAsSneak", @@ -19747,7 +19748,7 @@ mod stage2_injector_tests { // `begin_pending_trigger_target_selection` is STILL 134 — the function opens // `:12722 ⇒ :12778`, moving by the same `+56` as the pin, so the control // that caught this row's one historical silent drift is intact. - "game/engine.rs:12912".to_string(), + "game/engine.rs:13086".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/game/engine_resolve_batch.rs b/crates/engine/src/game/engine_resolve_batch.rs index a41934ab55..26405ee9f1 100644 --- a/crates/engine/src/game/engine_resolve_batch.rs +++ b/crates/engine/src/game/engine_resolve_batch.rs @@ -861,6 +861,20 @@ mod tests { no_op_entry(2, PlayerId(0)), no_op_entry(3, PlayerId(0)), ]); + let run = ready_consent_run(&state, PlayerId(0)) + .expect("the initiating representative remains authorized at Ready") + .clone(); + let mut proof = state.clone(); + proof.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + let (boundary, _) = materialize_one_consented_resolution(&mut proof, &run) + .expect("a full consent run materializes one ordinary priority cycle"); + assert_eq!(stack_resolved_count(&boundary.events), 1); + assert_eq!(proof.stack.len(), 2); + assert!(matches!(proof.waiting_for, WaitingFor::Priority { .. })); + assert!(stack::priority_checkpoint_is_settled(&proof)); + assert!(consent_authorization_matches(&proof, &run)); let result = resolve_all_ready_prefix(&mut state, PlayerId(0)); From fecafc51f26530795c5217f9db59078bfe774f6e Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 12:25:26 -0700 Subject: [PATCH 13/19] test(engine): exclude resolve-all from preflight census --- crates/engine/src/game/engine.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 1cdd0a55fe..2e090a4083 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -15233,7 +15233,12 @@ mod priority_reducer_census_tests { .collect::>(); let expected_preflight_families = expected .iter() - .filter(|family| *family != "PassPriority" && *family != "SetAutoPass") + .filter(|family| { + !matches!( + family.as_str(), + "BeginResolveAll" | "PassPriority" | "SetAutoPass" + ) + }) .map(|family| (*family).to_owned()) .collect::>(); assert_eq!(preflight_families, expected_preflight_families); From a707079ab9a157b0f63f8b2c6cd22c639fbd8b93 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 12:50:05 -0700 Subject: [PATCH 14/19] fix(resolve-all): preserve priority through consent --- crates/engine/src/game/public_state.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/engine/src/game/public_state.rs b/crates/engine/src/game/public_state.rs index 27cc59babc..21ea7fee72 100644 --- a/crates/engine/src/game/public_state.rs +++ b/crates/engine/src/game/public_state.rs @@ -158,6 +158,15 @@ fn normalize_legacy_attach_waiting_for(state: &mut GameState) { } fn sync_priority_player_from_waiting_for(state: &mut GameState) { + // Resolve All temporarily presents consent slots without transferring the + // underlying priority window. Its run snapshot owns that priority until a + // decline restores it or the Ready consumer materializes its passes. + if matches!( + &state.waiting_for, + WaitingFor::ResolveAllConsent { .. } | WaitingFor::ResolveAllReady { .. } + ) { + return; + } if let Some(player) = state.waiting_for.acting_player() { state.priority_player = turn_control::authorized_submitter_for_player(state, player); } From 77f7fd0acda3c17a96d6cd3e4dc402405e7de53b Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 13:08:18 -0700 Subject: [PATCH 15/19] test(engine): count resolve-all waiting states --- crates/engine/src/types/game_state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index a480666ac4..84cf0b5d64 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -29529,7 +29529,7 @@ mod tests { mana_reduction: ManaCost::zero(), pending_cast: dummy_pending(), })); - assert_eq!(variants.len(), 37); + assert_eq!(variants.len(), 39); } #[test] From e7f5e33fc1f54cc9caca5c7a1932300b1f48c4a8 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 13:27:55 -0700 Subject: [PATCH 16/19] test(resolve-all): register protocol surface --- crates/engine/tests/integration/loop_shortcut.rs | 6 ++++-- crates/manabrew-compat/src/lib.rs | 13 ++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index f54f335817..3c12b6f675 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -4818,8 +4818,8 @@ fn exactly_two_waiting_for_variants_carry_a_decision_template_and_both_are_redac // ── the classifier's own reach-guard: the enum was actually found ── let total = enum_variants(&enum_src, "WaitingFor").len(); assert_eq!( - total, 130, - "`WaitingFor` has 130 variants at this tip, read off the `syn` parse. This number is \ + total, 132, + "`WaitingFor` has 132 variants at this tip, read off the `syn` parse. This number is \ pinned so a variant REMOVED is as visible as one added; if you added a variant and it \ carries no `DecisionTemplate`, update this number. A wildly different count means the \ reader lost its anchor, and every assertion below would then be measuring an empty enum" @@ -4839,6 +4839,8 @@ fn exactly_two_waiting_for_variants_carry_a_decision_template_and_both_are_redac // contradicting itself, and a second instrument that disagrees with the `syn` parse is worth // less than no second instrument. The reach-guard did its whole job here: this drift produced // no merge conflict and could not have, so CI was the only thing between it and shipping. + // 130 ⇒ 132 is ADJUDICATED: ResolveAllConsent and ResolveAllReady are control-protocol states + // with no DecisionTemplate payload, so neither expands the carrier set nor the redaction duty. let carriers = carriers_in_source(&enum_src, "WaitingFor", &corpus, &marker, true); assert_eq!( diff --git a/crates/manabrew-compat/src/lib.rs b/crates/manabrew-compat/src/lib.rs index 07c0d1b30f..c74f99c42d 100644 --- a/crates/manabrew-compat/src/lib.rs +++ b/crates/manabrew-compat/src/lib.rs @@ -674,7 +674,7 @@ pub fn unsupported_protocol_capabilities() -> &'static [UnsupportedCapability] { /// `upstream.` = the protocol has no primitive for something the engine can do. /// `local.` = the protocol has the primitive but this engine cannot source it, /// or a documented adapter-local extension is intentionally in use. -static UNSUPPORTED_PROTOCOL_CAPABILITIES: [UnsupportedCapability; 88] = [ +static UNSUPPORTED_PROTOCOL_CAPABILITIES: [UnsupportedCapability; 89] = [ UnsupportedCapability { code: "upstream.object-selection-missing", area: "prompts", @@ -765,6 +765,12 @@ static UNSUPPORTED_PROTOCOL_CAPABILITIES: [UnsupportedCapability; 88] = [ reason: "v2 added ChooseActionOutput::Pass.exhaustStack (pass until the stack empties). Like pass.until it is a multi-window intent, and Phase's PassPriority yields exactly one priority window.", suggested_protocol_extension: "Clarify whether exhaustStack is advisory or requires an engine-backed auto-pass contract, alongside pass.until.", }, + UnsupportedCapability { + code: "local.resolve-all-unsupported", + area: "responses", + reason: "Phase's Resolve All consent protocol has no upstream action family and cannot be faithfully round-tripped as ordinary priority passing.", + suggested_protocol_extension: "Add an explicit consent-backed stack-resolution shortcut protocol, including grant, decline, and revocation semantics.", + }, UnsupportedCapability { code: "local.meld-pair-choice-unsupported", area: "prompts", @@ -8164,13 +8170,13 @@ mod tests { #[test] fn unsupported_capability_registry_is_well_formed() { let capabilities = unsupported_protocol_capabilities(); - assert_eq!(capabilities.len(), 88); + assert_eq!(capabilities.len(), 89); let codes: HashSet<_> = capabilities .iter() .map(|capability| capability.code) .collect(); - assert_eq!(codes.len(), 88, "capability codes must be unique"); + assert_eq!(codes.len(), 89, "capability codes must be unique"); for capability in capabilities { assert!( @@ -8341,6 +8347,7 @@ mod tests { "local.harmonize-tap-unsupported", "local.payment-resource-actions-missing", "local.exhaust-stack-pass-unsupported", + "local.resolve-all-unsupported", // Every code the adapter can emit must be declared here, or a // client that receives it looks it up and finds nothing. "local.dungeon-room-unsupported", From 79f5eab2aa2c4e11fe2743bb6d1c6e52c82719e9 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 13:47:38 -0700 Subject: [PATCH 17/19] test(resolve-all): cover consent-ready transport --- .../fixtures/cr733/authority_matrix.json.gz | Bin 41520 -> 42027 bytes crates/phase-server/src/main.rs | 29 ++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index 8fd48cb658a9709d02cde92ccba1ac1f16f92189..bf1aae1c260a399471896bbe1be6a33bc0908de3 100644 GIT binary patch literal 42027 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 literal 41520 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 z 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 diff --git a/crates/phase-server/src/main.rs b/crates/phase-server/src/main.rs index 9505420668..b2060e3921 100644 --- a/crates/phase-server/src/main.rs +++ b/crates/phase-server/src/main.rs @@ -7743,9 +7743,9 @@ async fn handle_client_message( #[cfg(test)] mod state_transport_derived_tests { use super::*; - use engine::game::deck_loading::PlayerDeckPayload; + use engine::game::{deck_loading::PlayerDeckPayload, engine::apply}; use engine::types::ability::{Effect, ResolvedAbility, SearchSelectionConstraint}; - use engine::types::actions::GameAction; + use engine::types::actions::{GameAction, ResolveAllConsentDecision}; use engine::types::game_state::{ ActiveSearchDecisionAuthority, ActiveSearchDecisionControl, PriorityPassingMode, StackEntry, StackEntryKind, WaitingFor, @@ -7927,6 +7927,31 @@ mod state_transport_derived_tests { )), }, }); + apply( + &mut session.state, + PlayerId(0), + GameAction::BeginResolveAll { max_resolutions: 1 }, + ) + .expect("priority holder may start Resolve All consent"); + let epoch = match session.state.waiting_for { + WaitingFor::ResolveAllConsent { epoch, .. } => epoch, + ref other => { + panic!("Resolve All consent must await the AI representative, got {other:?}") + } + }; + apply( + &mut session.state, + ai_player, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("AI representative may grant Resolve All consent"); + assert!(matches!( + session.state.waiting_for, + WaitingFor::ResolveAllReady { epoch: ready_epoch } if ready_epoch == epoch + )); let revision_before = session.state_revision; let state: SharedState = Arc::new(Mutex::new(manager)); From f88e0cb994a5c77993f9581ee7c7bd69c5f3efbb Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 14:25:39 -0700 Subject: [PATCH 18/19] fix(resolve-all): preserve consent priority order --- .../engine/src/game/engine_resolve_batch.rs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/engine_resolve_batch.rs b/crates/engine/src/game/engine_resolve_batch.rs index 26405ee9f1..3e287f0a20 100644 --- a/crates/engine/src/game/engine_resolve_batch.rs +++ b/crates/engine/src/game/engine_resolve_batch.rs @@ -164,7 +164,16 @@ fn ready_consent_run(state: &GameState, requester: PlayerId) -> Option<&ResolveA } fn consent_authorization_matches(state: &GameState, run: &ResolveAllConsentRun) -> bool { - let representatives = topology::priority_pass_participants(state); + let mut representatives = topology::priority_pass_participants(state); + let current = + topology::priority_pass_representative(state, run.priority_snapshot.waiting_player); + let Some(current_index) = representatives + .iter() + .position(|representative| *representative == current) + else { + return false; + }; + representatives.rotate_left(current_index); representatives.len() == run.participants.len() && representatives .iter() @@ -825,7 +834,15 @@ mod tests { } fn ready_state(stack: Vec) -> GameState { + ready_state_with_active_player(PlayerId(0), stack) + } + + fn ready_state_with_active_player( + active_player: PlayerId, + stack: Vec, + ) -> GameState { let mut state = priority_state(PlayerId(0), stack); + state.active_player = active_player; super::super::engine::apply( &mut state, PlayerId(0), @@ -852,6 +869,17 @@ mod tests { state } + #[test] + fn ready_consent_uses_the_priority_holder_first_when_active_player_has_passed() { + let mut state = + ready_state_with_active_player(PlayerId(1), vec![no_op_entry(1, PlayerId(0))]); + + let result = resolve_all_ready_prefix(&mut state, PlayerId(0)); + + assert_eq!(result.items_resolved, 1); + assert!(state.stack.is_empty()); + } + #[test] fn ready_consent_commits_the_greatest_settled_prefix_and_records_passes() { // The lower self-copy creates a new stack object. It is deliberately From 5fe9e8c7a6b1de0371bfbdd49d64ff597ef2c8e2 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 14:53:07 -0700 Subject: [PATCH 19/19] fix(resolve-all): harden consent handoff --- .../modal/ResolveAllConsentModal.tsx | 7 ++- .../game/__tests__/dispatchResolveAll.test.ts | 46 +++++++++++++++++++ .../__tests__/aiController.test.ts | 29 ++++++++++++ client/src/pages/GamePage.tsx | 2 +- crates/engine-wasm/src/lib.rs | 25 ++++++++++ crates/engine/src/ai_support/candidates.rs | 11 +++-- crates/engine/src/game/engine.rs | 32 ++++++++----- .../engine/src/game/engine_resolve_batch.rs | 45 +++++++++++++++++- .../engine/src/types/action_stable_order.rs | 28 ++++++++++- .../tests/integration/resolve_all_consent.rs | 43 +++++++++++++++++ crates/server-core/src/session.rs | 24 ++++++---- 11 files changed, 262 insertions(+), 30 deletions(-) diff --git a/client/src/components/modal/ResolveAllConsentModal.tsx b/client/src/components/modal/ResolveAllConsentModal.tsx index e287e7bb80..bab7be0cc7 100644 --- a/client/src/components/modal/ResolveAllConsentModal.tsx +++ b/client/src/components/modal/ResolveAllConsentModal.tsx @@ -23,13 +23,16 @@ export function ResolveAllConsentModal({ playerId }: { playerId: number }) { const respond = useCallback( async (decision: "Grant" | "Decline") => { if (waitingFor?.type !== "ResolveAllConsent") return; + const grantedEpoch = waitingFor.data.epoch; await dispatch({ type: "RespondResolveAllConsent", - data: { epoch: waitingFor.data.epoch, decision: { type: decision } }, + data: { epoch: grantedEpoch, decision: { type: decision } }, }); + const waitingForAfterSubmission = useGameStore.getState().gameState?.waiting_for; if ( decision === "Grant" && - useGameStore.getState().gameState?.waiting_for?.type === "ResolveAllReady" + waitingForAfterSubmission?.type === "ResolveAllReady" && + waitingForAfterSubmission.data.epoch === grantedEpoch ) { await dispatchResolveAll(playerId, []); } diff --git a/client/src/game/__tests__/dispatchResolveAll.test.ts b/client/src/game/__tests__/dispatchResolveAll.test.ts index 7f71fc8748..4e48a6a67a 100644 --- a/client/src/game/__tests__/dispatchResolveAll.test.ts +++ b/client/src/game/__tests__/dispatchResolveAll.test.ts @@ -181,6 +181,52 @@ describe("dispatchResolveAll progress", () => { expect(submitAction).not.toHaveBeenCalled(); }); + it("begins consent before the batch drain and retains its AI seats until Ready", async () => { + const seats = [{ playerId: 1, difficulty: "Medium" }]; + const phaseOneResolveAll = vi.fn(); + const submitAction = vi.fn().mockResolvedValue({ events: [] }); + const consent = buildGameState({ + waiting_for: { type: "ResolveAllConsent", data: { epoch: 1, representative: 1 } }, + stack: Array.from({ length: 2 }, (_, index) => buildStackEntry({ id: index + 1 })), + }); + useGameStore.setState({ + gameState: stateWithStack(2), + adapter: { + resolveAll: phaseOneResolveAll, + submitAction, + getSnapshot: vi.fn(async () => ({ + state: consent, + legalResult: { actions: [], autoPassRecommended: false }, + seq: nextSnapshotSeq(), + })), + } as never, + }); + + await dispatchResolveAll(0, seats); + + expect(submitAction).toHaveBeenCalledWith( + { type: "BeginResolveAll", data: { max_resolutions: 5 } }, + 0, + ); + expect(phaseOneResolveAll).not.toHaveBeenCalled(); + + const resolveAll = vi.fn().mockResolvedValue(chunk(1, 2)); + const getState = vi.fn().mockResolvedValue(stateWithStack(1)); + useGameStore.setState({ + gameState: readyStateWithStack(2), + adapter: { + resolveAll, + getState, + getLegalActions: vi.fn().mockResolvedValue({ actions: [], autoPassRecommended: false }), + getSnapshot: snapshotVia(getState), + } as never, + }); + + await dispatchResolveAll(0, []); + + expect(resolveAll).toHaveBeenCalledWith(0, seats, 5); + }); + it("uses an empty AI-seat list when the adapter delegates native AI ownership to its server", async () => { const resolveAll = vi.fn().mockResolvedValue(chunk(0, 2)); const getState = vi.fn().mockResolvedValue(stateWithStack(0)); diff --git a/client/src/game/controllers/__tests__/aiController.test.ts b/client/src/game/controllers/__tests__/aiController.test.ts index 1d9ac26138..498bed00e6 100644 --- a/client/src/game/controllers/__tests__/aiController.test.ts +++ b/client/src/game/controllers/__tests__/aiController.test.ts @@ -296,6 +296,35 @@ describe("AI proposal controller", () => { controller.dispose(); }); + it("does not start Resolve All after an AI representative declines consent", async () => { + const consent = { + type: "ResolveAllConsent", + data: { epoch: 7, representative: 1 }, + } as WaitingFor; + const state = buildGameState({ waiting_for: consent, priority_player: 0, stack: [] }); + const issued: AiActionProposal = { + token: "engine-bound-consent-decline", + semanticOwner: 1, + actor: 0, + action: { + type: "RespondResolveAllConsent", + data: { epoch: 7, decision: { type: "Decline" } }, + }, + }; + storeState.gameState = state; + storeState.waitingFor = consent; + storeState.adapter = { getAiActionProposal: vi.fn(async () => issued) }; + dispatchAiActionProposal.mockResolvedValue({ status: "applied" }); + + const controller = createAIController({ seats: [{ playerId: 1, difficulty: "Medium" }] }); + controller.start(); + await runOnce(); + + expect(dispatchAiActionProposal).toHaveBeenCalledWith(issued); + expect(dispatchResolveAll).not.toHaveBeenCalled(); + controller.dispose(); + }); + it("does not act when the local human is the Resolve All consent representative", async () => { const consent = { type: "ResolveAllConsent", diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index 33d5266d1f..106a29d61c 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -1869,7 +1869,7 @@ function GamePageContent({ canActForWaitingState && } {waitingFor?.type === "ReplacementChoice" && canActForWaitingState && } - + {canActForWaitingState && } {waitingFor?.type === "OrderTriggers" && canActForWaitingState && } diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index ef890d1971..f4a3930079 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -4496,6 +4496,31 @@ mod tests { state.priority_player = PlayerId(0); state.priority_passes.insert(PlayerId(0)); state.stack.push_back(no_op_stack_entry(1, PlayerId(1))); + apply( + &mut state, + PlayerId(0), + GameAction::BeginResolveAll { max_resolutions: 0 }, + ) + .expect("the controlled priority holder begins Resolve All consent"); + let epoch = match state.waiting_for { + WaitingFor::ResolveAllConsent { epoch, .. } => epoch, + ref other => { + panic!("Resolve All must prompt the remaining representative, got {other:?}") + } + }; + apply( + &mut state, + PlayerId(0), + GameAction::RespondResolveAllConsent { + epoch, + decision: engine::types::actions::ResolveAllConsentDecision::Grant, + }, + ) + .expect("the controlled representative grants Resolve All consent"); + assert!(matches!( + state.waiting_for, + WaitingFor::ResolveAllReady { epoch: ready_epoch } if ready_epoch == epoch + )); GAME_STATE.with(|cell| cell.set(Some(state))); let value = resolve_all(0, "[]", 0).unwrap(); diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 590065afed..890f6d9272 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -3614,11 +3614,12 @@ fn authorize_candidate_actors(state: &GameState, actions: &mut [CandidateAction] WaitingFor::ResolveAllConsent { epoch: active_epoch, representative, - } if *epoch == *active_epoch => state - .resolve_all_consent_run - .as_ref() - .filter(|run| run.epoch == *active_epoch) - .and_then(|run| run.authorized_submitter_for(*representative)), + } if *epoch == *active_epoch => { + Some(crate::game::turn_control::authorized_submitter_for_player( + state, + *representative, + )) + } _ => None, }, GameAction::RevokeResolveAllConsent { diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 2e090a4083..a904a3329a 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -7653,6 +7653,8 @@ fn resolve_all_consent_waiting_for(state: &GameState) -> Option { ) } +// CR 117.3d + CR 117.4: A declined shortcut resumes the exact ordinary +// priority-pass sequence it interrupted; no spell or ability has resolved. fn restore_resolve_all_priority_snapshot(state: &mut GameState) -> Result { let run = state.resolve_all_consent_run.take().ok_or_else(|| { EngineError::InvalidAction("Resolve All consent is not active".to_string()) @@ -7687,20 +7689,26 @@ fn respond_resolve_all_consent( "Resolve All consent response is no longer pending".to_string(), )); } - if matches!(decision, ResolveAllConsentDecision::Grant) { - let participant = run - .participants - .iter_mut() - .find(|participant| participant.representative == representative) - .expect("pending Resolve All representative must be a participant"); - participant.granted = true; + match decision { + ResolveAllConsentDecision::Grant => { + let participant = run + .participants + .iter_mut() + .find(|participant| participant.representative == representative) + .expect("pending Resolve All representative must be a participant"); + participant.granted = true; + } + ResolveAllConsentDecision::Decline => {} } } - if matches!(decision, ResolveAllConsentDecision::Decline) { - return restore_resolve_all_priority_snapshot(state); + match decision { + ResolveAllConsentDecision::Decline => restore_resolve_all_priority_snapshot(state), + ResolveAllConsentDecision::Grant => { + resolve_all_consent_waiting_for(state).ok_or_else(|| { + EngineError::InvalidAction("Resolve All consent is not active".to_string()) + }) + } } - resolve_all_consent_waiting_for(state) - .ok_or_else(|| EngineError::InvalidAction("Resolve All consent is not active".to_string())) } fn revoke_resolve_all_consent( @@ -19753,7 +19761,7 @@ mod stage2_injector_tests { // `begin_pending_trigger_target_selection` is STILL 134 — the function opens // `:12722 ⇒ :12778`, moving by the same `+56` as the pin, so the control // that caught this row's one historical silent drift is intact. - "game/engine.rs:13086".to_string(), + "game/engine.rs:13094".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/game/engine_resolve_batch.rs b/crates/engine/src/game/engine_resolve_batch.rs index 3e287f0a20..d7abc8810f 100644 --- a/crates/engine/src/game/engine_resolve_batch.rs +++ b/crates/engine/src/game/engine_resolve_batch.rs @@ -9,7 +9,7 @@ use crate::types::player::PlayerId; use super::engine::{apply_action_boundary_with_stack_limit, PublicFinalizeMode}; use super::public_state::finalize_display_state; -use super::{stack, topology, turn_control}; +use super::{interaction, stack, topology, turn_control}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -74,6 +74,7 @@ pub fn resolve_all_ready_prefix( if matches!(&state.waiting_for, WaitingFor::ResolveAllReady { .. }) { turn_control::invalidate_resolve_all_consent(state); finalize_display_state(state); + interaction::ensure_interaction_authority(state); } return ResolveAllFastForwardResult { events, @@ -131,6 +132,7 @@ pub fn resolve_all_ready_prefix( // priority; no later stack entry inherits this consent. state.resolve_all_consent_run = None; finalize_display_state(state); + interaction::ensure_interaction_authority(state); ResolveAllFastForwardResult { events, @@ -142,6 +144,14 @@ pub fn resolve_all_ready_prefix( } } +/// Returns whether the frozen Ready consent run authorizes this requester. +/// Transport callers must reject an unauthorized request before mutating the +/// authoritative session; the resolver's fail-closed invalidation remains its +/// defense-in-depth boundary. +pub fn resolve_all_ready_requester_is_authorized(state: &GameState, requester: PlayerId) -> bool { + ready_consent_run(state, requester).is_some() +} + /// Validates the frozen Phase-1 consent against the live topology before the /// Ready state is materialized. A changed controller, eliminated player, or /// stale requester fails closed without invoking a speculative callback. @@ -921,4 +931,37 @@ mod tests { "the collapsed prefix remains reproducible through ordinary actions" ); } + + #[test] + fn ready_consent_honors_its_saved_resolution_cap() { + let mut state = ready_state(vec![ + no_op_entry(1, PlayerId(0)), + no_op_entry(2, PlayerId(0)), + ]); + state + .resolve_all_consent_run + .as_mut() + .expect("Ready retains its frozen run") + .max_resolutions = 1; + + let result = resolve_all_ready_prefix(&mut state, PlayerId(0)); + + assert_eq!(result.items_resolved, 1); + assert_eq!(state.stack.len(), 1); + assert!(matches!(state.waiting_for, WaitingFor::Priority { .. })); + assert!(state.resolve_all_consent_run.is_none()); + } + + #[test] + fn changed_controller_invalidates_ready_consent_without_resolving() { + let mut state = ready_state(vec![no_op_entry(1, PlayerId(0))]); + state.turn_decision_controller = Some(PlayerId(1)); + + let result = resolve_all_ready_prefix(&mut state, PlayerId(0)); + + assert_eq!(result.items_resolved, 0); + assert_eq!(state.stack.len(), 1); + assert!(matches!(state.waiting_for, WaitingFor::Priority { .. })); + assert!(state.resolve_all_consent_run.is_none()); + } } diff --git a/crates/engine/src/types/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs index 448dd678d2..8dc6596645 100644 --- a/crates/engine/src/types/action_stable_order.rs +++ b/crates/engine/src/types/action_stable_order.rs @@ -1698,7 +1698,9 @@ mod tests { DecisionGroupKey, DecisionKind, DecisionTemplate, IterationCount, ReplayMode, }; use crate::game::combat::AttackTarget; - use crate::types::actions::{MayTriggerAutoChoiceOp, PrecastCopyShortcutResponse}; + use crate::types::actions::{ + MayTriggerAutoChoiceOp, PrecastCopyShortcutResponse, ResolveAllConsentDecision, + }; use crate::types::game_state::{EndEffectGroupId, MayTriggerAutoChoiceKey, MayTriggerOrigin}; use crate::types::identifiers::ObjectId; use crate::types::mana::{ManaCost, ManaCostShard}; @@ -1711,6 +1713,30 @@ mod tests { #[test] fn newer_action_variants_compare_their_payloads() { + assert_distinct_order( + GameAction::BeginResolveAll { max_resolutions: 1 }, + GameAction::BeginResolveAll { max_resolutions: 2 }, + ); + assert_distinct_order( + GameAction::RespondResolveAllConsent { + epoch: 1, + decision: ResolveAllConsentDecision::Grant, + }, + GameAction::RespondResolveAllConsent { + epoch: 1, + decision: ResolveAllConsentDecision::Decline, + }, + ); + assert_distinct_order( + GameAction::RevokeResolveAllConsent { + epoch: 1, + representative: PlayerId(0), + }, + GameAction::RevokeResolveAllConsent { + epoch: 1, + representative: PlayerId(1), + }, + ); assert_distinct_order( GameAction::EndContinuousEffect { group: EndEffectGroupId(1), diff --git a/crates/engine/tests/integration/resolve_all_consent.rs b/crates/engine/tests/integration/resolve_all_consent.rs index dbec57831c..e467a9790d 100644 --- a/crates/engine/tests/integration/resolve_all_consent.rs +++ b/crates/engine/tests/integration/resolve_all_consent.rs @@ -1,12 +1,14 @@ //! Phase-1 protocol coverage for explicit Resolve All consent. use engine::ai_support::{candidate_actions, legal_actions_for_viewer}; +use engine::game::elimination::eliminate_player; use engine::game::engine::apply; use engine::game::interaction::{ bind_interaction_authority, derive_viewer_interaction, resolve_interaction_response, }; use engine::game::visibility::filter_state_for_viewer; use engine::types::actions::{GameAction, ResolveAllConsentDecision}; +use engine::types::format::FormatConfig; use engine::types::game_state::{GameState, WaitingFor}; use engine::types::interaction::{ InteractionOpportunityResponse, InteractionResponse, InteractionSessionId, @@ -16,6 +18,7 @@ use engine::types::player::PlayerId; const P0: PlayerId = PlayerId(0); const P1: PlayerId = PlayerId(1); +const P2: PlayerId = PlayerId(2); fn begin(state: &mut GameState) -> u64 { apply( @@ -106,6 +109,36 @@ fn stale_epoch_and_decline_restore_the_exact_priority_snapshot() { .is_err()); } +#[test] +fn eliminating_a_consent_representative_drops_the_run_and_restores_living_priority() { + let mut state = GameState::new(FormatConfig::free_for_all(), 3, 44); + apply( + &mut state, + P0, + GameAction::BeginResolveAll { max_resolutions: 7 }, + ) + .expect("priority holder may begin Resolve All consent"); + assert!(matches!( + &state.waiting_for, + WaitingFor::ResolveAllConsent { + representative: P1, + .. + } + )); + state.priority_pass_count = 2; + state.priority_passes.insert(P0); + + eliminate_player(&mut state, P1, &mut Vec::new()); + + assert!(state.players[P1.0 as usize].is_eliminated); + assert!(state.resolve_all_consent_run.is_none()); + assert!(matches!(&state.waiting_for, WaitingFor::Priority { player } if *player == P0)); + assert_eq!(state.priority_player, P0); + assert_eq!(state.priority_pass_count, 0); + assert!(state.priority_passes.is_empty()); + assert!(!state.players[P2.0 as usize].is_eliminated); +} + #[test] fn queued_response_and_candidate_keep_the_frozen_submitter_after_control_changes() { let mut state = GameState::new_two_player(44); @@ -123,6 +156,15 @@ fn queued_response_and_candidate_keep_the_frozen_submitter_after_control_changes } if candidate_epoch == epoch ) && candidate.metadata.actor == Some(P1) })); + assert!(apply( + &mut state, + P0, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .is_err()); apply( &mut state, P1, @@ -267,6 +309,7 @@ fn ready_state_transport_materializes_each_grantors_frozen_revoke() { ) .expect("Ready binds one slot per frozen grantor"); let p0_view = derive_viewer_interaction(&state, &filter_state_for_viewer(&state, P0), P0); + assert_eq!(p0_view.opportunities.len(), 1); let InteractionOpportunityResponse::ExactChoices { choices } = &p0_view.opportunities[0].response else { diff --git a/crates/server-core/src/session.rs b/crates/server-core/src/session.rs index fa0b37a19e..4ad58921f6 100644 --- a/crates/server-core/src/session.rs +++ b/crates/server-core/src/session.rs @@ -7,7 +7,9 @@ use engine::database::legality::{validate_cedh_bracket, CedhBracketError}; use engine::database::CardDatabase; use engine::game::deck_loading::{DeckPayload, PlayerDeckPayload}; use engine::game::engine::{apply, start_game}; -use engine::game::engine_resolve_batch::resolve_all_ready_prefix; +use engine::game::engine_resolve_batch::{ + resolve_all_ready_prefix, resolve_all_ready_requester_is_authorized, +}; use engine::game::interaction::{bind_interaction_authority, submit_interaction}; use engine::game::layers::flush_layers; use engine::game::match_flow::apply_trusted_match_forfeit; @@ -96,8 +98,8 @@ pub type ActionResult = ( pub type RevisionedActionResult = (u64, ActionResult); /// Maximum server-authorized stack entries in one remote Resolve All request. -/// The wire request is untrusted; `0` is intentionally not the engine's -/// unlimited sentinel on this transport. +/// The wire request is untrusted, but `0` remains the engine-defined uncapped +/// sentinel; the active consent run, not this transport value, owns the cap. pub const MAX_RESOLVE_ALL_RESOLUTIONS: u32 = 5_000; #[derive(Debug, Clone)] @@ -1570,17 +1572,18 @@ impl SessionManager { )) } - /// Fast-forwards stack resolution for an authenticated player while every - /// non-requester priority holder is a server-configured AI seat. + /// Consumes an engine-issued Resolve All consent run for an authenticated + /// player. Every priority representative has already granted consent, so + /// the state must be `WaitingFor::ResolveAllReady`. pub fn resolve_all_for_player( &mut self, game_code: &str, player_token: &str, max_resolutions: u32, ) -> Result { - if max_resolutions == 0 || max_resolutions > MAX_RESOLVE_ALL_RESOLUTIONS { + if max_resolutions > MAX_RESOLVE_ALL_RESOLUTIONS { return Err(format!( - "Resolve All maximum must be between 1 and {MAX_RESOLVE_ALL_RESOLUTIONS}" + "Resolve All maximum must not exceed {MAX_RESOLVE_ALL_RESOLUTIONS}" )); } @@ -1605,12 +1608,17 @@ impl SessionManager { ) { return Err("Resolve All consent is not ready".to_string()); } + if !resolve_all_ready_requester_is_authorized(&session.state, requester) { + return Err( + "Resolve All requester is not authorized by the active consent".to_string(), + ); + } session.state.log_player_names = session.display_names.clone(); flush_layers(&mut session.state); let pre_action_state = session.state.clone(); // The cap is frozen into the consent run by BeginResolveAll. The wire - // argument remains range-checked above for protocol compatibility but + // argument remains range-checked above for transport compatibility but // cannot enlarge or replace that explicit authorization. let _ = max_resolutions; let batch = resolve_all_ready_prefix(&mut session.state, requester);