diff --git a/client/src/viewmodel/cardActionChoice.ts b/client/src/viewmodel/cardActionChoice.ts index 4431d36260..0420d72768 100644 --- a/client/src/viewmodel/cardActionChoice.ts +++ b/client/src/viewmodel/cardActionChoice.ts @@ -1,4 +1,10 @@ -import type { GameAction, GameObject, ObjectId, WaitingFor } from "../adapter/types.ts"; +import type { + GameAction, + GameObject, + ObjectAction, + ObjectId, + WaitingFor, +} from "../adapter/types.ts"; /** * Look up the legal actions whose `source_object()` is `objectId`. @@ -10,9 +16,9 @@ import type { GameAction, GameObject, ObjectId, WaitingFor } from "../adapter/ty * — never a client-side discriminated-union introspection. */ export function collectObjectActions( - legalActionsByObject: Record | undefined, + legalActionsByObject: Record | undefined, objectId: ObjectId, -): GameAction[] { +): ObjectAction[] { if (!legalActionsByObject) return []; return legalActionsByObject[String(objectId)] ?? []; } @@ -89,9 +95,9 @@ export function resolveSingleActionDispatch( * permission inspection. */ export function playOrCastActionsForObject( - legalActionsByObject: Record | undefined, + legalActionsByObject: Record | undefined, objectId: ObjectId, -): GameAction[] { +): ObjectAction[] { return collectObjectActions(legalActionsByObject, objectId).filter((a) => a.type === "CastSpell" || a.type === "CastSpellForFree" @@ -114,7 +120,7 @@ export function playOrCastActionsForObject( * gold with a promise that releasing will cast immediately. */ export function resolveDirectPlayOrCastAction( - legalActionsByObject: Record | undefined, + legalActionsByObject: Record | undefined, object: GameObject | undefined, ): GameAction | null { if (!object) return null; diff --git a/client/src/wasm/engine_wasm.d.ts b/client/src/wasm/engine_wasm.d.ts index bb9fa1248a..290af99062 100644 --- a/client/src/wasm/engine_wasm.d.ts +++ b/client/src/wasm/engine_wasm.d.ts @@ -8,6 +8,12 @@ */ export function apply_seat_mutation(state_json: string, mutation_json: string): any; +/** + * Build the bounded card corpus for parallel AI scoring workers. The live + * main engine remains the only authority that owns the full card database. + */ +export function build_ai_card_subset(): string; + /** * Classify a deck's archetype (Aggro / Midrange / Control / Combo / Ramp) using * `phase_ai::DeckProfile::analyze`. The engine is the single authority for archetype @@ -95,17 +101,32 @@ export function export_replay_log(): string; */ export function getFormatRegistry(): any; -/** Return an opaque action proposal bound to the live engine decision domain. */ +/** + * Mint an opaque, authority-bound proposal for the AI's next action. + * + * Callers must submit it through [`submit_ai_action_proposal`]. The registry + * is local to this live WASM instance and is cleared + * on every successful state mutation, restore, resume, reset, and new game. + */ export function get_ai_action_proposal(difficulty: string, player_id: number): any; -/** Filter score-only worker output through the live contract and mint a proposal. */ + +/** + * Convert score-only worker output into an authority-bound proposal. + * + * The worker state may be old, from another game, or maliciously altered. + * Consequently this endpoint always derives a new decision contract from the + * main WASM state, discards every score whose action is not an exact member, + * and only then mints an opaque proposal. There is intentionally no public + * score-to-`GameAction` endpoint. + */ export function get_ai_action_proposal_from_scores(scores_json: string, difficulty: string, player_id: number, rng_seed: bigint): any; -/** Score candidates in an isolated worker. The result is not dispatchable. */ -export function get_ai_scored_candidates(difficulty: string, player_id: number, rng_seed: bigint): any; -/** Build the game-scoped card database used by parallel AI workers. */ -export function build_ai_card_subset(): string; -/** Apply an opaque AI proposal, yielding an applied/stale/rejected tagged result. */ -export function submit_ai_action_proposal(token: string, actor: number, action: any): any; +/** + * Score candidates inside an isolated AI worker. These are plain, + * serializable hints rather than capabilities: they cannot cross the action + * boundary until the live main engine reissues an exact proposal. + */ +export function get_ai_scored_candidates(difficulty: string, player_id: number, rng_seed: bigint): any; /** * Look up a card face by name from the loaded card database. @@ -422,6 +443,15 @@ export function signatureSpellSelectionPolicy(request: any): any; */ export function submit_action(actor: number, action: any): any; +/** + * Submit an action selected from an engine-issued AI proposal. + * + * A stale or foreign proposal is a normal race outcome and is returned as a + * tagged value. Rejected actions leave the proposal live for diagnostics or a + * retry; only a successful apply invalidates the authority generation. + */ +export function submit_ai_action_proposal(token: string, actor: number, action: any): any; + /** * Submit one opaque, engine-authored interaction response. The browser never * materializes a `GameAction`; only a successful engine reducer result exposes @@ -443,6 +473,7 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl export interface InitOutput { readonly memory: WebAssembly.Memory; readonly apply_seat_mutation: (a: number, b: number, c: number, d: number) => [number, number, number]; + readonly build_ai_card_subset: () => [number, number, number, number]; readonly classify_deck_js: (a: any) => [number, number, number]; readonly clear_game_state: () => void; readonly commanderPartnerCandidates: (a: number, b: number, c: any) => [number, number, number]; @@ -455,8 +486,7 @@ export interface InitOutput { readonly getFormatRegistry: () => any; readonly get_ai_action_proposal: (a: number, b: number, c: number) => [number, number, number]; readonly get_ai_action_proposal_from_scores: (a: number, b: number, c: number, d: number, e: number, f: bigint) => [number, number, number]; - readonly get_ai_scored_candidates: (a: number, b: number, c: bigint) => [number, number, number]; - readonly build_ai_card_subset: () => [number, number, number]; + readonly get_ai_scored_candidates: (a: number, b: number, c: number, d: bigint) => [number, number, number]; readonly get_card_face_data: (a: number, b: number) => any; readonly get_card_parse_details: (a: number, b: number) => any; readonly get_card_rulings: (a: number, b: number) => any; @@ -486,7 +516,7 @@ export interface InitOutput { readonly sideboardPolicyForFormat: (a: any) => [number, number, number]; readonly signatureSpellSelectionPolicy: (a: any) => [number, number, number]; readonly submit_action: (a: number, b: any) => any; - readonly submit_ai_action_proposal: (a: number, b: number, c: any) => any; + readonly submit_ai_action_proposal: (a: number, b: number, c: number, d: any) => any; readonly submit_interaction_js: (a: number, b: any) => any; readonly take_last_panic_message: () => [number, number]; readonly get_game_state: () => any; diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 9172d2d0a8..715260819b 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -72,7 +72,8 @@ pub use swarm::{ #[cfg(feature = "test-support")] pub use swarm::{adversarial_swarm_witness_with_counters, SwarmWitnessCounters}; pub use targeted_exchange::{ - root_may_yield_adverse_exchange, targeted_exchange_verdict, TargetedExchangeVerdict, + is_targeted_exchange_root, root_may_yield_adverse_exchange, targeted_exchange_verdict, + TargetedExchangeVerdict, }; #[cfg(feature = "test-support")] pub use targeted_exchange::{targeted_exchange_verdict_with_budget, TargetedExchangeBudget}; diff --git a/crates/engine/src/ai_support/targeted_exchange.rs b/crates/engine/src/ai_support/targeted_exchange.rs index 5eb4e04e57..6a810ab840 100644 --- a/crates/engine/src/ai_support/targeted_exchange.rs +++ b/crates/engine/src/ai_support/targeted_exchange.rs @@ -46,9 +46,19 @@ enum RootBinding { impl RootBinding { fn from_action(action: &GameAction) -> Option { match action { - GameAction::CastSpell { object_id, .. } => Some(Self::Cast { + // These announcements all enter the normal casting pipeline with + // this card object as `PendingCast::object_id`, so their bound + // spell ability is authenticated exactly like a normal cast. + GameAction::CastSpell { object_id, .. } + | GameAction::CastSpellForFree { object_id, .. } + | GameAction::CastSpellAsMiracle { object_id, .. } + | GameAction::CastSpellAsMadness { object_id, .. } => Some(Self::Cast { object_id: *object_id, }), + GameAction::CastSpellAsSneak { hand_object, .. } + | GameAction::CastSpellAsWebSlinging { hand_object, .. } => Some(Self::Cast { + object_id: *hand_object, + }), GameAction::ActivateAbility { source_id, ability_index, @@ -101,6 +111,17 @@ impl RootBinding { } } +/// Whether `action` has a stable spell or activation object that the bounded +/// targeted-exchange preview can authenticate after announcement. +/// +/// `CastPreparedCopy` and `CastParadigmCopy` intentionally remain outside this +/// class: each action names its source, while its reducer synthesizes a distinct +/// copy object before binding targets. Treating that source as the cast object +/// would inspect the wrong ability tree. +pub fn is_targeted_exchange_root(action: &GameAction) -> bool { + RootBinding::from_action(action).is_some() +} + /// Clone-free precondition for [`targeted_exchange_verdict`]. `false` PROVES the /// verdict cannot be [`TargetedExchangeVerdict::Reject`], so the caller can skip /// both the candidate enumeration and the bounded reducer replay. @@ -1358,7 +1379,7 @@ mod tests { ); } - /// H6 — hostile branch precedence: a non-cast/non-activate action is answered + /// H6 — hostile branch precedence: a non-root action is answered /// by `RootBinding::from_action`, before the guard reads anything. The fixture /// deliberately holds a Fight spell so the `false` is about the action kind. #[test] @@ -1383,6 +1404,56 @@ mod tests { ); } + #[test] + fn direct_card_cast_variants_are_targeted_exchange_roots() { + let object_id = ObjectId(1); + let card_id = CardId(1); + let actions = [ + GameAction::CastSpell { + object_id, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }, + GameAction::CastSpellAsSneak { + hand_object: object_id, + card_id, + creature_to_return: ObjectId(2), + payment_mode: CastPaymentMode::Auto, + }, + GameAction::CastSpellAsWebSlinging { + hand_object: object_id, + card_id, + creature_to_return: ObjectId(2), + payment_mode: CastPaymentMode::Auto, + }, + GameAction::CastSpellForFree { + object_id, + card_id, + source_id: ObjectId(2), + payment_mode: CastPaymentMode::Auto, + }, + GameAction::CastSpellAsMiracle { + object_id, + card_id, + payment_mode: CastPaymentMode::Auto, + }, + GameAction::CastSpellAsMadness { + object_id, + card_id, + payment_mode: CastPaymentMode::Auto, + }, + ]; + + assert!(actions.iter().all(is_targeted_exchange_root)); + assert!(!is_targeted_exchange_root(&GameAction::CastPreparedCopy { + source: object_id, + })); + assert!(!is_targeted_exchange_root(&GameAction::CastParadigmCopy { + source: object_id, + })); + } + /// H7 — carrier completeness. Mirrors /// `printed_cards::tests::walker_covers_every_nested_carrier` with /// `Effect::Fight` as the marker instead of `Effect::Conjure`. A future nested diff --git a/crates/phase-ai/src/mana_colors.rs b/crates/phase-ai/src/mana_colors.rs index 89ba21d630..7b7c3e4bfc 100644 --- a/crates/phase-ai/src/mana_colors.rs +++ b/crates/phase-ai/src/mana_colors.rs @@ -8,7 +8,7 @@ use engine::ai_support::CandidateAction; use engine::game::mana_payment::{land_subtype_to_mana_type, outer_cost_color_demand, ColorDemand}; -use engine::game::mana_sources::{activatable_mana_actions_for_player, mana_color_to_type}; +use engine::game::mana_sources::mana_color_to_type; use engine::types::ability::{ AbilityDefinition, AbilityKind, CostCategory, Effect, ManaProduction, }; @@ -16,7 +16,6 @@ use engine::types::actions::GameAction; use engine::types::game_state::GameState; use engine::types::identifiers::ObjectId; use engine::types::mana::ManaType; -use engine::types::player::PlayerId; /// Distinct colored-mana types a land can produce, unioning (a) intrinsic mana /// from its basic land subtypes (a typed dual like "Land — Plains Island" makes @@ -117,50 +116,6 @@ fn color_is_demanded(demand: ColorDemand, mana_type: ManaType) -> bool { } } -/// CR 106.3 + CR 608.2d: which color a flexible source produces during a pending -/// cast is mechanical, not a policy judgment — the source must produce a color the -/// in-flight cost demands. True when tapping `source` for `mana_type` satisfies no -/// colored pip of the pending cast *while that same source has a live mana row that -/// would*, i.e. taking this row strands the demanded pip in a `ManaPayment` -/// dead-end (a U/R dual tapped for {R} against a {2}{U} spell). -/// -/// The color is carried in each `TapLandForMana` candidate's -/// `ManaSourceSelection::mana_type`, so the choice is expressed as a *set of -/// candidates* and must be resolved by eliminating the stranding ones rather than -/// by returning a color. -/// -/// Deliberately scoped to the color only: it never compares two different sources, -/// so *which* source to tap remains the strategic judgment it should be. If the -/// source cannot produce a demanded color at all, nothing is stranded and this is -/// false — tapping it for an undemanded color may still be a fine way to pay -/// generic. -/// -pub(crate) fn tap_strands_demanded_color( - state: &GameState, - player: PlayerId, - source: ObjectId, - mana_type: ManaType, -) -> bool { - let Some(pending_cast) = state.pending_cast.as_deref() else { - return false; - }; - let demand = outer_cost_color_demand(&pending_cast.cost); - if color_is_demanded(demand, mana_type) { - return false; - } - // Only reached for an undemanded color, so the enumeration is off the hot - // path for every correctly-colored tap. - activatable_mana_actions_for_player(state, player) - .iter() - .any(|action| match action { - GameAction::TapLandForMana { selection } => { - selection.source.object_id == source - && color_is_demanded(demand, selection.mana_type) - } - _ => false, - }) -} - /// CR 702.51a (Convoke) / CR 702.126a (Improvise) / Waterbend: whether tapping /// `object_id` for its Colorless convoke-family marker should be rejected /// because a currently-legal sibling candidate at this exact `ManaPayment` @@ -171,13 +126,9 @@ pub(crate) fn tap_strands_demanded_color( /// single tap on the SAME permanent, but the native ability can still cover /// the trailing generic slot once colored demand clears (or pay the colored /// pip directly), while the Colorless marker can never retroactively produce -/// a stranded color. Companion to `tap_strands_demanded_color` above — that -/// function fixed this same dead-end bug class for land tap-color selection; -/// this is the convoke-family tap-channel-selection variant: nothing -/// previously preferred a permanent's native colored channel over its -/// Colorless convoke-family marker, so a dual-purpose permanent (e.g. an -/// artifact land that also taps for a color) could be spent via the marker -/// first, permanently stranding a colored pip and dead-ending `ManaPayment`. +/// a stranded color. A dual-purpose permanent (e.g. an artifact land that +/// also taps for a color) could otherwise be spent via the marker first, +/// permanently stranding a colored pip and dead-ending `ManaPayment`. pub(crate) fn convoke_native_tap_still_demanded( state: &GameState, candidates: &[CandidateAction], @@ -246,95 +197,3 @@ fn sibling_native_tap_pays_demand( _ => false, } } - -#[cfg(test)] -mod tests { - use super::*; - use engine::types::ability::{Effect, QuantityExpr, ResolvedAbility, TargetFilter}; - use engine::types::game_state::PendingCast; - use engine::types::identifiers::{CardId, ObjectId}; - use engine::types::mana::{ManaCost, ManaCostShard}; - use engine::types::player::PlayerId; - - /// Battlefield fixture: one land for `P0` with `oracle_text`, plus a pending - /// cast of `{2}{U}` — the shape of the measured `Metallic Rebuke` repro. - fn state_with_land(oracle_text: &str) -> (GameState, ObjectId) { - let mut scenario = engine::game::scenario::GameScenario::new(); - let land = scenario - .add_land_from_oracle(PlayerId(0), "Test Dual", oracle_text) - .id(); - let runner = scenario.build(); - let mut state = runner.state().clone(); - state.pending_cast = Some(Box::new(PendingCast::new( - ObjectId(900), - CardId(900), - ResolvedAbility::new( - Effect::Draw { - count: QuantityExpr::Fixed { value: 0 }, - target: TargetFilter::Controller, - }, - Vec::new(), - ObjectId(900), - PlayerId(0), - ), - ManaCost::Cost { - shards: vec![ManaCostShard::Blue], - generic: 2, - }, - ))); - (state, land) - } - - /// The repro, at the unit level: a U/R source tapped for {R} against {2}{U} - /// strands the blue pip, because that same source could have produced {U}. - #[test] - fn strands_when_source_can_produce_the_demanded_color() { - let (state, land) = state_with_land("{T}: Add {U} or {R}."); - assert!(tap_strands_demanded_color( - &state, - PlayerId(0), - land, - ManaType::Red - )); - } - - /// The demanded color is never stranding — this is the row the gate must keep. - #[test] - fn demanded_color_does_not_strand() { - let (state, land) = state_with_land("{T}: Add {U} or {R}."); - assert!(!tap_strands_demanded_color( - &state, - PlayerId(0), - land, - ManaType::Blue - )); - } - - /// Sibling negative — the reason this is scoped to ONE source. A mono-red - /// source cannot produce {U}, so tapping it for {R} strands nothing: that is - /// a legitimate way to pay the {2} generic. Rejecting it would break ordinary - /// payment, which is the over-reject failure this test exists to catch. - #[test] - fn undemanded_color_does_not_strand_when_source_cannot_produce_the_demand() { - let (state, land) = state_with_land("{T}: Add {R}."); - assert!(!tap_strands_demanded_color( - &state, - PlayerId(0), - land, - ManaType::Red - )); - } - - /// No pending cast ⇒ no in-flight demand ⇒ nothing to strand. - #[test] - fn no_pending_cast_never_strands() { - let (mut state, land) = state_with_land("{T}: Add {U} or {R}."); - state.pending_cast = None; - assert!(!tap_strands_demanded_color( - &state, - PlayerId(0), - land, - ManaType::Red - )); - } -} diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 95f0827437..11c12eb1fd 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -6,9 +6,9 @@ use rand_chacha::ChaCha20Rng; use engine::ai_support::{ build_decision_context, build_decision_context_for_semantic_owner, certify_fetch_then_cast, - certify_pact_plan, is_pact_payment_cast, root_may_yield_adverse_exchange, - targeted_exchange_verdict, validated_candidate_actions_for_semantic_owner, AiDecisionContract, - TargetedExchangeVerdict, + certify_pact_plan, is_pact_payment_cast, is_targeted_exchange_root, + root_may_yield_adverse_exchange, targeted_exchange_verdict, + validated_candidate_actions_for_semantic_owner, AiDecisionContract, TargetedExchangeVerdict, }; use engine::types::ability::{ AbilityDefinition, ContinuousModification, Duration, Effect, ResolvedAbility, StaticDefinition, @@ -545,10 +545,7 @@ fn fast_priority_action( /// `targeted_exchange_verdict`, the old path returned `true` on every root this /// one short-circuits. fn root_action_is_allowed(state: &GameState, ai_player: PlayerId, action: &GameAction) -> bool { - if !matches!( - action, - GameAction::CastSpell { .. } | GameAction::ActivateAbility { .. } - ) { + if !is_targeted_exchange_root(action) { return true; } if !root_may_yield_adverse_exchange(state, action) { diff --git a/crates/phase-ai/src/tactical_gate.rs b/crates/phase-ai/src/tactical_gate.rs index 054e50bba7..085356a42e 100644 --- a/crates/phase-ai/src/tactical_gate.rs +++ b/crates/phase-ai/src/tactical_gate.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; -use engine::ai_support::{AiDecisionContext, CandidateAction}; +use engine::ai_support::{ + is_targeted_exchange_root, targeted_exchange_verdict, AiDecisionContext, CandidateAction, + TargetedExchangeVerdict, +}; use engine::game::combat::AttackTarget; use engine::types::ability::{AbilityCondition, Effect, PtValue, TargetFilter, TargetRef}; use engine::types::actions::GameAction; @@ -203,33 +206,23 @@ fn assess_candidate(ctx: &PolicyContext<'_>) -> GateDecision { // `search::fallback_action`, which emits CancelCast when the scored // pool is empty. GameAction::CancelCast => GateDecision::Reject, - // CR 106.3 + CR 608.2d: A flexible source's color is mechanical during a - // pending cast, not a policy judgment. Enumerating one candidate per - // (source, color) row lets the scorer pick an arbitrary color and tap a - // U/R dual for {R} against a {2}{U} spell, stranding the blue pip in a - // ManaPayment dead-end with no untapped source left to repair it. - // Rejecting only the stranding rows leaves at least the demanded-color - // row of that same source in the pool, so the choice of WHICH source to - // tap stays strategic. Mirrors the `ChooseManaColor` pre-emption in - // `search::choose_action_with_session`, which fixes the prompt-shaped - // expression of this same choice. - GameAction::TapLandForMana { selection } => { - if crate::mana_colors::tap_strands_demanded_color( - ctx.state, - ctx.ai_player, - selection.source.object_id, - selection.mana_type, - ) { - GateDecision::Reject - } else { - GateDecision::Allow - } - } _ => GateDecision::Allow, } } fn assess_pre_cast(ctx: &PolicyContext<'_>) -> GateDecision { + // CR 601.2c + CR 608.2c: Target-sourced self-damage and fight exchanges are + // evaluated from reducer-issued, fully-bound target paths before scoring. + // `Indeterminate` stays fail-open: this is a proof-backed veto only. + if is_targeted_exchange_root(&ctx.candidate.action) + && matches!( + targeted_exchange_verdict(ctx.state, ctx.candidate), + TargetedExchangeVerdict::Reject + ) + { + return GateDecision::Reject; + } + // CR 608.2c: Reject abilities whose source-type condition is known to fail. // E.g. Figure of Fable's "{1}{G/W}{G/W}: If this creature is a Scout, ..." when // the source is not currently a Scout. The ability is legal to activate but wastes mana.