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
13 changes: 11 additions & 2 deletions crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -839,11 +839,20 @@ pub fn spell_modal_unavailable_modes(
pub fn modal_spell_mode_abilities(
obj: &crate::game::game_object::GameObject,
) -> Vec<AbilityDefinition> {
modal_spell_mode_ability_refs(obj).cloned().collect()
}

/// Borrowing view of [`modal_spell_mode_abilities`] — the same predicate
/// without the per-call clone, for read-only consumers that only inspect the
/// modes (AI classification, coverage reporting). Both share this one
/// definition of "which abilities on this object are its printed modes" so the
/// owned and borrowed forms can never disagree.
pub fn modal_spell_mode_ability_refs(
obj: &crate::game::game_object::GameObject,
) -> impl Iterator<Item = &AbilityDefinition> {
obj.abilities
.iter()
.filter(|a| a.kind == AbilityKind::Spell)
.cloned()
.collect()
}

/// CR 700.2a-b + CR 700.2f: Extends `unavailable_modes` with mode indices
Expand Down
83 changes: 74 additions & 9 deletions crates/phase-ai/src/ability_chain.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Shared helpers for walking an `AbilityDefinition`'s effect chain.
//! Shared helpers for walking an `AbilityDefinition`'s effect tree.
//!
//! `AbilityDefinition` composes a primary `effect` with an optional
//! `sub_ability` that itself has an `effect` plus another optional
Expand All @@ -8,19 +8,84 @@
//! land onto the battlefield?"), so they collect the chain into a flat
//! slice and iterate with `matches!`.
//!
//! Two branches of that definition are *conditional* rather than part of the
//! unconditional chain: `else_ability` (the CR 608.2c "Otherwise, ..." leg) and
//! `mode_abilities` (the CR 700.2 modal alternatives). Whether they belong in
//! the walk depends on the question being asked, which is what [`AbilityScope`]
//! names — see its docs. [`collect_scoped_effects`] is the single authority for
//! both walks; `collect_chain_effects` is the unconditional shorthand.
//!
//! Keep this module small — it is a single building block shared across
//! `features/*` and `policies/*`.

use engine::types::ability::{AbilityDefinition, Effect};

/// Which part of an ability tree a classification question is asking about.
///
/// The distinction is a decision-boundary one, not a convenience one:
///
/// * [`AbilityScope::Unconditional`] answers *"does resolving this ability as
/// already announced produce effect X?"* — the walk a LIVE per-action policy
/// needs. CR 601.2b makes mode selection a distinct step of announcing a
/// spell, so at `CastSpell` time no mode has been chosen yet and a modal
/// branch must not be credited to the cast.
/// * [`AbilityScope::Potential`] answers *"can this card ever produce effect
/// X?"* — the walk DECK-TIME detection needs, where every branch the card
/// could take is in scope.
///
/// A typed scope rather than a `bool` so each call site states which question
/// it is asking.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AbilityScope {
/// `effect` plus the `sub_ability` chain — everything that happens no
/// matter which branch or mode is taken.
Unconditional,
/// The full tree: `Unconditional` plus the `else_ability` branch (CR
/// 608.2c) and every entry of `mode_abilities` (CR 700.2), recursively.
Potential,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Walk an ability tree at `scope`, returning borrowed effect references in
/// traversal order.
///
/// This is the mode-aware authority both deck-time detection and live policy
/// classification share; they differ only in the `scope` they pass, so the two
/// can never drift apart on which branches exist.
pub(crate) fn collect_scoped_effects(
ability: &AbilityDefinition,
scope: AbilityScope,
) -> Vec<&Effect> {
let mut effects: Vec<&Effect> = Vec::new();
push_scoped_effects(ability, scope, &mut effects);
effects
}

fn push_scoped_effects<'a>(
ability: &'a AbilityDefinition,
scope: AbilityScope,
out: &mut Vec<&'a Effect>,
) {
out.push(&ability.effect);
if let Some(sub) = &ability.sub_ability {
push_scoped_effects(sub, scope, out);
}
if scope == AbilityScope::Unconditional {
return;
}
// CR 608.2c: the "Otherwise, ..." leg is one of two mutually exclusive
// outcomes, so it is potential rather than unconditional.
if let Some(other) = &ability.else_ability {
push_scoped_effects(other, scope, out);
}
// CR 700.2: each mode is an alternative the controller may choose.
for mode in &ability.mode_abilities {
push_scoped_effects(mode, scope, out);
}
}

/// Walk `ability.effect` plus each `sub_ability.effect` in turn, returning
/// borrowed references in chain order.
/// borrowed references in chain order. Shorthand for
/// [`collect_scoped_effects`] at [`AbilityScope::Unconditional`].
pub(crate) fn collect_chain_effects(ability: &AbilityDefinition) -> Vec<&Effect> {
let mut effects: Vec<&Effect> = vec![&ability.effect];
let mut current = &ability.sub_ability;
while let Some(sub) = current {
effects.push(&sub.effect);
current = &sub.sub_ability;
}
effects
collect_scoped_effects(ability, AbilityScope::Unconditional)
}
49 changes: 49 additions & 0 deletions crates/phase-ai/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,11 @@ pub struct PolicyPenalties {
/// scalar.)
#[serde(default = "default_loop_shortcut_winning_declare_bonus")]
pub loop_shortcut_winning_declare_bonus: f64,
/// CR 104.3d: card-equivalent weight for advancing the poison clock.
/// Critical band when the action reaches ten poison (a win), scaled by
/// clock progress below that.
#[serde(default = "default_poison_clock_pressure")]
pub poison_clock_pressure: f64,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

impl Default for PolicyPenalties {
Expand Down Expand Up @@ -527,13 +532,20 @@ impl Default for PolicyPenalties {
cycling_patience_penalty: default_cycling_patience_penalty(),
cycling_needed_land_penalty: default_cycling_needed_land_penalty(),
loop_shortcut_winning_declare_bonus: default_loop_shortcut_winning_declare_bonus(),
poison_clock_pressure: default_poison_clock_pressure(),
}
}
}

fn default_wasted_cast_penalty() -> f64 {
-8.0
}
/// CR 104.3d. Shared by `Default` and `#[serde(default)]` so a tuning artifact
/// written before this field existed still deserializes (`ai_tune` reads the
/// `policy_penalties` section directly into this struct).
fn default_poison_clock_pressure() -> f64 {
6.0
}
fn default_untap_own_tapped_bonus() -> f64 {
8.0
}
Expand Down Expand Up @@ -724,6 +736,12 @@ pub const ACTIVE_POLICY_PENALTY_FIELDS: &[&str] = &[
/// Policy penalties intentionally not present in an active CMA-ES parameter
/// vector yet.
pub const UNTUNED_POLICY_PENALTY_FIELDS: &[(&str, &str)] = &[
(
"poison_clock_pressure",
"CR 104.3d win-detector weight — a critical-band term whose magnitude is \
load-bearing for correctness, not taste. Promote to ACTIVE only with a \
paired-seed ai-gate calibration.",
),
(
"artifact_cost_payoff_bonus",
"new ArtifactSynergyPolicy knob; awaiting a paired-seed ai-gate calibration before joining the CMA-ES vector",
Expand Down Expand Up @@ -1494,6 +1512,37 @@ mod tests {
assert_eq!(p.cycling_needed_land_penalty, -2.0);
}

/// Artifact compatibility: `ai_tune` deserializes a persisted
/// `policy_penalties` section straight into `PolicyPenalties`
/// (`bin/ai_tune.rs`, `TuneGroup::Penalties`), so an artifact written
/// before `poison_clock_pressure` existed must still load — with its own
/// tuned values intact and the new field filled from the shared default.
#[test]
fn policy_penalties_load_pre_poison_clock_artifact() {
let mut artifact = serde_json::to_value(PolicyPenalties::default()).unwrap();
let object = artifact.as_object_mut().expect("serializes as object");
object
.remove("poison_clock_pressure")
.expect("field must be present before removal");
// A value CMA-ES could plausibly have tuned, to prove the round-trip
// reads the artifact rather than silently falling back to Default.
object.insert("wasted_cast_penalty".into(), serde_json::json!(-3.5));

let loaded: PolicyPenalties = serde_json::from_value(artifact)
.expect("a pre-poison_clock_pressure artifact must still deserialize");
assert_eq!(loaded.wasted_cast_penalty, -3.5, "tuned value preserved");
assert_eq!(
loaded.poison_clock_pressure,
default_poison_clock_pressure(),
"absent field must fall back to the shared default"
);
assert_eq!(
PolicyPenalties::default().poison_clock_pressure,
default_poison_clock_pressure(),
"Default and serde must share one source of truth"
);
}

#[test]
fn every_policy_penalty_is_tuning_registered_or_explicitly_untuned() {
let value = serde_json::to_value(PolicyPenalties::default()).unwrap();
Expand Down
5 changes: 5 additions & 0 deletions crates/phase-ai/src/features/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod lifegain;
pub mod mana_ramp;
pub mod mill;
pub mod plus_one_counters;
pub mod poison;
pub mod reanimator;
pub mod spellslinger_prowess;
pub mod tokens_wide;
Expand All @@ -41,6 +42,7 @@ pub use lifegain::LifegainFeature;
pub use mana_ramp::ManaRampFeature;
pub use mill::MillFeature;
pub use plus_one_counters::PlusOneCountersFeature;
pub use poison::PoisonFeature;
pub use reanimator::ReanimatorFeature;
pub use spellslinger_prowess::SpellslingerProwessFeature;
pub use tokens_wide::TokensWideFeature;
Expand Down Expand Up @@ -78,6 +80,8 @@ pub struct DeckFeatures {
pub reanimator: ReanimatorFeature,
pub mill: MillFeature,
pub energy: EnergyFeature,
/// CR 104.3d: the alternate poison win clock (toxic / infect / proliferate).
pub poison: PoisonFeature,
/// Declaration-derived: the deck's declared bracket tier. Unlike the
/// other fields here, this is not structurally detected from card text —
/// it is a per-deck declaration set at deck-analysis time from deck
Expand Down Expand Up @@ -122,6 +126,7 @@ impl DeckFeatures {
reanimator: reanimator::detect(deck),
mill: mill::detect(deck),
energy: energy::detect(deck),
poison: poison::detect(deck),
bracket_tier: tier,
}
}
Expand Down
Loading
Loading