feat: morph / megamorph / disguise face-down spell casting (CR 708.4) - #5171
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
matthewevans
left a comment
There was a problem hiding this comment.
Thanks for pushing face-down spell casting forward. I found two correctness blockers on this head.
-
Canceling a face-down cast during mana payment can leave the card blanked in its origin zone.
continue_cast_face_downapplies the face-down profile beforeprepare_spell_cast_with_variant_override/ payment, and only restores the real face if preparation itself fails. If the player reachesWaitingFor::ManaPaymentand usesCancelCast, the engine goes throughcancel_pending_cast; that path restores alternate spell faces/prototype, but notCastingVariant::FaceDown. The result is that the object can remain face-down/nameless/typeless/no-cost after a canceled cast instead of rolling back to the real card. Please add rollback for face-down casts and a manual-payment cancel regression. -
Face-down casting is only surfaced from hand. CR 702.37c and CR 702.168b say morph/disguise may be used from any zone from which the card could normally be cast, and CR 708.4 applies the face-down characteristics before stack placement. This PR’s offer/auto-route is gated on
obj.zone == Zone::Hand, while the general castability path already recognizes other legal cast zones such as command, graveyard permissions, exile permissions, and top-of-library permissions. A morph card castable from one of those zones should be able to choose the {3} face-down cast too. Please route face-down as a real casting variant over the existing castable-zone machinery instead of a hand-only special case, with targeted tests for at least one non-hand permission path.
Also holding approval until the required parse-diff sticky posts for this parser/engine head.
Parse changes introduced by this PR · 1 card(s), 1 signature(s) (baseline: main
|
49394e9 to
220beee
Compare
|
🤖 AI text below 🤖 Thanks for the review — both blockers addressed. Head is now Blocker 1 — canceling a face-down cast leaves the card blanked. Added a Blocker 2 — face-down offered only from hand. The offer/auto-route gate changed from Documented, not fixed (separate axis, no printed card currently hits it): Verification on this head (
The parse-diff sticky re-runs against the fresh |
matthewevans
left a comment
There was a problem hiding this comment.
Thanks for fixing the two earlier blockers. I found one remaining correctness blocker on this head before I can clear the requested-changes state.
The face-down path is still unreachable for a Morph/Megamorph/Disguise card whose printed mana cost is NoCost. handle_cast_spell_with_payment_mode rejects hand cards with ManaCost::NoCost before the face-down offer block runs, and the offer block also gates on prepare_spell_cast(state, player, object_id).is_ok() against the unblanked face-up object. That means a no-mana-cost morph card cannot reach the {3} face-down alternative-cost path.
Rules evidence: CR 118.6a says an alternative cost may be paid even when the object has an unpayable mana cost, and CR 702.37c says morph pays {3} rather than the mana cost. The fix should let the face-down alternative bypass the face-up no-mana-cost rejection while still requiring an actual Morph/Megamorph/Disguise keyword and legal castable zone. Please add a regression with a ManaCost::NoCost morph/disguise card that can be cast face down for {3}.
|
One more blocker from the same current-head review: the new direct-manifest parser accepts Please either lower this continuation into a real follow-up attach effect targeting the manifested permanent, or keep this clause unsupported until that model exists. Otherwise the card is reported as implemented while the attach instruction is silently ignored. |
|
One more current-head blocker while the face-down routing is being fixed: the auto face-down path can still skip a legal free normal cast.
Please derive the normal option from the same prepared normal-cast cost authority, or explicitly include the next-spell-without-paying path before auto-routing to |
220beee to
d7ce264
Compare
|
🤖 AI text below 🤖 Thanks — the two in-scope blockers are fixed on the current head ( Blocker — a NoCost morph/megamorph/disguise card couldn't reach the face-down cast — fixed. A card with a printed Blocker — auto face-down route could rob a legal free normal cast — fixed. Third comment (direct-manifest "…and attach Lightform to it" continuation dropped) — out of scope for this PR. That targets Verification on
|
matthewevans
left a comment
There was a problem hiding this comment.
Thanks for fixing the NoCost morph path and the free-normal-cast auto-route issue. I also rechecked the direct-manifest/Lightform point against this head and agree that is now out of this PR's diff; it should be handled separately against main.
One correctness blocker remains before I can clear the requested-changes state: the face-down castability gate still asks prepare_spell_cast(state, player, object_id).is_ok() on the unblanked face-up object before offering/auto-routing the face-down cast (crates/engine/src/game/casting.rs, around the FaceDown offer block). The in-code strict-failure comment already describes the issue: name- or mana-value-based cast prohibitions are evaluated against the printed card, so the engine can suppress a legal face-down cast.
This is reachable with existing engine machinery, not only hypothetical future text. The runtime already enforces StaticMode::CantBeCast with TargetFilter::HasChosenName; cant_cast_filter_matches compares the chosen name against spell_obj.name. So a Meddling Mage/Nevermore-style permanent naming a morph/disguise card makes prepare_spell_cast() reject the printed face-up object, and the face-down offer never appears. Under CR 708.2 / CR 708.4 the face-down spell has blank characteristics/no name for this check, so that legal {3} face-down cast must still be available even though the normal face-up cast is prohibited.
Please move the face-down offer's legality/prohibition check onto the same blanked face-down characteristics used for the affordability path, or otherwise split the castable-zone/timing permission from characteristics-dependent CantBeCast evaluation so the latter runs against the face-down profile. Add a regression with a chosen-name or mana-value casting prohibition that blocks the normal face-up cast but still offers/casts the Morph/Megamorph/Disguise spell face down for {3}.
…R 708.4)
Cast a Morph/Megamorph/Disguise card from hand face down as a blank 2/2
creature spell for a fixed {3} (CR 702.37c / 702.168a), placed on the stack
(CR 708.4) and resolving to a face-down permanent (CR 702.37c). Closes the
phase-rs#5155 D10 deferral: Tin Street Gossip's FaceDownSpell restricted-mana leaf
now goes live with no type change, unlocking the full 228-card
morph/megamorph/disguise class.
Cast path (opt-in hand alt-cast idiom, mirroring Evoke/Blitz):
- New CastingVariant::FaceDown + AlternativeCastKeyword::FaceDown; the offer
in handle_cast_spell surfaces AlternativeCastChoice{FaceDown} for any card
with Morph/Megamorph/Disguise when the fixed {3} is affordable.
- continue_cast_face_down turns the object face down (blank 2/2, real card
stashed in back_face) via the shared apply_face_down_entry_profile BEFORE
the stack (CR 708.4). Visibility redaction, resolution to a face-down
permanent, CR 708.9 counter-reveal, and turn-face-up (CR 702.37e) are all
inherited from existing machinery.
- The {3} is injected as a synthetic constant into the alt-cost .or() chain,
never read off the blanked object (whose mana cost is now NoCost).
Payment (closes D10):
- SpellMeta.is_face_down is derived from the blanked object
(face_down && back_face.is_some()) as a single authority in build_spell_meta,
with no caller threading. A foretold card (face_down, back_face = None) stays
face up, preserving the existing foretell guard test. The OnlyForFaceDownSpell
mana gate now goes live.
Disguise's face-down 2/2 carries ward {2} (CR 702.168a) via the cloaked_2_2
profile; morph/megamorph use vanilla_2_2. The unmorph/turn-up cost is read
downstream from the hidden card's keyword, so CastingVariant::FaceDown is
parameterless.
Tests (engine_tests.rs): 8 discriminating tests including the D10 closure
(Tin Street Gossip's OnlyForAny([FaceDownSpell, TurnFaceUp]) restricted mana
funds the {3}), opponent redaction of the stack spell, disguise ward {2}, and
the CR 708.9 counter-reveal.
Assisted-by: ClaudeCode:claude-opus-4.8
Slice E of the morph/disguise face-down casting PR. Test-only — the AI already
handles the new `AlternativeCastChoice { keyword: FaceDown }` action via the
keyword-agnostic candidate-enumeration arm in `engine::ai_support::candidate_actions`
(candidates.rs), consumed by the phase-ai search through `build_decision_context`.
No production AI code changes; no DeckFeature/policy needed (measure-first showed
the action is already enumerated + scored).
Two discriminating tests:
- (i) both the face-down (Alternative) and printed (Normal) casts are enumerated
candidates — the face-down cast is not declined by omission.
- (ii) the eval credits the resulting permanent AS a 2/2 (CR 708.2): for a strong
real 5/5, the normal cast must OUTSCORE the face-down cast. Distinct + ordered
finite non-sentinel scores prove the eval distinguishes the two boards.
cargo ai-gate is null by construction for this change (the gate suite has no morph
creatures, so no candidate set changes); a direct discriminating scoring test is
the meaningful evidence.
Assisted-by: ClaudeCode:claude-opus-4.8
…e-cost modal
Slice F of the morph/disguise face-down casting PR. The engine already emits
`WaitingFor::AlternativeCastChoice { keyword: FaceDown }` for a Morph/Megamorph/
Disguise card in hand; this adds the display layer only — no game logic, the
engine owns the {3} cost and the blank 2/2 body (CR 708.4 / 702.37c / 702.168a).
- adapter/types.ts: add `{ type: "FaceDown" }` to the AlternativeCastChoice
keyword union (keeps the boundary-guardrails Rust↔TS lockstep test green).
- AlternativeCostModal.tsx: add the `case "FaceDown"` display-copy arm — mirrors
the existing per-keyword pattern; the `assertNever` fallback enforces
exhaustiveness. Costs render from the engine-provided fields.
- i18n: `alternativeCost.faceDown*` keys added to all 7 locales
(de/en/es/fr/it/pl/pt) — satisfies the resources.test.ts parity gate.
Also folds the now-stale D10 comments in
tests/restricted_mana_face_down_and_face_up.rs: the OnlyForFaceDownSpell gate was
HONEST-DEFERRED (no production path set is_face_down=true); it went LIVE with the
face-down cast (build_spell_meta derives is_face_down = face_down &&
back_face.is_some()). Comment-only — assertions unchanged; end-to-end coverage is
engine_tests::tin_street_gossip_restricted_mana_funds_face_down_cast.
Assisted-by: ClaudeCode:claude-opus-4.8
…-down casting exists Rebase-adaptation onto phase-rs#5165 ("keep Tin Street coverage honest"), which classified `ManaSpendRestriction::FaceDownSpell` as not coverage-supported with the explicit rationale that it "must stay red until face-down spell casting exists." This PR's engine core makes it exist: `build_spell_meta` now sets `is_face_down = true` at a `PaymentContext::Spell` site for a morph/megamorph/disguise face-down cast, so the `FaceDownSpell` leaf is production-live. - `is_coverage_supported(FaceDownSpell)`: false -> true. The sole production caller is the parser's `Effect::Mana` absorption seam (`oracle_effect/sequence.rs`) — a parse-time coverage-classification decision, NOT runtime payability (that stays on the separate `ManaRestriction::allows_spell`). So this is a coverage-honesty change with zero game-behavior change beyond the card becoming supported. - Tin Street Gossip's `Any([FaceDownSpell, TurnPermanentFaceUp])` now absorbs at the seam (coverage red -> green) instead of leaving `Effect::Unimplemented`. - Non-vacuity: `FaceDownSpell` was the last hardcoded-false leaf. The classifier test is restructured (not just flipped) — renamed to `is_coverage_supported_all_leaves_supported_empty_any_is_false`; every leaf is now asserted supported, with `Any(vec![]) == false` kept as the remaining non-vacuous false exemplar. Reverting `FaceDownSpell => true` flips its positive assertion and Tin Street's mixed-`Any` assertion back to red. - `tin_street_gossip_..._stays_coverage_red` -> `..._is_coverage_supported` (asserts the absorbed restriction + no `Unimplemented` residual). - Refreshes the now-stale "dead today / red until face-down casting exists" docs in ability.rs, mana.rs, sequence.rs, and the restricted-mana test. CR 708.4 / CR 702.37c / CR 106.6. Completes the phase-rs#5155 D10 closure at the parser coverage layer (the engine core closed it at the runtime-gate layer). Assisted-by: ClaudeCode:claude-opus-4.8
…c/702.168b)
CR 702.37c / CR 702.168b: "You can use a morph/disguise ability to cast a
card from any zone from which you could normally cast it." The {3}
face-down offer was gated on obj.zone == Zone::Hand, so a morph card
castable from the graveyard/exile/command/top-of-library was never
offered the face-down cast. Route the offer through the general
castable-zone authority (prepare_spell_cast(..).is_ok()) instead of the
hand-only special case; the other cast-offer blocks (Awaken/Impending/
Prototype) stay hand-gated because only morph/disguise carry the
cross-zone rule.
Assisted-by: ClaudeCode:claude-opus-4.8
continue_cast_face_down blanks the object to its face-down 2/2 (stashing the real card in back_face) BEFORE payment, but handle_cancel_cast only restored alternate spell faces / prototype / prepared-copy — not a face-down cast. A CancelCast from WaitingFor::ManaPayment (or any pre- finalize step) therefore left the card blanked, nameless, and no-cost in its origin zone. Add a CastingVariant::FaceDown branch that routes through the existing single-authority restore_face_down_cast_object (the same rollback used on the prep-failure error path), reversing the entire cast per CR 601.2i. Assisted-by: ClaudeCode:claude-opus-4.8
…ocker C)
A pending `WithoutPayingManaCost` next-spell modifier (Omniscience-style
one-shot) makes the FREE face-up normal cast of a morph/megamorph/disguise
card legal even when its printed cost is unpayable. `normal_cast_choice_cost_
and_affordability` only recognized the `Unlimited CastFromHandFree`
(Omniscience static) short-circuit, so a pending one-shot modifier was missed:
the normal path reported the unpayable printed cost, `normal_affordable` was
false, and the face-down offer auto-routed to the {3} face-down cast — robbing
the player of the legal free face-up cast.
Mirror the real prep authority (prepare_spell_cast_with_variant_override_inner
via `next_spell_without_paying`, CR 118.9a): when a `WithoutPayingManaCost`
modifier matches the spell, treat the normal path as `ManaCost::NoCost` and
affordable so the engine OFFERS free-normal vs {3}-face-down instead of
auto-routing.
CR 601.2f + CR 118.9a (verified against docs/MagicCompRules.txt).
Test `morph_free_normal_cast_not_robbed_by_face_down_autoroute` is
discriminating: disabling the short-circuit auto-routes face down
(`waiting_for` becomes Priority, not AlternativeCastChoice) → the offer
assertion's `other => panic!` fires (measured).
Assisted-by: ClaudeCode:claude-opus-4.8
…er A)
A card with no mana cost (`ManaCost::NoCost`) that carries an effective
morph/megamorph/disguise keyword is unpayable face up, but IS legally castable
FACE DOWN for the fixed {3} alternative cost (CR 118.6a: an alternative cost
may be applied to an unpayable cost). Three sites wrongly treated such a card
as uncastable:
- The `Zone::Hand` NoCost rejection in `handle_cast_spell_with_payment_mode`
returned an error before the face-down offer block could run.
- Its candidate/legal-action twin `can_cast_prepared_now_with_probe` returned
false, so the cast was never surfaced as a legal action even though dispatch
would have accepted it.
- A soundness trap in `normal_cast_choice_cost_and_affordability`:
`can_pay_cost_after_auto_tap` returns true for `NoCost` unconditionally, so
the offer reported the unpayable face-up cast as affordable and offered a
bogus free face-up cast instead of auto-routing to the {3} face-down. A
cost reduced to nothing is `{0}` (CR 601.2f), distinct from `NoCost`, so the
new `!matches!(normal_cost, NoCost)` guard never misfires on a
cost-reduced-to-zero card.
Both hand-gate exceptions and the legal-action twin are narrowed to the morph
class AND {3} affordability via a shared `object_has_effective_face_down_keyword`
helper (extracted from the inline array at the morph offer block) plus
`can_afford_face_down_cast`, so the unpayable-cost rejection still stands for
every non-morph NoCost card and for a morph without {3}.
CR 118.6 / 118.6a / 601.2f / 702.37c / 702.37b (megamorph) / 702.168b
(disguise), all verified against docs/MagicCompRules.txt.
Tests (all measured discriminating via temporary edit-revert):
- nocost_morph_auto_casts_face_down_for_three: remove E2 → rejected (.expect
panics); remove the normal_affordable guard → offers not auto-routes (obj
stays Hand).
- nocost_non_morph_creature_rejected_from_hand / nocost_morph_without_three_
mana_rejected: keyword + {3}-affordability gating (both stay Err).
- nocost_morph_offered_as_legal_action: remove E4 → can_cast_object_now false.
Assisted-by: ClaudeCode:claude-opus-4.8
…d profile (CR 708.4)
The morph/megamorph/disguise face-down cast castability gate evaluated cast
prohibitions against the un-blanked, printed object. A characteristics-dependent
prohibition (StaticMode::CantBeCast with a name- or mana-value-conditional filter —
e.g. Meddling Mage / Nevermore naming the card) rejected prepare_spell_cast on the
printed face, suppressing the legal {3} face-down cast at both the dispatch offer
gate and the can_cast_object_now feasibility path (dropping it from legal actions
and AI feasibility as well).
Per CR 708.2a a face-down spell is a nameless, mana-cost-less 2/2, and per CR 708.4
prohibitions apply to those face-down characteristics, not the face-up object's;
CR 601.3a lets a player ignore a qualities-conditional prohibition when a proposal
choice (casting face down) changes the qualities it reads.
New face_down_cast_is_permitted helper clones the state, blanks the object via the
existing face_down_cast_profile + apply_face_down_entry_profile infra (mirroring
can_afford_face_down_cast and the real-cast path continue_cast_face_down), then runs
prepare_spell_cast_with_variant_override(Some(FaceDown)).is_ok() against the blanked
profile. Wired into both the dispatch offer gate and the feasibility twin, so the
whole class of characteristics-dependent prohibitions (name, mana value, color,
subtype/type) is covered — blanking zeroes all of them.
Assisted-by: ClaudeCode:claude-opus-4.8
d7ce264 to
20e13d4
Compare
|
🤖 AI text below 🤖 Thanks — and good catch. The remaining blocker is fixed on the new head ( Face-down cast prohibitions are now evaluated against the blanked face-down profile, not the printed face — fixed. The castability gate previously ran Fix: a new I wired it into both call sites, which is where the root cause was broader than the offer path alone: besides the dispatch offer gate, the Regressions (
Each positive asserts both that the face-up normal cast is genuinely rejected (non-vacuity) and that the Verification on
One related gap is deliberately out of scope here and worth its own follow-up: a regular-cost morph whose face-up cast is merely unaffordable (not prohibited) while the |
matthewevans
left a comment
There was a problem hiding this comment.
Reviewed the current head against the prior blockers and the face-down casting rules boundary. The cancel rollback, non-hand cast zones, NoCost routing, free-normal-cast choice, and blanked-characteristics prohibition path are all covered at the engine level now. Exact-head CI is green, parse-diff is limited to the expected Tin Street Gossip restricted-mana signature change, and there are no unresolved review threads. Approving and enqueueing via the merge queue.
…ion (phase-rs#7771) * fix(engine): let a face-down creature spell match its own cost reduction CR 708.4 puts a face-down spell on the stack as a real spell, but the live cost seam projects the spell into a `SpellCastRecord` and `FilterProp::FaceDown` failed closed against that record — grouped with battlefield-only predicates. "Face-down creature spells you cast cost {N} less" therefore never matched anything. The fact was already in the record: `cast_variant`. The ledger writes the variant its caller announced, while the two live projections hardcoded `CastingVariant::Normal`. `live_spell_cast_record_for` now states the one variant the object itself evidences — `apply_face_down_entry_profile` has blanked it (CR 708.2), which `GameObject::spell_is_cast_face_down` reads. That predicate is phase-rs#5171's discriminator (`face_down && back_face.is_some()`, exact against foretell/hideaway and against printed DFC back faces); it moves to a named method so the restricted-mana payment seam and the filter projection cannot answer the same question differently. Playtest found a second half: the offer was judged against the printed {3}. `effective_face_down_cast_cost` runs the same modifier passes the real cast runs, so a reduction to {0} is castable with an empty pool, and the `AlternativeCastChoice` menu now carries the reduced cost instead of {3} while the payment takes {0} (the client renders that number verbatim). Class (measured against card-data.json, 3 cards): Kadena, Slinking Sorcerer; Dream Chisel; Obscuring Aether. Counter-proof: with `FilterProp::FaceDown` back to `false`, three of the seven regressions fail (`left: 0, right: 3`); with the offer-side modifier pass removed, `kadena_lets_a_face_down_creature_be_cast_with_an_empty_pool` fails alone. `a_face_up_creature_spell_is_not_reduced` and `off_color_mana_pays_the_generic_face_down_cost` stay green either way — they pin behaviour, they do not evidence this fix. Not covered: - A morph card whose PRINTED cost is unaffordable is still not OFFERED at all, so this reduction stays unreachable from the UI in that case. Own defect in candidate generation, filed as phase-rs#7770 with measurements. - `zone_change_record_matches_property` still fails closed on `FilterProp::FaceDown`; no card measured needs a face-down predicate against a zone-change snapshot. - The per-turn cast-limit filter is routed through the same authority for consistency; 0 cards carry `FilterProp::FaceDown` in a cast restriction, so that half changes no card's behaviour today. - The offer resolves modifiers with no casting-variant context, which is exact for this class (the object evidences the variant) but would not see a variant-keyed reduction; 5 cards use one, all Flashback, none reachable from a face-down offer. Fixes phase-rs#7769 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(engine): price the face-down offer with the elected cast authority Review find (matthewevans): `effective_face_down_cast_cost` projected through `apply_cost_modifiers_to_base`, which passes no casting variant and no permission index to `apply_all_cost_modifiers`, while the real face-down prepare passes `Some(CastingVariant::FaceDown)` and its elected permission. The projections could price different casts: with no variant the permission election infers Foretell first for a foretold exile card, while the explicit face-down cast routes through `PlayFromExile` — and only that grant carries `cast_cost_raise`; a `StaticCondition::CastingAsVariant` modifier was likewise invisible to the projection. `apply_cost_modifiers_to_base` keeps its signature (25 projection call sites stay variant-less by design) and delegates to a new `apply_cost_modifiers_to_base_for_variant`; the face-down projection now elects its permission via `selected_object_cast_permission_index` with the explicit `FaceDown` variant — the same authority the real prepare uses — and threads both through. The requested exile regression exposed a second defect on the same path: the exile-exit cleanup cleared `face_down` unconditionally (correct for the foretold/hideaway exile designation), stripping a spell CAST face down of its status on the way to the stack (CR 708.4) — which would in turn hide it from the face-down spell filter this PR fixes. The cleanup now keeps the flag exactly for the casting move (`to == Zone::Stack && spell_is_cast_face_down()`). Tests (both discriminating): - the_face_down_offer_from_exile_prices_the_play_from_exile_raise: the menu must show {3}+{2}={5} and charge exactly that; the variant-less projection shows {3}. - an_unpayable_exile_raise_withholds_the_face_down_offer: with 3 mana the {5} face-down cast is withheld and the legal face-up Foretell cast proceeds; the variant-less projection auto-routes and the cast dies in payment. Counter-proofs (abort-guarded probes, run separately): threading removed → exactly the two new tests fail, the seven prior stay green; exile-exit guard removed → exactly the pricing test fails. Class: no printed card carries a face-down-keyed `CastingAsVariant` modifier (5 cards use the condition, all Flashback); the raise/permission divergence is reachable through any `PlayFromExile` grant with `cast_cost_raise` (Lightstall Inquisitor) alongside a competing exile permission. Not covered: the other per-keyword offer projections (dash, blitz, spectacle, prowl, overload, cleave, mutate, awaken, impending, prototype, warp, emerge) still project variant-less through the unchanged wrapper — same latent divergence class, pre-existing there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hase-rs#7778) * fix(engine): let a face-down creature spell match its own cost reduction CR 708.4 puts a face-down spell on the stack as a real spell, but the live cost seam projects the spell into a `SpellCastRecord` and `FilterProp::FaceDown` failed closed against that record — grouped with battlefield-only predicates. "Face-down creature spells you cast cost {N} less" therefore never matched anything. The fact was already in the record: `cast_variant`. The ledger writes the variant its caller announced, while the two live projections hardcoded `CastingVariant::Normal`. `live_spell_cast_record_for` now states the one variant the object itself evidences — `apply_face_down_entry_profile` has blanked it (CR 708.2), which `GameObject::spell_is_cast_face_down` reads. That predicate is phase-rs#5171's discriminator (`face_down && back_face.is_some()`, exact against foretell/hideaway and against printed DFC back faces); it moves to a named method so the restricted-mana payment seam and the filter projection cannot answer the same question differently. Playtest found a second half: the offer was judged against the printed {3}. `effective_face_down_cast_cost` runs the same modifier passes the real cast runs, so a reduction to {0} is castable with an empty pool, and the `AlternativeCastChoice` menu now carries the reduced cost instead of {3} while the payment takes {0} (the client renders that number verbatim). Class (measured against card-data.json, 3 cards): Kadena, Slinking Sorcerer; Dream Chisel; Obscuring Aether. Counter-proof: with `FilterProp::FaceDown` back to `false`, three of the seven regressions fail (`left: 0, right: 3`); with the offer-side modifier pass removed, `kadena_lets_a_face_down_creature_be_cast_with_an_empty_pool` fails alone. `a_face_up_creature_spell_is_not_reduced` and `off_color_mana_pays_the_generic_face_down_cost` stay green either way — they pin behaviour, they do not evidence this fix. Not covered: - A morph card whose PRINTED cost is unaffordable is still not OFFERED at all, so this reduction stays unreachable from the UI in that case. Own defect in candidate generation, filed as phase-rs#7770 with measurements. - `zone_change_record_matches_property` still fails closed on `FilterProp::FaceDown`; no card measured needs a face-down predicate against a zone-change snapshot. - The per-turn cast-limit filter is routed through the same authority for consistency; 0 cards carry `FilterProp::FaceDown` in a cast restriction, so that half changes no card's behaviour today. - The offer resolves modifiers with no casting-variant context, which is exact for this class (the object evidences the variant) but would not see a variant-keyed reduction; 5 cards use one, all Flashback, none reachable from a face-down offer. Fixes phase-rs#7769 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(engine): price the face-down offer with the elected cast authority Review find (matthewevans): `effective_face_down_cast_cost` projected through `apply_cost_modifiers_to_base`, which passes no casting variant and no permission index to `apply_all_cost_modifiers`, while the real face-down prepare passes `Some(CastingVariant::FaceDown)` and its elected permission. The projections could price different casts: with no variant the permission election infers Foretell first for a foretold exile card, while the explicit face-down cast routes through `PlayFromExile` — and only that grant carries `cast_cost_raise`; a `StaticCondition::CastingAsVariant` modifier was likewise invisible to the projection. `apply_cost_modifiers_to_base` keeps its signature (25 projection call sites stay variant-less by design) and delegates to a new `apply_cost_modifiers_to_base_for_variant`; the face-down projection now elects its permission via `selected_object_cast_permission_index` with the explicit `FaceDown` variant — the same authority the real prepare uses — and threads both through. The requested exile regression exposed a second defect on the same path: the exile-exit cleanup cleared `face_down` unconditionally (correct for the foretold/hideaway exile designation), stripping a spell CAST face down of its status on the way to the stack (CR 708.4) — which would in turn hide it from the face-down spell filter this PR fixes. The cleanup now keeps the flag exactly for the casting move (`to == Zone::Stack && spell_is_cast_face_down()`). Tests (both discriminating): - the_face_down_offer_from_exile_prices_the_play_from_exile_raise: the menu must show {3}+{2}={5} and charge exactly that; the variant-less projection shows {3}. - an_unpayable_exile_raise_withholds_the_face_down_offer: with 3 mana the {5} face-down cast is withheld and the legal face-up Foretell cast proceeds; the variant-less projection auto-routes and the cast dies in payment. Counter-proofs (abort-guarded probes, run separately): threading removed → exactly the two new tests fail, the seven prior stay green; exile-exit guard removed → exactly the pricing test fails. Class: no printed card carries a face-down-keyed `CastingAsVariant` modifier (5 cards use the condition, all Flashback); the raise/permission divergence is reachable through any `PlayFromExile` grant with `cast_cost_raise` (Lightstall Inquisitor) alongside a competing exile permission. Not covered: the other per-keyword offer projections (dash, blitz, spectacle, prowl, overload, cleave, mutate, awaken, impending, prototype, warp, emerge) still project variant-less through the unchanged wrapper — same latent divergence class, pre-existing there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(engine): offer the face-down cast when only the {3} is payable A morph/megamorph/disguise card whose printed cost was not payable was never OFFERED, although dispatching `CastSpell` directly succeeded and auto-routed to the face-down cast (issue phase-rs#7770: three Islands could not cast a green morph creature from the UI). The offer gate (`castable_spell_verdict_with_probe`) judged a successfully prepared spell by its printed cost plus the casting-variant menu, and `CastingVariant::FaceDown` only enters that menu under an `unlimited_hand_cast_free_source` permission (Omniscience). Without one the offer said no while the reducer said yes. The prepare-FAILURE branch and the dispatch gate already asked the right three questions (effective keyword, permitted against the blanked 2/2 profile, {3} payable after cost modification). They are now one named predicate — `face_down_cast_is_feasible` — and the prepare-success branch asks it too, returning `prepared_cost: None` exactly like the failure branch (payment mode Auto, the mode the auto-route dispatches with). Reach: the verdict is shared by `can_cast_object_now` (dispatch preflight, effect-granted cast prompts) and the candidate emitters, so every consumer gains the same yes precisely where the reducer already accepted. Class (measured against card-data.json, 35,798 cards): 220 carry the keyword (141 Morph, 31 Megamorph, 48 Disguise). Counter-proof: with the success-branch rescue removed, the three discriminating tests fail (offer absent); the three pins stay green. Playtested: the three-Islands board and the Kadena empty-board case both offer the cast and resolve it face down. Not covered: - The casting-variant CHOICE MENU still lists FaceDown only under an unlimited free-cast permission; election without one keeps flowing through `AlternativeCastChoice` / the auto-route, unchanged. - The NoCost gate in `can_cast_prepared_now_with_probe` still pairs keyword + affordability without the `permitted` question (pre-existing). - Feasibility adds up to two state clones per morph card in hand, only in the printed-unaffordable case. Fixes phase-rs#7770 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(engine): cover Megamorph and Disguise through the offer and cost paths Review find (matthewevans, CodeRabbit): the face-down offer/cost regressions constructed only `Keyword::Morph`, while `object_has_effective_face_down_keyword` spans Morph, Megamorph, and Disguise — a regression narrowing that scan to Morph would have passed every existing test. Four discriminating siblings through the real cast path: - offer: a Megamorph card and a Disguise card, printed {1}{G} unpayable against three Islands, must be offered and dispatch face down (face_down_cast_offer.rs). - cost: Kadena reduces a Megamorph face-down cast to {0}; Dream Chisel takes {1} off a Disguise face-down cast (face_down_spell_cost_filter.rs). The branch also carries a merge of current main (5037022): the parse receipt's two "removed" signatures were main-baseline movement (phase-rs#7491's fail-closed classifiers); this branch touches no parser file, so the regenerated receipt should be empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
🤖 AI text below 🤖
Summary
Adds rules-correct morph / megamorph / disguise face-down spell casting (CR 708.4): a card with Morph, Megamorph, or Disguise can be cast from hand face down as a blank 2/2 creature spell for a fixed {3} (CR 702.37c / 702.168a), placed on the stack, resolving to a face-down permanent. This unlocks the full ~228-card morph/megamorph/disguise class and closes the #5155 D10 deferral — Tin Street Gossip's
FaceDownSpellrestricted-mana leaf goes live with no type change.The feature reuses the existing face-down machinery (
GameObject.face_down+back_face,apply_face_down_entry_profile,FaceDownProfile::{cloaked_2_2, vanilla_2_2}) rather than inventing new infrastructure:AlternativeCastKeyword::FaceDownsurfaces the face-down cast as an opt-in alternative to a normal cast (mirrors Evoke/Dash/Prowl), gated on the object carrying an effective Morph/Megamorph/Disguise keyword.CastingVariant::FaceDowntags the resolution context; the cast blanks the object to a 2/2 (ward {2} for Disguise, CR 702.168), stashes the real card inback_face, and pushes it to the stack for {3}.is_face_downpayment seam —build_spell_metanow derivesis_face_down = obj.face_down && obj.back_face.is_some(), which is true only for an objectcontinue_cast_face_downblanked at a payment site. This is what makes theOnlyForFaceDownSpellrestricted-mana gate (mana.rs) live, closing D10. (Foretell/hideaway setface_downbut noback_face; DFC/adventure/transform setback_facebutface_down = false— none produce both at a spell-payment site, so the discriminator is exact.)apply_zone_exit_cleanup— a countered/leaving face-down spell reveals in the graveyard with zero new variant code.Coverage honesty — closing the loop #5165 opened
PR #5165 ("keep Tin Street coverage honest") classified
ManaSpendRestriction::FaceDownSpellas not coverage-supported, with the explicit rationale thatSpellMeta.is_face_downis "never true at a payment site... [Tin Street] must stay red until face-down spell casting exists." This PR makes it exist. Now thatbuild_spell_metasetsis_face_down = trueat aPaymentContext::Spellsite (proven runtime-live bytin_street_gossip_restricted_mana_funds_face_down_cast), leaving the leaf classified dead would be the same coverage dishonesty #5165 fought, only inverted. So this PR completes that contract:is_coverage_supported(FaceDownSpell)flipsfalse → true; theAny([FaceDownSpell, TurnPermanentFaceUp])disjunction (Tin Street Gossip) becomes coverage-supported. The sole production caller is the parser'sEffect::Manaabsorption seam (oracle_effect/sequence.rs) — a parse-time coverage classification, not runtime payability (that stays on the separateManaRestriction::allows_spell), so this is a coverage-honesty change with zero game-behavior change beyond the card becoming supported.FaceDownSpellwas the last hardcoded-false leaf, so fix(engine): keep Tin Street coverage honest #5165's classifier test could no longer distinguish a dead leaf. It is restructured (not merely flipped) and renamedis_coverage_supported_distinguishes_live_and_dead_leaves→is_coverage_supported_all_leaves_supported_empty_any_is_false: every leaf is asserted supported, andAny(vec![]) == falseis kept as the remaining non-vacuous false exemplar. Revert-to-red: revertingFaceDownSpell => trueflips both its own positive assertion and Tin Street's mixed-Anyassertion back to failing. The Tin Street parser test is renamed..._stays_coverage_red→..._is_coverage_supportedand now asserts the absorbed restriction with noEffect::Unimplementedresidual. The "dead today / red until face-down casting exists" docs inability.rs/mana.rs/sequence.rs/ the restricted-mana test are refreshed.--fail-on-enginecheck); no committed coverage artifact needs refreshing (card-data.jsonis gitignored; the baseline is republished frommainto R2). Tin Street is the one affected card — measured against the full 35,396-card corpus: only Tin Street Gossip and Qarsi Deceiver carry the restricted-mana face-down pattern, and Qarsi stays coverage-red (its restriction also has "turn a manifested creature face up" + "pay a morph cost" leaves that remainUnimplementedindependent of theFaceDownSpellclassifier), so exactly one card flips.Frontend is display-only: the existing
AlternativeCostModalgains aFaceDownlabel branch and analternativeCost.faceDown*i18n key in all 7 locales. The engine owns the {3} cost and the 2/2 body; the client renders engine-provided cost fields and computes nothing.AI: the face-down cast is already enumerated and scored by the existing candidate pipeline (
engine::ai_support::candidate_actions→ phase-ai search); no new DeckFeature/policy is needed. A discriminating scoring test proves the AI enumerates both casts and credits the face-down permanent as the 2/2 it actually is.By-construction AI no-regression (§7)
The new action is provably inert for any deck without Morph/Megamorph/Disguise: (1) the alternative-cast offer is gated on
object_has_effective_keyword_kind(Morph|Megamorph|Disguise)— it never fires otherwise; (2)build_spell_meta'sis_face_down = face_down && back_face.is_some()is only ever true for an objectcontinue_cast_face_downblanked. So for thecargo ai-gatesuite (red/affinity/enchantress mirrors — zero morph cards) the change is a provable no-op.cargo ai-gatewas therefore deliberately skipped (a guaranteed 0-flip null that would require a costly card-data regen); the meaningful evidence is the direct discriminating scoring test (crates/phase-ai/tests/morph_face_down_ai_scoring.rs), which proves the AI genuinely enumerates + finite-scores the new action when reachable, and orders a normal 5/5 cast above the 2/2 face-down cast.Commits
feat(engine): rules-correct morph/disguise face-down spell casting (CR 708.4)— variant + eligibility + {3} cost + blank-and-stack + resolution + theis_face_downseam (D10 closure) + 8 discriminating engine tests.test(ai): morph face-down cast is enumerated and sanely scored by the AI— the AI scoring test (test-only; the AI already handles the action).feat(client): surface morph/disguise face-down cast in the alternative-cost modal— FaceDown display copy +faceDown*i18n across 7 locales + folded D10 comment refresh in the restricted-mana test.refactor(engine): FaceDownSpell mana is coverage-supported now that face-down casting exists— rebase-adaptation onto fix(engine): keep Tin Street coverage honest #5165: flipsis_coverage_supported(FaceDownSpell), updates fix(engine): keep Tin Street coverage honest #5165's coverage tests + docs, Tin Street coverage red→green + baseline refresh.Implementation method (required)
/engine-implementerpipeline (plan → review-plan → implement → review-impl → commit)/engine-implementer— explain why belowCR references
OnlyForFaceDownSpellgate, D10 + coverage flip).Verification
Tilt runs on the primary workdir, not this worktree, so all checks were run directly (
-j 4) against the rebased branch (baseupstream/main@ 2334f18).cargo check --workspacecargo clippy --workspace --all-targets -D warnings-p engine --all-targetsre-run after the flip)cargo test -p enginecargo test -p phase-ai --test morph_face_down_ai_scoringpnpm type-check+eslint .vitest runscripts/check-parser-combinators.sh upstream/maintin_street_gossip_face_down_or_turn_face_up_is_coverage_supportedasserts the restriction now absorbs (noEffect::Unimplementedresidual). The card database synthesizes engine cards through the same Oracle-text parser (database/synthesis.rs), and the soleFaceDownSpellproducer is the narrowall_consuming"cast face-down spell(s)" clause — so Tin Street is the one affected card. A red→green gain is informational (not a regression) for the CI--fail-on-enginecheck; no committed coverage artifact to refresh (card-data.jsonis gitignored; the baseline lives on R2). Full-corpus collateral count measured at push-gate against the 35,396-cardcard-data.json: exactly two cards match the restricted-mana face-down pattern (Tin Street Gossip, Qarsi Deceiver); only Tin Street flips, while Qarsi Deceiver stays coverage-red on its independent "turn a manifested creature face up" + "pay a morph cost"Unimplementedleaves — net exactly 1 card red→green.Discriminating-test highlights:
tin_street_gossip_restricted_mana_funds_face_down_cast— D10 closure: Tin Street's restricted{R}{G}(spendable only for face-down spells) funds a face-down cast end-to-end. Non-vacuous: the mana is restricted so a normal cast cannot be funded by it.is_coverage_supported_distinguishes_live_and_dead_leaves(flipped) —FaceDownSpelland the Tin StreetAnydisjunction are now coverage-supported.face_down_cast_puts_blank_2_2_on_stack/face_down_cast_resolves_to_face_down_permanent— the stack entry and resolved permanent are the blank 2/2 (CR 708.2).disguise_face_down_has_ward_morph_does_not— Disguise's face-down 2/2 carries ward {2}; Morph's does not (CR 702.168).countered_face_down_spell_is_revealed_in_graveyard— CR 708.9 reveal-on-leaving-stack.morph_creature_offers_face_down_alternative_cast/non_morph_creature_offers_no_face_down_cast— the offer fires iff the object carries the keyword.🤖 Generated with Claude Code