diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 7cff6adf57..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,6 +1796,34 @@ pub fn export_game_state_json() -> Result { })? } +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(|| { + "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 { + 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| { + *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. @@ -1810,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_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 @@ -1818,11 +1847,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; - CARD_DB.with(|cell| { - if let Some(db) = cell.borrow().as_ref() { - rehydrate_game_from_card_db(&mut state, db); - } - }); finalize_public_state(&mut state); bind_interaction_session(&mut state); GAME_STATE.with(|cell| cell.set(Some(state))); @@ -1875,7 +1899,8 @@ pub fn resume_multiplayer_host_state(json_str: &str) -> Result<(), JsValue> { )); } - let mut state = decode_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 @@ -1888,11 +1913,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; - CARD_DB.with(|cell| { - if let Some(db) = cell.borrow().as_ref() { - rehydrate_game_from_card_db(&mut state, db); - } - }); finalize_public_state(&mut state); bind_interaction_session(&mut state); @@ -1907,6 +1927,25 @@ pub fn resume_multiplayer_host_state(json_str: &str) -> Result<(), JsValue> { Ok(()) } +#[cfg(test)] +mod restored_card_db_requirements_tests { + use super::*; + + #[test] + 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 error = decode_and_rehydrate_restored_game_state(&json) + .expect_err("restore must require CARD_DB"); + assert!(error.contains("card database")); + assert!(GAME_STATE.with(|cell| cell.replace(None).is_none())); + assert!(!is_multiplayer_mode()); + } +} + // ── Replay system ─────────────────────────────────────────────────────── // // Recording: `initialize_game` auto-starts a `ReplayLog` (REPLAY_LOG) and @@ -2644,11 +2683,12 @@ 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, ContinuousModification, Duration, Effect, QuantityExpr, QuantityRef, ResolvedAbility, - TargetFilter, + TargetFilter, TargetRef, }; use engine::types::card::CardFace; use engine::types::card_type::{CardType, CoreType}; @@ -2660,7 +2700,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 +2778,122 @@ 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_final_target_state(pool: usize) -> (GameState, TargetRef) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + 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(P0, "Fireball", true) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::X, ManaCostShard::Red], + generic: 0, + }) + .with_strive_cost(ManaCost::Cost { + 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..pool) + .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"); + 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, TargetRef::Object(final_target)) + } + /// 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 +3024,121 @@ 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_final_target_filters_unpayable_surcharge_and_keeps_payable_sibling() { + clear_game_state(); + // {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", + ); + 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(); + + 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] fn proposal_boundary_rejects_changed_x_target_and_payment_arguments() { let player = PlayerId(0); @@ -3684,6 +3955,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 +3985,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 +4028,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 +4056,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 +4163,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 +4333,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..ff404bf1ca 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,17 +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 - .into_iter() + let mut actions: Vec = current_legal_targets + .iter() + .cloned() .map(|target| { candidate( GameAction::ChooseTarget { @@ -5970,7 +5975,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 +6013,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..c0d145b00a 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,17 @@ 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. 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 target_selection_requires_reducer_validation(state) { + candidates = FilterPipeline::default_pipeline().apply(state, candidates); + } candidates.sort_by(|left, right| left.action.cmp_stable(&right.action)); candidates }, @@ -97,6 +102,29 @@ impl AiDecisionContract { } } +pub(crate) fn target_selection_requires_reducer_validation(state: &GameState) -> bool { + let WaitingFor::TargetSelection { + player, + pending_cast, + target_slots, + selection, + .. + } = &state.waiting_for + else { + return false; + }; + + // 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 { match (issued, submitted) { ( diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index d2322515f7..1f7f720675 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -2014,8 +2014,16 @@ 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 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 @@ -5985,7 +5993,7 @@ mod tests { } #[test] - fn target_selection_legal_actions_do_not_simulate_each_target() { + fn target_selection_legal_actions_use_current_targets_without_simulation() { let mut state = setup_priority(); let targets: Vec = (0..25) .map(|i| { @@ -6033,6 +6041,14 @@ mod tests { assert_eq!(counters.state_clone_for_legality, 0); assert_eq!(counters.priority_cast_probe_builds, 0); + assert_eq!( + actions + .iter() + .filter(|action| matches!(action, GameAction::ChooseTarget { target: Some(_) })) + .count(), + 25 + ); + assert!(actions.contains(&GameAction::ChooseTarget { target: None })); assert_eq!(actions.len(), 26); assert!(spell_costs.is_empty()); assert!(grouped.is_empty()); @@ -6408,7 +6424,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, diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 6c7205061f..91b2cee266 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -14624,13 +14624,16 @@ 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, ) -> 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() @@ -14691,6 +14694,17 @@ pub(super) 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 { diff --git a/crates/phase-ai/src/policies/hand_disruption.rs b/crates/phase-ai/src/policies/hand_disruption.rs index 974102c77b..6681c0c7a3 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 { @@ -483,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] 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).