diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index 63464cbb66..8b71c6b631 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -17,7 +17,7 @@ export type InteractionSlotKind = "single" | "mulligan" | "openingBottom"; export type ActiveInteractionSlot = { semanticOwner: number, slotKind: InteractionSlotKind, interactionId: InteractionId, }; -export type SimultaneousDecisionKind = "mulligan" | "openingBottom"; +export type SimultaneousDecisionKind = "mulligan" | "openingBottom" | "resolveAllConsent"; export type InteractionWaitingForCode = "terminal" | "mulligan" | "openingBottom" | "choose" | "select" | "sequence" | "relations" | "manaGroups" | "text" | "deckPartition" | "number" | "shortcut" | "assignAmounts" | "assignDamage"; diff --git a/client/src/game/__tests__/dispatchResolveAll.test.ts b/client/src/game/__tests__/dispatchResolveAll.test.ts index c8615cfeb6..26454715de 100644 --- a/client/src/game/__tests__/dispatchResolveAll.test.ts +++ b/client/src/game/__tests__/dispatchResolveAll.test.ts @@ -181,7 +181,7 @@ describe("dispatchResolveAll progress", () => { expect(submitAction).not.toHaveBeenCalled(); }); - it("begins consent before the batch drain and retains its AI seats until Ready", async () => { + it("does not retain AI seats across the consent and Ready calls", async () => { const seats = [{ playerId: 1, difficulty: "Medium" }]; const submitAction = vi.fn().mockResolvedValue({ events: [] }); const consent = buildGameState({ @@ -222,13 +222,12 @@ describe("dispatchResolveAll progress", () => { await dispatchResolveAll(0, []); - expect(resolveAll).toHaveBeenCalledWith(0, seats, 5); + expect(resolveAll).toHaveBeenCalledWith(0, [], 5); }); - - it("uses an empty AI-seat list when the adapter delegates native AI ownership to its server", async () => { + it("consumes Ready consent with an empty AI-seat list when the server owns native AI", async () => { const resolveAll = vi.fn().mockResolvedValue(chunk(0, 2)); const getState = vi.fn().mockResolvedValue(stateWithStack(0)); - const submitAction = vi.fn(); + const submitAction = vi.fn().mockResolvedValue({ events: [] }); useGameStore.setState({ gameState: readyStateWithStack(2), adapter: { diff --git a/client/src/game/controllers/aiController.ts b/client/src/game/controllers/aiController.ts index 25458710dc..5fb97ca132 100644 --- a/client/src/game/controllers/aiController.ts +++ b/client/src/game/controllers/aiController.ts @@ -156,9 +156,8 @@ export function createAIController(config: AIControllerConfig): AIController { return null; } if (waitingFor.type === "ResolveAllConsent") { - return waitingFor.data.representative === PLAYER_ID - ? null - : waitingFor.data.representative; + const { representative } = waitingFor.data; + return aiPlayerIds.has(representative) ? representative : null; } if ( !("data" in waitingFor) || diff --git a/client/src/game/dispatch.ts b/client/src/game/dispatch.ts index 1da1567d7d..6c7ead47a3 100644 --- a/client/src/game/dispatch.ts +++ b/client/src/game/dispatch.ts @@ -967,7 +967,6 @@ const BATCH_CHUNK_SIZE = 5; // pathological stacks. const BATCH_CHUNK_INSTANT = 5_000; let batchResolveInProgress = false; -let pendingResolveAllSeats: { playerId: number; difficulty: string }[] | null = null; export async function dispatchResolveAll( requester: number, @@ -1009,7 +1008,6 @@ export async function dispatchResolveAll( // 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" @@ -1022,8 +1020,6 @@ export async function dispatchResolveAll( return; } - const resolvedSeats = pendingResolveAllSeats ?? aiSeats; - batchResolveInProgress = true; const { setIsResolvingAll, setResolutionProgress } = useGameStore.getState(); setIsResolvingAll(true); @@ -1041,7 +1037,7 @@ export async function dispatchResolveAll( ? BATCH_CHUNK_INSTANT : BATCH_CHUNK_SIZE; const batchResult: BatchResolveResult = await batchAdapter.resolveAll( - requester, resolvedSeats, maxResolutions, + requester, aiSeats, maxResolutions, ); if (latchedTotal === 0) latchedTotal = batchResult.total; @@ -1072,6 +1068,5 @@ export async function dispatchResolveAll( batchResolveInProgress = false; setIsResolvingAll(false); setResolutionProgress(null); - pendingResolveAllSeats = null; } } diff --git a/client/src/game/waitingForRegistry.ts b/client/src/game/waitingForRegistry.ts index bbad7104f4..b61f9f7875 100644 --- a/client/src/game/waitingForRegistry.ts +++ b/client/src/game/waitingForRegistry.ts @@ -33,8 +33,11 @@ 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. + // Resolve All's explicit standing-pass authorization. The consent modal + // gathers each representative's response; its final Grant consumes Ready + // through the engine adapter before ordinary priority resumes. "ResolveAllConsent", + "ResolveAllReady", // CR 701.42 / CR 508.4: meld pair and attacking-entry destination dialogs. "MeldPairChoice", "MeldAttackTargetChoice", diff --git a/client/src/wasm/engine_wasm.d.ts b/client/src/wasm/engine_wasm.d.ts index bc4f55f961..b4217a8c90 100644 --- a/client/src/wasm/engine_wasm.d.ts +++ b/client/src/wasm/engine_wasm.d.ts @@ -429,11 +429,20 @@ export function restore_game_state(json_str: string): void; * * Differs from `restore_game_state` in two load-bearing ways: * - * 1. **Fresh RNG seed.** `restore_game_state` re-seeds from the saved - * `rng_seed`, which rewinds the ChaCha20 stream to position 0 — - * correct for undo (replay from origin) but wrong for resume - * (subsequent draws would replay the pre-save sequence). This - * function stamps a fresh seed so continued play diverges. + * 1. **Fresh RNG seed.** `restore_game_state` re-seeds from the SAVED + * `rng_seed` and fast-forwards to the saved `rng_word_pos`, so the + * restored game continues the very stream the snapshot was taken on — + * correct for undo, wrong for resume, where continued play must not + * re-draw the values the pre-save timeline already committed to. This + * function stamps a FRESH seed and resets `rng_word_pos` to 0 so the + * resumed host diverges instead. + * + * It does NOT rewind to position 0: that was true only before issue + * #5466 taught the restore path to carry the offset, and it survives + * today just for snapshots written back then, which carry + * `rng_word_pos == 0`. Both the shared decode chokepoint + * (`PersistedGameState::into_game_state`) and `restore_game_state`'s + * own repeat call `rehydrate_rng`. * 2. **Atomic multiplayer-flag flip.** Sets `MULTIPLAYER_MODE` in the * same call that loads state, so there's no window where a stray * `restore_game_state` (undo) would be accepted on the resumed diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index f4a3930079..6c366a1269 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -13,8 +13,9 @@ 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}; -use engine::game::engine_resolve_batch::resolve_all_ready_prefix; +use engine::game::engine::{ + apply, apply_for_simulation, resolve_all_ready_is_authorized, 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::{ @@ -3025,32 +3026,13 @@ pub fn submit_ai_action_proposal(token: &str, actor: u8, action: JsValue) -> JsV /// events, so the WASM boundary intentionally returns empty event/log arrays /// instead of serializing thousands of records for pathological stacks. /// -/// Stop conditions (all CR-compliant): -/// - Stack empties -/// - Stack grows beyond the chunk-origin depth -/// - An interactive `WaitingFor` appears (target selection, scry, etc.) -/// - An unknown/non-requester human actor receives priority -/// - 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 { - player_id: u8, - difficulty: String, -} - #[wasm_bindgen] pub fn resolve_all( requester: u8, ai_seats_json: &str, max_resolutions: u32, ) -> Result { - let ai_seats: Vec = serde_json::from_str(ai_seats_json) + let _: serde_json::Value = serde_json::from_str(ai_seats_json) .map_err(|e| JsValue::from_str(&format!("Failed to deserialize AI seats: {e}")))?; let requester = PlayerId(requester); @@ -3061,8 +3043,8 @@ pub fn resolve_all( // 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 { .. }) { + let _ = max_resolutions; + if !resolve_all_ready_is_authorized(state, requester) { return Err(JsValue::from_str("Resolve All consent is not ready")); } let mut result = resolve_all_ready_prefix(state, requester); @@ -3232,6 +3214,7 @@ mod tests { use std::sync::Arc; use engine::game::deck_loading::create_object_from_card_face; + use engine::game::engine::ResolveAllFastForwardResult as BatchResolveResult; use engine::game::scenario::{GameScenario, P0, P1}; use engine::game::zones::create_object; use engine::types::ability::{ @@ -3239,6 +3222,7 @@ mod tests { ContinuousModification, Duration, Effect, QuantityExpr, QuantityRef, ResolvedAbility, TargetFilter, TargetRef, }; + use engine::types::actions::ResolveAllConsentDecision; use engine::types::card::CardFace; use engine::types::card_type::{CardType, CoreType}; use engine::types::counter::{CounterMatch, CounterType}; @@ -4494,7 +4478,6 @@ mod tests { state.active_player = PlayerId(1); state.turn_decision_controller = Some(PlayerId(0)); 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, @@ -4523,6 +4506,28 @@ mod tests { )); GAME_STATE.with(|cell| cell.set(Some(state))); + with_state_mut(|state| { + apply( + state, + PlayerId(0), + GameAction::BeginResolveAll { max_resolutions: 0 }, + ) + .expect("turn controller may begin the consent run"); + let WaitingFor::ResolveAllConsent { epoch, .. } = &state.waiting_for else { + panic!("controlled priority should queue Resolve All consent"); + }; + apply( + state, + PlayerId(0), + GameAction::RespondResolveAllConsent { + epoch: *epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("frozen turn controller may grant for the queued representative"); + }) + .expect("test state remains installed"); + let value = resolve_all(0, "[]", 0).unwrap(); let result: BatchResolveResult = serde_wasm_bindgen::from_value(value).unwrap(); diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 890f6d9272..86e914df22 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -3579,7 +3579,7 @@ fn semantic_candidate_actions_with_probe( // for broad-only callers, so composing both here would expose every // Grant, Decline, and Revoke choice twice. if !matches!( - &state.waiting_for, + state.waiting_for, WaitingFor::ResolveAllConsent { .. } | WaitingFor::ResolveAllReady { .. } ) { actions.extend(candidate_actions_broad_with_probe(state, probe)); @@ -3614,12 +3614,11 @@ fn authorize_candidate_actors(state: &GameState, actions: &mut [CandidateAction] WaitingFor::ResolveAllConsent { epoch: active_epoch, representative, - } if *epoch == *active_epoch => { - Some(crate::game::turn_control::authorized_submitter_for_player( - state, - *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 { diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 8b369025e2..b9c6d8ecd6 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -76,7 +76,8 @@ use super::zone_pipeline::{self, ZoneMoveRequest, ZoneMoveResult}; use super::zones; pub use super::engine_resolve_batch::{ - resolve_all_fast_forward, ResolveAllCallbackDecision, ResolveAllFastForwardResult, + resolve_all_fast_forward, resolve_all_ready_is_authorized, resolve_all_ready_prefix, + ResolveAllCallbackDecision, ResolveAllFastForwardResult, }; #[derive(Debug, Clone, Error)] @@ -7612,6 +7613,9 @@ fn begin_resolve_all_consent( EngineError::ActionNotAllowed("Resolve All consent epoch space exhausted".to_string()) })?; state.next_resolve_all_consent_epoch = next_epoch; + // CR 117.4: a stack object resolves only after every player passes in + // succession. Preserve the exact current pass cycle if consent is declined + // or revoked before its authorized one-entry materialization begins. state.resolve_all_consent_run = Some(ResolveAllConsentRun { epoch, max_resolutions, @@ -7689,26 +7693,34 @@ fn respond_resolve_all_consent( "Resolve All consent response is no longer pending".to_string(), )); } - 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::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::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()) - }) - } + if matches!(decision, ResolveAllConsentDecision::Decline) { + return restore_resolve_all_priority_snapshot(state); } + let waiting_for = resolve_all_consent_waiting_for(state).ok_or_else(|| { + EngineError::InvalidAction("Resolve All consent is not active".to_string()) + })?; + // ResolveAllReady has no current actor, so the ordinary waiting-state sync + // deliberately leaves `priority_player` alone. Restore the saved priority + // cursor now; the Ready consumer validates this exact snapshot before it + // begins its first materialized CR 117.4 pass cycle. + if matches!(waiting_for, WaitingFor::ResolveAllReady { .. }) { + state.priority_player = state + .resolve_all_consent_run + .as_ref() + .expect("an active consent run produced ResolveAllReady") + .priority_snapshot + .priority_player; + } + Ok(waiting_for) } fn revoke_resolve_all_consent( @@ -8089,7 +8101,11 @@ fn apply_action( let stack_len_before_action = state.stack.len(); if !matches!( action, - GameAction::PassPriority | GameAction::OrderTriggers { .. } + GameAction::PassPriority + | GameAction::OrderTriggers { .. } + | GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } ) && !answering_forced_window { state.loop_detect_ring.clear(); @@ -8110,7 +8126,10 @@ fn apply_action( match &action { GameAction::SetAutoPass { .. } | GameAction::PassPriority - | GameAction::ReorderHand { .. } => {} + | GameAction::ReorderHand { .. } + | GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } => {} _ => { state.auto_pass.remove(&actor); } @@ -19763,7 +19782,10 @@ 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:13094".to_string(), + // Resolve All consent adds its frozen-authority protocol above this producer: + // `:12912 ⇒ :13113`. It does not create a CR 603.5 prompt, and the pinned + // line remains the same `OptionalEffectChoice` construction. + "game/engine.rs:13113".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 d7abc8810f..b2120d4c06 100644 --- a/crates/engine/src/game/engine_resolve_batch.rs +++ b/crates/engine/src/game/engine_resolve_batch.rs @@ -130,7 +130,7 @@ pub fn resolve_all_ready_prefix( // 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; + turn_control::invalidate_resolve_all_consent(state); finalize_display_state(state); interaction::ensure_interaction_authority(state); @@ -155,14 +155,19 @@ pub fn resolve_all_ready_requester_is_authorized(state: &GameState, requester: P /// 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. +pub fn resolve_all_ready_is_authorized(state: &GameState, requester: PlayerId) -> bool { + ready_consent_run(state, requester).is_some() +} + 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))?; + let run = state.resolve_all_consent_run.as_ref().filter(|run| { + state.auto_pass.is_empty() + && run.epoch == *epoch + && run.participants.iter().all(|p| p.granted) + })?; (run.participants .iter() .any(|participant| participant.authorized_submitter == requester) @@ -185,14 +190,11 @@ fn consent_authorization_matches(state: &GameState, run: &ResolveAllConsentRun) }; representatives.rotate_left(current_index); 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 - }) + && run.participants.iter().all(|frozen| { + representatives.contains(&frozen.representative) + && turn_control::authorized_submitter_for_player(state, frozen.representative) + == frozen.authorized_submitter + }) } /// Performs exactly one actual priority cycle on a proof clone. Every seeded @@ -486,7 +488,7 @@ mod tests { 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}; + use crate::types::game_state::{AutoPassMode, PublicStateDirty, StackEntry, StackEntryKind}; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::mana::ManaColor; use crate::types::phase::{Phase, PhaseStop, PhaseStopScope}; @@ -916,7 +918,13 @@ mod tests { let result = resolve_all_ready_prefix(&mut state, PlayerId(0)); - assert_eq!(result.items_resolved, 2); + assert_eq!( + result.items_resolved, + 2, + "safe-prefix proof unexpectedly stopped: result={result:?}, waiting={:?}, stack_len={}", + state.waiting_for, + state.stack.len(), + ); 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()); @@ -956,6 +964,23 @@ mod tests { 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()); + } + + #[test] + fn ready_consent_refuses_to_collapse_while_an_auto_pass_preference_is_active() { + let mut state = ready_state(vec![no_op_entry(1, PlayerId(0))]); + state.auto_pass.insert( + PlayerId(0), + AutoPassMode::UntilStackEmpty { + initial_stack_len: state.stack.len(), + }, + ); let result = resolve_all_ready_prefix(&mut state, PlayerId(0)); diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index ea293be5a6..f934f30995 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -436,9 +436,13 @@ fn classify_waiting_for(waiting_for: &WaitingFor) -> WaitingClassification { None, Some(InteractionSlotKind::Single), ), + WaitingFor::ResolveAllConsent { .. } => ( + InteractionWaitingForCode::Shortcut, + Some(SimultaneousDecisionKind::ResolveAllConsent), + Some(InteractionSlotKind::Single), + ), WaitingFor::LoopShortcut { .. } | WaitingFor::RespondToShortcut { .. } - | WaitingFor::ResolveAllConsent { .. } | WaitingFor::ResolveAllReady { .. } => ( InteractionWaitingForCode::Shortcut, None, diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index a83830bfe6..9c32e2d844 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -2838,12 +2838,12 @@ pub fn resolve_next_with_limit( } } } - if let Some(run_len) = fixed_opponent_lose_life_run_len(state) { + if let Some(run_len) = fixed_opponent_effect_run_len(state) { let run_len = run_len.min(max_consumed); if run_len >= 2 { crate::game::perf_counters::record_stack_batch_candidate(); if let Some(consumed) = - resolve_proven_fixed_opponent_lose_life_batch(state, events, run_len) + resolve_proven_fixed_opponent_effect_batch(state, events, run_len) { return consumed; } @@ -3449,10 +3449,10 @@ fn fixed_controller_gain_life_ability_is_batch_candidate(ability: &ResolvedAbili && parent_target_missing_reason.is_none() } -/// CR 117.3b + CR 117.3d + CR 117.5 + CR 608.2 + CR 704.3 + CR 119.3: Fixed -/// opponent life-loss class — shared inert proof; life-loss observer refusal -/// is covered by the common event/settled checkpoint checks. -fn resolve_proven_fixed_opponent_lose_life_batch( +/// CR 117.3b + CR 117.3d + CR 117.5 + CR 608.2 + CR 704.3: Fixed opponent- +/// scoped effect class — shared inert proof. Zone-change and life-change +/// observers are covered by the common event/settled checkpoint checks. +fn resolve_proven_fixed_opponent_effect_batch( state: &mut GameState, events: &mut Vec, run_len: u32, @@ -3460,7 +3460,7 @@ fn resolve_proven_fixed_opponent_lose_life_batch( resolve_proven_inert_trigger_batch(state, events, run_len, None) } -struct FixedOpponentLoseLifeRunKey<'a> { +struct FixedOpponentEffectRunKey<'a> { controller: PlayerId, ability: &'a ResolvedAbility, condition: Option<&'a TriggerCondition>, @@ -3468,17 +3468,17 @@ struct FixedOpponentLoseLifeRunKey<'a> { } /// CR 603.3b + CR 603.4 + CR 608.2: Length of the top contiguous run of -/// identical triggered abilities that make each opponent lose a fixed amount -/// of life. Equal intervening-if conditions are admitted because the shared -/// clone proof rechecks every entry at resolution time before committing. +/// identical triggered abilities that apply a fixed life-loss or mill effect +/// to each opponent. Equal intervening-if conditions are admitted because the +/// shared clone proof rechecks every entry at resolution time before committing. /// Source provenance is inert for this effect shape, so distinct sources can /// share one run when all resolution-relevant fields agree. -fn fixed_opponent_lose_life_run_len(state: &GameState) -> Option { +fn fixed_opponent_effect_run_len(state: &GameState) -> Option { let top = state.stack.back()?; - let top_key = fixed_opponent_lose_life_run_key(state, top)?; + let top_key = fixed_opponent_effect_run_key(state, top)?; let mut len = 1u32; for entry in state.stack.iter().rev().skip(1) { - match fixed_opponent_lose_life_run_key(state, entry) { + match fixed_opponent_effect_run_key(state, entry) { Some(key) if key.controller == top_key.controller && key.condition == top_key.condition @@ -3496,10 +3496,10 @@ fn fixed_opponent_lose_life_run_len(state: &GameState) -> Option { Some(len) } -fn fixed_opponent_lose_life_run_key<'a>( +fn fixed_opponent_effect_run_key<'a>( state: &'a GameState, entry: &'a StackEntry, -) -> Option> { +) -> Option> { let StackEntryKind::TriggeredAbility { source_id: _, ability, @@ -3516,12 +3516,12 @@ fn fixed_opponent_lose_life_run_key<'a>( }; if !flatten_targets_in_chain(ability).is_empty() - || !fixed_opponent_lose_life_ability_is_batch_candidate(ability) + || !fixed_opponent_effect_ability_is_batch_candidate(ability) { return None; } - Some(FixedOpponentLoseLifeRunKey { + Some(FixedOpponentEffectRunKey { controller: entry.controller, ability, condition: condition.as_ref(), @@ -3529,7 +3529,7 @@ fn fixed_opponent_lose_life_run_key<'a>( }) } -fn fixed_opponent_lose_life_ability_is_batch_candidate(ability: &ResolvedAbility) -> bool { +fn fixed_opponent_effect_ability_is_batch_candidate(ability: &ResolvedAbility) -> bool { let ResolvedAbility { effect, targets, @@ -3589,15 +3589,19 @@ fn fixed_opponent_lose_life_ability_is_batch_candidate(ability: &ResolvedAbility parent_target_missing_reason, } = ability; - let fixed_opponent_lose_life = matches!( + let fixed_opponent_effect = matches!( effect, Effect::LoseLife { amount: QuantityExpr::Fixed { .. }, target: None, + } | Effect::Mill { + count: QuantityExpr::Fixed { .. }, + target: TargetFilter::Controller, + destination: Zone::Graveyard, } ); - fixed_opponent_lose_life + fixed_opponent_effect && targets.is_empty() && scoped_player.is_none() && matches!(kind, AbilityKind::Spell | AbilityKind::Database) @@ -4816,12 +4820,10 @@ mod tests { PlayerId(0), ); lose_life.player_scope = Some(crate::types::ability::PlayerFilter::Opponent); - assert!(fixed_opponent_lose_life_ability_is_batch_candidate( - &lose_life - )); + assert!(fixed_opponent_effect_ability_is_batch_candidate(&lose_life)); let mut divided_loss = lose_life.clone(); divided_loss.distribute = Some(crate::types::game_state::DistributionUnit::Life); - assert!(!fixed_opponent_lose_life_ability_is_batch_candidate( + assert!(!fixed_opponent_effect_ability_is_batch_candidate( ÷d_loss )); } @@ -7494,7 +7496,7 @@ 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, observers_are_batch_safe, + fixed_opponent_effect_run_len, observers_are_batch_safe, priority_checkpoint_is_settled, resolve_next, resolve_next_with_limit, resolve_top, self_counter_run_len, }; @@ -7992,6 +7994,47 @@ mod tests { }); } + fn fixed_opponent_mill_effect() -> Effect { + Effect::Mill { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + destination: Zone::Graveyard, + } + } + + fn push_fixed_opponent_mill_trigger( + state: &mut GameState, + source: ObjectId, + trigger_event: GameEvent, + ) { + let entry_id = ObjectId(state.next_object_id); + state.next_object_id += 1; + let mut ability = + ResolvedAbility::new(fixed_opponent_mill_effect(), vec![], source, PlayerId(0)); + ability.player_scope = Some(PlayerFilter::Opponent); + ability.description = Some("each opponent mills a card".to_string()); + ability.ability_index = Some(0); + state.stack.push_back(StackEntry { + id: entry_id, + source_id: source, + controller: PlayerId(0), + kind: StackEntryKind::TriggeredAbility { + source_id: source, + ability: Box::new(ability), + condition: None, + trigger_event: Some(trigger_event), + description: Some( + "Whenever another permanent enters, each opponent mills a card." + .to_string(), + ), + source_name: state.objects[&source].name.clone(), + subject_match_count: None, + die_result: None, + provenance: None, + }, + }); + } + fn life_event(player_id: PlayerId, amount: i32) -> GameEvent { GameEvent::LifeChanged { player_id, amount } } @@ -8349,7 +8392,7 @@ mod tests { ); assert_eq!( - fixed_opponent_lose_life_run_len(&state), + fixed_opponent_effect_run_len(&state), Some(3), "fixed opponent life loss should ignore inert source provenance" ); @@ -8428,7 +8471,7 @@ mod tests { ); assert_eq!( - fixed_opponent_lose_life_run_len(&state), + fixed_opponent_effect_run_len(&state), Some(2), "a distinct intervening-if must end the contiguous batch" ); @@ -8486,6 +8529,99 @@ mod tests { ); } + #[test] + fn fixed_opponent_mill_triggers_batch() { + crate::game::perf_counters::reset(); + let mut state = setup(); + let source = add_self_counter_source(&mut state, "Altar of the Brood"); + let milled_cards: Vec<_> = (0..3) + .map(|index| { + create_object( + &mut state, + CardId(9_700 + index), + PlayerId(1), + format!("Library Card {index}"), + Zone::Library, + ) + }) + .collect(); + let trigger_event = life_event(PlayerId(0), 0); + for _ in 0..3 { + push_fixed_opponent_mill_trigger(&mut state, source, trigger_event.clone()); + } + + assert_eq!( + fixed_opponent_effect_run_len(&state), + Some(3), + "identical Altar of the Brood triggers should form one inert run" + ); + + let mut events = Vec::new(); + let consumed = resolve_next(&mut state, &mut events); + + assert_eq!(consumed, 3); + assert!(state.stack.is_empty()); + assert!(milled_cards + .iter() + .all(|id| state.objects[id].zone == Zone::Graveyard)); + assert_eq!( + crate::game::perf_counters::snapshot().stack_batched_entries, + 3 + ); + } + + #[test] + fn fixed_opponent_mill_batch_refuses_when_mill_observer_fires() { + crate::game::perf_counters::reset(); + let mut state = setup(); + let source = add_self_counter_source(&mut state, "Altar of the Brood"); + let observer = create_object( + &mut state, + CardId(9_701), + PlayerId(0), + "Mill Watcher".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&observer).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + let trigger = TriggerDefinition::new(TriggerMode::ChangesZone) + .origin(Zone::Library) + .destination(Zone::Graveyard) + .execute(AbilityDefinition::new( + crate::types::ability::AbilityKind::Database, + Effect::NoOp, + )); + Arc::make_mut(&mut obj.base_trigger_definitions).push(trigger.clone()); + obj.trigger_definitions.push(trigger); + } + crate::types::game_state::TriggerIndex::rebuild_from_battlefield(&mut state); + for index in 0..2 { + create_object( + &mut state, + CardId(9_710 + index), + PlayerId(1), + format!("Library Card {index}"), + Zone::Library, + ); + } + let trigger_event = life_event(PlayerId(0), 0); + push_fixed_opponent_mill_trigger(&mut state, source, trigger_event.clone()); + push_fixed_opponent_mill_trigger(&mut state, source, trigger_event); + + let mut events = Vec::new(); + let consumed = resolve_next(&mut state, &mut events); + + assert_eq!( + consumed, 1, + "a library-to-graveyard observer must preserve the per-entry priority checkpoint" + ); + assert_eq!( + crate::game::perf_counters::snapshot().stack_batched_entries, + 0 + ); + } + // §9.2 — Layer C reports safe on an observer-free board. #[test] fn observers_are_batch_safe_true_without_observers() { diff --git a/crates/engine/src/game/turn_control.rs b/crates/engine/src/game/turn_control.rs index f408b5942b..74b562cdee 100644 --- a/crates/engine/src/game/turn_control.rs +++ b/crates/engine/src/game/turn_control.rs @@ -407,6 +407,12 @@ pub fn invalidate_resolve_all_consent(state: &mut GameState) { if state.resolve_all_consent_run.take().is_none() { return; } + if !matches!( + state.waiting_for, + WaitingFor::ResolveAllConsent { .. } | WaitingFor::ResolveAllReady { .. } + ) { + return; + } let preferred = super::topology::priority_pass_representative(state, state.active_player); let player = super::players::is_alive(state, preferred) .then_some(preferred) diff --git a/crates/engine/src/types/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs index 8dc6596645..41dec7df4f 100644 --- a/crates/engine/src/types/action_stable_order.rs +++ b/crates/engine/src/types/action_stable_order.rs @@ -1860,5 +1860,29 @@ mod tests { response: PrecastCopyShortcutResponse::Accept, }, ); + 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), + }, + ); } } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 84cf0b5d64..0dbbb329f0 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -22026,6 +22026,9 @@ impl GameState { // CR 104.4b: pip-id counter is a volatile monotonic field; zero it (like // next_object_id) so two otherwise-identical loop states compare equal. clone.next_pip_id = 0; + // CR 104.4b: consent epochs are monotonic authorization receipts, not + // recurring game-position state. + clone.next_resolve_all_consent_epoch = 0; // P1 provenance is append-only historical evidence, not live rules // state. Clear it with the other monotonic identity carriers so it // cannot hide a genuine CR 104.4b repeated position. diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index 11bb432273..198f2f1ed6 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -66,6 +66,7 @@ pub struct ActiveInteractionSlot { pub enum SimultaneousDecisionKind { Mulligan, OpeningBottom, + ResolveAllConsent, } /// Stable protocol classification of an engine prompt. This deliberately diff --git a/crates/engine/tests/integration/resolve_all_consent.rs b/crates/engine/tests/integration/resolve_all_consent.rs index e467a9790d..a55d5e5a81 100644 --- a/crates/engine/tests/integration/resolve_all_consent.rs +++ b/crates/engine/tests/integration/resolve_all_consent.rs @@ -2,14 +2,16 @@ use engine::ai_support::{candidate_actions, legal_actions_for_viewer}; use engine::game::elimination::eliminate_player; -use engine::game::engine::apply; +use engine::game::engine::{apply, resolve_all_ready_prefix}; use engine::game::interaction::{ bind_interaction_authority, derive_viewer_interaction, resolve_interaction_response, }; use engine::game::visibility::filter_state_for_viewer; +use engine::types::ability::{CopyRetargetPermission, Effect, ResolvedAbility, TargetFilter}; use engine::types::actions::{GameAction, ResolveAllConsentDecision}; use engine::types::format::FormatConfig; -use engine::types::game_state::{GameState, WaitingFor}; +use engine::types::game_state::{GameState, StackEntry, StackEntryKind, WaitingFor}; +use engine::types::identifiers::ObjectId; use engine::types::interaction::{ InteractionOpportunityResponse, InteractionResponse, InteractionSessionId, InteractionSubmission, @@ -60,6 +62,10 @@ fn consent_queue_reaches_inert_ready_only_after_every_representative_grants() { &state.waiting_for, WaitingFor::ResolveAllReady { epoch: ready_epoch } if *ready_epoch == epoch )); + assert_eq!( + state.priority_player, P0, + "Ready preserves the saved priority cursor" + ); assert!(apply(&mut state, P1, GameAction::PassPriority).is_err()); assert!(matches!( &state.waiting_for, @@ -174,6 +180,58 @@ fn queued_response_and_candidate_keep_the_frozen_submitter_after_control_changes }, ) .expect("frozen submitter, not the new live controller, answers the prompt"); + assert!(apply( + &mut state, + P0, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .is_err()); +} + +#[test] +fn rotated_three_player_consent_reaches_the_ready_prefix() { + let mut state = GameState::new(FormatConfig::free_for_all(), 3, 49); + let entry = StackEntry { + id: ObjectId(1), + source_id: ObjectId(1), + controller: P0, + kind: StackEntryKind::ActivatedAbility { + source_id: ObjectId(1), + ability: Box::new(ResolvedAbility::new(Effect::NoOp, vec![], ObjectId(1), P0)), + }, + }; + state.stack.push_back(entry); + let epoch = begin(&mut state); + + apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("first queued representative grants"); + assert!(matches!( + state.waiting_for, + WaitingFor::ResolveAllConsent { representative, .. } if representative == P2 + )); + apply( + &mut state, + P2, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("second queued representative grants"); + + let result = resolve_all_ready_prefix(&mut state, P0); + assert_eq!(result.items_resolved, 1); + assert!(state.stack.is_empty()); } #[test] @@ -335,3 +393,57 @@ fn ready_state_transport_materializes_each_grantors_frozen_revoke() { } ); } + +#[test] +fn ready_consent_collapses_the_safe_prefix_before_a_stack_growing_resolution() { + let entry = |id, effect| StackEntry { + id: ObjectId(id), + source_id: ObjectId(id), + controller: P0, + kind: StackEntryKind::ActivatedAbility { + source_id: ObjectId(id), + ability: Box::new(ResolvedAbility::new(effect, vec![], ObjectId(id), P0)), + }, + }; + let mut state = GameState::new_two_player(48); + state.waiting_for = WaitingFor::Priority { player: P0 }; + state.priority_player = P0; + state.stack = vec![ + entry( + 1, + Effect::CopySpell { + target: TargetFilter::SelfRef, + retarget: CopyRetargetPermission::KeepOriginalTargets, + copier: None, + additional_modifications: vec![], + starting_loyalty_from_casualty_sacrifice: false, + }, + ), + entry(2, Effect::NoOp), + entry(3, Effect::NoOp), + ] + .into_iter() + .collect(); + let epoch = begin(&mut state); + apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("second representative grants"); + + let result = resolve_all_ready_prefix(&mut state, P0); + + assert_eq!( + result.items_resolved, + 2, + "safe-prefix result={result:?}, waiting={:?}, stack_len={}", + state.waiting_for, + state.stack.len(), + ); + assert_eq!(state.stack.len(), 1, "the stack-growing item remains live"); + assert!(matches!(state.waiting_for, WaitingFor::Priority { .. })); +} diff --git a/crates/server-core/src/session.rs b/crates/server-core/src/session.rs index 4ad58921f6..84ec525bfa 100644 --- a/crates/server-core/src/session.rs +++ b/crates/server-core/src/session.rs @@ -6,9 +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, start_game}; -use engine::game::engine_resolve_batch::{ - resolve_all_ready_prefix, resolve_all_ready_requester_is_authorized, +use engine::game::engine::{ + apply, resolve_all_ready_is_authorized, resolve_all_ready_prefix, start_game, }; use engine::game::interaction::{bind_interaction_authority, submit_interaction}; use engine::game::layers::flush_layers; @@ -1572,9 +1571,11 @@ impl SessionManager { )) } - /// 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`. + /// Consumes an engine-issued unanimous Resolve All consent run for an + /// authenticated player. AI seats do not authorize future priority passes; + /// each representative grants through the engine's consent protocol first. + /// `max_resolutions` remains range-checked for wire compatibility, while + /// the already-issued consent run owns the resolution cap. pub fn resolve_all_for_player( &mut self, game_code: &str, @@ -1602,17 +1603,9 @@ impl SessionManager { ); } - if !matches!( - &session.state.waiting_for, - engine::types::game_state::WaitingFor::ResolveAllReady { .. } - ) { + if !resolve_all_ready_is_authorized(&session.state, requester) { 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);