diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 175535c939..26c8757475 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1095,6 +1095,16 @@ export interface GameObject { abilities: SerializedAbility[]; color: ManaColor[]; printed_ref?: PrintedRef | null; + /** + * Engine-owned discriminant for what this stored half actually IS. The + * `back_face` slot is shared by several printed layouts, so its presence + * alone does NOT mean the object is double-faced: CR 710 Kamigawa flip + * cards park their alternative (bottom) half here, and Adventure/Omen + * cards park their alternative spell here. Only `"Transform"`, `"Modal"`, + * and `"Meld"` are real second faces (CR 712). Absent when the engine has + * no layout to report. + */ + layout_kind?: LayoutKind | null; } | null; /** * CR 702.143c-d: Whether this card in exile is foretold. Its owner may look @@ -1109,6 +1119,22 @@ export interface PrintedRef { face_name: string; } +/** + * Mirror of the engine's `types::card::LayoutKind` (serialized as its plain + * variant name). Describes the printed layout that produced an object's stored + * `back_face`. + */ +export type LayoutKind = + | "Single" + | "Split" + | "Flip" + | "Transform" + | "Meld" + | "Adventure" + | "Modal" + | "Omen" + | "Prepare"; + export interface ObjectIncarnationRef { object_id: ObjectId; incarnation: number; @@ -2374,6 +2400,8 @@ export type GameEvent = | { type: "BecomesTarget"; data: { target: TargetRef; source_id: ObjectId } } | { type: "ReplacementApplied"; data: { source_id: ObjectId; event_type: string } } | { type: "Transformed"; data: { object_id: ObjectId } } + // CR 710.4: a Kamigawa flip permanent flipped to its alternative face. + | { type: "Flipped"; data: { object_id: ObjectId } } | { type: "DayNightChanged"; data: { new_state: string } } | { type: "TurnedFaceUp"; data: { object_id: ObjectId } } | { type: "TurnedFaceDown"; data: { object_id: ObjectId } } diff --git a/client/src/components/card/ArtCropCard.tsx b/client/src/components/card/ArtCropCard.tsx index 69603328ca..d5cffe3437 100644 --- a/client/src/components/card/ArtCropCard.tsx +++ b/client/src/components/card/ArtCropCard.tsx @@ -9,7 +9,7 @@ import { cardImageLookup, tokenFiltersForObject } from "../../services/cardImage import { CARD_BACK_URL } from "../../services/scryfall.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; -import { COUNTER_COLORS, computePTDisplay, toRoman } from "../../viewmodel/cardProps.ts"; +import { COUNTER_COLORS, computePTDisplay, hasOtherPrintedFace, toRoman } from "../../viewmodel/cardProps.ts"; import { CounterTooltip } from "../ui/CounterTooltip.tsx"; import { LoyaltyBadge } from "../ui/LoyaltyBadge.tsx"; import { CardArtFallback } from "./CardArtFallback.tsx"; @@ -83,7 +83,10 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr const src = obj.face_down ? CARD_BACK_URL : cardSrc; const isLoading = obj.face_down ? false : cardLoading; - const hasDfc = !obj.face_down && obj.back_face != null; + // CR 712 vs CR 710: `back_face != null` is NOT "has a second face" — a + // Kamigawa flip card stores its alternative half in the same slot and has no + // face 1 to inspect. Use the engine-provided layout discriminant. + const hasDfc = !obj.face_down && hasOtherPrintedFace(obj); // Filter out loyalty counters — shown separately as the loyalty badge const counters = Object.entries(obj.counters).filter((entry): entry is [string, number] => entry[1] != null && entry[0] !== "loyalty"); const devotionValue = obj.devotion ?? null; diff --git a/client/src/viewmodel/cardProps.ts b/client/src/viewmodel/cardProps.ts index abcbbc418c..4ce69f1a8d 100644 --- a/client/src/viewmodel/cardProps.ts +++ b/client/src/viewmodel/cardProps.ts @@ -213,6 +213,24 @@ export function formatTypeLine(cardTypes: CardType, keywords?: Keyword[]): strin return main; } +/** + * True when `obj`'s stored `back_face` is a SEPARATELY PRINTED face the UI can + * present on its own — a CR 712 transform/modal/meld back face, or an + * Adventure/Omen/Prepare alternative spell. + * + * False for CR 710 Kamigawa flip cards. Their alternative half is printed + * upside down on the SAME physical face, so there is no second face to inspect + * (CardPreview renders it as a 180° rotation instead). The `back_face` slot is + * shared by all of these layouts, so `back_face != null` is NOT the predicate: + * using it gives all 21 flip cards a bogus DFC badge and an "inspect face 1" + * button pointing at a face that doesn't exist. The engine owns the + * discriminant — it ships `layout_kind` on the serialized back face (the same + * value `engine::game::transform::is_double_faced_permanent` keys on). + */ +export function hasOtherPrintedFace(obj: Pick): boolean { + return obj.back_face != null && obj.back_face.layout_kind !== "Flip"; +} + export function computePTDisplay(obj: GameObject): PTDisplay | null { if (obj.power == null || obj.toughness == null) return null; diff --git a/crates/engine/src/analysis/ability_graph.rs b/crates/engine/src/analysis/ability_graph.rs index 3eea0c026b..6a108ffd01 100644 --- a/crates/engine/src/analysis/ability_graph.rs +++ b/crates/engine/src/analysis/ability_graph.rs @@ -897,6 +897,9 @@ fn effect_projection(effect: &Effect) -> Projection { | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + // CR 710.4: a flip instruction carries no nested ability edge, exactly + // like `Transform`. + | Effect::FlipPermanent { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealHand { .. } | Effect::RevealFromHand { .. } diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index e6127d76fe..2e3dac6919 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2804,6 +2804,8 @@ fn legacy_effect(x: &Effect) -> bool { | Effect::HideawayConceal { target } | Effect::ChooseCard { target, .. } | Effect::Transform { target } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target } | Effect::Shuffle { target } | Effect::Reveal { target } | Effect::TargetOnly { target } @@ -4891,6 +4893,10 @@ fn rw_effect( } => obj(StateKind::ObjectPt, target), Effect::SwitchPT { target } => obj(StateKind::ObjectPt, target), Effect::Transform { target } => obj(StateKind::ObjectPt, target), + // CR 710.1b: flipping replaces the permanent's power and toughness + // (along with its name, type line, and text box) — the same + // `ObjectPt` write axis `Transform` records. + Effect::FlipPermanent { target } => obj(StateKind::ObjectPt, target), Effect::BecomeCopy { target, recipient, diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index e7b79a6f74..5baecda62b 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -1040,6 +1040,13 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes { acc = acc.or(scan_target_filter(target, target_ctx, mode)); acc } + // CR 710.4: identical scan shape to `Transform` — the only read is the + // effect's own target filter. + Effect::FlipPermanent { target } => { + let mut acc = Axes::NONE; + acc = acc.or(scan_target_filter(target, target_ctx, mode)); + acc + } Effect::SearchLibrary { .. } => Axes::CONSERVATIVE, Effect::SearchOutsideGame { filter, @@ -5286,6 +5293,8 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext { | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + // CR 710.4: same single-target read context as `Transform`. + | Effect::FlipPermanent { .. } | Effect::SearchLibrary { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealHand { .. } @@ -5667,6 +5676,9 @@ fn effect_census_role(e: &Effect) -> CensusRole { | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + // CR 710.4: a flip reads only its own self-referential target — not a + // board census, mirroring `Transform`. + | Effect::FlipPermanent { .. } | Effect::TargetOnly { .. } | Effect::Choose { .. } | Effect::ChooseDamageSource { .. } @@ -5937,6 +5949,9 @@ fn effect_resolution_choice_freedom(e: &Effect) -> ResolutionChoiceFreedom { | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + // CR 710.4: `flip_permanent` offers no resolution-time choice (it is a + // status change or a silent no-op), exactly like `Transform`. + | Effect::FlipPermanent { .. } | Effect::SearchLibrary { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealHand { .. } @@ -6203,6 +6218,8 @@ pub(crate) fn effect_is_randomness_bearing(e: &Effect) -> bool { | Effect::Mana { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + // CR 710.4: flipping is deterministic — no RNG draw, mirroring `Transform`. + | Effect::FlipPermanent { .. } | Effect::SearchLibrary { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealFromHand { .. } diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index 84f727bff0..01803c210f 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -4834,6 +4834,8 @@ fn concretize_granting_object_in_effect(effect: &mut Effect, granter: ObjectId) | Effect::Pump { target, .. } | Effect::Counter { target, .. } | Effect::Transform { target, .. } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target, .. } | Effect::Connive { target, .. } | Effect::PhaseOut { target } | Effect::PhaseIn { target } diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 7ff4b55ebd..df676349f2 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -2352,6 +2352,8 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { | Effect::ForceBlock { target } | Effect::ForceAttack { target, .. } | Effect::Transform { target } + // CR 710.4: the flipping permanent is the effect's single reported target. + | Effect::FlipPermanent { target } | Effect::Shuffle { target } | Effect::Reveal { target } | Effect::Regenerate { target } diff --git a/crates/engine/src/game/effects/flip_permanent.rs b/crates/engine/src/game/effects/flip_permanent.rs new file mode 100644 index 0000000000..6e5a234c87 --- /dev/null +++ b/crates/engine/src/game/effects/flip_permanent.rs @@ -0,0 +1,263 @@ +use crate::game::flip::flip_permanent; +use crate::types::ability::{Effect, EffectError, EffectKind, ResolvedAbility, TargetRef}; +use crate::types::events::GameEvent; +use crate::types::game_state::GameState; + +/// CR 710.4: Flip a Kamigawa flip permanent to its alternative face. +/// +/// Every printed flip instruction names the permanent itself ("flip this +/// creature" / "flip it" / "flip "), so the no-target form resolving +/// against `source_id` is the production shape; the explicit-object-target form +/// mirrors `transform_effect` so an anaphoric trigger subject can bind a +/// different permanent. +pub fn resolve( + state: &mut GameState, + ability: &ResolvedAbility, + events: &mut Vec, +) -> Result<(), EffectError> { + match &ability.effect { + Effect::FlipPermanent { .. } => {} + _ => { + return Err(EffectError::InvalidParam( + "expected FlipPermanent effect".to_string(), + )) + } + } + + // CR 710.1: if the named permanent isn't represented by a flip card, or + // isn't on the battlefield, `flip_permanent` no-ops (CR 710.2). + let object_id = match ability.targets.as_slice() { + [TargetRef::Object(object_id)] => *object_id, + [] => ability.source_id, + _ => { + return Err(EffectError::InvalidParam( + "flip expects exactly one object target".to_string(), + )) + } + }; + + // CR 400.7: a self-flip instruction must not follow a source that left the + // battlefield and returned — that is a new object (a new, unflipped + // permanent per CR 110.5b), not the one the ability was put on the stack + // for. CR 710.4 already makes a repeat flip of the SAME object a no-op, so + // no transformation-count-style generation guard is needed here. + let stale_self_flip = object_id == ability.source_id && !ability.source_is_current(state); + if !stale_self_flip { + flip_permanent(state, object_id, events) + .map_err(|err| EffectError::InvalidParam(err.to_string()))?; + } + + events.push(GameEvent::EffectResolved { + kind: EffectKind::FlipPermanent, + source_id: ability.source_id, + subject: None, + }); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::game_object::BackFaceData; + use crate::game::zones::create_object; + use crate::types::ability::TargetFilter; + use crate::types::card_type::{CardType, CoreType, Supertype}; + use crate::types::identifiers::{CardId, ObjectId}; + use crate::types::keywords::Keyword; + use crate::types::mana::{ManaColor, ManaCost, ManaCostShard}; + use crate::types::player::PlayerId; + use crate::types::zones::Zone; + + /// Nezumi Shortfang // Stabwhisker the Odious — a {1}{B} 1/1 Rat Rogue + /// whose alternative half is a 3/3 Legendary Rat Shaman. + fn setup_flip_card(state: &mut GameState) -> ObjectId { + let id = create_object( + state, + CardId(1), + PlayerId(0), + "Nezumi Shortfang".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.power = Some(1); + obj.toughness = Some(1); + obj.base_power = Some(1); + obj.base_toughness = Some(1); + obj.card_types = CardType { + supertypes: vec![], + core_types: vec![CoreType::Creature], + subtypes: vec!["Rat".to_string(), "Rogue".to_string()], + }; + obj.base_card_types = obj.card_types.clone(); + obj.mana_cost = ManaCost::Cost { + shards: vec![ManaCostShard::Black], + generic: 1, + }; + obj.base_mana_cost = obj.mana_cost.clone(); + obj.color = vec![ManaColor::Black]; + obj.base_color = obj.color.clone(); + obj.back_face = Some(BackFaceData { + name: "Stabwhisker the Odious".to_string(), + power: Some(3), + toughness: Some(3), + loyalty: None, + defense: None, + card_types: CardType { + supertypes: vec![Supertype::Legendary], + core_types: vec![CoreType::Creature], + subtypes: vec!["Rat".to_string(), "Shaman".to_string()], + }, + mana_cost: ManaCost::default(), + keywords: vec![Keyword::Menace], + abilities: Vec::new(), + trigger_definitions: Default::default(), + replacement_definitions: Default::default(), + static_definitions: Default::default(), + color: Vec::new(), + printed_ref: None, + modal: None, + additional_cost: None, + strive_cost: None, + casting_restrictions: vec![], + casting_options: vec![], + layout_kind: Some(crate::types::card::LayoutKind::Flip), + }); + id + } + + /// CR 710.4: the no-target form flips the ability's source — the shape + /// every printed flip card produces. + #[test] + fn flip_effect_uses_source_when_no_explicit_target() { + let mut state = GameState::new_two_player(42); + let source_id = setup_flip_card(&mut state); + let ability = ResolvedAbility::new( + Effect::FlipPermanent { + target: TargetFilter::SelfRef, + }, + vec![], + source_id, + PlayerId(0), + ); + let mut events = Vec::new(); + + resolve(&mut state, &ability, &mut events).unwrap(); + + let object = &state.objects[&source_id]; + assert!(object.flipped); + assert_eq!(object.name, "Stabwhisker the Odious"); + assert!(events.iter().any( + |event| matches!(event, GameEvent::Flipped { object_id } if *object_id == source_id) + )); + assert!(events.iter().any(|event| matches!( + event, + GameEvent::EffectResolved { + kind: EffectKind::FlipPermanent, + source_id: emitted_source, + .. + } if *emitted_source == source_id + ))); + } + + /// CR 710.1c: the resolver path preserves color and mana cost, because it + /// routes through `flip::flip_permanent` and not the double-faced + /// applicator. + #[test] + fn flip_effect_preserves_color_and_mana_cost() { + let mut state = GameState::new_two_player(42); + let source_id = setup_flip_card(&mut state); + let cost_before = state.objects[&source_id].mana_cost.clone(); + let ability = ResolvedAbility::new( + Effect::FlipPermanent { + target: TargetFilter::SelfRef, + }, + vec![], + source_id, + PlayerId(0), + ); + let mut events = Vec::new(); + + resolve(&mut state, &ability, &mut events).unwrap(); + + let object = &state.objects[&source_id]; + assert_eq!(object.mana_cost, cost_before); + assert_eq!(object.color, vec![ManaColor::Black]); + } + + /// An explicit object target flips that permanent, not the source. + #[test] + fn flip_effect_uses_explicit_object_target() { + let mut state = GameState::new_two_player(42); + let source_id = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Source".to_string(), + Zone::Battlefield, + ); + let target_id = setup_flip_card(&mut state); + let ability = ResolvedAbility::new( + Effect::FlipPermanent { + target: TargetFilter::Any, + }, + vec![TargetRef::Object(target_id)], + source_id, + PlayerId(0), + ); + let mut events = Vec::new(); + + resolve(&mut state, &ability, &mut events).unwrap(); + + assert!(state.objects[&target_id].flipped); + assert!(!state.objects[&source_id].flipped); + } + + /// CR 400.7: a self-flip instruction on the stack must not follow a source + /// that left and re-entered the battlefield — that is a new object. + #[test] + fn self_flip_does_not_follow_a_blinked_source() { + use crate::game::ability_utils::build_resolved_from_def; + use crate::game::stack::push_to_stack; + use crate::game::zones::move_to_zone; + use crate::types::ability::{AbilityDefinition, AbilityKind}; + use crate::types::game_state::{StackEntry, StackEntryKind}; + + let mut state = GameState::new_two_player(42); + let source_id = setup_flip_card(&mut state); + let definition = AbilityDefinition::new( + AbilityKind::Spell, + Effect::FlipPermanent { + target: TargetFilter::SelfRef, + }, + ); + let ability = build_resolved_from_def(&definition, source_id, PlayerId(0)); + let mut events = Vec::new(); + push_to_stack( + &mut state, + StackEntry { + id: ObjectId(100), + source_id, + controller: PlayerId(0), + kind: StackEntryKind::ActivatedAbility { source_id, ability }, + }, + &mut events, + ); + + move_to_zone(&mut state, source_id, Zone::Exile, &mut events); + move_to_zone(&mut state, source_id, Zone::Battlefield, &mut events); + + let entry = state.stack.pop_back().expect("flip ability on stack"); + resolve( + &mut state, + entry.ability().expect("activated ability"), + &mut events, + ) + .expect("flip ability resolves"); + + assert!( + !state.objects[&source_id].flipped, + "CR 400.7: a stale self-flip must not affect the re-entered source" + ); + } +} diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 414d1df19a..4c6e9311de 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -121,6 +121,7 @@ pub mod explore; pub mod extra_turn; pub mod fight; pub mod flip_coin; +pub mod flip_permanent; pub mod forage; pub mod force_attack; pub mod force_block; @@ -3872,6 +3873,9 @@ pub fn resolve_effect( Effect::Discard { .. } => discard::resolve(state, ability, events), Effect::Shuffle { .. } => shuffle::resolve(state, ability, events), Effect::Transform { .. } => transform_effect::resolve(state, ability, events), + // CR 710.4: Kamigawa flip cards — a separate resolver from Transform + // because CR 710.1c keeps color and mana cost unchanged. + Effect::FlipPermanent { .. } => flip_permanent::resolve(state, ability, events), Effect::SearchLibrary { .. } => search_library::resolve(state, ability, events), Effect::SearchOutsideGame { .. } => search_outside_game::resolve(state, ability, events), Effect::Seek { .. } => seek::resolve(state, ability, events), @@ -6233,6 +6237,8 @@ fn extract_event_context_filter(effect: &Effect) -> Option<&TargetFilter> { | Effect::Attach { target, .. } | Effect::UnattachAll { target, .. } | Effect::Transform { target, .. } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target, .. } | Effect::CopySpell { target, .. } | Effect::CastCopyOfCard { target, .. } | Effect::CopyTokenOf { target, .. } diff --git a/crates/engine/src/game/effects/turn_face_down.rs b/crates/engine/src/game/effects/turn_face_down.rs index ef7d737945..bcb62b2c47 100644 --- a/crates/engine/src/game/effects/turn_face_down.rs +++ b/crates/engine/src/game/effects/turn_face_down.rs @@ -52,7 +52,21 @@ pub fn resolve( // writes these values into both live and base fields, so the layer // system then reapplies all continuous effects from the correct printed // baseline — not from an already-inflated one. - let snapshot = crate::game::printed_cards::snapshot_object_base_face(obj); + // + // CR 710.4 + CR 710.2: a FLIPPED flip permanent (CR 712.16 does not + // cover flip cards, so Ixidron / Cyber Conversion may legally turn one + // face down) already owns this slot: `flip::flip_permanent` stashed the + // NORMAL half there, and that half is what must reappear when the + // permanent leaves the battlefield. Overwriting it with a base snapshot + // — which, for a flipped permanent, is the ALTERNATIVE half — would put + // a flipped Kenzo the Hardhearted in the graveyard instead of Bushi + // Tenderfoot. Keep the flip stash; `zones::apply_zone_exit_cleanup` + // runs the CR 708.9 face-down restore BEFORE the CR 710.4 flip revert + // precisely so this single slot serves both. + let snapshot = match &obj.back_face { + Some(flip_stash) if obj.flipped => flip_stash.clone(), + _ => crate::game::printed_cards::snapshot_object_base_face(obj), + }; // CR 708.2a + CR 205.1a: Apply the effect-specified (or default vanilla // 2/2) face-down body. crate::game::morph::apply_face_down_creature_characteristics(obj, &profile); diff --git a/crates/engine/src/game/flip.rs b/crates/engine/src/game/flip.rs new file mode 100644 index 0000000000..d56c6ac11f --- /dev/null +++ b/crates/engine/src/game/flip.rs @@ -0,0 +1,883 @@ +//! CR 710: Flip cards (Kamigawa block). +//! +//! A flip card is a **single-faced** card with a two-part frame. Its top half +//! carries the normal characteristics (CR 710.1a); its bottom half carries an +//! alternative name, text box, type line, power, and toughness that apply only +//! while the permanent is on the battlefield **and** flipped (CR 710.1b). +//! +//! Flipping is therefore NOT transforming: CR 701.27a restricts transforming to +//! permanents represented by double-faced cards/tokens, and CR 710.1c fixes a +//! flip card's color and mana cost across the flip where transforming swaps +//! them. This module keeps its own applicator ([`apply_flipped_face_to_object`]) +//! for exactly that reason — reusing the double-faced applicator +//! (`printed_cards::apply_back_face_to_object`) would swap mana cost and color +//! and break CR 710.1c. +//! +//! Not modeled here, deliberately: +//! - CR 710.5 — a player choosing a flip card's *alternative* name for a +//! "choose a card name" effect. That is a name-choice-menu concern, not a +//! permanent-status concern, and no current name-choice path consults +//! alternative faces. +//! - CR 110.5b's "unless a spell or ability says otherwise" entry override, +//! i.e. Homura, Human Ascendant's "return it to the battlefield flipped". +//! That is an entry-time replacement rider (the flip-card analogue of +//! `enter_transformed`), not a flip instruction, and it is the only printed +//! card in the class. Every permanent therefore enters unflipped, which +//! [`revert_flip_on_zone_exit`] already guarantees on every zone exit. +//! - Turning a *face-down* flipped permanent back FACE UP (CR 708.8) restores +//! the NORMAL half, not the alternative one. `GameObject` has a single +//! `back_face` slot, and a flipped permanent that is turned face down +//! (Ixidron, Cyber Conversion) must keep the normal half there so the +//! CR 710.2 zone-exit result stays correct (see `effects::turn_face_down` +//! and the ordering note in `zones::apply_zone_exit_cleanup`). The +//! alternative half is therefore not recoverable on a later turn-up; the +//! permanent's `flipped` status is still retained (CR 710.4 — flipping is +//! one-way and is never cleared while the permanent stays on the +//! battlefield). Storing both halves at once needs a second stash slot, +//! which is a `GameObject`/serialization change beyond this module. +//! - CR 707.3's *second-order* flip copy case (CR 110.5c's Dimir Doppelganger +//! example): a permanent that is ALREADY flipped and then becomes a copy of +//! another flip card should show the copied card's ALTERNATIVE half. The +//! copy pipeline installs copiable values only (CR 707.2 — the normal half, +//! see [`flipped_normal_copiable_values`]) and never re-derives the copied +//! card's other half, which again needs a second stash slot. + +use std::sync::Arc; + +use crate::game::game_object::{BackFaceData, GameObject}; +use crate::types::ability::CopiableValues; +use crate::types::card::LayoutKind; +use crate::types::events::GameEvent; +use crate::types::game_state::GameState; +use crate::types::identifiers::ObjectId; +use crate::types::zones::Zone; + +use super::engine::EngineError; +use super::printed_cards::snapshot_object_base_face; + +/// CR 710.1: True when `obj` is represented by a CR 710 flip card — i.e. its +/// `back_face` slot holds the *other half* of a flip card rather than the back +/// face of a double-faced card. +/// +/// This is the single authority for "is this a flip card?" and the reason +/// [`stash_flip_face`] re-stamps `LayoutKind::Flip` on **both** halves: whether +/// the permanent is currently flipped or not, the half sitting in `back_face` +/// always carries the tag, so no double-faced path (CR 701.27a transform, +/// CR 712.16 turn-face-down, MDFC/Adventure face choice) can ever mistake a +/// flip card for a DFC — not even after a flip → leave-the-battlefield → +/// return round trip. +pub(crate) fn is_flip_permanent(obj: &GameObject) -> bool { + matches!( + obj.back_face.as_ref().and_then(|face| face.layout_kind), + Some(LayoutKind::Flip) + ) +} + +/// CR 710.1b + CR 710.1c: Snapshot the half of a flip card that is about to be +/// hidden, for storage in the shared `back_face` slot. +/// +/// Two deliberate differences from a plain `snapshot_object_face`: +/// - It reads the **printed/base** characteristics (CR 613.1): the live fields +/// may carry continuous-effect modifications (an anthem, a granted keyword — +/// Student of Elements' own trigger condition is "when this creature has +/// flying"). [`apply_flipped_face_to_object`] writes the stash into both the +/// live and the `base_*` fields, so stashing inflated values would bake a +/// temporary effect into the other half's printed baseline permanently. +/// - It re-stamps `layout_kind: Some(LayoutKind::Flip)` (both snapshot helpers +/// hard-code `None`). Without it, a permanent that flipped and then left the +/// battlefield would sit in its new zone with `flipped == false` AND an +/// untagged `back_face`, and every flip guard keyed on either signal would go +/// false — reopening the CR 710.1c hole where a "transform each ..." effect +/// runs the double-faced applicator on a flip card and swaps the mana cost +/// and color CR 710.1c holds fixed. +fn stash_flip_face(obj: &GameObject) -> BackFaceData { + let mut face = snapshot_object_base_face(obj); + face.layout_kind = Some(LayoutKind::Flip); + face +} + +/// CR 710.4: Flip `object_id` — a one-way status change (CR 110.5) after which +/// the permanent's alternative characteristics apply (CR 710.1b). +/// +/// Silent no-op (returns `Ok(())`) when the instruction cannot apply, mirroring +/// CR 701.27c's "nothing happens" for the analogous transform instruction: +/// - the object is not on the battlefield (CR 710.1b + CR 710.2: the +/// alternative characteristics exist only for a battlefield permanent), +/// - the permanent is already flipped (CR 710.4: flipping is one-way, so a +/// second instruction has nothing to do), +/// - the card carries no alternative face (not a flip card). +/// +/// The pre-flip (normal) characteristics are stashed in `back_face` so +/// `zones::apply_zone_exit_cleanup` can restore them when the permanent leaves +/// the battlefield (CR 710.4 + CR 110.5: a flipped permanent that leaves the +/// battlefield retains no memory of its status). +pub fn flip_permanent( + state: &mut GameState, + object_id: ObjectId, + events: &mut Vec, +) -> Result<(), EngineError> { + let obj = state + .objects + .get(&object_id) + .ok_or_else(|| EngineError::InvalidAction("Object not found".to_string()))?; + + // CR 710.1b + CR 710.2: the alternative characteristics are used only if the + // permanent is on the battlefield. In every other zone a flip card has only + // its normal characteristics, so there is nothing to flip. + if obj.zone != Zone::Battlefield { + return Ok(()); + } + + // CR 710.4: flipping a permanent is a one-way process — once flipped, it's + // impossible for it to become unflipped, and a further flip instruction has + // no effect. + if obj.flipped { + return Ok(()); + } + + // CR 710.1: only a flip card has alternative characteristics to flip to. + let Some(alternative_face) = obj.back_face.clone() else { + return Ok(()); + }; + + let obj = state.objects.get_mut(&object_id).unwrap(); + + // CR 710.4 + CR 110.5: stash the normal characteristics so the zone-exit + // cleanup can restore them — a flipped permanent that leaves the + // battlefield retains no memory of its flipped status. + // + // CR 613.7: the object deliberately keeps its EXISTING timestamp. CR 613.7 + // enumerates every event that grants a new one — 613.7d zone entry, 613.7e + // attachment, 613.7f turning face up or face down, 613.7g transforming or + // converting — and flipping is not among them. (CR 613.7a grants nothing on + // its own; it only says a static ability's continuous effect inherits the + // object's timestamp, so the alternative text box's statics simply take the + // timestamp the permanent already has.) + let normal_face = stash_flip_face(obj); + apply_flipped_face_to_object(obj, alternative_face); + obj.back_face = Some(normal_face); + obj.flipped = true; + + crate::game::layers::mark_layers_full(state); + + events.push(GameEvent::Flipped { object_id }); + + Ok(()) +} + +/// CR 710.4 + CR 110.5 + CR 710.2: Restore a flipped permanent's normal +/// characteristics as it leaves the battlefield. A flipped permanent that +/// leaves the battlefield retains no memory of its status (CR 710.4), and in +/// every zone other than the battlefield a flip card has only the normal +/// characteristics of the card (CR 710.2). +/// +/// Called from `zones::apply_zone_exit_cleanup`, which owns the zone-exit +/// status reset for every status category (CR 110.5). It runs this AFTER the +/// CR 708.9 face-down restore — see the ordering note there. +pub(crate) fn revert_flip_on_zone_exit(obj: &mut GameObject) { + if !obj.flipped { + return; + } + let Some(normal_face) = obj.back_face.clone() else { + // CR 708.9 + CR 710.4: reached when the permanent was flipped AND then + // turned face down. `effects::turn_face_down` left the flip stash (the + // normal half) in `back_face`, and the face-down restore that runs just + // before this already consumed it — the object is showing the normal + // half again, so only the status is left to clear (CR 110.5: the new + // object is unflipped). + obj.flipped = false; + return; + }; + let alternative_face = stash_flip_face(obj); + apply_flipped_face_to_object(obj, normal_face); + obj.back_face = Some(alternative_face); + obj.flipped = false; +} + +/// CR 707.2 + CR 707.3: the copiable values of a permanent that is currently +/// flipped are its **normal** (top-half) printed values, not the alternative +/// ones now showing. +/// +/// CR 707.2 lists the copiable values as "the values derived from the text +/// printed on the object" and states that status is NOT copied; flipped is a +/// status (CR 110.5). CR 707.3's worked example is literally a flip card +/// (Tomoya the Revealer copying Nezumi Shortfang gets Nezumi's values and its +/// own flipped status then selects Stabwhisker). So a Clone / Phyrexian +/// Metamorph / Kiki-Jiki / token copy of a flipped Kenzo the Hardhearted must +/// be an UNFLIPPED Bushi Tenderfoot 1/1 — reading the object's `base_*` fields +/// would instead yield an unflipped 3/4 legendary Kenzo, because +/// [`apply_flipped_face_to_object`] wrote the alternative half into `base_*`. +/// +/// Returns `None` for any object that is not a flipped flip permanent, so +/// `printed_cards::intrinsic_copiable_values` keeps its `base_*` fast path. +pub(crate) fn flipped_normal_copiable_values(obj: &GameObject) -> Option { + if !obj.flipped { + return None; + } + let normal_face = obj.back_face.as_ref()?; + Some(CopiableValues { + name: normal_face.name.clone(), + // CR 710.1c: cost and color never changed across the flip, so the + // stash and the live object agree here — taken from the stash anyway so + // every copiable value has one source. + mana_cost: normal_face.mana_cost.clone(), + color: normal_face.color.clone(), + card_types: normal_face.card_types.clone(), + power: normal_face.power, + toughness: normal_face.toughness, + loyalty: normal_face.loyalty, + keywords: normal_face.keywords.clone(), + abilities: Arc::new(normal_face.abilities.clone()), + trigger_definitions: Arc::new( + normal_face + .trigger_definitions + .iter_all() + .cloned() + .collect(), + ), + // CR 707.2 + CR 611.2b: runtime replacements durably parked in the + // definition set are not printed characteristics — same exclusion the + // unflipped path applies via `copiable_replacement_definitions`. + replacement_definitions: Arc::new( + normal_face + .replacement_definitions + .iter_all() + .filter(|def| !crate::game::printed_cards::is_runtime_non_copiable_replacement(def)) + .cloned() + .collect(), + ), + static_definitions: Arc::new(normal_face.static_definitions.iter_all().cloned().collect()), + }) +} + +/// CR 710.1c: Re-assert a flipped permanent's unchanged color and mana cost +/// from the normal half stashed in `back_face`. +/// +/// [`apply_flipped_face_to_object`] never touches those four fields, so this is +/// a no-op for a live flip. It exists for the ONE seam that can overwrite them: +/// `printed_cards::reapply_printed_faces_from_card_db` re-applies the printed +/// face named by `printed_ref` — which for a flipped permanent is the +/// alternative half, and an alternative half carries no printed mana cost. On +/// every state reload that would blank the cost CR 710.1c preserves. +pub(crate) fn restore_normal_cost_and_color_if_flipped(obj: &mut GameObject) { + if !obj.flipped { + return; + } + let Some(normal_face) = obj.back_face.as_ref() else { + return; + }; + let mana_cost = normal_face.mana_cost.clone(); + let color = normal_face.color.clone(); + obj.mana_cost = mana_cost.clone(); + obj.base_mana_cost = mana_cost; + obj.color = color.clone(); + obj.base_color = color; +} + +/// CR 710.1b + CR 710.1c: Apply a flip card's *alternative* characteristics to +/// a battlefield permanent. +/// +/// This deliberately does NOT delegate to +/// `printed_cards::apply_back_face_to_object` (the double-faced-card +/// applicator). That function swaps mana cost and color, which is correct for +/// CR 712 double-faced cards and **wrong** for CR 710 flip cards. +/// +/// Copied from the alternative face (CR 710.1b — "an alternative name, text +/// box, type line, power, and toughness"), each alongside its `base_*` twin so +/// the layer system (CR 613) recomputes from the new printed values: +/// - `name` / `base_name` +/// - `power` / `base_power`, `toughness` / `base_toughness` +/// - `card_types` / `base_card_types` (the alternative type line, which for +/// every printed flip card adds the Legendary supertype) +/// - `keywords` / `base_keywords` +/// - `abilities` / `base_abilities`, `trigger_definitions` (via +/// `install_trigger_base_definitions`), `replacement_definitions` / +/// `base_replacement_definitions`, `static_definitions` / +/// `base_static_definitions` — the alternative text box +/// - `printed_ref` / `base_printed_ref` — the display identity of the half now +/// showing +/// - `loyalty` / `base_loyalty` and `defense` / `base_defense`: not enumerated +/// in CR 710.1b (and no printed flip card has either), but they are +/// type-line-derived printed values (CR 306.5b / CR 310.4b), so they follow +/// the type line rather than leaving a stale top-half value behind. +/// +/// Deliberately NOT copied: +/// - `mana_cost` / `base_mana_cost` — CR 710.1c: a flip card's mana cost +/// doesn't change if the permanent is flipped. +/// - `color` / `base_color` — CR 710.1c: a flip card's color doesn't change if +/// the permanent is flipped. +/// - `modal`, `additional_cost`, `strive_cost`, `casting_restrictions`, +/// `casting_options` — CR 710.2: a flip card is cast using only its normal +/// characteristics (it has only those in every zone other than the +/// battlefield), so the alternative half carries no casting properties and +/// must not clobber the normal half's. +/// +/// External effects applied to the permanent are untouched (CR 710.1c: "any +/// changes to it by external effects will still apply") — this writes only +/// printed/base values plus their live mirrors, exactly as the layer system +/// expects; `mark_layers_full` then reapplies every continuous effect. +pub(crate) fn apply_flipped_face_to_object(obj: &mut GameObject, face: BackFaceData) { + // CR 710.1b: alternative name. + obj.name = face.name.clone(); + obj.base_name = face.name; + + // CR 710.1b: alternative power and toughness. + obj.power = face.power; + obj.base_power = face.power; + obj.toughness = face.toughness; + obj.base_toughness = face.toughness; + + // CR 306.5b + CR 310.4b: loyalty/defense track the alternative type line. + obj.loyalty = face.loyalty; + obj.base_loyalty = face.loyalty; + obj.defense = face.defense; + obj.base_defense = face.defense; + + // CR 710.1b: alternative type line. + obj.card_types = face.card_types.clone(); + obj.base_card_types = face.card_types; + + // CR 710.1b: alternative text box — keywords, abilities, triggers, + // replacements, and statics all come from the half now showing. + obj.keywords = face.keywords.clone(); + obj.base_keywords = face.keywords; + obj.abilities = Arc::new(face.abilities.clone()); + obj.base_abilities = Arc::new(face.abilities); + obj.replacement_definitions = face.replacement_definitions.clone(); + obj.base_replacement_definitions = + Arc::new(face.replacement_definitions.iter_all().cloned().collect()); + obj.static_definitions = face.static_definitions.clone(); + obj.base_static_definitions = Arc::new(face.static_definitions.iter_all().cloned().collect()); + obj.install_trigger_base_definitions(Arc::new( + face.trigger_definitions.iter_all().cloned().collect(), + )) + .expect("trigger base-set generation must not overflow"); + obj.base_characteristics_initialized = true; + + // CR 710.1b: the alternative half is what's now shown. Cloned before the + // move so both the display baseline and the live pointer are set. + obj.base_printed_ref = face.printed_ref.clone(); + obj.printed_ref = face.printed_ref; + + // CR 710.1c: a flip card's color and mana cost don't change when flipped — + // `mana_cost`, `base_mana_cost`, `color`, and `base_color` are deliberately + // left untouched (`face.mana_cost` / `face.color` go unread). This is the + // single behavioral difference from the double-faced applicator and the + // reason this function exists rather than delegating to it. + + // CR 710.2: the card is cast using only its normal characteristics, so the + // normal half's casting properties (`modal`, `additional_cost`, + // `strive_cost`, `casting_restrictions`, `casting_options`) are deliberately + // left untouched. +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::zones::create_object; + use crate::types::ability::{AbilityDefinition, AbilityKind, Effect}; + use crate::types::card_type::{CardType, CoreType, Supertype}; + use crate::types::identifiers::CardId; + use crate::types::keywords::Keyword; + use crate::types::mana::{ManaColor, ManaCost, ManaCostShard}; + use crate::types::player::PlayerId; + + /// `{W}` — Bushi Tenderfoot's printed mana cost (CR 202.1). + fn white_mana_cost() -> ManaCost { + ManaCost::Cost { + shards: vec![ManaCostShard::White], + generic: 0, + } + } + + /// Bushi Tenderfoot // Kenzo the Hardhearted, the canonical CR 710 flip + /// card: a {W} 1/1 Creature — Human Soldier whose alternative half is a 3/4 + /// Legendary Creature — Human Samurai with double strike. + fn setup_flip_card(state: &mut GameState) -> ObjectId { + let id = create_object( + state, + CardId(1), + PlayerId(0), + "Bushi Tenderfoot".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.power = Some(1); + obj.toughness = Some(1); + obj.base_power = Some(1); + obj.base_toughness = Some(1); + obj.card_types = CardType { + supertypes: vec![], + core_types: vec![CoreType::Creature], + subtypes: vec!["Human".to_string(), "Soldier".to_string()], + }; + obj.base_card_types = obj.card_types.clone(); + obj.keywords = vec![Keyword::Bushido(1)]; + obj.base_keywords = obj.keywords.clone(); + obj.abilities = Arc::new(vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::FlipPermanent { + target: crate::types::ability::TargetFilter::SelfRef, + }, + )]); + obj.base_abilities = Arc::clone(&obj.abilities); + obj.mana_cost = white_mana_cost(); + obj.base_mana_cost = obj.mana_cost.clone(); + obj.color = vec![ManaColor::White]; + obj.base_color = vec![ManaColor::White]; + + obj.back_face = Some(BackFaceData { + name: "Kenzo the Hardhearted".to_string(), + power: Some(3), + toughness: Some(4), + loyalty: None, + defense: None, + card_types: CardType { + supertypes: vec![Supertype::Legendary], + core_types: vec![CoreType::Creature], + subtypes: vec!["Human".to_string(), "Samurai".to_string()], + }, + // The alternative half of a flip card has no printed mana cost and + // no printed color indicator — proof that reusing the double-faced + // applicator would blank both (CR 710.1c). + mana_cost: ManaCost::default(), + keywords: vec![Keyword::DoubleStrike, Keyword::Bushido(2)], + abilities: Vec::new(), + trigger_definitions: Default::default(), + replacement_definitions: Default::default(), + static_definitions: Default::default(), + color: Vec::new(), + printed_ref: None, + modal: None, + additional_cost: None, + strive_cost: None, + casting_restrictions: vec![], + casting_options: vec![], + layout_kind: Some(crate::types::card::LayoutKind::Flip), + }); + + id + } + + /// CR 710.1b: the alternative name, type line, power, toughness, and text + /// box replace the normal ones once the permanent is flipped. + #[test] + fn flip_applies_the_alternative_face() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + + let obj = &state.objects[&id]; + assert!(obj.flipped); + assert_eq!(obj.name, "Kenzo the Hardhearted"); + assert_eq!(obj.base_name, "Kenzo the Hardhearted"); + assert_eq!(obj.power, Some(3)); + assert_eq!(obj.toughness, Some(4)); + assert!(obj.card_types.supertypes.contains(&Supertype::Legendary)); + assert!(obj.card_types.subtypes.iter().any(|s| s == "Samurai")); + assert!(crate::game::keywords::has_keyword( + obj, + &Keyword::DoubleStrike + )); + assert!(state.layers_dirty.is_dirty()); + assert_eq!(events, vec![GameEvent::Flipped { object_id: id }]); + } + + /// CR 710.1c: a flip card's color and mana cost don't change if the + /// permanent is flipped. Reverting to the double-faced applicator + /// (`apply_back_face_to_object`) blanks both and fails this test. + #[test] + fn flip_preserves_color_and_mana_cost() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + let cost_before = state.objects[&id].mana_cost.clone(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + + let obj = &state.objects[&id]; + assert_eq!( + obj.mana_cost, cost_before, + "CR 710.1c: mana cost must not change when the permanent flips" + ); + assert_eq!(obj.base_mana_cost, cost_before); + assert_eq!( + obj.color, + vec![ManaColor::White], + "CR 710.1c: color must not change when the permanent flips" + ); + assert_eq!(obj.base_color, vec![ManaColor::White]); + } + + /// CR 710.4: flipping is one-way — a second flip instruction is a no-op and + /// emits no event. + #[test] + fn flipping_an_already_flipped_permanent_is_a_no_op() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + let timestamp_after_first = state.objects[&id].timestamp; + events.clear(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + + let obj = &state.objects[&id]; + assert!(obj.flipped, "CR 710.4: the permanent stays flipped"); + assert_eq!(obj.name, "Kenzo the Hardhearted"); + assert_eq!(obj.power, Some(3)); + assert!(events.is_empty(), "no second Flipped event"); + assert_eq!( + obj.timestamp, timestamp_after_first, + "a no-op flip must not draw a timestamp" + ); + } + + /// CR 710.1b + CR 710.2: a flip card that is not on the battlefield has + /// only its normal characteristics — the instruction does nothing. + #[test] + fn off_battlefield_object_cannot_flip() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + state.objects.get_mut(&id).unwrap().zone = Zone::Graveyard; + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + + let obj = &state.objects[&id]; + assert!(!obj.flipped); + assert_eq!(obj.name, "Bushi Tenderfoot"); + assert!(events.is_empty()); + } + + /// CR 710.4 + CR 110.5: a flipped permanent that leaves the battlefield + /// retains no memory of its status — the graveyard card shows only the + /// normal characteristics (CR 710.2). + #[test] + fn zone_change_resets_flipped_status() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + assert!(state.objects[&id].flipped); + + crate::game::zones::move_to_zone(&mut state, id, Zone::Graveyard, &mut events); + + let obj = &state.objects[&id]; + assert!(!obj.flipped, "CR 110.5: the new object is unflipped"); + assert_eq!(obj.name, "Bushi Tenderfoot"); + assert_eq!(obj.power, Some(1)); + assert_eq!(obj.toughness, Some(1)); + assert!(!obj.card_types.supertypes.contains(&Supertype::Legendary)); + assert_eq!( + obj.mana_cost, + white_mana_cost(), + "CR 710.1c: the mana cost was never changed, so the revert restores it unchanged" + ); + } + + /// A permanent with no alternative face is not a flip card — CR 710.1's + /// alternative characteristics don't exist, so nothing happens. + #[test] + fn non_flip_card_cannot_flip() { + let mut state = GameState::new_two_player(42); + let id = create_object( + &mut state, + CardId(7), + PlayerId(0), + "Grizzly Bears".to_string(), + Zone::Battlefield, + ); + let timestamp_before = state.objects[&id].timestamp; + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + + assert!(!state.objects[&id].flipped); + assert!(events.is_empty()); + assert_eq!(state.objects[&id].timestamp, timestamp_before); + } + + /// CR 701.27a + CR 701.27c: a flip card is not represented by a + /// double-faced card, so an instruction to TRANSFORM it does nothing — + /// in particular it must not run the double-faced applicator and blank the + /// mana cost and color that CR 710.1c preserves. + #[test] + fn transform_instruction_does_nothing_to_a_flip_card() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + + crate::game::transform::transform_permanent(&mut state, id, &mut events).unwrap(); + + let obj = &state.objects[&id]; + assert!(!obj.transformed); + assert!(!obj.flipped); + assert_eq!(obj.name, "Bushi Tenderfoot"); + assert_eq!(obj.mana_cost, white_mana_cost()); + assert_eq!(obj.color, vec![ManaColor::White]); + assert!(events.is_empty()); + } + + /// CR 613.7: flipping grants NO new timestamp. CR 613.7 enumerates every + /// timestamp-granting event — 613.7d zone entry, 613.7e attachment, 613.7f + /// turning face up/down, 613.7g transforming/converting — and flipping is + /// not among them (CR 613.7a only says a static ability's continuous effect + /// inherits its object's timestamp; it grants nothing on its own). + /// + /// Discriminating: re-adding a `state.next_timestamp()` bump to + /// `flip_permanent` makes `after` differ from `before` and fails here. + #[test] + fn flip_does_not_grant_a_new_timestamp() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let before = state.objects[&id].timestamp; + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + + assert!(state.objects[&id].flipped, "reach guard: it really flipped"); + assert_eq!( + state.objects[&id].timestamp, before, + "CR 613.7: flipping is not a timestamp-granting event" + ); + } + + /// CR 710.1c + CR 701.27a: a flip card that flipped, LEFT the battlefield, + /// and came back is still a flip card — a later "transform each ..." effect + /// must not run the double-faced applicator on it. + /// + /// Discriminating: `stash_flip_face` re-stamps `LayoutKind::Flip` on the + /// half it parks in `back_face`. Drop that re-stamp (i.e. use a plain + /// `snapshot_object_*_face`, whose `layout_kind` is hard-coded `None`) and + /// the returned permanent has `flipped == false` AND an untagged + /// `back_face`, so both arms of the transform guard go false: the assertions + /// below on `transformed`, `name`, `mana_cost`, and `color` all fail + /// (`apply_back_face_to_object` swaps in Kenzo and blanks the {W} cost). + #[test] + fn a_flip_card_that_left_and_returned_still_cannot_transform() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + assert!(state.objects[&id].flipped, "reach guard: it really flipped"); + + // Die, then get reanimated onto the battlefield. + crate::game::zones::move_to_zone(&mut state, id, Zone::Graveyard, &mut events); + crate::game::zones::move_to_zone(&mut state, id, Zone::Battlefield, &mut events); + assert!( + !state.objects[&id].flipped, + "reach guard: CR 110.5b — the returning permanent is unflipped, so \ + the `flipped` arm of the transform guard cannot be what blocks it" + ); + assert_eq!( + state.objects[&id].zone, + Zone::Battlefield, + "reach guard: transform_permanent only acts on battlefield permanents" + ); + + events.clear(); + crate::game::transform::transform_permanent(&mut state, id, &mut events).unwrap(); + + let obj = &state.objects[&id]; + assert!( + !obj.transformed, + "CR 701.27a: a flip card is single-faced and cannot transform" + ); + assert_eq!(obj.name, "Bushi Tenderfoot"); + assert_eq!( + obj.mana_cost, + white_mana_cost(), + "CR 710.1c: the double-faced applicator must never touch a flip card's mana cost" + ); + assert_eq!(obj.color, vec![ManaColor::White]); + assert!(events.is_empty()); + assert!( + is_flip_permanent(obj), + "the LayoutKind::Flip tag survives the flip → leave → return round trip" + ); + } + + /// CR 613.1 + CR 710.1b: the half parked in `back_face` must be the PRINTED + /// one, not the layer-modified live one. Student of Elements' own trigger + /// condition is "when this creature has flying", so a granted keyword and a + /// pumped P/T are exactly the state a flip happens in. + /// + /// Discriminating: revert `stash_flip_face` to `snapshot_object_face` (live + /// fields) and the graveyard card is a 2/2 Bushi Tenderfoot WITH flying — + /// the printed-P/T and no-flying assertions below both fail. + #[test] + fn flip_stashes_printed_characteristics_not_layer_modified_ones() { + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + { + // Stand in for an active continuous effect (an anthem plus a + // granted keyword): live fields inflated, `base_*` untouched. + let obj = state.objects.get_mut(&id).unwrap(); + obj.power = Some(2); + obj.toughness = Some(2); + obj.keywords.push(Keyword::Flying); + } + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + assert_eq!( + state.objects[&id].name, "Kenzo the Hardhearted", + "reach guard: it really flipped" + ); + + crate::game::zones::move_to_zone(&mut state, id, Zone::Graveyard, &mut events); + + let obj = &state.objects[&id]; + assert_eq!(obj.name, "Bushi Tenderfoot"); + assert_eq!( + (obj.power, obj.toughness), + (Some(1), Some(1)), + "CR 613.1: the stash must carry the PRINTED 1/1, not the anthem-inflated 2/2" + ); + assert_eq!((obj.base_power, obj.base_toughness), (Some(1), Some(1))); + assert!( + !crate::game::keywords::has_keyword(obj, &Keyword::Flying), + "CR 613.1: a granted keyword must not be baked into the normal half" + ); + assert!( + !obj.base_keywords.contains(&Keyword::Flying), + "CR 613.1: a granted keyword must not be baked into the printed baseline" + ); + assert!( + crate::game::keywords::has_keyword(obj, &Keyword::Bushido(1)), + "reach guard: the printed keyword set really was restored" + ); + } + + /// CR 710.2 + CR 708.9: a flipped permanent turned FACE DOWN (Ixidron, + /// Cyber Conversion — CR 712.16 does not cover flip cards) and then killed + /// lands in the graveyard as the NORMAL half, not as the nameless + /// face-down shell and not as the alternative half. + /// + /// Discriminating: let `turn_face_down` overwrite `back_face` with a fresh + /// base snapshot again and the graveyard card is "Kenzo the Hardhearted" + /// 3/4; run `revert_flip_on_zone_exit` BEFORE the CR 708.9 restore again and + /// the graveyard card is the nameless 2/2 shell. Both fail the assertions + /// below. + #[test] + fn a_flipped_permanent_turned_face_down_still_dies_as_the_normal_half() { + use crate::types::ability::{FaceDownProfile, ResolvedAbility, TargetFilter}; + + let mut state = GameState::new_two_player(42); + let id = setup_flip_card(&mut state); + let mut events = Vec::new(); + + flip_permanent(&mut state, id, &mut events).unwrap(); + assert!(state.objects[&id].flipped, "reach guard: it really flipped"); + + let turn_down = ResolvedAbility::new( + Effect::TurnFaceDown { + target: TargetFilter::SpecificObject { id }, + profile: Some(FaceDownProfile::vanilla_2_2()), + }, + vec![], + ObjectId(999), + PlayerId(0), + ); + crate::game::effects::turn_face_down::resolve(&mut state, &turn_down, &mut events).unwrap(); + assert!( + state.objects[&id].face_down, + "reach guard: CR 712.16 must NOT block a flip card, so it really is face down" + ); + assert!( + state.objects[&id].flipped, + "CR 710.4: flipping is one-way — turning face down does not unflip it" + ); + + crate::game::zones::move_to_zone(&mut state, id, Zone::Graveyard, &mut events); + + let obj = &state.objects[&id]; + assert!( + !obj.face_down, + "CR 708.9: revealed on leaving the battlefield" + ); + assert!(!obj.flipped, "CR 110.5: the new object is unflipped"); + assert_eq!( + obj.name, "Bushi Tenderfoot", + "CR 710.2: off the battlefield a flip card has only its normal characteristics" + ); + assert_eq!((obj.power, obj.toughness), (Some(1), Some(1))); + assert!(!obj.card_types.supertypes.contains(&Supertype::Legendary)); + assert_eq!(obj.mana_cost, white_mana_cost()); + } + + /// CR 707.2 + CR 707.3: status is not copied, and CR 707.3's worked example + /// is a flip card. A Clone copying a flipped Kenzo the Hardhearted must + /// become an UNFLIPPED Bushi Tenderfoot 1/1. + /// + /// Discriminating: remove the flipped branch from + /// `printed_cards::intrinsic_copiable_values` and the copy reads the + /// object's `base_*` — which `apply_flipped_face_to_object` overwrote with + /// the alternative half — producing a legendary 3/4 "Kenzo the Hardhearted" + /// and failing every assertion below. + #[test] + fn a_copy_of_a_flipped_permanent_takes_the_unflipped_normal_half() { + use crate::types::ability::{Duration, ResolvedAbility, TargetFilter, TargetRef}; + + let mut state = GameState::new_two_player(42); + let flipped = setup_flip_card(&mut state); + let clone = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Clone".to_string(), + Zone::Battlefield, + ); + let mut events = Vec::new(); + + flip_permanent(&mut state, flipped, &mut events).unwrap(); + assert_eq!( + state.objects[&flipped].name, "Kenzo the Hardhearted", + "reach guard: the copy source really is flipped" + ); + + let become_copy = ResolvedAbility::new( + Effect::BecomeCopy { + target: TargetFilter::Any, + recipient: TargetFilter::SelfRef, + duration: Some(Duration::Permanent), + mana_value_limit: None, + additional_modifications: Vec::new(), + }, + vec![TargetRef::Object(flipped)], + clone, + PlayerId(0), + ); + crate::game::effects::become_copy::resolve(&mut state, &become_copy, &mut events).unwrap(); + crate::game::layers::flush_layers(&mut state); + + let copy = &state.objects[&clone]; + assert!( + !copy.flipped, + "CR 707.2: status is not copied — the copy enters unflipped" + ); + assert_eq!( + copy.name, "Bushi Tenderfoot", + "CR 707.2 + CR 707.3: the copiable values are the NORMAL half" + ); + assert_eq!((copy.power, copy.toughness), (Some(1), Some(1))); + assert!( + !copy.card_types.supertypes.contains(&Supertype::Legendary), + "CR 710.1b: the Legendary supertype lives only on the alternative half" + ); + assert!( + !crate::game::keywords::has_keyword(copy, &Keyword::DoubleStrike), + "CR 707.2: the alternative half's text box is not a copiable value" + ); + assert_eq!( + state.objects[&flipped].name, "Kenzo the Hardhearted", + "the copy source is untouched" + ); + } +} diff --git a/crates/engine/src/game/log.rs b/crates/engine/src/game/log.rs index 25a22ffeb2..e1b68c61a4 100644 --- a/crates/engine/src/game/log.rs +++ b/crates/engine/src/game/log.rs @@ -179,6 +179,9 @@ fn categorize(event: &GameEvent) -> LogCategory { | GameEvent::CounterRemoved { .. } | GameEvent::ControllerChanged { .. } | GameEvent::Transformed { .. } + // CR 710.4: flipping is an object-status change, grouped with transform + // and face up/down. + | GameEvent::Flipped { .. } | GameEvent::TurnedFaceUp { .. } | GameEvent::TurnedFaceDown { .. } | GameEvent::Regenerated { .. } @@ -753,6 +756,12 @@ fn format_segments(event: &GameEvent, state: &GameState) -> Vec { vec![card_seg(state, *object_id), text(" transforms")] } + // CR 710.4: the log names the permanent by its (now alternative, + // CR 710.1b) characteristics, which `card_seg` reads live. + GameEvent::Flipped { object_id } => { + vec![card_seg(state, *object_id), text(" flips")] + } + GameEvent::Specialized { object_id, color } => { vec![ card_seg(state, *object_id), diff --git a/crates/engine/src/game/mod.rs b/crates/engine/src/game/mod.rs index 21a3f2d4d0..688eee5d79 100644 --- a/crates/engine/src/game/mod.rs +++ b/crates/engine/src/game/mod.rs @@ -61,6 +61,8 @@ pub mod engine_resolve_batch; pub(crate) mod engine_stack; pub(crate) mod exile_links; pub mod filter; +// CR 710: Kamigawa flip cards (flipping, alternative-face application). +pub mod flip; pub mod functioning_abilities; pub mod game_object; pub mod gap_analysis; diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index 8fe5d9d215..32605d84e8 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -456,6 +456,13 @@ pub fn self_etb_counter_replacements( } pub fn intrinsic_copiable_values(obj: &GameObject) -> CopiableValues { + // CR 707.2 + CR 710.2: a flipped flip permanent's `base_*` fields hold the + // ALTERNATIVE half (written there by `flip::apply_flipped_face_to_object`), + // but flipped is a status (CR 110.5) and status is not copied. The copiable + // values are the normal half, which `flip` keeps stashed in `back_face`. + if let Some(values) = crate::game::flip::flipped_normal_copiable_values(obj) { + return values; + } CopiableValues { name: obj.base_name.clone(), mana_cost: obj.base_mana_cost.clone(), @@ -1202,6 +1209,8 @@ fn walk_effect(effect: &Effect, out: &mut Vec) { | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + // CR 710.4: no nested ability carrier and no conjured card name. + | Effect::FlipPermanent { .. } | Effect::SearchLibrary { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealHand { .. } @@ -1422,6 +1431,13 @@ pub fn populate_back_face_if_dfc(obj: &mut GameObject, db: &CardDatabase, card_f CardLayout::Modal(_, back) => Some((LayoutKind::Modal, back)), CardLayout::Meld(_, back) => Some((LayoutKind::Meld, back)), CardLayout::Omen(_, back) => Some((LayoutKind::Omen, back)), + // CR 710.1b: a flip card's alternative name, text box, type line, + // power, and toughness live on its bottom half. Stored in the same + // `back_face` slot so `flip::flip_permanent` can apply it — the + // `LayoutKind::Flip` tag is what keeps it out of every double-faced + // path (`transform::is_double_faced_permanent`, + // `transform::transform_permanent`, MDFC/Adventure face choice). + CardLayout::Flip(_, back) => Some((LayoutKind::Flip, back)), // CR 722: Preparation cards expose prepare-spell characteristics. CardLayout::Prepare(_, back) => Some((LayoutKind::Prepare, back)), _ => None, @@ -1755,6 +1771,10 @@ fn reapply_printed_faces_from_card_db(state: &mut GameState, db: &CardDatabase) CardLayout::Modal(..) => Some(LayoutKind::Modal), CardLayout::Meld(..) => Some(LayoutKind::Meld), CardLayout::Omen(..) => Some(LayoutKind::Omen), + // CR 710.1b: restore the flip tag so a reloaded + // flip permanent's stashed alternative face stays + // excluded from the double-faced paths. + CardLayout::Flip(..) => Some(LayoutKind::Flip), // CR 702.xxx: Prepare (Strixhaven) — treat like Adventure for // back-face layout tracking. Assign when WotC publishes SOS CR update. CardLayout::Prepare(..) => Some(LayoutKind::Prepare), @@ -1770,6 +1790,15 @@ fn reapply_printed_faces_from_card_db(state: &mut GameState, db: &CardDatabase) } } + // CR 710.1c: a flip card's color and mana cost don't change if the + // permanent is flipped. A flipped permanent's `printed_ref` names + // the ALTERNATIVE half, which carries no printed mana cost, so the + // `apply_card_face_to_object` reapply above would blank it on every + // reload. Restore both from the (just-refreshed) normal half stashed + // in `back_face` — the same values `flip::flip_permanent` + // deliberately left untouched when it flipped the permanent. + crate::game::flip::restore_normal_cost_and_color_if_flipped(obj); + if is_face_down_battlefield { // CR 708.2a: This reload path only runs while `printed_ref` is // still set (see the `obj.printed_ref.clone()` guard above); @@ -2141,6 +2170,105 @@ mod tests { } } + /// CR 710.1c: a flip card's color and mana cost don't change if the + /// permanent is flipped — including across a state reload. + /// + /// A flipped permanent's `printed_ref` names the ALTERNATIVE half, which (on + /// every real flip card) has no printed mana cost. Without the + /// `restore_normal_cost_and_color_if_flipped` call in + /// `reapply_printed_faces_from_card_db`, the reapply blanks the cost and the + /// permanent silently becomes a {0} object on load. Reverting that call + /// fails the mana-cost assertion below. + #[test] + fn rehydrate_keeps_a_flipped_permanents_mana_cost_and_color() { + let normal_cost = ManaCost::Cost { + shards: vec![ManaCostShard::White], + generic: 0, + }; + let mut normal_half = test_face( + "Rehydrate Flip Normal", + "rehydrate-flip-oracle-id", + vec![CoreType::Creature], + normal_cost.clone(), + ); + normal_half.color_override = Some(vec![ManaColor::White]); + // CR 710.1b: the alternative half has no printed mana cost and no + // printed color indicator — exactly as MTGJSON reports face b. + let mut alternative_half = test_face( + "Rehydrate Flip Alternative", + "rehydrate-flip-oracle-id", + vec![CoreType::Creature], + ManaCost::default(), + ); + alternative_half.color_override = Some(vec![]); + let db = db_from_faces(&[normal_half.clone(), alternative_half.clone()]); + + let mut state = GameState::new_two_player(42); + let id = create_object( + &mut state, + CardId(31), + PlayerId(0), + "Rehydrate Flip Alternative".to_string(), + Zone::Battlefield, + ); + let object = state.objects.get_mut(&id).unwrap(); + // Post-flip state, exactly as `flip::flip_permanent` leaves it: the + // alternative half is displayed, the normal half is stashed, and the + // mana cost / color are still the normal half's (CR 710.1c). + object.flipped = true; + object.printed_ref = printed_ref_from_face(&alternative_half); + object.base_printed_ref = object.printed_ref.clone(); + object.mana_cost = normal_cost.clone(); + object.base_mana_cost = normal_cost.clone(); + object.color = vec![ManaColor::White]; + object.base_color = vec![ManaColor::White]; + object.back_face = Some(BackFaceData { + name: normal_half.name.clone(), + power: None, + toughness: None, + loyalty: None, + defense: None, + card_types: normal_half.card_type.clone(), + mana_cost: normal_cost.clone(), + keywords: vec![], + abilities: vec![], + trigger_definitions: Default::default(), + replacement_definitions: Default::default(), + static_definitions: Default::default(), + color: vec![ManaColor::White], + printed_ref: printed_ref_from_face(&normal_half), + modal: None, + additional_cost: None, + strive_cost: None, + casting_restrictions: vec![], + casting_options: vec![], + layout_kind: None, + }); + + rehydrate_game_from_card_db(&mut state, &db); + + let object = &state.objects[&id]; + assert!( + object.flipped, + "reach guard: the permanent is still flipped" + ); + assert_eq!( + object.name, "Rehydrate Flip Alternative", + "reach guard: the reapply really did run over the alternative half" + ); + assert_eq!( + object.mana_cost, normal_cost, + "CR 710.1c: reloading must not blank a flipped permanent's mana cost" + ); + assert_eq!(object.base_mana_cost, normal_cost); + assert_eq!( + object.color, + vec![ManaColor::White], + "CR 710.1c: reloading must not blank a flipped permanent's color" + ); + assert_eq!(object.base_color, vec![ManaColor::White]); + } + /// CR 604.3: explicit all-zone color data is authoritative even when a face /// also has Devoid. Production devoid cards normally enter through this path /// with `color_override: Some([])`. diff --git a/crates/engine/src/game/public_state.rs b/crates/engine/src/game/public_state.rs index ff2a152bf0..1ced56b127 100644 --- a/crates/engine/src/game/public_state.rs +++ b/crates/engine/src/game/public_state.rs @@ -376,6 +376,10 @@ pub fn mark_public_state_from_events(state: &mut GameState, events: &[GameEvent] // Transform changes copiable values (Layer 1) and can flip statics // on/off; conservatively all-dirty. | GameEvent::Transformed { .. } + // CR 710.1b: flipping replaces the permanent's name, type line, + // power, toughness, and text box (Layer 1 copiable values) and can + // flip statics on/off; conservatively all-dirty like Transform. + | GameEvent::Flipped { .. } | GameEvent::Specialized { .. } | GameEvent::TurnedFaceUp { .. } // Turning a permanent face down resets its copiable values to a 2/2 diff --git a/crates/engine/src/game/targeting.rs b/crates/engine/src/game/targeting.rs index e5d5a92aaf..f19cf028f1 100644 --- a/crates/engine/src/game/targeting.rs +++ b/crates/engine/src/game/targeting.rs @@ -1462,6 +1462,8 @@ pub(crate) fn extract_source_from_event( } => Some(*object_id), GameEvent::Discarded { object_id, .. } => Some(*object_id), GameEvent::Transformed { object_id } => Some(*object_id), + // CR 710.4: the flipped permanent is the event's subject. + GameEvent::Flipped { object_id } => Some(*object_id), GameEvent::TurnedFaceUp { object_id } => Some(*object_id), GameEvent::TurnedFaceDown { object_id } => Some(*object_id), GameEvent::Cycled { object_id, .. } => Some(*object_id), diff --git a/crates/engine/src/game/transform.rs b/crates/engine/src/game/transform.rs index e6a60e67ef..c6a8d11ec8 100644 --- a/crates/engine/src/game/transform.rs +++ b/crates/engine/src/game/transform.rs @@ -49,6 +49,22 @@ pub fn transform_permanent( return Ok(()); } + // CR 701.27a + CR 701.27c: only permanents represented by double-faced + // tokens and double-faced cards can transform; if a spell or ability + // instructs a player to transform anything else, nothing happens. A CR 710 + // flip card is a SINGLE-faced card whose alternative characteristics are + // reached by flipping (CR 710.4), never by transforming — and applying the + // double-faced applicator to one would swap the mana cost and color that + // CR 710.1c holds fixed. `flip::is_flip_permanent` is the single authority: + // `flip::stash_flip_face` re-stamps `LayoutKind::Flip` on WHICHEVER half is + // parked in `back_face`, so the tag survives a flip, a zone exit (which + // reverts the flip and re-stashes the alternative half), and a later return + // to the battlefield. The `flipped` status is kept as a redundant second + // arm so a stash that some other path zeroed still cannot be transformed. + if obj.flipped || crate::game::flip::is_flip_permanent(obj) { + return Ok(()); + } + let back_face = obj .back_face .clone() @@ -90,15 +106,19 @@ pub fn transform_permanent( /// CR 712.16 + CR 730.2j: True when `obj` is a double-faced permanent /// (transform/modal/meld DFC) or a melded permanent — none of which can be -/// turned face down. Used by `effects::turn_face_down` to enforce the no-op. +/// turned face down. Used by `effects::turn_face_down` to enforce the no-op, +/// and by presentation adapters that need the engine's authoritative +/// "is this permanent double-faced?" answer instead of re-deriving one. /// /// Keys on the typed layout/merge discriminants rather than `back_face.is_some()` /// so that single-faced layouts that may legally be turned face down — Adventure, /// Omen, Split, Flip — are NOT blocked (they carry no Transform/Modal/Meld -/// `layout_kind`). A DFC currently showing its back face is caught by the +/// `layout_kind`). CR 710 flip cards in particular put their alternative half in +/// the same `back_face` slot, so `back_face.is_some()` would report all 21 of +/// them as double-faced. A DFC currently showing its back face is caught by the /// `transformed` flag, because `snapshot_object_face` zeroes `layout_kind` when /// the front face is stashed in `back_face` during a transform. -pub(crate) fn is_double_faced_permanent(obj: &crate::game::game_object::GameObject) -> bool { +pub fn is_double_faced_permanent(obj: &crate::game::game_object::GameObject) -> bool { use crate::types::card::LayoutKind; // CR 730.2j: a face-up melded permanent contains a double-faced component. if obj.merge_kind == Some(crate::game::game_object::MergeKind::Meld) { diff --git a/crates/engine/src/game/trigger_index.rs b/crates/engine/src/game/trigger_index.rs index da611f19f8..ee949a4190 100644 --- a/crates/engine/src/game/trigger_index.rs +++ b/crates/engine/src/game/trigger_index.rs @@ -611,6 +611,15 @@ pub(crate) fn keys_from_event(event: &GameEvent, state: &GameState) -> Keys { | GameEvent::TurnedFaceDown { .. } => { push(TriggerEventKey::FaceOrTransform); } + // CR 701.27b (by analogy): transforming and turning a permanent face + // up/down are distinct game actions that don't share triggers even + // though they use the same physical action; flipping is likewise its + // own game action. No printed flip card has a trigger that fires on + // flipping (a design fact about the card pool, not a CR statement). + // Deliberately dispatches NO trigger key — folding it into + // `FaceOrTransform` would consult transform/face-change triggers for an + // event none of them can match. + GameEvent::Flipped { .. } => {} GameEvent::DayNightChanged { .. } => push(TriggerEventKey::DayNightChanged), GameEvent::CardsRevealed { .. } => push(TriggerEventKey::Revealed), GameEvent::CrimeCommitted { .. } => push(TriggerEventKey::PlayerActionPerformed), @@ -931,6 +940,12 @@ fn keys_from_effect_kind(kind: EffectKind, push: &mut impl FnMut(TriggerEventKey // action; its own `EffectResolved` dispatches no trigger key. | EffectKind::BecomeSaddled | EffectKind::Transform + // No printed flip card has a trigger that fires on flipping (a design + // fact about the card pool, not a CR statement), so — mirroring + // `Transform` above — this effect's `EffectResolved` dispatches no key; + // `GameEvent::Flipped` is a log/display notification and dispatches no + // key either. + | EffectKind::FlipPermanent | EffectKind::TurnFaceUp // CR 701.27b: a turned-face-down permanent fires any face-down trigger // via the dedicated `GameEvent::TurnedFaceDown`, not via this effect's diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index 84044d229e..a7d2d46513 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -993,6 +993,11 @@ fn count_matching_trigger_event_subjects( | GameEvent::Saddled { .. } | GameEvent::ReplacementApplied { .. } | GameEvent::Transformed { .. } + // No printed flip card has a trigger that fires on flipping (a design + // fact about the card pool, not a CR statement), so — like `Transformed` + // above — this event carries no per-object trigger subject in this + // generic helper. + | GameEvent::Flipped { .. } | GameEvent::Specialized { .. } | GameEvent::DayNightChanged { .. } | GameEvent::TurnedFaceUp { .. } diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index 8a0a14d54d..58ed2c76be 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -289,6 +289,22 @@ pub(crate) fn apply_zone_exit_cleanup( } } + // CR 710.4 + CR 110.5: A flipped permanent that leaves the battlefield + // retains no memory of its status, and in every zone other than the + // battlefield a flip card has only its normal characteristics + // (CR 710.2). Restore the normal half and clear the flipped status. + // + // Ordered AFTER the CR 708.9 face-down restore on purpose: a flipped + // permanent that was then turned face down (Ixidron, Cyber Conversion) + // shares this one `back_face` slot between both statuses. + // `effects::turn_face_down` keeps the flip stash (the normal half) in + // it, so the face-down restore above already puts the normal half back + // on the object; this call then only has to clear the flipped status + // (its `back_face == None` branch). Running it first would instead + // consume the flip stash and leave the face-down 2/2 shell to be + // restored into the graveyard. + crate::game::flip::revert_flip_on_zone_exit(obj_mut); + // CR 400.7 + CR 113.6e: Clear exile-based casting permissions when leaving exile // (prevents re-casting if the card returns to exile via a different effect). if from == Zone::Exile { diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index ab8ea00b1b..a701011a62 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -5051,6 +5051,71 @@ pub(super) fn lower_choose_ast(ast: ChooseImperativeAst) -> Effect { } } +/// CR 710.4 + CR 608.2k: Which anaphor class names the permanent in a +/// "flip <x>" instruction. The two classes bind differently — a self-deictic +/// always names the object the ability is on, while a bare object pronoun +/// routes through `resolve_pronoun_target`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FlipSubjectAnaphor { + SelfDeictic, + ObjectPronoun, +} + +/// CR 710.4: `flip`/`flips` + the permanent being flipped, anchored at `eof`. +/// +/// This covers the ENTIRE printed flip-card corpus (21 cards), which uses +/// exactly three surface forms: +/// - "flip this creature" — Budoka Gardener, Bushi Tenderfoot, Initiate of +/// Blood, Jushi Apprentice, Nezumi Graverobber, Nezumi Shortfang, Orochi +/// Eggwatcher +/// - "flip it" — Akki Lavarunner, Kitsune Mystic, Student of Elements, and the +/// five ki-counter Ascendants (Budoka Pupil, Callow Jushi, Cunning Bandit, +/// Faithful Squire, Hired Muscle) +/// - "flip <name>" — Erayo, Kuon, Rune-Tail, Sasaya; `normalize_card_name_refs` +/// has already rewritten the legendary short name to `~` before this runs +/// +/// CR 710 defines no "flip target <permanent>" form — a flip instruction is +/// always self-referential — so this recognizer is deliberately closed over +/// self-references and the bare object pronoun rather than falling through to +/// `parse_target`. That closure is also what keeps CR 705.1 coin flips out: +/// "flip a coin" matches no arm here, and the coin recognizers in +/// `parse_imperative_family_ast` are tried first regardless. +fn parse_flip_permanent_subject(input: &str) -> OracleResult<'_, FlipSubjectAnaphor> { + let (input, _) = alt((tag("flip "), tag("flips "))).parse(input)?; + let (input, anaphor) = alt(( + // Longest-match first: with the `eof` anchor outside this `alt`, a bare + // "it" arm would consume the head of "itself" and then fail without + // backtracking. + value( + FlipSubjectAnaphor::ObjectPronoun, + alt((tag("itself"), tag("it"))), + ), + value(FlipSubjectAnaphor::SelfDeictic, tag("~")), + // CR 700.7: "this" / "this " self-deictics ("this + // [something]" refers to that particular object), nested on the shared + // "this" prefix. + value( + FlipSubjectAnaphor::SelfDeictic, + preceded( + tag("this"), + opt(preceded( + tag(" "), + alt(( + tag("creature"), + tag("permanent"), + tag("artifact"), + tag("enchantment"), + tag("land"), + )), + )), + ), + ), + )) + .parse(input)?; + let (input, _) = eof.parse(input)?; + Ok((input, anaphor)) +} + pub(super) fn parse_utility_imperative_ast( text: &str, lower: &str, @@ -5253,6 +5318,25 @@ pub(super) fn parse_utility_imperative_ast( return Some(UtilityImperativeAst::Transform { target }); } } + // CR 710.4: the Kamigawa flip-card instruction. See + // `parse_flip_permanent_subject` for the corpus and the coin-flip + // separation (CR 705.1). + if let Some(anaphor) = nom_parse_lower(lower, parse_flip_permanent_subject) { + return Some(UtilityImperativeAst::FlipPermanent { + target: match anaphor { + // CR 700.7 + CR 201.5: a self-deictic always names the object + // the ability is on — CR 700.7 governs the "this " form + // ("this [something]" refers to that particular object), CR + // 201.5 the "~" name form (text referring to the object by name). + FlipSubjectAnaphor::SelfDeictic => TargetFilter::SelfRef, + // CR 608.2k: the bare object pronoun binds through the same + // anaphor dispatch as "transform it" — a typed trigger subject + // resolves to the triggering source, self-ref/any/none stays on + // the source. + FlipSubjectAnaphor::ObjectPronoun => resolve_pronoun_target(ctx, "it"), + }, + }); + } // CR 613.4d: switch power and toughness — two surface forms (sibling branches): // - prepositional: "switch the power and toughness of " (Inversion // Behemoth class — supports the "(each of) any number of target X" @@ -5661,6 +5745,8 @@ pub(super) fn lower_utility_imperative_ast(ast: UtilityImperativeAst) -> Effect starting_loyalty_from_casualty_sacrifice: false, }, UtilityImperativeAst::Transform { target } => Effect::Transform { target }, + // CR 710.4: Kamigawa flip cards. + UtilityImperativeAst::FlipPermanent { target } => Effect::FlipPermanent { target }, UtilityImperativeAst::Attach { attachment, target, .. } => Effect::Attach { attachment, target }, @@ -10040,6 +10126,15 @@ pub(super) fn parse_imperative_family_ast( .parse(lower) .ok() .map(|(_, ast)| ast) + // CR 710.4 vs CR 705.1: every coin-flip form is tried first, so the + // Kamigawa flip-card instruction ("flip this creature" / "flip it" / + // "flip ") can only reach this fallback once no coin arm has + // matched. `parse_flip_permanent_subject` is itself closed over + // self-references, so the two mechanics cannot collide either way. + .or_else(|| { + parse_utility_imperative_ast(text, lower, ctx) + .map(|ast| ImperativeFamilyAst::Structured(ImperativeAst::Utility(ast))) + }) } // CR 701.52: "roll to visit your Attractions" (not a generic d20/d6 roll). "roll" | "rolls" => { diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 7edf095edd..1619a3a7b0 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -17661,6 +17661,8 @@ fn replace_target_with_parent(effect: &mut Effect) { | Effect::Pump { target, .. } | Effect::Counter { target, .. } | Effect::Transform { target, .. } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target, .. } | Effect::Connive { target, .. } | Effect::PhaseOut { target } // CR 702.26c: PhaseIn is the symmetric partner of PhaseOut; route its @@ -17788,6 +17790,11 @@ fn replace_target_with_self(effect: &mut Effect) { Effect::Transform { target, .. } => { *target = TargetFilter::SelfRef; } + // CR 710.4: every printed flip instruction is self-referential, so the + // same source-binding fixup applies. + Effect::FlipPermanent { target, .. } => { + *target = TargetFilter::SelfRef; + } Effect::GenericEffect { target, static_abilities, @@ -19986,6 +19993,8 @@ fn inject_subject_target(effect: &mut Effect, subject: &SubjectPhraseAst) { | Effect::MoveCounters { target, .. } | Effect::Animate { target, .. } | Effect::Transform { target, .. } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target, .. } | Effect::RevealHand { target, .. } | Effect::TargetOnly { target, .. } | Effect::PreventDamage { target, .. } @@ -23978,6 +23987,8 @@ fn rewrite_parent_targets_to_tracked_set(effect: &mut Effect) { | Effect::Pump { target, .. } | Effect::Counter { target, .. } | Effect::Transform { target, .. } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target, .. } | Effect::Connive { target, .. } | Effect::PhaseOut { target } // CR 702.26c: PhaseIn mirrors PhaseOut; expose its target to tracked-set @@ -24237,6 +24248,8 @@ pub(crate) fn each_target_filter_mut(effect: &mut Effect, f: &mut impl FnMut(&mu | Effect::UnattachAll { target, .. } | Effect::Counter { target, .. } | Effect::Transform { target, .. } + // CR 710.4: same single-target-slot shape as `Transform`. + | Effect::FlipPermanent { target, .. } | Effect::Connive { target, .. } | Effect::PhaseOut { target } // CR 702.26c: PhaseIn is the symmetric partner of PhaseOut above; expose diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 465c44a762..f48a864142 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -5970,6 +5970,7 @@ pub(super) fn clause_is_dig_lookback_transparent(effect: &Effect) -> bool { | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + | Effect::FlipPermanent { .. } | Effect::SearchLibrary { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealHand { .. } diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index 6c6c17a226..ca1a2a7a98 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -1208,6 +1208,14 @@ pub(crate) enum UtilityImperativeAst { Transform { target: TargetFilter, }, + /// CR 710.4: the Kamigawa flip-card instruction ("flip this creature" / + /// "flip it" / "flip "). A sibling of [`UtilityImperativeAst::Transform`] + /// rather than a parameterization of it because CR 701.27a and CR 710 are + /// different game actions with different copiable-value semantics + /// (CR 710.1c holds color and mana cost fixed). + FlipPermanent { + target: TargetFilter, + }, Attach { attachment: TargetFilter, target: TargetFilter, diff --git a/crates/engine/src/parser/oracle_ir/doc.rs b/crates/engine/src/parser/oracle_ir/doc.rs index e8cec36a5e..005cd37449 100644 --- a/crates/engine/src/parser/oracle_ir/doc.rs +++ b/crates/engine/src/parser/oracle_ir/doc.rs @@ -1271,6 +1271,7 @@ fn stamp_effect_printed_slot(effect: &mut Effect, slot: usize, kind: PrintedItem Effect::Discard { .. } => {} Effect::Shuffle { .. } => {} Effect::Transform { .. } => {} + Effect::FlipPermanent { .. } => {} Effect::SearchLibrary { .. } => {} Effect::SearchOutsideGame { .. } => {} Effect::RevealHand { .. } => {} diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 64269f60d1..d77cc221e7 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -11165,6 +11165,27 @@ pub enum Effect { #[serde(default = "default_target_filter_self_ref")] target: TargetFilter, }, + /// CR 710.4: Flip a Kamigawa flip permanent — a one-way status change + /// (CR 110.5) after which the card's alternative name, text box, type line, + /// power, and toughness apply (CR 710.1b) while it remains on the + /// battlefield. + /// + /// Deliberately a sibling of [`Effect::Transform`] rather than a + /// parameterization of it: CR 701.27a restricts transforming to permanents + /// represented by double-faced cards, and CR 710.1c holds a flipped + /// permanent's color and mana cost FIXED where transforming swaps them. + /// The two live in different CR sections (701.27 vs 710) with different + /// copiable-value semantics, so the shared "turn the card over" surface is + /// not a single parameterized axis. + /// + /// Every printed flip instruction names the permanent itself ("flip this + /// creature" / "flip it" / "flip "), so `target` is `SelfRef` for the + /// whole corpus; it is a `TargetFilter` for uniformity with `Transform` and + /// so an anaphoric trigger subject can bind the flipping permanent. + FlipPermanent { + #[serde(default = "default_target_filter_self_ref")] + target: TargetFilter, + }, /// Search a player's library for card(s) matching a filter. /// The destination is handled by the sub_ability chain (ChangeZone + Shuffle). SearchLibrary { @@ -14090,6 +14111,9 @@ impl Effect { | Effect::Discard { target, .. } | Effect::Shuffle { target, .. } | Effect::Transform { target, .. } + // CR 710.4: the flipping permanent is named by the effect's target + // slot exactly like `Transform`'s. + | Effect::FlipPermanent { target, .. } | Effect::RevealHand { target, .. } | Effect::Reveal { target, .. } | Effect::TargetOnly { target, .. } @@ -14706,6 +14730,7 @@ impl Effect { | Effect::SetRoomDoorLock { .. } | Effect::ExtraTurn { .. } | Effect::Transform { .. } + | Effect::FlipPermanent { .. } | Effect::RevealTop { .. } | Effect::Reveal { .. } | Effect::TargetOnly { .. } @@ -14959,6 +14984,7 @@ impl Effect { | Effect::SetRoomDoorLock { .. } | Effect::ExtraTurn { .. } | Effect::Transform { .. } + | Effect::FlipPermanent { .. } | Effect::RevealTop { .. } | Effect::Reveal { .. } | Effect::TargetOnly { .. } @@ -15171,6 +15197,7 @@ pub fn effect_variant_name(effect: &Effect) -> &str { Effect::Discard { .. } => "Discard", Effect::Shuffle { .. } => "Shuffle", Effect::Transform { .. } => "Transform", + Effect::FlipPermanent { .. } => "FlipPermanent", Effect::SearchLibrary { .. } => "SearchLibrary", Effect::SearchOutsideGame { .. } => "SearchOutsideGame", Effect::RevealHand { .. } => "RevealHand", @@ -15552,6 +15579,8 @@ pub enum EffectKind { DraftFromSpellbook, ChooseOneOf, ChooseCounterAdjustment, + /// CR 710.4: a Kamigawa flip permanent was flipped to its alternative face. + FlipPermanent, Unimplemented, /// Engine-level equip action (not via an Effect handler). Equip, @@ -15672,6 +15701,7 @@ impl From<&Effect> for EffectKind { Effect::Discard { .. } => EffectKind::Discard, Effect::Shuffle { .. } => EffectKind::Shuffle, Effect::Transform { .. } => EffectKind::Transform, + Effect::FlipPermanent { .. } => EffectKind::FlipPermanent, Effect::SearchLibrary { .. } => EffectKind::SearchLibrary, Effect::SearchOutsideGame { .. } => EffectKind::SearchOutsideGame, Effect::RevealHand { .. } => EffectKind::Reveal, diff --git a/crates/engine/src/types/events.rs b/crates/engine/src/types/events.rs index 22bd13b117..7d40d28b39 100644 --- a/crates/engine/src/types/events.rs +++ b/crates/engine/src/types/events.rs @@ -1179,6 +1179,15 @@ pub enum GameEvent { Transformed { object_id: ObjectId, }, + /// CR 710.4: A Kamigawa flip permanent was flipped to its alternative face. + /// Distinct from `Transformed` — CR 701.27a restricts transforming to + /// double-faced permanents, and CR 710.1c keeps a flipped permanent's color + /// and mana cost unchanged where transforming swaps them. Drives the game + /// log and the public-state/frontend re-render. No printed card triggers on + /// a permanent flipping, so this event dispatches no trigger key. + Flipped { + object_id: ObjectId, + }, /// Digital-only Specialize: a permanent became a color-specific specialized face. Specialized { object_id: ObjectId, diff --git a/crates/engine/tests/integration/kamigawa_flip_cards.rs b/crates/engine/tests/integration/kamigawa_flip_cards.rs new file mode 100644 index 0000000000..58f1b14c44 --- /dev/null +++ b/crates/engine/tests/integration/kamigawa_flip_cards.rs @@ -0,0 +1,605 @@ +//! CR 710: Kamigawa flip cards, end to end. +//! +//! Every test here builds its permanent from the card's VERBATIM Oracle text +//! and resolves the flip instruction through the real stack (`push_to_stack` +//! plus `GameAction::PassPriority` submitted through `apply()`), so the parser +//! dispatch, the `Effect` lowering, the effects dispatcher, and +//! `flip::flip_permanent` all run in production order. +//! +//! The rules-bearing assertion is CR 710.1c (`flip_keeps_color_and_mana_cost`): +//! a flip card's color and mana cost don't change when the permanent flips. +//! Reverting `flip::apply_flipped_face_to_object` to the double-faced +//! applicator (`printed_cards::apply_back_face_to_object`) blanks both and +//! fails exactly that test while every other assertion here still passes. + +use engine::game::ability_utils::build_resolved_from_def; +use engine::game::game_object::BackFaceData; +use engine::game::layers::flush_layers; +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::game::stack; +use engine::types::ability::{AbilityDefinition, AbilityKind, Effect, EffectKind}; +use engine::types::actions::GameAction; +use engine::types::card::LayoutKind; +use engine::types::card_type::{CardType, CoreType, Supertype}; +use engine::types::events::GameEvent; +use engine::types::game_state::{StackEntry, StackEntryKind}; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::Keyword; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard}; +use engine::types::zones::Zone; + +/// Bushi Tenderfoot's verbatim Oracle text (MTGJSON `text`, face a). +const BUSHI_TENDERFOOT_ORACLE: &str = + "When a creature dealt damage by this creature this turn dies, flip this creature."; + +/// `{W}` — Bushi Tenderfoot's printed mana cost (CR 202.1). +fn white_mana_cost() -> ManaCost { + ManaCost::Cost { + shards: vec![ManaCostShard::White], + generic: 0, + } +} + +/// Kenzo the Hardhearted — Bushi Tenderfoot's alternative half (CR 710.1b). +/// A real flip card's bottom half has NO printed mana cost, which is why +/// reusing the double-faced applicator would violate CR 710.1c. +fn kenzo_alternative_face() -> BackFaceData { + BackFaceData { + name: "Kenzo the Hardhearted".to_string(), + power: Some(3), + toughness: Some(4), + loyalty: None, + defense: None, + card_types: CardType { + supertypes: vec![Supertype::Legendary], + core_types: vec![CoreType::Creature], + subtypes: vec!["Human".to_string(), "Samurai".to_string()], + }, + mana_cost: ManaCost::default(), + keywords: vec![Keyword::DoubleStrike, Keyword::Bushido(2)], + abilities: Vec::new(), + trigger_definitions: Default::default(), + replacement_definitions: Default::default(), + static_definitions: Default::default(), + color: Vec::new(), + printed_ref: None, + modal: None, + additional_cost: None, + strive_cost: None, + casting_restrictions: vec![], + casting_options: vec![], + layout_kind: Some(LayoutKind::Flip), + } +} + +/// Build a battlefield Bushi Tenderfoot from its verbatim Oracle text, with the +/// Kenzo half stashed exactly as `printed_cards::populate_back_face_if_dfc` +/// stores it for a `CardLayout::Flip` card. +fn bushi_tenderfoot_on_battlefield() -> (GameRunner, ObjectId) { + let mut scenario = GameScenario::new(); + let id = scenario + .add_creature_from_oracle(P0, "Bushi Tenderfoot", 1, 1, BUSHI_TENDERFOOT_ORACLE) + .id(); + let mut runner = scenario.build(); + { + let object = runner.state_mut().objects.get_mut(&id).unwrap(); + object.mana_cost = white_mana_cost(); + object.base_mana_cost = object.mana_cost.clone(); + object.color = vec![ManaColor::White]; + object.base_color = object.color.clone(); + object.back_face = Some(kenzo_alternative_face()); + } + (runner, id) +} + +/// Akki Lavarunner's verbatim Oracle text (MTGJSON `text`, face a). Its flip +/// instruction uses the BARE OBJECT PRONOUN form ("flip it"), which 8 of the 19 +/// corpus cards carrying a flip instruction use — the single most common +/// surface form, and the one that lowers to `TargetFilter::ParentTarget`. +const AKKI_LAVARUNNER_ORACLE: &str = "Haste +Whenever this creature deals damage to an opponent, flip it."; + +/// Tok-Tok, Volcano Born — Akki Lavarunner's alternative half (CR 710.1b). +/// A 2/2 Legendary Creature — Goblin Shaman with a damage-prevention static. +fn tok_tok_alternative_face() -> BackFaceData { + BackFaceData { + name: "Tok-Tok, Volcano Born".to_string(), + power: Some(2), + toughness: Some(2), + loyalty: None, + defense: None, + card_types: CardType { + supertypes: vec![Supertype::Legendary], + core_types: vec![CoreType::Creature], + subtypes: vec!["Goblin".to_string(), "Shaman".to_string()], + }, + mana_cost: ManaCost::default(), + keywords: Vec::new(), + abilities: Vec::new(), + trigger_definitions: Default::default(), + replacement_definitions: Default::default(), + static_definitions: Default::default(), + color: Vec::new(), + printed_ref: None, + modal: None, + additional_cost: None, + strive_cost: None, + casting_restrictions: vec![], + casting_options: vec![], + layout_kind: Some(LayoutKind::Flip), + } +} + +/// `{1}{R}` — Akki Lavarunner's printed mana cost (CR 202.1). +fn one_red_mana_cost() -> ManaCost { + ManaCost::Cost { + shards: vec![ManaCostShard::Red], + generic: 1, + } +} + +/// Build a battlefield Akki Lavarunner from its verbatim Oracle text. +fn akki_lavarunner_on_battlefield() -> (GameRunner, ObjectId) { + let mut scenario = GameScenario::new(); + let id = scenario + .add_creature_from_oracle(P0, "Akki Lavarunner", 1, 1, AKKI_LAVARUNNER_ORACLE) + .id(); + let mut runner = scenario.build(); + { + let object = runner.state_mut().objects.get_mut(&id).unwrap(); + object.mana_cost = one_red_mana_cost(); + object.base_mana_cost = object.mana_cost.clone(); + object.color = vec![ManaColor::Red]; + object.base_color = object.color.clone(); + object.back_face = Some(tok_tok_alternative_face()); + } + (runner, id) +} + +/// The parsed body of `source`'s first trigger — for Bushi Tenderfoot, the +/// "flip this creature" instruction. +/// +/// Captured separately from resolution because flipping REPLACES the +/// permanent's text box (CR 710.1b): after the flip, Kenzo the Hardhearted has +/// no triggered ability at all, so the repeat-flip test must re-push the body +/// it captured before the flip. +fn captured_trigger_body( + runner: &GameRunner, + source: ObjectId, + source_name: &str, +) -> AbilityDefinition { + runner.state().objects[&source] + .trigger_definitions + .as_slice() + .first() + .and_then(|entry| entry.definition.execute.as_deref().cloned()) + .unwrap_or_else(|| panic!("{source_name} must parse a trigger with an execute body")) +} + +/// Push `execute` onto the stack as a triggered ability of `source` and resolve +/// it by submitting real `GameAction::PassPriority` actions through `apply()`, +/// returning every `GameEvent` the engine emitted along the way. +fn resolve_trigger_body( + runner: &mut GameRunner, + source: ObjectId, + source_name: &str, + execute: &AbilityDefinition, +) -> Vec { + let ability = build_resolved_from_def(execute, source, P0); + let entry_id = ObjectId(runner.state().next_object_id); + runner.state_mut().next_object_id += 1; + stack::push_to_stack( + runner.state_mut(), + StackEntry { + id: entry_id, + source_id: source, + controller: P0, + kind: StackEntryKind::TriggeredAbility { + source_id: source, + ability: Box::new(ability), + condition: None, + trigger_event: None, + description: None, + source_name: source_name.to_string(), + subject_match_count: None, + die_result: None, + }, + }, + &mut vec![], + ); + + let mut emitted = Vec::new(); + let initial_stack_len = runner.state().stack.len(); + for _ in 0..10 { + if runner.state().stack.len() < initial_stack_len { + break; + } + match runner.act(GameAction::PassPriority) { + Ok(result) => emitted.extend(result.events), + Err(_) => break, + } + } + flush_layers(runner.state_mut()); + emitted +} + +/// CR 710.1b: once the permanent is flipped, the alternative name, type line, +/// power, toughness, and text box apply instead of the normal ones. +#[test] +fn flip_applies_the_alternative_name_type_line_pt_and_text_box() { + let (mut runner, bushi) = bushi_tenderfoot_on_battlefield(); + assert_eq!(runner.state().objects[&bushi].name, "Bushi Tenderfoot"); + assert!(!runner.state().objects[&bushi].flipped); + + let execute = captured_trigger_body(&runner, bushi, "Bushi Tenderfoot"); + let _ = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + + let object = &runner.state().objects[&bushi]; + assert!(object.flipped, "CR 710.4: the permanent is now flipped"); + assert_eq!(object.name, "Kenzo the Hardhearted"); + assert_eq!( + (object.power, object.toughness), + (Some(3), Some(4)), + "CR 710.1b: the alternative power and toughness apply" + ); + assert!( + object.card_types.supertypes.contains(&Supertype::Legendary), + "CR 710.1b: the alternative type line applies" + ); + assert!( + object + .card_types + .subtypes + .iter() + .any(|subtype| subtype == "Samurai"), + "CR 710.1b: the alternative type line applies" + ); + assert!( + object.keywords.contains(&Keyword::DoubleStrike), + "CR 710.1b: the alternative text box applies" + ); + assert!( + object.trigger_definitions.as_slice().is_empty(), + "CR 710.1b + CR 710.2: the normal half's triggered ability no longer applies once the permanent is flipped" + ); +} + +/// CR 710.1c: a flip card's color and mana cost DON'T change if the permanent +/// is flipped. +/// +/// This is the assertion that fails if `flip::apply_flipped_face_to_object` is +/// replaced by the double-faced applicator: Kenzo's half has no printed mana +/// cost and no color, so the permanent would become a colorless {0} object. +#[test] +fn flip_keeps_color_and_mana_cost() { + let (mut runner, bushi) = bushi_tenderfoot_on_battlefield(); + + let execute = captured_trigger_body(&runner, bushi, "Bushi Tenderfoot"); + let _ = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + + let object = &runner.state().objects[&bushi]; + assert!( + object.flipped, + "reach guard: the permanent actually flipped" + ); + assert_eq!(object.name, "Kenzo the Hardhearted", "reach guard"); + assert_eq!( + object.mana_cost, + white_mana_cost(), + "CR 710.1c: a flip card's mana cost doesn't change when it flips" + ); + assert_eq!( + object.base_mana_cost, + white_mana_cost(), + "CR 710.1c: the printed baseline keeps the normal half's mana cost" + ); + assert_eq!( + object.color, + vec![ManaColor::White], + "CR 710.1c: a flip card's color doesn't change when it flips" + ); + assert_eq!(object.base_color, vec![ManaColor::White]); +} + +/// CR 710.4: flipping a permanent is a one-way process. A second flip +/// instruction does nothing and emits no second `Flipped` event. +#[test] +fn flipping_an_already_flipped_permanent_is_a_no_op() { + let (mut runner, bushi) = bushi_tenderfoot_on_battlefield(); + + let execute = captured_trigger_body(&runner, bushi, "Bushi Tenderfoot"); + let first = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + assert!(runner.state().objects[&bushi].flipped, "reach guard"); + assert_eq!( + first + .iter() + .filter( + |event| matches!(event, GameEvent::Flipped { object_id } if *object_id == bushi) + ) + .count(), + 1, + "reach guard: the first instruction really did flip the permanent" + ); + let name_after_first = runner.state().objects[&bushi].name.clone(); + + let second = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + + // Reach guard: the second flip effect actually resolved (got past dispatch + // into `flip_permanent::resolve`), so the absence of a second `Flipped` + // event below is the CR 710.4 one-way no-op — not an upstream short-circuit + // that skipped the effect entirely. + assert!( + second.iter().any(|event| matches!( + event, + GameEvent::EffectResolved { + kind: EffectKind::FlipPermanent, + .. + } + )), + "the repeat FlipPermanent effect must reach the resolver even when it no-ops" + ); + + let state = runner.state(); + assert!(state.objects[&bushi].flipped); + assert_eq!( + state.objects[&bushi].name, name_after_first, + "CR 710.4: the permanent cannot flip back or flip again" + ); + assert_eq!( + (state.objects[&bushi].power, state.objects[&bushi].toughness), + (Some(3), Some(4)) + ); + assert_eq!( + second + .iter() + .filter( + |event| matches!(event, GameEvent::Flipped { object_id } if *object_id == bushi) + ) + .count(), + 0, + "CR 710.4: the repeat instruction emits no second Flipped event" + ); +} + +/// CR 710.2 + CR 710.4 + CR 110.5: in every zone other than the battlefield a +/// flip card has only the normal characteristics of the card, and a flipped +/// permanent that leaves the battlefield retains no memory of its status. +#[test] +fn a_flipped_permanent_leaving_the_battlefield_shows_only_normal_characteristics() { + let (mut runner, bushi) = bushi_tenderfoot_on_battlefield(); + + let execute = captured_trigger_body(&runner, bushi, "Bushi Tenderfoot"); + let _ = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + assert!(runner.state().objects[&bushi].flipped, "reach guard"); + assert_eq!(runner.state().objects[&bushi].name, "Kenzo the Hardhearted"); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), bushi, Zone::Graveyard, &mut events); + + let object = &runner.state().objects[&bushi]; + assert_eq!(object.zone, Zone::Graveyard); + assert!( + !object.flipped, + "CR 110.5b + CR 710.4: the card in the graveyard is not flipped" + ); + assert_eq!( + object.name, "Bushi Tenderfoot", + "CR 710.2: only the normal characteristics apply off the battlefield" + ); + assert_eq!((object.power, object.toughness), (Some(1), Some(1))); + assert!(!object.card_types.supertypes.contains(&Supertype::Legendary)); + assert_eq!( + object.mana_cost, + white_mana_cost(), + "CR 710.1c: the mana cost was never changed, so the revert restores it unchanged" + ); +} + +/// CR 710.1b + CR 710.2: a flip card sitting in a non-battlefield zone cannot +/// be flipped at all — the alternative characteristics exist only for a +/// battlefield permanent. +#[test] +fn a_flip_card_off_the_battlefield_cannot_flip() { + let (mut runner, bushi) = bushi_tenderfoot_on_battlefield(); + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), bushi, Zone::Graveyard, &mut events); + + let execute = captured_trigger_body(&runner, bushi, "Bushi Tenderfoot"); + let events = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + + // Reach guard: the flip effect actually resolved (got past dispatch into + // `flip_permanent::resolve`), so the unchanged object below reflects the CR + // 710.2 off-battlefield no-op, not an upstream short-circuit that never ran. + assert!( + events.iter().any(|event| matches!( + event, + GameEvent::EffectResolved { + kind: EffectKind::FlipPermanent, + .. + } + )), + "the FlipPermanent effect must reach the resolver even when it no-ops" + ); + + let object = &runner.state().objects[&bushi]; + assert!(!object.flipped); + assert_eq!(object.name, "Bushi Tenderfoot"); + assert_eq!((object.power, object.toughness), (Some(1), Some(1))); +} + +/// The verbatim Oracle text (MTGJSON `text`, face a) of every printed flip card +/// that carries a flip INSTRUCTION — 19 of the 21 CR 710 flip cards. +/// +/// The two excluded cards carry no flip instruction and are documented +/// out-of-scope in `game::flip`'s module docs: +/// - Homura, Human Ascendant — "return it to the battlefield flipped" is an +/// entry-time rider (CR 110.5b), not a flip instruction. +/// - Curse of the Fire Penguin — an Un-set Aura with no flip verb at all. +const FLIP_INSTRUCTION_CORPUS: &[(&str, &str)] = &[ + ("Akki Lavarunner", "Haste +Whenever this creature deals damage to an opponent, flip it."), + ("Budoka Gardener", "{T}: You may put a land card from your hand onto the battlefield. If you control ten or more lands, flip this creature."), + ("Budoka Pupil", "Whenever you cast a Spirit or Arcane spell, you may put a ki counter on this creature. +At the beginning of the end step, if there are two or more ki counters on this creature, you may flip it."), + ("Bushi Tenderfoot", "When a creature dealt damage by this creature this turn dies, flip this creature."), + ("Callow Jushi", "Whenever you cast a Spirit or Arcane spell, you may put a ki counter on this creature. +At the beginning of the end step, if there are two or more ki counters on this creature, you may flip it."), + ("Cunning Bandit", "Whenever you cast a Spirit or Arcane spell, you may put a ki counter on this creature. +At the beginning of the end step, if there are two or more ki counters on this creature, you may flip it."), + ("Erayo, Soratami Ascendant", "Flying +Whenever the fourth spell of a turn is cast, flip Erayo."), + ("Faithful Squire", "Whenever you cast a Spirit or Arcane spell, you may put a ki counter on this creature. +At the beginning of the end step, if there are two or more ki counters on this creature, you may flip it."), + ("Hired Muscle", "Whenever you cast a Spirit or Arcane spell, you may put a ki counter on this creature. +At the beginning of the end step, if there are two or more ki counters on this creature, you may flip it."), + ("Initiate of Blood", "{T}: This creature deals 1 damage to target creature that was dealt damage this turn. When that creature dies this turn, flip this creature."), + ("Jushi Apprentice", "{2}{U}, {T}: Draw a card. If you have nine or more cards in hand, flip this creature."), + ("Kitsune Mystic", "At the beginning of the end step, if this creature is enchanted by two or more Auras, flip it."), + ("Kuon, Ogre Ascendant", "At the beginning of the end step, if three or more creatures died this turn, flip Kuon."), + ("Nezumi Graverobber", "{1}{B}: Exile target card from an opponent's graveyard. If no cards are in that graveyard, flip this creature."), + ("Nezumi Shortfang", "{1}{B}, {T}: Target opponent discards a card. Then if that player has no cards in hand, flip this creature."), + ("Orochi Eggwatcher", "{2}{G}, {T}: Create a 1/1 green Snake creature token. If you control ten or more creatures, flip this creature."), + ("Rune-Tail, Kitsune Ascendant", "When you have 30 or more life, flip Rune-Tail."), + ("Sasaya, Orochi Ascendant", "Reveal your hand: If you have seven or more land cards in your hand, flip Sasaya."), + ("Student of Elements", "When this creature has flying, flip it."), +]; + +/// True when any effect anywhere in `definition`'s chain is `FlipPermanent`, +/// including inside a delayed-trigger body (Initiate of Blood's "When that +/// creature dies this turn, flip this creature" rider, CR 603.7a). +fn chain_contains_flip_permanent(definition: &AbilityDefinition) -> bool { + if matches!(*definition.effect, Effect::FlipPermanent { .. }) { + return true; + } + if let Effect::CreateDelayedTrigger { effect, .. } = definition.effect.as_ref() { + if chain_contains_flip_permanent(effect) { + return true; + } + } + definition + .sub_ability + .iter() + .chain(definition.else_ability.iter()) + .map(|boxed| boxed.as_ref()) + .chain(definition.mode_abilities.iter()) + .any(chain_contains_flip_permanent) +} + +/// CR 710.4: EVERY printed flip card's flip instruction lowers to +/// `Effect::FlipPermanent`, across all three surface forms — "flip this +/// creature" (7 cards), "flip it" (8 cards), and the by-name "flip <name>" +/// (4 cards, normalized to `~` upstream) — and across both carriers (activated +/// abilities and triggered abilities). This is the build-for-the-class proof: +/// the recognizer is not tuned to one card's phrasing. +#[test] +fn every_printed_flip_card_lowers_its_instruction_to_flip_permanent() { + let mut missing = Vec::new(); + for (card_name, oracle) in FLIP_INSTRUCTION_CORPUS { + let parsed = engine::parser::oracle::parse_oracle_text(oracle, card_name, &[], &[], &[]); + let found = parsed.abilities.iter().any(chain_contains_flip_permanent) + || parsed.triggers.iter().any(|trigger| { + trigger + .execute + .as_deref() + .is_some_and(chain_contains_flip_permanent) + }); + if !found { + missing.push(*card_name); + } + } + assert!( + missing.is_empty(), + "these flip cards did not lower their flip instruction to Effect::FlipPermanent: {missing:?}" + ); +} + +/// CR 710.4 + CR 608.2k: the BARE OBJECT PRONOUN form ("flip it") — an +/// untargeted back-reference to the object named by the trigger condition — +/// resolves and flips the ability's own source, end to end. +/// +/// This is the surface form 8 of the 19 corpus cards use (Akki Lavarunner, the +/// five ki-counter Ascendants, Kitsune Mystic, Student of Elements), and it +/// lowers to a different `TargetFilter` than the self-deictic "flip this +/// creature" form: `resolve_pronoun_target` yields `TargetFilter::ParentTarget` +/// whenever the parse context has no non-self trigger subject, which is true for +/// all eight. It reaches `flip_permanent` only through +/// `effects::flip_permanent`'s empty-`targets` arm (`[] => ability.source_id`). +/// +/// Discriminating: break that arm (return an error, or resolve `ParentTarget` +/// to anything other than the source) and the permanent never flips — the +/// `flipped` / name / power assertions below all fail. The parse assertion +/// alone would not catch it; this drives the real stack via +/// `GameAction::PassPriority` through `apply()`. +#[test] +fn the_flip_it_pronoun_form_flips_its_own_source_end_to_end() { + let (mut runner, akki) = akki_lavarunner_on_battlefield(); + assert_eq!(runner.state().objects[&akki].name, "Akki Lavarunner"); + assert!(!runner.state().objects[&akki].flipped); + + let execute = captured_trigger_body(&runner, akki, "Akki Lavarunner"); + // Reach guard: the "flip it" form really does take the ParentTarget branch + // (not the SelfRef branch the "flip this creature" cards take), so this test + // exercises the arm the majority of the corpus depends on. + assert!( + matches!( + *execute.effect, + Effect::FlipPermanent { + target: engine::types::ability::TargetFilter::ParentTarget + } + ), + "reach guard: 'flip it' must lower to ParentTarget, got {:?}", + execute.effect + ); + + let emitted = resolve_trigger_body(&mut runner, akki, "Akki Lavarunner", &execute); + + let object = &runner.state().objects[&akki]; + assert!(object.flipped, "CR 710.4: the permanent is now flipped"); + assert_eq!(object.name, "Tok-Tok, Volcano Born"); + assert_eq!( + (object.power, object.toughness), + (Some(2), Some(2)), + "CR 710.1b: the alternative power and toughness apply" + ); + assert!( + object.card_types.supertypes.contains(&Supertype::Legendary), + "CR 710.1b: the alternative type line applies" + ); + assert_eq!( + object.mana_cost, + one_red_mana_cost(), + "CR 710.1c: a flip card's mana cost doesn't change when it flips" + ); + assert_eq!(object.color, vec![ManaColor::Red]); + assert_eq!( + emitted + .iter() + .filter(|event| matches!(event, GameEvent::Flipped { object_id } if *object_id == akki)) + .count(), + 1, + "exactly one Flipped event for the source" + ); +} + +/// CR 705.1 vs CR 710.4: the coin-flip mechanic is untouched — "flip a coin" +/// still lowers to `Effect::FlipCoin`, not to the new flip-permanent effect. +#[test] +fn flip_a_coin_still_parses_to_flip_coin() { + let definition = + engine::parser::oracle_effect::parse_effect_chain("Flip a coin.", AbilityKind::Spell); + assert!( + matches!(*definition.effect, Effect::FlipCoin { .. }), + "CR 705.1: 'flip a coin' must stay a coin flip, got {:?}", + definition.effect + ); + + let until_lose = engine::parser::oracle_effect::parse_effect_chain( + "Flip a coin until you lose a flip.", + AbilityKind::Spell, + ); + assert!( + matches!(*until_lose.effect, Effect::FlipCoinUntilLose { .. }), + "CR 705.1: the Krark-style repeat coin flip is unaffected, got {:?}", + until_lose.effect + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 621d6f14a3..e041e3ae5a 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -620,6 +620,7 @@ mod jaws_of_defeat; mod json_smoke_test; mod judgment_bolt_where_x_damage_runtime; mod kaito_integration; +mod kamigawa_flip_cards; mod kaya_geist_hunter; mod kaya_spirits_justice_per_opponent_exile; mod kaysa_green_anthem; diff --git a/crates/manabrew-compat/src/lib.rs b/crates/manabrew-compat/src/lib.rs index efa4b03f7d..057e763689 100644 --- a/crates/manabrew-compat/src/lib.rs +++ b/crates/manabrew-compat/src/lib.rs @@ -2156,7 +2156,11 @@ fn build_card_dto( }, summoning_sick: !redacted && object.has_summoning_sickness, is_copy: false, - is_double_faced: !redacted && object.back_face.is_some(), + // CR 712.16 + CR 710.1b: the engine owns "is this permanent + // double-faced?". `back_face.is_some()` is not that predicate — a CR 710 + // flip card parks its alternative half in the same slot (as do Adventure + // and Omen cards), so the raw check reports every flip card as a DFC. + is_double_faced: !redacted && engine::game::transform::is_double_faced_permanent(object), is_transformed: !redacted && object.transformed, is_face_down: object.face_down, is_bestowed: !redacted && object.bestow_form.is_some(), diff --git a/crates/phase-ai/src/policies/effect_classify.rs b/crates/phase-ai/src/policies/effect_classify.rs index a2801396b3..2ce6573ea6 100644 --- a/crates/phase-ai/src/policies/effect_classify.rs +++ b/crates/phase-ai/src/policies/effect_classify.rs @@ -368,6 +368,9 @@ pub(crate) fn effect_polarity(effect: &Effect) -> EffectPolarity { | Effect::TargetOnly { .. } | Effect::TimeTravel | Effect::Transform { .. } + // CR 710.4: like Transform, flipping swaps a permanent's characteristics + // wholesale — whether the alternative half is better is card-specific. + | Effect::FlipPermanent { .. } | Effect::Tribute { .. } | Effect::TurnFaceDown { .. } | Effect::TurnFaceUp { .. } diff --git a/crates/phase-ai/src/policies/redundancy_avoidance.rs b/crates/phase-ai/src/policies/redundancy_avoidance.rs index b87847ef36..88a9f31f17 100644 --- a/crates/phase-ai/src/policies/redundancy_avoidance.rs +++ b/crates/phase-ai/src/policies/redundancy_avoidance.rs @@ -23,6 +23,8 @@ //! Shipped predicates (see `redundancy_delta` arms): //! - `Tap` — every candidate target is already tapped. //! - `Untap` — every candidate target is already untapped. +//! - `FlipPermanent` — every candidate target is already flipped (CR 710.4: +//! flipping is one-way, so re-flipping a flipped permanent is a no-op). //! - `Pump` — every candidate target already has an active //! `UntilEndOfTurn` pump from this same source with matching P/T. //! - `GainLife` — controller's life ≥ `LIFE_DIMINISHING_RETURNS`. @@ -147,6 +149,10 @@ const KIND_ADD_COUNTER_ZERO: i64 = 9; /// CR 601.3b: Activating a flash-cast permission (Alchemist's Refuge class) /// when no hand spell would gain instant-speed timing. const KIND_FLASH_CAST_PERMISSION: i64 = 10; +/// CR 710.4: Flipping is a one-way process — once a permanent is flipped it can +/// never become unflipped. Targeting an already-flipped permanent with a flip +/// instruction is therefore a deterministic no-op, unlike two-way `Transform`. +const KIND_FLIP_ALREADY_FLIPPED: i64 = 11; pub struct RedundancyAvoidancePolicy; @@ -358,6 +364,11 @@ fn redundancy_delta( } Some(_) => None, }, + // CR 710.4: flipping is one-way — an already-flipped permanent can never + // become unflipped, so a flip instruction on it is a deterministic no-op. + // Unlike two-way `Transform` (which stays in the no-op list below), this + // admits a target-aware redundancy signal when every candidate is flipped. + Effect::FlipPermanent { target } => flip_redundancy(state, source_id, target), // ----- Variants with no shipped redundancy check ----- // @@ -778,6 +789,32 @@ fn tap_redundancy( } } +/// Flip-on-flipped: every candidate match already has `obj.flipped == true`. +/// +/// CR 710.4: flipping is a one-way process — once a permanent is flipped it can +/// never become unflipped. A flip instruction whose entire candidate set is +/// already flipped therefore does nothing, exactly like tap-on-tapped. This is +/// the axis on which `FlipPermanent` diverges from the two-way `Transform`, +/// which stays in the no-op list because re-transforming flips the face back. +fn flip_redundancy( + state: &GameState, + source_id: ObjectId, + target: &TargetFilter, +) -> Option<(f64, i64, i64)> { + let candidates = resolved_candidate_targets(state, source_id, target); + if candidates.is_empty() { + return None; + } + let all_flipped = candidates + .iter() + .all(|id| state.objects.get(id).is_some_and(|o| o.flipped)); + if all_flipped { + Some((-3.0, KIND_FLIP_ALREADY_FLIPPED, candidates.len() as i64)) + } else { + None + } +} + /// Untap-on-untapped: symmetric to `tap_redundancy`. Every candidate match /// is already untapped, so the Untap effect is a no-op on its target set. fn untap_redundancy( @@ -1305,6 +1342,63 @@ mod tests { assert_eq!(delta, 0.0, "untap on tapped should not penalise"); } + #[test] + fn flip_on_flipped_source_penalized() { + // CR 710.4: flipping is one-way, so a flip instruction targeting an + // already-flipped permanent is a deterministic no-op — same -3.0 + // classification as tap-on-tapped. + let mut state = GameState::new_two_player(0); + let obj_id = make_creature_with_ability( + &mut state, + "Kenzo the Hardhearted", + Effect::FlipPermanent { + target: TargetFilter::SelfRef, + }, + ); + state.objects.get_mut(&obj_id).unwrap().flipped = true; + + let config = AiConfig::default(); + let ai_ctx = AiContext::empty(&config.weights); + let decision = priority_decision(); + let candidate = activate_candidate(obj_id); + let ctx = mk_ctx(&state, &decision, &candidate, &config, &ai_ctx); + + let PolicyVerdict::Score { delta, .. } = RedundancyAvoidancePolicy.verdict(&ctx) else { + panic!("expected Score verdict"); + }; + assert_eq!( + delta, -3.0, + "flip on already-flipped should emit -3.0 delta" + ); + } + + #[test] + fn flip_on_unflipped_source_not_penalized() { + // Reach guard: same effect, unflipped target — the flip is meaningful, + // so the redundancy arm must NOT fire (proves the -3.0 above is driven + // by the `flipped` state, not an upstream short-circuit). + let mut state = GameState::new_two_player(0); + let obj_id = make_creature_with_ability( + &mut state, + "Bushi Tenderfoot", + Effect::FlipPermanent { + target: TargetFilter::SelfRef, + }, + ); + // default flipped = false -- flipping is a real state change here + + let config = AiConfig::default(); + let ai_ctx = AiContext::empty(&config.weights); + let decision = priority_decision(); + let candidate = activate_candidate(obj_id); + let ctx = mk_ctx(&state, &decision, &candidate, &config, &ai_ctx); + + let PolicyVerdict::Score { delta, .. } = RedundancyAvoidancePolicy.verdict(&ctx) else { + panic!("expected Score verdict"); + }; + assert_eq!(delta, 0.0, "flip on unflipped must not penalise"); + } + #[test] fn walking_ballista_deal_damage_not_penalized() { // Walking Ballista's ability is "Remove +1/+1 counter → deal 1 damage".