diff --git a/client/src/game/__tests__/dispatchResolveAll.test.ts b/client/src/game/__tests__/dispatchResolveAll.test.ts index 26454715de..e2d217ba1f 100644 --- a/client/src/game/__tests__/dispatchResolveAll.test.ts +++ b/client/src/game/__tests__/dispatchResolveAll.test.ts @@ -6,7 +6,7 @@ import { useGameStore } from "../../stores/gameStore"; import { useAppNotificationStore } from "../../stores/appToastStore"; import { usePreferencesStore } from "../../stores/preferencesStore"; import { buildGameState, buildPriorityWaitingFor, buildStackEntry } from "../../test/factories/gameStateFactory"; -import { dispatchResolveAll } from "../dispatch"; +import { dispatchAction, dispatchResolveAll } from "../dispatch"; // A Priority-on-the-storming-player WaitingFor (active player holds priority). const priorityWf: BatchResolveResult["waitingFor"] = buildPriorityWaitingFor(); @@ -66,6 +66,7 @@ describe("dispatchResolveAll progress", () => { afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); }); it("reports the engine-proved prefix once and clears progress at the end", async () => { @@ -224,6 +225,73 @@ describe("dispatchResolveAll progress", () => { expect(resolveAll).toHaveBeenCalledWith(0, [], 5); }); + + it("submits a Resolve All click queued behind a fresh Priority snapshot", async () => { + vi.useFakeTimers(); + usePreferencesStore.setState({ animationSpeedMultiplier: 1 }); + + const initialPriority = buildPriorityWaitingFor(); + const freshPriority = buildPriorityWaitingFor(); + const priorityState = buildGameState({ + waiting_for: initialPriority, + stack: [buildStackEntry({ id: 1 })], + }); + const postPassState = buildGameState({ + waiting_for: freshPriority, + stack: [buildStackEntry({ id: 1 })], + }); + const consentState = buildGameState({ + waiting_for: { type: "ResolveAllConsent", data: { epoch: 1, representative: 1 } }, + stack: [buildStackEntry({ id: 1 })], + }); + const submitAction = vi + .fn() + .mockResolvedValueOnce({ + events: [{ type: "LifeChanged", data: { player_id: 0, amount: -1 } }], + log_entries: [], + }) + .mockResolvedValue({ events: [], log_entries: [] }); + const getSnapshot = vi + .fn<() => Promise>() + .mockResolvedValueOnce({ + state: postPassState, + legalResult: { actions: [{ type: "PassPriority" }], autoPassRecommended: false }, + seq: nextSnapshotSeq(), + }) + .mockResolvedValueOnce({ + state: consentState, + legalResult: { actions: [], autoPassRecommended: false }, + seq: nextSnapshotSeq(), + }) + .mockResolvedValue({ + state: consentState, + legalResult: { actions: [], autoPassRecommended: false }, + seq: nextSnapshotSeq(), + }); + + useGameStore.setState({ + gameState: priorityState, + waitingFor: initialPriority, + legalActions: [{ type: "PassPriority" }], + adapter: { submitAction, getSnapshot, resolveAll: vi.fn() } as never, + }); + + const pass = dispatchAction({ type: "PassPriority" }, 0); + await vi.advanceTimersByTimeAsync(0); + const resolveAll = dispatchResolveAll(0, [{ playerId: 1, difficulty: "Medium" }]); + const cancelAutoPass = dispatchAction({ type: "CancelAutoPass" }, 0); + + await vi.runAllTimersAsync(); + await pass; + await resolveAll; + await cancelAutoPass; + + expect(submitAction).toHaveBeenNthCalledWith(2, { + type: "BeginResolveAll", + data: { max_resolutions: 5 }, + }, 0); + expect(submitAction).toHaveBeenNthCalledWith(3, { type: "CancelAutoPass" }, 0); + }); 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)); diff --git a/client/src/game/dispatch.ts b/client/src/game/dispatch.ts index 6c7ead47a3..ad052b384a 100644 --- a/client/src/game/dispatch.ts +++ b/client/src/game/dispatch.ts @@ -217,6 +217,7 @@ function queuedLocalActionStillApplies(next: PendingLocalAction): boolean { if ( next.action.type === "SetPhaseStops" || next.action.type === "SetPriorityPassingMode" + || next.action.type === "CancelAutoPass" ) { return true; } @@ -224,8 +225,10 @@ function queuedLocalActionStillApplies(next: PendingLocalAction): boolean { if (Object.is(next.waitingFor, waitingFor)) return true; if (!waitingForActorMatches(waitingFor, gameState, next.actor)) return false; if (legalActions.some((action) => actionsEqual(action, next.action))) return true; + // Resolve All begins from Priority but is deliberately absent from normal + // legal actions: it opens its own engine-authored consent protocol. return ( - next.action.type === "PassPriority" && + (next.action.type === "PassPriority" || next.action.type === "BeginResolveAll") && waitingFor?.type === "Priority" && gameState != null ); diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index ea316d6376..dbd76fead8 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -7590,11 +7590,7 @@ fn begin_resolve_all_consent( priority_player: PlayerId, max_resolutions: u32, ) -> Result { - if state.priority_player - != turn_control::authorized_submitter_for_player(state, priority_player) - { - return Err(EngineError::NotYourPriority); - } + super::priority::pass_priority_legality(state, priority_player)?; let current_representative = super::topology::priority_pass_representative(state, priority_player); let mut representatives = super::topology::priority_pass_participants(state); @@ -7657,8 +7653,9 @@ 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. +// CR 117.3d + CR 117.4: A declined optimized batch restores the exact +// priority-pass sequence it interrupted before ordinary priority handling +// resumes; this restoration itself resolves no stack object. 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()) @@ -7672,6 +7669,109 @@ fn restore_resolve_all_priority_snapshot(state: &mut GameState) -> Result, +) -> Result { + let WaitingFor::Priority { player } = &state.waiting_for else { + unreachable!("auto-pass may only be installed from a Priority window"); + }; + let pass_immediately = *player == auto_pass_owner; + if pass_immediately && super::precast_copy_shortcut::blocks_pass(state, *player) { + return Err(EngineError::ActionNotAllowed( + "A shortened pre-cast shortcut requires a different meaningful action before passing" + .to_string(), + )); + } + store_auto_pass_request(state, auto_pass_owner, mode); + if !pass_immediately { + return Ok(ActionResult { + events: std::mem::take(events), + waiting_for: state.waiting_for.clone(), + log_entries: vec![], + }); + } + let waiting_for = pass_priority_once_with_pipeline(state, events, None)?; + Ok(ActionResult { + events: std::mem::take(events), + waiting_for, + log_entries: vec![], + }) +} + +fn store_auto_pass_request( + state: &mut GameState, + auto_pass_owner: PlayerId, + mode: AutoPassRequest, +) { + let stored_mode = match mode { + AutoPassRequest::UntilStackEmpty => AutoPassMode::UntilStackEmpty { + initial_stack_len: state.stack.len(), + }, + AutoPassRequest::UntilTurnBoundary { until } => AutoPassMode::UntilTurnBoundary { until }, + }; + state.auto_pass.insert(auto_pass_owner, stored_mode); +} + +/// Stores Resolve All's durable "do not make me pass each frame" intent in +/// the same engine-owned `UntilStackEmpty` flow as a direct priority request. +pub(crate) fn install_until_stack_empty_auto_pass_and_pass_priority( + state: &mut GameState, + auto_pass_owner: PlayerId, + events: &mut Vec, +) -> Result { + install_auto_pass_and_pass_priority( + state, + auto_pass_owner, + AutoPassRequest::UntilStackEmpty, + events, + ) +} + +/// Retains Resolve All's durable no-manual-priority preference when a rules +/// guard prevents its initial immediate pass. The normal auto-pass loop resumes +/// after that required action completes. +pub(crate) fn install_until_stack_empty_auto_pass( + state: &mut GameState, + auto_pass_owner: PlayerId, +) { + store_auto_pass_request(state, auto_pass_owner, AutoPassRequest::UntilStackEmpty); +} + +/// CR 117.3d + CR 117.4: Declining the optimized Resolve All batch preserves +/// the requester's intent by switching to the ordinary engine auto-pass flow. +fn decline_resolve_all_consent_with_auto_pass( + state: &mut GameState, + epoch: u64, + representative: PlayerId, + response_epoch: u64, + events: &mut Vec, +) -> Result { + let waiting_for = respond_resolve_all_consent( + state, + epoch, + representative, + response_epoch, + ResolveAllConsentDecision::Decline, + )?; + let WaitingFor::Priority { player } = waiting_for else { + unreachable!("declined Resolve All consent must restore Priority"); + }; + // `pass_priority_once_with_pipeline` derives the semantic priority seat + // from this state. Install the captured Priority window before reusing the + // normal SetAutoPass path, rather than passing from the consent prompt. + state.waiting_for = WaitingFor::Priority { player }; + install_until_stack_empty_auto_pass_and_pass_priority(state, player, events) +} + fn respond_resolve_all_consent( state: &mut GameState, epoch: u64, @@ -8174,6 +8274,24 @@ fn apply_action( (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: ResolveAllConsentDecision::Decline, + }, + ) => { + return decline_resolve_all_consent_with_auto_pass( + state, + *epoch, + *representative, + response_epoch, + &mut events, + ); + } ( WaitingFor::ResolveAllConsent { epoch, @@ -11863,28 +11981,7 @@ fn apply_action( GameAction::PassParadigmOffer, ) => WaitingFor::Priority { player: *player }, (WaitingFor::Priority { player }, GameAction::SetAutoPass { mode }) => { - if super::precast_copy_shortcut::blocks_pass(state, *player) { - return Err(EngineError::ActionNotAllowed( - "A shortened pre-cast shortcut requires a different meaningful action before passing" - .to_string(), - )); - } - // Convert request to stored mode, capturing engine state as needed. - let stored_mode = match mode { - AutoPassRequest::UntilStackEmpty => AutoPassMode::UntilStackEmpty { - initial_stack_len: state.stack.len(), - }, - AutoPassRequest::UntilTurnBoundary { until } => { - AutoPassMode::UntilTurnBoundary { until } - } - }; - state.auto_pass.insert(*player, stored_mode); - let wf = pass_priority_once_with_pipeline(state, &mut events, None)?; - return Ok(ActionResult { - events, - waiting_for: wf, - log_entries: vec![], - }); + return install_auto_pass_and_pass_priority(state, *player, mode, &mut events); } // CR 701.34a: Proliferate — player selected targets to proliferate. ( @@ -20056,7 +20153,7 @@ mod stage2_injector_tests { // 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(), + "game/engine.rs:13210".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 b2120d4c06..1e124a5945 100644 --- a/crates/engine/src/game/engine_resolve_batch.rs +++ b/crates/engine/src/game/engine_resolve_batch.rs @@ -69,6 +69,7 @@ pub fn resolve_all_ready_prefix( let mut log_entries = Vec::new(); let mut recorded_actions = Vec::new(); let mut items_resolved = 0; + let mut proof_stopped = false; let Some(run) = ready_consent_run(state, requester).cloned() else { if matches!(&state.waiting_for, WaitingFor::ResolveAllReady { .. }) { @@ -104,6 +105,7 @@ pub fn resolve_all_ready_prefix( let stack_before = proof.stack.len(); let Some((boundary, mut actions)) = materialize_one_consented_resolution(&mut proof, &run) else { + proof_stopped = true; break; }; @@ -117,6 +119,7 @@ pub fn resolve_all_ready_prefix( || !stack::priority_checkpoint_is_settled(&proof) || !consent_authorization_matches(&proof, &run) { + proof_stopped = true; break; } @@ -127,10 +130,35 @@ pub fn resolve_all_ready_prefix( *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. + // Authorization is one run only. Once the proved prefix ends, no later + // stack entry inherits this consent. A proof failure is different from a + // requested cap: it only rejects collapsing this sequence, not the + // requester's durable intent to avoid manual priority passes. Continue + // through the ordinary `UntilStackEmpty` engine path in that case. turn_control::invalidate_resolve_all_consent(state); + if proof_stopped { + let mut fallback_events = Vec::new(); + match super::engine::install_until_stack_empty_auto_pass_and_pass_priority( + state, + run.priority_snapshot.waiting_player, + &mut fallback_events, + ) { + Ok(fallback) => { + items_resolved += stack_resolved_count(&fallback.events); + events.extend(fallback.events); + log_entries.extend(fallback.log_entries); + } + Err(_) => { + // A pre-cast shortcut may require a meaningful action before the + // current Priority window can pass. Keep the requester's durable + // preference so the ordinary loop resumes after that action. + super::engine::install_until_stack_empty_auto_pass( + state, + run.priority_snapshot.waiting_player, + ); + } + } + } finalize_display_state(state); interaction::ensure_interaction_authority(state); diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index bf07335011..a4032e41f2 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -10650,6 +10650,14 @@ impl PersistedGameState { // CR 732.2a (FIX-3): drop stale transient loop-detection bookkeeping on load unless the save // sits in an object-growth shortcut window whose pending resolution still consumes it. state.migrate_transient_loop_sequence(); + // `pending_trigger_event_batch` is a construction carrier for the + // corresponding `pending_trigger`. A historical save can retain the + // carrier after its trigger was dropped; it cannot represent live + // mid-resolution work and would make every conservative stack batch + // reject an otherwise valid Priority checkpoint. + if state.pending_trigger.is_none() { + state.pending_trigger_event_batch.clear(); + } // CR 109.5 + CR 611.2a: discard any restriction still carrying the raw // `SourceController` placeholder — a legitimately-captured state never has // one (it is lowered to the activator at creation), so this only sanitizes diff --git a/crates/engine/tests/integration/precast_copy_shortcut.rs b/crates/engine/tests/integration/precast_copy_shortcut.rs index 9d23ba792a..460a64e216 100644 --- a/crates/engine/tests/integration/precast_copy_shortcut.rs +++ b/crates/engine/tests/integration/precast_copy_shortcut.rs @@ -517,6 +517,13 @@ fn precast_shorten_requires_meaningful_divergence_before_manual_or_auto_pass() { }) .expect("shorten at the issued boundary"); + assert!(runner + .act(GameAction::BeginResolveAll { max_resolutions: 7 }) + .is_err()); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::Priority { player } if player == P1 + )); assert!(runner.act(GameAction::PassPriority).is_err()); assert!(runner .act(GameAction::SetAutoPass { diff --git a/crates/engine/tests/integration/resolve_all_consent.rs b/crates/engine/tests/integration/resolve_all_consent.rs index a55d5e5a81..6e9397d88f 100644 --- a/crates/engine/tests/integration/resolve_all_consent.rs +++ b/crates/engine/tests/integration/resolve_all_consent.rs @@ -3,24 +3,35 @@ use engine::ai_support::{candidate_actions, legal_actions_for_viewer}; use engine::game::elimination::eliminate_player; use engine::game::engine::{apply, resolve_all_ready_prefix}; +use engine::game::game_object::AttachTarget; 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::game::zones::create_object; +use engine::types::ability::{ + ControllerRef, CopyRetargetPermission, Effect, ResolvedAbility, TargetFilter, TargetRef, + TypedFilter, +}; use engine::types::actions::{GameAction, ResolveAllConsentDecision}; +use engine::types::card_type::CoreType; +use engine::types::events::GameEvent; use engine::types::format::FormatConfig; -use engine::types::game_state::{GameState, StackEntry, StackEntryKind, WaitingFor}; -use engine::types::identifiers::ObjectId; +use engine::types::game_state::{ + AutoPassMode, GameState, PersistedGameState, StackEntry, StackEntryKind, WaitingFor, +}; +use engine::types::identifiers::{CardId, ObjectId}; use engine::types::interaction::{ InteractionOpportunityResponse, InteractionResponse, InteractionSessionId, InteractionSubmission, }; use engine::types::player::PlayerId; +use engine::types::zones::Zone; const P0: PlayerId = PlayerId(0); const P1: PlayerId = PlayerId(1); const P2: PlayerId = PlayerId(2); +const P3: PlayerId = PlayerId(3); fn begin(state: &mut GameState) -> u64 { apply( @@ -44,6 +55,253 @@ fn begin(state: &mut GameState) -> u64 { } } +fn no_op_entry(id: u64, controller: PlayerId) -> StackEntry { + StackEntry { + id: ObjectId(id), + source_id: ObjectId(id), + controller, + kind: StackEntryKind::ActivatedAbility { + source_id: ObjectId(id), + ability: Box::new(ResolvedAbility::new( + Effect::NoOp, + vec![], + ObjectId(id), + controller, + )), + }, + } +} + +/// Mirrors the live browser failure: P2 has already passed, P0 holds priority, +/// and a fourth seat has been eliminated. The stack item is the same Equip +/// shape (Equipment -> targeted creature) from the captured game, rather than +/// a synthetic spell-only shortcut. +fn browser_partial_priority_equip_state() -> (GameState, ObjectId, ObjectId) { + let mut state = GameState::new(FormatConfig::free_for_all(), 4, 0x0A11_E0A1); + state.active_player = P2; + state.priority_player = P0; + state.waiting_for = WaitingFor::Priority { player: P0 }; + state.priority_pass_count = 1; + state.priority_passes.insert(P2); + state.players[P3.0 as usize].is_eliminated = true; + + let equipment = create_object( + &mut state, + CardId(140), + P2, + "Sigiled Sword of Valeron".to_string(), + Zone::Battlefield, + ); + let creature = create_object( + &mut state, + CardId(289), + P2, + "Cold-Eyed Selkie".to_string(), + Zone::Battlefield, + ); + { + let equipment_object = state + .objects + .get_mut(&equipment) + .expect("fixture Equipment exists"); + equipment_object.card_types.core_types = vec![CoreType::Artifact]; + equipment_object.card_types.subtypes = vec!["Equipment".to_string()]; + equipment_object.base_card_types = equipment_object.card_types.clone(); + } + { + let creature_object = state + .objects + .get_mut(&creature) + .expect("fixture creature exists"); + creature_object.card_types.core_types = vec![CoreType::Creature]; + creature_object.base_card_types = creature_object.card_types.clone(); + } + state.stack.push_back(StackEntry { + id: ObjectId(461), + source_id: equipment, + controller: P2, + kind: StackEntryKind::ActivatedAbility { + source_id: equipment, + ability: Box::new(ResolvedAbility::new( + Effect::Attach { + attachment: TargetFilter::SelfRef, + target: TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::You), + ), + }, + vec![TargetRef::Object(creature)], + equipment, + P2, + )), + }, + }); + (state, equipment, creature) +} + +#[test] +fn browser_partial_priority_equip_grants_then_resolves_at_the_public_batch_seam() { + let (mut state, equipment, creature) = browser_partial_priority_equip_state(); + + apply( + &mut state, + P0, + GameAction::BeginResolveAll { + max_resolutions: 100, + }, + ) + .expect("the human priority holder starts the browser Resolve All flow"); + let epoch = match state.waiting_for { + WaitingFor::ResolveAllConsent { + epoch, + representative: P1, + } => epoch, + ref waiting_for => panic!("P1 should consent first, got {waiting_for:?}"), + }; + apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("the first AI grants consent"); + assert!(matches!( + state.waiting_for, + WaitingFor::ResolveAllConsent { + epoch: next_epoch, + representative: P2, + } if next_epoch == epoch + )); + apply( + &mut state, + P2, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("the final AI grants consent"); + assert!(matches!( + state.waiting_for, + WaitingFor::ResolveAllReady { epoch: ready_epoch } if ready_epoch == epoch + )); + + let result = resolve_all_ready_prefix(&mut state, P2); + + assert_eq!( + result.items_resolved, 1, + "a granted browser Resolve All must not return to manual priority with the Equip still on the stack" + ); + assert!(state.stack.is_empty()); + assert_eq!( + state.objects[&equipment].attached_to, + Some(AttachTarget::Object(creature)), + "the resolved Equip attaches to its already-selected creature target" + ); +} + +#[test] +fn browser_partial_priority_equip_keeps_the_requesters_no_manual_resolution_intent_when_a_pending_event_blocks_batch_proof( +) { + let (mut state, equipment, creature) = browser_partial_priority_equip_state(); + // This is the latent combat-damage event carrier present in the live game. + // It makes the proof checkpoint intentionally fail closed, but it must not + // erase the human request to continue through ordinary engine auto-pass. + state.pending_trigger_event_batch = vec![GameEvent::DamageDealt { + source_id: ObjectId(398), + target: TargetRef::Player(P0), + amount: 4, + is_combat: true, + excess: 0, + }]; + + apply( + &mut state, + P0, + GameAction::BeginResolveAll { + max_resolutions: 100, + }, + ) + .expect("the human priority holder starts Resolve All"); + let epoch = match state.waiting_for { + WaitingFor::ResolveAllConsent { + epoch, + representative: P1, + } => epoch, + ref waiting_for => panic!("P1 should consent first, got {waiting_for:?}"), + }; + for representative in [P1, P2] { + apply( + &mut state, + representative, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("each AI representative grants the live Resolve All prompt"); + } + assert!(matches!( + state.waiting_for, + WaitingFor::ResolveAllReady { epoch: ready_epoch } if ready_epoch == epoch + )); + + let result = resolve_all_ready_prefix(&mut state, P2); + + assert_eq!( + result.items_resolved, 0, + "the conservative batch proof must not consume an unsettled checkpoint" + ); + assert!(matches!( + state.waiting_for, + WaitingFor::Priority { player: P1 }, + )); + assert_eq!( + state.auto_pass.get(&P0), + Some(&AutoPassMode::UntilStackEmpty { + initial_stack_len: 1, + }), + "the failed proof becomes the requester's ordinary standing auto-pass" + ); + apply(&mut state, P1, GameAction::PassPriority) + .expect("the next AI's ordinary pass continues the requester's auto-pass"); + assert!( + state.stack.is_empty(), + "the Equip resolves without another manual P0 action" + ); + assert_eq!( + state.objects[&equipment].attached_to, + Some(AttachTarget::Object(creature)), + ); + assert!(state.auto_pass.is_empty()); +} + +#[test] +fn restored_mid_stack_priority_discards_an_orphaned_trigger_event_carrier() { + let (mut state, _, _) = browser_partial_priority_equip_state(); + state.pending_trigger_event_batch = vec![GameEvent::DamageDealt { + source_id: ObjectId(398), + target: TargetRef::Player(P0), + amount: 4, + is_combat: true, + excess: 0, + }]; + assert!(state.pending_trigger.is_none()); + + let persisted = PersistedGameState::capture(state); + let encoded = serde_json::to_string(&persisted).expect("mid-stack state serializes"); + let persisted: PersistedGameState = + serde_json::from_str(&encoded).expect("mid-stack state deserializes"); + let restored = persisted.into_game_state(); + + assert!( + restored.pending_trigger_event_batch.is_empty(), + "a saved orphan carrier is not active stack work and must not poison Resolve All after reload" + ); + assert!(restored.pending_trigger.is_none()); +} + #[test] fn consent_queue_reaches_inert_ready_only_after_every_representative_grants() { let mut state = GameState::new_two_player(42); @@ -74,10 +332,9 @@ fn consent_queue_reaches_inert_ready_only_after_every_representative_grants() { } #[test] -fn stale_epoch_and_decline_restore_the_exact_priority_snapshot() { +fn stale_epoch_and_decline_continue_through_the_requesters_engine_auto_pass() { let mut state = GameState::new_two_player(43); - state.priority_pass_count = 3; - state.priority_passes.insert(P0); + state.stack.push_back(no_op_entry(1, P0)); let epoch = begin(&mut state); assert!(apply( @@ -89,7 +346,7 @@ fn stale_epoch_and_decline_restore_the_exact_priority_snapshot() { }, ) .is_err()); - apply( + let decline = apply( &mut state, P1, GameAction::RespondResolveAllConsent { @@ -99,10 +356,15 @@ fn stale_epoch_and_decline_restore_the_exact_priority_snapshot() { ) .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.stack.is_empty(), + "the two-player auto-pass resolves the stack immediately" + ); + assert!(decline.events.iter().any(|event| matches!( + event, + engine::types::events::GameEvent::EffectResolved { .. } + ))); + assert!(state.auto_pass.is_empty()); assert!(state.resolve_all_consent_run.is_none()); assert!(apply( &mut state, @@ -115,6 +377,106 @@ fn stale_epoch_and_decline_restore_the_exact_priority_snapshot() { .is_err()); } +#[test] +fn persisted_pending_consent_keeps_the_initiators_auto_pass_fallback() { + let mut state = GameState::new_two_player(430); + state.stack.push_back(no_op_entry(1, P0)); + let epoch = begin(&mut state); + + let persisted = PersistedGameState::capture(state); + let encoded = serde_json::to_string(&persisted).expect("pending consent serializes"); + let persisted: PersistedGameState = + serde_json::from_str(&encoded).expect("pending consent deserializes"); + let mut restored = persisted.into_game_state(); + assert!(matches!( + restored.waiting_for, + WaitingFor::ResolveAllConsent { epoch: restored_epoch, representative } if restored_epoch == epoch && representative == P1 + )); + + let decline = apply( + &mut restored, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Decline, + }, + ) + .expect("restored responder may decline"); + assert!( + decline.events.iter().any(|event| matches!( + event, + engine::types::events::GameEvent::EffectResolved { .. } + )), + "declining after a save resumes the normal auto-pass pipeline" + ); + assert!(restored.stack.is_empty()); + assert!(restored.auto_pass.is_empty()); +} + +#[test] +fn restored_mid_stack_priority_can_start_a_new_resolve_all_consent_run() { + let mut state = GameState::new_two_player(431); + state.stack.push_back(no_op_entry(1, P0)); + let persisted = PersistedGameState::capture(state); + let encoded = serde_json::to_string(&persisted).expect("mid-stack priority serializes"); + let persisted: PersistedGameState = + serde_json::from_str(&encoded).expect("mid-stack priority deserializes"); + let mut restored = persisted.into_game_state(); + + let epoch = begin(&mut restored); + assert!(matches!( + restored.waiting_for, + WaitingFor::ResolveAllConsent { epoch: restored_epoch, representative } if restored_epoch == epoch && representative == P1 + )); +} + +#[test] +fn decline_auto_pass_is_owned_by_the_semantic_priority_seat_under_turn_control() { + let mut state = GameState::new_two_player(432); + state.active_player = P0; + state.turn_decision_controller = Some(P1); + state.priority_player = P1; + state.waiting_for = WaitingFor::Priority { player: P0 }; + state.stack.push_back(no_op_entry(1, P0)); + + apply( + &mut state, + P1, + GameAction::BeginResolveAll { max_resolutions: 7 }, + ) + .expect("the controller may begin Resolve All for the controlled priority seat"); + let epoch = match state.waiting_for { + WaitingFor::ResolveAllConsent { + epoch, + representative, + } => { + assert_eq!(representative, P1); + epoch + } + ref waiting_for => panic!("expected queued consent, got {waiting_for:?}"), + }; + + let decline = apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Decline, + }, + ) + .expect("the responder may decline"); + + assert!( + state.stack.is_empty(), + "the semantic priority seat P0 was passed immediately; using submitter P1 would leave the stack intact" + ); + assert!(decline.events.iter().any(|event| matches!( + event, + engine::types::events::GameEvent::EffectResolved { .. } + ))); + assert!(state.auto_pass.is_empty()); +} + #[test] fn eliminating_a_consent_representative_drops_the_run_and_restores_living_priority() { let mut state = GameState::new(FormatConfig::free_for_all(), 3, 44); @@ -142,6 +504,7 @@ fn eliminating_a_consent_representative_drops_the_run_and_restores_living_priori assert_eq!(state.priority_player, P0); assert_eq!(state.priority_pass_count, 0); assert!(state.priority_passes.is_empty()); + assert!(state.auto_pass.is_empty()); assert!(!state.players[P2.0 as usize].is_eliminated); } @@ -273,6 +636,7 @@ fn granted_representative_can_revoke_off_queue_and_private_run_is_not_visible() .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()); + assert!(state.auto_pass.is_empty()); } #[test] @@ -446,4 +810,25 @@ fn ready_consent_collapses_the_safe_prefix_before_a_stack_growing_resolution() { ); assert_eq!(state.stack.len(), 1, "the stack-growing item remains live"); assert!(matches!(state.waiting_for, WaitingFor::Priority { .. })); + assert_eq!( + state.auto_pass.get(&P0), + Some(&AutoPassMode::UntilStackEmpty { + initial_stack_len: 1, + }), + "a partial proof keeps the original requester as the durable auto-pass owner" + ); + + let WaitingFor::Priority { player } = state.waiting_for else { + panic!("the unproved entry must return to the ordinary priority pipeline"); + }; + assert_ne!( + player, P0, + "the requester has already passed; another seat now owns the live priority window" + ); + apply(&mut state, player, GameAction::PassPriority) + .expect("the ordinary priority action resumes the requester's stored auto-pass"); + assert!( + state.auto_pass.is_empty(), + "a stack-growing resolution interrupts UntilStackEmpty instead of inheriting stale consent" + ); } diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index d5395c014f..00d0359ff5 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -437,6 +437,13 @@ fn choose_action_with_session_inner( } } + // Resolve All is a user-proposed shortcut, not a tactical game decision. + // Answer its finite engine-issued consent domain directly so tactical + // scoring cannot randomly select Decline when Grant is available. + if matches!(state.waiting_for, WaitingFor::ResolveAllConsent { .. }) { + return direct(fallback_action(state, config, &contract).and_then(&bind_specialist)); + } + if let Some(action) = fast_priority_action(state, ai_player, config, session) .filter(|action| durable_pact_routes || !is_certified_pact_root(state, ai_player, action)) { @@ -1232,14 +1239,14 @@ 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. + // A local player explicitly proposed this shortcut. AI seats accept the + // engine-issued consent so the authoritative Ready consumer can + // materialize the already-agreed priority cycle. WaitingFor::ResolveAllConsent { .. } => issued(|action| { matches!( action, GameAction::RespondResolveAllConsent { - decision: engine::types::actions::ResolveAllConsentDecision::Decline, + decision: engine::types::actions::ResolveAllConsentDecision::Grant, .. } ) @@ -3007,6 +3014,15 @@ fn score_candidates_core( session: &Arc, deadline_override: Option, ) -> Vec<(GameAction, f64)> { + // The scored/parallel-worker path bypasses `choose_action_with_session_inner`. + // Preserve Resolve All's user-proposed shortcut semantics here as well: Grant + // is chosen from the engine-issued consent domain without tactical scoring. + if matches!(state.waiting_for, WaitingFor::ResolveAllConsent { .. }) { + let contract = AiDecisionContract::issue(state, ai_player); + return fallback_action(state, config, &contract) + .map(|action| vec![(action, 1.0)]) + .unwrap_or_default(); + } if matches!( state.waiting_for, WaitingFor::ChooseManaColor { @@ -5321,6 +5337,102 @@ mod tests { ); } + #[test] + fn resolve_all_consent_fallback_accepts_the_user_proposed_shortcut() { + let mut state = make_state(); + engine::game::engine::apply( + &mut state, + P0, + GameAction::BeginResolveAll { max_resolutions: 5 }, + ) + .expect("the priority holder may propose Resolve All"); + + let epoch = match state.waiting_for { + engine::types::game_state::WaitingFor::ResolveAllConsent { epoch, .. } => epoch, + ref waiting_for => panic!("expected Resolve All consent, got {waiting_for:?}"), + }; + + assert_eq!( + fallback_action_default(&state), + Some(GameAction::RespondResolveAllConsent { + epoch, + decision: engine::types::actions::ResolveAllConsentDecision::Grant, + }), + "an AI responder must accept the engine-issued shortcut proposal so it can reach Ready" + ); + } + + #[test] + fn choose_action_accepts_resolve_all_consent_before_tactical_scoring() { + let mut state = make_state(); + engine::game::engine::apply( + &mut state, + P0, + GameAction::BeginResolveAll { max_resolutions: 5 }, + ) + .expect("the priority holder may propose Resolve All"); + + let epoch = match state.waiting_for { + engine::types::game_state::WaitingFor::ResolveAllConsent { epoch, .. } => epoch, + ref waiting_for => panic!("expected Resolve All consent, got {waiting_for:?}"), + }; + assert!( + AiDecisionContract::issue(&state, PlayerId(1)) + .candidates + .len() + > 1, + "Resolve All consent must issue both Grant and Decline before testing AI preference" + ); + let action = choose_action( + &state, + PlayerId(1), + &create_config(AiDifficulty::Medium, Platform::Native), + &mut SmallRng::seed_from_u64(7), + ); + + assert_eq!( + action, + Some(GameAction::RespondResolveAllConsent { + epoch, + decision: engine::types::actions::ResolveAllConsentDecision::Grant, + }), + "normal AI selection must not route this user-proposed shortcut through tactical scoring" + ); + } + + #[test] + fn scored_candidates_accept_resolve_all_consent_before_tactical_scoring() { + let mut state = make_state(); + engine::game::engine::apply( + &mut state, + P0, + GameAction::BeginResolveAll { max_resolutions: 5 }, + ) + .expect("the priority holder may propose Resolve All"); + + let epoch = match state.waiting_for { + engine::types::game_state::WaitingFor::ResolveAllConsent { epoch, .. } => epoch, + ref waiting_for => panic!("expected Resolve All consent, got {waiting_for:?}"), + }; + let scored = score_candidates( + &state, + PlayerId(1), + &create_config(AiDifficulty::Medium, Platform::Native), + ); + + assert_eq!( + scored, + vec![( + GameAction::RespondResolveAllConsent { + epoch, + decision: engine::types::actions::ResolveAllConsentDecision::Grant, + }, + 1.0, + )], + "the scored/parallel-worker path must not tactically prefer Decline" + ); + } + /// CR 701.42b: the public search path prefers the physical canonical meld /// pair over an earlier live-name impostor that would exile both selected /// objects without producing the result permanent. This proves the choice