diff --git a/crates/engine/src/game/engine_auto_pass_decision_tests.rs b/crates/engine/src/game/engine_auto_pass_decision_tests.rs index 46bdfb9cb9..cd2dba83d8 100644 --- a/crates/engine/src/game/engine_auto_pass_decision_tests.rs +++ b/crates/engine/src/game/engine_auto_pass_decision_tests.rs @@ -253,6 +253,57 @@ fn until_end_of_turn_finishes_at_configured_phase_stop() { assert!(is_finish(&priority_auto_pass_decision(&state, PlayerId(0)))); } +/// CR 507.2 + CR 117.3c: A beginning-of-combat phase stop interrupts an +/// `UntilTurnBoundary` shortcut at a usable priority window. The non-mana +/// activation proves this is a real priority window, not merely a rendered +/// phase marker. +#[test] +fn begin_combat_phase_stop_interrupts_auto_pass_with_usable_priority() { + let mut state = priority_state(); + let artifact = add_non_mana_activated_artifact(&mut state, PlayerId(0)); + state.auto_pass.insert( + PlayerId(0), + AutoPassMode::UntilTurnBoundary { + until: TurnBoundary::EndOfCurrentTurn, + }, + ); + state.phase_stops.insert( + PlayerId(0), + vec![stop(Phase::BeginCombat, PhaseStopScope::OwnTurn)], + ); + + apply_as_current(&mut state, GameAction::PassPriority).unwrap(); + let at_begin_combat = apply_as_current(&mut state, GameAction::PassPriority).unwrap(); + + assert_eq!(state.phase, Phase::BeginCombat); + assert!(matches!( + at_begin_combat.waiting_for, + WaitingFor::Priority { + player: PlayerId(0) + } + )); + assert!( + !state.auto_pass.contains_key(&PlayerId(0)), + "the explicit stop must interrupt the standing auto-pass session" + ); + + let activated = apply_as_current( + &mut state, + GameAction::ActivateAbility { + source_id: artifact, + ability_index: 0, + }, + ) + .expect("a non-mana activated ability is legal in the stopped BeginCombat window"); + assert_eq!(state.stack.len(), 1); + assert!(matches!( + activated.waiting_for, + WaitingFor::Priority { + player: PlayerId(0) + } + )); +} + /// V8: the per-window interrupt logic is boundary-agnostic. A /// `MyNextTurnStart` session must Pass/Finish in exactly the same windows as /// the `EndOfCurrentTurn` sessions above (empty stack → Pass, opponent stack → diff --git a/crates/engine/src/game/engine_phase_trigger_regression_tests.rs b/crates/engine/src/game/engine_phase_trigger_regression_tests.rs index d3dcd65125..5c9385c7ea 100644 --- a/crates/engine/src/game/engine_phase_trigger_regression_tests.rs +++ b/crates/engine/src/game/engine_phase_trigger_regression_tests.rs @@ -17,6 +17,7 @@ use crate::types::format::FormatConfig; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::keywords::Keyword; use crate::types::mana::{ManaColor, ManaCost, ManaType, ManaUnit}; +use crate::types::phase::{PhaseStop, PhaseStopScope}; use crate::types::player::PlayerId; use crate::types::replacements::ReplacementEvent; use crate::types::triggers::TriggerMode; @@ -84,11 +85,11 @@ fn hand_to_battlefield_choice_ability( ) } -/// Verify that combat is skipped when there are no attackers and no triggers. -/// With no BeginCombat triggers and no potential attackers, auto_advance() -/// skips straight to PostCombatMain. +/// CR 507.2 + CR 508.1a: Even with no attackers and no beginning-of-combat +/// triggers, beginning of combat has an active-player priority window before +/// the active player makes the (here forced-empty) attacker declaration. #[test] -fn combat_skipped_when_no_attackers_no_triggers() { +fn begin_combat_window_precedes_forced_empty_attacker_declaration() { let mut state = new_game(42); state.turn_number = 2; state.phase = Phase::PreCombatMain; @@ -98,24 +99,23 @@ fn combat_skipped_when_no_attackers_no_triggers() { player: PlayerId(0), }; - // Create a 0/1 creature with no triggers — can't attack, no combat triggers. - let creature_id = create_object( - &mut state, - CardId(200), + // Stops make both windows observable; otherwise the forced empty + // declaration is deliberately auto-submitted by the normal auto-pass loop. + state.phase_stops.insert( PlayerId(0), - "Wall".to_string(), - Zone::Battlefield, + vec![ + PhaseStop { + phase: Phase::BeginCombat, + scope: PhaseStopScope::OwnTurn, + }, + PhaseStop { + phase: Phase::DeclareAttackers, + scope: PhaseStopScope::OwnTurn, + }, + ], ); - { - let obj = state.objects.get_mut(&creature_id).unwrap(); - obj.card_types.core_types.push(CoreType::Creature); - obj.power = Some(0); - obj.toughness = Some(1); - } - // Pass priority twice (P0 passes, then P1 passes) with empty stack. - // This advances from PreCombatMain → BeginCombat → no triggers, no - // attackers → skip to PostCombatMain. + // Passing the precombat-main priority window reaches beginning of combat. let result1 = apply_as_current(&mut state, GameAction::PassPriority).unwrap(); assert!(matches!( result1.waiting_for, @@ -126,18 +126,85 @@ fn combat_skipped_when_no_attackers_no_triggers() { let result2 = apply_as_current(&mut state, GameAction::PassPriority).unwrap(); - // We should now be at PostCombatMain with empty stack. - assert_eq!(state.phase, Phase::PostCombatMain); + assert_eq!(state.phase, Phase::BeginCombat); + assert!(matches!( + result2.waiting_for, + WaitingFor::Priority { + player: PlayerId(0) + } + )); assert!( state.stack.is_empty(), - "Stack should be empty — no triggers exist. Stack: {:?}", + "the no-trigger window must not fabricate stack work: {:?}", state.stack ); + assert!(state.pending_trigger.is_none()); + + // Both players then pass the mandated beginning-of-combat window. The + // downstream DeclareAttackers prompt remains visible despite its forced + // empty declaration because the explicit stop overrides auto-submit. + apply_as_current(&mut state, GameAction::PassPriority).unwrap(); + let result4 = apply_as_current(&mut state, GameAction::PassPriority).unwrap(); + assert_eq!(state.phase, Phase::DeclareAttackers); + assert!(matches!( + result4.waiting_for, + WaitingFor::DeclareAttackers { + player: PlayerId(0), + ref valid_attacker_ids, + .. + } if valid_attacker_ids.is_empty() + )); + + // Submit the only legal declaration through the production action path. + // CR 508.8 then skips only DeclareBlockers and CombatDamage, leaving combat + // and arriving at PostCombatMain normally. + let empty_declaration = apply_as_current( + &mut state, + GameAction::DeclareAttackers { + attacks: vec![], + bands: vec![], + }, + ) + .expect("the empty declaration offered by the engine must be submitable"); + assert_eq!(state.phase, Phase::PostCombatMain); assert!( - state.pending_trigger.is_none(), - "No pending trigger should exist" + state.combat.is_none(), + "combat ends after the empty declaration" ); - assert!(matches!(result2.waiting_for, WaitingFor::Priority { .. })); + assert!(matches!( + empty_declaration.waiting_for, + WaitingFor::Priority { + player: PlayerId(0) + } + )); +} + +/// CR 500.8 + CR 507.2: An inserted combat phase gets the same +/// beginning-of-combat priority window as the natural combat phase. +#[test] +fn inserted_begin_combat_gets_priority_window() { + let mut state = setup_game_at_main_phase(); + state.phase = Phase::EndCombat; + state + .extra_phases + .push(crate::types::game_state::ExtraPhase { + anchor: Phase::EndCombat, + phase: Phase::BeginCombat, + attacker_restriction: None, + attacker_restriction_source: None, + }); + + let mut events = Vec::new(); + let waiting_for = crate::game::turns::auto_advance(&mut state, &mut events); + + assert_eq!(state.phase, Phase::BeginCombat); + assert!(state.combat.is_some()); + assert!(matches!( + waiting_for, + WaitingFor::Priority { + player: PlayerId(0) + } + )); } /// CR 503.1a: Upkeep triggers fire when the upkeep step begins. @@ -246,6 +313,61 @@ fn begin_combat_trigger_fires_with_attackers() { ); } +/// CR 603.3b + CR 507.2: A phase-trigger ordering prompt is a stronger result +/// than the ordinary beginning-of-combat priority window and must propagate +/// unchanged through the phase interpreter. +#[test] +fn begin_combat_propagates_generic_phase_trigger_ordering_prompt() { + let mut state = setup_game_at_main_phase(); + for (card_id, amount) in [(201_u64, 1), (202, 2)] { + let source_id = create_object( + &mut state, + CardId(card_id), + PlayerId(0), + format!("Combat trigger {card_id}"), + Zone::Battlefield, + ); + state + .objects + .get_mut(&source_id) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + let source = state.objects.get_mut(&source_id).unwrap(); + source.power = Some(1); + source.toughness = Some(1); + source.trigger_definitions.push( + TriggerDefinition::new(TriggerMode::Phase) + .phase(Phase::BeginCombat) + .execute(AbilityDefinition::new( + AbilityKind::Activated, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: amount }, + player: TargetFilter::Controller, + }, + )) + .trigger_zones(vec![Zone::Battlefield]), + ); + } + + apply_as_current(&mut state, GameAction::PassPriority).unwrap(); + let result = apply_as_current(&mut state, GameAction::PassPriority).unwrap(); + + assert_eq!(state.phase, Phase::BeginCombat); + assert!( + state.combat.is_some(), + "combat state is available to the prompt" + ); + assert!(matches!( + result.waiting_for, + WaitingFor::OrderTriggers { + player: PlayerId(0), + ref triggers, + } if triggers.len() == 2 + )); +} + /// CR 507.1: BeginCombat triggers fire even without potential attackers. #[test] fn begin_combat_trigger_fires_without_attackers() { diff --git a/crates/engine/src/game/engine_tests.rs b/crates/engine/src/game/engine_tests.rs index ad5ea695f6..4d69116a6d 100644 --- a/crates/engine/src/game/engine_tests.rs +++ b/crates/engine/src/game/engine_tests.rs @@ -3966,9 +3966,14 @@ fn integration_full_turn_cycle() { } )); - // Pass priority from player 1 (both passed, stack empty -> advance) + // Pass priority from player 1 (both passed, stack empty -> BeginCombat). let _result = apply_as_current(&mut state, GameAction::PassPriority).unwrap(); - // Should skip combat phases and land at PostCombatMain + assert_eq!(state.phase, Phase::BeginCombat); + + // Beginning of combat has its own priority window. With no attackers, the + // subsequent forced empty declaration skips only blockers and damage. + apply_as_current(&mut state, GameAction::PassPriority).unwrap(); + apply_as_current(&mut state, GameAction::PassPriority).unwrap(); assert_eq!(state.phase, Phase::PostCombatMain); // Pass through post-combat main @@ -5915,7 +5920,12 @@ fn full_turn_integration_with_mulligan() { // Pass priority through the rest of the turn // PreCombatMain: P0 passes apply_as_current(&mut state, GameAction::PassPriority).unwrap(); - // PreCombatMain: P1 passes -> advances to PostCombatMain + // PreCombatMain: P1 passes -> BeginCombat priority. + apply_as_current(&mut state, GameAction::PassPriority).unwrap(); + assert_eq!(state.phase, Phase::BeginCombat); + // BeginCombat: both pass. No attackers are declared, so only Declare + // Blockers and Combat Damage are skipped before PostCombatMain. + apply_as_current(&mut state, GameAction::PassPriority).unwrap(); apply_as_current(&mut state, GameAction::PassPriority).unwrap(); assert_eq!(state.phase, Phase::PostCombatMain); diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index 13b5708793..a1b10dface 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -1310,6 +1310,29 @@ impl GameRunner { /// Execute a single action. Returns the `ActionResult` from the engine. pub fn act(&mut self, action: GameAction) -> Result { + // Test scenarios historically modelled the transition out of precombat + // main as directly reaching DeclareAttackers. CR 507.2 now exposes the + // intervening priority window, so preserve that test-driver shorthand + // by passing the window only when a scenario submits its declaration. + // Live callers use `engine::apply` and must act during that window. + if matches!(&action, GameAction::DeclareAttackers { .. }) + && self.state.phase == Phase::BeginCombat + && matches!(self.state.waiting_for, WaitingFor::Priority { .. }) + && self.state.stack.is_empty() + { + let mut pass_events = Vec::new(); + while self.state.phase == Phase::BeginCombat + && matches!(self.state.waiting_for, WaitingFor::Priority { .. }) + && self.state.stack.is_empty() + { + pass_events + .extend(apply_as_current(&mut self.state, GameAction::PassPriority)?.events); + } + let mut result = apply_as_current(&mut self.state, action)?; + pass_events.append(&mut result.events); + result.events = pass_events; + return Ok(result); + } apply_as_current(&mut self.state, action) } @@ -1456,8 +1479,8 @@ impl GameRunner { self.advance_to_phase(Phase::Upkeep); } - /// Declare attackers (CR 508.1). Must be called when the engine is at - /// `WaitingFor::DeclareAttackers` (use [`GameRunner::advance_to_combat`]). + /// Declare attackers (CR 508.1). Accepts the scenario driver's established + /// shorthand for passing an empty beginning-of-combat priority window. /// Each entry is `(attacker, defender)` where `defender` is an /// [`AttackTarget`](crate::game::combat::AttackTarget) — a player, /// planeswalker, or battle (CR 508.1b). @@ -1465,13 +1488,10 @@ impl GameRunner { &mut self, attacks: &[(ObjectId, crate::game::combat::AttackTarget)], ) -> Result { - apply_as_current( - &mut self.state, - GameAction::DeclareAttackers { - attacks: attacks.to_vec(), - bands: vec![], - }, - ) + self.act(GameAction::DeclareAttackers { + attacks: attacks.to_vec(), + bands: vec![], + }) } /// CR 702.103b: put `attachment` onto `host` in its BESTOWED AURA FORM — diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index c9faf481dd..9d3117d56f 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -2609,9 +2609,7 @@ fn add_lore_counters_to_sagas(state: &mut GameState, events: &mut Vec /// /// Returns `(fired, ordering_prompt)`: /// * `fired` is `true` if any triggers were placed on the stack, are pending -/// target selection, or are awaiting CR 603.3b ordering. The combat arms -/// (BeginCombat / EndCombat) use this to decide whether to set up / tear down -/// combat and grant a priority window. +/// target selection, or are awaiting CR 603.3b ordering. /// * `ordering_prompt` is `Some(...)` when the phase must pause before priority: /// - `WaitingFor::OrderTriggers { .. }` when 2+ simultaneous triggers controlled /// by the same player fired and that player must order them (CR 603.3b), or @@ -2858,37 +2856,25 @@ fn auto_advance_once(state: &mut GameState, events: &mut Vec) -> Auto }); } Phase::BeginCombat => { - // CR 507.1: "At the beginning of combat" triggers fire here. - // Process triggers regardless of attackers — CR 507.1 says the step - // happens unconditionally; trigger conditions (e.g., ControlCount) - // are checked by the trigger system, not by skipping the step. + // CR 507.1 + CR 507.2: The beginning-of-combat step always occurs, then + // the active player receives priority. Set combat state before + // processing triggers so it is available to every resulting prompt + // and to abilities that later resolve from that trigger batch. + state.combat = Some(crate::game::combat::CombatState::default()); let event_snapshot = events.clone(); - let (triggers_fired, ordering_prompt) = - process_phase_triggers(state, &event_snapshot, events); - if triggers_fired { - state.combat = Some(crate::game::combat::CombatState::default()); - // CR 603.3b: surface a same-controller ordering prompt before - // priority; combat state is set first so it exists when the - // ordered begin-combat triggers later resolve. - if let Some(prompt) = ordering_prompt { - return AutoAdvanceStep::waiting(prompt); - } - return AutoAdvanceStep::waiting(WaitingFor::Priority { - player: state.active_player, - }); - } - if combat::has_potential_attackers(state) { - state.combat = Some(crate::game::combat::CombatState::default()); - let _ = advance_phase_once(state, events); - // Continue to DeclareAttackers - } else { - // CR 508.8: No attackers possible and no begin-combat - // triggers — skip declare attackers through end of combat. - // Don't return: continue the loop so the PostCombatMain - // match arm runs process_phase_triggers (survival, etc.). - state.combat = None; - enter_phase(state, Phase::PostCombatMain, events); + let (_, ordering_prompt) = process_phase_triggers(state, &event_snapshot, events); + // CR 603.3b: preserve a same-controller ordering prompt before + // priority; combat state already exists when the ordered + // beginning-of-combat triggers later resolve. + if let Some(prompt) = ordering_prompt { + return AutoAdvanceStep::waiting(prompt); } + // CR 507.2 + CR 117.3a: priority belongs semantically to the + // active player. `finish_enter_phase` separately records the + // authorized submitter for controlled-turn windows. + return AutoAdvanceStep::waiting(WaitingFor::Priority { + player: state.active_player, + }); } Phase::DeclareAttackers => { // CR 508.1: Active player declares attackers as a turn-based action. @@ -3040,9 +3026,12 @@ fn auto_advance_once(state: &mut GameState, events: &mut Vec) -> Auto #[cfg(test)] mod tests { use super::*; + use crate::game::engine::apply; use crate::game::zones::create_object; + use crate::types::actions::GameAction; use crate::types::card_type::Supertype; use crate::types::identifiers::{CardId, ObjectId}; + use crate::types::phase::{PhaseStop, PhaseStopScope}; use crate::types::player::PlayerId; use crate::types::zones::Zone; use std::sync::Arc; @@ -7474,15 +7463,46 @@ mod tests { } #[test] - fn auto_advance_skips_combat_phases() { + fn empty_combat_reaches_post_combat_main_after_priority_and_declaration() { let mut state = setup(); state.phase = Phase::BeginCombat; + state.phase_stops.insert( + PlayerId(0), + vec![PhaseStop { + phase: Phase::DeclareAttackers, + scope: PhaseStopScope::OwnTurn, + }], + ); let mut events = Vec::new(); let waiting = auto_advance(&mut state, &mut events); - assert_eq!(state.phase, Phase::PostCombatMain); + assert_eq!(state.phase, Phase::BeginCombat); assert!(matches!(waiting, WaitingFor::Priority { .. })); + + state.waiting_for = waiting; + for _ in 0..4 { + if matches!(state.waiting_for, WaitingFor::DeclareAttackers { .. }) { + break; + } + let actor = state.priority_player; + apply(&mut state, actor, GameAction::PassPriority).unwrap(); + } + assert!(matches!( + state.waiting_for, + WaitingFor::DeclareAttackers { .. } + )); + apply( + &mut state, + PlayerId(0), + GameAction::DeclareAttackers { + attacks: vec![], + bands: vec![], + }, + ) + .unwrap(); + + assert_eq!(state.phase, Phase::PostCombatMain); } #[test] @@ -8864,6 +8884,15 @@ mod tests { enter_phase(&mut state, Phase::BeginCombat, &mut events); assert_eq!(turn_control::turn_decision_maker(&state), controller); + assert_eq!( + state.priority_player, controller, + "the controlled active player's authorized submitter holds priority" + ); + let waiting_for = auto_advance(&mut state, &mut events); + assert!(matches!( + waiting_for, + WaitingFor::Priority { player } if player == owner + )); assert_eq!( turn_control::authorized_submitter_for_player(&state, owner), controller, diff --git a/crates/engine/tests/integration/engine_invariants.rs b/crates/engine/tests/integration/engine_invariants.rs index 99fc1c80a6..8f0b1c21d3 100644 --- a/crates/engine/tests/integration/engine_invariants.rs +++ b/crates/engine/tests/integration/engine_invariants.rs @@ -35,6 +35,7 @@ fn declare_attackers_state() -> GameState { scenario.add_creature(P0, "Attacker", 3, 3); let mut runner = scenario.build(); runner.pass_both_players(); + runner.pass_both_players(); assert_matches!( runner.state().waiting_for, WaitingFor::DeclareAttackers { .. } @@ -49,6 +50,7 @@ fn declare_blockers_state() -> GameState { scenario.add_creature(P1, "Blocker", 2, 2); let mut runner = scenario.build(); runner.pass_both_players(); + runner.pass_both_players(); runner .act(GameAction::DeclareAttackers { attacks: vec![(attacker, AttackTarget::Player(P1))], @@ -75,6 +77,7 @@ fn assign_combat_damage_state() -> GameState { let blocker_b = scenario.add_creature(P1, "Blocker B", 2, 2).id(); let mut runner = scenario.build(); runner.pass_both_players(); + runner.pass_both_players(); runner .act(GameAction::DeclareAttackers { attacks: vec![(attacker, AttackTarget::Player(P1))], diff --git a/crates/engine/tests/integration/export_runtime_canaries.rs b/crates/engine/tests/integration/export_runtime_canaries.rs index 7dad2393d0..5624c368d4 100644 --- a/crates/engine/tests/integration/export_runtime_canaries.rs +++ b/crates/engine/tests/integration/export_runtime_canaries.rs @@ -82,6 +82,7 @@ fn export_backed_grizzly_bears_combat_canary() { let bears_id = scenario.add_real_card(P0, "Grizzly Bears", Zone::Battlefield, db); let mut runner = scenario.build(); + runner.pass_both_players(); runner.pass_both_players(); assert_matches!( runner.state().waiting_for, diff --git a/crates/engine/tests/integration/issue_3279_song_of_dryads.rs b/crates/engine/tests/integration/issue_3279_song_of_dryads.rs index 184c3c376b..6c2cf27f64 100644 --- a/crates/engine/tests/integration/issue_3279_song_of_dryads.rs +++ b/crates/engine/tests/integration/issue_3279_song_of_dryads.rs @@ -8,7 +8,7 @@ use engine::game::mana_sources::activatable_land_mana_options; use engine::game::scenario::{GameScenario, P0}; use engine::types::card_type::CoreType; use engine::types::game_state::WaitingFor; -use engine::types::mana::ManaType; +use engine::types::mana::{ManaColor, ManaType}; use engine::types::phase::Phase; use engine::types::triggers::TriggerMode; @@ -18,6 +18,18 @@ const SONG_ORACLE: &str = "Enchant permanent\nEnchanted permanent is a colorless #[test] fn issue_3279_song_of_dryads_strips_enchanted_permanent_abilities() { + let mut control = GameScenario::new(); + control.at_phase(Phase::PreCombatMain); + control.add_creature_from_oracle(P0, "Obuun, Mul Daya Ancestor", 3, 3, OBUUN_ORACLE); + control.add_basic_land(P0, ManaColor::Green); + let mut control_runner = control.build(); + control_runner.pass_both_players(); + control_runner.pass_both_players(); + assert!(matches!( + control_runner.state().waiting_for, + WaitingFor::TriggerTargetSelection { .. } | WaitingFor::OrderTriggers { .. } + )); + let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); @@ -94,6 +106,7 @@ fn issue_3279_song_of_dryads_strips_enchanted_permanent_abilities() { "Forest land must tap for {{G}}, got {mana_options:?}" ); + runner.pass_both_players(); runner.pass_both_players(); assert_eq!(runner.state().phase, Phase::PostCombatMain); assert!( diff --git a/crates/engine/tests/integration/json_smoke_test.rs b/crates/engine/tests/integration/json_smoke_test.rs index a561f103ee..65c9c00e29 100644 --- a/crates/engine/tests/integration/json_smoke_test.rs +++ b/crates/engine/tests/integration/json_smoke_test.rs @@ -217,7 +217,8 @@ fn test_smoke_game_combat_damage() { assert_eq!(runner.life(P1), 20); - // Advance from PreCombatMain to DeclareAttackers + // Pass the precombat-main and beginning-of-combat priority windows. + runner.pass_both_players(); runner.pass_both_players(); assert!( diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index f5b26b7576..fec9c46a1d 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -9534,7 +9534,7 @@ fn bloodloop_state(players: u8) -> GameState { /// commit CHANGED; pinning it is what stops a later change from moving it silently. #[test] fn bloodloop_mandatory_draw_cascade_offers_at_2p_3p_and_4p() { - for (players, expected_beat, expected_turn) in [(2u8, 29usize, 4u32), (3, 58, 5), (4, 97, 6)] { + for (players, expected_beat, expected_turn) in [(2u8, 33usize, 4u32), (3, 67, 5), (4, 113, 6)] { let mut state = bloodloop_state(players); let beat = drive_to_bounded_offer(&mut state, 400).unwrap_or_else(|| { panic!(