Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions client/src/viewmodel/cardActionChoice.ts
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -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<string, GameAction[]> | undefined,
legalActionsByObject: Record<string, ObjectAction[]> | undefined,
objectId: ObjectId,
): GameAction[] {
): ObjectAction[] {
if (!legalActionsByObject) return [];
return legalActionsByObject[String(objectId)] ?? [];
}
Expand Down Expand Up @@ -89,9 +95,9 @@ export function resolveSingleActionDispatch(
* permission inspection.
*/
export function playOrCastActionsForObject(
legalActionsByObject: Record<string, GameAction[]> | undefined,
legalActionsByObject: Record<string, ObjectAction[]> | undefined,
objectId: ObjectId,
): GameAction[] {
): ObjectAction[] {
return collectObjectActions(legalActionsByObject, objectId).filter((a) =>
a.type === "CastSpell"
|| a.type === "CastSpellForFree"
Expand All @@ -114,7 +120,7 @@ export function playOrCastActionsForObject(
* gold with a promise that releasing will cast immediately.
*/
export function resolveDirectPlayOrCastAction(
legalActionsByObject: Record<string, GameAction[]> | undefined,
legalActionsByObject: Record<string, ObjectAction[]> | undefined,
object: GameObject | undefined,
): GameAction | null {
if (!object) return null;
Expand Down
52 changes: 41 additions & 11 deletions client/src/wasm/engine_wasm.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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];
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion crates/engine/src/ai_support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
75 changes: 73 additions & 2 deletions crates/engine/src/ai_support/targeted_exchange.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,19 @@ enum RootBinding {
impl RootBinding {
fn from_action(action: &GameAction) -> Option<Self> {
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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand All @@ -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
Expand Down
Loading
Loading