Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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 } }
Expand Down
65 changes: 60 additions & 5 deletions client/src/components/modal/AlternativeCostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";

import type {
AlternativeAdditionalCostDescription,
EmergeSacrificeQuality,
GameAction,
ManaCost,
SerializedAbilityCost,
Expand Down Expand Up @@ -35,6 +37,7 @@ interface KeywordCopy {
function keywordCopy(
keyword: Keyword,
cardName: string,
alternativeAdditionalCostDescription: AlternativeAdditionalCostDescription | null,
t: TFunction<"game">,
): KeywordCopy {
switch (keyword) {
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
/>
);
Expand All @@ -254,13 +305,15 @@ function AlternativeCostContent({
normalCost,
alternativeCost,
alternativeAdditionalCost,
alternativeAdditionalCostDescription,
dispatch,
}: {
objectId: number;
keyword: Keyword;
normalCost: ManaCost;
alternativeCost: ManaCost | null;
alternativeAdditionalCost: SerializedAbilityCost | null;
alternativeAdditionalCostDescription: AlternativeAdditionalCostDescription | null;
dispatch: (action: GameAction) => Promise<unknown>;
}) {
const { t } = useTranslation("game");
Expand All @@ -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 (
<DialogShell
Expand Down Expand Up @@ -315,7 +368,9 @@ function AlternativeCostContent({
)}
{alternativeAdditionalCost && (
<span className="ml-2 text-xs text-slate-300">
{describeAdditionalCost(alternativeAdditionalCost, t)}
{alternativeAdditionalCostDescription
? describeAdditionalCostDescription(alternativeAdditionalCostDescription, t)
: describeAdditionalCost(alternativeAdditionalCost, t)}
</span>
)}
{copy.altSuffix && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
},
};

Expand All @@ -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"`
Expand Down Expand Up @@ -121,4 +129,27 @@ describe("AlternativeCostModal", () => {
).toBeInTheDocument();
},
);

it("renders Emerge's engine-provided sacrifice description", () => {
setSpectacleChoice("Emerge", {
type: "EmergeSacrifice",
quality: { type: "Artifact" },
});
render(<AlternativeCostModal />);

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(<AlternativeCostModal />);

await waitFor(() => {
expect(screen.getByText(/sacrificando un artefacto/i)).toBeInTheDocument();
});
});
});
17 changes: 16 additions & 1 deletion client/src/i18n/locales/de/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion client/src/i18n/locales/en/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion client/src/i18n/locales/es/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion client/src/i18n/locales/fr/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion client/src/i18n/locales/it/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion client/src/i18n/locales/pl/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading