From 359e3d529cabec8b1c3a253e12f35cdb6e99e2bc Mon Sep 17 00:00:00 2001 From: real-venus Date: Tue, 21 Jul 2026 16:13:26 -0700 Subject: [PATCH 1/3] feat(engine): implement CR 710 Kamigawa flip cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `Effect::FlipPermanent`, implementing the flip-card mechanic. 19 of the 21 printed CR 710 cards now lower their flip instruction to a real effect instead of `Unimplemented` (Bushi Tenderfoot, Budoka Gardener, Nezumi Shortfang, Kitsune Mystic, Akki Lavarunner, the five Saviors "Ascendant" cards, ...). Their alternative faces already parsed, so for most of them this was the only gap. Flipping is deliberately a separate engine slot from transforming, per the CR: - CR 710.1b: the alternative name / type line / P/T / text box apply only while the permanent is on the battlefield and flipped. - CR 710.1c: a flip card's color and mana cost do NOT change. `flip.rs` therefore has its own `apply_flipped_face_to_object` rather than reusing the DFC applicator, which swaps them. This is enforced on the flip path, the save/load rehydrate path, and by a guard that stops `Effect::Transform` / `GameAction::Transform` from ever running the DFC applicator on a flip card. - CR 710.2 / 710.4: flipping is one-way, and a flipped permanent that leaves the battlefield reverts to its normal half (CR 110.5 new object). - CR 707.2: a copy of a flipped permanent copies the unflipped normal half. Also handles the interactions that giving flip cards a back face exposes: turning a flipped permanent face down no longer clobbers its stashed normal half, and the double-faced-card indicator (engine DTO + client) no longer fires for flip cards. `GameEvent::Flipped` is emitted for the log/UI but intentionally dispatches no trigger key — no printed card triggers on flipping. Out of scope, documented in `flip.rs`: CR 710.5 (choosing the alternative name), Homura's enters-flipped rider, and two second-order cases that would need a second face-stash slot on GameObject. 18 tests: per-rule coverage of 710.1b/1c/2/4, the transform and face-down interactions, copy semantics, save/load, the "flip it" pronoun form end to end, and a corpus test pinning all 19 cards. CR 705.1 ("flip a coin") is unaffected. --- client/src/adapter/types.ts | 28 + client/src/components/card/ArtCropCard.tsx | 7 +- client/src/viewmodel/cardProps.ts | 18 + crates/engine/src/analysis/ability_graph.rs | 3 + crates/engine/src/game/ability_rw.rs | 6 + crates/engine/src/game/ability_scan.rs | 17 + crates/engine/src/game/ability_utils.rs | 2 + crates/engine/src/game/coverage.rs | 2 + .../engine/src/game/effects/flip_permanent.rs | 263 ++++++ crates/engine/src/game/effects/mod.rs | 6 + .../engine/src/game/effects/turn_face_down.rs | 16 +- crates/engine/src/game/flip.rs | 880 ++++++++++++++++++ crates/engine/src/game/log.rs | 9 + crates/engine/src/game/mod.rs | 2 + crates/engine/src/game/printed_cards.rs | 128 +++ crates/engine/src/game/public_state.rs | 4 + crates/engine/src/game/targeting.rs | 2 + crates/engine/src/game/transform.rs | 26 +- crates/engine/src/game/trigger_index.rs | 9 + crates/engine/src/game/trigger_matchers.rs | 3 + crates/engine/src/game/zones.rs | 16 + .../src/parser/oracle_effect/imperative.rs | 92 ++ crates/engine/src/parser/oracle_effect/mod.rs | 13 + .../src/parser/oracle_effect/sequence.rs | 1 + crates/engine/src/parser/oracle_ir/ast.rs | 8 + crates/engine/src/parser/oracle_ir/doc.rs | 1 + crates/engine/src/types/ability.rs | 30 + crates/engine/src/types/events.rs | 9 + .../tests/integration/kamigawa_flip_cards.rs | 576 ++++++++++++ crates/engine/tests/integration/main.rs | 1 + crates/manabrew-compat/src/lib.rs | 6 +- .../phase-ai/src/policies/effect_classify.rs | 3 + .../src/policies/redundancy_avoidance.rs | 3 + 33 files changed, 2183 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/game/effects/flip_permanent.rs create mode 100644 crates/engine/src/game/flip.rs create mode 100644 crates/engine/tests/integration/kamigawa_flip_cards.rs diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 0af332e48c..b40d7e39cd 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1023,6 +1023,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 @@ -1037,6 +1047,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; @@ -2263,6 +2289,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 797a166e61..c7242e76b4 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 } @@ -4880,6 +4882,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 9c57fa1a42..f472ad3f15 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, @@ -5249,6 +5256,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 { .. } @@ -5630,6 +5639,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 { .. } @@ -5900,6 +5912,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 { .. } @@ -6166,6 +6181,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 05f04e4ed6..01629d25cd 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -4828,6 +4828,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 d3582e4e93..93243f6522 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 22527f2fbf..329f04d596 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; @@ -3833,6 +3834,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), @@ -6139,6 +6143,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..8c8f232153 --- /dev/null +++ b/crates/engine/src/game/flip.rs @@ -0,0 +1,880 @@ +//! 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!(obj.keywords.contains(&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!( + !obj.keywords.contains(&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!( + obj.keywords.contains(&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!( + !copy.keywords.contains(&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 f9b715a48d..b0e1e5c4e6 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 3e22256c03..61bafe579a 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 a549d8e302..fcc3ab7385 100644 --- a/crates/engine/src/game/targeting.rs +++ b/crates/engine/src/game/targeting.rs @@ -1448,6 +1448,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..23ddd412fd 100644 --- a/crates/engine/src/game/trigger_index.rs +++ b/crates/engine/src/game/trigger_index.rs @@ -611,6 +611,12 @@ pub(crate) fn keys_from_event(event: &GameEvent, state: &GameState) -> Keys { | GameEvent::TurnedFaceDown { .. } => { push(TriggerEventKey::FaceOrTransform); } + // CR 710.4 + CR 701.27b: flipping is its own game action, distinct from + // transforming and from turning face up/down, and no printed card + // triggers on a permanent flipping. 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 +937,9 @@ fn keys_from_effect_kind(kind: EffectKind, push: &mut impl FnMut(TriggerEventKey // action; its own `EffectResolved` dispatches no trigger key. | EffectKind::BecomeSaddled | EffectKind::Transform + // CR 710.4: the flip fires no trigger; `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 64023bac19..89c69d186d 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -993,6 +993,9 @@ fn count_matching_trigger_event_subjects( | GameEvent::Saddled { .. } | GameEvent::ReplacementApplied { .. } | GameEvent::Transformed { .. } + // CR 710.4: no printed card triggers on a permanent flipping, so this + // event carries no per-object trigger subject. + | 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 990a8a5ed9..e9c6dc8edc 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -286,6 +286,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 aff31f627a..6802cd2754 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -5002,6 +5002,70 @@ 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 201.5: "this" / "this " self-deictics, 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, @@ -5204,6 +5268,23 @@ 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 201.5: a self-deictic always names the object the ability + // is on. + 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" @@ -5612,6 +5693,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 }, @@ -9965,6 +10048,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 ac11f43359..691ff5c2d6 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -17510,6 +17510,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 @@ -17637,6 +17639,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, @@ -19797,6 +19804,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, .. } @@ -23641,6 +23650,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 @@ -23900,6 +23911,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 c780fa22e5..5425fd4799 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -5968,6 +5968,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 2a1cc15ba2..0f82dd4c9f 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -1205,6 +1205,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 319ebecc63..9277494123 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -11147,6 +11147,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 { @@ -14062,6 +14083,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, .. } @@ -14678,6 +14702,7 @@ impl Effect { | Effect::SetRoomDoorLock { .. } | Effect::ExtraTurn { .. } | Effect::Transform { .. } + | Effect::FlipPermanent { .. } | Effect::RevealTop { .. } | Effect::Reveal { .. } | Effect::TargetOnly { .. } @@ -14931,6 +14956,7 @@ impl Effect { | Effect::SetRoomDoorLock { .. } | Effect::ExtraTurn { .. } | Effect::Transform { .. } + | Effect::FlipPermanent { .. } | Effect::RevealTop { .. } | Effect::Reveal { .. } | Effect::TargetOnly { .. } @@ -15143,6 +15169,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", @@ -15524,6 +15551,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, @@ -15644,6 +15673,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..17049d529f --- /dev/null +++ b/crates/engine/tests/integration/kamigawa_flip_cards.rs @@ -0,0 +1,576 @@ +//! 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}; +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); + + 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 _ = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + + 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 36388c0d84..09a93b9cf8 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -595,6 +595,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 594710543b..68b8e59390 100644 --- a/crates/manabrew-compat/src/lib.rs +++ b/crates/manabrew-compat/src/lib.rs @@ -2147,7 +2147,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 17fd23af68..4ac00f2c82 100644 --- a/crates/phase-ai/src/policies/redundancy_avoidance.rs +++ b/crates/phase-ai/src/policies/redundancy_avoidance.rs @@ -459,6 +459,9 @@ fn redundancy_delta( | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } + // CR 710.4: flipping is one-way and idempotent by rule, so there is no + // redundancy signal to score — same no-op classification as Transform. + | Effect::FlipPermanent { .. } | Effect::SearchLibrary { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealHand { .. } From 3746a2d694dcf950a8e40e3410651b6c1b04eb1a Mon Sep 17 00:00:00 2001 From: real-venus Date: Thu, 23 Jul 2026 17:18:48 -0700 Subject: [PATCH 2/3] test(engine): route flip test keyword queries through the keyword authority Main added scripts/check-engine-authorities.sh, which rejects raw obj.keywords.contains(&kw) in favor of game::keywords::has_keyword. Route the four flip.rs test assertions through the authority helper. Also merges current main (resolves the src-tauri Cargo.lock version stamp); no engine behavior change. --- crates/engine/src/game/flip.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/flip.rs b/crates/engine/src/game/flip.rs index 8c8f232153..d56c6ac11f 100644 --- a/crates/engine/src/game/flip.rs +++ b/crates/engine/src/game/flip.rs @@ -477,7 +477,10 @@ mod tests { 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!(obj.keywords.contains(&Keyword::DoubleStrike)); + assert!(crate::game::keywords::has_keyword( + obj, + &Keyword::DoubleStrike + )); assert!(state.layers_dirty.is_dirty()); assert_eq!(events, vec![GameEvent::Flipped { object_id: id }]); } @@ -739,7 +742,7 @@ mod tests { ); assert_eq!((obj.base_power, obj.base_toughness), (Some(1), Some(1))); assert!( - !obj.keywords.contains(&Keyword::Flying), + !crate::game::keywords::has_keyword(obj, &Keyword::Flying), "CR 613.1: a granted keyword must not be baked into the normal half" ); assert!( @@ -747,7 +750,7 @@ mod tests { "CR 613.1: a granted keyword must not be baked into the printed baseline" ); assert!( - obj.keywords.contains(&Keyword::Bushido(1)), + crate::game::keywords::has_keyword(obj, &Keyword::Bushido(1)), "reach guard: the printed keyword set really was restored" ); } @@ -869,7 +872,7 @@ mod tests { "CR 710.1b: the Legendary supertype lives only on the alternative half" ); assert!( - !copy.keywords.contains(&Keyword::DoubleStrike), + !crate::game::keywords::has_keyword(copy, &Keyword::DoubleStrike), "CR 707.2: the alternative half's text box is not a copiable value" ); assert_eq!( From db3c44ab8193a1e086c846d27def6f3b4851cbde Mon Sep 17 00:00:00 2001 From: real-venus Date: Thu, 23 Jul 2026 20:10:42 -0700 Subject: [PATCH 3/3] review(flip): address CodeRabbit feedback on CR 710 flip cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reach-guards: the two negative flip tests now capture the resolver's events and assert GameEvent::EffectResolved{FlipPermanent} fired, so an upstream short-circuit can't make the CR 710.2 / 710.4 no-op assertions pass for the wrong reason. - Self-deictic citation: "this creature"/"this permanent" is CR 700.7, not CR 201.5 (which governs references by name); 201.5 kept only for the "~" name form. - Trigger annotations: dropped a misapplied CR 710.4 (one-way process, says nothing about trigger generation) from the three trigger-dispatch sites, replaced with the design fact (no printed flip card triggers on flipping) and the CR 701.27b distinct-game-action analogy. - AI: FlipPermanent is one-way (CR 710.4), so targeting an already-flipped permanent is a deterministic no-op — redundancy_avoidance now penalizes it instead of letting the AI waste an activation (mirrors tap_redundancy). --- crates/engine/src/game/trigger_index.rs | 20 ++-- crates/engine/src/game/trigger_matchers.rs | 6 +- .../src/parser/oracle_effect/imperative.rs | 11 ++- .../tests/integration/kamigawa_flip_cards.rs | 33 ++++++- .../src/policies/redundancy_avoidance.rs | 97 ++++++++++++++++++- 5 files changed, 149 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/game/trigger_index.rs b/crates/engine/src/game/trigger_index.rs index 23ddd412fd..ee949a4190 100644 --- a/crates/engine/src/game/trigger_index.rs +++ b/crates/engine/src/game/trigger_index.rs @@ -611,11 +611,14 @@ pub(crate) fn keys_from_event(event: &GameEvent, state: &GameState) -> Keys { | GameEvent::TurnedFaceDown { .. } => { push(TriggerEventKey::FaceOrTransform); } - // CR 710.4 + CR 701.27b: flipping is its own game action, distinct from - // transforming and from turning face up/down, and no printed card - // triggers on a permanent flipping. Deliberately dispatches NO trigger - // key — folding it into `FaceOrTransform` would consult transform/ - // face-change triggers for an event none of them can match. + // 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), @@ -937,8 +940,11 @@ fn keys_from_effect_kind(kind: EffectKind, push: &mut impl FnMut(TriggerEventKey // action; its own `EffectResolved` dispatches no trigger key. | EffectKind::BecomeSaddled | EffectKind::Transform - // CR 710.4: the flip fires no trigger; `GameEvent::Flipped` is a log/ - // display notification and dispatches no key either. + // 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 diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index ebba46350f..a7d2d46513 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -993,8 +993,10 @@ fn count_matching_trigger_event_subjects( | GameEvent::Saddled { .. } | GameEvent::ReplacementApplied { .. } | GameEvent::Transformed { .. } - // CR 710.4: no printed card triggers on a permanent flipping, so this - // event carries no per-object trigger subject. + // 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 { .. } diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index f778ad9a8a..a701011a62 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -5091,8 +5091,9 @@ fn parse_flip_permanent_subject(input: &str) -> OracleResult<'_, FlipSubjectAnap alt((tag("itself"), tag("it"))), ), value(FlipSubjectAnaphor::SelfDeictic, tag("~")), - // CR 201.5: "this" / "this " self-deictics, nested on - // the shared "this" prefix. + // CR 700.7: "this" / "this " self-deictics ("this + // [something]" refers to that particular object), nested on the shared + // "this" prefix. value( FlipSubjectAnaphor::SelfDeictic, preceded( @@ -5323,8 +5324,10 @@ pub(super) fn parse_utility_imperative_ast( if let Some(anaphor) = nom_parse_lower(lower, parse_flip_permanent_subject) { return Some(UtilityImperativeAst::FlipPermanent { target: match anaphor { - // CR 201.5: a self-deictic always names the object the ability - // is on. + // 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 diff --git a/crates/engine/tests/integration/kamigawa_flip_cards.rs b/crates/engine/tests/integration/kamigawa_flip_cards.rs index 17049d529f..58f1b14c44 100644 --- a/crates/engine/tests/integration/kamigawa_flip_cards.rs +++ b/crates/engine/tests/integration/kamigawa_flip_cards.rs @@ -17,7 +17,7 @@ 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}; +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}; @@ -324,6 +324,21 @@ fn flipping_an_already_flipped_permanent_is_a_no_op() { 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!( @@ -390,7 +405,21 @@ fn a_flip_card_off_the_battlefield_cannot_flip() { engine::game::zones::move_to_zone(runner.state_mut(), bushi, Zone::Graveyard, &mut events); let execute = captured_trigger_body(&runner, bushi, "Bushi Tenderfoot"); - let _ = resolve_trigger_body(&mut runner, bushi, "Bushi Tenderfoot", &execute); + 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); diff --git a/crates/phase-ai/src/policies/redundancy_avoidance.rs b/crates/phase-ai/src/policies/redundancy_avoidance.rs index 2d56507124..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 ----- // @@ -459,9 +470,6 @@ fn redundancy_delta( | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } - // CR 710.4: flipping is one-way and idempotent by rule, so there is no - // redundancy signal to score — same no-op classification as Transform. - | Effect::FlipPermanent { .. } | Effect::SearchLibrary { .. } | Effect::SearchOutsideGame { .. } | Effect::RevealHand { .. } @@ -781,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( @@ -1308,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".