From f7a327d6ccdee5d707f1f1e4f5c6520b0ff573df Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 09:24:52 -0700 Subject: [PATCH 01/10] fix(ai): validate restored and target continuations --- crates/engine-wasm/src/lib.rs | 238 ++++++++++++++++++++- crates/engine/src/ai_support/candidates.rs | 48 +++-- crates/engine/src/ai_support/context.rs | 29 ++- crates/engine/src/ai_support/mod.rs | 65 ++---- crates/phase-ai/src/search.rs | 32 ++- 5 files changed, 323 insertions(+), 89 deletions(-) diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 7cff6adf57..98095e8f4d 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -1796,6 +1796,29 @@ pub fn export_game_state_json() -> Result { })? } +fn rehydrate_restored_state_from_card_db(state: &mut GameState) -> Result<(), JsValue> { + CARD_DB.with(|cell| { + let db = cell.borrow(); + let db = db.as_ref().ok_or_else(|| { + JsValue::from_str( + "Cannot restore game state: card database is not loaded. Call load_card_database first.", + ) + })?; + rehydrate_game_from_card_db(state, db); + Ok(()) + }) +} + +#[cfg(test)] +fn load_minimal_test_card_database() { + CARD_DB.with(|cell| { + *cell.borrow_mut() = Some( + CardDatabase::from_json_str("{}") + .expect("an empty test card database must deserialize"), + ); + }); +} + /// Restore the game state from a JSON string. /// Uses serde_json which handles string-keyed maps (from localStorage round-trip) /// correctly deserializing into HashMap. @@ -1818,11 +1841,7 @@ pub fn restore_game_state(json_str: &str) -> Result<(), JsValue> { // and reproduce the previous rewind-to-origin behavior. state.rehydrate_rng(); state.debug_mode = true; - CARD_DB.with(|cell| { - if let Some(db) = cell.borrow().as_ref() { - rehydrate_game_from_card_db(&mut state, db); - } - }); + rehydrate_restored_state_from_card_db(&mut state)?; finalize_public_state(&mut state); bind_interaction_session(&mut state); GAME_STATE.with(|cell| cell.set(Some(state))); @@ -1888,11 +1907,7 @@ pub fn resume_multiplayer_host_state(json_str: &str) -> Result<(), JsValue> { state.rng = ChaCha20Rng::seed_from_u64(fresh_seed); state.rng_word_pos = 0; - CARD_DB.with(|cell| { - if let Some(db) = cell.borrow().as_ref() { - rehydrate_game_from_card_db(&mut state, db); - } - }); + rehydrate_restored_state_from_card_db(&mut state)?; finalize_public_state(&mut state); bind_interaction_session(&mut state); @@ -1907,6 +1922,33 @@ pub fn resume_multiplayer_host_state(json_str: &str) -> Result<(), JsValue> { Ok(()) } +#[cfg(test)] +mod restored_card_db_requirements_tests { + use super::*; + + #[test] + fn restore_and_resume_require_a_card_database_before_mutating_state() { + clear_game_state(); + set_multiplayer_mode(false); + CARD_DB.with(|cell| *cell.borrow_mut() = None); + let json = serde_json::to_string(&GameState::new_two_player(17)).unwrap(); + + let restore_error = restore_game_state(&json).expect_err("restore must require CARD_DB"); + assert!(restore_error + .as_string() + .is_some_and(|message| message.contains("card database"))); + assert!(GAME_STATE.with(|cell| cell.borrow().is_none())); + + let resume_error = + resume_multiplayer_host_state(&json).expect_err("resume must require CARD_DB"); + assert!(resume_error + .as_string() + .is_some_and(|message| message.contains("card database"))); + assert!(GAME_STATE.with(|cell| cell.borrow().is_none())); + assert!(!is_multiplayer_mode()); + } +} + // ── Replay system ─────────────────────────────────────────────────────── // // Recording: `initialize_game` auto-starts a `ReplayLog` (REPLAY_LOG) and @@ -2644,6 +2686,7 @@ mod tests { use std::sync::Arc; use engine::game::deck_loading::create_object_from_card_face; + use engine::game::scenario::{GameScenario, P0, P1}; use engine::game::zones::create_object; use engine::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, ChoiceType, ChosenAttribute, @@ -2660,7 +2703,7 @@ mod tests { }; use engine::types::identifiers::{CardId, ObjectId}; use engine::types::keywords::Keyword; - use engine::types::mana::{ManaColor, ManaCost, ManaCostShard}; + use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; use engine::types::phase::Phase; use engine::types::player::PlayerId; @@ -2738,6 +2781,93 @@ mod tests { action } + fn load_disruptor_flute_database() { + load_card_database( + r#"{ + "disruptor flute": { + "name": "Disruptor Flute", + "mana_cost": { "type": "NoCost" }, + "card_type": { "supertypes": [], "core_types": ["Artifact"], "subtypes": [] }, + "power": null, + "toughness": null, + "loyalty": null, + "defense": null, + "oracle_text": "Flash\\nAs this artifact enters, choose a card name.", + "abilities": [], + "triggers": [], + "static_abilities": [], + "replacements": [], + "keywords": [] + } + }"#, + ) + .expect("Disruptor Flute fixture database must load"); + } + + fn disruptor_flute_card_name_state() -> GameState { + let mut state = GameState::new_two_player(42); + create_object( + &mut state, + CardId(880), + PlayerId(0), + "Disruptor Flute".to_string(), + Zone::Battlefield, + ); + state.waiting_for = WaitingFor::NamedChoice { + player: PlayerId(0), + choice_type: ChoiceType::CardName, + options: Vec::new(), + source: None, + persist_player: None, + }; + state + } + + fn fireball_target_selection_state() -> GameState { + const FIREBALL_ORACLE: &str = + "Fireball deals X damage divided evenly, rounded down, among any number of targets."; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_creature(P1, "Fireball Target", 3, 3); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Fireball", false, FIREBALL_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::X, ManaCostShard::Red], + generic: 0, + }) + .with_strive_cost(ManaCost::Cost { + shards: Vec::new(), + generic: 1, + }) + .id(); + scenario.with_mana_pool( + P0, + (0..8) + .map(|_| ManaUnit::new(ManaType::Red, ObjectId(0), false, Vec::new())) + .collect(), + ); + + let mut state = scenario.build().state().clone(); + engine::game::engine::apply_as_current( + &mut state, + GameAction::CastSpell { + object_id: spell, + card_id: CardId(spell.0), + targets: Vec::new(), + payment_mode: engine::types::game_state::CastPaymentMode::Auto, + }, + ) + .expect("Fireball announcement must reach ChooseX"); + engine::game::engine::apply_as_current(&mut state, GameAction::ChooseX { value: 3 }) + .expect("Fireball X announcement must reach target selection"); + assert!(matches!( + state.waiting_for, + WaitingFor::TargetSelection { .. } + )); + state + } + /// The contract is only useful if every member can cross the public /// proposal boundary. Reinstall the unchanged pre-decision state for each /// member because a successful submission invalidates its siblings. @@ -2868,6 +2998,86 @@ mod tests { state } + #[test] + fn restored_disruptor_flute_card_name_proposal_applies_after_rehydration() { + clear_game_state(); + set_multiplayer_mode(false); + load_disruptor_flute_database(); + let json = serde_json::to_string(&disruptor_flute_card_name_state()).unwrap(); + + restore_game_state(&json).expect("restore must rehydrate CardName metadata"); + let proposal: serde_json::Value = serde_wasm_bindgen::from_value( + get_ai_action_proposal("Medium", PlayerId(0).0) + .expect("public issuer must answer restored Flute prompt"), + ) + .unwrap(); + assert!(matches!( + serde_json::from_value::(proposal["action"].clone()), + Ok(GameAction::ChooseOption { ref choice }) if choice == "Disruptor Flute" + )); + submit_public_proposal(&proposal); + with_state(|state| assert!(matches!(state.waiting_for, WaitingFor::Priority { .. }))) + .expect("applied card-name choice must leave a live successor"); + clear_game_state(); + } + + #[test] + fn resumed_disruptor_flute_card_name_proposal_applies_after_rehydration() { + clear_game_state(); + set_multiplayer_mode(false); + load_disruptor_flute_database(); + let json = serde_json::to_string(&disruptor_flute_card_name_state()).unwrap(); + + resume_multiplayer_host_state(&json).expect("resume must rehydrate CardName metadata"); + let proposal: serde_json::Value = serde_wasm_bindgen::from_value( + get_ai_action_proposal("Medium", PlayerId(0).0) + .expect("public issuer must answer resumed Flute prompt"), + ) + .unwrap(); + assert!(matches!( + serde_json::from_value::(proposal["action"].clone()), + Ok(GameAction::ChooseOption { ref choice }) if choice == "Disruptor Flute" + )); + submit_public_proposal(&proposal); + with_state(|state| assert!(matches!(state.waiting_for, WaitingFor::Priority { .. }))) + .expect("applied card-name choice must leave a live successor"); + assert!(is_multiplayer_mode()); + clear_game_state(); + set_multiplayer_mode(false); + } + + #[test] + fn public_fireball_x_target_and_payment_proposals_never_reject() { + clear_game_state(); + GAME_STATE.with(|cell| cell.set(Some(fireball_target_selection_state()))); + + for step in 0..12 { + let proposal: serde_json::Value = serde_wasm_bindgen::from_value( + get_ai_action_proposal("Medium", P0.0) + .expect("public issuer must answer Fireball continuation"), + ) + .expect("proposal must serialize"); + let action: GameAction = serde_json::from_value(proposal["action"].clone()) + .expect("proposal action must deserialize"); + if step == 0 { + assert!( + matches!(action, GameAction::ChooseTarget { .. }), + "the real X route must issue a public target proposal first, got {action:?}" + ); + } + submit_public_proposal(&proposal); + + let done = with_state(|state| matches!(state.waiting_for, WaitingFor::Priority { .. })) + .expect("successful public proposal must retain state"); + if done { + clear_game_state(); + return; + } + } + + panic!("Fireball public continuation did not reach payment/priority within 12 proposals"); + } + #[test] fn proposal_boundary_rejects_changed_x_target_and_payment_arguments() { let player = PlayerId(0); @@ -3684,6 +3894,7 @@ mod tests { #[test] fn multiplayer_mode_refuses_restore_game_state() { + load_minimal_test_card_database(); // Single-player baseline: restore succeeds. let state = GameState::new_two_player(7); let json = serde_json::to_string(&state).unwrap(); @@ -3713,6 +3924,7 @@ mod tests { // thread-local state. clear_game_state(); set_multiplayer_mode(false); + load_minimal_test_card_database(); // Seed a game so `resume_` sees it as "already initialized". let state = GameState::new_two_player(7); @@ -3755,6 +3967,7 @@ mod tests { fn resume_multiplayer_host_state_stamps_fresh_rng_seed_and_enables_flag() { clear_game_state(); set_multiplayer_mode(false); + load_minimal_test_card_database(); let mut state = GameState::new_two_player(42); // Force a known "stale" seed so we can prove it was replaced. @@ -3782,6 +3995,7 @@ mod tests { #[test] fn restore_keeps_legacy_state_without_printed_ref() { + load_minimal_test_card_database(); let mut state = GameState::new_two_player(42); let object_id = ObjectId(1); state.objects.insert( @@ -3888,6 +4102,7 @@ mod replay_bridge_tests { #[test] fn restore_game_state_invalidates_the_in_progress_recording() { clear_game_state(); + load_minimal_test_card_database(); let state = GameState::new_two_player(7); REPLAY_LOG.with(|cell| { @@ -4057,6 +4272,7 @@ mod rng_restore_bridge_tests { // `state.rehydrate_rng()` in restore turns it red. Asserts on consumed // randomness, not the stored `rng_word_pos` integer. clear_game_state(); + load_minimal_test_card_database(); // Seed a live game and consume randomness as gameplay would. let mut state = GameState::new_two_player(0x51A7_C0DE); diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index c348a06c0e..1c758ceca5 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -903,12 +903,22 @@ pub fn candidate_actions_broad_with_probe( target_slots, selection, .. - } => target_step_actions( - *player, - target_slots, - selection.current_slot, - &selection.current_legal_targets, - ), + } => { + let mut actions = target_step_actions( + *player, + target_slots, + selection.current_slot, + &selection.current_legal_targets, + ); + if state.waiting_for.allows_cancel_cast() { + actions.push(candidate( + GameAction::CancelCast, + TacticalClass::Pass, + Some(*player), + )); + } + actions + } WaitingFor::TriggerTargetSelection { player, target_slots, @@ -3457,7 +3467,10 @@ fn semantic_candidate_actions_with_probe( let allows_cancel_cast = state.waiting_for.allows_cancel_cast() || (matches!(state.waiting_for, WaitingFor::DistributeAmong { .. }) && state.pending_cast.is_some()); - if has_pending_cast && allows_cancel_cast { + if has_pending_cast + && allows_cancel_cast + && !matches!(state.waiting_for, WaitingFor::TargetSelection { .. }) + { if let Some(player) = state.waiting_for.acting_player() { actions.push(candidate( GameAction::CancelCast, @@ -4345,16 +4358,9 @@ fn target_step_actions( current_slot: usize, current_legal_targets: &[TargetRef], ) -> Vec { - let legal_targets: Vec = if !current_legal_targets.is_empty() { - current_legal_targets.to_vec() - } else { - target_slots - .get(current_slot) - .map(|slot| slot.legal_targets.clone()) - .unwrap_or_default() - }; - - let mut actions: Vec = legal_targets + let mut actions: Vec = current_legal_targets + .iter() + .cloned() .into_iter() .map(|target| { candidate( @@ -5970,7 +5976,7 @@ mod tests { } #[test] - fn target_selection_uses_current_slot_legality() { + fn target_selection_does_not_revive_stale_slot_targets() { let mut state = GameState::new_two_player(42); let p0 = PlayerId(0); let target_a = create_object( @@ -6008,8 +6014,10 @@ mod tests { }; let actions = candidate_actions(&state); - assert_eq!(actions.len(), 2); - assert!(matches!(actions[0].action, GameAction::ChooseTarget { .. })); + assert!( + actions.is_empty(), + "an empty current prompt must not fall back to historical slot targets" + ); } /// CR 732.2a: at a `PayableResource::LoopCollapse` prompt the AI enumerates ONLY diff --git a/crates/engine/src/ai_support/context.rs b/crates/engine/src/ai_support/context.rs index 285cbcd5dc..9563ae2609 100644 --- a/crates/engine/src/ai_support/context.rs +++ b/crates/engine/src/ai_support/context.rs @@ -4,7 +4,10 @@ use crate::game::turn_control; use crate::types::actions::GameAction; use crate::types::player::PlayerId; -use super::candidates::{candidate_actions_for_semantic_owner_with_probe, CandidateAction}; +use super::{ + candidates::{candidate_actions_for_semantic_owner_with_probe, CandidateAction}, + FilterPipeline, +}; #[derive(Debug, Clone)] pub struct AiDecisionContext { @@ -35,15 +38,18 @@ impl AiDecisionContract { authorized_actor: turn_control::authorized_submitter_for_player(state, semantic_owner), state_revision: state.state_revision, // The engine's candidate enumerator is the authoritative finite - // domain for this prompt. Some bounded continuation forms (combat, - // search, and multi-step selections) are intentionally completed - // by their dedicated reducer paths, so a generic clone-and-apply - // probe is not a sound way to remove them from the contract. - // Submission still performs the public action-boundary apply after - // exact-membership and owner checks. + // domain for this prompt. Combat and search continuations remain + // reducer-owned, but target prompts and in-flight casts must cross + // their next reducer boundary before they are issued: a target can + // determine the final mana cost. Submission still performs the + // public action-boundary apply after exact-membership and owner + // checks. candidates: { let mut candidates = candidate_actions_for_semantic_owner_with_probe(state, semantic_owner, None); + if requires_reducer_validated_contract(state) { + candidates = FilterPipeline::default_pipeline().apply(state, candidates); + } candidates.sort_by(|left, right| left.action.cmp_stable(&right.action)); candidates }, @@ -97,6 +103,15 @@ impl AiDecisionContract { } } +fn requires_reducer_validated_contract(state: &GameState) -> bool { + matches!( + state.waiting_for, + WaitingFor::TargetSelection { .. } | WaitingFor::TriggerTargetSelection { .. } + ) || state.waiting_for.has_pending_cast() + || (matches!(state.waiting_for, WaitingFor::DistributeAmong { .. }) + && state.pending_cast.is_some()) +} + fn candidate_action_matches(issued: &GameAction, submitted: &GameAction) -> bool { match (issued, submitted) { ( diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index d2322515f7..b320c321e7 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -1922,41 +1922,6 @@ pub fn current_target_selection_targets(state: &GameState) -> Option<&[TargetRef } } -fn target_selection_actions_without_simulation(state: &GameState) -> Option> { - let (target_slots, current_slot) = match &state.waiting_for { - WaitingFor::TargetSelection { - target_slots, - selection, - .. - } - | WaitingFor::TriggerTargetSelection { - target_slots, - selection, - .. - } => (target_slots, selection.current_slot), - _ => return None, - }; - - let current_legal_targets = current_target_selection_targets(state)?; - - let mut actions: Vec = current_legal_targets - .iter() - .cloned() - .map(|target| GameAction::ChooseTarget { - target: Some(target), - }) - .collect(); - - if target_slots - .get(current_slot) - .is_some_and(|slot| slot.optional) - { - actions.push(GameAction::ChooseTarget { target: None }); - } - - Some(actions) -} - /// The flat priority-action list: validated candidate actions minus mana /// abilities. This is the single authority for the non-target-selection action /// body so the auto-pass probe (`priority_player_has_meaningful_action`) and @@ -2014,8 +1979,17 @@ pub fn legal_actions_full(state: &GameState) -> LegalActionsFull { _ => (state, None), }; - let mut actions: Vec = target_selection_actions_without_simulation(state) - .unwrap_or_else(|| flat_priority_actions_with_probe(state, priority_probe)); + let mut actions: Vec = if matches!( + state.waiting_for, + WaitingFor::TargetSelection { .. } | WaitingFor::TriggerTargetSelection { .. } + ) { + validated_candidate_actions(state) + .into_iter() + .map(|candidate| candidate.action) + .collect() + } else { + flat_priority_actions_with_probe(state, priority_probe) + }; // This preference-setting action is intentionally excluded from AI candidate // generation: it changes future prompt behavior rather than making a tactical @@ -6408,7 +6382,7 @@ mod tests { player: PlayerId(0), pending_cast, target_slots: vec![crate::types::game_state::TargetSelectionSlot { - legal_targets: vec![target], + legal_targets: vec![target.clone()], optional: true, chooser: None, effect_kind: EffectKind::NoOp, @@ -6425,11 +6399,18 @@ mod tests { crate::game::perf_counters::reset(); let (actions, _spell_costs, _grouped) = legal_actions_full(&state); - assert_eq!( - crate::game::perf_counters::snapshot().state_clone_for_legality, - 0 + assert!( + crate::game::perf_counters::snapshot().state_clone_for_legality > 0, + "target-prompt legal actions must use the same reducer-validation authority as AI contracts" + ); + assert!(actions.contains(&GameAction::ChooseTarget { target: None })); + assert!(actions.contains(&GameAction::CancelCast)); + assert!( + !actions.contains(&GameAction::ChooseTarget { + target: Some(target) + }), + "public legal actions must not revive stale slot targets" ); - assert_eq!(actions, vec![GameAction::ChooseTarget { target: None }]); } /// False-positive sweep (CR 103.5 / TL:R 906.6a): the simultaneous diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index b872f91868..ed4851f3a8 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -1156,15 +1156,29 @@ pub fn fallback_action( ) }); } - // CR 601.2c: A spell's target step must use the engine's current legal - // target list. `target_slots` is a historical snapshot and can be stale - // after earlier selections; if no current legal action remains, abort the - // in-flight cast rather than fabricating an illegal required-target skip. - if matches!(state.waiting_for, WaitingFor::TargetSelection { .. }) { - return engine::ai_support::legal_actions(state) - .into_iter() - .find(|action| matches!(action, GameAction::ChooseTarget { .. })) - .or(Some(GameAction::CancelCast)); + // Target prompts must answer from the exact domain that will gate the + // public proposal. The contract has already filtered current targets + // through the reducer; rebuilding an answer from prompt snapshots can + // reintroduce stale targets or an unpayable cast continuation. + if matches!( + state.waiting_for, + WaitingFor::TargetSelection { .. } | WaitingFor::TriggerTargetSelection { .. } + ) { + let target = contract + .candidates + .iter() + .find(|candidate| matches!(candidate.action, GameAction::ChooseTarget { .. })) + .map(|candidate| candidate.action.clone()); + if target.is_some() + || matches!(state.waiting_for, WaitingFor::TriggerTargetSelection { .. }) + { + return target; + } + return contract + .candidates + .iter() + .find(|candidate| matches!(candidate.action, GameAction::CancelCast)) + .map(|candidate| candidate.action.clone()); } // Pending-cast states can always be escaped with CancelCast (CR 601.2). From b005bbc4e222e6c91219cbcd0c0ac47bc3d8e5ca Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 09:32:40 -0700 Subject: [PATCH 02/10] test(ai): cover target surcharge proposal boundary --- crates/engine-wasm/src/lib.rs | 134 ++++++++++++++++++++-------- crates/engine/src/ai_support/mod.rs | 29 +++++- 2 files changed, 125 insertions(+), 38 deletions(-) diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 98095e8f4d..8434ed431c 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -2691,7 +2691,7 @@ mod tests { use engine::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, ChoiceType, ChosenAttribute, ContinuousModification, Duration, Effect, QuantityExpr, QuantityRef, ResolvedAbility, - TargetFilter, + TargetFilter, TargetRef, }; use engine::types::card::CardFace; use engine::types::card_type::{CardType, CoreType}; @@ -2823,15 +2823,13 @@ mod tests { state } - fn fireball_target_selection_state() -> GameState { - const FIREBALL_ORACLE: &str = - "Fireball deals X damage divided evenly, rounded down, among any number of targets."; - + fn fireball_final_target_state(pool: usize) -> (GameState, TargetRef) { let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); - scenario.add_creature(P1, "Fireball Target", 3, 3); + let first_target = scenario.add_creature(P1, "Fireball Target One", 3, 3).id(); + let final_target = scenario.add_creature(P1, "Fireball Target Two", 3, 3).id(); let spell = scenario - .add_spell_to_hand_from_oracle(P0, "Fireball", false, FIREBALL_ORACLE) + .add_spell_to_hand(P0, "Fireball", true) .with_mana_cost(ManaCost::Cost { shards: vec![ManaCostShard::X, ManaCostShard::Red], generic: 0, @@ -2840,10 +2838,34 @@ mod tests { shards: Vec::new(), generic: 1, }) + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Ref { + qty: QuantityRef::CostXPaid, + }, + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + ) + .sub_ability(AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Ref { + qty: QuantityRef::CostXPaid, + }, + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + )), + ) .id(); scenario.with_mana_pool( P0, - (0..8) + (0..pool) .map(|_| ManaUnit::new(ManaType::Red, ObjectId(0), false, Vec::new())) .collect(), ); @@ -2861,11 +2883,18 @@ mod tests { .expect("Fireball announcement must reach ChooseX"); engine::game::engine::apply_as_current(&mut state, GameAction::ChooseX { value: 3 }) .expect("Fireball X announcement must reach target selection"); + engine::game::engine::apply_as_current( + &mut state, + GameAction::ChooseTarget { + target: Some(TargetRef::Object(first_target)), + }, + ) + .expect("first Fireball target must leave the final target slot pending"); assert!(matches!( state.waiting_for, WaitingFor::TargetSelection { .. } )); - state + (state, TargetRef::Object(final_target)) } /// The contract is only useful if every member can cross the public @@ -3047,35 +3076,70 @@ mod tests { } #[test] - fn public_fireball_x_target_and_payment_proposals_never_reject() { + fn public_fireball_final_target_filters_unpayable_surcharge_and_keeps_payable_sibling() { clear_game_state(); - GAME_STATE.with(|cell| cell.set(Some(fireball_target_selection_state()))); - - for step in 0..12 { - let proposal: serde_json::Value = serde_wasm_bindgen::from_value( - get_ai_action_proposal("Medium", P0.0) - .expect("public issuer must answer Fireball continuation"), - ) - .expect("proposal must serialize"); - let action: GameAction = serde_json::from_value(proposal["action"].clone()) - .expect("proposal action must deserialize"); - if step == 0 { - assert!( - matches!(action, GameAction::ChooseTarget { .. }), - "the real X route must issue a public target proposal first, got {action:?}" + // {X}{R} with X=3 costs four mana for one target. The final second + // target adds the pinned Fireball/Strive-shaped {1} surcharge, so this + // exact reducer transition is rejected from a four-mana pool. + let (doomed_state, doomed_target) = fireball_final_target_state(4); + let doomed_action = GameAction::ChooseTarget { + target: Some(doomed_target.clone()), + }; + let mut direct_doomed_state = doomed_state.clone(); + let error = + engine::game::engine::apply_as_current(&mut direct_doomed_state, doomed_action.clone()) + .expect_err( + "reach guard: the final target must hit the unpayable payment boundary", ); - } - submit_public_proposal(&proposal); - - let done = with_state(|state| matches!(state.waiting_for, WaitingFor::Priority { .. })) - .expect("successful public proposal must retain state"); - if done { - clear_game_state(); - return; - } - } + assert!( + error.to_string().contains("Cannot pay mana cost"), + "expected the production payment rejection, got {error}" + ); + let doomed_contract = AiDecisionContract::issue(&doomed_state, P0); + assert!( + !doomed_contract.contains_action(&doomed_state, &doomed_action), + "the unpayable final target must not enter the issued contract" + ); + assert!(doomed_contract.contains_action(&doomed_state, &GameAction::CancelCast)); + assert!( + !engine::ai_support::legal_actions(&doomed_state).contains(&doomed_action), + "public legal actions must share the contract's filtered target domain" + ); + GAME_STATE.with(|cell| cell.set(Some(doomed_state))); + let doomed_proposal: serde_json::Value = serde_wasm_bindgen::from_value( + get_ai_action_proposal("Medium", P0.0) + .expect("public issuer must expose the issued cancellation"), + ) + .expect("proposal must serialize"); + assert!(matches!( + serde_json::from_value::(doomed_proposal["action"].clone()), + Ok(GameAction::CancelCast) + )); + submit_public_proposal(&doomed_proposal); + clear_game_state(); - panic!("Fireball public continuation did not reach payment/priority within 12 proposals"); + let (payable_state, payable_target) = fireball_final_target_state(5); + let payable_action = GameAction::ChooseTarget { + target: Some(payable_target), + }; + let payable_contract = AiDecisionContract::issue(&payable_state, P0); + assert!( + payable_contract.contains_action(&payable_state, &payable_action), + "the same final target must remain issued once its target-dependent cost is payable" + ); + assert!(engine::ai_support::legal_actions(&payable_state).contains(&payable_action)); + GAME_STATE.with(|cell| cell.set(Some(payable_state))); + let payable_proposal: serde_json::Value = serde_wasm_bindgen::from_value( + get_ai_action_proposal("Medium", P0.0) + .expect("public issuer must retain the payable target"), + ) + .expect("proposal must serialize"); + assert!(matches!( + serde_json::from_value::(payable_proposal["action"].clone()), + Ok(GameAction::ChooseTarget { target: Some(_) }) + )); + submit_public_proposal(&payable_proposal); + clear_game_state(); } #[test] diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index b320c321e7..7796d0d581 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -5959,8 +5959,14 @@ mod tests { } #[test] - fn target_selection_legal_actions_do_not_simulate_each_target() { + fn target_selection_legal_actions_validate_each_current_target() { let mut state = setup_priority(); + state.players[0].mana_pool.mana.push(ManaUnit::new( + ManaType::Colorless, + ObjectId(0), + false, + vec![], + )); let targets: Vec = (0..25) .map(|i| { let creature = create_object( @@ -6005,9 +6011,20 @@ mod tests { let (actions, spell_costs, grouped) = legal_actions_full(&state); let counters = crate::game::perf_counters::snapshot(); - assert_eq!(counters.state_clone_for_legality, 0); + assert!( + counters.state_clone_for_legality >= 26, + "every current target and the optional skip must be checked through the reducer" + ); assert_eq!(counters.priority_cast_probe_builds, 0); - assert_eq!(actions.len(), 26); + assert_eq!( + actions + .iter() + .filter(|action| matches!(action, GameAction::ChooseTarget { target: Some(_) })) + .count(), + 25 + ); + assert!(actions.contains(&GameAction::ChooseTarget { target: None })); + assert!(actions.contains(&GameAction::CancelCast)); assert!(spell_costs.is_empty()); assert!(grouped.is_empty()); assert!(actions @@ -6368,6 +6385,12 @@ mod tests { #[test] fn target_selection_legal_actions_do_not_fall_back_to_stale_slot_targets() { let mut state = setup_priority(); + state.players[0].mana_pool.mana.push(ManaUnit::new( + ManaType::Colorless, + ObjectId(0), + false, + vec![], + )); let target = TargetRef::Object(create_object( &mut state, CardId(101), From 6e66c4f677747c6e744c797a4c1bddf87d4ea187 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 10:01:52 -0700 Subject: [PATCH 03/10] fix(ai): remove redundant target iterator conversion --- crates/engine/src/ai_support/candidates.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 1c758ceca5..ff404bf1ca 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -4361,7 +4361,6 @@ fn target_step_actions( let mut actions: Vec = current_legal_targets .iter() .cloned() - .into_iter() .map(|target| { candidate( GameAction::ChooseTarget { From 6332372ef2292315e87d183ee695983adba11fd1 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 10:13:13 -0700 Subject: [PATCH 04/10] fix(wasm): validate database before restored state mutation --- crates/engine-wasm/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 8434ed431c..583b22df25 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -1834,6 +1834,7 @@ pub fn restore_game_state(json_str: &str) -> Result<(), JsValue> { )); } let mut state = decode_restored_game_state(json_str)?; + rehydrate_restored_state_from_card_db(&mut state)?; // Reseed the skipped `rng` and fast-forward it to the offset captured at // export (issue #5466) so the restored game draws the values that would have // come NEXT rather than replaying from origin. The engine owns this logic @@ -1841,7 +1842,6 @@ pub fn restore_game_state(json_str: &str) -> Result<(), JsValue> { // and reproduce the previous rewind-to-origin behavior. state.rehydrate_rng(); state.debug_mode = true; - rehydrate_restored_state_from_card_db(&mut state)?; finalize_public_state(&mut state); bind_interaction_session(&mut state); GAME_STATE.with(|cell| cell.set(Some(state))); @@ -1895,6 +1895,7 @@ pub fn resume_multiplayer_host_state(json_str: &str) -> Result<(), JsValue> { } let mut state = decode_restored_game_state(json_str)?; + rehydrate_restored_state_from_card_db(&mut state)?; // Deliberately re-roll a fresh seed on multiplayer host resume so continued // play diverges from any pre-save sequence (mirrors server-core). This is a @@ -1907,7 +1908,6 @@ pub fn resume_multiplayer_host_state(json_str: &str) -> Result<(), JsValue> { state.rng = ChaCha20Rng::seed_from_u64(fresh_seed); state.rng_word_pos = 0; - rehydrate_restored_state_from_card_db(&mut state)?; finalize_public_state(&mut state); bind_interaction_session(&mut state); From 5650ea7e13ee17d66c819014e150a2ab28b4c14f Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 10:17:30 -0700 Subject: [PATCH 05/10] test(wasm): fix restored state regression assertions --- crates/engine-wasm/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 583b22df25..5f000e6b41 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -1937,14 +1937,14 @@ mod restored_card_db_requirements_tests { assert!(restore_error .as_string() .is_some_and(|message| message.contains("card database"))); - assert!(GAME_STATE.with(|cell| cell.borrow().is_none())); + assert!(GAME_STATE.with(|cell| cell.replace(None).is_none())); let resume_error = resume_multiplayer_host_state(&json).expect_err("resume must require CARD_DB"); assert!(resume_error .as_string() .is_some_and(|message| message.contains("card database"))); - assert!(GAME_STATE.with(|cell| cell.borrow().is_none())); + assert!(GAME_STATE.with(|cell| cell.replace(None).is_none())); assert!(!is_multiplayer_mode()); } } From e282099c25905a0ec1161258d909fdc1dddb6c68 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 10:40:17 -0700 Subject: [PATCH 06/10] fix(ai): validate only target-dependent cost continuations --- crates/engine-wasm/src/lib.rs | 26 ++++--- crates/engine/src/ai_support/context.rs | 33 +++++---- crates/engine/src/ai_support/mod.rs | 92 ++++++++++++++----------- crates/engine/src/game/casting.rs | 2 +- 4 files changed, 85 insertions(+), 68 deletions(-) diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 5f000e6b41..1b7566098b 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -1809,6 +1809,12 @@ fn rehydrate_restored_state_from_card_db(state: &mut GameState) -> Result<(), Js }) } +fn decode_and_rehydrate_restored_game_state(json_str: &str) -> Result { + let mut state = decode_restored_game_state(json_str)?; + rehydrate_restored_state_from_card_db(&mut state)?; + Ok(state) +} + #[cfg(test)] fn load_minimal_test_card_database() { CARD_DB.with(|cell| { @@ -1833,8 +1839,7 @@ pub fn restore_game_state(json_str: &str) -> Result<(), JsValue> { "restore_game_state refused: undo is disabled in multiplayer sessions", )); } - let mut state = decode_restored_game_state(json_str)?; - rehydrate_restored_state_from_card_db(&mut state)?; + let mut state = decode_and_rehydrate_restored_game_state(json_str)?; // Reseed the skipped `rng` and fast-forward it to the offset captured at // export (issue #5466) so the restored game draws the values that would have // come NEXT rather than replaying from origin. The engine owns this logic @@ -1894,8 +1899,7 @@ pub fn resume_multiplayer_host_state(json_str: &str) -> Result<(), JsValue> { )); } - let mut state = decode_restored_game_state(json_str)?; - rehydrate_restored_state_from_card_db(&mut state)?; + let mut state = decode_and_rehydrate_restored_game_state(json_str)?; // Deliberately re-roll a fresh seed on multiplayer host resume so continued // play diverges from any pre-save sequence (mirrors server-core). This is a @@ -1927,21 +1931,15 @@ mod restored_card_db_requirements_tests { use super::*; #[test] - fn restore_and_resume_require_a_card_database_before_mutating_state() { + fn decoded_restore_requires_a_card_database_before_state_mutation() { clear_game_state(); set_multiplayer_mode(false); CARD_DB.with(|cell| *cell.borrow_mut() = None); let json = serde_json::to_string(&GameState::new_two_player(17)).unwrap(); - let restore_error = restore_game_state(&json).expect_err("restore must require CARD_DB"); - assert!(restore_error - .as_string() - .is_some_and(|message| message.contains("card database"))); - assert!(GAME_STATE.with(|cell| cell.replace(None).is_none())); - - let resume_error = - resume_multiplayer_host_state(&json).expect_err("resume must require CARD_DB"); - assert!(resume_error + let error = decode_and_rehydrate_restored_game_state(&json) + .expect_err("restore must require CARD_DB"); + assert!(error .as_string() .is_some_and(|message| message.contains("card database"))); assert!(GAME_STATE.with(|cell| cell.replace(None).is_none())); diff --git a/crates/engine/src/ai_support/context.rs b/crates/engine/src/ai_support/context.rs index 9563ae2609..ef6efd0cac 100644 --- a/crates/engine/src/ai_support/context.rs +++ b/crates/engine/src/ai_support/context.rs @@ -39,15 +39,14 @@ impl AiDecisionContract { state_revision: state.state_revision, // The engine's candidate enumerator is the authoritative finite // domain for this prompt. Combat and search continuations remain - // reducer-owned, but target prompts and in-flight casts must cross - // their next reducer boundary before they are issued: a target can - // determine the final mana cost. Submission still performs the - // public action-boundary apply after exact-membership and owner - // checks. + // reducer-owned. A target choice crosses the reducer only when the + // pending spell's mana obligation can still change with its final + // target set. Submission still performs the public action-boundary + // apply after exact-membership and owner checks. candidates: { let mut candidates = candidate_actions_for_semantic_owner_with_probe(state, semantic_owner, None); - if requires_reducer_validated_contract(state) { + if target_selection_requires_reducer_validation(state) { candidates = FilterPipeline::default_pipeline().apply(state, candidates); } candidates.sort_by(|left, right| left.action.cmp_stable(&right.action)); @@ -103,13 +102,21 @@ impl AiDecisionContract { } } -fn requires_reducer_validated_contract(state: &GameState) -> bool { - matches!( - state.waiting_for, - WaitingFor::TargetSelection { .. } | WaitingFor::TriggerTargetSelection { .. } - ) || state.waiting_for.has_pending_cast() - || (matches!(state.waiting_for, WaitingFor::DistributeAmong { .. }) - && state.pending_cast.is_some()) +pub(crate) fn target_selection_requires_reducer_validation(state: &GameState) -> bool { + let WaitingFor::TargetSelection { + player, + pending_cast, + .. + } = &state.waiting_for + else { + return false; + }; + + !crate::game::casting::pending_mana_obligation_is_stable_before_targets( + state, + *player, + pending_cast, + ) } fn candidate_action_matches(issued: &GameAction, submitted: &GameAction) -> bool { diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 7796d0d581..1f7f720675 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -1922,6 +1922,41 @@ pub fn current_target_selection_targets(state: &GameState) -> Option<&[TargetRef } } +fn target_selection_actions_without_simulation(state: &GameState) -> Option> { + let (target_slots, current_slot) = match &state.waiting_for { + WaitingFor::TargetSelection { + target_slots, + selection, + .. + } + | WaitingFor::TriggerTargetSelection { + target_slots, + selection, + .. + } => (target_slots, selection.current_slot), + _ => return None, + }; + + let current_legal_targets = current_target_selection_targets(state)?; + + let mut actions: Vec = current_legal_targets + .iter() + .cloned() + .map(|target| GameAction::ChooseTarget { + target: Some(target), + }) + .collect(); + + if target_slots + .get(current_slot) + .is_some_and(|slot| slot.optional) + { + actions.push(GameAction::ChooseTarget { target: None }); + } + + Some(actions) +} + /// The flat priority-action list: validated candidate actions minus mana /// abilities. This is the single authority for the non-target-selection action /// body so the auto-pass probe (`priority_player_has_meaningful_action`) and @@ -1979,17 +2014,16 @@ pub fn legal_actions_full(state: &GameState) -> LegalActionsFull { _ => (state, None), }; - let mut actions: Vec = if matches!( - state.waiting_for, - WaitingFor::TargetSelection { .. } | WaitingFor::TriggerTargetSelection { .. } - ) { - validated_candidate_actions(state) - .into_iter() - .map(|candidate| candidate.action) - .collect() - } else { - flat_priority_actions_with_probe(state, priority_probe) - }; + let mut actions: Vec = + if context::target_selection_requires_reducer_validation(state) { + validated_candidate_actions(state) + .into_iter() + .map(|candidate| candidate.action) + .collect() + } else { + target_selection_actions_without_simulation(state) + .unwrap_or_else(|| flat_priority_actions_with_probe(state, priority_probe)) + }; // This preference-setting action is intentionally excluded from AI candidate // generation: it changes future prompt behavior rather than making a tactical @@ -5959,14 +5993,8 @@ mod tests { } #[test] - fn target_selection_legal_actions_validate_each_current_target() { + fn target_selection_legal_actions_use_current_targets_without_simulation() { let mut state = setup_priority(); - state.players[0].mana_pool.mana.push(ManaUnit::new( - ManaType::Colorless, - ObjectId(0), - false, - vec![], - )); let targets: Vec = (0..25) .map(|i| { let creature = create_object( @@ -6011,10 +6039,7 @@ mod tests { let (actions, spell_costs, grouped) = legal_actions_full(&state); let counters = crate::game::perf_counters::snapshot(); - assert!( - counters.state_clone_for_legality >= 26, - "every current target and the optional skip must be checked through the reducer" - ); + assert_eq!(counters.state_clone_for_legality, 0); assert_eq!(counters.priority_cast_probe_builds, 0); assert_eq!( actions @@ -6024,7 +6049,7 @@ mod tests { 25 ); assert!(actions.contains(&GameAction::ChooseTarget { target: None })); - assert!(actions.contains(&GameAction::CancelCast)); + assert_eq!(actions.len(), 26); assert!(spell_costs.is_empty()); assert!(grouped.is_empty()); assert!(actions @@ -6385,12 +6410,6 @@ mod tests { #[test] fn target_selection_legal_actions_do_not_fall_back_to_stale_slot_targets() { let mut state = setup_priority(); - state.players[0].mana_pool.mana.push(ManaUnit::new( - ManaType::Colorless, - ObjectId(0), - false, - vec![], - )); let target = TargetRef::Object(create_object( &mut state, CardId(101), @@ -6422,18 +6441,11 @@ mod tests { crate::game::perf_counters::reset(); let (actions, _spell_costs, _grouped) = legal_actions_full(&state); - assert!( - crate::game::perf_counters::snapshot().state_clone_for_legality > 0, - "target-prompt legal actions must use the same reducer-validation authority as AI contracts" - ); - assert!(actions.contains(&GameAction::ChooseTarget { target: None })); - assert!(actions.contains(&GameAction::CancelCast)); - assert!( - !actions.contains(&GameAction::ChooseTarget { - target: Some(target) - }), - "public legal actions must not revive stale slot targets" + assert_eq!( + crate::game::perf_counters::snapshot().state_clone_for_legality, + 0 ); + assert_eq!(actions, vec![GameAction::ChooseTarget { target: None }]); } /// False-positive sweep (CR 103.5 / TL:R 906.6a): the simultaneous diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 6c7205061f..3d40f7779c 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -14624,7 +14624,7 @@ pub(super) fn spell_tap_payment_mode_for( /// CR 601.2c + CR 601.2f: Target selection may precede locking the final /// mana obligation. Return true only when none of the production cost axes can /// still change the amount or the sources available before payment. -pub(super) fn pending_mana_obligation_is_stable_before_targets( +pub(crate) fn pending_mana_obligation_is_stable_before_targets( state: &GameState, player: PlayerId, pending: &PendingCast, From 01c0d4a3e01600a9cf6dcc47f19b07817959900e Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 11:08:01 -0700 Subject: [PATCH 07/10] fix(ai): avoid target continuation regression --- crates/engine-wasm/src/lib.rs | 23 +++++++++---------- crates/engine/src/ai_support/context.rs | 16 +++++++++---- .../phase-ai/src/policies/hand_disruption.rs | 7 ++++-- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 1b7566098b..c3451c7508 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -125,10 +125,10 @@ fn format_diagnostic_value(value: &serde_json::Value) -> String { } } -fn decode_restored_game_state(json_str: &str) -> Result { +fn decode_restored_game_state(json_str: &str) -> Result { serde_json::from_str::(json_str) .map(PersistedGameState::into_game_state) - .map_err(|e| JsValue::from_str(&format!("Failed to deserialize GameState: {e}"))) + .map_err(|error| format!("Failed to deserialize GameState: {error}")) } /// Bind the engine's interaction authority for the one game this module hosts. @@ -1796,20 +1796,19 @@ pub fn export_game_state_json() -> Result { })? } -fn rehydrate_restored_state_from_card_db(state: &mut GameState) -> Result<(), JsValue> { +fn rehydrate_restored_state_from_card_db(state: &mut GameState) -> Result<(), String> { CARD_DB.with(|cell| { let db = cell.borrow(); let db = db.as_ref().ok_or_else(|| { - JsValue::from_str( - "Cannot restore game state: card database is not loaded. Call load_card_database first.", - ) + "Cannot restore game state: card database is not loaded. Call load_card_database first." + .to_string() })?; rehydrate_game_from_card_db(state, db); Ok(()) }) } -fn decode_and_rehydrate_restored_game_state(json_str: &str) -> Result { +fn decode_and_rehydrate_restored_game_state(json_str: &str) -> Result { let mut state = decode_restored_game_state(json_str)?; rehydrate_restored_state_from_card_db(&mut state)?; Ok(state) @@ -1839,7 +1838,8 @@ pub fn restore_game_state(json_str: &str) -> Result<(), JsValue> { "restore_game_state refused: undo is disabled in multiplayer sessions", )); } - let mut state = decode_and_rehydrate_restored_game_state(json_str)?; + let mut state = decode_and_rehydrate_restored_game_state(json_str) + .map_err(|error| JsValue::from_str(&error))?; // Reseed the skipped `rng` and fast-forward it to the offset captured at // export (issue #5466) so the restored game draws the values that would have // come NEXT rather than replaying from origin. The engine owns this logic @@ -1899,7 +1899,8 @@ pub fn resume_multiplayer_host_state(json_str: &str) -> Result<(), JsValue> { )); } - let mut state = decode_and_rehydrate_restored_game_state(json_str)?; + let mut state = decode_and_rehydrate_restored_game_state(json_str) + .map_err(|error| JsValue::from_str(&error))?; // Deliberately re-roll a fresh seed on multiplayer host resume so continued // play diverges from any pre-save sequence (mirrors server-core). This is a @@ -1939,9 +1940,7 @@ mod restored_card_db_requirements_tests { let error = decode_and_rehydrate_restored_game_state(&json) .expect_err("restore must require CARD_DB"); - assert!(error - .as_string() - .is_some_and(|message| message.contains("card database"))); + assert!(error.contains("card database")); assert!(GAME_STATE.with(|cell| cell.replace(None).is_none())); assert!(!is_multiplayer_mode()); } diff --git a/crates/engine/src/ai_support/context.rs b/crates/engine/src/ai_support/context.rs index ef6efd0cac..c0d145b00a 100644 --- a/crates/engine/src/ai_support/context.rs +++ b/crates/engine/src/ai_support/context.rs @@ -106,17 +106,23 @@ pub(crate) fn target_selection_requires_reducer_validation(state: &GameState) -> let WaitingFor::TargetSelection { player, pending_cast, + target_slots, + selection, .. } = &state.waiting_for else { return false; }; - !crate::game::casting::pending_mana_obligation_is_stable_before_targets( - state, - *player, - pending_cast, - ) + // Only the final target can lock a target-dependent cost. Earlier + // selections are valid reducer continuations regardless of whether the + // eventual cost is payable. + selection.current_slot.checked_add(1) == Some(target_slots.len()) + && !crate::game::casting::pending_mana_obligation_is_stable_before_targets( + state, + *player, + pending_cast, + ) } fn candidate_action_matches(issued: &GameAction, submitted: &GameAction) -> bool { diff --git a/crates/phase-ai/src/policies/hand_disruption.rs b/crates/phase-ai/src/policies/hand_disruption.rs index 974102c77b..24ada59de6 100644 --- a/crates/phase-ai/src/policies/hand_disruption.rs +++ b/crates/phase-ai/src/policies/hand_disruption.rs @@ -16,7 +16,7 @@ use super::context::PolicyContext; use super::registry::{DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy}; use super::strategy_helpers::best_proactive_cast_score; #[cfg(test)] -use engine::types::game_state::CastPaymentMode; +use engine::types::game_state::{CastPaymentMode, TargetSelectionProgress}; pub struct HandDisruptionPolicy; @@ -448,7 +448,10 @@ mod tests { effect_detail: TargetEffectDetail::None, }], mode_labels: Vec::new(), - selection: Default::default(), + selection: TargetSelectionProgress { + current_legal_targets: legal_targets, + ..Default::default() + }, }; state.waiting_for = waiting_for.clone(); let decision = AiDecisionContext { From 72f9d43abf69ebe6b154041233d976babf117bbd Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 11:35:16 -0700 Subject: [PATCH 08/10] perf(ai): gate target cost stability scans --- crates/engine/src/game/casting.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 3d40f7779c..4dbe952b7d 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -14691,6 +14691,17 @@ pub(crate) fn pending_mana_obligation_is_stable_before_targets( return false; } + // `static_mode_presence` is a post-flush superset of the two static + // families that can affect a spell's cost. Its absence proves the exact + // scan below cannot find a target-dependent axis, avoiding an O(board) + // scan at every ordinary target-selection prompt. + if !state.layers_dirty.is_dirty() + && !static_kind_present(state, StaticModeKind::ModifyCost) + && !static_kind_present(state, StaticModeKind::ImposeAdditionalCost) + { + return true; + } + !super::functioning_abilities::game_functioning_statics(state).any(|(source, definition)| { let payment_axis_unstable = match &definition.mode { StaticMode::ModifyCost { From fe67e0f5622bcf7b9ec6b17d8e8a3e24d05a360c Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 11:59:48 -0700 Subject: [PATCH 09/10] fix(ai): skip activation target cost revalidation --- crates/engine/src/game/casting.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 4dbe952b7d..91b2cee266 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -14629,8 +14629,11 @@ pub(crate) fn pending_mana_obligation_is_stable_before_targets( player: PlayerId, pending: &PendingCast, ) -> bool { - if pending.activation_ability_index.is_some() - || casting_costs::cost_has_x(&pending.cost) + if pending.activation_ability_index.is_some() { + return true; + } + + if casting_costs::cost_has_x(&pending.cost) || pending.additional_cost_flow.is_some() || pending.deferred_required_additional_cost.is_some() || !pending.additional_cost_queue.is_empty() From e41dffb63fbd8e60c4a15dbf5c71bb4b5ab813f6 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 12:23:23 -0700 Subject: [PATCH 10/10] test(ai): keep hand target fixture reducer-valid --- .../phase-ai/src/policies/hand_disruption.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/crates/phase-ai/src/policies/hand_disruption.rs b/crates/phase-ai/src/policies/hand_disruption.rs index 24ada59de6..6681c0c7a3 100644 --- a/crates/phase-ai/src/policies/hand_disruption.rs +++ b/crates/phase-ai/src/policies/hand_disruption.rs @@ -486,24 +486,6 @@ mod tests { > target_score(TargetRef::Player(PlayerId(0))), "Peek-style hand reveal should prefer an opponent's hand over the AI's own hand" ); - - let scored = crate::search::score_candidates(&state, PlayerId(0), &config); - let score_for_target = |target| { - scored - .iter() - .find_map(|(action, score)| match action { - GameAction::ChooseTarget { - target: Some(chosen), - } if *chosen == target => Some(*score), - _ => None, - }) - .expect("target candidate should be scored") - }; - assert!( - score_for_target(TargetRef::Player(PlayerId(1))) - > score_for_target(TargetRef::Player(PlayerId(0))), - "registered AI scoring should prefer the opponent target" - ); } #[test]