diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 86e914df22..7cf1d43cff 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -4226,6 +4226,45 @@ pub(crate) fn priority_actions_with_probe( } } + // CR 116.2b + CR 702.37e / CR 702.168d / CR 701.40b: turning a face-down + // permanent face up is a special action available ANY time its controller + // has priority — no timing gate, no stack, either player's turn. + // + // Offered from the same admission authority the Priority preflight uses + // (`morph::turn_face_up_offer`), so the engine's progress gate and the list + // the client renders cannot disagree. They did until now: the reducer + // accepted `GameAction::TurnFaceUp` and the preflight counted it as + // progress, but this list never emitted it, so no client could ever send it + // and the whole morph / megamorph / disguise / manifest / cloak class was + // unturnable in play (#6732, #4381). + // + // Split second does NOT stop it: CR 702.61b prohibits casting spells and + // activating abilities, and a special action is neither (CR 116.1). + for &object_id in &state.battlefield { + let Some(object) = state.objects.get(&object_id) else { + continue; + }; + if !object.face_down || object.controller != player { + continue; + } + match crate::game::morph::turn_face_up_offer(state, player, object_id) { + Some(crate::game::morph::TurnFaceUpOffer::Ready) => { + actions.push(candidate( + GameAction::TurnFaceUp { object_id, x: 0 }, + TacticalClass::Ability, + Some(player), + )); + } + // CR 107.3d: the player chooses X immediately before paying, so a + // flat action list has no value to offer. Announcing one here would + // be the engine choosing for them. Stated rather than silently + // dropped: Warbreak Trumpeter, Bane of the Living and Aurelia's + // Vindicator stay unofferable until the client can announce an X for + // a special action. + Some(crate::game::morph::TurnFaceUpOffer::RequiresChosenX) | None => {} + } + } + // CR 702.143a-b: Foretell is a priority-time special action from hand // during the player's own turn. It does not use the stack; the runtime // handler pays {2}, exiles the card, marks it foretold, and grants the diff --git a/crates/engine/src/ai_support/payment_continuation.rs b/crates/engine/src/ai_support/payment_continuation.rs index e6bdc40ee4..2ee8c7b685 100644 --- a/crates/engine/src/ai_support/payment_continuation.rs +++ b/crates/engine/src/ai_support/payment_continuation.rs @@ -469,6 +469,7 @@ fn classify_deferred_life_root( ManaAbilityResume::Priority | ManaAbilityResume::CompanionToHand { .. } | ManaAbilityResume::EndContinuousEffect { .. } + | ManaAbilityResume::TurnFaceUp { .. } | ManaAbilityResume::UnlessPayment { .. } | ManaAbilityResume::EffectPayCost { .. } => PaymentContinuationState::NotAffiliated, }, @@ -559,9 +560,13 @@ fn record_root_from_resume( ManaAbilityResume::FinalizePendingManaPayment { player } => { Some(root_from_global(state, *player)?) } + // Special actions and effect payments are not a CAST's payment root: + // they carry their own typed continuation and never resume into a + // pending cast (CR 116.1 — a special action does not use the stack). ManaAbilityResume::Priority | ManaAbilityResume::CompanionToHand { .. } | ManaAbilityResume::EndContinuousEffect { .. } + | ManaAbilityResume::TurnFaceUp { .. } | ManaAbilityResume::UnlessPayment { .. } | ManaAbilityResume::EffectPayCost { .. } => None, }; diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 612ae90237..1554474c27 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -11702,91 +11702,11 @@ fn apply_action( { return Err(EngineError::NotYourPriority); } - let p = *player; - let announced_x = x; - // CR 116.2b + CR 702.37e / CR 702.168d / CR 701.40b + CR 106.6: turning - // a face-down permanent face up is a special action whose morph/disguise/ - // manifest cost must be paid *before* the flip. `turn_face_up_prepare` - // validates the action and derives that cost; payment routes through - // `PaymentContext::SpecialAction(TurnFaceUp)` so spend-restricted mana - // ("only to turn permanents face up", Overgrown Zealot / Tin Street - // Gossip) is eligible here while other-context mana is rejected. Mirrors - // the `UnlockDoor` special-action handler. - let cost = super::morph::turn_face_up_prepare(state, object_id, p)?; - let mut cost = casting::apply_special_action_cost_reduction( - state, - p, - crate::types::mana::SpecialAction::TurnFaceUp, - cost, - ); - - // CR 107.3d: "If a cost associated with a special action, such as a suspend - // cost or a morph cost, has an {X} ... in it, the value of X is chosen by the - // player taking the special action immediately before they pay that cost." - // The announcement happens HERE — inside the action, with no priority window - // between choosing X and paying it, exactly as the rule describes. - // - // Warbreak Trumpeter (Morph {X}{X}{R}), Bane of the Living (Morph {X}{B}{B}) - // and Aurelia's Vindicator (Disguise {X}{3}{W}) are the live faces. - let has_x = casting_costs::cost_has_x(&cost); - if has_x { - // CR 118.3: a player can't announce an X they cannot pay for. The cap is - // computed with `object_id: None` deliberately — this is a SPECIAL ACTION, - // not a cast, so cast-time cost modifiers and floors must not apply (the - // special-action reduction was already applied above). - let max_x = casting_costs::max_x_value(state, p, &cost, None); - if announced_x > max_x { - return Err(EngineError::InvalidAction(format!( - "X={announced_x} exceeds the maximum payable value of {max_x} for this \ - turn-face-up cost" - ))); - } - // CR 107.1b + CR 601.2f: each `{X}` shard becomes `announced_x` generic, so - // Warbreak Trumpeter's `{X}{X}{R}` costs 2X + {R}. Without this the X shards - // reach mana payment unresolved and are dropped — the permanent flips for - // its non-X remainder alone. - cost.concretize_x(announced_x); - } else if announced_x != 0 { - // A cost with no {X} admits no choice: CR 107.3d only grants one "if a cost - // ... has an {X} ... in it". Reject rather than silently ignore, so a client - // bug cannot masquerade as a legal flip. - return Err(EngineError::InvalidAction( - "This permanent's turn-face-up cost has no {X}, so X must be 0".to_string(), - )); - } - casting::pay_special_action_mana_cost( - state, - p, - Some(object_id), - &cost, - crate::types::mana::SpecialAction::TurnFaceUp, - &mut events, - )?; - - // CR 702.37f (morph) / CR 702.168e (disguise): "If a permanent's morph cost - // includes X, other abilities of that permanent may also refer to X. The value - // of X in those abilities is equal to the value of X chosen as the morph special - // action was taken." Publish the announced X on the source-keyed carrier BEFORE - // the flip emits `TurnedFaceUp`, so `triggers::build_triggered_ability` — the - // single trigger-instantiation authority — stamps it onto the turn-face-up - // trigger's `chosen_x`. - // - // The stamp must land at INSTANTIATION, not resolution: Aurelia's Vindicator - // spends its X in `multi_target.max` ("exile up to X other target creatures"), - // which is consumed during target selection, before the trigger ever resolves. - // - // Published only when the cost actually HAS an {X} (CR 107.3d grants a choice - // only then). A no-X flip leaves the carrier untouched rather than clobbering it - // with `Some((.., 0))`: an unrelated activated ability of ANOTHER object may be - // on the stack with its own announced X in flight, and that value must survive. - // The carrier is cleared at the start of the next `resolve_top`, so this - // publication cannot outlive the trigger it is for. - if has_x { - state.announced_source_x = Some((object_id, announced_x)); - } - - super::morph::turn_face_up(state, p, object_id, &mut events)?; - WaitingFor::Priority { player: p } + // CR 116.2b + CR 702.37e / CR 702.168d / CR 701.40b: the whole action — + // legality, the CR 106.6 spend-restricted payment, CR 107.3d's X + // announcement and the flip — belongs to one authority, shared with the + // CR 616.1 resume that finishes a payment whose mana source paused. + super::morph::handle_turn_face_up(state, *player, object_id, x, &mut events)? } ( WaitingFor::TriggerTargetSelection { @@ -20179,7 +20099,12 @@ mod stage2_injector_tests { // Resolve All consent adds its frozen-authority protocol above this producer: // `:12912 ⇒ :13113`. It does not create a CR 603.5 prompt, and the pinned // line remains the same `OptionalEffectChoice` construction. - "game/engine.rs:13210".to_string(), + // Folding the turn-face-up special action into `morph::handle_turn_face_up` + // removed 80 lines from the reducer above this producer: + // `:13210 ⇒ :13130`. It creates no CR 603.5 prompt either — a special + // action does not use the stack (CR 116.1) — and the pinned line is again + // the same `OptionalEffectChoice` construction, moved wholesale. + "game/engine.rs:13130".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index d66e068f4e..92fe535cb9 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -3278,6 +3278,22 @@ pub(crate) fn resume_mana_ability_root( ManaAbilityResume::CompanionToHand { player, cost } => { super::companion::resume_companion_to_hand_payment(state, player, cost, events) } + // CR 116.2b + CR 605.3b: NOT compiler-forced either — the `resume =>` + // catch-all below would route a paused turn-face-up payment into + // `resume_waiting_for`, which `unreachable!()`s for this family. + ManaAbilityResume::TurnFaceUp { + player, + object_id, + cost, + announced_x, + } => super::morph::resume_turn_face_up_payment( + state, + player, + object_id, + cost, + announced_x, + events, + ), // CR 116.2c + CR 605.3b: NOT compiler-forced — the `resume =>` catch-all // below would silently route a paused pay-to-end payment into // `resume_waiting_for`, which `unreachable!()`s for this family. @@ -3393,6 +3409,14 @@ pub(crate) fn finish_mana_root_after_deferred_life_payment( ManaAbilityResume::CompanionToHand { player, .. } => Ok( super::companion::finish_paid_companion_to_hand(state, player, events), ), + ManaAbilityResume::TurnFaceUp { + player, + object_id, + announced_x, + .. + // CR 702.37e + CR 107.3d: payment has completed, so commit the + // turn-face-up action with its already-announced X value. + } => super::morph::finish_paid_turn_face_up(state, player, object_id, announced_x, events), ManaAbilityResume::EndContinuousEffect { player, group, .. } => Ok( super::end_continuous_effect::finish_paid_end_continuous_effect( state, player, group, events, @@ -4766,9 +4790,11 @@ pub(crate) fn resume_waiting_for( | ManaAbilityResume::PhyrexianCastPayment { .. } | ManaAbilityResume::FinalizePendingManaPayment { .. } | ManaAbilityResume::CompanionToHand { .. } - // CR 116.2c: like `CompanionToHand`, the pay-to-end special action is - // resumed by `resume_mana_ability_root`'s named arm, never here. - | ManaAbilityResume::EndContinuousEffect { .. } => { + // CR 116.2c + CR 116.2b: like `CompanionToHand`, the pay-to-end and + // turn-face-up special actions are resumed by + // `resume_mana_ability_root`'s named arms, never here. + | ManaAbilityResume::EndContinuousEffect { .. } + | ManaAbilityResume::TurnFaceUp { .. } => { unreachable!("effect-cost resume is handled by resume_mana_ability_root") } } diff --git a/crates/engine/src/game/morph.rs b/crates/engine/src/game/morph.rs index f81e77a819..39e43d18a3 100644 --- a/crates/engine/src/game/morph.rs +++ b/crates/engine/src/game/morph.rs @@ -3,7 +3,7 @@ use crate::types::ability::{ }; use crate::types::card_type::{CardType, CoreType}; use crate::types::events::GameEvent; -use crate::types::game_state::GameState; +use crate::types::game_state::{GameState, WaitingFor}; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::keywords::Keyword; use crate::types::mana::ManaCost; @@ -42,39 +42,72 @@ pub(in crate::game) enum PriorityTurnFaceUpCandidate { RequiresChosenX, } +/// Whether `player` may take the turn-face-up special action on `object_id` +/// right now, and in which shape. +/// +/// The single admission authority for the action, shared by the Priority +/// preflight (which only asks "is any action available?") and by the +/// legal-action enumeration the client renders. Splitting those two would let +/// the engine's own progress gate and its offer list disagree about what a +/// player can do — which is exactly how the action came to be accepted by the +/// reducer and offered nowhere (#6732). +/// +/// `turn_face_up_prepare` remains the legality and cost authority; this adds +/// the action-specific cost reduction and the affordability probe the reducer +/// would apply. +pub(crate) fn turn_face_up_offer( + state: &GameState, + player: PlayerId, + object_id: ObjectId, +) -> Option { + let cost = turn_face_up_prepare(state, object_id, player).ok()?; + let cost = super::casting::apply_special_action_cost_reduction( + state, + player, + crate::types::mana::SpecialAction::TurnFaceUp, + cost, + ); + super::casting::can_pay_special_action_mana_cost_after_auto_tap( + state, + player, + Some(object_id), + &cost, + crate::types::mana::SpecialAction::TurnFaceUp, + ) + .then_some(())?; + Some(if super::casting_costs::cost_has_x(&cost) { + TurnFaceUpOffer::RequiresChosenX + } else { + TurnFaceUpOffer::Ready + }) +} + +/// The shape of an available turn-face-up action. `RequiresChosenX` carries no +/// value on purpose: CR 107.3d says the player chooses X immediately before +/// paying, so neither the progress gate nor the offer list may guess one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TurnFaceUpOffer { + Ready, + RequiresChosenX, +} + /// Enumerates the current holder's face-up special-action outcomes in -/// battlefield order. `turn_face_up_prepare` remains the single legality and -/// cost authority; priority applies the reducer's action-specific cost -/// adjustment and affordability check before offering a primer. +/// battlefield order, from the shared admission authority above. pub(in crate::game) fn priority_turn_face_up_candidates( state: &GameState, principal: &PriorityPrincipal, ) -> Vec { + let player = principal.semantic_holder(); state .battlefield .iter() .copied() .filter_map(|object_id| { - let player = principal.semantic_holder(); - let cost = turn_face_up_prepare(state, object_id, player).ok()?; - let cost = super::casting::apply_special_action_cost_reduction( - state, - player, - crate::types::mana::SpecialAction::TurnFaceUp, - cost, - ); - super::casting::can_pay_special_action_mana_cost_after_auto_tap( - state, - player, - Some(object_id), - &cost, - crate::types::mana::SpecialAction::TurnFaceUp, - ) - .then_some(())?; - Some(if super::casting_costs::cost_has_x(&cost) { - PriorityTurnFaceUpCandidate::RequiresChosenX - } else { - PriorityTurnFaceUpCandidate::Ready(PriorityTurnFaceUpAnnouncement::new(object_id)) + Some(match turn_face_up_offer(state, player, object_id)? { + TurnFaceUpOffer::RequiresChosenX => PriorityTurnFaceUpCandidate::RequiresChosenX, + TurnFaceUpOffer::Ready => PriorityTurnFaceUpCandidate::Ready( + PriorityTurnFaceUpAnnouncement::new(object_id), + ), }) }) .collect() @@ -438,6 +471,189 @@ pub(crate) fn turn_face_up_prepare( }) } +/// CR 116.2b + CR 702.37e / CR 702.168d / CR 701.40b: take the turn-face-up +/// special action — derive its cost, announce CR 107.3d's `{X}`, pay, and flip. +/// +/// Single authority for the action, shared by the `GameAction::TurnFaceUp` +/// reducer arm and by the CR 616.1 resume below, so the two cannot drift about +/// what was paid or what X was announced. +/// +/// A paused payment is a real outcome, not an error: +/// `pay_special_action_mana_cost_with_resume` returns `Paused` when an +/// auto-tapped mana source's own cost surfaces a replacement choice +/// (CR 605.3b + CR 616.1). The permanent stays face down and the prompt is +/// returned; [`resume_turn_face_up_payment`] finishes the action once the choice +/// is answered. The compatibility wrapper `pay_special_action_mana_cost` turns +/// that state into an error, which is why this action may not use it. +pub(crate) fn handle_turn_face_up( + state: &mut GameState, + player: PlayerId, + object_id: ObjectId, + announced_x: u32, + events: &mut Vec, +) -> Result { + // CR 116.2b: `turn_face_up_prepare` is the single legality and cost + // authority, shared with the Priority offer enumeration. + let cost = turn_face_up_prepare(state, object_id, player)?; + let mut cost = super::casting::apply_special_action_cost_reduction( + state, + player, + crate::types::mana::SpecialAction::TurnFaceUp, + cost, + ); + + // CR 107.3d: "If a cost associated with a special action, such as a suspend + // cost or a morph cost, has an {X} … in it, the value of X is chosen by the + // player taking the special action immediately before they pay that cost." + // The announcement happens HERE — inside the action, with no priority window + // between choosing X and paying it, exactly as the rule describes. + // + // Warbreak Trumpeter (Morph {X}{X}{R}), Bane of the Living (Morph {X}{B}{B}) + // and Aurelia's Vindicator (Disguise {X}{3}{W}) are the live faces. + let has_x = super::casting_costs::cost_has_x(&cost); + if has_x { + // CR 118.3: a player can't announce an X they cannot pay for. The cap is + // computed with `object_id: None` deliberately — this is a SPECIAL + // ACTION, not a cast, so cast-time cost modifiers and floors must not + // apply (the special-action reduction was already applied above). + let max_x = super::casting_costs::max_x_value(state, player, &cost, None); + if announced_x > max_x { + return Err(EngineError::InvalidAction(format!( + "X={announced_x} exceeds the maximum payable value of {max_x} for this \ + turn-face-up cost" + ))); + } + // CR 107.1b + CR 601.2f: each `{X}` shard becomes `announced_x` generic, + // so Warbreak Trumpeter's `{X}{X}{R}` costs 2X + {R}. Without this the X + // shards reach mana payment unresolved and are dropped — the permanent + // flips for its non-X remainder alone. + cost.concretize_x(announced_x); + } else if announced_x != 0 { + // A cost with no {X} admits no choice: CR 107.3d only grants one "if a + // cost … has an {X} … in it". Reject rather than silently ignore, so a + // client bug cannot masquerade as a legal flip. + return Err(EngineError::InvalidAction( + "This permanent's turn-face-up cost has no {X}, so X must be 0".to_string(), + )); + } + + let announced_x = has_x.then_some(announced_x); + match pay_turn_face_up_cost(state, player, object_id, &cost, announced_x, events)? { + super::casting::SpecialActionManaPayment::Paid => { + finish_paid_turn_face_up(state, player, object_id, announced_x, events) + } + // The permanent is still face down and nothing has been committed: the + // mana source's replacement choice owns the window now. + super::casting::SpecialActionManaPayment::Paused => Ok(state.waiting_for.clone()), + } +} + +/// CR 116.2b + CR 106.6: pay the already-derived turn-face-up cost through +/// `PaymentContext::SpecialAction(TurnFaceUp)`, so spend-restricted mana ("only +/// to turn permanents face up" — Overgrown Zealot, Tin Street Gossip) is +/// eligible here while other-context mana is rejected. +fn pay_turn_face_up_cost( + state: &mut GameState, + player: PlayerId, + object_id: ObjectId, + cost: &ManaCost, + announced_x: Option, + events: &mut Vec, +) -> Result { + let resume = crate::types::game_state::ManaAbilityResume::TurnFaceUp { + player, + object_id, + cost: cost.clone(), + announced_x, + }; + super::casting::pay_special_action_mana_cost_with_resume( + state, + player, + Some(object_id), + cost, + crate::types::mana::SpecialAction::TurnFaceUp, + Some(&resume), + events, + ) +} + +/// CR 605.3b + CR 616.1: finish a turn-face-up whose mana-source cost paused. +/// `cost` was locked at initiation, so this must not re-derive it against a +/// board that changed while the replacement choice was pending. +pub(crate) fn resume_turn_face_up_payment( + state: &mut GameState, + player: PlayerId, + object_id: ObjectId, + cost: ManaCost, + announced_x: Option, + events: &mut Vec, +) -> Result { + // The locked cost already had CR 107.3d's `{X}` concretized, so its shards + // no longer say X. Keep the pre-concretization fact separately: X=0 is a + // real announcement and must still bind to the resulting trigger. + match pay_turn_face_up_cost(state, player, object_id, &cost, announced_x, events)? { + super::casting::SpecialActionManaPayment::Paid => { + finish_paid_turn_face_up(state, player, object_id, announced_x, events) + } + super::casting::SpecialActionManaPayment::Paused => Ok(state.waiting_for.clone()), + } +} + +/// CR 702.37e: commit the flip, and only once the whole payment has succeeded. +pub(crate) fn finish_paid_turn_face_up( + state: &mut GameState, + player: PlayerId, + object_id: ObjectId, + announced_x: Option, + events: &mut Vec, +) -> Result { + // CR 702.37f (morph) / CR 702.168e (disguise): "If a permanent's morph cost + // includes X, other abilities of that permanent may also refer to X. The + // value of X in those abilities is equal to the value of X chosen as the + // morph special action was taken." Publish the announced X on the + // source-keyed carrier BEFORE the flip emits `TurnedFaceUp`, so + // `triggers::build_triggered_ability` — the single trigger-instantiation + // authority — stamps it onto the turn-face-up trigger's `chosen_x`. + // + // The stamp must land at INSTANTIATION, not resolution: Aurelia's Vindicator + // spends its X in `multi_target.max` ("exile up to X other target + // creatures"), which is consumed during target selection, before the trigger + // ever resolves. + // + // Published only when the cost actually HAS an {X} (CR 107.3d grants a + // choice only then). A no-X flip leaves the carrier untouched rather than + // clobbering it with `Some((.., 0))`: an unrelated activated ability of + // ANOTHER object may be on the stack with its own announced X in flight, and + // that value must survive. The carrier is cleared at the start of the next + // `resolve_top`, so this publication cannot outlive the trigger it is for. + if let Some(announced_x) = announced_x { + state.announced_source_x = Some((object_id, announced_x)); + } + + // CR 614.1e + CR 708.11 + CR 616.1: the "As ~ is turned face up" replacement + // pipeline inside `turn_face_up` resolves its execute through + // `resolve_ability_chain`, which can install an interactive `WaitingFor` + // (measured: two materially-ordered counter-addition replacements turn the + // execute's `AddCounter` into a CR 616.1 ordering prompt). Seed the settled + // outcome FIRST, then hand back whatever the pipeline left: `Priority` when + // nothing interfered, the live choice when something did. Seeding also + // covers the paused-payment resume route, where `state.waiting_for` still + // holds the mana source's just-answered replacement prompt — returning THAT + // would resurrect a dead prompt. An `Err` from `turn_face_up` cannot leak + // the seed: every dispatch into this completion runs under the action + // boundary, which restores the whole pre-action state on `Err`. + state.waiting_for = WaitingFor::Priority { player }; + turn_face_up(state, player, object_id, events)?; + // CR 603.2 + CR 603.3b: when the pipeline paused, the reducer's settled + // epilogue will not run for this action, so the `TurnedFaceUp` observer + // triggers in `events` would be lost (measured: the "when turned face up" + // draw never reached the stack). Park them into `deferred_triggers` — the + // established authority for exactly this shape — for the drain once the + // interposed choice settles. A no-op when the flip completed undisturbed. + crate::game::triggers::park_observer_triggers_if_paused(state, events, 0); + Ok(state.waiting_for.clone()) +} + /// CR 702.37e: Turning a face-down permanent face up ends the morph effect and /// the permanent "regains its normal characteristics". (CR 702.37c is the /// CASTING half — it is what turns the card face down in the first place.) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index e7f975f92b..fd965e80d2 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -6962,6 +6962,24 @@ pub enum ManaAbilityResume { player: PlayerId, cost: ManaCost, }, + /// CR 116.2b + CR 702.37e / CR 702.168d / CR 701.40b + CR 605.3b + CR 616.1: + /// A turn-face-up special action whose auto-tapped mana source paused on a + /// replacement-aware cost move. `cost` is the final cost locked at action + /// initiation — after the special-action reduction and after CR 107.3d's + /// `{X}` was concretized — so resumption cannot re-derive it against a board + /// that changed while the replacement choice was pending. `announced_x` + /// travels with it because CR 702.37f / CR 702.168e publish that value to the + /// permanent's own turn-face-up trigger, which fires after the payment + /// completes. + TurnFaceUp { + player: PlayerId, + object_id: ObjectId, + cost: ManaCost, + /// The announced value for a cost that contained X. `None` means the + /// cost had no X; `Some(0)` remains a real CR 107.3d announcement and + /// binds that zero to the resulting turn-face-up trigger. + announced_x: Option, + }, /// CR 116.2c + CR 605.3b + CR 616.1: A pay-to-end special action whose /// auto-tapped mana source paused on a replacement-aware cost move. `cost` /// is the permission's printed cost, latched at action initiation; @@ -10401,6 +10419,47 @@ fn migrate_legacy_tap_effect(effect: &mut serde_json::Value) { effect.insert("state".to_string(), serde_json::json!({ "type": state })); } +/// Maps the pre-Option turn-face-up continuation wire shape onto the typed +/// `Option` representation. A legacy `cost_had_x: false` plus zero was +/// never an X announcement, while `true` plus zero was. +fn migrate_legacy_turn_face_up_resume(value: &mut serde_json::Value) -> Result<(), String> { + match value { + serde_json::Value::Array(values) => { + for value in values { + migrate_legacy_turn_face_up_resume(value)?; + } + } + serde_json::Value::Object(object) => { + if let Some(resume) = object.get_mut("TurnFaceUp") { + let fields = resume + .as_object_mut() + .ok_or_else(|| "legacy TurnFaceUp resume must be an object".to_string())?; + if let Some(cost_had_x) = fields.remove("cost_had_x") { + let cost_had_x = cost_had_x.as_bool().ok_or_else(|| { + "legacy TurnFaceUp resume cost_had_x must be a boolean".to_string() + })?; + let announced_x = fields + .remove("announced_x") + .unwrap_or_else(|| serde_json::Value::from(0)); + fields.insert( + "announced_x".to_string(), + if cost_had_x { + announced_x + } else { + serde_json::Value::Null + }, + ); + } + } + for value in object.values_mut() { + migrate_legacy_turn_face_up_resume(value)?; + } + } + _ => {} + } + Ok(()) +} + fn delayed_trigger_install_command( entry: &serde_json::Value, ) -> Option<&serde_json::Map> { @@ -17637,6 +17696,7 @@ impl GameStateDecode { ), )?; } + migrate_legacy_turn_face_up_resume(&mut value)?; let mut state = Self::materialize_prepared(value)?; normalize_delayed_trigger_allocators(&mut state)?; validate_trigger_firing_coherence(&state)?; @@ -17667,6 +17727,7 @@ impl GameStateDecode { )?; migrate_legacy_mana_target_roles(value)?; migrate_legacy_tap_effects(value); + migrate_legacy_turn_face_up_resume(value)?; Ok(()) } @@ -25048,6 +25109,34 @@ mod tests { ); } + #[test] + fn legacy_turn_face_up_resume_preserves_none_and_zero() { + let mut legacy = serde_json::json!({ + "no_x": { + "TurnFaceUp": { + "cost_had_x": false, + "announced_x": 0 + } + }, + "zero_x": { + "TurnFaceUp": { + "cost_had_x": true, + "announced_x": 0 + } + } + }); + + migrate_legacy_turn_face_up_resume(&mut legacy).expect("legacy shape migrates"); + + assert_eq!( + legacy["no_x"]["TurnFaceUp"]["announced_x"], + serde_json::Value::Null + ); + assert_eq!(legacy["zero_x"]["TurnFaceUp"]["announced_x"], 0); + assert!(legacy["no_x"]["TurnFaceUp"].get("cost_had_x").is_none()); + assert!(legacy["zero_x"]["TurnFaceUp"].get("cost_had_x").is_none()); + } + #[test] fn persisted_legacy_untap_effect_restores_as_set_tap_state() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs b/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs new file mode 100644 index 0000000000..becb1f274d --- /dev/null +++ b/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs @@ -0,0 +1,610 @@ +//! CR 116.2b + CR 702.37e: the turn-face-up special action must be OFFERED, not +//! merely accepted (#6732, #4381). +//! +//! The engine implemented the action and its Priority preflight counted it as +//! progress, but `ai_support::candidates::priority_actions_with_probe` — the +//! list the client renders — never emitted it. Nothing could send an action the +//! engine never advertised, so the whole morph / megamorph / disguise / manifest +//! / cloak class was unturnable in play. #7342 wired the client's dispatch and +//! closed both reports; its test supplies the action to itself, so it proves the +//! client's half and cannot observe the engine's. +//! +//! Reported from a real game state: the controller had priority, a face-down +//! Coral Trickster (morph `{U}`) and thirty untapped Islands, and `legalActions` +//! held six casts, four land plays and a pass. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::{ + AbilityCost, AbilityDefinition, AbilityKind, Effect, ManaContribution, ManaProduction, + ReplacementDefinition, TargetFilter, +}; +use engine::types::actions::GameAction; +use engine::types::game_state::{ + ManaAbilityResume, PendingCostMoveResume, StackEntryKind, WaitingFor, +}; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::Keyword; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::replacements::ReplacementEvent; +use engine::types::zones::{EtbTapState, Zone}; + +fn morph_cost() -> ManaCost { + ManaCost::Cost { + shards: vec![ManaCostShard::Blue], + generic: 0, + } +} + +fn pool(kinds: &[ManaType]) -> Vec { + kinds + .iter() + .copied() + .map(|kind| ManaUnit::new(kind, ObjectId(0), false, vec![])) + .collect() +} + +/// A face-down permanent with a morph cost, put onto the battlefield through the +/// engine's own face-down play so `back_face` carries the real card. +fn face_down_morph_board(controller: PlayerId, mana: &[ManaType]) -> (GameRunner, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let id = scenario + .add_creature_to_hand(controller, "Coral Trickster", 2, 1) + .with_keyword(Keyword::Morph(morph_cost())) + .id(); + if !mana.is_empty() { + scenario.with_mana_pool(controller, pool(mana)); + } + let mut runner = scenario.build(); + + let mut events = Vec::new(); + engine::game::morph::play_face_down(runner.state_mut(), controller, id, &mut events) + .expect("the card is played face down"); + assert!( + runner.state().objects[&id].face_down, + "setup: the permanent is face down" + ); + + (runner, id) +} + +fn offered_turn_face_ups(runner: &GameRunner) -> Vec { + engine::ai_support::legal_actions(runner.state()) + .into_iter() + .filter_map(|action| match action { + GameAction::TurnFaceUp { object_id, .. } => Some(object_id), + _ => None, + }) + .collect() +} + +/// The defect, end to end: the action is offered, and taking the offer flips the +/// permanent to its real face. +#[test] +fn a_face_down_morph_permanent_is_offered_and_flips() { + let (mut runner, id) = face_down_morph_board(P0, &[ManaType::Blue]); + + assert_eq!( + offered_turn_face_ups(&runner), + vec![id], + "CR 116.2b: the controller has priority and can pay {{U}}, so the special \ + action must be on the list the client renders" + ); + + runner + .act(GameAction::TurnFaceUp { + object_id: id, + x: 0, + }) + .expect("the offered action must be accepted"); + + let obj = &runner.state().objects[&id]; + assert!(!obj.face_down, "CR 702.37e: the permanent is now face up"); + assert_eq!(obj.name, "Coral Trickster", "and shows its real face"); +} + +/// The affordability half of the same authority: an unpayable cost is not +/// offered, so the list never advertises an action the reducer would reject. +/// +/// This is also what keeps the row above from passing for the wrong reason — if +/// the offer were unconditional, this row would fail. +#[test] +fn an_unpayable_turn_face_up_is_not_offered() { + let (runner, _) = face_down_morph_board(P0, &[]); + + assert!( + offered_turn_face_ups(&runner).is_empty(), + "with no mana the morph cost cannot be paid, so nothing is offered" + ); +} + +/// CR 702.37e: "you may turn a face-down permanent YOU CONTROL face up". The +/// offer is per-holder, and `legal_actions` speaks for the priority holder. +#[test] +fn an_opponents_face_down_permanent_is_not_offered() { + let (mut runner, _) = face_down_morph_board(P1, &[ManaType::Blue]); + runner.state_mut().priority_player = P0; + runner.state_mut().waiting_for = WaitingFor::Priority { player: P0 }; + + assert!( + offered_turn_face_ups(&runner).is_empty(), + "a face-down permanent an opponent controls is not this player's to turn up" + ); +} + +// ── The paused mana-source payment (#4538's blocker) ──────────────────────── + +fn redirect_exile_to_graveyard() -> ReplacementDefinition { + ReplacementDefinition::new(ReplacementEvent::Moved) + .destination_zone(Zone::Exile) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + destination: Zone::Graveyard, + origin: None, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + )) +} + +const WARBREAK_TRUMPETER: &str = "Morph {X}{X}{R} (You may cast this card face down as a 2/2 \\ + creature for {3}. Turn it face up any time for its morph \\ + cost.)\nWhen this creature is turned face up, create X 1/1 red \\ + Goblin creature tokens."; + +/// CR 605.3b + CR 616.1: the offer is only honest if the action can FINISH. +/// +/// A mana source whose own cost exiles it, plus two exile→graveyard +/// replacements, forces a CR 616.1 ordering choice while the turn-face-up cost +/// is being auto-tapped. `casting.rs` deliberately reports such a source as +/// payable, so the offer above is right to include it — but the compatibility +/// wrapper `pay_special_action_mana_cost` converts the resulting `Paused` into +/// an error. That is why #4538 was asked to build a typed resume before the +/// action could be offered. +/// +/// The permanent must still be face down while the choice is open, and must flip +/// exactly once the choice is answered. +#[test] +fn a_paused_mana_source_resumes_the_locked_turn_face_up() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let id = scenario + .add_creature_to_hand_from_oracle(P0, "Warbreak Trumpeter", 1, 1, WARBREAK_TRUMPETER) + .id(); + let source = scenario + .add_creature(P0, "Self-Exiling Mana Source", 1, 1) + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![ManaColor::Red], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Tap, + AbilityCost::Exile { + count: 1, + zone: None, + filter: Some(TargetFilter::SelfRef), + }, + ], + }), + ) + .id(); + for name in ["First Pause Replacement", "Second Pause Replacement"] { + scenario + .add_creature(P0, name, 0, 0) + .as_enchantment() + .with_replacement_definition(redirect_exile_to_graveyard()); + } + let mut runner = scenario.build(); + + let mut events = Vec::new(); + engine::game::morph::play_face_down(runner.state_mut(), P0, id, &mut events) + .expect("the card is played face down"); + + let paused = runner + .act(GameAction::TurnFaceUp { + object_id: id, + x: 0, + }) + .expect("the source's own cost pauses the payment rather than failing it"); + assert!( + matches!(paused.waiting_for, WaitingFor::ReplacementChoice { .. }), + "the mana source's exile replacement owns the window, got {:?}", + paused.waiting_for + ); + assert!( + matches!( + runner.state().pending_cost_move_resume.as_ref(), + Some(PendingCostMoveResume::ManaAbilityPayment { pending, .. }) + if matches!( + &pending.resume, + ManaAbilityResume::TurnFaceUp { + player, + object_id, + announced_x: Some(0), + .. + } if *player == P0 && *object_id == id + ) + ), + "the typed continuation names the action to finish" + ); + assert!( + runner.state().objects[&id].face_down, + "nothing is committed while the payment is open" + ); + + runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the replacement choice is answered"); + + let obj = &runner.state().objects[&id]; + assert!( + !obj.face_down, + "CR 605.3b: the locked payment completed and the flip committed" + ); + assert_eq!(obj.name, "Warbreak Trumpeter"); + let bound_x = runner + .state() + .stack + .iter() + .find_map(|entry| match &entry.kind { + StackEntryKind::TriggeredAbility { + source_id, ability, .. + } if *source_id == id => Some(ability.chosen_x), + _ => None, + }) + .expect("the turned-face-up trigger must be on the stack after the paused payment"); + assert_eq!( + bound_x, + Some(0), + "a paused X=0 payment must preserve the real zero announcement, not collapse it to no X" + ); + assert_eq!( + runner.state().objects[&source].zone, + Zone::Graveyard, + "the mana source's own cost still resolved through its replacement" + ); +} + +// ── The interactive turn-up replacement (review round 2) ──────────────────── + +/// CR 614.1e + CR 708.11: "As ~ is turned face up, put five +1/+1 counters on +/// it" — the parsed Hooded Hydra class. Its `AddCounter`, modified by two +/// materially-ordered replacements, raises a CR 616.1 ordering prompt DURING +/// the flip. The completion must hand that prompt back, not overwrite it with +/// `Priority`. +const COUNTER_MORPH: &str = "Morph {1} (You may cast this card face down as a \ + 2/2 creature for {3}. Turn it face up any time for \ + its morph cost.)\nAs this creature is turned face \ + up, put five +1/+1 counters on it.\nWhen this \ + creature is turned face up, draw a card."; + +fn add_counter_modifier( + scenario: &mut GameScenario, + name: &str, + modification: engine::types::ability::QuantityModification, +) { + scenario + .add_creature(P0, name, 0, 4) + .with_replacement_definition( + ReplacementDefinition::new(ReplacementEvent::AddCounter) + .quantity_modification(modification) + .counter_match(engine::types::counter::CounterMatch::OfType( + engine::types::counter::CounterType::Plus1Plus1, + )), + ); +} + +/// The review-round-2 blocker, fresh route: the flip commits, and the ordering +/// choice the execute raised stays live instead of being clobbered to +/// `Priority` (which stranded a live `pending_replacement` record and silently +/// dropped both modifiers). +/// +/// Review round 3: one run per selectable order. CR 616.1 lets the affected +/// object's controller pick which applicable replacement applies first, so the +/// pick must be proven to determine the result — (5+1)*2 = 12 when the plus +/// applies first, 5*2+1 = 11 when the doubling applies first — and the prompt +/// must hold EXACTLY the two live modifiers, with no stale or extra candidates. +#[test] +fn an_interactive_turn_up_replacement_keeps_its_choice_live() { + use engine::types::ability::QuantityModification; + use engine::types::counter::CounterType; + + let run_with_first_choice = |first_choice: &str| { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let id = scenario + .add_creature_to_hand_from_oracle(P0, "Counter Morph", 2, 2, COUNTER_MORPH) + .id(); + add_counter_modifier( + &mut scenario, + "Plus One Modifier", + QuantityModification::Plus { value: 1 }, + ); + add_counter_modifier( + &mut scenario, + "Times Two Modifier", + QuantityModification::Times { factor: 2 }, + ); + scenario.with_mana_pool(P0, pool(&[ManaType::Colorless])); + let mut runner = scenario.build(); + + let mut events = Vec::new(); + engine::game::morph::play_face_down(runner.state_mut(), P0, id, &mut events) + .expect("the card is played face down"); + + let paused = runner + .act(GameAction::TurnFaceUp { + object_id: id, + x: 0, + }) + .expect("the special action succeeds up to the replacement's own choice"); + let WaitingFor::ReplacementChoice { candidates, .. } = &paused.waiting_for else { + panic!( + "CR 616.1: the ordering choice the turn-up replacement raised must stay \ + live, got {:?}", + paused.waiting_for + ); + }; + let names: Vec<&str> = candidates + .iter() + .map(|candidate| candidate.source_name.as_str()) + .collect(); + assert_eq!( + names.len(), + 2, + "exactly the two live modifiers may compete — no stale or extra \ + candidates, got {names:?}" + ); + assert!( + names.contains(&"Plus One Modifier") && names.contains(&"Times Two Modifier"), + "the candidate set is the two counter modifiers, got {names:?}" + ); + assert!( + runner.state().pending_replacement.is_some(), + "the parked counter addition is still waiting for its order" + ); + let obj = &runner.state().objects[&id]; + assert!( + !obj.face_down, + "CR 708.11: the turn-up itself is not prevented by the pending choice" + ); + assert_eq!( + obj.counters.get(&CounterType::Plus1Plus1), + None, + "no counters land before the order is chosen" + ); + + let index = names + .iter() + .position(|name| *name == first_choice) + .expect("the requested first choice is among the candidates"); + runner + .act(GameAction::ChooseReplacement { index }) + .expect("the ordering choice is answered"); + + assert!( + runner.state().pending_replacement.is_none(), + "the counter addition settled; no ghost record remains" + ); + assert!( + runner + .state() + .stack + .iter() + .any(|entry| matches!(&entry.kind, StackEntryKind::TriggeredAbility { source_id, .. } if *source_id == id)), + "CR 603.2: the 'when turned face up' trigger still reaches the stack \ + after the interposed choice" + ); + runner.state().objects[&id] + .counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0) + }; + + assert_eq!( + run_with_first_choice("Plus One Modifier"), + 12, + "CR 616.1: the plus applies first, then the doubling — (5+1)*2" + ); + assert_eq!( + run_with_first_choice("Times Two Modifier"), + 11, + "CR 616.1: the doubling applies first, then the plus — 5*2+1" + ); +} + +const WARBREAK_WITH_COUNTERS: &str = "Morph {X}{X}{R} (You may cast this card face down as a \ + 2/2 creature for {3}. Turn it face up any time for its \ + morph cost.)\nAs this creature is turned face up, put \ + five +1/+1 counters on it.\nWhen this creature is \ + turned face up, create X 1/1 red Goblin creature tokens."; + +/// The same blocker through the paused-payment route: the mana source's own +/// replacement choice settles FIRST, the resumed completion flips, and the +/// turn-up replacement's ordering prompt must then surface — not the stale +/// exile prompt, and not a premature `Priority` that strands the parked +/// counters. The X=0 announcement must still bind across BOTH pauses. +/// +/// Review round 3: one run per selectable order (CR 616.1) — the resumed +/// prompt must hold EXACTLY the two counter modifiers (the settled exile +/// prompt must not resurface among them), and the chosen order must determine +/// the count: plus first (5+1)*2 = 12, doubling first 5*2+1 = 11. +#[test] +fn a_resumed_payment_still_surfaces_the_turn_up_replacement_choice() { + use engine::types::ability::QuantityModification; + use engine::types::counter::CounterType; + + let run_with_first_choice = |first_choice: &str| { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let id = scenario + .add_creature_to_hand_from_oracle( + P0, + "Warbreak Trumpeter", + 1, + 1, + WARBREAK_WITH_COUNTERS, + ) + .id(); + let source = scenario + .add_creature(P0, "Self-Exiling Mana Source", 1, 1) + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![ManaColor::Red], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Tap, + AbilityCost::Exile { + count: 1, + zone: None, + filter: Some(TargetFilter::SelfRef), + }, + ], + }), + ) + .id(); + for name in ["First Pause Replacement", "Second Pause Replacement"] { + scenario + .add_creature(P0, name, 0, 0) + .as_enchantment() + .with_replacement_definition(redirect_exile_to_graveyard()); + } + add_counter_modifier( + &mut scenario, + "Plus One Modifier", + QuantityModification::Plus { value: 1 }, + ); + add_counter_modifier( + &mut scenario, + "Times Two Modifier", + QuantityModification::Times { factor: 2 }, + ); + let mut runner = scenario.build(); + + let mut events = Vec::new(); + engine::game::morph::play_face_down(runner.state_mut(), P0, id, &mut events) + .expect("the card is played face down"); + + let paused = runner + .act(GameAction::TurnFaceUp { + object_id: id, + x: 0, + }) + .expect("the source's own cost pauses the payment rather than failing it"); + assert!( + matches!(paused.waiting_for, WaitingFor::ReplacementChoice { .. }), + "first pause: the mana source's exile replacement owns the window" + ); + + let resumed = runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the exile choice is answered and the payment resumes"); + let WaitingFor::ReplacementChoice { candidates, .. } = &resumed.waiting_for else { + panic!( + "second pause: the resumed completion must hand back the turn-up \ + replacement's ordering choice, got {:?}", + resumed.waiting_for + ); + }; + let names: Vec<&str> = candidates + .iter() + .map(|candidate| candidate.source_name.as_str()) + .collect(); + assert_eq!( + names.len(), + 2, + "exactly the two counter modifiers — the settled exile prompt must \ + not resurface among the candidates, got {names:?}" + ); + assert!( + names.contains(&"Plus One Modifier") && names.contains(&"Times Two Modifier"), + "the live prompt is the COUNTER ordering choice, got {names:?}" + ); + assert!( + !runner.state().objects[&id].face_down, + "CR 708.11: the flip itself committed before the counter order is chosen" + ); + + let index = names + .iter() + .position(|name| *name == first_choice) + .expect("the requested first choice is among the candidates"); + runner + .act(GameAction::ChooseReplacement { index }) + .expect("the ordering choice is answered"); + + let bound_x = runner + .state() + .stack + .iter() + .find_map(|entry| match &entry.kind { + StackEntryKind::TriggeredAbility { + source_id, ability, .. + } if *source_id == id => Some(ability.chosen_x), + _ => None, + }) + .expect("the turned-face-up trigger must still reach the stack across both pauses"); + assert_eq!( + bound_x, + Some(0), + "the X=0 announcement survives the payment pause AND the turn-up choice" + ); + assert_eq!( + runner.state().objects[&source].zone, + Zone::Graveyard, + "the mana source's own cost still resolved through its replacement" + ); + + runner.state().objects[&id] + .counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0) + }; + + assert_eq!( + run_with_first_choice("Plus One Modifier"), + 12, + "CR 616.1 across both pauses: the plus applies first — (5+1)*2" + ); + assert_eq!( + run_with_first_choice("Times Two Modifier"), + 11, + "CR 616.1 across both pauses: the doubling applies first — 5*2+1" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index e447993d99..70b5762307 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -720,6 +720,7 @@ mod issue_6643_party_dude_opponents_attacked; mod issue_6677_wakandan_royal_guard; mod issue_6678_captain_america_shield_tap_defender; mod issue_6691_enters_under_their_control; +mod issue_6732_offer_turn_face_up; mod issue_6769_serras_emissary_reanimation; mod issue_680_shalai_and_hallar_forgotten_ancient; mod issue_680_shalai_upkeep_move;