diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 3b254d7f93..8495461144 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1620,6 +1620,25 @@ export interface ReplacementCandidateSummary { description: string; } +export type EmergeSacrificeQuality = + | { type: "Artifact" } + | { type: "Battle" } + | { type: "Card" } + | { type: "Creature" } + | { type: "Enchantment" } + | { type: "Instant" } + | { type: "Kindred" } + | { type: "Land" } + | { type: "Permanent" } + | { type: "Planeswalker" } + | { type: "Sorcery" } + | { type: "Subtype"; data: string }; + +export type AlternativeAdditionalCostDescription = { + type: "EmergeSacrifice"; + quality: EmergeSacrificeQuality; +}; + // ── WaitingFor (discriminated union with tag="type", content="data") ───── export type OpeningHandBottomReason = { type: "TinyLeadersMultiCommander" }; @@ -1748,7 +1767,7 @@ export type WaitingFor = // `keyword.type` mirrors engine `AlternativeCastKeyword` (game_state.rs) 1:1. // Keep this union exhaustive with the engine enum so the modal's keyword // switch is type-checked against every variant the engine can emit. - | { type: "AlternativeCastChoice"; data: { player: PlayerId; object_id: ObjectId; card_id: CardId; payment_mode?: CastPaymentMode; keyword: { type: "Warp" } | { type: "Evoke" } | { type: "Emerge" } | { type: "Dash" } | { type: "Blitz" } | { type: "Overload" } | { type: "Bestow" } | { type: "Awaken" } | { type: "Cleave" } | { type: "MoreThanMeetsTheEye" } | { type: "Impending" } | { type: "Prototype" } | { type: "Mutate" } | { type: "Spectacle" } | { type: "Prowl" } | { type: "FaceDown" }; normal_cost: ManaCost; alternative_cost: ManaCost | null; alternative_additional_cost: SerializedAbilityCost | null } } + | { type: "AlternativeCastChoice"; data: { player: PlayerId; object_id: ObjectId; card_id: CardId; payment_mode?: CastPaymentMode; keyword: { type: "Warp" } | { type: "Evoke" } | { type: "Emerge" } | { type: "Dash" } | { type: "Blitz" } | { type: "Overload" } | { type: "Bestow" } | { type: "Awaken" } | { type: "Cleave" } | { type: "MoreThanMeetsTheEye" } | { type: "Impending" } | { type: "Prototype" } | { type: "Mutate" } | { type: "Spectacle" } | { type: "Prowl" } | { type: "FaceDown" }; normal_cost: ManaCost; alternative_cost: ManaCost | null; alternative_additional_cost: SerializedAbilityCost | null; alternative_additional_cost_description: AlternativeAdditionalCostDescription | null } } // CR 702.140c + CR 730.2a: mutating creature spell resolving with a legal // target — controller chooses to put it on top of or under the target creature. | { type: "MutateMergeChoice"; data: { player: PlayerId; merging_id: ObjectId; target_id: ObjectId } } diff --git a/client/src/components/modal/AlternativeCostModal.tsx b/client/src/components/modal/AlternativeCostModal.tsx index e0f5086d3b..3dcc514680 100644 --- a/client/src/components/modal/AlternativeCostModal.tsx +++ b/client/src/components/modal/AlternativeCostModal.tsx @@ -2,6 +2,8 @@ import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import type { + AlternativeAdditionalCostDescription, + EmergeSacrificeQuality, GameAction, ManaCost, SerializedAbilityCost, @@ -35,6 +37,7 @@ interface KeywordCopy { function keywordCopy( keyword: Keyword, cardName: string, + alternativeAdditionalCostDescription: AlternativeAdditionalCostDescription | null, t: TFunction<"game">, ): KeywordCopy { switch (keyword) { @@ -55,15 +58,20 @@ function keywordCopy( showOracleText: true, subtitle: t("alternativeCost.evokeSubtitle", { name: cardName }), }; - // CR 702.119a-c: Emerge — sacrifice a creature while casting; the emerge - // cost is reduced by that creature's mana value (handled engine-side). + // CR 702.119a-b: Emerge's required sacrifice quality is supplied by the + // engine; the modal must not infer it from the typed cost filter. case "Emerge": return { eyebrow: t("alternativeCost.emergeEyebrow"), normalLabel: t("alternativeCost.emergeNormalLabel"), altLabel: t("alternativeCost.emergeAltLabel"), showOracleText: true, - subtitle: t("alternativeCost.emergeSubtitle", { name: cardName }), + subtitle: t("alternativeCost.emergeSubtitle", { + name: cardName, + sacrifice: alternativeAdditionalCostDescription + ? describeAdditionalCostDescription(alternativeAdditionalCostDescription, t) + : t("alternativeCost.emergeFallbackSacrifice"), + }), }; // CR 702.109a: Dash — like Warp, the rider (haste + end-step return to hand) // lives on the keyword itself and doesn't change the spell's printed text. @@ -193,6 +201,48 @@ function keywordCopy( return assertNever(keyword); } +function describeEmergeSacrificeQuality( + quality: EmergeSacrificeQuality, + t: TFunction<"game">, +): string { + switch (quality.type) { + case "Artifact": + return t("alternativeCost.emergeSacrificeQuality.artifact"); + case "Battle": + return t("alternativeCost.emergeSacrificeQuality.battle"); + case "Card": + return t("alternativeCost.emergeSacrificeQuality.card"); + case "Creature": + return t("alternativeCost.emergeSacrificeQuality.creature"); + case "Enchantment": + return t("alternativeCost.emergeSacrificeQuality.enchantment"); + case "Instant": + return t("alternativeCost.emergeSacrificeQuality.instant"); + case "Kindred": + return t("alternativeCost.emergeSacrificeQuality.kindred"); + case "Land": + return t("alternativeCost.emergeSacrificeQuality.land"); + case "Permanent": + return t("alternativeCost.emergeSacrificeQuality.permanent"); + case "Planeswalker": + return t("alternativeCost.emergeSacrificeQuality.planeswalker"); + case "Sorcery": + return t("alternativeCost.emergeSacrificeQuality.sorcery"); + case "Subtype": + return t("alternativeCost.emergeSacrificeQuality.subtype", { subtype: quality.data }); + } +} + +function describeAdditionalCostDescription( + description: AlternativeAdditionalCostDescription, + t: TFunction<"game">, +): string { + switch (description.type) { + case "EmergeSacrifice": + return describeEmergeSacrificeQuality(description.quality, t); + } +} + /** * CR 702.74a + CR 601.2h: Compact display copy for the non-mana portion of * an alternative cost (e.g., Solitude's Evoke "Exile a white card from your @@ -243,6 +293,7 @@ export function AlternativeCostModal() { normalCost={data.normal_cost} alternativeCost={data.alternative_cost} alternativeAdditionalCost={data.alternative_additional_cost} + alternativeAdditionalCostDescription={data.alternative_additional_cost_description} dispatch={dispatch} /> ); @@ -254,6 +305,7 @@ function AlternativeCostContent({ normalCost, alternativeCost, alternativeAdditionalCost, + alternativeAdditionalCostDescription, dispatch, }: { objectId: number; @@ -261,6 +313,7 @@ function AlternativeCostContent({ normalCost: ManaCost; alternativeCost: ManaCost | null; alternativeAdditionalCost: SerializedAbilityCost | null; + alternativeAdditionalCostDescription: AlternativeAdditionalCostDescription | null; dispatch: (action: GameAction) => Promise; }) { const { t } = useTranslation("game"); @@ -269,7 +322,7 @@ function AlternativeCostContent({ if (!obj) return null; const cardName = obj.name; - const copy = keywordCopy(keyword, cardName, t); + const copy = keywordCopy(keyword, cardName, alternativeAdditionalCostDescription, t); return ( - {describeAdditionalCost(alternativeAdditionalCost, t)} + {alternativeAdditionalCostDescription + ? describeAdditionalCostDescription(alternativeAdditionalCostDescription, t) + : describeAdditionalCost(alternativeAdditionalCost, t)} )} {copy.altSuffix && ( diff --git a/client/src/components/modal/__tests__/AlternativeCostModal.test.tsx b/client/src/components/modal/__tests__/AlternativeCostModal.test.tsx index 58b65a7a11..52145e5278 100644 --- a/client/src/components/modal/__tests__/AlternativeCostModal.test.tsx +++ b/client/src/components/modal/__tests__/AlternativeCostModal.test.tsx @@ -1,11 +1,13 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { + AlternativeAdditionalCostDescription, GameObject, ManaCost, WaitingFor, } from "../../../adapter/types.ts"; +import { usePreferencesStore } from "../../../stores/preferencesStore.ts"; import { useGameStore } from "../../../stores/gameStore.ts"; import { buildGameObjectWithCoreTypes, buildObjectMap } from "../../../test/factories/gameObjectFactory.ts"; import { buildGameState } from "../../../test/factories/gameStateFactory.ts"; @@ -34,7 +36,10 @@ type AltKeyword = Extract< { type: "AlternativeCastChoice" } >["data"]["keyword"]["type"]; -function setSpectacleChoice(keyword: AltKeyword) { +function setSpectacleChoice( + keyword: AltKeyword, + alternativeAdditionalCostDescription: AlternativeAdditionalCostDescription | null = null, +) { const waitingFor: WaitingFor = { type: "AlternativeCastChoice", data: { @@ -45,6 +50,7 @@ function setSpectacleChoice(keyword: AltKeyword) { normal_cost: { type: "Cost", shards: ["Red"], generic: 3 }, alternative_cost: RED_COST, alternative_additional_cost: null, + alternative_additional_cost_description: alternativeAdditionalCostDescription, }, }; @@ -68,10 +74,12 @@ describe("AlternativeCostModal", () => { beforeEach(() => { dispatchMock.mockReset(); dispatchMock.mockResolvedValue(undefined); + usePreferencesStore.setState({ language: "en" }); }); afterEach(() => { cleanup(); + usePreferencesStore.setState({ language: "en" }); }); // Regression for issue #2939: the engine emits `keyword.type === "Spectacle"` @@ -121,4 +129,27 @@ describe("AlternativeCostModal", () => { ).toBeInTheDocument(); }, ); + + it("renders Emerge's engine-provided sacrifice description", () => { + setSpectacleChoice("Emerge", { + type: "EmergeSacrifice", + quality: { type: "Artifact" }, + }); + render(); + + expect(screen.getByText(/sacrificing an artifact/i)).toBeInTheDocument(); + }); + + it("localizes Emerge's typed sacrifice quality", async () => { + usePreferencesStore.setState({ language: "es" }); + setSpectacleChoice("Emerge", { + type: "EmergeSacrifice", + quality: { type: "Artifact" }, + }); + render(); + + await waitFor(() => { + expect(screen.getByText(/sacrificando un artefacto/i)).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 702d4c847b..df4f1d69a6 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -1758,7 +1758,22 @@ "emergeEyebrow": "Auftauchen", "emergeNormalLabel": "Normal wirken", "emergeAltLabel": "Mit Auftauchen wirken", - "emergeSubtitle": "Wirke {{name}} normal oder zahle seine Auftauchen-Kosten, indem du eine Kreatur opferst, was die Kosten um den Manawert dieser Kreatur reduziert.", + "emergeSubtitle": "Wirke {{name}} normal oder zahle seine Auftauchen-Kosten, indem du {{sacrifice}} opferst, was die Kosten um den Manawert dieser bleibenden Karte reduziert.", + "emergeFallbackSacrifice": "eine passende bleibende Karte", + "emergeSacrificeQuality": { + "artifact": "ein Artefakt", + "battle": "eine Schlacht", + "card": "eine Karte", + "creature": "eine Kreatur", + "enchantment": "eine Verzauberung", + "instant": "ein Spontanzauber", + "kindred": "ein Stammes-Permanent", + "land": "ein Land", + "permanent": "ein Permanent", + "planeswalker": "ein Planeswalker", + "sorcery": "eine Hexerei", + "subtype": "ein Permanent vom Typ {{subtype}}" + }, "impendingEyebrow": "Drohend", "impendingNormalLabel": "Normal wirken", "impendingAltLabel": "Mit Drohend wirken", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 9d9a7e3465..cfc73722f6 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -1802,7 +1802,22 @@ "emergeEyebrow": "Emerge", "emergeNormalLabel": "Cast Normally", "emergeAltLabel": "Cast with Emerge", - "emergeSubtitle": "Cast {{name}} normally, or pay its Emerge cost by sacrificing a creature, reducing the cost by that creature's mana value.", + "emergeSubtitle": "Cast {{name}} normally, or pay its Emerge cost by sacrificing {{sacrifice}}, reducing the cost by that permanent's mana value.", + "emergeFallbackSacrifice": "a matching permanent", + "emergeSacrificeQuality": { + "artifact": "an artifact", + "battle": "a battle", + "card": "a card", + "creature": "a creature", + "enchantment": "an enchantment", + "instant": "an instant", + "kindred": "a kindred", + "land": "a land", + "permanent": "a permanent", + "planeswalker": "a planeswalker", + "sorcery": "a sorcery", + "subtype": "a permanent of type {{subtype}}" + }, "impendingEyebrow": "Impending", "impendingNormalLabel": "Cast Normally", "impendingAltLabel": "Cast with Impending", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index ee216ea24e..a721912f4c 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -1758,7 +1758,22 @@ "emergeEyebrow": "Emerger", "emergeNormalLabel": "Lanzar normalmente", "emergeAltLabel": "Lanzar con Emerger", - "emergeSubtitle": "Lanza {{name}} normalmente, o paga su coste de Emerger sacrificando una criatura, reduciendo el coste en el valor de maná de esa criatura.", + "emergeSubtitle": "Lanza {{name}} normalmente, o paga su coste de Emerger sacrificando {{sacrifice}}, reduciendo el coste en el valor de maná de ese permanente.", + "emergeFallbackSacrifice": "un permanente que cumpla los requisitos", + "emergeSacrificeQuality": { + "artifact": "un artefacto", + "battle": "una batalla", + "card": "una carta", + "creature": "una criatura", + "enchantment": "un encantamiento", + "instant": "un instantáneo", + "kindred": "un tipo tribal", + "land": "una tierra", + "permanent": "un permanente", + "planeswalker": "un planeswalker", + "sorcery": "un conjuro", + "subtype": "un permanente del tipo {{subtype}}" + }, "impendingEyebrow": "Inminencia", "impendingNormalLabel": "Lanzar normalmente", "impendingAltLabel": "Lanzar con Inminencia", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 62a2304b79..3149b0ebd3 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -1758,7 +1758,22 @@ "emergeEyebrow": "Émergence", "emergeNormalLabel": "Lancer normalement", "emergeAltLabel": "Lancer avec Émergence", - "emergeSubtitle": "Lancez {{name}} normalement, ou payez son coût d'Émergence en sacrifiant une créature, ce qui réduit le coût de la valeur de mana de cette créature.", + "emergeSubtitle": "Lancez {{name}} normalement, ou payez son coût d'Émergence en sacrifiant {{sacrifice}}, ce qui réduit le coût de la valeur de mana de ce permanent.", + "emergeFallbackSacrifice": "un permanent correspondant", + "emergeSacrificeQuality": { + "artifact": "un artefact", + "battle": "une bataille", + "card": "une carte", + "creature": "une créature", + "enchantment": "un enchantement", + "instant": "un éphémère", + "kindred": "un tribal", + "land": "un terrain", + "permanent": "un permanent", + "planeswalker": "un planeswalker", + "sorcery": "un rituel", + "subtype": "un permanent du type {{subtype}}" + }, "impendingEyebrow": "Imminence", "impendingNormalLabel": "Lancer normalement", "impendingAltLabel": "Lancer avec Imminence", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index a97b671014..4bda263127 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -1758,7 +1758,22 @@ "emergeEyebrow": "Emergere", "emergeNormalLabel": "Lancia normalmente", "emergeAltLabel": "Lancia con Emergere", - "emergeSubtitle": "Lancia {{name}} normalmente, o paga il suo costo di Emergere sacrificando una creatura, riducendo il costo del valore di mana di quella creatura.", + "emergeSubtitle": "Lancia {{name}} normalmente, o paga il suo costo di Emergere sacrificando {{sacrifice}}, riducendo il costo del valore di mana di quel permanente.", + "emergeFallbackSacrifice": "un permanente corrispondente", + "emergeSacrificeQuality": { + "artifact": "un artefatto", + "battle": "una battaglia", + "card": "una carta", + "creature": "una creatura", + "enchantment": "un incantesimo", + "instant": "un istantaneo", + "kindred": "un tribale", + "land": "una terra", + "permanent": "un permanente", + "planeswalker": "un planeswalker", + "sorcery": "una stregoneria", + "subtype": "un permanente di tipo {{subtype}}" + }, "impendingEyebrow": "Incombere", "impendingNormalLabel": "Lancia normalmente", "impendingAltLabel": "Lancia con Incombere", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 72cd30d1bf..6c22be57ad 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -1758,7 +1758,22 @@ "emergeEyebrow": "Emerge", "emergeNormalLabel": "Rzuć normalnie", "emergeAltLabel": "Rzuć z Emerge", - "emergeSubtitle": "Rzuć {{name}} normalnie lub zapłać jego koszt Emerge, poświęcając stwora, co zmniejsza koszt o wartość many tego stwora.", + "emergeSubtitle": "Rzuć {{name}} normalnie lub zapłać jego koszt Emerge, poświęcając {{sacrifice}}, co zmniejsza koszt o wartość many tego permanentu.", + "emergeFallbackSacrifice": "pasujący permanent", + "emergeSacrificeQuality": { + "artifact": "artefakt", + "battle": "bitwę", + "card": "kartę", + "creature": "stwora", + "enchantment": "urok", + "instant": "sztuczkę", + "kindred": "permanent typowy", + "land": "ląd", + "permanent": "permanent", + "planeswalker": "wędrowca", + "sorcery": "obrzęd", + "subtype": "permanent typu {{subtype}}" + }, "impendingEyebrow": "Impending", "impendingNormalLabel": "Rzuć normalnie", "impendingAltLabel": "Rzuć z Impending", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 91acbfb55f..0d3a4afdaf 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -1758,7 +1758,22 @@ "emergeEyebrow": "Emergir", "emergeNormalLabel": "Conjurar Normalmente", "emergeAltLabel": "Conjurar com Emergir", - "emergeSubtitle": "Conjure {{name}} normalmente, ou pague seu custo de Emergir sacrificando uma criatura, reduzindo o custo pelo valor de mana daquela criatura.", + "emergeSubtitle": "Conjure {{name}} normalmente, ou pague seu custo de Emergir sacrificando {{sacrifice}}, reduzindo o custo pelo valor de mana daquele permanente.", + "emergeFallbackSacrifice": "um permanente correspondente", + "emergeSacrificeQuality": { + "artifact": "um artefato", + "battle": "uma batalha", + "card": "uma carta", + "creature": "uma criatura", + "enchantment": "um encantamento", + "instant": "uma mágica instantânea", + "kindred": "um tipo tribal", + "land": "um terreno", + "permanent": "uma permanente", + "planeswalker": "um planeswalker", + "sorcery": "uma mágica", + "subtype": "uma permanente do tipo {{subtype}}" + }, "impendingEyebrow": "Iminente", "impendingNormalLabel": "Conjurar Normalmente", "impendingAltLabel": "Conjurar com Iminente", diff --git a/client/src/network/__tests__/protocol.test.ts b/client/src/network/__tests__/protocol.test.ts index 6e37b10492..58104761bf 100644 --- a/client/src/network/__tests__/protocol.test.ts +++ b/client/src/network/__tests__/protocol.test.ts @@ -36,8 +36,8 @@ const viewerInteractionWithProducedMana = { } as never; describe("encodeWireMessage / decodeWireMessage", () => { - it("pins the P2P wire protocol to v22", () => { - expect(WIRE_PROTOCOL_VERSION).toBe(22); + it("pins the P2P wire protocol to v23", () => { + expect(WIRE_PROTOCOL_VERSION).toBe(23); }); it("defaults shortcut actions for a legacy payload created before the additive field", () => { @@ -234,15 +234,15 @@ describe("encodeWireMessage / decodeWireMessage", () => { // and nothing about the version. Both halves here stamp LITERALS — a frame // built from WIRE_PROTOCOL_VERSION cannot tell a bumped client from an // unbumped one, which is why every other handshake fixture in the suite is - // useless as an instrument for a bump. Revert 22 → 21 and BOTH halves red: - // the v21 frame stops being refused, and the v22 frame stops being admitted. - // The admitting half is the reach-guard: without it "refuses v21" is also + // useless as an instrument for a bump. Revert 23 → 22 and BOTH halves red: + // the v22 frame stops being refused, and the v23 frame stops being admitted. + // The admitting half is the reach-guard: without it "refuses v22" is also // satisfied by a client that refuses everything. - it("refuses the previous wire protocol (v21) and admits its own (v22)", () => { - expect(() => validateMessage(setupFrameAt(21))).toThrow(/Wire protocol mismatch/); - expect(validateMessage(setupFrameAt(22))).toMatchObject({ + it("refuses the previous wire protocol (v22) and admits its own (v23)", () => { + expect(() => validateMessage(setupFrameAt(22))).toThrow(/Wire protocol mismatch/); + expect(validateMessage(setupFrameAt(23))).toMatchObject({ type: "game_setup", - wireProtocolVersion: 22, + wireProtocolVersion: 23, }); }); diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 2febc35eca..c3b39b4cd8 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -80,6 +80,9 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * of silently corrupting state. * * Bumps to date: + * 23 — WaitingFor::AlternativeCastChoice.alternative_additional_cost_description + * changed from a string to a typed Emerge-sacrifice descriptor. Older + * clients would receive an object where their modal expects display text. * 22 — LegalActionsWire.viewerInteraction carries attachmentViews: the engine's * membership list for each host's attachment fan. It parses on a v21 peer * as an empty map, so the loss is silent — a guest paired with a v21 host @@ -120,7 +123,7 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * sub-phase on WaitingFor::MulliganDecision; the MulliganBottomCards * variant was removed */ -export const WIRE_PROTOCOL_VERSION = 22 as const; +export const WIRE_PROTOCOL_VERSION = 23 as const; export type P2PMessage = P2PAuthorityWire & ( | { type: "guest_deck"; deckData: unknown; displayName?: string; reservationToken?: string } diff --git a/client/src/viewmodel/__tests__/keywordProps.test.ts b/client/src/viewmodel/__tests__/keywordProps.test.ts index da12bc080f..5b634ee42a 100644 --- a/client/src/viewmodel/__tests__/keywordProps.test.ts +++ b/client/src/viewmodel/__tests__/keywordProps.test.ts @@ -60,6 +60,17 @@ describe("getKeywordDetail", () => { expect(getKeywordDetail({ Flashback: "SelfManaCost" })).toBe("its mana cost"); }); + it("formats the mana cost nested in EmergeCost", () => { + expect( + getKeywordDetail({ + Emerge: { + mana_cost: { Cost: { shards: ["Black", "Black"], generic: 5 } }, + sacrifice_filter: { type: "Typed", type_filters: ["Artifact"] }, + }, + }), + ).toBe("{5}{B}{B}"); + }); + it("formats u32 params", () => { expect(getKeywordDetail({ Dredge: 3 })).toBe("3"); expect(getKeywordDetail({ Annihilator: 2 })).toBe("2"); diff --git a/client/src/viewmodel/keywordProps.ts b/client/src/viewmodel/keywordProps.ts index 8cde76d047..e8b1c7d33d 100644 --- a/client/src/viewmodel/keywordProps.ts +++ b/client/src/viewmodel/keywordProps.ts @@ -370,6 +370,13 @@ export function getKeywordDetail(kw: Keyword): string | null { const key = Object.keys(kw)[0]; const val = kw[key]; + if (key === "Emerge") { + const manaCost = val && typeof val === "object" && "mana_cost" in val + ? val.mana_cost + : val; + return formatKeywordManaCost(manaCost); + } + if (MANA_COST_KEYWORDS.has(key)) return formatKeywordManaCost(val); if (U32_KEYWORDS.has(key)) return String(val); diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 6c83730be9..ab0d7755f4 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -4931,7 +4931,8 @@ fn scan_keyword(kw: &Keyword, mode: ScanMode) -> Axes { | Keyword::Echo(_) | Keyword::Buyback(_) | Keyword::Cycling(_) - | Keyword::Flashback(_) => Axes::CONSERVATIVE, + | Keyword::Flashback(_) + | Keyword::Emerge(_) => Axes::CONSERVATIVE, // Every other keyword carries a read-free payload (unit / u32 / String / // ManaCost / value tag): it reads nothing on any axis here. Its cost-read, // if any, is already captured by `cost_read` above. @@ -5017,7 +5018,6 @@ fn scan_keyword(kw: &Keyword, mode: ScanMode) -> Axes { | Keyword::Madness(_) | Keyword::Miracle(_) | Keyword::Dash(_) - | Keyword::Emerge(_) | Keyword::Harmonize(_) | Keyword::Foretell(_) | Keyword::Mutate(_) diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 6e68071f6a..8596fb81dc 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -7,14 +7,15 @@ use crate::types::ability::{ ModalSelectionCondition, ObjectScope, PlayerFilter, PlayerScope, ProhibitedActivity, QuantityExpr, QuantityRef, ResolvedAbility, RestrictionExpiry, RestrictionPlayerScope, StaticCondition, StaticDefinition, SubAbilityLink, TapCreaturesRequirement, TargetFilter, - TargetRef, + TargetRef, TypeFilter, }; use crate::types::actions::{AlternativeCastDecision, GameAction}; use crate::types::card::LayoutKind; use crate::types::events::{ActivatedAbilityKind, GameEvent}; use crate::types::game_state::{ - ActivationResidual, ActivationTargetSelection, CastOfferKind, CastPaymentMode, - CastingPermissionIndex, CastingVariant, CastingVariantChoiceOption, ConvokeMode, CostResume, + ActivationResidual, ActivationTargetSelection, AlternativeAdditionalCostDescription, + CastOfferKind, CastPaymentMode, CastingPermissionIndex, CastingVariant, + CastingVariantChoiceOption, ConvokeMode, CostResume, DistributionUnit, EmergeSacrificeQuality, GameState, ManaAbilityCostParent, ManaAbilityResume, ManaChoice, ManaChoiceContext, ManaChoicePrompt, NextSpellModifier, PayCostKind, PendingCast, PendingCostMoveResume, SneakPlacement, SpellCostSource, StackEntry, StackEntryKind, TargetEffectDetail, @@ -2547,6 +2548,93 @@ pub(crate) fn effective_spell_keywords( effective_spell_keywords_for(state, caster, object_id, false) } +/// CR 702.119a-b: The active Emerge keyword supplies both the mana cost and +/// permanent quality for its required sacrifice cost. +fn effective_emerge_cost( + state: &GameState, + caster: PlayerId, + object_id: ObjectId, +) -> Option { + effective_spell_keywords(state, caster, object_id) + .into_iter() + .find_map(|keyword| match keyword { + Keyword::Emerge(cost) => Some(cost), + _ => None, + }) +} + +/// CR 702.119b: Emerge's sacrifice quality is part of the alternative cost, so +/// the engine supplies a typed descriptor rather than requiring a client to +/// interpret its `TargetFilter`. Complex filters use the localized generic +/// fallback rather than a lossy partial description. +fn emerge_sacrifice_description( + sacrifice_filter: &TargetFilter, +) -> Option { + let TargetFilter::Typed(filter) = sacrifice_filter else { + return None; + }; + if filter.type_filters.len() != 1 + || filter.controller.is_some() + || !filter.properties.is_empty() + { + return None; + } + let quality = match filter.type_filters.first()? { + TypeFilter::Artifact => EmergeSacrificeQuality::Artifact, + TypeFilter::Battle => EmergeSacrificeQuality::Battle, + TypeFilter::Card => EmergeSacrificeQuality::Card, + TypeFilter::Creature => EmergeSacrificeQuality::Creature, + TypeFilter::Enchantment => EmergeSacrificeQuality::Enchantment, + TypeFilter::Instant => EmergeSacrificeQuality::Instant, + TypeFilter::Kindred => EmergeSacrificeQuality::Kindred, + TypeFilter::Land => EmergeSacrificeQuality::Land, + TypeFilter::Permanent => EmergeSacrificeQuality::Permanent, + TypeFilter::Planeswalker => EmergeSacrificeQuality::Planeswalker, + TypeFilter::Sorcery => EmergeSacrificeQuality::Sorcery, + TypeFilter::Subtype(subtype) => EmergeSacrificeQuality::Subtype(subtype.clone()), + TypeFilter::Any | TypeFilter::AnyOf(_) | TypeFilter::Non(_) => return None, + }; + Some(AlternativeAdditionalCostDescription::EmergeSacrifice { quality }) +} + +/// CR 702.119c + CR 601.2b/h: Declare Emerge's required sacrifice before +/// targets and mana payment, using the same effective keyword snapshot as the +/// alternative-cost offer and mana-cost substitution paths. +fn begin_emerge_cost_before_targets( + state: &mut GameState, + player: PlayerId, + prepared: &PreparedSpellCast, + resolved: ResolvedAbility, + distribute: Option, + events: &mut Vec, +) -> Result { + let sacrifice_filter = effective_emerge_cost(state, player, prepared.object_id) + .ok_or_else(|| { + EngineError::ActionNotAllowed( + "Emerge casting variant requires an effective Emerge keyword".to_string(), + ) + })? + .sacrifice_filter; + casting_costs::begin_required_cost_before_targets( + state, + player, + prepared.object_id, + prepared.card_id, + resolved, + prepared.mana_cost.clone(), + Some(prepared.base_mana_cost.clone()), + casting_costs::emerge_sacrifice_cost(sacrifice_filter), + SpellCostSource::Emerge, + prepared.casting_variant, + prepared.casting_permission_index, + prepared.cast_timing_permission, + distribute, + prepared.origin_zone, + prepared.payment_mode, + events, + ) +} + /// Fuse-aware sibling of [`effective_spell_keywords`]. `fused` projects a /// pre-payment fused split spell with its COMBINED characteristics (CR 702.102b) /// so `CastWithKeyword`-granted keywords keyed on mana value / colors are granted @@ -5734,9 +5822,9 @@ fn casting_variant_candidates( candidates.push(CastingVariant::Overload); } - // CR 702.119a-c + CR 118.9: Emerge is a hand-zone alternative cost that - // requires sacrificing a creature and reducing the emerge cost by that - // creature's mana value. + // CR 702.119a-b + CR 118.9: Emerge is a hand-zone alternative cost that + // requires sacrificing its printed permanent quality and reducing the + // emerge cost by that permanent's mana value. if obj.zone == Zone::Hand && effective_spell_keywords(state, player, object_id) .iter() @@ -6542,16 +6630,10 @@ fn prepare_spell_cast_with_variant_override_inner( // (CR 702.119c, CR 601.2h). // CR 702.102b: GUARDED — arm requires `casting_variant == Emerge`; Fuse never // equals it, so this read is unreachable for a fused split cast. - let emerge_cost = if casting_variant == CastingVariant::Emerge { - effective_spell_keywords(state, player, object_id) - .iter() - .find_map(|k| match k { - crate::types::keywords::Keyword::Emerge(cost) => Some(cost.clone()), - _ => None, - }) - } else { - None - }; + let emerge_cost = (casting_variant == CastingVariant::Emerge) + .then(|| effective_emerge_cost(state, player, object_id)) + .flatten() + .map(|cost| cost.mana_cost); // CR 702.103a + CR 118.9: When the caller explicitly opted into Bestow (via // `variant_override = Some(CastingVariant::Bestow)`), substitute the bestow // mana sub-cost taken from the object's `Keyword::Bestow(cost)` payload. @@ -11479,6 +11561,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(warp_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } // If only normal is affordable, skip warp — prepare_spell_cast will @@ -11534,6 +11617,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost: offer.normal_cost, alternative_cost: offer.alternative_cost, alternative_additional_cost: offer.alternative_additional_cost, + alternative_additional_cost_description: None, }); } if !eligibility.normal_affordable && eligibility.evoke_affordable { @@ -11549,26 +11633,29 @@ pub fn handle_cast_spell_with_payment_mode( } } - // CR 702.119a-c: Emerge — when a hand card has Keyword::Emerge and both + // CR 702.119a-b: Emerge — when a hand card has Keyword::Emerge and both // costs are affordable, present a choice. Emerge affordability includes a - // legal creature sacrifice and the reduced emerge cost after that - // sacrificed creature's mana value is subtracted. + // legal printed-quality sacrifice and the reduced emerge cost after that + // permanent's mana value is subtracted. if let Some(obj) = state.objects.get(&object_id) { if obj.zone == Zone::Hand { - if let Some(emerge_cost) = effective_spell_keywords(state, player, object_id) - .into_iter() - .find_map(|k| match k { - crate::types::keywords::Keyword::Emerge(cost) => Some(cost), - _ => None, - }) - { + if let Some(emerge_cost) = effective_emerge_cost(state, player, object_id) { let (normal_cost, normal_affordable) = normal_cast_choice_cost_and_affordability(state, player, object_id, obj); - let emerge_cost_eff = - apply_cost_modifiers_to_base(state, player, object_id, emerge_cost.clone()) - .unwrap_or_else(|| emerge_cost.clone()); - let emerge_affordable = - casting_costs::can_pay_emerge_cost(state, player, object_id, &emerge_cost_eff); + let emerge_cost_eff = apply_cost_modifiers_to_base( + state, + player, + object_id, + emerge_cost.mana_cost.clone(), + ) + .unwrap_or_else(|| emerge_cost.mana_cost.clone()); + let emerge_affordable = casting_costs::can_pay_emerge_cost( + state, + player, + object_id, + &emerge_cost_eff, + &emerge_cost.sacrifice_filter, + ); if normal_affordable && emerge_affordable { return Ok(WaitingFor::AlternativeCastChoice { player, @@ -11578,7 +11665,12 @@ pub fn handle_cast_spell_with_payment_mode( keyword: crate::types::game_state::AlternativeCastKeyword::Emerge, normal_cost, alternative_cost: Some(emerge_cost_eff), - alternative_additional_cost: Some(casting_costs::emerge_sacrifice_cost()), + alternative_additional_cost: Some(casting_costs::emerge_sacrifice_cost( + emerge_cost.sacrifice_filter.clone(), + )), + alternative_additional_cost_description: emerge_sacrifice_description( + &emerge_cost.sacrifice_filter, + ), }); } if !normal_affordable && emerge_affordable { @@ -11630,6 +11722,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(dash_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && dash_affordable { @@ -11685,6 +11778,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(blitz_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && blitz_affordable { @@ -11736,6 +11830,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(spectacle_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && spectacle_affordable { @@ -11793,6 +11888,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(prowl_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && prowl_affordable { @@ -11842,6 +11938,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(overload_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && overload_affordable { @@ -11899,6 +11996,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(mtmte_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && mtmte_affordable { @@ -11952,6 +12050,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(cleave_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && cleave_affordable { @@ -12054,6 +12153,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: bestow_mana_eff, alternative_additional_cost: bestow_non_mana_part, + alternative_additional_cost_description: None, }); } if has_legal_creature_target && bestow_affordable { @@ -12137,6 +12237,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(mutate_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if has_legal_mutate_target && !normal_affordable && mutate_affordable { @@ -12204,6 +12305,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(awaken_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if has_legal_land && !normal_affordable && awaken_affordable { @@ -12251,6 +12353,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(impending_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && impending_affordable { @@ -12298,6 +12401,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(prototype_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && prototype_affordable { @@ -12355,6 +12459,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(face_down_cost), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } // Only the face-down {3} is affordable — proceed face down. @@ -12843,30 +12948,20 @@ fn continue_with_prepared( )); } - // CR 702.119a-c + CR 601.2b/h: Emerge requires choosing which creature to - // sacrifice as the player chooses to pay the emerge cost, then sacrificing - // it as that cost is paid. Route this before any target selection so the - // required sacrifice is declared on the CR 601.2b axis. + // CR 702.119a-c + CR 601.2b/h: Emerge requires choosing the matching + // permanent to sacrifice as the player chooses to pay the emerge cost, + // then sacrificing it as that cost is paid. Route this before any target + // selection so the required sacrifice is declared on the CR 601.2b axis. if prepared.casting_variant == CastingVariant::Emerge { - return casting_costs::begin_required_cost_before_targets( + return begin_emerge_cost_before_targets( state, player, - prepared.object_id, - prepared.card_id, + &prepared, resolved, - prepared.mana_cost, - Some(prepared.base_mana_cost.clone()), - casting_costs::emerge_sacrifice_cost(), - SpellCostSource::Emerge, - prepared.casting_variant, - prepared.casting_permission_index, - prepared.cast_timing_permission, prepared .ability_def .as_ref() .and_then(|a| a.distribute.clone()), - prepared.origin_zone, - prepared.payment_mode, events, ); } @@ -13358,22 +13453,12 @@ fn continue_with_no_ability( player, ); if prepared.casting_variant == CastingVariant::Emerge { - return casting_costs::begin_required_cost_before_targets( + return begin_emerge_cost_before_targets( state, player, - prepared.object_id, - prepared.card_id, + &prepared, placeholder, - prepared.mana_cost, - Some(prepared.base_mana_cost.clone()), - casting_costs::emerge_sacrifice_cost(), - SpellCostSource::Emerge, - prepared.casting_variant, - prepared.casting_permission_index, - prepared.cast_timing_permission, None, - prepared.origin_zone, - prepared.payment_mode, events, ); } @@ -14239,16 +14324,22 @@ fn can_cast_prepared_now_with_probe( return false; } - // CR 702.119a-c: Emerge affordability is the reduced emerge cost after - // sacrificing a legal creature, not the unreduced `prepared.mana_cost`. + // CR 702.119a-b: Emerge affordability is the reduced emerge cost after + // sacrificing a legal matching permanent, not the unreduced + // `prepared.mana_cost`. if prepared.casting_variant == CastingVariant::Emerge { return (prepared.modal.is_some() || spell_has_legal_targets_with_probe(state, obj.id, player, probe)) - && casting_costs::can_pay_emerge_cost( - state, - player, - prepared.object_id, - &prepared.mana_cost, + && effective_emerge_cost(state, player, prepared.object_id).is_some_and( + |emerge_cost| { + casting_costs::can_pay_emerge_cost( + state, + player, + prepared.object_id, + &prepared.mana_cost, + &emerge_cost.sacrifice_filter, + ) + }, ); } diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 5fc4ef7961..9ce94ea539 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -2906,7 +2906,7 @@ pub(crate) fn handle_sacrifice_for_cost( { Some(SpellCostSource::Offering) } else if payment.source == SpellCostSource::Emerge - && is_emerge_sacrifice_cost(payment.cost) + && is_emerge_sacrifice_cost(state, player, pending.object_id, payment.cost) { Some(SpellCostSource::Emerge) } else { @@ -7786,52 +7786,58 @@ fn is_offering_sacrifice_cost( ) } -fn emerge_sacrifice_filter() -> TargetFilter { - TargetFilter::Typed(TypedFilter::creature()) -} - -fn is_emerge_sacrifice_cost(cost: &AbilityCost) -> bool { +fn is_emerge_sacrifice_cost( + state: &GameState, + player: PlayerId, + object_id: ObjectId, + cost: &AbilityCost, +) -> bool { + let Some(sacrifice_filter) = super::casting::effective_spell_keywords(state, player, object_id) + .into_iter() + .find_map(|keyword| match keyword { + crate::types::keywords::Keyword::Emerge(cost) => Some(cost.sacrifice_filter), + _ => None, + }) + else { + return false; + }; matches!( cost, AbilityCost::Sacrifice(cost) if cost.requirement == SacrificeRequirement::count(1) - && cost.target == emerge_sacrifice_filter() + && cost.target == sacrifice_filter ) } -/// CR 702.119a-c: Build the required sacrifice component of Emerge's -/// alternative cost. The sacrificed creature's mana value is applied as a cost -/// reduction by `handle_sacrifice_for_cost` while the creature is still on the +/// CR 702.119a-b: Build Emerge's required sacrifice component from its printed +/// permanent-quality filter. The sacrificed permanent's mana value is applied +/// as a cost reduction by `handle_sacrifice_for_cost` while it remains on the /// battlefield. -pub(super) fn emerge_sacrifice_cost() -> AbilityCost { - AbilityCost::Sacrifice(SacrificeCost::count(emerge_sacrifice_filter(), 1)) +pub(super) fn emerge_sacrifice_cost(sacrifice_filter: TargetFilter) -> AbilityCost { + AbilityCost::Sacrifice(SacrificeCost::count(sacrifice_filter, 1)) } -/// CR 702.119a-c: Emerge can be paid only if a legal creature can be +/// CR 702.119a-b: Emerge can be paid only if a matching permanent can be /// sacrificed and the resulting reduced emerge mana cost can be paid. pub(super) fn can_pay_emerge_cost( state: &GameState, player: PlayerId, object_id: ObjectId, emerge_cost: &ManaCost, + sacrifice_filter: &TargetFilter, ) -> bool { - super::casting::find_eligible_sacrifice_targets( - state, - player, - object_id, - &emerge_sacrifice_filter(), - ) - .into_iter() - .any(|creature| { - let mut reduced = emerge_cost.clone(); - apply_emerge_cost_reduction(state, creature, &mut reduced); - // CR 601.2f + CR 702.119a: Affordability probes must include the - // final Trinisphere-class floor after Emerge's sacrifice reduction. - if !cost_has_x(&reduced) { - super::casting::apply_cost_floor(state, player, object_id, &mut reduced); - } - super::casting::can_pay_cost_after_auto_tap(state, player, object_id, &reduced) - }) + super::casting::find_eligible_sacrifice_targets(state, player, object_id, sacrifice_filter) + .into_iter() + .any(|permanent| { + let mut reduced = emerge_cost.clone(); + apply_emerge_cost_reduction(state, permanent, &mut reduced); + // CR 601.2f + CR 702.119a: Affordability probes must include the + // final Trinisphere-class floor after Emerge's sacrifice reduction. + if !cost_has_x(&reduced) { + super::casting::apply_cost_floor(state, player, object_id, &mut reduced); + } + super::casting::can_pay_cost_after_auto_tap(state, player, object_id, &reduced) + }) } fn additional_cost_x_max( @@ -8477,8 +8483,8 @@ pub(super) fn apply_offering_cost_reduction( *spell_generic = spell_generic.saturating_sub(sac_generic); } -/// CR 702.119a: Reduce the Emerge cost by generic mana equal to the sacrificed -/// creature's mana value. Colored pips in the Emerge cost are never reduced. +/// CR 702.119a-b: Reduce the Emerge cost by generic mana equal to the sacrificed +/// permanent's mana value. Colored pips in the Emerge cost are never reduced. pub(super) fn apply_emerge_cost_reduction( state: &GameState, sacrifice_id: ObjectId, diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index d284a6f756..325b829eeb 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -21,7 +21,7 @@ use crate::types::card_type::{CoreType, Supertype}; use crate::types::counter::CounterType; use crate::types::events::GameEvent; use crate::types::game_state::{ManaChoice, ManaChoicePrompt, SpellCastRecord}; -use crate::types::keywords::{EscapeCost, FlashbackCost, Keyword, KeywordKind}; +use crate::types::keywords::{EmergeCost, EscapeCost, FlashbackCost, Keyword, KeywordKind}; use crate::types::mana::{ ManaColor, ManaCost, ManaCostShard, ManaRestriction, ManaSourceSelection, ManaSpellGrant, ManaType, ManaUnit, @@ -37230,10 +37230,48 @@ mod alt_cost_reduction_509 { obj.base_power = Some(5); obj.base_toughness = Some(5); obj.base_characteristics_initialized = true; - obj.keywords.push(Keyword::Emerge(emerge)); + obj.keywords + .push(Keyword::Emerge(EmergeCost::creature(emerge))); obj_id } + fn create_artifact_emerge_spell( + state: &mut GameState, + player: PlayerId, + card_id: u64, + printed: ManaCost, + emerge: ManaCost, + ) -> ObjectId { + let spell = create_emerge_spell(state, player, card_id, printed, emerge.clone()); + state.objects.get_mut(&spell).unwrap().keywords = + vec![Keyword::Emerge(EmergeCost::from_quality( + emerge, + TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), + ))]; + spell + } + + fn create_sacrifice_artifact( + state: &mut GameState, + player: PlayerId, + card_id: u64, + mana_cost: ManaCost, + ) -> ObjectId { + let artifact = create_object( + state, + CardId(card_id), + player, + "Sacrifice Artifact".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&artifact).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + obj.base_card_types.core_types.push(CoreType::Artifact); + obj.mana_cost = mana_cost.clone(); + obj.base_mana_cost = mana_cost; + artifact + } + fn create_sacrifice_creature( state: &mut GameState, player: PlayerId, @@ -37919,6 +37957,175 @@ mod alt_cost_reduction_509 { ); } + /// CR 702.119b-c: Emerge from artifact must offer only qualifying artifacts + /// and reduce the emerge cost by the selected artifact's mana value. + #[test] + fn emerge_from_artifact_casts_after_sacrificing_an_artifact() { + let mut state = setup_game_at_main_phase(); + add_mana(&mut state, PlayerId(0), ManaType::Black, 2); + + let emerge = create_artifact_emerge_spell( + &mut state, + PlayerId(0), + 811, + ManaCost::generic(6), + ManaCost::Cost { + shards: vec![ManaCostShard::Black, ManaCostShard::Black], + generic: 5, + }, + ); + let artifact = + create_sacrifice_artifact(&mut state, PlayerId(0), 812, ManaCost::generic(5)); + let creature = + create_sacrifice_creature(&mut state, PlayerId(0), 813, ManaCost::generic(1)); + + assert!( + can_cast_object_now(&state, PlayerId(0), emerge), + "an artifact with mana value 5 must reduce {{5}}{{B}}{{B}} to payable {{B}}{{B}}" + ); + + let mut events = Vec::new(); + let waiting_for = + handle_cast_spell(&mut state, PlayerId(0), emerge, CardId(811), &mut events) + .expect("artifact emerge should enter sacrifice payment"); + match &waiting_for { + WaitingFor::PayCost { + kind: PayCostKind::Sacrifice, + choices, + .. + } => { + assert!( + choices.contains(&artifact), + "artifact must be a legal emerge sacrifice" + ); + assert!( + !choices.contains(&creature), + "a creature must not be legal for emerge from artifact" + ); + } + other => panic!("expected Emerge PayCost(Sacrifice), got {other:?}"), + } + + state.waiting_for = waiting_for; + apply_as_current( + &mut state, + GameAction::SelectCards { + cards: vec![artifact], + }, + ) + .expect("sacrificing the artifact should complete the emerge cast"); + + assert_eq!(state.objects[&artifact].zone, Zone::Graveyard); + assert_eq!(state.objects[&emerge].zone, Zone::Stack); + assert_eq!( + state.players[0].mana_pool.total(), + 0, + "artifact mana value must reduce the emerge cost before black mana is paid" + ); + } + + const CRABOMINATION_ORACLE: &str = "Emerge from artifact {5}{B}{B} (You may cast this spell by sacrificing an artifact and paying the emerge cost reduced by that artifact's mana value.)\nWhen this creature enters, target opponent exiles the top card of their library, a card at random from their graveyard, and a card at random from their hand. You may cast a spell from among cards exiled this way without paying its mana cost."; + + /// CR 702.119b-c: Crabomination's real Oracle text must carry its artifact + /// quality through parsing and into the cast-cost selection pipeline. + #[test] + fn crabomination_real_oracle_casts_by_sacrificing_only_an_artifact() { + use crate::game::scenario::{GameScenario, P0}; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let crabomination = scenario + .add_creature_to_hand_from_oracle(P0, "Crabomination", 5, 5, CRABOMINATION_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black, ManaCostShard::Black], + generic: 4, + }) + .id(); + let artifact = scenario + .add_creature(P0, "Artifact Tribute", 1, 1) + .as_artifact() + .with_mana_cost(ManaCost::generic(5)) + .id(); + let creature = scenario + .add_creature(P0, "Creature Tribute", 1, 1) + .with_mana_cost(ManaCost::generic(1)) + .id(); + + let mut runner = scenario.build(); + add_mana(runner.state_mut(), P0, ManaType::Black, 2); + let card_id = runner.state().objects[&crabomination].card_id; + let mut events = Vec::new(); + let waiting_for = + handle_cast_spell(runner.state_mut(), P0, crabomination, card_id, &mut events) + .expect("Crabomination must enter its Emerge sacrifice payment"); + + match &waiting_for { + WaitingFor::PayCost { + kind: PayCostKind::Sacrifice, + choices, + .. + } => { + assert!(choices.contains(&artifact)); + assert!(!choices.contains(&creature)); + } + other => panic!("expected Crabomination Emerge PayCost(Sacrifice), got {other:?}"), + } + + runner.state_mut().waiting_for = waiting_for; + apply_as_current( + runner.state_mut(), + GameAction::SelectCards { + cards: vec![artifact], + }, + ) + .expect("the artifact sacrifice must complete Crabomination's Emerge cast"); + + assert_eq!(runner.state().objects[&artifact].zone, Zone::Graveyard); + assert_eq!(runner.state().objects[&crabomination].zone, Zone::Stack); + assert_eq!(runner.state().players[P0.0 as usize].mana_pool.total(), 0); + } + + #[test] + fn crabomination_real_oracle_prompt_describes_artifact_sacrifice() { + use crate::game::scenario::{GameScenario, P0}; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let crabomination = scenario + .add_creature_to_hand_from_oracle(P0, "Crabomination", 5, 5, CRABOMINATION_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black, ManaCostShard::Black], + generic: 4, + }) + .id(); + scenario + .add_creature(P0, "Artifact Tribute", 1, 1) + .as_artifact() + .with_mana_cost(ManaCost::generic(5)); + + let mut runner = scenario.build(); + add_mana(runner.state_mut(), P0, ManaType::Black, 6); + let card_id = runner.state().objects[&crabomination].card_id; + let mut events = Vec::new(); + let waiting_for = + handle_cast_spell(runner.state_mut(), P0, crabomination, card_id, &mut events) + .expect("Crabomination must offer its normal and Emerge casts"); + + match waiting_for { + WaitingFor::AlternativeCastChoice { + keyword: crate::types::game_state::AlternativeCastKeyword::Emerge, + alternative_additional_cost_description, + .. + } => assert_eq!( + alternative_additional_cost_description, + Some(crate::types::game_state::AlternativeAdditionalCostDescription::EmergeSacrifice { + quality: crate::types::game_state::EmergeSacrificeQuality::Artifact, + }) + ), + other => panic!("expected Crabomination AlternativeCastChoice(Emerge), got {other:?}"), + } + } + #[test] fn emerge_mana_value_reduction_preserves_colored_pips() { let mut state = setup_game_at_main_phase(); @@ -42687,6 +42894,7 @@ fn bestow_cost_choice_legal_actions_includes_both_paths() { generic: 3, }), alternative_additional_cost: None, + alternative_additional_cost_description: None, payment_mode: CastPaymentMode::Auto, }; let cands = candidate_actions_broad(&state); diff --git a/crates/engine/src/parser/oracle_keyword.rs b/crates/engine/src/parser/oracle_keyword.rs index 7c34362996..d39055a215 100644 --- a/crates/engine/src/parser/oracle_keyword.rs +++ b/crates/engine/src/parser/oracle_keyword.rs @@ -21,7 +21,7 @@ use crate::types::ability::{ }; use crate::types::keywords::{ normalize_bands_with_other_quality, BloodthirstValue, BuybackCost, CyclingCost, DisguiseCost, - EmbalmCost, EscapeCost, EternalizeCost, FlashbackCost, Keyword, WardCost, + EmbalmCost, EmergeCost, EscapeCost, EternalizeCost, FlashbackCost, Keyword, WardCost, }; use crate::types::mana::{ManaCost, ManaCostShard}; use crate::types::zones::Zone; @@ -1424,6 +1424,15 @@ pub(crate) fn parse_keyword_line_core(text: &str) -> Option<(Keyword, &str)> { return Some(result); } + // CR 702.119b: "Emerge from [quality] {cost}" replaces ordinary Emerge's + // creature sacrifice with a permanent matching the parsed quality. + if tag::<_, _, OracleError<'_>>("emerge from ") + .parse(text) + .is_ok() + { + return parse_emerge_from_quality_keyword_line(text); + } + if let Some(kw) = parse_firebending_keyword_line(text) { return Some((kw, "")); } @@ -1950,6 +1959,27 @@ pub(crate) fn parse_keyword_line_core(text: &str) -> Option<(Keyword, &str)> { Some((parsed, unconsumed)) } +/// CR 702.119b: Parse "emerge from [quality] {cost}" without swallowing a +/// missing mana cost or semantic suffix. The type parser owns the quality grammar +/// and the mana combinator leaves any trailing text for the strict router. +fn parse_emerge_from_quality_keyword_line(text: &str) -> Option<(Keyword, &str)> { + let (after_prefix, _) = tag::<_, _, OracleError<'_>>("emerge from ") + .parse(text) + .ok()?; + let (sacrifice_filter, after_quality) = parse_type_phrase(after_prefix); + if after_quality.len() == after_prefix.len() { + return None; + } + let (after_cost, _) = space1::<_, OracleError<'_>>.parse(after_quality).ok()?; + let upper_cost = after_cost.to_ascii_uppercase(); + let (upper_remainder, mana_cost) = nom_primitives::parse_mana_cost(&upper_cost).ok()?; + let remainder = &after_cost[after_cost.len() - upper_remainder.len()..]; + Some(( + Keyword::Emerge(EmergeCost::from_quality(mana_cost, sacrifice_filter)), + remainder, + )) +} + /// Permissive, grant-context keyword parser. Returns the typed leading keyword /// and **deliberately discards** whatever the core did not consume. /// @@ -2885,6 +2915,66 @@ mod tests { use crate::types::mana::ManaCost; use crate::types::player::PlayerCounterKind; + #[test] + fn parse_keyword_line_core_emerge_from_artifact_preserves_quality() { + let (keyword, remainder) = parse_keyword_line_core("emerge from artifact {5}{b}{b}") + .expect("artifact-qualified Emerge must parse"); + assert!(remainder.is_empty()); + match keyword { + Keyword::Emerge(EmergeCost { + mana_cost, + sacrifice_filter: TargetFilter::Typed(filter), + }) => { + assert_eq!( + mana_cost, + ManaCost::Cost { + shards: vec![ManaCostShard::Black, ManaCostShard::Black], + generic: 5, + } + ); + assert_eq!(filter.type_filters, vec![TypeFilter::Artifact]); + } + other => panic!("expected artifact-qualified Emerge, got {other:?}"), + } + } + + #[test] + fn parse_keyword_line_core_emerge_from_creature_preserves_quality() { + let (keyword, remainder) = parse_keyword_line_core("emerge from creature {3}{u}") + .expect("creature-qualified Emerge must parse"); + assert!(remainder.is_empty()); + match keyword { + Keyword::Emerge(EmergeCost { + mana_cost, + sacrifice_filter: TargetFilter::Typed(filter), + }) => { + assert_eq!( + mana_cost, + ManaCost::Cost { + shards: vec![ManaCostShard::Blue], + generic: 3, + } + ); + assert_eq!(filter.type_filters, vec![TypeFilter::Creature]); + } + other => panic!("expected creature-qualified Emerge, got {other:?}"), + } + } + + #[test] + fn parse_keyword_line_core_emerge_from_quality_requires_mana_cost() { + assert!(parse_keyword_line_core("emerge from artifact").is_none()); + } + + #[test] + fn parse_router_keyword_line_emerge_from_quality_rejects_semantic_suffix() { + assert!( + parse_router_keyword_line("Emerge from artifact {5} if you control an Island") + .is_none(), + "a semantic suffix must remain unconsumed so the strict router declines the line" + ); + } + #[test] fn ward_get_poison_counters_parses_as_player_counter_cost() { // Issue #6640 (The Serpent Society): "Ward—Get five poison counters." diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 2212d7691a..3750e04bbd 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -7598,6 +7598,30 @@ pub struct PileResult { /// /// Adding a new alternative-cost keyword (e.g., Madness CR 702.35a, Spectacle /// CR 702.137a) is a compile error at every dispatch site until handled. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum AlternativeAdditionalCostDescription { + /// CR 702.119b: The quality named by Emerge from [quality]. + EmergeSacrifice { quality: EmergeSacrificeQuality }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub enum EmergeSacrificeQuality { + Artifact, + Battle, + Card, + Creature, + Enchantment, + Instant, + Kindred, + Land, + Permanent, + Planeswalker, + Sorcery, + Subtype(String), +} + #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] #[serde(tag = "type")] pub enum AlternativeCastKeyword { @@ -7606,8 +7630,9 @@ pub enum AlternativeCastKeyword { /// CR 702.74a: ETB + sacrifice trigger fires when the resolving permanent /// was cast for its evoke cost (CR 702.74b). Evoke, - /// CR 702.119a-c: Emerge alternative cost requires sacrificing a creature - /// while casting and reduces the emerge cost by that creature's mana value. + /// CR 702.119a-c: Emerge alternative cost requires sacrificing the specified + /// permanent quality while casting and reduces the emerge cost by that + /// permanent's mana value. Emerge, /// CR 702.109a: Cast for the dash cost — the resolving permanent gains haste /// and is returned to its owner's hand at the next end step. @@ -11470,10 +11495,15 @@ pub enum WaitingFor { /// the alternative cost (e.g., `AbilityCost::Exile { count, zone, /// filter }` for the MH2 Evoke Incarnations). `None` when the /// alternative cost is pure mana (Warp, Lorwyn Evoke, Overload, - /// Bestow, mana-only Flashback). Engine owns the derived display - /// string; the frontend renders the engine-provided description. + /// Bestow, mana-only Flashback). The engine owns the typed display + /// payload; the frontend localizes and renders the descriptor. #[serde(default)] alternative_additional_cost: Option, + /// Engine-authored typed display descriptor for an alternative cost's + /// non-mana component when its semantic details affect player-facing + /// wording. The frontend localizes this descriptor. + #[serde(default)] + alternative_additional_cost_description: Option, }, /// CR 702.140c + CR 730.2a: As a mutating creature spell resolves with a /// legal target, the spell's controller chooses whether the spell is put on diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index 4e4780cb50..446d169468 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -116,6 +116,37 @@ pub enum BestowCost { NonMana(AbilityCost), } +/// CR 702.119a-b: Emerge's mana cost and the permanent quality required for +/// its sacrifice cost. Ordinary emerge sacrifices a creature; "emerge from +/// [quality]" uses the printed permanent filter instead. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EmergeCost { + pub mana_cost: ManaCost, + pub sacrifice_filter: TargetFilter, +} + +impl EmergeCost { + pub fn creature(mana_cost: ManaCost) -> Self { + Self { + mana_cost, + sacrifice_filter: TargetFilter::Typed(TypedFilter::creature()), + } + } + + pub fn from_quality(mana_cost: ManaCost, sacrifice_filter: TargetFilter) -> Self { + Self { + mana_cost, + sacrifice_filter, + } + } +} + +impl Default for EmergeCost { + fn default() -> Self { + Self::creature(ManaCost::default()) + } +} + /// CR 702.138a + CR 118.9 + CR 601.2f-h: Escape cost — an alternative cost paid /// to cast a card from the graveyard (CR 702.138a). Almost always a compound /// cost: a mana sub-cost plus "Exile N other cards from your graveyard". A few @@ -756,9 +787,10 @@ pub enum Keyword { /// `CastingVariant::Miracle` with the miracle mana cost. Miracle(ManaCost), Dash(ManaCost), - /// CR 702.119a-c: Emerge is an alternative cost paid by sacrificing a - /// creature and reducing the emerge cost by that creature's mana value. - Emerge(ManaCost), + /// CR 702.119a-b: Emerge is an alternative cost paid by sacrificing the + /// specified permanent quality and reducing the emerge cost by that + /// permanent's mana value. + Emerge(EmergeCost), /// CR 702.138a: Escape — cast from graveyard for an alternative cost. The /// compound escape cost (mana sub-cost plus one or more exile sub-costs) is /// modeled by `EscapeCost` and split at runtime by @@ -2371,7 +2403,12 @@ impl FromStr for Keyword { "madness" => return Ok(Keyword::Madness(parse_keyword_mana_cost(p))), "miracle" => return Ok(Keyword::Miracle(parse_keyword_mana_cost(p))), "dash" => return Ok(Keyword::Dash(parse_keyword_mana_cost(p))), - "emerge" => return Ok(Keyword::Emerge(parse_keyword_mana_cost(p))), + // CR 702.119a: Bare Emerge defaults to sacrificing a creature. + "emerge" => { + return Ok(Keyword::Emerge(EmergeCost::creature( + parse_keyword_mana_cost(p), + ))) + } "harmonize" => return Ok(Keyword::Harmonize(parse_keyword_mana_cost(p))), "escape" => { // CR 702.138a: MTGJSON's keywords array carries only the bare @@ -3231,7 +3268,15 @@ fn keyword_from_tagged(variant: &str, data: &serde_json::Value) -> Result Ok(Keyword::Madness(mana(data)?)), "Miracle" => Ok(Keyword::Miracle(mana(data)?)), "Dash" => Ok(Keyword::Dash(mana(data)?)), - "Emerge" => Ok(Keyword::Emerge(mana(data)?)), + // CR 702.119a: Historic bare Emerge payloads use only the mana cost, + // which implies the ordinary creature sacrifice filter. + "Emerge" => match serde_json::from_value::(data.clone()) { + Ok(cost) => Ok(Keyword::Emerge(cost)), + Err(_) => Ok(Keyword::Emerge(EmergeCost::creature(mana(data)?))), + }, + "EmergeFromQuality" => serde_json::from_value(data.clone()) + .map(Keyword::Emerge) + .map_err(|error| format!("EmergeFromQuality: {error}")), "Harmonize" => Ok(Keyword::Harmonize(mana(data)?)), // CR 702.138a: MTGJSON provides bare "Escape" with no structured cost data. // Accept both legacy ManaCost format and new EscapeCost tagged format @@ -4734,6 +4779,29 @@ mod tests { }, } ); + + let legacy_emerge: Keyword = + serde_json::from_str(r#"{"Emerge":{"type":"Cost","shards":["Blue"],"generic":3}}"#) + .expect("legacy Emerge mana payload deserializes"); + assert_eq!( + legacy_emerge, + Keyword::Emerge(EmergeCost::creature(ManaCost::Cost { + shards: vec![crate::types::mana::ManaCostShard::Blue], + generic: 3, + })) + ); + + let legacy_quality_emerge: Keyword = serde_json::from_str( + r#"{"EmergeFromQuality":{"mana_cost":{"type":"Cost","shards":[],"generic":5},"sacrifice_filter":{"type":"Typed","type_filters":["Artifact"],"controller":null,"properties":[]}}}"#, + ) + .expect("legacy EmergeFromQuality payload deserializes"); + assert_eq!( + legacy_quality_emerge, + Keyword::Emerge(EmergeCost::from_quality( + ManaCost::generic(5), + TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), + )) + ); } #[test] @@ -5153,7 +5221,10 @@ mod tests { Keyword::Madness(mc("{2}{R}")), Keyword::Miracle(mc("{2}{R}")), Keyword::Dash(mc("{2}{R}")), - Keyword::Emerge(mc("{2}{R}")), + Keyword::Emerge(EmergeCost::from_quality( + mc("{2}{R}"), + TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), + )), Keyword::Escape(EscapeCost::NonMana(pay_life_cost())), Keyword::Harmonize(mc("{2}{R}")), Keyword::Evoke(EvokeCost::NonMana(pay_life_cost())), diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index ff087760d3..d7db6bdcb3 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -1304,6 +1304,7 @@ fn alternative_cast_siblings_use_stable_typed_codes() { normal_cost: ManaCost::NoCost, alternative_cost: Some(ManaCost::NoCost), alternative_additional_cost: None, + alternative_additional_cost_description: None, }; bind(runner.state_mut(), "alternative-cast-codes"); diff --git a/crates/mtgish-import/src/convert/keyword.rs b/crates/mtgish-import/src/convert/keyword.rs index 64028bea65..5dbcf2b5e3 100644 --- a/crates/mtgish-import/src/convert/keyword.rs +++ b/crates/mtgish-import/src/convert/keyword.rs @@ -177,7 +177,10 @@ pub fn try_convert(rule: &Rule, path: &str) -> ConvResult> { "Rule::Embalm", path, )?)), - Rule::Emerge(c) => Keyword::Emerge(pure_mana(c, "Rule::Emerge", path)?), + // CR 702.119a: Bare Emerge defaults to sacrificing a creature. + Rule::Emerge(c) => Keyword::Emerge(engine::types::keywords::EmergeCost::creature( + pure_mana(c, "Rule::Emerge", path)?, + )), Rule::Encore(c) => Keyword::Encore(pure_mana(c, "Rule::Encore", path)?), Rule::Eternalize(c) => Keyword::Eternalize(engine::types::keywords::EternalizeCost::Mana( pure_mana(c, "Rule::Eternalize", path)?, diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index a525bf96e5..7445e6e550 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -1877,7 +1877,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Corruption of Towashi - Cosmic Horror - Covenant of Minds -- Crabomination - Crosis, the Purger - Cry of the Carnarium - Cunning Nightbonder