Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c4f1940
fix(ai): apply the mulligan card-count floor to every deck, not just …
matthewevans Jul 27, 2026
b4d1f60
docs(ai): correct a false architectural claim in the mulligan floor d…
matthewevans Jul 27, 2026
de0a581
feat(phase-ai): value mana development with a fixed-coefficient serve…
matthewevans Jul 28, 2026
7ec5c70
fix(phase-ai): tier cleanup discard and sacrifice selection by mana role
matthewevans Jul 28, 2026
8bd50d2
feat(phase-ai): gate sacrifice costs on the mana the payment would de…
matthewevans Jul 28, 2026
39834f0
feat(phase-ai): value mana development as a differential, not an abso…
matthewevans Jul 28, 2026
18090a1
test(engine): prove Notion Thief's controller actually draws at runtime
matthewevans Jul 28, 2026
ce3eb23
fix(phase-ai): veto certified-losing self-cost trades, and stop prici…
matthewevans Jul 28, 2026
0387d5f
chore(phase-ai): refresh the duel-suite baseline after the AI land ca…
matthewevans Jul 28, 2026
0bffb54
fix(phase-ai): price a draw at what the pipeline will actually deliver
matthewevans Jul 28, 2026
92a0df0
feat(phase-ai): price an owned commander at what it costs to get it back
matthewevans Jul 28, 2026
cd87f58
chore(ci): re-freeze the Draw-replacement producer census
matthewevans Jul 28, 2026
4028bdd
merge: resolve main into ai land decisions
matthewevans Jul 28, 2026
f63edab
fix: address AI mana decision review feedback
matthewevans Jul 28, 2026
0568ec9
fix: make review regressions testable
matthewevans Jul 29, 2026
404f9b6
fix(ai): prevent stranding demanded mana colors
matthewevans Jul 29, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 19 additions & 2 deletions crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,15 @@ pub(crate) fn begin_variable_speed_payment(

/// CR 107.3a + CR 118.3: X in an activation/additional cost is chosen as part
/// of activating or casting, bounded by the resources available to pay fully.
pub(crate) fn sacrifice_cost_bounds(count: u32, eligible_len: usize) -> (usize, usize) {
///
/// `pub` as the single authority for the `u32::MAX` X-sentinel encoding, so a
/// cast-time cost gate in `phase-ai` reads the engine's own minimum rather than
/// re-spelling the sentinel. That is a *prospective* single-authority argument,
/// not a measured divergence: for every `count < u32::MAX` this returns
/// `(n, n)`, which a hand-rolled `count as usize` matches exactly. It becomes
/// load-bearing the day a third bounds shape ("up to N") is added, at which
/// point a copy would desynchronize with no compile error.
pub fn sacrifice_cost_bounds(count: u32, eligible_len: usize) -> (usize, usize) {
if count == u32::MAX {
(0, eligible_len)
} else {
Expand Down Expand Up @@ -16385,7 +16393,16 @@ fn find_pay_life_cost(
/// CR 118.3: Find permanents controlled by `player` matching `filter` on the battlefield.
/// The source is eligible when it matches the printed filter; "another" is
/// represented by `FilterProp::Another` and enforced by `matches_target_filter`.
pub(super) fn find_eligible_sacrifice_targets(
///
/// The single authority for sacrifice-cost eligibility. Three conditions, and all
/// three matter: controller (CR 701.21a — "a player can't sacrifice … something
/// that's a permanent they don't control"), the `player_cant_sacrifice_as_cost`
/// static (Yasharn, Angel of Jubilation), and the filter itself. Callers must not
/// re-derive this — an AI-side copy that omits the static check over-counts
/// eligible fodder under a "players can't sacrifice" effect. `pub` so `phase-ai`'s
/// cast-time cost gates share it with the payment path in `cost_payability.rs` and
/// `casting_costs.rs`.
pub fn find_eligible_sacrifice_targets(
state: &GameState,
player: PlayerId,
source_id: ObjectId,
Expand Down
185 changes: 185 additions & 0 deletions crates/engine/src/game/game_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2477,6 +2477,102 @@ impl GameObject {
self.phase_status.is_phased_in()
}

/// CR 712.8a + CR 708.2a: the mana value this permanent will show **once it
/// leaves the battlefield** — the quantity a caller pricing a battlefield exit
/// must use, and which `self.mana_cost` and `self.base_mana_cost` each get
/// wrong in a different case.
///
/// # Scope: BATTLEFIELD EXIT ONLY. Deliberately not zone-parameterized.
///
/// `zones::apply_zone_exit_cleanup` (`zones.rs:137`) restores the stashed face
/// through **three independent gates**, not one disjunction, and only two of the
/// three are unconditional:
///
/// | flag | gate in `apply_zone_exit_cleanup` | at `from = Battlefield` |
/// |---|---|---|
/// | `transformed` (`:261`, CR 712.8a + CR 400.7) | no zone gate at all | reverts |
/// | `modal_back_face` (`:273`, CR 712.8a + CR 400.7) | `to != Stack && to != Battlefield` | reverts for any non-stack, non-battlefield destination |
/// | `face_down` (`:286`, CR 708.9) | `from == Battlefield \|\| (from == Stack && to != Battlefield)` | reverts |
///
/// **Each flag INDEPENDENTLY reverts for `Battlefield -> Graveyard`** (CR
/// 701.21a's transition), which is what the disjunction below relies on. The
/// claim is deliberately per-flag: the disjunction also admits flag
/// COMBINATIONS, and one is real and handled in-tree — see the next section.
/// None of the three is exact for a battlefield-to-STACK move, where the
/// `modal_back_face` gate does not fire. A destination parameter is not added:
/// only the battlefield-exit value is proven, and a parameter with one proven
/// value is speculative generality. Add it when a second transition earns its
/// own per-flag proof.
///
/// # The one flag combination that occurs, and why it is still correct
///
/// A **flipped permanent that is then turned face down** (Ixidron, Cyber
/// Conversion — CR 712.16 does not cover flip cards, so this is legal) sets
/// `flipped` AND `face_down` at once, and the two statuses **share the single
/// `back_face` slot**. `effects::turn_face_down` (`turn_face_down.rs:66-69`)
/// keeps the FLIP stash there rather than overwriting it with a base snapshot,
/// and `zones::apply_zone_exit_cleanup` runs the CR 708.9 face-down restore
/// BEFORE the CR 710.4 flip revert (`zones.rs:300-309`) precisely so one slot
/// serves both.
///
/// So for that object `back_face` holds the flip card's NORMAL half, not the
/// base face — and reading it is not merely harmless, it is REQUIRED. Turning
/// the permanent face down set both `mana_cost` and `base_mana_cost` to
/// `ManaCost::NoCost` (`morph.rs:47`, CR 708.2a), so the `None =>` arm would
/// return 0 here. The `face_down` disjunct is what routes this object to the
/// stash instead. CR 710.1c ("A flip card's color and mana cost don't change if
/// the permanent is flipped") makes that stash mana-cost-identical to the
/// printed cost, so the number returned is right. Pinned by F8-E arm (e).
///
/// `flipped` is **not** a fourth conjunct, and this is settled — do not
/// re-chase it. CR 710.1c again: `flip::apply_flipped_face_to_object`
/// (`flip.rs:320`) leaves `mana_cost` and `base_mana_cost` untouched by design
/// (`flip.rs:363-365`), so for a flipped-but-face-UP permanent the `None =>`
/// arm already returns the right number.
///
/// # Why not `self.mana_cost`
///
/// It is the live, layer-mutable characteristic (CR 613.1). `layers::
/// seed_live_characteristics_from_base` re-seeds it from `base_mana_cost` at the
/// top of every layer pass, and `printed_cards::apply_copiable_values`
/// (`printed_cards.rs:644`) then overwrites **only** the live field — it writes
/// no `base_*` field at all. CR 903.3's own example puts a copied commander in
/// scope here ("A commander that's copying another card … is still a commander").
///
/// # Why not `self.base_mana_cost` alone
///
/// `printed_cards::apply_back_face_to_object` (`:287`) writes **both** the live
/// (`:295`) and base (`:308`) fields from the installed face, and
/// `morph::apply_face_down_creature_characteristics` (`morph.rs:47`) sets both to
/// `ManaCost::NoCost` (CR 708.2a). For those objects `base_mana_cost` describes
/// the face currently shown, not the face the card will show off the battlefield.
///
/// # Known residual, stated per flag rather than as a blanket claim
///
/// The `back_face` slot is **shared** by the transform, MDFC, face-down and flip
/// stashes, and its writers do not agree on which snapshot they take:
/// * `transform.rs:85`/`:90` stash `printed_cards::snapshot_object_face`, which
/// captures the **live** `mana_cost` (`printed_cards.rs:717`). A permanent
/// transformed while already under a mana-cost-altering copy effect therefore
/// parks a polluted value, and this method will read it.
/// * `effects/turn_face_down.rs:68` stashes `snapshot_object_base_face` — the
/// **printed** baseline. That path is clean.
/// The divergence is **BIDIRECTIONAL**, not downward-only: `intrinsic_copiable_
/// values` (`printed_cards.rs:486`) sources `obj.base_mana_cost` from the COPY
/// SOURCE and `apply_copiable_values` writes it to the RECIPIENT's live field
/// with no clamp, so a `{1}{U}` Clone copying a fifteen-drop ends with live 15
/// against base 2. Tracked as task #36; the fix is an engine change at the
/// transform site and is out of this unit's scope.
pub fn mana_value_on_battlefield_exit(&self) -> u32 {
let shows_stashed_face = self.transformed || self.modal_back_face || self.face_down;
match self.back_face.as_ref().filter(|_| shows_stashed_face) {
Some(stashed) => stashed.mana_cost.mana_value(),
// CR 613.1: "For a card, [the characteristics are] the values printed on
// that card" — `base_mana_cost` is the engine's name for that baseline.
None => self.base_mana_cost.mana_value(),
}
}

/// CR 702.26b: Whether this object is currently phased out (treated as
/// though it doesn't exist for almost all rules queries).
pub fn is_phased_out(&self) -> bool {
Expand Down Expand Up @@ -3685,4 +3781,93 @@ mod tests {
"a Room card in hand combines both halves (CR 709.4b): {{U}} + {{4}}{{U}} = 6"
);
}

fn exit_value_fixture(id: u64, mana_value: u32) -> GameObject {
let mut obj = GameObject::new(
ObjectId(id),
CardId(id),
PlayerId(0),
"Exit Value Fixture".to_string(),
Zone::Battlefield,
);
obj.mana_cost = ManaCost::generic(mana_value);
obj.base_mana_cost = ManaCost::generic(mana_value);
obj
}

fn install_stashed_face(obj: &mut GameObject, mana_value: u32) {
let mut stashed = exit_value_fixture(obj.id.0 + 1000, mana_value);
stashed.name = "Stashed Face".to_string();
obj.back_face = Some(crate::game::printed_cards::snapshot_object_face(&stashed));
}

/// CR 712.8a + CR 708.2a: the exit accessor reads the restored face, not a
/// currently displayed transformed or face-down shell. The paired
/// flipped-only and flipped-plus-face-down arms pin CR 710.1c's distinction.
#[test]
fn mana_value_on_battlefield_exit_uses_the_face_that_will_be_restored() {
let mut transformed = exit_value_fixture(1, 0);
transformed.transformed = true;
install_stashed_face(&mut transformed, 5);
assert_eq!(
transformed.mana_value_on_battlefield_exit(),
5,
"transformed DFC restores its stashed face"
);

let mut face_down = exit_value_fixture(2, 0);
face_down.face_down = true;
face_down.mana_cost = ManaCost::NoCost;
face_down.base_mana_cost = ManaCost::NoCost;
install_stashed_face(&mut face_down, 4);
assert_eq!(
face_down.mana_value_on_battlefield_exit(),
4,
"face-down permanent restores its stashed identity"
);

let mut copied_cost_strip = exit_value_fixture(3, 4);
copied_cost_strip.mana_cost = ManaCost::NoCost;
assert_eq!(
copied_cost_strip.mana_value_on_battlefield_exit(),
4,
"a live cost change does not alter the printed exit cost"
);

let untouched = exit_value_fixture(4, 3);
assert_eq!(
untouched.mana_value_on_battlefield_exit(),
3,
"an untouched permanent uses its printed baseline"
);

let mut flipped_then_face_down = exit_value_fixture(5, 0);
flipped_then_face_down.flipped = true;
flipped_then_face_down.face_down = true;
flipped_then_face_down.mana_cost = ManaCost::NoCost;
flipped_then_face_down.base_mana_cost = ManaCost::NoCost;
install_stashed_face(&mut flipped_then_face_down, 2);
assert_eq!(
flipped_then_face_down.mana_value_on_battlefield_exit(),
2,
"the face-down gate must preserve a flipped card's normal-half stash"
);

let mut flipped_only = exit_value_fixture(6, 2);
flipped_only.flipped = true;
install_stashed_face(&mut flipped_only, 9);
assert_eq!(
flipped_only.mana_value_on_battlefield_exit(),
2,
"CR 710.1c: flipped-only does not route through the stash"
);

let mut missing_stash = exit_value_fixture(7, 4);
missing_stash.face_down = true;
assert_eq!(
missing_stash.mana_value_on_battlefield_exit(),
4,
"a missing stash falls back to the baseline rather than panicking"
);
}
}
Loading
Loading