- {i + 1}
+
+ {i + 1}
+
{entry.display_name}
{entry.match_wins}-{entry.match_losses}
diff --git a/client/src/components/menu/AiDifficultyDropdown.tsx b/client/src/components/menu/AiDifficultyDropdown.tsx
index 304e54945c..39eda43f95 100644
--- a/client/src/components/menu/AiDifficultyDropdown.tsx
+++ b/client/src/components/menu/AiDifficultyDropdown.tsx
@@ -1,10 +1,6 @@
import { useTranslation } from "react-i18next";
-import {
- AI_DIFFICULTIES,
- getAiDifficultyLabel,
- type AIDifficulty,
-} from "../../constants/ai";
+import { AI_DIFFICULTIES, type AIDifficulty } from "../../constants/ai";
interface AiDifficultyDropdownProps {
difficulty: AIDifficulty;
@@ -29,7 +25,9 @@ export function AiDifficultyDropdown({
event.stopPropagation()}
onChange={(event) => onChange(event.target.value as AIDifficulty)}
@@ -41,7 +39,7 @@ export function AiDifficultyDropdown({
>
{AI_DIFFICULTIES.map((item) => (
- {item.label}
+ {t(`aiDifficulty.levels.${item.id}`)}
))}
diff --git a/client/src/components/menu/AiOpponentConfig.tsx b/client/src/components/menu/AiOpponentConfig.tsx
index e731612a53..b11ec8a031 100644
--- a/client/src/components/menu/AiOpponentConfig.tsx
+++ b/client/src/components/menu/AiOpponentConfig.tsx
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { GameFormat, MatchType } from "../../adapter/types";
-import { AI_DIFFICULTIES, getAiDifficultyLabel, type AIDifficulty } from "../../constants/ai";
+import { AI_DIFFICULTIES, type AIDifficulty } from "../../constants/ai";
import type { AiDeckCandidate } from "../../services/aiDeckCatalog";
import { useAiDeckCatalog } from "../../services/aiDeckCatalog";
import {
@@ -260,7 +260,7 @@ function AiSeatPanel({
const summaryDeck = isRandom
? t("aiOpponent.deckRandomCount", { count: filteredDecks.length })
: (selectedCandidate?.name ?? t("aiOpponent.deckRandom"));
- const summaryDifficulty = getAiDifficultyLabel(seat.difficulty);
+ const summaryDifficulty = t(`aiDifficulty.levels.${seat.difficulty}`);
const body = (
@@ -295,7 +295,7 @@ function AiSeatPanel({
>
{AI_DIFFICULTIES.map((item) => (
- {item.label}
+ {t(`aiDifficulty.levels.${item.id}`)}
))}
diff --git a/client/src/components/menu/LoadGameStateModal.tsx b/client/src/components/menu/LoadGameStateModal.tsx
index 9aa0a7e0ce..c9201d9025 100644
--- a/client/src/components/menu/LoadGameStateModal.tsx
+++ b/client/src/components/menu/LoadGameStateModal.tsx
@@ -58,7 +58,7 @@ export function LoadGameStateModal({ open, onClose, onLoaded }: LoadGameStateMod
try {
handleParsed(await readImportFile(file));
} catch (err: unknown) {
- setError(err instanceof Error ? err.message : "Failed to read file");
+ setError(err instanceof Error ? err.message : t("loadGameState.readFailed"));
}
};
diff --git a/client/src/components/menu/MyDecks.tsx b/client/src/components/menu/MyDecks.tsx
index dfc9bb7ccd..8a142be61a 100644
--- a/client/src/components/menu/MyDecks.tsx
+++ b/client/src/components/menu/MyDecks.tsx
@@ -1093,7 +1093,7 @@ export function MyDecks({
>
{FORMAT_FILTERS.map(({ key, label }) => (
- {label}
+ {key === "all" ? t("myDecks.filterAll") : label}
))}
diff --git a/client/src/components/modal/CardChoiceModal.tsx b/client/src/components/modal/CardChoiceModal.tsx
index 423aee2548..049b51fde0 100644
--- a/client/src/components/modal/CardChoiceModal.tsx
+++ b/client/src/components/modal/CardChoiceModal.tsx
@@ -1069,7 +1069,9 @@ function OutsideGameModal({ data }: { data: OutsideGameChoice["data"] }) {
const selectedCount = Math.min(selectedCounts.get(key) ?? 0, entry.count);
const isSelected = selectedCount > 0;
const sourceLabel =
- entry.source.type === "FaceUpExile" ? "From exile" : "From sideboard";
+ entry.source.type === "FaceUpExile"
+ ? t("outsideGame.fromExile")
+ : t("outsideGame.fromSideboard");
return (
;
type ChooseObjectsSelection = Extract<
@@ -44,6 +45,7 @@ export function ProliferateModal({
const { t } = useTranslation("game");
const dispatch = useGameDispatch();
const objects = useGameStore((s) => s.gameState?.objects);
+ const playerId = usePlayerId();
const [selected, setSelected] = useState(data.eligible);
@@ -72,6 +74,31 @@ export function ProliferateModal({
subtitle={t(`proliferate.${VARIANT_KEYS[variant].subtitle}`)}
footer={ }
>
+ {data.eligible.length > 1 && (
+
+ setSelected(data.eligible)}
+ className={gameButtonClass({ tone: "neutral", size: "xs" })}
+ >
+ {t("proliferate.selectAll")}
+
+ setSelected([])}
+ className={gameButtonClass({ tone: "neutral", size: "xs" })}
+ >
+ {t("proliferate.selectNone")}
+
+ setSelected(filterTargetsByController(data.eligible, objects, playerId))}
+ className={gameButtonClass({ tone: "neutral", size: "xs" })}
+ >
+ {t("proliferate.selectMine")}
+
+
+ )}
{data.eligible.map((target) => {
const key = targetKey(target);
diff --git a/client/src/components/modal/targetRef.ts b/client/src/components/modal/targetRef.ts
index 6264d2db3e..a7b9d2762e 100644
--- a/client/src/components/modal/targetRef.ts
+++ b/client/src/components/modal/targetRef.ts
@@ -1,4 +1,4 @@
-import type { GameObject, TargetRef } from "../../adapter/types.ts";
+import type { GameObject, PlayerId, TargetRef } from "../../adapter/types.ts";
import { getPlayerDisplayName } from "../../stores/multiplayerStore.ts";
export function targetLabel(
@@ -15,3 +15,18 @@ export function targetKey(target: TargetRef): string {
if ("Object" in target) return `obj-${target.Object}`;
return `player-${target.Player}`;
}
+
+/** Filters targets down to those on a player's own side: permanents they control
+ * plus their own player target. Used by chooser quick-selects so a player can
+ * pick "my side" in one click instead of deselecting every opponent target. */
+export function filterTargetsByController(
+ targets: TargetRef[],
+ objects: Record
| undefined,
+ playerId: PlayerId,
+): TargetRef[] {
+ return targets.filter((target) =>
+ "Object" in target
+ ? objects?.[String(target.Object)]?.controller === playerId
+ : target.Player === playerId,
+ );
+}
diff --git a/client/src/components/settings/PreferencesModal.tsx b/client/src/components/settings/PreferencesModal.tsx
index 15368f16f2..3d962dd3e5 100644
--- a/client/src/components/settings/PreferencesModal.tsx
+++ b/client/src/components/settings/PreferencesModal.tsx
@@ -15,8 +15,6 @@ import {
ANIMATION_SPEED_STEP,
PACING_CATEGORIES,
PACING_DEFAULT,
- PACING_DESCRIPTIONS,
- PACING_LABELS,
PACING_MAX,
PACING_MIN,
PACING_STEP,
@@ -52,6 +50,7 @@ const LANGUAGE_OPTIONS: { value: SupportedLng; label: string }[] = [
{ value: "de", label: "Deutsch" },
{ value: "it", label: "Italiano" },
{ value: "pt", label: "Português" },
+ { value: "pl", label: "Polski" },
];
const CARD_SIZES: CardSizePreference[] = ["small", "medium", "large"];
@@ -286,6 +285,7 @@ export function PreferencesModal({
options={CARD_SIZES}
value={cardSize}
onChange={setCardSize}
+ renderLabel={(opt) => t(`gameplay.cardSizeOptions.${opt}`)}
/>
@@ -294,6 +294,7 @@ export function PreferencesModal({
options={LOG_DEFAULTS}
value={logDefaultState}
onChange={setLogDefaultState}
+ renderLabel={(opt) => t(`gameplay.logDefaultOptions.${opt}`)}
/>
@@ -354,6 +355,7 @@ export function PreferencesModal({
options={VFX_QUALITIES}
value={vfxQuality}
onChange={setVfxQuality}
+ renderLabel={(opt) => t(`visual.vfxQualityOptions.${opt}`)}
/>
@@ -874,8 +876,8 @@ function PacingSection({
{PACING_CATEGORIES.map((category) => (
({
options,
value,
onChange,
+ renderLabel,
}: {
options: T[];
value: T;
onChange: (v: T) => void;
+ /** Maps a raw option value to its translated, display-ready label. */
+ renderLabel: (opt: T) => string;
}) {
return (
@@ -1109,13 +1114,13 @@ function SegmentedControl({
onChange(opt)}
- className={`min-h-9 flex-1 rounded-[12px] px-3 py-2 text-xs font-semibold capitalize transition-colors ${
+ className={`min-h-9 flex-1 rounded-[12px] px-3 py-2 text-xs font-semibold transition-colors ${
value === opt
? "bg-sky-500/80 text-white"
: "text-slate-400 hover:text-slate-200"
}`}
>
- {opt}
+ {renderLabel(opt)}
))}
diff --git a/client/src/components/ui/LanguageFlag.tsx b/client/src/components/ui/LanguageFlag.tsx
index 2376f883e5..098525efd3 100644
--- a/client/src/components/ui/LanguageFlag.tsx
+++ b/client/src/components/ui/LanguageFlag.tsx
@@ -92,6 +92,16 @@ function FlagPT({ className }: { className?: string }) {
);
}
+function FlagPL({ className }: { className?: string }) {
+ // White over red, horizontal halves.
+ return (
+
+
+
+
+ );
+}
+
export function LanguageFlag({ lng, className }: { lng: SupportedLng; className?: string }) {
// Exhaustive over SupportedLng — a new language without a flag is a compile error.
switch (lng) {
@@ -107,5 +117,7 @@ export function LanguageFlag({ lng, className }: { lng: SupportedLng; className?
return ;
case "pt":
return ;
+ case "pl":
+ return ;
}
}
diff --git a/client/src/constants/ai.ts b/client/src/constants/ai.ts
index d9d9ee7119..29d4a1b6ef 100644
--- a/client/src/constants/ai.ts
+++ b/client/src/constants/ai.ts
@@ -1,15 +1,13 @@
+// `id` is the engine difficulty enum; display labels are translated at render
+// via `t("aiDifficulty.levels.")` (menu namespace) — not stored here.
export const AI_DIFFICULTIES = [
- { id: "VeryEasy", label: "Very Easy", shortLabel: "Very Easy" },
- { id: "Easy", label: "Easy", shortLabel: "Easy" },
- { id: "Medium", label: "Medium", shortLabel: "Medium" },
- { id: "Hard", label: "Hard", shortLabel: "Hard" },
- { id: "VeryHard", label: "Very Hard", shortLabel: "Very Hard" },
+ { id: "VeryEasy" },
+ { id: "Easy" },
+ { id: "Medium" },
+ { id: "Hard" },
+ { id: "VeryHard" },
] as const;
export type AIDifficulty = (typeof AI_DIFFICULTIES)[number]["id"];
export const DEFAULT_AI_DIFFICULTY: AIDifficulty = "Medium";
-
-export function getAiDifficultyLabel(difficulty: string): string {
- return AI_DIFFICULTIES.find((item) => item.id === difficulty)?.label ?? difficulty;
-}
diff --git a/client/src/hooks/useCardImage.ts b/client/src/hooks/useCardImage.ts
index 1656a9e3d5..44e0ccd62e 100644
--- a/client/src/hooks/useCardImage.ts
+++ b/client/src/hooks/useCardImage.ts
@@ -7,6 +7,7 @@ import {
fetchTokenImageUrl,
findPrintingById,
getCardPrintings,
+ isCardImageFlipLayoutSync,
isCardImageRotatedSync,
resolveFaceIndexSync,
resolveOracleIdSync,
@@ -53,6 +54,9 @@ interface UseCardImageResult {
src: string | null;
isLoading: boolean;
isRotated: boolean;
+ /** True for Kamigawa-style flip cards (`layout: "flip"`), whose alternate half
+ * is the same image rotated 180°. The preview uses this to enable Ctrl-spin. */
+ isFlip: boolean;
}
interface MemoryCacheEntry {
@@ -314,6 +318,7 @@ export function useCardImage(
const [src, setSrc] = useState(null);
const [isRotated, setIsRotated] = useState(false);
+ const [isFlip, setIsFlip] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [, setArtCacheTick] = useState(0);
@@ -382,6 +387,7 @@ export function useCardImage(
if (overrideUrl) {
setSrc(overrideUrl);
setIsRotated(isCardImageRotatedSync(resolvedOracleId, cardName));
+ setIsFlip(isCardImageFlipLayoutSync(resolvedOracleId, cardName));
setIsLoading(false);
return;
}
@@ -389,6 +395,7 @@ export function useCardImage(
if (!cardName && !oracleId) {
setSrc(null);
setIsRotated(false);
+ setIsFlip(false);
setIsLoading(false);
return;
}
@@ -418,11 +425,13 @@ export function useCardImage(
if (!cancelled) {
setSrc(imageAsset?.src || null);
setIsRotated(imageAsset?.isRotated ?? false);
+ setIsFlip(isCardImageFlipLayoutSync(resolvedOracleId, cardName));
setIsLoading(false);
}
} catch {
if (!cancelled) {
setIsRotated(false);
+ setIsFlip(false);
setIsLoading(false);
}
}
@@ -453,5 +462,5 @@ export function useCardImage(
size,
]);
- return { src, isLoading, isRotated };
+ return { src, isLoading, isRotated, isFlip };
}
diff --git a/client/src/hooks/usePhaseInfo.ts b/client/src/hooks/usePhaseInfo.ts
index 34eb6fe77a..34d571226d 100644
--- a/client/src/hooks/usePhaseInfo.ts
+++ b/client/src/hooks/usePhaseInfo.ts
@@ -1,3 +1,5 @@
+import { useTranslation } from "react-i18next";
+
import type { Phase } from "../adapter/types.ts";
import { useGameStore } from "../stores/gameStore.ts";
import { usePlayerId } from "./usePlayerId.ts";
@@ -74,29 +76,25 @@ const COMBAT_PHASES = new Set([
"EndCombat",
]);
-const NEXT_PHASE_LABELS: Partial> = {
+// Maps the current phase to the next one the "advance" button moves to. The
+// display name is translated via `phaseName.`; this stays an enum map so
+// the label routes through i18n rather than carrying hardcoded English.
+const NEXT_PHASE: Partial> = {
Untap: "Upkeep",
Upkeep: "Draw",
- Draw: "Main Phase 1",
- PreCombatMain: "Begin Combat",
- BeginCombat: "Declare Attackers",
- DeclareAttackers: "Declare Blockers",
- DeclareBlockers: "Combat Damage",
- CombatDamage: "End Combat",
- EndCombat: "Main Phase 2",
- PostCombatMain: "End Step",
+ Draw: "PreCombatMain",
+ PreCombatMain: "BeginCombat",
+ BeginCombat: "DeclareAttackers",
+ DeclareAttackers: "DeclareBlockers",
+ DeclareBlockers: "CombatDamage",
+ CombatDamage: "EndCombat",
+ EndCombat: "PostCombatMain",
+ PostCombatMain: "End",
End: "Cleanup",
};
-function getAdvanceLabel(phase: Phase, hasStackItems: boolean, isMyTurn: boolean): string {
- if (hasStackItems) return "Resolve";
- if (!isMyTurn) return "Pass Priority";
-
- const nextPhaseLabel = NEXT_PHASE_LABELS[phase];
- return nextPhaseLabel ? `To ${nextPhaseLabel}` : "Pass Priority";
-}
-
export function usePhaseInfo(): PhaseInfo {
+ const { t } = useTranslation("game");
const phase = useGameStore((s) => s.gameState?.phase ?? "Untap");
const stackLength = useGameStore((s) => s.gameState?.stack.length ?? 0);
const activePlayer = useGameStore((s) => s.gameState?.active_player ?? 0);
@@ -106,8 +104,18 @@ export function usePhaseInfo(): PhaseInfo {
const displayKey = PHASE_TO_DISPLAY[phase];
const currentOrder = DISPLAY_ORDER[displayKey];
const isCombatPhase = COMBAT_PHASES.has(phase);
- const advanceLabel = getAdvanceLabel(phase, stackLength > 0, isMyTurn);
- const nextPhaseLabel = NEXT_PHASE_LABELS[phase] ?? null;
+
+ const nextPhase = NEXT_PHASE[phase];
+ const nextPhaseLabel = nextPhase ? t(`phaseName.${nextPhase}`) : null;
+
+ let advanceLabel: string;
+ if (stackLength > 0) {
+ advanceLabel = t("advance.resolve");
+ } else if (!isMyTurn || !nextPhaseLabel) {
+ advanceLabel = t("advance.passPriority");
+ } else {
+ advanceLabel = t("advance.toPhase", { phase: nextPhaseLabel });
+ }
return {
displayKey,
diff --git a/client/src/i18n/README.md b/client/src/i18n/README.md
index c89fc1f036..318be9042b 100644
--- a/client/src/i18n/README.md
+++ b/client/src/i18n/README.md
@@ -57,3 +57,9 @@ Namespaces: `common` (default), `menu`, `game`, `deck-builder`, `draft`,
use `usePreferencesStore.getState().setLanguage(lng)`.
- English (`en`) is the typing oracle: add a key to `en/.json` **before**
referencing it, or it won't type-check. Other locales fall back to English.
+- Encoding: catalogs are **UTF-8 with literal accented characters** (write
+ `"Wähle"` directly — never `\u`-escape sequences) so translations stay
+ human-readable in diffs. Save files as UTF-8 with no BOM.
+- Every other locale must carry the **exact same keys** as `en` — no missing
+ translations, no orphans. `resources.test.ts` enforces both key parity and
+ UTF-8 encoding across all catalogs (runs in CI + Tilt `test-frontend`).
diff --git a/client/src/i18n/locales/de/common.json b/client/src/i18n/locales/de/common.json
index f54727bd6a..408cd90b20 100644
--- a/client/src/i18n/locales/de/common.json
+++ b/client/src/i18n/locales/de/common.json
@@ -198,5 +198,11 @@
},
"turnBanner": {
"turn": "Zug {{number}}"
+ },
+ "chrome": {
+ "back": "Zurück",
+ "settings": "Einstellungen",
+ "languageSettings": "Sprache ({{lang}}) — Einstellungen öffnen",
+ "languageTitle": "Sprache: {{lang}}"
}
}
diff --git a/client/src/i18n/locales/de/draft.json b/client/src/i18n/locales/de/draft.json
index 10897ab61b..269eb69f97 100644
--- a/client/src/i18n/locales/de/draft.json
+++ b/client/src/i18n/locales/de/draft.json
@@ -39,7 +39,9 @@
"overrideResult": "Ergebnis überschreiben",
"versusPair": "{{a}} gegen {{b}}",
"kickReplace": "Rauswerfen + Ersetzen",
- "replaceWithBot": "{{name}} durch Bot ersetzen"
+ "replaceWithBot": "{{name}} durch Bot ersetzen",
+ "endDraft": "Draft beenden",
+ "endDraftConfirm": "Diesen Draft fuer alle beenden? Das kann nicht rueckgaengig gemacht werden."
},
"manaCurve": {
"title": "Manakurve"
@@ -60,7 +62,8 @@
"botDifficulty": "Bot-Schwierigkeit",
"chooseSet": "Wähle ein Set",
"noPools": "Keine Draft-Pools verfügbar. Führe zuerst die Draft-Datenpipeline aus.",
- "setIconAlt": "{{name}} Set-Symbol"
+ "setIconAlt": "{{name}} Set-Symbol",
+ "loadFailed": "Sets konnten nicht geladen werden"
},
"pack": {
"confirmPick": "Pick bestätigen",
diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json
index 0e630c2558..a8f19634ce 100644
--- a/client/src/i18n/locales/de/game.json
+++ b/client/src/i18n/locales/de/game.json
@@ -8,6 +8,8 @@
"preview": {
"holdCtrlBack": "Strg halten für Rückseite",
"holdCtrlFront": "Strg halten für Vorderseite",
+ "holdCtrlFlip": "Strg halten zum Umdrehen",
+ "flip": "Umdrehen",
"altParsedAbilities": "Alt: eingelesene Fähigkeiten",
"debugId": "ID: {{id}}",
"engineParse": "Engine-Parse",
@@ -307,7 +309,9 @@
"statHand": "Hand",
"statCreatures": "Kreaturen",
"statLands": "Länder",
- "statOther": "Sonstige"
+ "statOther": "Sonstige",
+ "compactHud": "Kompaktes Gegner-HUD. Reduziert jeden Gegner auf eine einzelne schmale Zeile (Name + Leben); tippe auf einen Gegner, um sein gesamtes Spielfeld anzuzeigen.",
+ "expandHud": "Gegner-HUD erweitern. Zeigt die Handkarten und die Spielfeldaufschlüsselung jedes Gegners."
},
"combat": {
"attackAll": "Alle angreifen",
@@ -380,7 +384,9 @@
"nounTarget": "Ziel",
"confirmTap": "Tappen bestätigen ({{selected}}/{{count}})",
"keepCurrentTargets": "Aktuelle Ziele behalten",
- "skip": "Überspringen"
+ "skip": "Überspringen",
+ "chooseNewTarget": "Wähle das neue Ziel des Zauberspruchs",
+ "chooseNewTargetForSpell": "Wähle ein neues Ziel für {{spell}}"
},
"log": {
"title": "Spielprotokoll",
@@ -1247,6 +1253,9 @@
"proliferateSubtitle": "Wähle eine beliebige Anzahl bleibender Karten und Spieler mit Marken. Jedes gewählte Ziel erhält eine weitere Marke jeder Art, die bereits dort vorhanden ist.",
"chooseObjectsTitle": "Bleibende Karten wählen",
"chooseObjectsSubtitle": "Wähle eine beliebige Anzahl bleibender Karten. Du zahlst für jede gewählte Karte Kosten.",
+ "selectAll": "Alle",
+ "selectNone": "Keine",
+ "selectMine": "Meine Seite",
"confirm": "Bestätigen"
},
"replacement": {
@@ -1260,7 +1269,8 @@
"scopeSingle": "Wähle ein neues Ziel für den Zauberspruch",
"scopeMulti": "Wähle neue Ziele für den Zauberspruch",
"subtitle": "{{scope}}. Aktuell: {{current}}",
- "confirm": "Bestätigen"
+ "confirm": "Bestätigen",
+ "badgeNewTarget": "Neues Ziel"
},
"separatePiles": {
"chooserName": "Spieler {{number}}",
@@ -1321,5 +1331,27 @@
"subtitleLabel": "Wähle eine Kennzeichnung für {{name}}",
"subtitleVoteRemaining": "Gib eine Stimme ab ({{count}} verbleibend)",
"subtitleVote": "Gib deine Stimme ab"
+ },
+ "advance": {
+ "resolve": "Auflösen",
+ "passPriority": "Priorität abgeben",
+ "toPhase": "Zu {{phase}}"
+ },
+ "outsideGame": {
+ "fromExile": "Aus dem Exil",
+ "fromSideboard": "Aus dem Sideboard"
+ },
+ "phaseName": {
+ "Upkeep": "Versorgung",
+ "Draw": "Ziehen",
+ "PreCombatMain": "Hauptphase 1",
+ "BeginCombat": "Kampfbeginn",
+ "DeclareAttackers": "Angreifer deklarieren",
+ "DeclareBlockers": "Blocker deklarieren",
+ "CombatDamage": "Kampfschaden",
+ "EndCombat": "Kampfende",
+ "PostCombatMain": "Hauptphase 2",
+ "End": "Endschritt",
+ "Cleanup": "Aufräumen"
}
}
diff --git a/client/src/i18n/locales/de/menu.json b/client/src/i18n/locales/de/menu.json
index 57fa25a17d..34f7158ddd 100644
--- a/client/src/i18n/locales/de/menu.json
+++ b/client/src/i18n/locales/de/menu.json
@@ -4,7 +4,14 @@
},
"aiDifficulty": {
"label": "KI-Schwierigkeit",
- "ariaLabel": "KI-Schwierigkeit: {{difficulty}}"
+ "ariaLabel": "KI-Schwierigkeit: {{difficulty}}",
+ "levels": {
+ "VeryEasy": "Sehr leicht",
+ "Easy": "Leicht",
+ "Medium": "Mittel",
+ "Hard": "Schwer",
+ "VeryHard": "Sehr schwer"
+ }
},
"bracketFilter": {
"label": "Bracket-Filter"
@@ -205,7 +212,8 @@
"browseAll": "Alle durchsuchen",
"loadMore": "Mehr laden",
"selectedDeck": "Ausgewähltes Deck",
- "chooseDeckToContinue": "Wähle ein Deck, um fortzufahren"
+ "chooseDeckToContinue": "Wähle ein Deck, um fortzufahren",
+ "filterAll": "Alle"
},
"subscriptions": {
"emptyTitle": "Keine Feed-Abonnements",
@@ -213,5 +221,13 @@
"feedMeta_one": "· {{count}} Deck · Aktualisiert {{date}}",
"feedMeta_other": "· {{count}} Decks · Aktualisiert {{date}}",
"error": "Fehler: {{error}}"
+ },
+ "myDecksPage": {
+ "eyebrow": "Decks",
+ "title": "Decks.",
+ "description": "Öffne eine gespeicherte Liste, importiere eine neue oder mach im Deck-Builder weiter."
+ },
+ "loadGameState": {
+ "readFailed": "Datei konnte nicht gelesen werden"
}
}
diff --git a/client/src/i18n/locales/de/settings.json b/client/src/i18n/locales/de/settings.json
index 0264415295..32f9e228fc 100644
--- a/client/src/i18n/locales/de/settings.json
+++ b/client/src/i18n/locales/de/settings.json
@@ -32,6 +32,15 @@
"random": "Zufällig",
"customUrl": "Eigene URL",
"none": "Keiner"
+ },
+ "cardSizeOptions": {
+ "small": "Klein",
+ "medium": "Mittel",
+ "large": "Groß"
+ },
+ "logDefaultOptions": {
+ "open": "Offen",
+ "closed": "Geschlossen"
}
},
"visual": {
@@ -45,7 +54,12 @@
"clearArtOverrides_one": "Alle Artwork-Überschreibungen aufheben ({{count}})",
"clearArtOverrides_other": "Alle Artwork-Überschreibungen aufheben ({{count}})",
"clearArtOverridesConfirm_one": "Alle {{count}} Artwork-Überschreibung aufheben?",
- "clearArtOverridesConfirm_other": "Alle {{count}} Artwork-Überschreibungen aufheben?"
+ "clearArtOverridesConfirm_other": "Alle {{count}} Artwork-Überschreibungen aufheben?",
+ "vfxQualityOptions": {
+ "full": "Voll",
+ "reduced": "Reduziert",
+ "minimal": "Minimal"
+ }
},
"audio": {
"title": "Audio",
@@ -106,7 +120,17 @@
"slowest": "Am langsamsten",
"resetSliderLabel": "{{label}} auf Standard zurücksetzen",
"atDefault": "Auf Standard",
- "resetToDefault": "Auf Standard zurücksetzen"
+ "resetToDefault": "Auf Standard zurücksetzen",
+ "labels": {
+ "effects": "Effekt-Tempo",
+ "combat": "Kampf-Tempo",
+ "banners": "Banner-Tempo"
+ },
+ "descriptions": {
+ "effects": "Zaubersprüche, Zonenwechsel, Tode, Lebenspunkteänderungen, Marken, Tappen/Enttappen.",
+ "combat": "Zeitpunkt des Kampfschadens — wie lange Blocker und Angreifer verweilen, bevor der Schaden abgehandelt wird.",
+ "banners": "Anzeige des Banners zu Zugbeginn."
+ }
},
"artChain": {
"emptyState": "Standard-Scryfall-Artwork wird verwendet. Füge unten Regeln hinzu, um anzupassen.",
diff --git a/client/src/i18n/locales/en/common.json b/client/src/i18n/locales/en/common.json
index e4409db870..3e513cd883 100644
--- a/client/src/i18n/locales/en/common.json
+++ b/client/src/i18n/locales/en/common.json
@@ -198,5 +198,11 @@
},
"turnBanner": {
"turn": "Turn {{number}}"
+ },
+ "chrome": {
+ "back": "Back",
+ "settings": "Settings",
+ "languageSettings": "Language ({{lang}}) — open settings",
+ "languageTitle": "Language: {{lang}}"
}
}
diff --git a/client/src/i18n/locales/en/draft.json b/client/src/i18n/locales/en/draft.json
index d6f5f45bd9..3dc186512c 100644
--- a/client/src/i18n/locales/en/draft.json
+++ b/client/src/i18n/locales/en/draft.json
@@ -39,7 +39,9 @@
"overrideResult": "Override Result",
"versusPair": "{{a}} v {{b}}",
"kickReplace": "Kick + Replace",
- "replaceWithBot": "Replace {{name}} with Bot"
+ "replaceWithBot": "Replace {{name}} with Bot",
+ "endDraft": "End Draft",
+ "endDraftConfirm": "End this draft for everyone? This cannot be undone."
},
"manaCurve": {
"title": "Mana Curve"
@@ -60,7 +62,8 @@
"botDifficulty": "Bot Difficulty",
"chooseSet": "Choose a Set",
"noPools": "No draft pools available. Run the draft data pipeline first.",
- "setIconAlt": "{{name}} set icon"
+ "setIconAlt": "{{name}} set icon",
+ "loadFailed": "Failed to load sets"
},
"pack": {
"confirmPick": "Confirm Pick",
diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json
index 8608bf6c17..5db3918a59 100644
--- a/client/src/i18n/locales/en/game.json
+++ b/client/src/i18n/locales/en/game.json
@@ -8,6 +8,8 @@
"preview": {
"holdCtrlBack": "Hold Ctrl for back face",
"holdCtrlFront": "Hold Ctrl for front face",
+ "holdCtrlFlip": "Hold Ctrl to flip",
+ "flip": "Flip",
"altParsedAbilities": "Alt: parsed abilities",
"debugId": "ID: {{id}}",
"engineParse": "Engine Parse",
@@ -1251,6 +1253,9 @@
"proliferateSubtitle": "Choose any number of permanents and players with counters. Each chosen target gets one more counter of each kind already there.",
"chooseObjectsTitle": "Choose Permanents",
"chooseObjectsSubtitle": "Choose any number of permanents. You pay a cost for each one chosen.",
+ "selectAll": "All",
+ "selectNone": "None",
+ "selectMine": "My side",
"confirm": "Confirm"
},
"replacement": {
@@ -1326,5 +1331,27 @@
"subtitleLabel": "Choose a label for {{name}}",
"subtitleVoteRemaining": "Cast a vote ({{count}} remaining)",
"subtitleVote": "Cast your vote"
+ },
+ "advance": {
+ "resolve": "Resolve",
+ "passPriority": "Pass Priority",
+ "toPhase": "To {{phase}}"
+ },
+ "outsideGame": {
+ "fromExile": "From exile",
+ "fromSideboard": "From sideboard"
+ },
+ "phaseName": {
+ "Upkeep": "Upkeep",
+ "Draw": "Draw",
+ "PreCombatMain": "Main Phase 1",
+ "BeginCombat": "Begin Combat",
+ "DeclareAttackers": "Declare Attackers",
+ "DeclareBlockers": "Declare Blockers",
+ "CombatDamage": "Combat Damage",
+ "EndCombat": "End Combat",
+ "PostCombatMain": "Main Phase 2",
+ "End": "End Step",
+ "Cleanup": "Cleanup"
}
}
diff --git a/client/src/i18n/locales/en/menu.json b/client/src/i18n/locales/en/menu.json
index 4d8f92044d..19efcc2140 100644
--- a/client/src/i18n/locales/en/menu.json
+++ b/client/src/i18n/locales/en/menu.json
@@ -4,7 +4,14 @@
},
"aiDifficulty": {
"label": "AI difficulty",
- "ariaLabel": "AI difficulty: {{difficulty}}"
+ "ariaLabel": "AI difficulty: {{difficulty}}",
+ "levels": {
+ "VeryEasy": "Very Easy",
+ "Easy": "Easy",
+ "Medium": "Medium",
+ "Hard": "Hard",
+ "VeryHard": "Very Hard"
+ }
},
"bracketFilter": {
"label": "Bracket filter"
@@ -205,7 +212,8 @@
"browseAll": "Browse All",
"loadMore": "Load More",
"selectedDeck": "Selected deck",
- "chooseDeckToContinue": "Choose a deck to continue"
+ "chooseDeckToContinue": "Choose a deck to continue",
+ "filterAll": "All"
},
"subscriptions": {
"emptyTitle": "No feed subscriptions",
@@ -213,5 +221,13 @@
"feedMeta_one": "· {{count}} deck · Updated {{date}}",
"feedMeta_other": "· {{count}} decks · Updated {{date}}",
"error": "Error: {{error}}"
+ },
+ "myDecksPage": {
+ "eyebrow": "Decks",
+ "title": "Decks.",
+ "description": "Open a saved list, import a new one, or continue in deck builder."
+ },
+ "loadGameState": {
+ "readFailed": "Failed to read file"
}
}
diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json
index d6daab5b0d..1be4806a2c 100644
--- a/client/src/i18n/locales/en/settings.json
+++ b/client/src/i18n/locales/en/settings.json
@@ -32,6 +32,15 @@
"random": "Random",
"customUrl": "Custom URL",
"none": "None"
+ },
+ "cardSizeOptions": {
+ "small": "Small",
+ "medium": "Medium",
+ "large": "Large"
+ },
+ "logDefaultOptions": {
+ "open": "Open",
+ "closed": "Closed"
}
},
"visual": {
@@ -45,7 +54,12 @@
"clearArtOverrides_one": "Clear All Art Overrides ({{count}})",
"clearArtOverrides_other": "Clear All Art Overrides ({{count}})",
"clearArtOverridesConfirm_one": "Clear all {{count}} art override?",
- "clearArtOverridesConfirm_other": "Clear all {{count}} art overrides?"
+ "clearArtOverridesConfirm_other": "Clear all {{count}} art overrides?",
+ "vfxQualityOptions": {
+ "full": "Full",
+ "reduced": "Reduced",
+ "minimal": "Minimal"
+ }
},
"audio": {
"title": "Audio",
@@ -106,7 +120,17 @@
"slowest": "Slowest",
"resetSliderLabel": "Reset {{label}} to default",
"atDefault": "At default",
- "resetToDefault": "Reset to default"
+ "resetToDefault": "Reset to default",
+ "labels": {
+ "effects": "Effect Pacing",
+ "combat": "Combat Pacing",
+ "banners": "Banner Pacing"
+ },
+ "descriptions": {
+ "effects": "Spell casts, zone changes, deaths, life changes, counters, tap/untap.",
+ "combat": "Combat damage timing — how long blockers and attackers linger before damage resolves.",
+ "banners": "Turn-start banner display."
+ }
},
"artChain": {
"emptyState": "Using default Scryfall art. Add rules below to customize.",
diff --git a/client/src/i18n/locales/es/common.json b/client/src/i18n/locales/es/common.json
index 7a818962f7..3476212a86 100644
--- a/client/src/i18n/locales/es/common.json
+++ b/client/src/i18n/locales/es/common.json
@@ -198,5 +198,11 @@
},
"turnBanner": {
"turn": "Turno {{number}}"
+ },
+ "chrome": {
+ "back": "Atrás",
+ "settings": "Ajustes",
+ "languageSettings": "Idioma ({{lang}}) — abrir ajustes",
+ "languageTitle": "Idioma: {{lang}}"
}
}
diff --git a/client/src/i18n/locales/es/draft.json b/client/src/i18n/locales/es/draft.json
index 76ff2003ec..4a43163a6c 100644
--- a/client/src/i18n/locales/es/draft.json
+++ b/client/src/i18n/locales/es/draft.json
@@ -39,7 +39,9 @@
"overrideResult": "Anular resultado",
"versusPair": "{{a}} contra {{b}}",
"kickReplace": "Expulsar + reemplazar",
- "replaceWithBot": "Reemplazar a {{name}} con un bot"
+ "replaceWithBot": "Reemplazar a {{name}} con un bot",
+ "endDraft": "Terminar draft",
+ "endDraftConfirm": "¿Terminar este draft para todos? Esta acción no se puede deshacer."
},
"manaCurve": {
"title": "Curva de maná"
@@ -60,7 +62,8 @@
"botDifficulty": "Dificultad de los bots",
"chooseSet": "Elige una colección",
"noPools": "No hay grupos de draft disponibles. Ejecuta primero la canalización de datos del draft.",
- "setIconAlt": "Icono de la colección {{name}}"
+ "setIconAlt": "Icono de la colección {{name}}",
+ "loadFailed": "Error al cargar las colecciones"
},
"pack": {
"confirmPick": "Confirmar elección",
diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json
index f22ebb2b0c..53cdf6298e 100644
--- a/client/src/i18n/locales/es/game.json
+++ b/client/src/i18n/locales/es/game.json
@@ -8,6 +8,8 @@
"preview": {
"holdCtrlBack": "Mantén Ctrl para la cara trasera",
"holdCtrlFront": "Mantén Ctrl para la cara frontal",
+ "holdCtrlFlip": "Mantén Ctrl para girar",
+ "flip": "Girar",
"altParsedAbilities": "Alt: habilidades analizadas",
"debugId": "ID: {{id}}",
"engineParse": "Análisis del motor",
@@ -307,7 +309,9 @@
"statHand": "Mano",
"statCreatures": "Criaturas",
"statLands": "Tierras",
- "statOther": "Otros"
+ "statOther": "Otros",
+ "compactHud": "HUD de oponente compacto. Contrae cada oponente a una sola fila fina (nombre + vidas); toca un oponente para enfocar todo su campo de batalla.",
+ "expandHud": "Expandir HUD de oponente. Muestra la mano y el desglose del campo de batalla de cada oponente."
},
"combat": {
"attackAll": "Atacar a todos",
@@ -380,7 +384,9 @@
"nounTarget": "objetivo",
"confirmTap": "Confirmar giro ({{selected}}/{{count}})",
"keepCurrentTargets": "Conservar objetivos actuales",
- "skip": "Omitir"
+ "skip": "Omitir",
+ "chooseNewTarget": "Elige el nuevo objetivo del hechizo",
+ "chooseNewTargetForSpell": "Elige un nuevo objetivo para {{spell}}"
},
"log": {
"title": "Registro de la partida",
@@ -1247,6 +1253,9 @@
"proliferateSubtitle": "Elige cualquier número de permanentes y jugadores con contadores. Cada objetivo elegido recibe un contador más de cada tipo que ya tenga.",
"chooseObjectsTitle": "Elegir permanentes",
"chooseObjectsSubtitle": "Elige cualquier número de permanentes. Pagas un coste por cada uno elegido.",
+ "selectAll": "Todos",
+ "selectNone": "Ninguno",
+ "selectMine": "Mi lado",
"confirm": "Confirmar"
},
"replacement": {
@@ -1260,7 +1269,8 @@
"scopeSingle": "Elige un nuevo objetivo para el hechizo",
"scopeMulti": "Elige nuevos objetivos para el hechizo",
"subtitle": "{{scope}}. Actual: {{current}}",
- "confirm": "Confirmar"
+ "confirm": "Confirmar",
+ "badgeNewTarget": "Nuevo objetivo"
},
"separatePiles": {
"chooserName": "Jugador {{number}}",
@@ -1321,5 +1331,27 @@
"subtitleLabel": "Elige una etiqueta para {{name}}",
"subtitleVoteRemaining": "Emite un voto ({{count}} restantes)",
"subtitleVote": "Emite tu voto"
+ },
+ "advance": {
+ "resolve": "Resolver",
+ "passPriority": "Pasar prioridad",
+ "toPhase": "A {{phase}}"
+ },
+ "outsideGame": {
+ "fromExile": "Desde el exilio",
+ "fromSideboard": "Desde el banquillo"
+ },
+ "phaseName": {
+ "Upkeep": "Mantenimiento",
+ "Draw": "Robar",
+ "PreCombatMain": "Fase principal 1",
+ "BeginCombat": "Inicio del combate",
+ "DeclareAttackers": "Declarar atacantes",
+ "DeclareBlockers": "Declarar bloqueadores",
+ "CombatDamage": "Daño de combate",
+ "EndCombat": "Fin del combate",
+ "PostCombatMain": "Fase principal 2",
+ "End": "Paso final",
+ "Cleanup": "Limpieza"
}
}
diff --git a/client/src/i18n/locales/es/menu.json b/client/src/i18n/locales/es/menu.json
index 0829d66de4..dc4f2b027e 100644
--- a/client/src/i18n/locales/es/menu.json
+++ b/client/src/i18n/locales/es/menu.json
@@ -4,7 +4,14 @@
},
"aiDifficulty": {
"label": "Dificultad de la IA",
- "ariaLabel": "Dificultad de la IA: {{difficulty}}"
+ "ariaLabel": "Dificultad de la IA: {{difficulty}}",
+ "levels": {
+ "VeryEasy": "Muy fácil",
+ "Easy": "Fácil",
+ "Medium": "Media",
+ "Hard": "Difícil",
+ "VeryHard": "Muy difícil"
+ }
},
"bracketFilter": {
"label": "Filtro de nivel"
@@ -205,7 +212,8 @@
"browseAll": "Explorar todos",
"loadMore": "Cargar más",
"selectedDeck": "Mazo seleccionado",
- "chooseDeckToContinue": "Elige un mazo para continuar"
+ "chooseDeckToContinue": "Elige un mazo para continuar",
+ "filterAll": "Todos"
},
"subscriptions": {
"emptyTitle": "Sin suscripciones a feeds",
@@ -213,5 +221,13 @@
"feedMeta_one": "· {{count}} mazo · Actualizado el {{date}}",
"feedMeta_other": "· {{count}} mazos · Actualizado el {{date}}",
"error": "Error: {{error}}"
+ },
+ "myDecksPage": {
+ "eyebrow": "Mazos",
+ "title": "Mazos.",
+ "description": "Abre una lista guardada, importa una nueva o continúa en el editor de mazos."
+ },
+ "loadGameState": {
+ "readFailed": "Error al leer el archivo"
}
}
diff --git a/client/src/i18n/locales/es/settings.json b/client/src/i18n/locales/es/settings.json
index c024373f65..c9851d26de 100644
--- a/client/src/i18n/locales/es/settings.json
+++ b/client/src/i18n/locales/es/settings.json
@@ -32,6 +32,15 @@
"random": "Aleatorio",
"customUrl": "URL personalizada",
"none": "Ninguno"
+ },
+ "cardSizeOptions": {
+ "small": "Pequeño",
+ "medium": "Mediano",
+ "large": "Grande"
+ },
+ "logDefaultOptions": {
+ "open": "Abierto",
+ "closed": "Cerrado"
}
},
"visual": {
@@ -45,7 +54,12 @@
"clearArtOverrides_one": "Borrar todas las anulaciones de ilustración ({{count}})",
"clearArtOverrides_other": "Borrar todas las anulaciones de ilustración ({{count}})",
"clearArtOverridesConfirm_one": "¿Borrar la anulación de ilustración? ({{count}})",
- "clearArtOverridesConfirm_other": "¿Borrar las {{count}} anulaciones de ilustración?"
+ "clearArtOverridesConfirm_other": "¿Borrar las {{count}} anulaciones de ilustración?",
+ "vfxQualityOptions": {
+ "full": "Completa",
+ "reduced": "Reducida",
+ "minimal": "Mínima"
+ }
},
"audio": {
"title": "Audio",
@@ -106,7 +120,17 @@
"slowest": "Lo más lento",
"resetSliderLabel": "Restablecer {{label}} al valor predeterminado",
"atDefault": "En el valor predeterminado",
- "resetToDefault": "Restablecer al valor predeterminado"
+ "resetToDefault": "Restablecer al valor predeterminado",
+ "labels": {
+ "effects": "Ritmo de efectos",
+ "combat": "Ritmo de combate",
+ "banners": "Ritmo de carteles"
+ },
+ "descriptions": {
+ "effects": "Lanzamientos de hechizos, cambios de zona, muertes, cambios de vida, contadores, girar/enderezar.",
+ "combat": "Tiempo del daño de combate: cuánto permanecen bloqueadores y atacantes antes de que se resuelva el daño.",
+ "banners": "Visualización del cartel de inicio de turno."
+ }
},
"artChain": {
"emptyState": "Usando la ilustración predeterminada de Scryfall. Añade reglas a continuación para personalizar.",
diff --git a/client/src/i18n/locales/fr/common.json b/client/src/i18n/locales/fr/common.json
index 22dcbe4d88..76565137e4 100644
--- a/client/src/i18n/locales/fr/common.json
+++ b/client/src/i18n/locales/fr/common.json
@@ -198,5 +198,11 @@
},
"turnBanner": {
"turn": "Tour {{number}}"
+ },
+ "chrome": {
+ "back": "Retour",
+ "settings": "Paramètres",
+ "languageSettings": "Langue ({{lang}}) — ouvrir les paramètres",
+ "languageTitle": "Langue : {{lang}}"
}
}
diff --git a/client/src/i18n/locales/fr/draft.json b/client/src/i18n/locales/fr/draft.json
index 417597fcfd..7662ad16a1 100644
--- a/client/src/i18n/locales/fr/draft.json
+++ b/client/src/i18n/locales/fr/draft.json
@@ -39,7 +39,9 @@
"overrideResult": "Modifier le résultat",
"versusPair": "{{a}} c. {{b}}",
"kickReplace": "Exclure + Remplacer",
- "replaceWithBot": "Remplacer {{name}} par un bot"
+ "replaceWithBot": "Remplacer {{name}} par un bot",
+ "endDraft": "Terminer le draft",
+ "endDraftConfirm": "Terminer ce draft pour tout le monde ? Cette action est irreversible."
},
"manaCurve": {
"title": "Courbe de mana"
@@ -60,7 +62,8 @@
"botDifficulty": "Difficulté des bots",
"chooseSet": "Choisir une extension",
"noPools": "Aucun pool de draft disponible. Lancez d'abord le pipeline de données de draft.",
- "setIconAlt": "Icône d'extension {{name}}"
+ "setIconAlt": "Icône d'extension {{name}}",
+ "loadFailed": "Échec du chargement des éditions"
},
"pack": {
"confirmPick": "Confirmer le choix",
diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json
index 1c09fd3e51..3faccc579b 100644
--- a/client/src/i18n/locales/fr/game.json
+++ b/client/src/i18n/locales/fr/game.json
@@ -8,6 +8,8 @@
"preview": {
"holdCtrlBack": "Maintenez Ctrl pour la face arrière",
"holdCtrlFront": "Maintenez Ctrl pour la face avant",
+ "holdCtrlFlip": "Maintenez Ctrl pour pivoter",
+ "flip": "Pivoter",
"altParsedAbilities": "Alt : capacités analysées",
"debugId": "ID : {{id}}",
"engineParse": "Analyse du moteur",
@@ -307,7 +309,9 @@
"statHand": "Main",
"statCreatures": "Créatures",
"statLands": "Terrains",
- "statOther": "Autre"
+ "statOther": "Autre",
+ "compactHud": "HUD d'adversaire compact. Réduit chaque adversaire à une seule ligne fine (nom + points de vie) ; touchez un adversaire pour afficher tout son champ de bataille.",
+ "expandHud": "Développer le HUD d'adversaire. Affiche la main et le détail du champ de bataille de chaque adversaire."
},
"combat": {
"attackAll": "Attaquer tous",
@@ -380,7 +384,9 @@
"nounTarget": "cible",
"confirmTap": "Confirmer l'engagement ({{selected}}/{{count}})",
"keepCurrentTargets": "Conserver les cibles actuelles",
- "skip": "Ignorer"
+ "skip": "Ignorer",
+ "chooseNewTarget": "Choisissez la nouvelle cible du sort",
+ "chooseNewTargetForSpell": "Choisissez une nouvelle cible pour {{spell}}"
},
"log": {
"title": "Journal de partie",
@@ -1247,6 +1253,9 @@
"proliferateSubtitle": "Choisissez n'importe quel nombre de permanents et de joueurs ayant des marqueurs. Chaque cible choisie reçoit un marqueur de plus de chaque sorte déjà présente.",
"chooseObjectsTitle": "Choisir des permanents",
"chooseObjectsSubtitle": "Choisissez n'importe quel nombre de permanents. Vous payez un coût pour chacun choisi.",
+ "selectAll": "Tout",
+ "selectNone": "Aucun",
+ "selectMine": "Mon côté",
"confirm": "Confirmer"
},
"replacement": {
@@ -1260,7 +1269,8 @@
"scopeSingle": "Choisissez une nouvelle cible pour le sort",
"scopeMulti": "Choisissez de nouvelles cibles pour le sort",
"subtitle": "{{scope}}. Actuelle : {{current}}",
- "confirm": "Confirmer"
+ "confirm": "Confirmer",
+ "badgeNewTarget": "Nouvelle cible"
},
"separatePiles": {
"chooserName": "Joueur {{number}}",
@@ -1321,5 +1331,27 @@
"subtitleLabel": "Choisissez une étiquette pour {{name}}",
"subtitleVoteRemaining": "Votez ({{count}} restant(s))",
"subtitleVote": "Exprimez votre vote"
+ },
+ "advance": {
+ "resolve": "Résoudre",
+ "passPriority": "Passer la priorité",
+ "toPhase": "Vers {{phase}}"
+ },
+ "outsideGame": {
+ "fromExile": "Depuis l'exil",
+ "fromSideboard": "Depuis la réserve"
+ },
+ "phaseName": {
+ "Upkeep": "Entretien",
+ "Draw": "Pioche",
+ "PreCombatMain": "Phase principale 1",
+ "BeginCombat": "Début de combat",
+ "DeclareAttackers": "Déclaration des attaquants",
+ "DeclareBlockers": "Déclaration des bloqueurs",
+ "CombatDamage": "Blessures de combat",
+ "EndCombat": "Fin de combat",
+ "PostCombatMain": "Phase principale 2",
+ "End": "Étape de fin",
+ "Cleanup": "Nettoyage"
}
}
diff --git a/client/src/i18n/locales/fr/menu.json b/client/src/i18n/locales/fr/menu.json
index b4fff56628..9f4dc9224d 100644
--- a/client/src/i18n/locales/fr/menu.json
+++ b/client/src/i18n/locales/fr/menu.json
@@ -4,7 +4,14 @@
},
"aiDifficulty": {
"label": "Difficulté de l'IA",
- "ariaLabel": "Difficulté de l'IA : {{difficulty}}"
+ "ariaLabel": "Difficulté de l'IA : {{difficulty}}",
+ "levels": {
+ "VeryEasy": "Très facile",
+ "Easy": "Facile",
+ "Medium": "Moyen",
+ "Hard": "Difficile",
+ "VeryHard": "Très difficile"
+ }
},
"bracketFilter": {
"label": "Filtre par palier"
@@ -205,7 +212,8 @@
"browseAll": "Tout parcourir",
"loadMore": "Charger plus",
"selectedDeck": "Deck sélectionné",
- "chooseDeckToContinue": "Choisissez un deck pour continuer"
+ "chooseDeckToContinue": "Choisissez un deck pour continuer",
+ "filterAll": "Tous"
},
"subscriptions": {
"emptyTitle": "Aucun abonnement à un flux",
@@ -213,5 +221,13 @@
"feedMeta_one": "· {{count}} deck · Mis à jour le {{date}}",
"feedMeta_other": "· {{count}} decks · Mis à jour le {{date}}",
"error": "Erreur : {{error}}"
+ },
+ "myDecksPage": {
+ "eyebrow": "Decks",
+ "title": "Decks.",
+ "description": "Ouvrez une liste enregistrée, importez-en une nouvelle ou continuez dans l'éditeur de deck."
+ },
+ "loadGameState": {
+ "readFailed": "Échec de la lecture du fichier"
}
}
diff --git a/client/src/i18n/locales/fr/settings.json b/client/src/i18n/locales/fr/settings.json
index b3e13fc25d..a04854c409 100644
--- a/client/src/i18n/locales/fr/settings.json
+++ b/client/src/i18n/locales/fr/settings.json
@@ -4,7 +4,7 @@
"subtitle": "Réglez le gameplay, les visuels, l'audio et les valeurs par défaut du multijoueur."
},
"tabs": {
- "gameplay": "Gameplay",
+ "gameplay": "Jouabilité",
"visual": "Visuel",
"combat": "Rythme",
"audio": "Audio",
@@ -13,7 +13,7 @@
"experimental": "Expérimental"
},
"gameplay": {
- "title": "Gameplay",
+ "title": "Jouabilité",
"language": "Langue",
"cardSize": "Taille des cartes",
"logDefault": "Journal par défaut",
@@ -32,6 +32,15 @@
"random": "Aléatoire",
"customUrl": "URL personnalisée",
"none": "Aucun"
+ },
+ "cardSizeOptions": {
+ "small": "Petit",
+ "medium": "Moyen",
+ "large": "Grand"
+ },
+ "logDefaultOptions": {
+ "open": "Ouvert",
+ "closed": "Fermé"
}
},
"visual": {
@@ -45,7 +54,12 @@
"clearArtOverrides_one": "Effacer tous les remplacements d'illustration ({{count}})",
"clearArtOverrides_other": "Effacer tous les remplacements d'illustration ({{count}})",
"clearArtOverridesConfirm_one": "Effacer les {{count}} remplacement d'illustration ?",
- "clearArtOverridesConfirm_other": "Effacer les {{count}} remplacements d'illustration ?"
+ "clearArtOverridesConfirm_other": "Effacer les {{count}} remplacements d'illustration ?",
+ "vfxQualityOptions": {
+ "full": "Complète",
+ "reduced": "Réduite",
+ "minimal": "Minimale"
+ }
},
"audio": {
"title": "Audio",
@@ -106,7 +120,17 @@
"slowest": "Le plus lent",
"resetSliderLabel": "Réinitialiser {{label}} par défaut",
"atDefault": "Valeur par défaut",
- "resetToDefault": "Réinitialiser par défaut"
+ "resetToDefault": "Réinitialiser par défaut",
+ "labels": {
+ "effects": "Rythme des effets",
+ "combat": "Rythme du combat",
+ "banners": "Rythme des bannières"
+ },
+ "descriptions": {
+ "effects": "Lancements de sorts, changements de zone, morts, changements de points de vie, marqueurs, engagement/dégagement.",
+ "combat": "Minutage des blessures de combat : combien de temps les bloqueurs et les attaquants restent avant que les blessures soient infligées.",
+ "banners": "Affichage de la bannière de début de tour."
+ }
},
"artChain": {
"emptyState": "Utilisation de l'illustration Scryfall par défaut. Ajoutez des règles ci-dessous pour personnaliser.",
diff --git a/client/src/i18n/locales/it/common.json b/client/src/i18n/locales/it/common.json
index 76e7ebcff1..b704f18c03 100644
--- a/client/src/i18n/locales/it/common.json
+++ b/client/src/i18n/locales/it/common.json
@@ -198,5 +198,11 @@
},
"turnBanner": {
"turn": "Turno {{number}}"
+ },
+ "chrome": {
+ "back": "Indietro",
+ "settings": "Impostazioni",
+ "languageSettings": "Lingua ({{lang}}) — apri impostazioni",
+ "languageTitle": "Lingua: {{lang}}"
}
}
diff --git a/client/src/i18n/locales/it/draft.json b/client/src/i18n/locales/it/draft.json
index 7892a88600..4975cdabd0 100644
--- a/client/src/i18n/locales/it/draft.json
+++ b/client/src/i18n/locales/it/draft.json
@@ -39,7 +39,9 @@
"overrideResult": "Sovrascrivi risultato",
"versusPair": "{{a}} v {{b}}",
"kickReplace": "Espelli + sostituisci",
- "replaceWithBot": "Sostituisci {{name}} con un bot"
+ "replaceWithBot": "Sostituisci {{name}} con un bot",
+ "endDraft": "Termina draft",
+ "endDraftConfirm": "Terminare questo draft per tutti? L'azione non puo essere annullata."
},
"manaCurve": {
"title": "Curva del mana"
@@ -60,7 +62,8 @@
"botDifficulty": "Difficoltà bot",
"chooseSet": "Scegli un set",
"noPools": "Nessun pool di draft disponibile. Esegui prima la pipeline dei dati di draft.",
- "setIconAlt": "Icona set {{name}}"
+ "setIconAlt": "Icona set {{name}}",
+ "loadFailed": "Impossibile caricare i set"
},
"pack": {
"confirmPick": "Conferma scelta",
diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json
index aae64166b7..e6d30c1468 100644
--- a/client/src/i18n/locales/it/game.json
+++ b/client/src/i18n/locales/it/game.json
@@ -8,6 +8,8 @@
"preview": {
"holdCtrlBack": "Tieni premuto Ctrl per il retro",
"holdCtrlFront": "Tieni premuto Ctrl per il fronte",
+ "holdCtrlFlip": "Tieni premuto Ctrl per girare",
+ "flip": "Gira",
"altParsedAbilities": "Alt: abilità analizzate",
"debugId": "ID: {{id}}",
"engineParse": "Analisi del motore",
@@ -307,7 +309,9 @@
"statHand": "Mano",
"statCreatures": "Creature",
"statLands": "Terre",
- "statOther": "Altro"
+ "statOther": "Altro",
+ "compactHud": "HUD avversario compatto. Riduce ogni avversario a una singola riga sottile (nome + punti vita); tocca un avversario per visualizzare l'intero campo di battaglia.",
+ "expandHud": "Espandi HUD avversario. Mostra la mano e il dettaglio del campo di battaglia di ogni avversario."
},
"combat": {
"attackAll": "Attacca tutti",
@@ -380,7 +384,9 @@
"nounTarget": "bersaglio",
"confirmTap": "Conferma TAP ({{selected}}/{{count}})",
"keepCurrentTargets": "Mantieni i bersagli attuali",
- "skip": "Salta"
+ "skip": "Salta",
+ "chooseNewTarget": "Scegli il nuovo bersaglio della magia",
+ "chooseNewTargetForSpell": "Scegli un nuovo bersaglio per {{spell}}"
},
"log": {
"title": "Registro di gioco",
@@ -1247,6 +1253,9 @@
"proliferateSubtitle": "Scegli un numero qualsiasi di permanenti e giocatori con segnalini. Ogni bersaglio scelto riceve un altro segnalino di ciascun tipo già presente.",
"chooseObjectsTitle": "Scegli permanenti",
"chooseObjectsSubtitle": "Scegli un numero qualsiasi di permanenti. Paghi un costo per ciascuno scelto.",
+ "selectAll": "Tutti",
+ "selectNone": "Nessuno",
+ "selectMine": "Il mio lato",
"confirm": "Conferma"
},
"replacement": {
@@ -1260,7 +1269,8 @@
"scopeSingle": "Scegli un nuovo bersaglio per la magia",
"scopeMulti": "Scegli nuovi bersagli per la magia",
"subtitle": "{{scope}}. Attuale: {{current}}",
- "confirm": "Conferma"
+ "confirm": "Conferma",
+ "badgeNewTarget": "Nuovo bersaglio"
},
"separatePiles": {
"chooserName": "Giocatore {{number}}",
@@ -1321,5 +1331,27 @@
"subtitleLabel": "Scegli un'etichetta per {{name}}",
"subtitleVoteRemaining": "Esprimi un voto ({{count}} rimanenti)",
"subtitleVote": "Esprimi il tuo voto"
+ },
+ "advance": {
+ "resolve": "Risolvi",
+ "passPriority": "Passa priorità",
+ "toPhase": "A {{phase}}"
+ },
+ "outsideGame": {
+ "fromExile": "Dall'esilio",
+ "fromSideboard": "Dalla sideboard"
+ },
+ "phaseName": {
+ "Upkeep": "Mantenimento",
+ "Draw": "Pesca",
+ "PreCombatMain": "Fase principale 1",
+ "BeginCombat": "Inizio combattimento",
+ "DeclareAttackers": "Dichiarazione attaccanti",
+ "DeclareBlockers": "Dichiarazione bloccanti",
+ "CombatDamage": "Danno da combattimento",
+ "EndCombat": "Fine combattimento",
+ "PostCombatMain": "Fase principale 2",
+ "End": "Sottofase finale",
+ "Cleanup": "Ripulitura"
}
}
diff --git a/client/src/i18n/locales/it/menu.json b/client/src/i18n/locales/it/menu.json
index 6660e30503..04d376a89e 100644
--- a/client/src/i18n/locales/it/menu.json
+++ b/client/src/i18n/locales/it/menu.json
@@ -4,7 +4,14 @@
},
"aiDifficulty": {
"label": "Difficoltà IA",
- "ariaLabel": "Difficoltà IA: {{difficulty}}"
+ "ariaLabel": "Difficoltà IA: {{difficulty}}",
+ "levels": {
+ "VeryEasy": "Molto facile",
+ "Easy": "Facile",
+ "Medium": "Media",
+ "Hard": "Difficile",
+ "VeryHard": "Molto difficile"
+ }
},
"bracketFilter": {
"label": "Filtro bracket"
@@ -205,7 +212,8 @@
"browseAll": "Sfoglia tutti",
"loadMore": "Carica altri",
"selectedDeck": "Mazzo selezionato",
- "chooseDeckToContinue": "Scegli un mazzo per continuare"
+ "chooseDeckToContinue": "Scegli un mazzo per continuare",
+ "filterAll": "Tutti"
},
"subscriptions": {
"emptyTitle": "Nessuna iscrizione ai feed",
@@ -213,5 +221,13 @@
"feedMeta_one": "· {{count}} mazzo · Aggiornato {{date}}",
"feedMeta_other": "· {{count}} mazzi · Aggiornato {{date}}",
"error": "Errore: {{error}}"
+ },
+ "myDecksPage": {
+ "eyebrow": "Mazzi",
+ "title": "Mazzi.",
+ "description": "Apri una lista salvata, importane una nuova o continua nell'editor di mazzi."
+ },
+ "loadGameState": {
+ "readFailed": "Impossibile leggere il file"
}
}
diff --git a/client/src/i18n/locales/it/settings.json b/client/src/i18n/locales/it/settings.json
index 4a506e9dc7..c375f911af 100644
--- a/client/src/i18n/locales/it/settings.json
+++ b/client/src/i18n/locales/it/settings.json
@@ -4,7 +4,7 @@
"subtitle": "Regola gameplay, grafica, audio e impostazioni predefinite del multigiocatore."
},
"tabs": {
- "gameplay": "Gameplay",
+ "gameplay": "Giocabilità",
"visual": "Grafica",
"combat": "Ritmo",
"audio": "Audio",
@@ -13,7 +13,7 @@
"experimental": "Sperimentale"
},
"gameplay": {
- "title": "Gameplay",
+ "title": "Giocabilità",
"language": "Lingua",
"cardSize": "Dimensione carte",
"logDefault": "Registro predefinito",
@@ -32,6 +32,15 @@
"random": "Casuale",
"customUrl": "URL personalizzato",
"none": "Nessuno"
+ },
+ "cardSizeOptions": {
+ "small": "Piccolo",
+ "medium": "Medio",
+ "large": "Grande"
+ },
+ "logDefaultOptions": {
+ "open": "Aperto",
+ "closed": "Chiuso"
}
},
"visual": {
@@ -45,7 +54,12 @@
"clearArtOverrides_one": "Cancella tutte le sostituzioni di illustrazione ({{count}})",
"clearArtOverrides_other": "Cancella tutte le sostituzioni di illustrazione ({{count}})",
"clearArtOverridesConfirm_one": "Cancellare tutte le {{count}} sostituzione di illustrazione?",
- "clearArtOverridesConfirm_other": "Cancellare tutte le {{count}} sostituzioni di illustrazione?"
+ "clearArtOverridesConfirm_other": "Cancellare tutte le {{count}} sostituzioni di illustrazione?",
+ "vfxQualityOptions": {
+ "full": "Completa",
+ "reduced": "Ridotta",
+ "minimal": "Minima"
+ }
},
"audio": {
"title": "Audio",
@@ -106,7 +120,17 @@
"slowest": "Più lento",
"resetSliderLabel": "Reimposta {{label}} al valore predefinito",
"atDefault": "Al valore predefinito",
- "resetToDefault": "Reimposta al valore predefinito"
+ "resetToDefault": "Reimposta al valore predefinito",
+ "labels": {
+ "effects": "Ritmo degli effetti",
+ "combat": "Ritmo del combattimento",
+ "banners": "Ritmo degli striscioni"
+ },
+ "descriptions": {
+ "effects": "Lancio di magie, cambi di zona, morti, cambi di punti vita, segnalini, TAP/STAP.",
+ "combat": "Tempistica del danno da combattimento: per quanto tempo bloccanti e attaccanti rimangono prima che il danno venga risolto.",
+ "banners": "Visualizzazione dello striscione di inizio turno."
+ }
},
"artChain": {
"emptyState": "In uso l'illustrazione predefinita di Scryfall. Aggiungi regole qui sotto per personalizzare.",
diff --git a/client/src/i18n/locales/pl/common.json b/client/src/i18n/locales/pl/common.json
new file mode 100644
index 0000000000..cf1479ec17
--- /dev/null
+++ b/client/src/i18n/locales/pl/common.json
@@ -0,0 +1,208 @@
+{
+ "actions": {
+ "cancel": "Anuluj",
+ "save": "Zapisz",
+ "close": "Zamknij",
+ "closeNamed": "Zamknij {{name}}"
+ },
+ "modal": {
+ "defaultEyebrow": "Narzędzie warsztatu"
+ },
+ "pausedBanner": {
+ "message": "Gra wstrzymana — {{reason}}"
+ },
+ "fullscreen": {
+ "enter": "Włącz pełny ekran",
+ "exit": "Wyłącz pełny ekran"
+ },
+ "buildBadge": {
+ "downloading": "pobieranie… {{progress}}%",
+ "checking": "sprawdzanie…",
+ "updating": "aktualizowanie…",
+ "updatePending": "aktualizacja oczekuje po zakończeniu gry",
+ "cards": "karty {{age}} ({{commit}})",
+ "cardDataTitle": "Dane kart wygenerowane {{date}} z {{commit}}",
+ "checkForUpdates": "Sprawdź aktualizacje",
+ "updaterDebugInfo": "Informacje debugowania aktualizatora",
+ "updaterIssue": "Problem z aktualizatorem: {{error}}",
+ "updateIssue": "problem z aktualizacją",
+ "updated": "zaktualizowano"
+ },
+ "gameMenu": {
+ "menu": "Menu gry",
+ "sandboxTools": "Narzędzia piaskownicy",
+ "sandboxToolsTitle": "Narzędzia piaskownicy — skonfiguruj dowolny stan pola bitwy (`)",
+ "resume": "Wznów",
+ "settings": "Ustawienia",
+ "helpShortcuts": "Pomoc i skróty",
+ "showAiHand": "Pokaż rękę AI",
+ "hideAiHand": "Ukryj rękę AI",
+ "concede": "Poddaj się",
+ "backToDraft": "Wróć do draftu",
+ "mainMenu": "Menu główne",
+ "cards": "karty {{commit}}",
+ "cardDataTitle": "Dane kart: {{date}}"
+ },
+ "volume": {
+ "mute": "Wycisz",
+ "unmute": "Wyłącz wyciszenie",
+ "volume": "Głośność"
+ },
+ "hostControl": {
+ "seatHost": "Gospodarz",
+ "seatPlayer": "Gracz",
+ "seatOpen": "Wolne",
+ "seatAi": "AI ({{difficulty}})",
+ "waiting": "Oczekiwanie…",
+ "seatLabel": "Miejsce {{id}}",
+ "addAi": "Dodaj AI",
+ "remove": "Usuń",
+ "human": "Człowiek",
+ "kick": "Wyrzuć",
+ "replaceAi": "Zastąp przez AI",
+ "random": "Losowo",
+ "connecting": "Łączenie…",
+ "cancelHosting": "Anuluj hostowanie",
+ "kickConfirm": "Wyrzucić gracza {{name}} z pokoju?",
+ "replaceConfirm": "Zastąpić gracza {{name}} przez AI? Spowoduje to usunięcie go z pokoju.",
+ "fallbackPlayerName": "Gracz {{number}}",
+ "seatsOccupied": "{{occupied}}/{{total}} miejsc zajętych",
+ "startGame": "Rozpocznij grę",
+ "startNow": "Rozpocznij teraz",
+ "fillWithAi": "Wypełnij przez AI"
+ },
+ "grantDebug": {
+ "heading": "Piaskownica: uprawnienia debugowania",
+ "description": "Przyznaj innym graczom możliwość wykonywania akcji debugowania. Ich akcje są rejestrowane publicznie.",
+ "playerLabel": "Gracz {{number}}",
+ "revoke": "ODBIERZ",
+ "grant": "PRZYZNAJ"
+ },
+ "help": {
+ "eyebrow": "Pomoc",
+ "title": "Pomoc i skróty",
+ "subtitle": "Cyfrowy przebieg Magic dla graczy papierowych: zatrzymania, priorytet, pasowanie i narzędzia naprawcze.",
+ "closeHelp": "Zamknij pomoc",
+ "searchPlaceholder": "Szukaj w pomocy lub skrótach",
+ "whatCanIDo": "Co mogę teraz zrobić?",
+ "recoveryTitle": "Narzędzia naprawcze",
+ "recoveryDescription": "Jeśli karta zachowuje się nieprawidłowo, najpierw wyeksportuj stan. Zaawansowany panel debugowania jest dostępny, gdy musisz sprawdzić lub dostosować grę.",
+ "copyState": "Kopiuj stan",
+ "exportState": "Eksportuj stan",
+ "openAdvancedDebug": "Otwórz zaawansowane debugowanie",
+ "sections": {
+ "Flow": "Przebieg",
+ "Shortcuts": "Skróty",
+ "Recovery": "Naprawa"
+ },
+ "status": {
+ "copied": "Skopiowano stan gry do schowka.",
+ "copyFailed": "Nie można skopiować stanu gry.",
+ "exported": "Wyeksportowano {{filename}}.",
+ "exportFailed": "Nie można wyeksportować stanu gry."
+ },
+ "prompt": {
+ "starting": "Gra się rozpoczyna lub przywraca stan.",
+ "gameOver": "Gra zakończona.",
+ "mulliganDecide": "Zdecyduj, czy zatrzymać tę rękę początkową, czy wykonać mulligan.",
+ "mulliganWaitDecide": "Oczekiwanie, aż inny gracz zdecyduje o swojej ręce początkowej.",
+ "mulliganBottom": "Wybierz karty do umieszczenia na spodzie po zatrzymaniu ręki po mulliganie.",
+ "mulliganWaitBottom": "Oczekiwanie, aż inny gracz zakończy swój mulligan.",
+ "openingHandBottom": "Wybierz kartę do umieszczenia na spodzie, zanim rozpoczną się normalne mulligany.",
+ "openingHandWait": "Oczekiwanie, aż inny gracz rozstrzygnie swoją rękę początkową.",
+ "waitOther": "Oczekiwanie na ruch innego gracza.",
+ "priorityStack": "Masz priorytet, gdy coś jest na stosie. Rozstrzygnięcie pasuje priorytet, aby górny element mógł się rozstrzygnąć.",
+ "priorityAutoPass": "Masz priorytet. Klient może automatycznie pasować spokojne okna, chyba że włączone jest zatrzymanie lub Pełna kontrola.",
+ "priorityActions": "Masz priorytet. Możesz użyć dostępnych kart lub spasować, aby kontynuować turę.",
+ "priorityPass": "Masz priorytet. Pasowanie przechodzi do następnego kroku lub gracza.",
+ "manaPayment": "Zapłać manę za oczekujący czar lub zdolność. Naciśnij T, aby przekręcić dostępne ziemie.",
+ "targetSelection": "Wybierz podświetlony legalny cel lub anuluj, jeśli komunikat na to pozwala.",
+ "declareAttackers": "Wybierz atakujących, a następnie potwierdź atakujących. Możesz też zaatakować bez żadnych.",
+ "declareBlockers": "Wybierz blokujących i przypisz ich do atakujących, a następnie potwierdź blokujących.",
+ "chooseXValue": "Wybierz wartość X przed kontynuowaniem czaru lub zdolności.",
+ "payAmountChoice": "Wybierz, ile z żądanego zasobu zapłacić.",
+ "default": "Gra czeka na twój wybór. Postępuj zgodnie z aktywnym komunikatem, aby kontynuować."
+ },
+ "entries": {
+ "automaticPhaseSkips": {
+ "title": "Automatyczne pomijanie faz",
+ "body": "Pomijanie faz jest automatyczne. Użyj zatrzymań lub Pełnej kontroli, gdy chcesz uzyskać okna priorytetu w stylu papierowym."
+ },
+ "phaseStops": {
+ "title": "Zatrzymania faz",
+ "body": "Zatrzymanie wstrzymuje grę przed danym krokiem, podobnie jak deklaracja, że możesz zagrać przed walką, przed dobraniem lub przed krokiem końcowym w papierze."
+ },
+ "fullControl": {
+ "title": "Pełna kontrola",
+ "body": "Pełna kontrola zapobiega pomijaniu okien priorytetu, w tym miejsc, w których cyfrowy klient normalnie kontynuowałby grę."
+ },
+ "resolve": {
+ "title": "Rozstrzygnij",
+ "body": "Rozstrzygnięcie oznacza, że pasujesz priorytet, aby górny czar lub zdolność na stosie mogły się rozstrzygnąć, jeśli wszyscy inni również spasują."
+ },
+ "passToEnd": {
+ "title": "Pasuj do końca",
+ "body": "Pasowanie do końca pasuje priorytet przez całą turę, chyba że przerwie je wybór, zatrzymanie lub Pełna kontrola."
+ },
+ "manaPayment": {
+ "title": "Płatność many",
+ "body": "Podczas płatności many możesz ręcznie przekręcać ziemie lub nacisnąć T, aby przekręcić dostępne ziemie."
+ },
+ "combatDeclarations": {
+ "title": "Deklaracje walki",
+ "body": "Gra prosi o atakujących i blokujących tylko podczas kroków deklaracji. Wybierz stwory, a następnie potwierdź deklarację."
+ },
+ "openHelp": {
+ "title": "Otwórz pomoc",
+ "body": "Otwórz ten arkusz pomocy."
+ },
+ "passPriority": {
+ "title": "Pasuj priorytet",
+ "body": "Pasuj priorytet lub przejdź przez bieżący komunikat priorytetu."
+ },
+ "undo": {
+ "title": "Cofnij",
+ "body": "Cofnij ostatnią lokalną akcję, która nie ujawniła ukrytych informacji."
+ },
+ "cancel": {
+ "title": "Anuluj",
+ "body": "Anuluj bieżący wybór, płatność many, wybór celu lub automatyczne pasowanie, gdy jest dostępne."
+ },
+ "advancedDebugPanel": {
+ "title": "Zaawansowany panel debugowania",
+ "body": "Otwórz zaawansowany panel debugowania. Większość graczy powinna najpierw zacząć od Narzędzi naprawczych."
+ },
+ "reportOrExportState": {
+ "title": "Zgłoś lub wyeksportuj stan",
+ "body": "Jeśli karta zachowuje się nieprawidłowo, wyeksportuj bieżący stan gry, aby można było odtworzyć dokładną pozycję na polu bitwy."
+ },
+ "boardRightClickMenu": {
+ "title": "Menu pola bitwy pod prawym przyciskiem myszy",
+ "body": "Na komputerze kliknij prawym przyciskiem myszy puste miejsce na polu bitwy, aby uzyskać dziennik gry, narzędzia naprawcze/debugowania i ustawienia tła."
+ }
+ },
+ "flowNudge": {
+ "message": "Pomijanie faz jest automatyczne. Użyj zatrzymań lub Pełnej kontroli, gdy chcesz uzyskać okna priorytetu w stylu papierowym.",
+ "dismiss": "Odrzuć",
+ "learnFlow": "Poznaj przebieg"
+ },
+ "sandboxNudge": {
+ "message": "Skonfiguruj dowolny stan pola bitwy za pomocą Narzędzi piaskownicy — dodawaj karty i żetony, zmieniaj życie i znaczniki, kopiuj trwałe permanenty lub przeskakuj fazy. Otwórz je w dowolnej chwili klawiszem ` .",
+ "dismiss": "Odrzuć",
+ "open": "Otwórz Narzędzia piaskownicy"
+ }
+ },
+ "splash": {
+ "ready": "Gotowe",
+ "loading": "Ładowanie..."
+ },
+ "turnBanner": {
+ "turn": "Tura {{number}}"
+ },
+ "chrome": {
+ "back": "Wstecz",
+ "settings": "Ustawienia",
+ "languageSettings": "Język ({{lang}}) — otwórz ustawienia",
+ "languageTitle": "Język: {{lang}}"
+ }
+}
diff --git a/client/src/i18n/locales/pl/deck-builder.json b/client/src/i18n/locales/pl/deck-builder.json
new file mode 100644
index 0000000000..7026fe95ae
--- /dev/null
+++ b/client/src/i18n/locales/pl/deck-builder.json
@@ -0,0 +1,210 @@
+{
+ "search": {
+ "title": "Szukaj",
+ "subtitle": "Dodaj karty do bieżącej listy.",
+ "reset": "Resetuj",
+ "textPlaceholder": "Szukaj kart...",
+ "allTypes": "Wszystkie typy",
+ "cmcMax": "Maks. CMC:",
+ "sets": "Dodatki",
+ "addSetPlaceholder": "Dodaj kod dodatku...",
+ "addSet": "Dodaj",
+ "removeSet": "Usuń {{name}}",
+ "emptyHint": "Dodaj filtr, aby rozpocząć przeglądanie",
+ "searching": "Wyszukiwanie...",
+ "searchFailed": "Wyszukiwanie nie powiodło się",
+ "results_one": "{{count}} wynik",
+ "results_other": "{{count}} wyników",
+ "browseFormat": {
+ "all": "Wszystkie karty"
+ }
+ },
+ "filters": {
+ "title": "Filtry",
+ "close": "Zamknij filtry",
+ "done": "Gotowe",
+ "search": "Szukaj"
+ },
+ "deck": {
+ "backToDeck": "Powrót do talii",
+ "listView": "Widok listy",
+ "stackView": "Widok stosu"
+ },
+ "tabs": {
+ "ariaLabel": "Powierzchnia kreatora talii",
+ "deck": "Talia",
+ "info": "Informacje"
+ },
+ "toolbar": {
+ "menu": "Menu",
+ "deckBuilder": "Kreator talii",
+ "untitledDeck": "Talia bez tytułu",
+ "deckName": "Nazwa talii",
+ "format": "Format",
+ "bracket": "Poziom",
+ "saved": "Zapisano ✓",
+ "clone": "Klonuj",
+ "cloneTitle": "Zapisz kopię pod nową nazwą",
+ "loadDeck": "Wczytaj talię...",
+ "nameToSave": "Nazwij talię, aby ją zapisać"
+ },
+ "unsaved": {
+ "title": "Niezapisane zmiany",
+ "dismiss": "Zamknij okno dialogowe",
+ "bodyLeaving": "Masz niezapisane zmiany w tej talii. Zapisać je przed wyjściem?",
+ "bodyLoading": "Masz niezapisane zmiany w tej talii. Zapisać je przed wczytaniem innej talii?",
+ "discard": "Odrzuć",
+ "saveAndContinue": "Zapisz i kontynuuj"
+ },
+ "card": {
+ "moveToSideboard": "Przenieś jedną kartę {{name}} do zaplecza",
+ "moveToMain": "Przenieś jedną kartę {{name}} do talii głównej",
+ "removeOne": "Usuń jedną kartę {{name}}",
+ "makeCommander": "Ustaw {{name}} jako dowódcę",
+ "makeCommanderTitle": "Ustaw jako mojego dowódcę",
+ "chooseArtFor": "Wybierz grafikę dla {{name}}",
+ "alternateArtTap": "Dostępna alternatywna grafika — dotknij, aby wybrać",
+ "alternateArtRightClick": "Dostępna alternatywna grafika — kliknij prawym przyciskiem, aby wybrać",
+ "unsupportedExpand_one": "{{count}} nieobsługiwana mechanika; rozwiń szczegóły",
+ "unsupportedExpand_other": "{{count}} nieobsługiwanych mechanik; rozwiń szczegóły",
+ "unsupportedTitle_one": "{{count}} nieobsługiwana mechanika — kliknij, aby rozwinąć",
+ "unsupportedTitle_other": "{{count}} nieobsługiwanych mechanik — kliknij, aby rozwinąć"
+ },
+ "grid": {
+ "allFormats": "Wszystkie",
+ "addCard": "Dodaj {{name}}",
+ "notLegal": "{{name}} - Niedozwolona w formacie {{format}}",
+ "notFormat": "Nie {{format}}"
+ },
+ "deckList": {
+ "currentList": "Bieżąca lista",
+ "import": "Importuj",
+ "importTitle": "Importuj talię z tekstu (format MTGA lub .dck)",
+ "export": "Eksportuj",
+ "exportTitle": "Eksportuj talię",
+ "mainTab": "Główna ({{count}})",
+ "sideboardTab": "Zaplecze ({{count}})",
+ "mainDeckHeading_one": "Talia główna ({{count}} karta)",
+ "mainDeckHeading_other": "Talia główna ({{count}} kart)",
+ "sideboardUnlimited": "Zaplecze ({{count}})",
+ "sideboardLimited": "Zaplecze ({{count}}/{{max}})",
+ "sideboardExceeds": "Zaplecze przekracza limit {{max}} kart",
+ "sideboardEmptyHint": "Najedź na kartę z talii głównej i kliknij →, aby ją tu przenieść.",
+ "group": {
+ "Creatures": "Stwory",
+ "Spells": "Czary",
+ "Lands": "Ziemie"
+ },
+ "importModalTitle": "Importuj talię",
+ "pastePlaceholder": "Wklej listę talii (format MTGA lub .dck)...",
+ "fromFile": "Z pliku",
+ "parse": "Przetwórz",
+ "exportModalTitle": "Eksportuj talię",
+ "saveToFile": "Zapisz do pliku",
+ "copied": "Skopiowano!",
+ "copy": "Kopiuj"
+ },
+ "moveList": {
+ "empty": "Pusto"
+ },
+ "stack": {
+ "deckView": "Widok talii",
+ "visualDeckStack": "Wizualny stos talii",
+ "mainBadge": "Główna {{count}}",
+ "sideboardBadge": "Zaplecze {{count}}",
+ "emptyHint": "Dodane karty pojawią się tutaj jako kaskadowy stos.",
+ "commanderBadge": "Dowódca",
+ "commanderLane": "Dowódca",
+ "mainDeckLane": "Talia główna",
+ "noCommander": "Nie wybrano dowódcy.",
+ "mainEmpty": "Karty talii głównej pojawią się tutaj.",
+ "cardCount_one": "{{count}} karta",
+ "cardCount_other": "{{count}} kart",
+ "mainSideBadge": "{{main}} główna + {{side}} zaplecze",
+ "groupCards_one": "{{count}} karta",
+ "groupCards_other": "{{count}} kart",
+ "group": {
+ "Creatures": "Stwory",
+ "Spells": "Czary",
+ "Lands": "Ziemie"
+ },
+ "sideboardGroup": "Zaplecze",
+ "addOne": "Dodaj jedną kartę {{name}}",
+ "copyLimit": "{{name}} osiągnęła limit kopii",
+ "removeOne": "Usuń jedną kartę {{name}}",
+ "removeCommander": "Usuń {{name}} jako dowódcę"
+ },
+ "printingPicker": {
+ "title": "Wybierz grafikę",
+ "noPrintings": "Brak alternatywnych wydań dla tej karty.",
+ "filterPlaceholder": "Filtruj według dodatku lub numeru kolekcjonerskiego…",
+ "filterAriaLabel": "Filtruj wydania",
+ "count_one": "{{count}} wydanie",
+ "count_other": "{{count}} wydań",
+ "countMatching_one": "{{count}} wydanie pasujące do „{{query}}”",
+ "countMatching_other": "{{count}} wydań pasujących do „{{query}}”",
+ "useDefault": "Użyj domyślnej",
+ "noMatches": "Żadne wydanie nie pasuje do filtra.",
+ "noImage": "Brak obrazu",
+ "showMore_one": "Pokaż więcej (pozostało {{count}})",
+ "showMore_other": "Pokaż więcej (pozostało {{count}})"
+ },
+ "stats": {
+ "formatLegality": "Dozwolenie w formacie",
+ "engineCoverage": "Obsługa silnika",
+ "unsupportedTitle": "Nieobsługiwane:\n{{list}}"
+ },
+ "commanderPanel": {
+ "heading": "Dowódca",
+ "noCommander": "Nie wybrano dowódcy",
+ "remove": "Usuń",
+ "identity": "Tożsamość:",
+ "setAsCommander": "Ustaw jako dowódcę:",
+ "cardCount": "{{count}}/{{expected}} kart",
+ "singletonViolations": "Naruszenia zasady singletona: {{cards}}",
+ "colorViolations": "Naruszenia tożsamości kolorów: {{cards}}"
+ },
+ "manaCurve": {
+ "title": "Krzywa many",
+ "colors": "Kolory"
+ },
+ "legality": {
+ "legal": "Dozwolona",
+ "banned": "Zakazana",
+ "restricted": "Ograniczona",
+ "notLegal": "Niedozwolona"
+ },
+ "contextMenu": {
+ "chooseArt": "Wybierz grafikę…",
+ "noAlternates": "Brak alternatywnych wydań",
+ "clearOverride": "Wyczyść nadpisanie grafiki"
+ },
+ "bracket": {
+ "unavailable": "Szacowanie poziomu nie jest dostępne w tej wersji.",
+ "addCommander": "Dodaj dowódcę, aby zobaczyć szacowany poziom.",
+ "estimated": "Szacowany: B{{tier}} {{label}}",
+ "manual": "Ręczny: B{{tier}} {{label}}",
+ "mismatch": " ⚠ niezgodność",
+ "hideBreakdown": "Ukryj szczegóły",
+ "showBreakdown": "Pokaż szczegóły",
+ "hideBreakdownButton": "▲ Ukryj szczegóły",
+ "showBreakdownButton": "▼ Pokaż szczegóły",
+ "forced": "(wymuszony B{{tier}})",
+ "dataVersion": "Dane: {{version}}",
+ "aboutBrackets": "O poziomach ↗",
+ "estimatedChip": "Szacowany: B{{tier}}",
+ "estimatedChipFull": "Szacowany poziom: B{{tier}} {{label}}"
+ },
+ "bracketPicker": {
+ "ariaLabel": "Poziom talii",
+ "unrated": "Bez oceny"
+ },
+ "warnings": {
+ "commanderCount": "Talia ma {{count}} kart (potrzeba dokładnie {{expected}})",
+ "singleton": "{{name}}: wiele kopii (format singleton)",
+ "colorIdentity": "{{name}}: poza tożsamością kolorów dowódcy",
+ "minimumCount": "Talia ma {{count}} kart (minimum 60)",
+ "maxCopies": "{{name}}: {{count}} kopii (maks. 4)",
+ "companionInMain": "{{name}} jest twoim towarzyszem, ale znajduje się też w talii głównej — to narusza jego warunek budowy talii. Usuń go z talii głównej, aby użyć go jako towarzysza."
+ }
+}
diff --git a/client/src/i18n/locales/pl/draft.json b/client/src/i18n/locales/pl/draft.json
new file mode 100644
index 0000000000..d3d53e0fa0
--- /dev/null
+++ b/client/src/i18n/locales/pl/draft.json
@@ -0,0 +1,326 @@
+{
+ "intro": {
+ "quickTitle": "Szybki draft",
+ "podTitle": "Draft w grupie",
+ "subtitle": "Oto jak to działa",
+ "startDrafting": "Rozpocznij draft",
+ "quick": {
+ "step1": "Otworzysz 3 boostery po 14 kart każdy",
+ "step2": "Wybierz jedną kartę z każdego boostera, a resztę przekaż dalej do botów",
+ "step3": "Boostery zmieniają kierunek w każdej rundzie — w lewo, w prawo, w lewo",
+ "step4": "Po wszystkich wyborach zbuduj talię z 40 kart i rozegraj mecz"
+ },
+ "pod": {
+ "step1": "Draftujesz z {{count}} graczami w grupie",
+ "step2": "Otwórz 3 boostery po 14 kart — wybierz jedną, resztę przekaż dalej",
+ "step3": "Boostery zmieniają kierunek w każdej rundzie — w lewo, w prawo, w lewo",
+ "step4": "Po drafcie zbuduj talię z 40 kart i rozegraj mecze turniejowe"
+ }
+ },
+ "steps": {
+ "navLabel": "Postęp draftu",
+ "chooseSet": "Wybierz dodatek",
+ "draft": "Draft",
+ "buildDeck": "Zbuduj talię",
+ "play": "Graj"
+ },
+ "bracket": {
+ "title": "Drabinka",
+ "versus": "vs",
+ "quarterfinals": "Ćwierćfinały",
+ "semifinals": "Półfinały",
+ "final": "Finał"
+ },
+ "hostControls": {
+ "title": "Sterowanie gospodarza",
+ "resumeDraft": "Wznów draft",
+ "pauseDraft": "Wstrzymaj draft",
+ "startNextRound": "Rozpocznij następną rundę",
+ "overrideResult": "Zmień wynik",
+ "versusPair": "{{a}} v {{b}}",
+ "kickReplace": "Wyrzuć i zastąp",
+ "replaceWithBot": "Zastąp gracza {{name}} botem",
+ "endDraft": "Zakoncz draft",
+ "endDraftConfirm": "Zakonczyc ten draft dla wszystkich? Tej akcji nie mozna cofnac."
+ },
+ "manaCurve": {
+ "title": "Krzywa many"
+ },
+ "pickTimer": {
+ "label": "Czas na wybór",
+ "seconds": "{{count}}s"
+ },
+ "scoreBadge": {
+ "label": "Wynik: {{count}} wygranych"
+ },
+ "seat": {
+ "label": "Miejsce {{number}}",
+ "passingLeft": "→ Przekazywanie w lewo →",
+ "passingRight": "← Przekazywanie w prawo ←"
+ },
+ "setSelector": {
+ "botDifficulty": "Poziom trudności botów",
+ "chooseSet": "Wybierz dodatek",
+ "noPools": "Brak dostępnych pul do draftu. Najpierw uruchom pipeline danych draftu.",
+ "setIconAlt": "Ikona dodatku {{name}}",
+ "loadFailed": "Nie udało się wczytać dodatków"
+ },
+ "pack": {
+ "confirmPick": "Potwierdź wybór",
+ "waitingNext": "Oczekiwanie na następny booster...",
+ "cardsInPack": "{{count}} kart w boosterze",
+ "picking": "Wybieranie…",
+ "autoPick": "Wybór automatyczny"
+ },
+ "pool": {
+ "cardsDrafted": "{{count}} wybranych kart",
+ "empty": "Nie wybrano jeszcze żadnych kart",
+ "sortColor": "Kolor",
+ "sortType": "Typ",
+ "sortCmc": "CMC"
+ },
+ "standings": {
+ "title": "Klasyfikacja — Runda {{round}}",
+ "rank": "#",
+ "player": "Gracz",
+ "record": "Bilans",
+ "gwp": "GWP",
+ "currentPairings": "Aktualne pary",
+ "versus": "vs"
+ },
+ "limitedDeck": {
+ "poolHeading": "Pula ({{count}})",
+ "allAdded": "Wszystkie karty dodane do talii.",
+ "mainDeck": "Talia główna",
+ "emptyDeckHint": "Klikaj karty z puli, aby dodać je do talii.",
+ "addableCards": "Karty do dodania",
+ "autoLands": "Automatyczne ziemie",
+ "suggestDeck": "Zaproponuj talię",
+ "submitDeck": "Zatwierdź talię",
+ "removeCard": "Usuń {{name}}",
+ "addCard": "Dodaj {{name}}",
+ "cardCount": "/ {{min}} kart",
+ "spellCount_one": "{{count}} czar",
+ "spellCount_other": "{{count}} czarów",
+ "landCount_one": "{{count}} ziemia",
+ "landCount_other": "{{count}} ziem",
+ "readyToSubmit": "gotowe do zatwierdzenia",
+ "moreNeeded": "potrzeba jeszcze {{count}}"
+ },
+ "landing": {
+ "title": "Draft",
+ "startNew": "Rozpocznij nowy",
+ "experimental": "Eksperymentalne",
+ "quickDraft": {
+ "title": "Szybki draft",
+ "description": "Zdraftuj 3 boostery z 7 botami, zbuduj talię z 40 kart, a potem rozegraj mecz Bo1 przeciwko jednemu z nich."
+ },
+ "cubeDraft": {
+ "title": "Draft kostki",
+ "description": "Wklej lub wczytaj listę kostki, dostosuj zasady boosterów i talii, a potem zdraftuj szybko przeciwko botom."
+ },
+ "podDraft": {
+ "title": "Draft w grupie",
+ "description": "Załóż lub dołącz do grupy liczącej do 8 graczy, draftujcie razem na żywo, a potem rozegrajcie turniej szwajcarski lub na wylot."
+ },
+ "podInProgress": "Trwa draft w grupie",
+ "podLabel": "Grupa {{kind}}",
+ "seatCount": "{{count}} miejsc",
+ "roomLabel": "Pokój {{code}}",
+ "cardsPicked": "{{count}} wybranych kart",
+ "resume": "Wznów",
+ "draftInProgress": "Trwa draft",
+ "draftComplete": "Draft zakończony",
+ "resumeMatch": "Wznów mecz",
+ "viewResults": "Zobacz wyniki",
+ "setIconAlt": "Ikona {{code}}"
+ },
+ "podPhase": {
+ "lobby": "Poczekalnia",
+ "drafting": "Draftowanie",
+ "deckbuilding": "Budowanie talii",
+ "pairing": "Dobieranie par",
+ "matchInProgress": "Trwa mecz",
+ "complete": "Zakończono"
+ },
+ "quickPhase": {
+ "drafting": "Draftowanie",
+ "deckbuilding": "Budowanie talii",
+ "matchRecord": "Mecz {{number}} — {{wins}}W-{{losses}}P",
+ "record": "{{wins}}W-{{losses}}P",
+ "runComplete": "Bieg zakończony — {{wins}}W-{{losses}}P"
+ },
+ "relativeTime": {
+ "justNow": "przed chwilą",
+ "minutes": "{{count}} min temu",
+ "hours": "{{count}} godz. temu",
+ "days": "{{count}} dni temu"
+ },
+ "cubeSetup": {
+ "defaultCubeName": "Własna kostka",
+ "fetchError": "Nie udało się pobrać listy kostki",
+ "startError": "Nie udało się rozpocząć draftu kostki",
+ "cubeName": "Nazwa kostki",
+ "seats": "Miejsca",
+ "packs": "Boostery",
+ "packSize": "Rozmiar boostera",
+ "minDeck": "Minimalna talia",
+ "exportUrl": "URL eksportu CubeCobra",
+ "exportUrlPlaceholder": "Wklej surowy URL eksportu lub wklej listę poniżej",
+ "loadUrl": "Wczytaj URL",
+ "deckAddables": "Karty dodawane do talii",
+ "addablesStandardBasics": "Standardowe ziemie podstawowe",
+ "addablesBasicsPlusCustom": "Ziemie podstawowe plus własne",
+ "addablesCustomOnly": "Tylko własne",
+ "customAddableCards": "Własne karty do dodania",
+ "customAddablePlaceholder": "Jedna nazwa karty w wierszu",
+ "startCubeDraft": "Rozpocznij draft kostki"
+ },
+ "formatPicker": {
+ "title": "Twoja talia jest gotowa",
+ "subtitle": "Wybierz, jak chcesz grać.",
+ "startMatch": "Rozpocznij mecz",
+ "single": {
+ "label": "Pojedynczy mecz",
+ "description": "Rozegraj jeden mecz Bo1 swoją zdraftowaną talią."
+ },
+ "bo3": {
+ "label": "Do dwóch wygranych",
+ "description": "Rozegraj mecz Bo3 z wymianą kart bocznych między grami."
+ },
+ "run": {
+ "label": "Pełny bieg",
+ "description": "Graj mecze Bo1, aż osiągniesz 7 wygranych lub 3 porażki."
+ }
+ },
+ "run": {
+ "draftRun": "Bieg draftu",
+ "upNext": "Następny — Mecz {{number}}",
+ "nextMatch": "Następny mecz",
+ "endRun": "Zakończ bieg",
+ "done": "Gotowe",
+ "perfectRun": "Idealny bieg",
+ "runComplete": "Bieg zakończony",
+ "runOver": "Koniec biegu",
+ "finishedFlawless": "Zakończyłeś z bilansem {{wins}}–{{losses}}. Bezbłędnie.",
+ "finishedCongrats": "Zakończyłeś z bilansem {{wins}}–{{losses}}. Gratulacje!",
+ "finishedRecord": "Zakończyłeś z bilansem {{wins}}–{{losses}}.",
+ "wins": "Wygrane",
+ "losses": "Porażki",
+ "drawCount_one": "+ {{count}} remis",
+ "drawCount_other": "+ {{count}} remisów",
+ "matchLog": "Dziennik meczu",
+ "matchResultTitle": "Mecz {{number}}: {{result}}",
+ "result": {
+ "win": "wygrana",
+ "loss": "porażka",
+ "draw": "remis"
+ },
+ "resultShort": {
+ "win": "W",
+ "loss": "P",
+ "draw": "R"
+ }
+ },
+ "page": {
+ "cubeDraftTitle": "Draft kostki",
+ "quickDraftTitle": "Szybki draft",
+ "setDraftTab": "Draft dodatku",
+ "cubeTab": "Kostka"
+ },
+ "podSetup": {
+ "title": "Draft w grupie",
+ "subtitle": "Draftuj ze znajomymi — otwieraj boostery, wybieraj karty i graj mecze turniejowe.",
+ "hostCardTitle": "Załóż grupę",
+ "hostCardDesc": "Utwórz nowy pokój draftu. Wybierasz dodatek, format i rozmiar grupy — a potem udostępnij kod pokoju znajomym. Puste miejsca można wypełnić botami.",
+ "joinCardTitle": "Dołącz do grupy",
+ "joinCardDesc": "Wpisz kod pokoju od gospodarza, aby dołączyć do istniejącego draftu. Zostaniesz posadzony na następnym wolnym miejscu i będziesz draftować razem ze wszystkimi w czasie rzeczywistym.",
+ "howItWorksTitle": "Jak działa draft w grupie",
+ "howItWorks1": "Każdy gracz otwiera 3 boostery po 14 kart — wybierz jedną, resztę przekaż dalej",
+ "howItWorks2": "Boostery zmieniają kierunek w każdej rundzie (w lewo → w prawo → w lewo)",
+ "howItWorks3": "Po drafcie każdy buduje talię z 40 kart ze swojej puli",
+ "howItWorks4": "Następnie rozgrywacie turniej szwajcarski lub na pojedyncze wyeliminowanie",
+ "back": "← Wstecz",
+ "hostTitle": "Załóż grupę",
+ "joinTitle": "Dołącz do grupy",
+ "displayName": "Wyświetlana nazwa",
+ "namePlaceholder": "Wpisz swoją nazwę...",
+ "draftType": "Typ draftu",
+ "kindPremier": "Premier",
+ "kindPremierDesc": "Mecze turniejowe do jednej wygranej po budowaniu talii. Szybsze rundy, bez wymiany kart bocznych między grami.",
+ "kindTraditional": "Tradycyjny",
+ "kindTraditionalDesc": "Mecze turniejowe do dwóch wygranych po budowaniu talii, z wymianą kart bocznych między grami.",
+ "tournamentFormat": "Format turnieju",
+ "tournamentSwiss": "Szwajcarski",
+ "tournamentSwissDesc": "Wszyscy grają przez trzy rundy, nawet po porażce.",
+ "tournamentElimination": "Pojedyncze wyeliminowanie",
+ "tournamentEliminationDesc": "Gracze odpadają po przegranym meczu, aż pozostanie jeden zwycięzca.",
+ "podPolicy": "Zasady grupy",
+ "policyCompetitive": "Wyczynowe",
+ "policyCompetitiveDesc": "Wybory na czas, automatyczne wybory po upływie czasu i automatyczne przechodzenie do kolejnych rund.",
+ "policyCasual": "Swobodne",
+ "policyCasualDesc": "Wybory bez limitu czasu, ręczne przechodzenie do kolejnych rund i narzędzia gospodarza do rozwiązywania problemów.",
+ "podSize": "Rozmiar grupy",
+ "podSizeDesc": "Łącznie {{count}} miejsc. Puste miejsca można wypełnić botami z poczekalni przed rozpoczęciem draftu.",
+ "playerCount": "{{count}} graczy",
+ "setSelectorHint": "Wybierz dodatek do draftu na końcu. Wybór dodatku wczytuje jego pulę kart i tworzy pokój grupy.",
+ "loadingPool": "Wczytywanie danych puli dodatku...",
+ "roomCode": "Kod pokoju",
+ "roomCodePlaceholder": "Wpisz kod pokoju...",
+ "joinPod": "Dołącz do grupy"
+ },
+ "podPhaseView": {
+ "tournamentPairings": "Pary turniejowe",
+ "matchesInProgress": "Trwające mecze",
+ "yourMatch": "Twój mecz",
+ "versusOpponent": "vs {{name}}",
+ "youAreHosting": "Jesteś gospodarzem",
+ "connectingOpponent": "Łączenie z przeciwnikiem...",
+ "waitingResults": "Oczekiwanie na wyniki meczu...",
+ "hidePool": "Ukryj pulę",
+ "reviewPool": "Przejrzyj pulę",
+ "roundComplete": "Runda zakończona",
+ "waitingNextRound": "Oczekiwanie, aż gospodarz rozpocznie następną rundę...",
+ "nextRoundShortly": "Następna runda rozpocznie się wkrótce..."
+ },
+ "betweenGames": {
+ "game": "Gra {{number}}",
+ "lostPreviousGame": "Przegrałeś poprzednią grę. Wybierz:",
+ "seconds": "{{count}}s",
+ "secondsRemaining": "pozostało {{count}}s",
+ "playFirst": "Zagraj pierwszy",
+ "drawFirst": "Dobierz pierwszy",
+ "sideboarding": "Wymiana kart bocznych",
+ "waitingSideboard": "Oczekiwanie, aż przeciwnik zatwierdzi karty boczne...",
+ "sideboardGame": "Karty boczne — Gra {{number}}",
+ "sideboardHint": "Wprowadź zmiany w kartach bocznych, a potem zatwierdź. Twoja pula jest dostępna poniżej.",
+ "submitSideboard": "Zatwierdź karty boczne",
+ "preparingNext": "Przygotowywanie następnej gry..."
+ },
+ "podComplete": {
+ "title": "Draft zakończony",
+ "returnToMenu": "Powrót do menu"
+ },
+ "podError": {
+ "kicked": "Zostałeś wyrzucony z grupy",
+ "hostLeft": "Gospodarz opuścił draft",
+ "connection": "Błąd połączenia"
+ },
+ "lobby": {
+ "title": "Poczekalnia grupy draftu",
+ "draftKind": "Draft {{kind}}",
+ "botSeat": "Bot",
+ "waitingSeat": "Oczekiwanie...",
+ "seatNumber": "Miejsce {{number}}",
+ "hostBadge": "GOSPODARZ",
+ "kick": "Wyrzuć",
+ "copyRoomCodeTitle": "Kliknij, aby skopiować kod pokoju",
+ "roomCode": "Kod pokoju",
+ "clickToCopy": "Kliknij, aby skopiować",
+ "seatsFilled": "{{current}} / {{total}} miejsc zajętych",
+ "fillWithBots": "Wypełnij puste miejsca botami",
+ "leave": "Opuść",
+ "startDraft": "Rozpocznij draft",
+ "waitingForHost": "Oczekiwanie, aż gospodarz rozpocznie draft..."
+ }
+}
diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json
new file mode 100644
index 0000000000..88779097e2
--- /dev/null
+++ b/client/src/i18n/locales/pl/game.json
@@ -0,0 +1,1357 @@
+{
+ "card": {
+ "faceDownName": "Karta zakryta",
+ "loading": "Wczytywanie {{name}}",
+ "unimplemented": "Niezaimplementowane: {{mechanics}}",
+ "dfc": "DFC"
+ },
+ "preview": {
+ "holdCtrlBack": "Przytrzymaj Ctrl, aby zobaczyć tylną stronę",
+ "holdCtrlFront": "Przytrzymaj Ctrl, aby zobaczyć przednią stronę",
+ "holdCtrlFlip": "Przytrzymaj Ctrl, aby odwrócić",
+ "flip": "Odwróć",
+ "altParsedAbilities": "Alt: przeanalizowane zdolności",
+ "debugId": "ID: {{id}}",
+ "engineParse": "Analiza silnika",
+ "altKey": "Alt",
+ "parse": "Analiza",
+ "rulings": "Interpretacje",
+ "rulingCount_one": "{{count}} interpretacja",
+ "rulingCount_other": "{{count}} interpretacji",
+ "vanilla": "Vanilla — brak przeanalizowanych zdolności",
+ "unsupported": "nieobsługiwane",
+ "showMore": "Pokaż {{count}} więcej",
+ "showLess": "Pokaż mniej",
+ "fromSource": "(od {{source}})",
+ "basePT": "(bazowo {{power}}/{{toughness}})",
+ "damage": "Obrażenia: {{amount}}",
+ "ptDeltaFrom": "{{delta}} od {{source}}",
+ "colors": "Kolory: {{colors}}",
+ "colorless": "Bezbarwny"
+ },
+ "actionButton": {
+ "clearAttackers": "Wyczyść atakujących",
+ "attackWithAll": "Atakuj wszystkimi",
+ "confirmAttackers": "Potwierdź atakujących ({{count}})",
+ "attackWithNone": "Nie atakuj nikim",
+ "attackWithNoneConfirm": "Naciśnij ponownie: Nie atakuj nikim",
+ "confirmBlockers": "Potwierdź blokujących ({{count}})",
+ "resetBlocks": "Zresetuj bloki",
+ "blockWithNone": "Nie blokuj nikim",
+ "blockWithNoneConfirm": "Naciśnij ponownie: Nie blokuj nikim",
+ "selectAttackerForBlocker": "Wybierz atakującego, przed którym ten blokujący ma bronić",
+ "companionToHand": "Towarzysz do ręki",
+ "resolve": "Rozpatrz",
+ "resolveTooltip": "Przekaż priorytet, aby górny element na stosie mógł zostać rozpatrzony, jeśli każdy gracz również przekaże priorytet. Skrót: Spacja.",
+ "resolveAll": "Rozpatrz wszystko",
+ "resolveAllTooltip": "Przekazuj priorytet, dopóki stos się rozpatruje. Wymagany wybór lub zatrzymanie może to przerwać.",
+ "waiting": "Oczekiwanie",
+ "priorityTooltip": "Przekaż priorytet. Jeśli stos jest pusty, spowoduje to przejście przez bieżące okno priorytetu. Skrót: Spacja.",
+ "pass": "Przekaż",
+ "passToEndTooltip": "Automatycznie przekazuj priorytet aż do kroku końcowego, chyba że przerwie to wybór, zatrzymanie lub Pełna kontrola. Skrót: Enter.",
+ "autoPassing": "Automatyczne przekazywanie do kroku końcowego..."
+ },
+ "board": {
+ "regroupCreatures": "Pogrupuj zduplikowane grupy stworów",
+ "changeBackground": "Zmień tło…",
+ "gameLog": "Dziennik gry",
+ "debugLog": "Dziennik debugowania",
+ "waitingForGame": "Oczekiwanie na grę...",
+ "clickOpponent": "Kliknij przeciwnika, aby zobaczyć jego pole bitwy",
+ "undo": "Cofnij"
+ },
+ "permanent": {
+ "expandGroup": "Rozwiń grupę {{name}}",
+ "chooseToken": "Wybierz token {{name}}",
+ "pick": "Wybierz",
+ "close": "zamknij",
+ "eligibleCount": "{{count}} dozwolonych",
+ "attackingCount": "atak {{count}}",
+ "selectedCount": "wyb {{count}}",
+ "pickAll": "Wszystko",
+ "pickNone": "Nic",
+ "underAttack_one": "Atakowany przez {{count}} stwora",
+ "underAttack_other": "Atakowany przez {{count}} stworów",
+ "target": "Cel",
+ "copy": "Kopia",
+ "copyTooltip": "Kopia tokenowa prawdziwej karty"
+ },
+ "player": {
+ "opponent": "Przec {{seat}}",
+ "cardsInHand": "Karty w ręce",
+ "hand": "Ręka",
+ "creaturesAbbr": "Stw",
+ "landsAbbr": "Ziem",
+ "otherAbbr": "Inne",
+ "out": "POZA GRĄ",
+ "eliminated": "Wyeliminowany",
+ "phasedOut": "Przefazowany",
+ "you": "Ty",
+ "commanderDamageFrom": "Obrażenia od dowódcy od {{source}}: {{damage}}/{{threshold}}"
+ },
+ "attackTargetPicker": {
+ "heading": "Wybierz cel ataku",
+ "attackAll": "Atakuj wszystkich",
+ "splitAttacks": "Rozdziel ataki",
+ "confirmSplitAttacks": "Potwierdź rozdzielenie ataków",
+ "attackWith_one": "Atakuj {{label}} {{count}} stworem",
+ "attackWith_other": "Atakuj {{label}} {{count}} stworami",
+ "allTo": "Wszyscy → {{label}} ",
+ "creatureFallback": "Stwór #{{id}}",
+ "planeswalkerFallback": "Wędrowiec #{{id}}",
+ "you": "Ty",
+ "ally": "Sojusznik",
+ "restoreDialog": "Przywróć okno dialogowe",
+ "restoreDialogTitle": "Przywróć okno dialogowe celu ataku"
+ },
+ "fullControl": {
+ "on": "Pełna kontrola włączona",
+ "off": "Pełna kontrola wyłączona",
+ "tooltip": "Zatrzymuj okna priorytetu zamiast pozwalać na automatyczne przekazywanie w spokojnych momentach. Skrót: F."
+ },
+ "passButton": {
+ "resolve": "Rozpatrz",
+ "done": "Gotowe"
+ },
+ "lifeTotal": {
+ "playerLabel": "G{{seat}}"
+ },
+ "phaseStop": {
+ "untapLabel": "Krok odwracania",
+ "untapDescription": "Twoje obrócone trwałe odwracają się tutaj.",
+ "upkeepLabel": "Krok utrzymania",
+ "upkeepDescription": "Wyzwalacze utrzymania i powtarzające się koszty następują tutaj.",
+ "drawLabel": "Krok dobierania",
+ "drawDescription": "Tutaj dobierasz kartę na turę.",
+ "preCombatMainLabel": "Pierwsza faza główna",
+ "preCombatMainDescription": "Zagraj ziemie i rzuć czary przed walką.",
+ "beginCombatLabel": "Krok rozpoczęcia walki",
+ "beginCombatDescription": "Ostatnie okno priorytetu przed wybraniem atakujących.",
+ "declareAttackersLabel": "Krok deklaracji atakujących",
+ "declareAttackersDescription": "Atakujący gracz wybiera atakujących.",
+ "declareBlockersLabel": "Krok deklaracji blokujących",
+ "declareBlockersDescription": "Broniący gracze wybierają blokujących.",
+ "combatDamageLabel": "Krok obrażeń bojowych",
+ "combatDamageDescription": "Stwory przydzielają i zadają obrażenia bojowe.",
+ "endCombatLabel": "Krok zakończenia walki",
+ "endCombatDescription": "Ostatnie okno priorytetu walki po obrażeniach.",
+ "postCombatMainLabel": "Druga faza główna",
+ "postCombatMainDescription": "Zagraj ziemie lub rzuć czary po walce.",
+ "endLabel": "Krok końcowy",
+ "endDescription": "Wyzwalacze kroku końcowego i działania końca tury następują tutaj.",
+ "cleanupLabel": "Krok porządkowy",
+ "cleanupDescription": "Obrażenia znikają i sprawdzany jest maksymalny rozmiar ręki.",
+ "tooltipStopSet": "Zatrzymanie ustawione: kliknij, aby usunąć to zatrzymanie automatycznego przekazywania.",
+ "tooltipNoStop": "Brak zatrzymania: kliknij, aby wstrzymać tutaj automatyczne przekazywanie.",
+ "tooltipCurrentPhase": "Bieżąca faza.",
+ "tooltip": "Zatrzymanie fazy: {{label}}. {{description}} {{stopText}}{{activeText}}"
+ },
+ "phaseTracker": {
+ "turn": "Tura {{number}}"
+ },
+ "attachments": {
+ "enchantmentsOnPlayer": "Zaklęcia na graczu",
+ "attachedTo": "Dołączony do",
+ "attachedCount_one": "{{count}} dołączony trwały",
+ "attachedCount_other": "{{count}} dołączonych trwałych",
+ "unknownHost": "Nieznany",
+ "target": "Cel"
+ },
+ "battlefieldPeek": {
+ "boardOf": "Pole bitwy {{name}}",
+ "noNonlandPermanents": "Brak trwałych niebędących ziemiami",
+ "morePermanents_one": "+{{count}} trwały więcej",
+ "morePermanents_other": "+{{count}} trwałych więcej",
+ "notTargetable": " (nie można obrać za cel)"
+ },
+ "disconnectDialog": {
+ "title": "{{name}} rozłączył się",
+ "reconnecting": "Ponowne łączenie… pozostało {{seconds}}s ",
+ "pauseAndWait": "Wstrzymaj i czekaj",
+ "continueWithout": "Kontynuuj bez nich"
+ },
+ "kickDialog": {
+ "title": "Wyrzucić {{name}}?",
+ "body": "Przegrają grę i nie będą mogli dołączyć ponownie.",
+ "kick": "Wyrzuć"
+ },
+ "enchantmentsBadge": {
+ "ariaLabel_one": "1 zaklęcie na tym graczu",
+ "ariaLabel_other": "{{count}} zaklęć na tym graczu",
+ "tooltip_one": "Najedź, aby podejrzeć, kliknij, aby zobaczyć",
+ "tooltip_other": "{{count}} zaklęć — najedź, aby podejrzeć, kliknij, aby zobaczyć"
+ },
+ "badges": {
+ "monarch": "Monarcha",
+ "monarchTooltip": "Monarcha — dobiera dodatkową kartę na końcu tury",
+ "initiative": "Inicjatywa",
+ "initiativeTooltip": "Ma Inicjatywę — wyprawia się do Podmiasta na początku utrzymania",
+ "cityBlessing": "Błogosławieństwo miasta",
+ "cityBlessingTooltip": "Błogosławieństwo miasta — kontroluje dziesięć lub więcej trwałych (Ascend)",
+ "dungeonAriaLabel": "Wyprawa w {{name}}, pokój {{room}}",
+ "dungeonTooltip": "Wyprawa w {{name}} — pokój {{room}}",
+ "poisonAriaLabel_one": "{{count}} znacznik trucizny",
+ "poisonAriaLabel_other": "{{count}} znaczników trucizny",
+ "poisonTooltip": "Znaczniki trucizny: {{count}}",
+ "energyAriaLabel_one": "{{count}} znacznik energii",
+ "energyAriaLabel_other": "{{count}} znaczników energii",
+ "energyTooltip": "Energia: {{count}}",
+ "ringAriaLabel": "Pierścień cię kusi (poziom {{level}})",
+ "ringTooltip": "Pierścień cię kusi — poziom {{level}}",
+ "radAriaLabel_one": "{{count}} znacznik promieniowania",
+ "radAriaLabel_other": "{{count}} znaczników promieniowania",
+ "radTooltip": "Znaczniki promieniowania: {{count}}",
+ "speedAriaLabel": "Prędkość {{value}}",
+ "speedTooltip": "Prędkość: {{value}}",
+ "companion": "Towarzysz"
+ },
+ "avatar": {
+ "underAttack": "{{name}} jest atakowany"
+ },
+ "incomingAttackers": {
+ "summary": "⚔×{{count}} nadchodzi od {{name}}",
+ "clickToFocus": "kliknij, aby skupić"
+ },
+ "manaPool": {
+ "onlyForSpellType": "Wydaj tylko na rzucanie czarów określonego typu",
+ "onlyForCreatureType": "Wydaj tylko na rzucenie czaru stwora wybranego typu",
+ "onlyForTypeSpellsOrAbilities": "Wydaj tylko na czary lub zdolności określonego typu",
+ "onlyForSpellWithKeywordKind": "Wydaj tylko na rzucanie czarów z określonym słowem kluczowym",
+ "onlyForSpellWithKeywordKindFromZone": "Wydaj tylko na rzucanie czarów ze słowem kluczowym z cmentarza",
+ "onlyForActivation": "Wydaj tylko na aktywowanie zdolności",
+ "onlyForXCosts": "Wydaj tylko na koszty zawierające {X}",
+ "convokePayment": "Płatność Convoke",
+ "grantsProperty": "Nadaje czarowi właściwość"
+ },
+ "zone": {
+ "emblem": "Emblemat",
+ "emblemFallback": "Emblemat",
+ "commander": "Dowódca",
+ "commanderTitle": "Dowódca: {{name}}",
+ "commanderTitleTax": "Dowódca: {{name}} (Podatek: +{{tax}})",
+ "castCommander": "Rzuć {{name}} — kliknij dwukrotnie lub przeciągnij, aby zagrać",
+ "castCommanderTax": "Rzuć {{name}} (Podatek: +{{tax}}) — kliknij dwukrotnie lub przeciągnij, aby zagrać",
+ "tax": "Podatek: +{{tax}}",
+ "companion": "Towarzysz",
+ "companionTitle": "Towarzysz: {{name}}",
+ "companionTitleUsed": "Towarzysz: {{name}} (Wykorzystany)",
+ "companionActivate": "Zapłać {3}: Weź {{name}} do ręki",
+ "used": "Wykorzystany",
+ "exile": "Wygnanie",
+ "exileTitle": "Wygnanie ({{count}})",
+ "graveyard": "Cmentarz",
+ "graveyardShort": "CM",
+ "graveyardTitle": "Cmentarz ({{count}})",
+ "libraryAlt": "Biblioteka",
+ "libraryCount_one": "Biblioteka ({{count}} karta)",
+ "libraryCount_other": "Biblioteka ({{count}} kart)",
+ "playFromTop": "Zagraj {{name}} z wierzchu biblioteki",
+ "topOfLibrary": "wierzch biblioteki",
+ "indicatorOpponentPrefix": "Przec ",
+ "graveyardLower": "cmentarz",
+ "exileLower": "wygnanie",
+ "zoneTitle": "{{zone}} ({{count}})",
+ "noCardsIn": "Brak kart w strefie {{zone}}",
+ "castFromZone": "Rzuć z {{zone}}: {{name}}",
+ "viewCount": "Zobacz {{count}}",
+ "castableCount": "{{zone}} — {{count}} możliwych do rzucenia"
+ },
+ "mana": {
+ "free": "Za darmo",
+ "chooseXTitle": "Wybierz wartość dla X",
+ "chooseXAria": "Wybierz wartość X",
+ "xEquals": "X = {{value}}",
+ "minMax": "min {{min}} / maks {{max}}",
+ "maxOnly": "maks {{max}}",
+ "confirmX": "Potwierdź X = {{value}}",
+ "payMana": "Zapłać koszt many",
+ "convokeHint": "Obróć stwory, aby pomóc zapłacić.",
+ "improviseHint": "Obróć artefakty, aby pomóc zapłacić.",
+ "convokeOrImproviseHint": "Obróć stwory lub artefakty, aby pomóc zapłacić.",
+ "lifeAmount": "2 życia",
+ "lifeCostSummary": "({{count}} życia)",
+ "paymentPending": "Płatność wciąż oczekuje. Obróć trwałe lub anuluj tę akcję.",
+ "poolLabel": "Pula:",
+ "poolEmpty": "Pusta",
+ "pay": "Zapłać",
+ "chooseAmountTitle": "Wybierz kwotę do zapłaty",
+ "chooseAmountAria": "Wybierz kwotę do zapłaty",
+ "payAmount": "Zapłać {{value}} {{resource}}",
+ "resourceEnergy": "energii",
+ "resourceMana": "many"
+ },
+ "hand": {
+ "viewFullHand_one": "Zobacz całą rękę ({{count}} karta)",
+ "viewFullHand_other": "Zobacz całą rękę ({{count}} kart)",
+ "handLabel": "Ręka {{count}}",
+ "handTitle": "Ręka ({{count}})",
+ "cardBack": "Rewers karty"
+ },
+ "opponentHud": {
+ "ally": "Sojusznik",
+ "out": "Poza grą",
+ "phased": "Przefazowany",
+ "followingActiveOpponent": "Śledzenie aktywnego przeciwnika. Skupienie przełącza się na przeciwnika, którego jest tura.",
+ "followActiveOpponent": "Śledź aktywnego przeciwnika. Skupienie przełączy się na przeciwnika, którego jest tura.",
+ "compactHud": "Kompaktowy HUD przeciwnika. Zwija każdego przeciwnika do jednego cienkiego wiersza (imię + życie); dotknij przeciwnika, aby skupić jego całe pole bitwy.",
+ "expandHud": "Rozwiń HUD przeciwnika. Pokazuje rękę każdego przeciwnika i zestawienie pola bitwy.",
+ "targetPlayer": "Obierz za cel {{name}}",
+ "viewBoardThenTarget": "Zobacz pole bitwy {{name}} (kliknij ponownie, aby obrać za cel {{name}})",
+ "viewBoard": "Zobacz pole bitwy {{name}}",
+ "clickToTarget": "Kliknij, aby obrać za cel {{name}}",
+ "clickToViewThenTarget": "Kliknij, aby zobaczyć pole bitwy {{name}}, a następnie kliknij ponownie, aby obrać za cel {{name}}",
+ "clickToViewBoard": "Kliknij, aby zobaczyć pole bitwy {{name}}",
+ "underAttack": "{{name}} jest atakowany",
+ "kickPlayer": "Wyrzuć gracza {{seat}}",
+ "kickPlayerTooltip": "Wyrzuć gracza (poddanie)",
+ "incomingAttackers_one": "{{count}} stwór cię atakuje",
+ "incomingAttackers_other": "{{count}} stworów cię atakuje",
+ "disconnected": "Rozłączony",
+ "connected": "Połączony",
+ "statHand": "Ręka",
+ "statCreatures": "Stwory",
+ "statLands": "Ziemie",
+ "statOther": "Inne"
+ },
+ "combat": {
+ "attackAll": "Atakuj wszystkich",
+ "skip": "Pomiń",
+ "confirmAttackers": "Potwierdź atakujących ({{count}})",
+ "confirmBlockers": "Potwierdź blokujących ({{count}})",
+ "clickAttackerToAssignBlocker": "Kliknij atakującego, aby przydzielić blokującego",
+ "menaceRequirement": "Musi zostać zablokowany przez {{count}} lub wcale",
+ "blockNeedsBadge": "Potrzeba {{required}}",
+ "blockProgressBadge": "{{assigned}} / {{required}}",
+ "blockSatisfiedBadge": "✓ {{required}}",
+ "blockIncompleteAttacker": "{{assigned}} z {{required}} blokujących przydzielonych",
+ "blockIncomplete_one": "{{count}} atakujący potrzebuje kolejnego blokującego",
+ "blockIncomplete_other": "{{count}} atakujących potrzebuje kolejnego blokującego",
+ "assignDamageTitle": "Przydziel {{amount}} obrażeń bojowych",
+ "assignDamageSubtitle": "{{name}} — Pozostało: {{remaining}}",
+ "assignDamageButton": "Przydziel obrażenia",
+ "lethalLabel": "śmiertelne: {{amount}}",
+ "lethalBadge": "Śmiertelne",
+ "planeswalkerLoyalty": "Wędrowiec (lojalność: {{loyalty}})",
+ "defendingPlayerTrample": "Broniący gracz (Tratowanie)",
+ "pwControllerTrample": "Kontroler wędrowca (Tratowanie przez wędrowca)"
+ },
+ "stack": {
+ "title": "Stos",
+ "collapsePanel": "Zwiń panel stosu",
+ "expandPanel": "Rozwiń panel stosu",
+ "dockLeft": "Zadokuj stos po lewej",
+ "dockRight": "Zadokuj stos po prawej",
+ "casting": "Rzucanie…",
+ "next": "Dalej",
+ "activated": "Aktywowana",
+ "triggered": "Wyzwolona",
+ "triggeredFrom": "Wyzwolona — Od {{source}}",
+ "controllerYou": "Ty",
+ "controllerOpp": "Przec",
+ "controllerInitialYou": "T",
+ "controllerInitialOpp": "G{{seat}}",
+ "targetingLabel": "Obiera za cel {{label}}",
+ "titleTargets": "Cele: {{targets}}",
+ "titlePaid": "Zapłacono: {{paid}}",
+ "titleContext": "Kontekst: {{context}}",
+ "paidXValue": "X={{value}}",
+ "paidManaSpent": "{{amount}} many",
+ "paidColorsSpent": "{{count}} kolorów",
+ "paidKicked": "kicker",
+ "paidKickedTimes": "kicker ×{{count}}",
+ "paidAdditionalCost": "dodatkowy koszt",
+ "paidConvoked": "convoke ×{{count}}"
+ },
+ "targeting": {
+ "choosePermanentToCopy": "Wybierz trwałego do skopiowania",
+ "retargetCopySlot": "Zmień cel kopii: slot {{current}} z {{total}}",
+ "chooseTargetForCopySlot": "Wybierz cel dla kopii: slot {{current}} z {{total}}",
+ "chooseNewTargetForCopy": "Wybierz nowy cel dla kopii",
+ "chooseTargetForCopy": "Wybierz cel dla kopii",
+ "chooseNewTarget": "Wybierz nowy cel czaru",
+ "chooseNewTargetForSpell": "Wybierz nowy cel dla {{spell}}",
+ "chooseCreatureToExplore": "Wybierz, który stwór eksploruje jako następny",
+ "tapUntappedCreatures_one": "Obróć {{count}} nieobróconego stwora",
+ "tapUntappedCreatures_other": "Obróć {{count}} nieobróconych stworów",
+ "chooseTargetOf": "Wybierz cel {{current}} z {{total}}",
+ "chooseTarget": "Wybierz cel",
+ "noLegalTargets": "Brak dostępnych dozwolonych celów",
+ "upToOne": "do jednego {{target}}",
+ "one": "{{target}}",
+ "nounPlayer": "gracz",
+ "nounNonlandPermanent": "trwały niebędący ziemią",
+ "nounCreature": "stwór",
+ "nounPlaneswalker": "wędrowiec",
+ "nounTargetPermanent": "docelowy trwały",
+ "nounTarget": "cel",
+ "confirmTap": "Potwierdź obrócenie ({{selected}}/{{count}})",
+ "keepCurrentTargets": "Zachowaj bieżące cele",
+ "skip": "Pomiń"
+ },
+ "log": {
+ "title": "Dziennik gry",
+ "noEvents": "Brak zdarzeń",
+ "closeLog": "Zamknij dziennik gry",
+ "verbosityFull": "pełny",
+ "verbosityCompact": "kompaktowy",
+ "verbosityMinimal": "minimalny",
+ "gameStarted": "Gra rozpoczęta",
+ "turnStarted": "Tura {{turn}} -- {{player}}",
+ "phaseChanged": "Faza: {{phase}}",
+ "priorityPassed": "{{player}} przekazał priorytet",
+ "spellCast": "Czar rzucony przez {{player}}",
+ "abilityActivated": "Zdolność aktywowana (źródło {{sourceId}})",
+ "zoneChangedMoved": "Obiekt {{objectId}} przeniesiony {{from}} -> {{to}}",
+ "zoneChangedEnters": "Obiekt {{objectId}} wchodzi do {{to}}",
+ "lifeChanged": "Życie {{player}}: {{sign}}{{amount}}",
+ "manaAdded": "{{player}} dodał manę {{manaType}}",
+ "permanentTapped": "Trwały {{objectId}} obrócony",
+ "permanentUntapped": "Trwały {{objectId}} odwrócony",
+ "playerLost": "{{player}} przegrał grę",
+ "mulliganStarted": "Faza mulligana",
+ "cardsDrawn_one": "{{player}} dobrał {{count}} kartę",
+ "cardsDrawn_other": "{{player}} dobrał {{count}} kart",
+ "cardDrawn": "{{player}} dobrał kartę",
+ "landPlayed": "{{player}} zagrał ziemię",
+ "stackPushed": "Obiekt {{objectId}} dodany na stos",
+ "stackResolved": "Wpis stosu {{objectId}} rozpatrzony",
+ "discarded": "{{player}} odrzucił",
+ "damageCleared": "Obrażenia usunięte z {{objectId}}",
+ "gameOverWinner": "Koniec gry -- {{player}} wygrywa!",
+ "gameOverDraw": "Koniec gry -- Remis",
+ "damageDealtPlayer": "Źródło {{sourceId}} zadaje {{amount}} obrażeń graczowi {{player}}",
+ "damageDealtObject": "Źródło {{sourceId}} zadaje {{amount}} obrażeń obiektowi {{objectId}}",
+ "spellCountered": "Obiekt {{objectId}} skontrowany przez {{counteredBy}}",
+ "counterAdded": "{{counterType}} x{{count}} dodano do {{objectId}}",
+ "counterRemoved": "{{counterType}} x{{count}} usunięto z {{objectId}}",
+ "tokenCreated": "Token \"{{name}}\" utworzony",
+ "creatureDestroyed": "Stwór {{objectId}} zniszczony",
+ "permanentSacrificed": "{{player}} poświęcił {{objectId}}",
+ "effectResolved": "Efekt {{kind}} rozpatrzony",
+ "attackersDeclared_one": "{{count}} atakujący zadeklarowany",
+ "attackersDeclared_other": "{{count}} atakujących zadeklarowanych",
+ "blockersDeclared_one": "{{count}} blokujący przydzielony",
+ "blockersDeclared_other": "{{count}} blokujących przydzielonych",
+ "becomesTarget": "Obiekt {{objectId}} obrany za cel przez {{sourceId}}",
+ "replacementApplied": "Zastosowano efekt zastępujący: {{eventType}}",
+ "companionRevealed": "{{player}} ujawnił towarzysza: {{cardName}}",
+ "companionMovedToHand": "{{player}} wziął towarzysza {{cardName}} do ręki",
+ "powerToughnessChanged": "Obiekt {{objectId}} ma teraz {{power}}/{{toughness}} ({{powerDelta}}/{{toughnessDelta}})",
+ "genericEvent": "Zdarzenie: {{type}}"
+ },
+ "coverage": {
+ "engineTools": "Narzędzia silnika",
+ "title": "Pokrycie kart",
+ "subtitle": "Sprawdź pokrycie implementacji i obsługiwane handlery silnika.",
+ "tabCardCoverage": "Pokrycie kart",
+ "tabBySet": "Wg dodatku",
+ "tabGapAnalysis": "Analiza braków",
+ "tabSupportedHandlers": "Obsługiwane handlery",
+ "noDataAvailable": "Brak dostępnych danych o pokryciu.",
+ "generateHint": "Wygeneruj je za pomocą: cargo run --bin coverage-report -- /path/to/cards --all > client/public/coverage-data.json",
+ "emptyData": "Dane o pokryciu są puste (przeanalizowano 0 kart).",
+ "runHint": "Uruchom: cargo run --bin coverage-report -- /path/to/cards --all > client/public/coverage-data.json",
+ "searchCards": "Szukaj kart...",
+ "filterAll": "Wszystkie",
+ "filterSupported": "Obsługiwane",
+ "filterUnsupported": "Nieobsługiwane",
+ "sortAz": "Sortuj: A-Z",
+ "sortMostGaps": "Sortuj: Najwięcej braków",
+ "sortFewestGaps": "Sortuj: Najmniej braków",
+ "shownOfTotal": "{{shown}} z {{total}} wyświetlonych",
+ "noMatches": "Brak dopasowań",
+ "matchesCount": "{{shown}} z {{total}} dopasowań",
+ "cardsCount": "{{count}} kart",
+ "selectCardPrompt": "Wybierz kartę, aby sprawdzić jej rozbicie analizy",
+ "navHint": "Użyj strzałek do nawigacji, Escape do odznaczenia",
+ "coverageByFormat": "Pokrycie wg formatu",
+ "allFormats": "Wszystkie formaty",
+ "setThresholdHint": "Dodatki z ≥{{minCards}} kartami i ≥{{minCoverage}}% w pełni obsługiwanymi. Rozwiń dodatek, aby sprawdzić pozostałe braki.",
+ "setsCount": "({{count}} dodatków)",
+ "noSetsMeetThreshold": "Żaden dodatek nie spełnia jeszcze progu.",
+ "setMembershipMissing": "Dane o przynależności do dodatku nie znajdują się w tym eksporcie pokrycia.",
+ "regenerateHint": "Wygeneruj ponownie za pomocą: ./scripts/gen-card-data.sh",
+ "gapCount_one": "{{count}} brak",
+ "gapCount_other": "{{count}} braków",
+ "noUnsupportedInSet": "Brak nieobsługiwanych kart w tym dodatku.",
+ "unsupportedCardsHint": "Nieobsługiwane karty ({{count}}) — kliknij jedną, aby sprawdzić drzewo analizy",
+ "noGapDetails": "brak szczegółów braku",
+ "noGapAnalysis": "Brak dostępnych danych analizy braków.",
+ "filterByFormat": "Filtruj wg formatu:",
+ "topGaps": "Najważniejsze braki wg wpływu (Top 50)",
+ "copyAsTsv": "Kopiuj jako TSV",
+ "totalLabel": "{{count}} łącznie",
+ "unlockLabel": "{{count}} odblokuje",
+ "indLabel": "{{ratio}} ind",
+ "exampleCard": "np. {{name}}",
+ "oraclePatterns": "Wzorce tekstu Oracle",
+ "coOccurringGaps": "Współwystępujące braki",
+ "singleGapUnlockByFormat": "Odblokowanie jednym brakiem wg formatu",
+ "twoGapBundles": "Pakiety 2 braków (zaimplementuj oba, aby odblokować karty)",
+ "threeGapBundles": "Pakiety 3 braków (zaimplementuj wszystkie trzy, aby odblokować karty)",
+ "bundleCards": "{{count}} kart",
+ "backToList": "Powrót do listy",
+ "supported": "Obsługiwane",
+ "unsupported": "Nieobsługiwane",
+ "copyOracle": "Kopiuj tekst Oracle",
+ "unresolvedGaps": "Nierozwiązane braki ({{count}})",
+ "vanillaCard": "Brak przeanalizowanych elementów (karta vanilla).",
+ "additionalParseItems": "Dodatkowe elementy analizy",
+ "itemUnsupported": "nieobsługiwane",
+ "categoryKeyword": "Słowo kluczowe",
+ "categoryAbility": "Zdolność",
+ "categoryTrigger": "Wyzwalacz",
+ "categoryStatic": "Statyczna",
+ "categoryReplacement": "Zastępująca",
+ "categoryCost": "Koszt",
+ "copied": "Skopiowano!",
+ "handlersObserved": "{{count}} handlerów zaobserwowanych na obsługiwanych kartach",
+ "derivedFrom": "wyprowadzone z wyniku analizy · zaślepki i nieużywane warianty wykluczone",
+ "tabEffects": "Efekty",
+ "tabTriggers": "Wyzwalacze",
+ "tabKeywords": "Słowa kluczowe",
+ "tabStatics": "Statyczne",
+ "tabReplacements": "Zastępujące",
+ "search": "Szukaj...",
+ "cardCount_one": "{{formattedCount}} karta",
+ "cardCount_other": "{{formattedCount}} kart",
+ "noMatchesFound": "Nie znaleziono dopasowań dla „{{query}}”",
+ "noHandlersInCategory": "Żadne handlery z tej kategorii nie są tworzone przez parser na w pełni obsługiwanych kartach.",
+ "footerSummary": "{{handlers}} handlerów w {{categories}} kategoriach · wyprowadzone z silnika z {{cards}} obsługiwanych kart"
+ },
+ "gamePage": {
+ "actions": {
+ "returnToMenu": "Powrót do menu",
+ "returnToMenuLower": "Powrót do menu",
+ "concedeGame": "Poddaj grę",
+ "ok": "OK"
+ },
+ "toasts": {
+ "playerDisconnected": "{{name}} rozłączył się",
+ "gamePausedPlayerDisconnected": "Gra wstrzymana — {{name}} rozłączył się",
+ "roomFull": "Pokój pełny — gotowe do rozpoczęcia!",
+ "hostReconnecting": "Gospodarz rozłączył się — ponowne łączenie (próba {{attempt}})…",
+ "roomStillClaimed": "Twój poprzedni pokój wciąż jest wygaszany na serwerze sygnalizacyjnym. Odczekaj około minuty, a potem spróbuj wznowić ponownie."
+ },
+ "sandbox": {
+ "banner": "Tryb piaskownicy — akcje debugowania włączone",
+ "bannerAria": "Baner trybu piaskownicy"
+ },
+ "reconnect": {
+ "banner": "Ponowne łączenie… (próba {{attempt}})",
+ "bannerWithMax": "Ponowne łączenie… (próba {{attempt}}/{{maxAttempts}})",
+ "connectionLost": "Utracono połączenie"
+ },
+ "opponentDisconnected": {
+ "title": "Przeciwnik rozłączony",
+ "body": "Oczekiwanie na ponowne połączenie przeciwnika..."
+ },
+ "resumeReset": {
+ "message": "{{reason}} Rozpoczęto nową grę."
+ },
+ "playDraw": {
+ "title": "Gra {{gameNumber}}: Wybierz zagrywanie lub dobieranie",
+ "matchScore": "Wynik meczu {{p0Wins}}-{{p1Wins}}",
+ "playFirst": "Zagrywaj pierwszy",
+ "playFirstDescription": "Wykonaj pierwszą turę",
+ "drawFirst": "Dobieraj pierwszy",
+ "drawFirstDescription": "Wykonaj dodatkowe dobranie w swojej pierwszej turze"
+ },
+ "mulligan": {
+ "opponentDeciding": "Przeciwnik wybiera swoją rękę startową…",
+ "londonTitle": "Mulligan londyński ({{count}} wziętych)",
+ "keepHand": "Zatrzymaj rękę",
+ "putOnBottom": "Połóż {{count}} na spód",
+ "noCardsToBottom": "Brak kart na spód",
+ "mulligan": "Mulligan",
+ "freeMulligan": "Darmowy mulligan",
+ "mulliganTo": "Mulligan do {{count}}",
+ "shuffleDrawSevenFree": "Przetasuj i dobierz 7 — brak kart na spód",
+ "shuffleDrawSevenAgain": "Przetasuj i dobierz 7 ponownie",
+ "usePowder": "Użyj {{name}}",
+ "powderDescription": "Wygnaj każdą kartę z ręki, dobierz tyle samo — to nie mulligan",
+ "powderTooltip": "Wygnaj każdą kartę z ręki i dobierz tyle samo. To nie mulligan — liczba i karty na spodzie pozostają bez zmian.",
+ "eyebrowMulligan": "Mulligan {{count}} · Londyński",
+ "eyebrowOpening": "Ręka startowa · Mulligan londyński",
+ "reviewTitle": "Przejrzyj swoją rękę startową",
+ "subtitleKeepWithBottom": "Zatrzymaj tę rękę (położysz {{count}} na spód) lub zrób mulligan ponownie po świeże 7.",
+ "subtitleKeepFree": "Zatrzymaj tę rękę (darmowy mulligan — brak kart na spód) lub zrób mulligan ponownie po świeże 7.",
+ "subtitleOpeningFree": "Zatrzymaj tę rękę lub zrób darmowy mulligan (brak kart na spód).",
+ "subtitleOpeningOne": "Zatrzymaj tę rękę lub zrób mulligan po świeże 7 (gdy zatrzymasz, położysz 1 na spód)."
+ },
+ "companion": {
+ "eyebrow": "Przed grą",
+ "title": "Ujawnić towarzysza?",
+ "subtitle": "Możesz ujawnić towarzysza ze swojego sideboardu. Zostanie umieszczony w strefie towarzysza i można go wziąć do ręki raz w trakcie gry, płacąc {3}.",
+ "decline": "Odmów",
+ "reveal": "Ujawnij {{name}}"
+ },
+ "bottomCards": {
+ "eyebrowTinyLeaders": "Tiny Leaders",
+ "eyebrowLondon": "Mulligan londyński",
+ "title_one": "Połóż {{count}} kartę na spód",
+ "title_other": "Połóż {{count}} kart na spód",
+ "subtitleOpening_one": "Wybierz {{count}} kartę z ręki startowej przed rozpoczęciem mulliganów.",
+ "subtitleOpening_other": "Wybierz {{count}} kart z ręki startowej przed rozpoczęciem mulliganów.",
+ "subtitleMulligan_one": "Wybierz {{count}} kartę z ręki. Zostanie zwrócona na spód biblioteki w wybranej tutaj kolejności.",
+ "subtitleMulligan_other": "Wybierz {{count}} kart z ręki. Zostaną zwrócone na spód biblioteki w wybranej tutaj kolejności.",
+ "selectedOf": "Wybrano {{selected}} z {{count}}",
+ "confirmSelection": "Potwierdź wybór"
+ },
+ "gameOver": {
+ "draw": "REMIS",
+ "victory": "ZWYCIĘSTWO",
+ "defeat": "PORAŻKA",
+ "lifeSummary": "Ty: {{playerLife}} / Przeciwnik: {{opponentLife}} ",
+ "turns": "Tury: {{count}}",
+ "duration": "Czas trwania: {{time}}",
+ "backToDraft": "Powrót do draftu",
+ "continueRun": "Kontynuuj rozgrywkę",
+ "backToLobby": "Powrót do lobby",
+ "rematch": "Rewanż"
+ },
+ "abilityChoice": {
+ "subtitleSneak": "Wybierz, którego atakującego zwrócić (koszt Sneak)",
+ "subtitlePreparedCopy": "Rzucić przygotowany czar?",
+ "subtitleActivate": "Aktywować tę zdolność?",
+ "subtitlePlay": "Wybierz, jak zagrać tę kartę",
+ "subtitleChoose": "Wybierz zdolność do aktywowania"
+ },
+ "defiler": {
+ "title": "Redukcja kosztu Defiler",
+ "subtitle": "Zapłacić {{lifeCost}} życia, aby zmniejszyć koszt many?",
+ "payLife": "Zapłać {{lifeCost}} życia",
+ "decline": "Odmów"
+ },
+ "untap": {
+ "permanentFallback": "Trwały",
+ "title": "Odwrócić {{name}}?",
+ "subtitle": "Wybierz, czy ten trwały odwraca się w trakcie twojego kroku odwracania.",
+ "untap": "Odwróć",
+ "untapDescription": "{{name}} odwraca się teraz.",
+ "keepTapped": "Zostaw obrócony",
+ "keepTappedDescription": "{{name}} pozostaje obrócony w tym kroku odwracania."
+ },
+ "cost": {
+ "discardCard": "odrzuć kartę",
+ "life": "{{amount}} życia",
+ "sacrifice_one": "poświęć trwałego",
+ "sacrifice_other": "poświęć {{count}} trwałych",
+ "returnToHand_one": "zwróć trwałego do ręki",
+ "returnToHand_other": "zwróć {{count}} trwałych do ręki",
+ "energy": "{{amount}} energii",
+ "generic": "koszt",
+ "pay": "Zapłać {{cost}}"
+ },
+ "unlessPayment": {
+ "defaultEffect": "Skontruj, chyba że zapłacisz",
+ "defaultChooseCost": "Wybierz koszt",
+ "title": "{{effect}}, chyba że zapłacisz",
+ "titleChooseOne": "{{effect}}, chyba że zapłacisz jeden",
+ "dontPay": "Nie płać",
+ "takeEffect": "Przyjmij efekt"
+ },
+ "activationCost": {
+ "title": "Wybierz koszt aktywacji"
+ },
+ "debug": {
+ "modeBanner": "TRYB DEBUGOWANIA - Kliknij dowolną kartę"
+ }
+ },
+ "gameProvider": {
+ "passwordPrompt": "Ten pokój wymaga hasła:",
+ "toasts": {
+ "connectionFailed": "Połączenie nie powiodło się. Spróbuj ponownie lub zmień serwer w Ustawieniach.",
+ "eliminatedSpectating": "Zostałeś wyeliminowany. Teraz obserwujesz."
+ },
+ "resumeReset": {
+ "appUpdated": "Aplikacja została zaktualizowana, a zapisana gra jest niezgodna z nową wersją.",
+ "restoreFailed": "Nie udało się przywrócić zapisanej gry: {{error}}"
+ },
+ "notification": {
+ "title": "Przeciwnik dołączył",
+ "opponentJoined": "Twój przeciwnik dołączył do gry. Wróć i graj!",
+ "opponentJoinedNamed": "{{name}} dołączył do twojej gry. Wróć i graj!"
+ },
+ "noLegalAiDecks": {
+ "withFormat": "Brak dozwolonych talii AI dla formatu {{format}}.",
+ "generic": "Brak dozwolonych talii AI dla wybranego formatu."
+ }
+ },
+ "gameSetup": {
+ "eyebrow": "Konfiguracja meczu",
+ "title": "Rozpocznij mecz.",
+ "startMatch": "Rozpocznij mecz",
+ "startMatchWithOpponents": "Rozpocznij mecz ({{count}} przec.)",
+ "deckNotLegal": "Talia nie jest dozwolona w formacie {{format}}.",
+ "selectDeckPrompt": "Wybierz talię",
+ "commanderNote": "Commander: 100 kart singleton, obrażenia od dowódcy przy {{threshold}}",
+ "formatChip": {
+ "kicker": "Format",
+ "choosePlaceholder": "Wybierz format",
+ "ariaLabel": "Format: {{label}} ({{group}}). Dotknij, aby zmienić.",
+ "ariaLabelEmpty": "Wybierz format meczu"
+ },
+ "formatPicker": {
+ "title": "Wybierz format",
+ "subtitle": "Wybierz zasady, według których będą grać wszyscy przy stole."
+ },
+ "deckPreview": {
+ "editDeck": "Edytuj {{name}}",
+ "cardCount_one": "{{count}} karta",
+ "cardCount_other": "{{count}} kart",
+ "unknownBadge": "Nieznane {{count}}",
+ "unknownCardsTitle": "Nieznane karty:\n{{cards}}"
+ },
+ "config": {
+ "startingLife": "Życie początkowe",
+ "players": "Gracze",
+ "whoGoesFirst": "Kto zaczyna",
+ "firstPlayer": {
+ "random": "Losowo",
+ "play": "Zagrywanie",
+ "draw": "Dobieranie"
+ }
+ },
+ "noLegalAiDecks": {
+ "withFormat": "Dodaj lub zaimportuj talię dozwoloną w formacie {{format}}, aby AI mogło jej użyć.",
+ "generic": "Dodaj lub zaimportuj talię dozwoloną w tym formacie, aby AI mogło jej użyć."
+ }
+ },
+ "cardChoice": {
+ "badges": {
+ "choose": "Wybierz",
+ "selected": "Wybrano",
+ "keep": "Zatrzymaj",
+ "top": "Wierzch",
+ "bottom": "Spód",
+ "graveyard": "Cmentarz",
+ "put": "Połóż",
+ "sacrifice": "Poświęć",
+ "exile": "Wygnaj",
+ "battlefield": "Pole bitwy",
+ "target": "Cel",
+ "return": "Zwróć",
+ "discard": "Odrzuć",
+ "evidence": "Dowody",
+ "manifest": "Manifest",
+ "crew": "Crew ({{power}})",
+ "saddle": "Saddle ({{power}})",
+ "station": "Station (+{{power}})"
+ },
+ "buttons": {
+ "confirm": "Potwierdź",
+ "decline": "Odmów",
+ "pass": "Przekaż",
+ "skip": "Pomiń",
+ "repeat": "Powtórz",
+ "stop": "Zatrzymaj",
+ "confirmCount": "Potwierdź ({{selected}}/{{count}})",
+ "confirmOrder": "Potwierdź kolejność ({{selected}}/{{count}})",
+ "sacrificeCount": "Poświęć ({{selected}}/{{count}})",
+ "exileCount": "Wygnaj ({{selected}}/{{count}})",
+ "discardCount": "Odrzuć ({{selected}}/{{count}})",
+ "collectCount": "Zbierz ({{total}}/{{minimum}})",
+ "labelCount": "{{label}} ({{selected}}/{{count}})"
+ },
+ "ringBearer": {
+ "title": "Wybierz Nosiciela Pierścienia",
+ "subtitle": "Wybierz stwora, którego kontrolujesz"
+ },
+ "reorderHint": "Przeciągnij, aby zmienić kolejność · karta na wierzchu jest dobierana pierwsza",
+ "scry": {
+ "title": "Scry",
+ "subtitle_one": "Spójrz na {{count}} kartę z wierzchu swojej biblioteki",
+ "subtitle_other": "Spójrz na {{count}} kart z wierzchu swojej biblioteki"
+ },
+ "dig": {
+ "titleReorder": "Zmień kolejność kart",
+ "title": "Wybierz karty",
+ "subtitleReorder": "Wybierz wszystkie {{count}} kart w kolejności od wierzchu do spodu",
+ "subtitleExact_one": "Wybierz {{count}} kartę, aby położyć {{destination}}",
+ "subtitleExact_other": "Wybierz {{count}} kart, aby położyć {{destination}}",
+ "subtitleUpTo_one": "Wybierz do {{count}} karty, aby położyć {{destination}}",
+ "subtitleUpTo_other": "Wybierz do {{count}} kart, aby położyć {{destination}}",
+ "destinationTop": "na wierzchu twojej biblioteki",
+ "destinationBattlefield": "na pole bitwy",
+ "destinationHand": "do twojej ręki"
+ },
+ "surveil": {
+ "title": "Surveil",
+ "subtitle_one": "Spójrz na {{count}} kartę z wierzchu swojej biblioteki",
+ "subtitle_other": "Spójrz na {{count}} kart z wierzchu swojej biblioteki"
+ },
+ "reveal": {
+ "titleReveal": "Ujawnij z ręki",
+ "titleOpponentHand": "Ręka przeciwnika",
+ "subtitleReveal": "Wybierz kartę do ujawnienia lub odmów",
+ "subtitleChoose": "Wybierz kartę"
+ },
+ "search": {
+ "title": "Przeszukaj bibliotekę",
+ "subtitleExact_one": "Wybierz {{count}} kartę",
+ "subtitleExact_other": "Wybierz {{count}} kart",
+ "subtitleUpTo_one": "Wybierz do {{count}} karty",
+ "subtitleUpTo_other": "Wybierz do {{count}} kart",
+ "subtitleMatchExact_one": "Wybierz {{count}} kartę spełniającą wymienione kryteria wyszukiwania",
+ "subtitleMatchExact_other": "Wybierz {{count}} kart spełniających wymienione kryteria wyszukiwania",
+ "subtitleMatchUpTo_one": "Wybierz do {{count}} karty spełniającej wymienione kryteria wyszukiwania",
+ "subtitleMatchUpTo_other": "Wybierz do {{count}} kart spełniających wymienione kryteria wyszukiwania",
+ "subtitleDistinctExact_one": "Wybierz {{count}} kartę o odrębnych cechach",
+ "subtitleDistinctExact_other": "Wybierz {{count}} kart o odrębnych cechach",
+ "subtitleDistinctUpTo_one": "Wybierz do {{count}} karty o odrębnych cechach",
+ "subtitleDistinctUpTo_other": "Wybierz do {{count}} kart o odrębnych cechach",
+ "subtitleManaValueExact_one": "Wybierz {{count}} kartę w granicach limitu wartości many",
+ "subtitleManaValueExact_other": "Wybierz {{count}} kart w granicach limitu wartości many",
+ "subtitleManaValueUpTo_one": "Wybierz do {{count}} karty w granicach limitu wartości many",
+ "subtitleManaValueUpTo_other": "Wybierz do {{count}} kart w granicach limitu wartości many"
+ },
+ "searchPartition": {
+ "title": "Wybierz karty na pole bitwy",
+ "subtitle_one": "Wybierz {{count}} kartę, aby położyć na pole bitwy{{tapped}}; reszta trafia do twojej ręki",
+ "subtitle_other": "Wybierz {{count}} kart, aby położyć na pole bitwy{{tapped}}; reszta trafia do twojej ręki",
+ "tapped": " obrócone"
+ },
+ "outsideGame": {
+ "title": "Wybierz z sideboardu",
+ "subtitleExact": "Wybierz {{count}}",
+ "subtitleUpTo": "Wybierz do {{count}}"
+ },
+ "chooseFromZone": {
+ "title": "Wybierz karty",
+ "subtitleExact_one": "Wybierz {{count}} kartę",
+ "subtitleExact_other": "Wybierz {{count}} kart",
+ "subtitleUpTo_one": "Wybierz do {{count}} karty",
+ "subtitleUpTo_other": "Wybierz do {{count}} kart",
+ "subtitleDistinctCardTypes": "Wybierz do {{count}} kart o odrębnych typach kart"
+ },
+ "pair": {
+ "title": "Wybierz partnera Soulbond",
+ "subtitle": "Połącz z niepołączonym stworem, którego kontrolujesz"
+ },
+ "effectZone": {
+ "titleSacrifice": "Poświęć",
+ "titleTopdeck": "Połóż na bibliotekę",
+ "titleBattlefield": "Połóż na pole bitwy",
+ "subtitleSacrificeExact_one": "Wybierz {{count}} trwałego do poświęcenia",
+ "subtitleSacrificeExact_other": "Wybierz {{count}} trwałych do poświęcenia",
+ "subtitleSacrificeUpTo_one": "Wybierz do {{count}} trwałego do poświęcenia",
+ "subtitleSacrificeUpTo_other": "Wybierz do {{count}} trwałych do poświęcenia",
+ "subtitleSacrificeRange_one": "Wybierz {{min}}-{{count}} trwałego do poświęcenia",
+ "subtitleSacrificeRange_other": "Wybierz {{min}}-{{count}} trwałych do poświęcenia",
+ "subtitleTopdeckExact_one": "Wybierz {{count}} kartę, aby położyć na wierzchu swojej biblioteki",
+ "subtitleTopdeckExact_other": "Wybierz {{count}} kart, aby położyć na wierzchu swojej biblioteki",
+ "subtitleTopdeckUpTo_one": "Wybierz do {{count}} karty, aby położyć na wierzchu swojej biblioteki",
+ "subtitleTopdeckUpTo_other": "Wybierz do {{count}} kart, aby położyć na wierzchu swojej biblioteki",
+ "subtitleTopdeckRange_one": "Wybierz {{min}}-{{count}} kartę, aby położyć na wierzchu swojej biblioteki",
+ "subtitleTopdeckRange_other": "Wybierz {{min}}-{{count}} kart, aby położyć na wierzchu swojej biblioteki",
+ "subtitleBattlefieldExact_one": "Wybierz {{count}} kartę, aby położyć na pole bitwy",
+ "subtitleBattlefieldExact_other": "Wybierz {{count}} kart, aby położyć na pole bitwy",
+ "subtitleBattlefieldUpTo_one": "Wybierz do {{count}} karty, aby położyć na pole bitwy",
+ "subtitleBattlefieldUpTo_other": "Wybierz do {{count}} kart, aby położyć na pole bitwy",
+ "subtitleBattlefieldRange_one": "Wybierz {{min}}-{{count}} kartę, aby położyć na pole bitwy",
+ "subtitleBattlefieldRange_other": "Wybierz {{min}}-{{count}} kart, aby położyć na pole bitwy",
+ "labelSkip": "Pomiń",
+ "labelDecline": "Odmów",
+ "labelPutOnTop": "Połóż na wierzch ({{order}})",
+ "labelConfirm": "Potwierdź ({{selected}}/{{count}})",
+ "labelTop": "Wierzch ({{selected}}/{{count}})",
+ "labelPut": "Połóż ({{selected}}/{{count}})",
+ "orderTop": "Wierzch"
+ },
+ "drawnThisTurn": {
+ "title": "Dobrane w tej turze",
+ "subtitle": "Połóż do {{count}} na wierzch; zapłać {{life}} życia za każdą zatrzymaną",
+ "labelPayLife": "Zapłać {{life}} życia",
+ "labelConfirm": "Potwierdź ({{selected}}/{{count}})"
+ },
+ "sacrifice": {
+ "title": "Poświęć",
+ "subtitle_one": "Wybierz {{count}} trwałego do poświęcenia",
+ "subtitle_other": "Wybierz {{count}} trwałych do poświęcenia"
+ },
+ "exileBattlefield": {
+ "title": "Wygnaj",
+ "subtitle_one": "Wybierz {{count}} trwałego do wygnania",
+ "subtitle_other": "Wybierz {{count}} trwałych do wygnania"
+ },
+ "multiTarget": {
+ "title": "Wybierz cele",
+ "subtitleExact_one": "Wybierz {{count}} cel",
+ "subtitleExact_other": "Wybierz {{count}} celów",
+ "subtitleRange": "Wybierz {{min}}–{{max}} celów"
+ },
+ "paradigm": {
+ "title": "Paradigm",
+ "subtitle": "Rzuć kopię jednego z tych czarów bez płacenia jego kosztu many lub przekaż."
+ },
+ "payManaAbility": {
+ "title": "Zapłać koszt zdolności manowej",
+ "subtitle": "Wybierz, którą manę wydać"
+ },
+ "returnToHand": {
+ "title": "Zwróć",
+ "subtitle_one": "Wybierz {{count}} trwałego do zwrócenia",
+ "subtitle_other": "Wybierz {{count}} trwałych do zwrócenia"
+ },
+ "removeCounter": {
+ "title": "Usuń znacznik",
+ "subtitle": "Wybierz trwałego, z którego usunąć znacznik",
+ "label": "Usuń"
+ },
+ "blight": {
+ "title": "Blight",
+ "subtitle_one": "Połóż znacznik -1/-1 na {{count}} stwora, którego kontrolujesz",
+ "subtitle_other": "Połóż znacznik -1/-1 na {{count}} stworów, których kontrolujesz"
+ },
+ "crew": {
+ "title": "Załoguj pojazd",
+ "subtitle": "Obróć stwory o łącznej sile {{power}} lub większej",
+ "label": "Crew ({{total}}/{{power}})"
+ },
+ "station": {
+ "title": "Station",
+ "subtitle": "Obróć innego nieobróconego stwora, którego kontrolujesz. Dodana liczba znaczników ładunku równa jest jego sile.",
+ "labelWithCharge": "Station (+{{charge}} ładunku)",
+ "label": "Station"
+ },
+ "saddle": {
+ "title": "Osiodłaj wierzchowca",
+ "subtitle": "Obróć stwory o łącznej sile {{power}} lub większej",
+ "label": "Saddle ({{total}}/{{power}})"
+ },
+ "wardSacrifice": {
+ "title_one": "Ward — Poświęć trwałego",
+ "title_other": "Ward — Poświęć {{count}} trwałych",
+ "subtitle": "Wybierz trwałego do poświęcenia"
+ },
+ "unlessBounce": {
+ "title_one": "Zwróć trwałego do ręki",
+ "title_other": "Zwróć {{count}} trwałych do ręki",
+ "subtitle": "Wybierz trwałego do zwrócenia do ręki jego właściciela"
+ },
+ "exileForCost": {
+ "titleAlternative": "Koszt alternatywny",
+ "titleEscape": "Escape",
+ "sourceHand": "twojej ręki",
+ "sourceGraveyard": "twojego cmentarza",
+ "subtitle_one": "Wygnaj {{count}} kartę z {{source}}",
+ "subtitle_other": "Wygnaj {{count}} kart z {{source}}"
+ },
+ "behold": {
+ "title": "Behold",
+ "subtitleExile": "Wygnaj pasującego trwałego lub kartę",
+ "subtitleChoose": "Wybierz pasującego trwałego lub ujawnij pasującą kartę",
+ "labelExile": "Wygnaj",
+ "labelBehold": "Behold"
+ },
+ "collectEvidence": {
+ "title": "Zbierz dowody",
+ "subtitle": "Wygnaj karty ze swojego cmentarza o łącznej wartości many {{minimum}} lub większej"
+ },
+ "discard": {
+ "title": "Odrzuć",
+ "titleAdditionalCost": "Odrzuć jako koszt dodatkowy",
+ "titleManaAbility": "Odrzuć dla zdolności manowej",
+ "titleConnive_one": "Connive — Odrzuć kartę",
+ "titleConnive_other": "Connive — Odrzuć {{count}} kart",
+ "titleUpTo": "Odrzuć do {{count}} kart",
+ "titleExact_one": "Odrzuć kartę",
+ "titleExact_other": "Odrzuć {{count}} kart",
+ "titleWard": "Ward — Odrzuć kartę",
+ "subtitleUpTo_one": "Wybierz do {{count}} karty do odrzucenia",
+ "subtitleUpTo_other": "Wybierz do {{count}} kart do odrzucenia",
+ "subtitleUnless": "Wybierz {{count}} kart lub 1 pasującą kartę do odrzucenia",
+ "subtitleExact_one": "Wybierz {{count}} kartę do odrzucenia",
+ "subtitleExact_other": "Wybierz {{count}} kart do odrzucenia"
+ },
+ "harmonize": {
+ "title": "Harmonize",
+ "subtitle": "Obróć stwora, aby zmniejszyć koszt rzucania o jego siłę, lub pomiń",
+ "labelSkip": "Pomiń (zapłać pełny koszt)"
+ },
+ "legend": {
+ "title": "Zasada legend",
+ "subtitle": "Wybierz, którego „{{name}}” zatrzymać",
+ "keepAria": "Zatrzymaj {{name}} ({{status}})",
+ "statusJustEntered": "Właśnie wszedł",
+ "statusAlready": "Już na polu bitwy"
+ },
+ "commanderZone": {
+ "title": "Strefa dowodzenia",
+ "subtitle": "{{name}} został umieszczony w strefie {{zone}}. Zwrócić do strefy dowodzenia?",
+ "commanderFallback": "Dowódca",
+ "labelCommandZone": "Strefa dowodzenia",
+ "labelLeave": "Zostaw w {{zone}}"
+ },
+ "revealUntil": {
+ "title": "Ujawniaj, aż",
+ "subtitle": "Położyć {{name}} na pole bitwy?",
+ "cardFallback": "ujawnioną kartę",
+ "labelBattlefield": "Na pole bitwy",
+ "labelInto": "Do {{zone}}"
+ },
+ "repeatProcess": {
+ "title": "Powtórz ten proces",
+ "subtitle": "Powtórzyć ten proces ponownie?"
+ },
+ "damageSource": {
+ "title": "Źródło obrażeń",
+ "subtitle": "Wybierz źródło"
+ },
+ "manifestDread": {
+ "title": "Manifest Dread",
+ "subtitle": "Wybierz kartę do zmanifestowania zakrytej. Druga trafi na twój cmentarz.",
+ "label": "Potwierdź Manifest"
+ },
+ "manaColor": {
+ "title": "Wybierz kolor many",
+ "subtitleBatch": "Wybierz kolor, a następnie ile źródeł obrócić",
+ "subtitle": "Wybierz, jaki kolor many wytworzyć",
+ "howMany": "Ile?",
+ "tapFewer": "Obróć mniej",
+ "tapMore": "Obróć więcej",
+ "labelAdd": "Dodaj {{count}}",
+ "labelConfirm": "Potwierdź"
+ },
+ "manaCombination": {
+ "title": "Wybierz kombinację many",
+ "subtitleAny": "Wybierz każdy kolor many do wytworzenia",
+ "subtitle": "Wybierz, jaką kombinację many wytworzyć"
+ }
+ },
+ "dialogShell": {
+ "eyebrow": "Wybór w grze",
+ "restoreDialog": "Przywróć okno dialogowe",
+ "peekAria": "Odsuń okno dialogowe na bok",
+ "peekTitle": "Podejrzyj pole bitwy",
+ "close": "Zamknij",
+ "closeTitle": "Zamknij (Esc)"
+ },
+ "choiceOverlay": {
+ "eyebrow": "Wybór w grze",
+ "scrollLeft": "Przewiń w lewo",
+ "scrollRight": "Przewiń w prawo",
+ "confirm": "Potwierdź",
+ "cancel": "Anuluj"
+ },
+ "adventureCast": {
+ "eyebrow": "Adventure",
+ "title": "Wybierz stronę",
+ "subtitle": "Rzuć jako stwór lub jako czar Adventure.",
+ "adventureFallback": "Adventure",
+ "castNamed": "Rzuć {{name}}",
+ "creatureTag": "(Stwór)",
+ "adventureTag": "(Adventure)"
+ },
+ "alternativeCost": {
+ "title": "Wybierz koszt rzucania",
+ "warpEyebrow": "Warp",
+ "warpNormalLabel": "Rzuć normalnie",
+ "warpAltLabel": "Rzuć z Warp",
+ "warpAltSuffix": "(wygnanie w kroku końcowym)",
+ "warpSubtitle": "Rzuć {{name}} normalnie lub użyj jego kosztu Warp.",
+ "evokeEyebrow": "Evoke",
+ "evokeNormalLabel": "Rzuć normalnie",
+ "evokeAltLabel": "Rzuć z Evoke",
+ "evokeSubtitle": "Rzuć {{name}} normalnie lub rzuć go za jego koszt Evoke.",
+ "overloadEyebrow": "Overload",
+ "overloadNormalLabel": "Rzuć normalnie",
+ "overloadAltLabel": "Rzuć z Overload",
+ "overloadSubtitle": "Rzuć {{name}} normalnie, obierając za cel jednego trwałego, lub zapłać jego koszt Overload, aby wpłynąć na wszystkie poprawne cele.",
+ "bestowEyebrow": "Bestow",
+ "bestowNormalLabel": "Rzuć jako stwora",
+ "bestowAltLabel": "Rzuć z Bestow",
+ "bestowSubtitle": "Rzuć {{name}} normalnie jako stwora lub zapłać jego koszt Bestow, aby rzucić go jako Aurę.",
+ "additionalExile": "+ Wygnaj kartę",
+ "additionalSacrifice": "+ Poświęć trwałego",
+ "additionalPayLife": "+ Zapłać życie",
+ "additionalDiscard": "+ Odrzuć kartę",
+ "additionalTapCreatures": "+ Obróć stwory",
+ "additionalGeneric": "+ {{type}}"
+ },
+ "battleProtector": {
+ "title": "Wybierz obrońcę",
+ "battleFallback": "Bitwa",
+ "subtitle": "{{name}} potrzebuje nowego obrońcy. Wybierz, który przeciwnik będzie jej bronił."
+ },
+ "cardDataMissing": {
+ "title": "Brak danych kart",
+ "body": "Nie znaleziono card-data.json. Gra potrzebuje definicji kart, aby grać prawdziwymi kartami.",
+ "generatePrompt": "Wygeneruj je, uruchamiając:",
+ "placeFile": "Następnie umieść plik wynikowy w client/public/card-data.json i odśwież.",
+ "continueAnyway": "Kontynuuj mimo to"
+ },
+ "cascadeChoice": {
+ "cascadeEyebrow": "Cascade",
+ "discoverEyebrow": "Discover",
+ "title": "Rzucić {{name}}?",
+ "subtitleCascade": "Cascade wygnało {{name}} (wartość many poniżej {{sourceMv}}). Rzuć go bez płacenia jego kosztu many lub odmów i przetasuj wszystkie {{total}} wygnanych kart na spód swojej biblioteki.",
+ "subtitleDiscover": "Discover wygnało {{name}}. Rzuć go bez płacenia jego kosztu many lub weź go do ręki i przetasuj pozostałe {{missCount}} wygnanych kart na spód swojej biblioteki.",
+ "castNamed": "Rzuć {{name}}",
+ "castSuffix": "(bez płacenia jego kosztu many)",
+ "putIntoHand": "Weź do ręki",
+ "decline": "Odmów",
+ "discoverDeclineSuffix": "(połóż resztę na spód)",
+ "cascadeDeclineSuffix": "(przetasuj wszystkie wygnane karty na spód)"
+ },
+ "castingVariant": {
+ "eyebrow": "Rzuć",
+ "title": "Wybierz rzucanie",
+ "variantNormal": "Rzuć normalnie",
+ "variantAdventure": "Rzuć jako Adventure",
+ "variantOmen": "Rzuć jako Omen",
+ "variantWarp": "Rzuć z Warp",
+ "variantEscape": "Rzuć z Escape",
+ "variantRetrace": "Rzuć z Retrace",
+ "variantHarmonize": "Rzuć z Harmonize",
+ "variantFlashback": "Rzuć z Flashback",
+ "variantAftermath": "Rzuć z Aftermath",
+ "variantGraveyardPermission": "Rzuć z cmentarza",
+ "variantHandPermission": "Rzuć z ręki",
+ "variantMiracle": "Rzuć z Miracle",
+ "variantMadness": "Rzuć z Madness",
+ "variantEvoke": "Rzuć z Evoke",
+ "variantSuspend": "Rzuć z Suspend",
+ "variantPlot": "Rzuć z Plot",
+ "variantForetell": "Rzuć z Foretell",
+ "variantOverload": "Rzuć z Overload",
+ "variantBestow": "Rzuć z Bestow",
+ "variantFallback": "Rzuć z {{type}}"
+ },
+ "categoryChoice": {
+ "title": "Wybierz trwałe do zatrzymania",
+ "subtitleOpponent": "Wybierz po jednym trwałym każdego typu spośród trwałych niebędących ziemiami, które kontroluje {{name}}; reszta zostaje poświęcona.",
+ "subtitleSelf": "Wybierz po jednym trwałym każdego typu spośród trwałych niebędących ziemiami, które kontrolujesz; reszta zostaje poświęcona.",
+ "noneToKeep": "Brak {{category}} — nic do zatrzymania",
+ "objectFallback": "Obiekt {{id}}"
+ },
+ "chooseOneOfBranch": {
+ "eyebrow": "Wybór",
+ "title": "Wybierz jedno",
+ "subtitle": "Wybierz opcję do rozpatrzenia.",
+ "optionFallback": "Opcja {{number}}"
+ },
+ "combatTax": {
+ "eyebrow": "Podatek bojowy",
+ "titleAttack": "Zapłać, aby atakować",
+ "titleBlock": "Zapłać, aby blokować",
+ "subtitleAttack": "Jeden lub więcej atakujących jest opodatkowanych. Zapłać sumę lub usuń ich z ataku.",
+ "subtitleBlock": "Jeden lub więcej blokujących jest opodatkowanych. Zapłać sumę lub usuń ich z bloku.",
+ "declineAttack": "Odmów (usuń opodatkowanych atakujących)",
+ "declineBlock": "Odmów (usuń opodatkowanych blokujących)",
+ "perCreatureBreakdown": "Zestawienie na stwora",
+ "total": "Suma",
+ "pay": "Zapłać",
+ "creatureFallback": "Stwór {{id}}"
+ },
+ "distributeAmong": {
+ "unitCounter": "znacznik {{counter}}",
+ "unitDamage": "obrażeń",
+ "unitLife": "życia",
+ "title": "Rozdziel {{total}} {{unit}}",
+ "subtitle": "Przydziel co najmniej 1 {{unit}} każdemu celowi. Pozostało: {{remaining}}"
+ },
+ "dungeonChoice": {
+ "title": "Wybierz loch",
+ "subtitle": "Wybierz loch, do którego się wyprawić",
+ "roomTitle": "Wybierz pokój",
+ "roomSubtitle": "Awansuj w {{name}}"
+ },
+ "engineLost": {
+ "crashTitle": "Silnik uległ awarii",
+ "crashBody": "phase.rs napotkał wewnętrzny błąd i nie może bezpiecznie kontynuować tej akcji. Twoja ostatnia zapisana tura jest zachowana — odśwież, aby przywrócić grę. Zgłoś to , abyśmy mogli to naprawić.",
+ "connectionTitle": "Utracono połączenie z silnikiem",
+ "connectionBody": "phase.rs utracił połączenie z silnikiem gry — najczęściej spowodowane aktualizacją w tle aktywowaną w trakcie gry. Twoja ostatnia zapisana tura jest zachowana; odśwież, aby przywrócić grę.",
+ "diagnostic": "diagnostyka: {{reason}}",
+ "showDetails": "Pokaż szczegóły",
+ "copyDiagnostic": "Kopiuj diagnostykę",
+ "copied": "Skopiowano!",
+ "reportOnGithub": "Zgłoś na GitHub",
+ "reload": "Odśwież",
+ "reportTitle": "Awaria silnika: {{summary}}",
+ "reportConnectionSummary": "Utracono połączenie z silnikiem",
+ "reportWhatHappened": "**Co się stało**\n_opisz krótko, co robiłeś_\n\n**Diagnostyka**\n```\n{{diagnostic}}\n```",
+ "copyPrompt": "Skopiuj tę diagnostykę:"
+ },
+ "miracleReveal": {
+ "eyebrowMiracle": "Miracle",
+ "eyebrowMadness": "Madness",
+ "titleReveal": "Ujawnić {{name}}?",
+ "titleCast": "Rzucić {{name}}?",
+ "subtitleReveal": "Możesz ujawnić tę kartę, aby rzucić ją za jej koszt Miracle.",
+ "subtitleCastMiracle": "Możesz rzucić tę kartę za jej koszt Miracle.",
+ "subtitleCastMadness": "Możesz rzucić tę kartę za jej koszt Madness.",
+ "reveal": "Ujawnij",
+ "cast": "Rzuć",
+ "decline": "Odmów"
+ },
+ "modalFace": {
+ "eyebrow": "Modalny DFC",
+ "title": "Wybierz stronę",
+ "subtitle": "Wybierz, którą stronę zagrać lub rzucić.",
+ "backFaceFallback": "Tylna strona",
+ "labelFront": "Przód",
+ "labelBack": "Tył",
+ "play": "Zagraj {{name}}",
+ "cast": "Rzuć {{name}}"
+ },
+ "modeChoice": {
+ "eyebrowAbility": "Tryby zdolności",
+ "eyebrowSpell": "Tryby czaru",
+ "subtitle": "Wybierz tryb lub tryby do zastosowania.",
+ "chooseExact": "Wybierz {{count}}",
+ "chooseRange": "Wybierz od {{min}} do {{max}}",
+ "confirm": "Potwierdź ({{selected}}/{{count}})",
+ "clear": "Wyczyść",
+ "alreadyChosen": "(już wybrane)"
+ },
+ "namedChoice": {
+ "title": {
+ "creatureType": "Wybierz typ stwora",
+ "color": "Wybierz kolor",
+ "oddOrEven": "Wybierz nieparzyste lub parzyste",
+ "basicLandType": "Wybierz typ ziemi podstawowej",
+ "cardType": "Wybierz typ karty",
+ "cardName": "Nazwij kartę",
+ "landType": "Wybierz typ ziemi",
+ "opponent": "Wybierz przeciwnika",
+ "player": "Wybierz gracza",
+ "twoColors": "Wybierz dwa kolory",
+ "numberRange": "Wybierz liczbę",
+ "labeled": "Dokonaj wyboru",
+ "keyword": "Wybierz zdolność",
+ "fallback": "Dokonaj wyboru"
+ },
+ "searchSubtitle": "Pisz, aby przeszukać wszystkie karty",
+ "searchPlaceholder": "Szukaj po nazwie...",
+ "noCardsFound": "Nie znaleziono kart",
+ "buttonSubtitle": "Wybierz jedną opcję"
+ },
+ "nonFatalPanic": {
+ "heading": "Ostrzeżenie silnika (niekrytyczne)",
+ "dismiss": "Odrzuć",
+ "body": "phase.rs napotkał wewnętrzne ostrzeżenie, ale odzyskał sprawność. Można bezpiecznie kontynuować grę. Prosimy zgłosić to , abyśmy mogli zbadać sprawę.",
+ "dontShowAgain": "Nie pokazuj ponownie w tej sesji",
+ "reportTitle": "Niekrytyczna awaria silnika: {{summary}}",
+ "reportWhatHappened": "**Co się stało**\n_opisz krótko, co robiłeś_\n\n**Diagnostyka**\n```\n{{diagnostic}}\n```"
+ },
+ "optionalEffect": {
+ "sourceFallback": "Efekt",
+ "title": "{{name}} - Efekt opcjonalny",
+ "yes": "Tak",
+ "no": "Nie",
+ "dontAskAgain": "Nie pytaj ponownie w tej grze"
+ },
+ "permanentTypeSlot": {
+ "eyebrow": "Typ trwałego",
+ "title": "Wybierz slot typu",
+ "subtitle": "{{name}} ma wiele typów trwałego. Wybierz, którego slotu typu użyć."
+ },
+ "proliferate": {
+ "proliferateTitle": "Proliferate",
+ "proliferateSubtitle": "Wybierz dowolną liczbę trwałych i graczy ze znacznikami. Każdy wybrany cel otrzymuje po jednym dodatkowym znaczniku każdego rodzaju, który już tam jest.",
+ "chooseObjectsTitle": "Wybierz trwałe",
+ "chooseObjectsSubtitle": "Wybierz dowolną liczbę trwałych. Płacisz koszt za każdego wybranego.",
+ "selectAll": "Wszystko",
+ "selectNone": "Żaden",
+ "selectMine": "Moja strona",
+ "confirm": "Potwierdź"
+ },
+ "replacement": {
+ "eyebrow": "Kolejność rozpatrywania",
+ "title": "Efekty zastępujące",
+ "subtitle": "Wybierz, który efekt zastępujący zastosować jako pierwszy.",
+ "candidateFallback": "Efekt zastępujący {{number}}"
+ },
+ "retargetChoice": {
+ "title": "Zmień cel",
+ "scopeSingle": "Wybierz nowy cel dla czaru",
+ "scopeMulti": "Wybierz nowe cele dla czaru",
+ "subtitle": "{{scope}}. Obecnie: {{current}}",
+ "confirm": "Potwierdź",
+ "badgeNewTarget": "Nowy cel"
+ },
+ "separatePiles": {
+ "chooserName": "Gracz {{number}}",
+ "partitionTitle": "Rozdziel na dwa stosy",
+ "partitionSubtitle": "Rozdziel swoje stwory na dwa stosy. {{chooser}} wybierze jeden.",
+ "partitionSubtitleRemaining_one": "Rozdziel swoje stwory na dwa stosy. {{chooser}} wybierze jeden. ({{count}} gracz więcej po tobie)",
+ "partitionSubtitleRemaining_other": "Rozdziel swoje stwory na dwa stosy. {{chooser}} wybierze jeden. ({{count}} graczy więcej po tobie)",
+ "pileALabel": "Stos A:",
+ "pileBLabel": "Stos B:",
+ "pileAria": "{{name}} — stos {{pile}}",
+ "pileBadge": "Stos {{pile}}",
+ "chooseTitle": "Wybierz stos",
+ "chooseSubtitle": "Wybierz jeden ze stosów gracza {{subject}}.",
+ "chooseSubtitleRemaining_one": "Wybierz jeden ze stosów gracza {{subject}}. ({{count}} podział więcej po tym)",
+ "chooseSubtitleRemaining_other": "Wybierz jeden ze stosów gracza {{subject}}. ({{count}} podziałów więcej po tym)",
+ "pileHeader": "Stos {{pile}} ({{count}})",
+ "empty": "(pusty)",
+ "choosePileA": "Wybierz stos A",
+ "choosePileB": "Wybierz stos B"
+ },
+ "tribute": {
+ "sourceFallback": "Stwór z Tribute",
+ "title": "Tribute — {{name}}",
+ "subtitle_one": "Położyć {{count}} znacznik +1/+1 na {{name}}?",
+ "subtitle_other": "Położyć {{count}} znaczników +1/+1 na {{name}}?",
+ "payLabel": "Zapłać Tribute",
+ "payDescription_one": "Połóż {{count}} znacznik +1/+1 na {{name}}.",
+ "payDescription_other": "Połóż {{count}} znaczników +1/+1 na {{name}}.",
+ "declineLabel": "Odmów",
+ "declineDescription": "Odmów Tribute. Wyzwala „jeśli Tribute nie został zapłacony”."
+ },
+ "triggerOrder": {
+ "eyebrow": "Kolejność rozpatrywania",
+ "title": "Ustal kolejność swoich zdolności wyzwalanych",
+ "subtitle": "Wybierz kolejność, w jakiej te wyzwalacze trafią na stos. Wierzch tej listy rozpatrywany jest jako ostatni (CR 405.3).",
+ "confirmOrder": "Potwierdź kolejność",
+ "resolvesLast": "Rozpatrywany jako ostatni (spód stosu)",
+ "resolvesFirst": "Rozpatrywany jako pierwszy (wierzch stosu)",
+ "moveUp": "Przesuń w górę",
+ "moveDown": "Przesuń w dół",
+ "triggerFallback": "Wyzwalacz {{number}}"
+ },
+ "unhandledWaitingFor": {
+ "title": "Wymagana akcja, ale brakuje interfejsu",
+ "body": "Gra oczekuje na twoją akcję, ale ta wersja phase.rs nie ma jeszcze do tego interfejsu. To błąd — prosimy go zgłosić, abyśmy mogli go naprawić. Użyj przycisku poniżej, aby opuścić grę.",
+ "missingState": "Brakujący stan",
+ "copyDiagnostic": "Kopiuj diagnostykę",
+ "copied": "Skopiowano!",
+ "reportOnGithub": "Zgłoś na GitHub",
+ "copyPrompt": "Skopiuj tę diagnostykę:",
+ "reportTitle": "Nieobsłużony WaitingFor: {{type}}",
+ "reportWhatHappened": "**Co się stało**\n_opisz krótko, co robiłeś_\n\n**Diagnostyka**\n```\n{{diagnostic}}\n```"
+ },
+ "voteChoice": {
+ "subjectName": "Gracz {{number}}",
+ "titleLabel": "Oznacz gracza",
+ "titleVote": "Głosuj",
+ "subtitleLabel": "Wybierz oznaczenie dla {{name}}",
+ "subtitleVoteRemaining": "Oddaj głos (pozostało {{count}})",
+ "subtitleVote": "Oddaj swój głos"
+ },
+ "advance": {
+ "resolve": "Rozpatrz",
+ "passPriority": "Przekaż priorytet",
+ "toPhase": "Do {{phase}}"
+ },
+ "outsideGame": {
+ "fromExile": "Z wygnania",
+ "fromSideboard": "Z zaplecza"
+ },
+ "phaseName": {
+ "Upkeep": "Utrzymanie",
+ "Draw": "Dobieranie",
+ "PreCombatMain": "Główna faza 1",
+ "BeginCombat": "Początek walki",
+ "DeclareAttackers": "Deklaracja atakujących",
+ "DeclareBlockers": "Deklaracja blokujących",
+ "CombatDamage": "Obrażenia bojowe",
+ "EndCombat": "Koniec walki",
+ "PostCombatMain": "Główna faza 2",
+ "End": "Krok końcowy",
+ "Cleanup": "Sprzątanie"
+ }
+}
diff --git a/client/src/i18n/locales/pl/menu.json b/client/src/i18n/locales/pl/menu.json
new file mode 100644
index 0000000000..7837ac0929
--- /dev/null
+++ b/client/src/i18n/locales/pl/menu.json
@@ -0,0 +1,233 @@
+{
+ "backButton": {
+ "label": "Wstecz"
+ },
+ "aiDifficulty": {
+ "label": "Poziom trudności AI",
+ "ariaLabel": "Poziom trudności AI: {{difficulty}}",
+ "levels": {
+ "VeryEasy": "Bardzo łatwy",
+ "Easy": "Łatwy",
+ "Medium": "Średni",
+ "Hard": "Trudny",
+ "VeryHard": "Bardzo trudny"
+ }
+ },
+ "bracketFilter": {
+ "label": "Filtr poziomu"
+ },
+ "gamePresets": {
+ "heading": "Szybki start"
+ },
+ "shell": {
+ "disclaimer": "phase.rs to niekomercyjny projekt fanowski, niepowiązany z Wizards of the Coast ani przez nich nieautoryzowany. Magic: The Gathering jest © Wizards of the Coast LLC. Obrazy kart są pobierane ze Scryfall w czasie działania; dane kart pochodzą z MTGJSON. Żadne objęte prawem autorskim zasoby WotC nie są dołączone do tego projektu."
+ },
+ "home": {
+ "resume": {
+ "title": "Wznów grę",
+ "description": "Kontynuuj ostatnio zapisany pojedynek od bieżącej tury i stanu pola gry."
+ },
+ "setup": {
+ "title": "Graj z AI",
+ "titleSaved": "Nowy pojedynek z AI",
+ "description": "Rozegraj samotny pojedynek przeciwko przeciwnikowi AI — wybierz format, talię, archetyp i poziom trudności."
+ },
+ "online": {
+ "title": "Graj online",
+ "description": "Załóż pokój, dołącz przez kod lub połącz się ponownie z trybem wieloosobowym."
+ },
+ "draft": {
+ "title": "Draft",
+ "description": "Szybki draft przeciwko AI oraz eksperymentalne opcje draftu kostkowego i podu."
+ },
+ "decks": {
+ "title": "Talie",
+ "description": "Otwórz zapisane talie, zmień aktywną listę i edytuj konstrukcje."
+ },
+ "social": {
+ "sponsor": "Wesprzyj"
+ },
+ "preview": {
+ "cta": "Wypróbuj wersję zapowiadającą",
+ "tooltip": "Zagraj w najnowszą wersję zapowiadającą — nowe karty i poprawki trafiają tu przed każdym wydaniem."
+ },
+ "coverage": {
+ "title": "Otwórz panel obsługi kart",
+ "ariaLabel": "Otwórz panel obsługi kart",
+ "heading": "Panel obsługi kart",
+ "viewDetails": "Zobacz szczegóły"
+ },
+ "preparingCards": "Przygotowywanie kart…",
+ "alpha": {
+ "label": "Wczesna alfa",
+ "message": " — spodziewaj się niedziałających kart i brakujących funkcji."
+ },
+ "savedMatchAvailable": "Dostępny zapisany pojedynek",
+ "load": {
+ "title": "Wczytaj stan gry"
+ },
+ "exit": "Wyjdź"
+ },
+ "loadState": {
+ "title": "Wczytaj stan gry",
+ "subtitle": "Przywróć wyeksportowany lub wklejony stan i kontynuuj grę przeciwko AI.",
+ "tabPaste": "Wklej JSON",
+ "tabFile": "Z pliku",
+ "pastePlaceholder": "Wklej JSON stanu gry…",
+ "fileSupports": "Obsługuje pliki .json, .txt i .zip wyeksportowane z panelu debugowania",
+ "chooseFile": "Wybierz plik",
+ "load": "Wczytaj"
+ },
+ "aiOpponent": {
+ "heading": "Przeciwnik AI",
+ "headingMulti": "Przeciwnicy AI ({{count}})",
+ "analyzingDecks": "Analizowanie talii…",
+ "noLegalDecks": "Brak dozwolonych talii AI dla tego formatu.",
+ "catalogUnavailable": "Katalog talii AI niedostępny: {{error}}",
+ "randomPoolFilters": "Filtry losowej puli",
+ "archetype": "Archetyp",
+ "cardCoverage": "Obsługa kart",
+ "coverageThresholdHint": "Wyklucz talie poniżej tego progu obsługi przez silnik",
+ "bracket": "Poziom",
+ "bracketHint": "Losowe AI wybiera z tych poziomów. Talie bez tagów są wykluczane podczas filtrowania.",
+ "opponentLabel": "Przeciwnik {{number}}",
+ "deck": "Talia",
+ "difficulty": "Poziom trudności",
+ "deckRandom": "Losowa",
+ "deckRandomCount": "Losowa ({{count}})",
+ "source": {
+ "feed": "Kanał",
+ "user": "Użytkownik",
+ "precon": "Precon"
+ }
+ },
+ "feedManager": {
+ "title": "Zarządzaj kanałami",
+ "refreshing": "Odświeżanie…",
+ "refreshAll": "Odśwież wszystkie",
+ "refresh": "Odśwież",
+ "subscribe": "Subskrybuj",
+ "unsubscribe": "Anuluj subskrypcję",
+ "lastRefreshed": "Ostatnio odświeżono: {{date}}",
+ "addCustomFeed": "Dodaj własny kanał",
+ "add": "Dodaj",
+ "done": "Gotowe"
+ },
+ "importDeck": {
+ "title": "Importuj talię",
+ "tabPaste": "Wklej tekst",
+ "tabFile": "Z pliku",
+ "deckNamePlaceholder": "Nazwa talii",
+ "import": "Importuj",
+ "fileSupports": "Obsługuje formaty .dck, .dec, .txt oraz MTGA",
+ "chooseFile": "Wybierz plik"
+ },
+ "precon": {
+ "savePrompt": "Zapisz gotową talię jako:",
+ "overwriteConfirm": "„{{name}}” już istnieje. Nadpisać?",
+ "overwriteAllConfirm": "Wszystkie {{count}} wybrane talie już istnieją. Nadpisać?",
+ "overwriteSomeConfirm": "{{conflicts}} z {{total}} wybranych talii już istnieje. Nadpisać duplikaty? (Anuluj zachowuje istniejące kopie i importuje resztę.)",
+ "importedSkipped_one": "Zaimportowano {{count}} talię; pominięto {{skipped}} istniejących.",
+ "importedSkipped_other": "Zaimportowano {{count}} talii; pominięto {{skipped}} istniejących.",
+ "title": "Gotowe talie",
+ "subtitle": "Pochodzą z MTGJSON AllDeckFiles — każdy precon wydany przez WotC.",
+ "totalCount": "· {{count}} łącznie",
+ "searchPlaceholder": "Szukaj według nazwy, kodu dodatku lub typu…",
+ "allTypes": "Wszystkie ({{count}})",
+ "loadingCatalog": "Wczytywanie katalogu talii…",
+ "noMatch": "Brak pasujących talii.",
+ "selectDeck": "Wybierz {{name}}",
+ "cardCount_one": "{{count}} karta",
+ "cardCount_other": "{{count}} kart",
+ "commanderSuffix": " · dow.",
+ "showingFirst": "Wyświetlono pierwsze {{count}} — zawęź wyszukiwanie.",
+ "selectAllVisible": "Zaznacz wszystkie widoczne",
+ "clearSelection": "Wyczyść ({{count}})",
+ "importSelected": "Importuj {{count}} wybranych"
+ },
+ "deckTile": {
+ "feedBadge": "Kanał",
+ "edit": "Edytuj {{name}}",
+ "delete": "Usuń",
+ "deleteTitle": "Usuń talię",
+ "copy": "Kopiuj do Moich talii",
+ "copyTitle": "Kopiuj do Moich talii (usuwa śledzenie kanału)",
+ "cardCount_one": "{{count}} karta",
+ "cardCount_other": "{{count}} kart",
+ "preconBadge": "precon",
+ "unknown": "Nieznane {{count}}",
+ "unknownCardsTitle": "Nieznane karty:\n{{cards}}",
+ "unsupportedTitle": "Nieobsługiwane ({{unique}} unikalnych, {{copies}} kopii):\n{{cards}}",
+ "setIconAlt": "Ikona dodatku {{code}}"
+ },
+ "myDecks": {
+ "headingManage": "Moje talie",
+ "headingSelect": "Wybierz talię",
+ "tabDecks": "Moje talie",
+ "tabSubscriptions": "Subskrypcje",
+ "createNew": "Utwórz nową",
+ "refreshing": "Odświeżanie…",
+ "refreshAll": "Odśwież wszystkie",
+ "manageFeeds": "Zarządzaj kanałami",
+ "searchPlaceholder": "Szukaj talii…",
+ "formatLabel": "Format",
+ "browseAetherhub": "Przeglądaj talie {{format}} na Aetherhub",
+ "showAllDecks": "Pokaż wszystkie talie",
+ "showLegalOnly": "Pokaż tylko dozwolone",
+ "showAllDecksButton": "Pokaż wszystkie talie",
+ "sortName": "Nazwa",
+ "sortDateAdded": "Data dodania",
+ "sortFormat": "Format",
+ "ascending": "Rosnąco",
+ "descending": "Malejąco",
+ "saveAsPrompt": "Zapisz jako:",
+ "banner": {
+ "showingAllFor": "Wyświetlanie wszystkich zapisanych talii dla",
+ "showingLegalIn": "Wyświetlanie talii dozwolonych w",
+ "deckCount": "· {{visible}} z {{total}}"
+ },
+ "status": {
+ "fallbackUserDeck": "talia użytkownika",
+ "fallbackVisibleDeck": "widoczna talia",
+ "startingWorker": "Uruchamianie procesu zgodności…",
+ "loadingDatabase": "Wczytywanie bazy danych zgodności…",
+ "checkingDeck": "Sprawdzanie {{deck}}…",
+ "checkedDeck": "Sprawdzono {{deck}}",
+ "loadingCoverage": "Wczytywanie obsługi dla {{deck}}…",
+ "checkingSelected": "Sprawdzanie zgodności wybranej talii…",
+ "evaluatingVisible": "Ocenianie widocznych talii…",
+ "remaining": "Pozostało {{count}}"
+ },
+ "compatibilityError": "Sprawdzanie zgodności niedostępne: {{error}}",
+ "empty": {
+ "title": "Brak talii pasujących do tego filtra.",
+ "selectHint": "Zaimportuj zgodną talię lub zmień format, aby zobaczyć dostępne talie.",
+ "manageHint": "Wybierz inny filtr lub pokaż wszystkie talie, aby wybrać z całej kolekcji."
+ },
+ "sectionMyDecks": "Moje talie",
+ "sectionStarterDecks": "Talie startowe",
+ "sectionLegalPrecons": "Dozwolone precony",
+ "importDeckTile": "Importuj talię",
+ "preconstructedTile": "Gotowa talia",
+ "browseAll": "Przeglądaj wszystkie",
+ "loadMore": "Wczytaj więcej",
+ "selectedDeck": "Wybrana talia",
+ "chooseDeckToContinue": "Wybierz talię, aby kontynuować",
+ "filterAll": "Wszystkie"
+ },
+ "subscriptions": {
+ "emptyTitle": "Brak subskrypcji kanałów",
+ "emptyDescription": "Subskrybuj kanały talii, aby otrzymywać wyselekcjonowane kolekcje talii, które aktualizują się automatycznie.",
+ "feedMeta_one": "· {{count}} talia · Zaktualizowano {{date}}",
+ "feedMeta_other": "· {{count}} talii · Zaktualizowano {{date}}",
+ "error": "Błąd: {{error}}"
+ },
+ "myDecksPage": {
+ "eyebrow": "Talie",
+ "title": "Talie.",
+ "description": "Otwórz zapisaną listę, zaimportuj nową lub kontynuuj w kreatorze talii."
+ },
+ "loadGameState": {
+ "readFailed": "Nie udało się odczytać pliku"
+ }
+}
diff --git a/client/src/i18n/locales/pl/multiplayer.json b/client/src/i18n/locales/pl/multiplayer.json
new file mode 100644
index 0000000000..dd1e4551ba
--- /dev/null
+++ b/client/src/i18n/locales/pl/multiplayer.json
@@ -0,0 +1,236 @@
+{
+ "concedeDialog": {
+ "title": "Poddać grę?",
+ "message": "Twój przeciwnik zostanie ogłoszony zwycięzcą.",
+ "concede": "Poddaj się"
+ },
+ "connectionDot": {
+ "connected": "Połączono",
+ "connecting": "Łączenie...",
+ "disconnected": "Rozłączono"
+ },
+ "connectionToast": {
+ "opponentDisconnected": "Przeciwnik się rozłączył",
+ "forfeitCountdown": "— {{seconds}}s do walkowera",
+ "retry": "Ponów",
+ "settings": "Ustawienia"
+ },
+ "emoteOverlay": {
+ "ariaLabel": "Emotki",
+ "options": {
+ "goodGame": "Dobra gra",
+ "nicePlay": "Niezłe zagranie",
+ "thinking": "Myślę...",
+ "hello": "Cześć!",
+ "oops": "Ups"
+ }
+ },
+ "lobbyProgress": {
+ "waiting": "Oczekiwanie na graczy…",
+ "playersReady": "{{joined}} / {{total}} graczy gotowych"
+ },
+ "serverOfflineDialog": {
+ "title": "Serwer offline",
+ "couldNotConnect": "Nie udało się połączyć z dedykowanym serwerem rozgrywki sieciowej.",
+ "switchedToP2P": "Przełączono na tryb tylko P2P na tę sesję.",
+ "serverAddress": "Adres serwera:",
+ "dismiss": "Zamknij",
+ "openSettings": "Otwórz ustawienia"
+ },
+ "sideboardModal": {
+ "game": "Gra {{number}}",
+ "title": "Wymiana kart bocznych",
+ "matchScore": "Wynik meczu {{p0}}-{{p1}}{{draws}}. Przenoś karty między talią główną a kartami bocznymi, a potem zatwierdź.",
+ "draws_one": " ({{count}} remis)",
+ "draws_other": " ({{count}} remisów)",
+ "main": "Główna ({{count}}/{{total}})",
+ "sideboard": "Karty boczne ({{count}})",
+ "moveFromSideboard": "Przenieś karty z kart bocznych.",
+ "moveFromMain": "Przenieś karty z talii głównej.",
+ "matchesRegistered": "Talia główna odpowiada zarejestrowanemu rozmiarowi ({{total}}).",
+ "submitDisabled": "Talia główna ma {{count}} / {{total}} — zatwierdzanie wyłączone.",
+ "resetAria": "Przywróć zarejestrowaną talię",
+ "reset": "Resetuj",
+ "submitAria": "Zatwierdź talię na następną grę",
+ "submitDeck": "Zatwierdź talię"
+ },
+ "brokerOfflinePrompt": {
+ "title": "Serwer poczekalni nieosiągalny",
+ "message": "Twoja gra nie będzie publicznie wyświetlana. Nadal możesz być gospodarzem przez P2P — po prostu udostępnij kod pokoju bezpośrednio przeciwnikowi.",
+ "continueWithoutLobby": "Kontynuuj bez poczekalni"
+ },
+ "joinErrorDialog": {
+ "dismiss": "Zamknij"
+ },
+ "serverOfflinePrompt": {
+ "title": "Dobieranie graczy nieosiągalne",
+ "message": "Nie udało się połączyć z serwerem gry, więc publiczna poczekalnia jest teraz niedostępna. Nadal możesz grać, udostępniając bezpośredni kod znajomemu.",
+ "keepTrying": "Próbuj dalej",
+ "useDirectCode": "Użyj bezpośredniego kodu"
+ },
+ "playerIdentityBanner": {
+ "playerName": "Nazwa gracza",
+ "namePlaceholder": "Wpisz swoją nazwę",
+ "editTitle": "Kliknij, aby edytować swoją nazwę",
+ "setName": "Ustaw nazwę…",
+ "change": "Zmień"
+ },
+ "gameListItem": {
+ "waitTimeJustNow": "przed chwilą",
+ "waitTimeMinutes": "{{count}} min temu",
+ "waitTimeHours": "{{count}} godz. temu",
+ "buildMismatchTitle": "Gospodarz ma wersję {{version}} ({{commit}}) — twoja kompilacja jest inna. Odśwież, aby zaktualizować.",
+ "gameFull": "Ta gra jest pełna.",
+ "sandboxConfirm": "To jest gra w trybie piaskownicy. Gracze z uprawnieniami debugowania mogą bezpośrednio manipulować stanem gry. Kontynuować?",
+ "draftBadgeTitle": "Draft {{kind}} — {{setCode}}",
+ "draftBadge": "Draft {{setCode}}",
+ "p2pBadgeTitle": "Gra peer-to-peer (gospodarz uruchamia silnik)",
+ "sandboxBadgeTitle": "Ta gra pozwala na akcje debugowania. Używaj do testów — to nie jest mecz wyczynowy.",
+ "anonymous": "Anonimowy",
+ "by": "autor: {{name}}",
+ "passwordProtected": "Chronione hasłem"
+ },
+ "serverPicker": {
+ "title": "Serwer",
+ "subtitle": "Wybierz region lub połącz się z własną instancją.",
+ "noneLabel": "Brak (tylko P2P)",
+ "directCodes": "bezpośrednie kody",
+ "selfHosted": "Własny hosting",
+ "customUrlPlaceholder": "wss://twoj-serwer.example/ws",
+ "test": "Testuj",
+ "use": "Użyj",
+ "urlError": "URL musi zaczynać się od ws:// lub wss://",
+ "connected": "Połączono",
+ "connectionFailed": "Połączenie nie powiodło się",
+ "testing": "Testowanie…"
+ },
+ "lobbyDraftRooms": {
+ "noRooms": "Brak dostępnych pokoi draftu",
+ "playerHosted": "Hostowane przez gracza",
+ "server": "Serwer",
+ "passwordProtected": "Chronione hasłem",
+ "join": "Dołącz",
+ "createTitle": "Utwórz draft serwerowy",
+ "set": "Dodatek",
+ "kind": "Rodzaj",
+ "premier": "Premier",
+ "traditional": "Tradycyjny",
+ "podSize": "Rozmiar grupy",
+ "timerSeconds": "Czas (s)",
+ "passwordOptional": "Hasło (opcjonalnie)",
+ "passwordPlaceholder": "Pozostaw puste dla gry publicznej",
+ "creating": "Tworzenie...",
+ "createDraft": "Utwórz draft"
+ },
+ "lobbyView": {
+ "directConnection": "Połączenie bezpośrednie",
+ "onlineLobby": "Poczekalnia online",
+ "pickServerTitle": "Wybierz serwer, aby korzystać z trybu poczekalni online",
+ "pickServer": "Wybierz serwer",
+ "online": "{{count}} online",
+ "format": "Format",
+ "allFormats": "Wszystkie formaty",
+ "roomTypeAll": "Wszystkie",
+ "roomTypeDraft": "Draft",
+ "roomTypeP2P": "P2P",
+ "roomTypeServer": "Serwer",
+ "openTables": "Otwarte stoły",
+ "noFormatGames": "Brak gier {{format}} w tej chwili.",
+ "noOpenGames": "Brak otwartych gier w tej chwili.",
+ "showAllFormats": "Pokaż wszystkie formaty",
+ "p2pNotice": "Dedykowany serwer niedostępny. Nadal możesz być gospodarzem lub dołączyć bezpośrednio za pomocą 5-znakowego kodu pokoju.",
+ "joinByCode": "Dołącz za pomocą kodu",
+ "joinATable": "Dołącz do stołu",
+ "p2pCodePlaceholder": "Wpisz 5-znakowy kod P2P",
+ "serverCodePlaceholder": "Wpisz kod lub KOD@IP:PORT",
+ "join": "Dołącz",
+ "host": "Hostuj",
+ "hostP2PDescription": "Utwórz bezpośredni pokój dla jednego przeciwnika.",
+ "hostServerDescription": "Otwórz pokój i czekaj na graczy.",
+ "hostDraft": "Hostuj draft",
+ "hostGame": "Hostuj grę",
+ "hostP2PGame": "Hostuj grę P2P",
+ "passwordRequired": "Wymagane hasło",
+ "passwordPlaceholder": "Wpisz hasło"
+ },
+ "hostSetup": {
+ "hostDirectMatch": "Hostuj mecz bezpośredni",
+ "hostMatch": "Hostuj mecz",
+ "p2pNotice": "Dedykowany serwer jest niedostępny, więc ten pokój użyje połączenia bezpośredniego.",
+ "roomName": "Nazwa pokoju",
+ "optional": "(opcjonalnie)",
+ "roomNamePlaceholder": "np. Piątkowy wieczór Commander",
+ "roomNameDefaultPlaceholder": "Stół gracza {{name}}",
+ "roomNameHelp": "Wyświetlana graczom przeglądającym poczekalnię.",
+ "roomNameHelpDefault": " Domyślnie \"Stół gracza {{name}}\".",
+ "format": "Format",
+ "startingLife": "Początkowe życie",
+ "deckSize": "Rozmiar talii",
+ "players": "Gracze",
+ "matchType": "Typ meczu",
+ "bo1": "BO1",
+ "bo3": "BO3",
+ "bo3Note": "BO3 jest dostępne tylko dla meczów 2-osobowych.",
+ "commanderDamage": "Obrażenia od dowódcy",
+ "playerSeats": "Miejsca graczy",
+ "seat": "Miejsce {{number}}",
+ "youHost": "Ty (gospodarz)",
+ "ai": "SI",
+ "human": "Człowiek",
+ "waitingForPlayer": "Oczekiwanie na gracza",
+ "startWhenFull": "Rozpocznij po zapełnieniu",
+ "listInLobby": "Wyświetl w poczekalni",
+ "sandboxMode": "Tryb piaskownicy — zezwól na akcje debugowania",
+ "sandboxModeHelp": "Gospodarz może bezpośrednio manipulować stanem gry (przenosić karty, zmieniać życie, modyfikować znaczniki) i przyznawać uprawnienia debugowania innym graczom. Używaj do testów lub gry w piaskownicy — nie do meczów wyczynowych. Tego ustawienia nie można zmienić po rozpoczęciu gry.",
+ "setPassword": "Ustaw hasło",
+ "passwordPlaceholder": "Hasło do gry",
+ "back": "Wstecz",
+ "hostP2PGame": "Hostuj grę P2P",
+ "hostGame": "Hostuj grę"
+ },
+ "page": {
+ "deckRejected": "Talia została odrzucona przez gospodarza.",
+ "selectDeckFirst": "Wybierz talię przed kontynuowaniem.",
+ "couldNotLoadDeck": "Nie udało się wczytać talii. Spróbuj zaimportować ją ponownie.",
+ "deckNotLegal": "Talia nie jest dozwolona w formacie {{format}}.",
+ "deckCheckFailed": "Sprawdzanie talii nie powiodło się: {{error}}",
+ "deckCheckFailedGeneric": "Sprawdzanie talii nie powiodło się.",
+ "failedToJoinDraft": "Nie udało się dołączyć do grupy draftu.",
+ "passwordPrompt": "Ten pokój wymaga hasła:",
+ "joinErrorOutOfDateTitle": "Klient nieaktualny",
+ "joinErrorRefresh": "Odśwież",
+ "joinErrorCantJoinTitle": "Nie można dołączyć do tego pokoju",
+ "titleLobby": "Dołącz do stołu lub załóż własny.",
+ "titleHostSetup": "Skonfiguruj swój stół.",
+ "titleDraftLobby": "Grupa draftu",
+ "titleDeckSelect": "Wybierz talię.",
+ "descriptionLobby": "Przeglądaj dostępne stoły, dołączaj za pomocą kodu lub załóż nowy mecz.",
+ "descriptionHostSetup": "Dostosuj format, prywatność i czas przed otwarciem pokoju.",
+ "descriptionDraftLobby": "Oczekiwanie na graczy dołączających do grupy draftu.",
+ "descriptionDeckSelectFormat": "Wybierz talię dla formatu {{format}}.",
+ "descriptionDeckSelect": "Wybierz talię, którą chcesz wystawić online.",
+ "eyebrow": "Rozgrywka sieciowa",
+ "activeDeck": "Aktywna talia",
+ "edit": "Edytuj",
+ "change": "Zmień",
+ "noDeckWarning": "Nie wybrano talii — musisz wybrać jedną przed hostowaniem.",
+ "pickDeck": "Wybierz talię",
+ "anonymous": "Anonimowy",
+ "joining": "Dołączanie"
+ },
+ "draftLobbyPanel": {
+ "draftPod": "Grupa draftu",
+ "connecting": "Łączenie z grupą draftu...",
+ "connectionFailed": "Połączenie nie powiodło się.",
+ "playersJoined": "Dołączyło {{joined}}/{{total}} graczy",
+ "seat": "Miejsce {{number}}",
+ "draftInProgress": "Trwa draft. Widok draftu otworzy się automatycznie.",
+ "leaveDraft": "Opuść draft"
+ },
+ "deckLegalityChip": {
+ "checking": "Sprawdzanie talii pod kątem formatu {{format}}…",
+ "checkingLegality": "Sprawdzanie zgodności talii z zasadami…",
+ "legal": "✓ Dozwolona w formacie {{format}}",
+ "notLegal": "Niedozwolona w formacie {{format}}"
+ }
+}
diff --git a/client/src/i18n/locales/pl/settings.json b/client/src/i18n/locales/pl/settings.json
new file mode 100644
index 0000000000..f7d10727ee
--- /dev/null
+++ b/client/src/i18n/locales/pl/settings.json
@@ -0,0 +1,154 @@
+{
+ "modal": {
+ "title": "Ustawienia",
+ "subtitle": "Dostosuj rozgrywkę, grafikę, dźwięk i domyślne ustawienia trybu wieloosobowego."
+ },
+ "tabs": {
+ "gameplay": "Rozgrywka",
+ "visual": "Grafika",
+ "combat": "Tempo",
+ "audio": "Dźwięk",
+ "multiplayer": "Tryb wieloosobowy",
+ "data": "Dane",
+ "experimental": "Eksperymentalne"
+ },
+ "gameplay": {
+ "title": "Rozgrywka",
+ "language": "Język",
+ "cardSize": "Rozmiar kart",
+ "logDefault": "Domyślny dziennik",
+ "spellPayment": "Płatność za czary",
+ "manualManaPayment": "Ręczna płatność many za czary",
+ "boardBackground": "Tło pola bitwy",
+ "boardBackgroundGroups": {
+ "automatic": "Automatyczne",
+ "battlefields": "Pola bitwy",
+ "plain": "Jednolite",
+ "custom": "Niestandardowe",
+ "off": "Wyłączone"
+ },
+ "boardBackgroundOptions": {
+ "autoMatchDeck": "Auto (dopasuj do talii)",
+ "random": "Losowe",
+ "customUrl": "Niestandardowy URL",
+ "none": "Brak"
+ },
+ "cardSizeOptions": {
+ "small": "Małe",
+ "medium": "Średnie",
+ "large": "Duże"
+ },
+ "logDefaultOptions": {
+ "open": "Otwarty",
+ "closed": "Zamknięty"
+ }
+ },
+ "visual": {
+ "title": "Grafika",
+ "vfxQuality": "Jakość efektów wizualnych",
+ "keywordStrip": "Pasek słów kluczowych",
+ "showKeywords": "Pokaż słowa kluczowe na kartach na polu bitwy",
+ "opponentHoverPreview": "Podgląd po najechaniu na przeciwnika",
+ "showOpponentBoard": "Pokaż pole bitwy przeciwnika po najechaniu na HUD",
+ "cardArtPreferences": "Preferencje grafiki kart",
+ "clearArtOverrides_one": "Wyczyść wszystkie nadpisania grafiki ({{count}})",
+ "clearArtOverrides_other": "Wyczyść wszystkie nadpisania grafiki ({{count}})",
+ "clearArtOverridesConfirm_one": "Wyczyścić {{count}} nadpisanie grafiki?",
+ "clearArtOverridesConfirm_other": "Wyczyścić wszystkie nadpisania grafiki ({{count}})?",
+ "vfxQualityOptions": {
+ "full": "Pełna",
+ "reduced": "Zredukowana",
+ "minimal": "Minimalna"
+ }
+ },
+ "audio": {
+ "title": "Dźwięk",
+ "muteAll": "Wycisz wszystko",
+ "muteAllAudio": "Wycisz cały dźwięk",
+ "globalVolume": "Głośność ogólna",
+ "sfxVolume": "Głośność efektów",
+ "musicVolume": "Głośność muzyki"
+ },
+ "audioTheme": {
+ "title": "Motyw dźwiękowy",
+ "theme": "Motyw",
+ "importTheme": "Importuj motyw",
+ "loading": "Ładowanie...",
+ "import": "Importuj",
+ "importFailed": "Nie udało się zaimportować motywu",
+ "customThemes": "Niestandardowe motywy",
+ "remove": "Usuń"
+ },
+ "multiplayer": {
+ "title": "Tryb wieloosobowy",
+ "displayName": "Nazwa wyświetlana",
+ "displayNamePlaceholder": "Wprowadź swoją nazwę",
+ "serverSelectionNote": "Wybór serwera został przeniesiony do poczekalni — otwórz Tryb wieloosobowy i użyj plakietki serwera (lub „Wybierz serwer” w trybie P2P), aby zmieniać regiony, skonfigurować własną instancję lub przetestować połączenie."
+ },
+ "experimental": {
+ "title": "Eksperymentalne",
+ "description": "Te funkcje są wciąż w fazie rozwoju. Mogą być niekompletne, zawierać błędy lub zmieniać się bez ostrzeżenia. Włącz je, aby wypróbować nowości wcześniej.",
+ "draftExperiments": "Eksperymenty draftowe",
+ "enableDraftFeatures": "Włącz eksperymentalne funkcje draftu",
+ "draftFeaturesDescription": "Odblokowuje Cube Draft i Pod Draft. Szybki draft przeciwko AI jest zawsze dostępny."
+ },
+ "data": {
+ "title": "Kopia zapasowa i przywracanie",
+ "description": "Eksport pakuje twoje preferencje, zaimportowane talie i subskrypcje kanałów do jednego pliku JSON. Import przywraca je na innym komputerze. Pamięci podręczne IndexedDB (pamięć kanałów, pamięć dźwięku, zapisane gry) nie są dołączane — odbudowują się automatycznie.",
+ "exportBackup": "Eksportuj kopię zapasową…",
+ "importBackup": "Importuj kopię zapasową…",
+ "importConfirm": "Nadpisać istniejące preferencje i talie?\n\nOK: zastąp wszystko kopią zapasową (destrukcyjne).\nAnuluj: scal — zachowaj istniejące talie, dodaj nowe z kopii zapasowej.",
+ "backupDownloaded": "Kopia zapasowa pobrana.",
+ "imported_one": "Zaimportowano {{count}} talię.",
+ "imported_other": "Zaimportowano {{count}} talii.",
+ "importedWithPreferences_one": "Zaimportowano {{count}} talię i preferencje.",
+ "importedWithPreferences_other": "Zaimportowano {{count}} talii i preferencje.",
+ "skippedMalformed_one": "Pominięto {{count}} nieprawidłowy wpis.",
+ "skippedMalformed_other": "Pominięto {{count}} nieprawidłowych wpisów."
+ },
+ "resetAll": {
+ "confirm": "Zresetować wszystkie preferencje do wartości domyślnych? Spowoduje to wyczyszczenie każdego ustawienia w tym oknie.",
+ "button": "Zresetuj wszystkie preferencje"
+ },
+ "pacing": {
+ "title": "Tempo",
+ "resetSection": "Zresetuj sekcję",
+ "animationSpeed": "Szybkość animacji",
+ "animationSpeedDescription": "Główna szybkość — wyższa jest szybsza. Skrajnie w prawo całkowicie pomija animacje.",
+ "hint": "Suwaki poszczególnych kategorii mnożą się na podstawie Szybkości animacji. Kliknij dwukrotnie dowolny suwak — lub dotknij ↺ obok niego — aby zresetować.",
+ "instant": "Natychmiast",
+ "slowest": "Najwolniej",
+ "resetSliderLabel": "Zresetuj {{label}} do wartości domyślnej",
+ "atDefault": "Wartość domyślna",
+ "resetToDefault": "Zresetuj do wartości domyślnej",
+ "labels": {
+ "effects": "Tempo efektów",
+ "combat": "Tempo walki",
+ "banners": "Tempo banerów"
+ },
+ "descriptions": {
+ "effects": "Rzucanie czarów, zmiany stref, śmierci, zmiany życia, znaczniki, obracanie/odwracanie.",
+ "combat": "Czas obrażeń bojowych — jak długo blokujący i atakujący pozostają, zanim obrażenia zostaną rozpatrzone.",
+ "banners": "Wyświetlanie banera na początku tury."
+ }
+ },
+ "artChain": {
+ "emptyState": "Używanie domyślnej grafiki Scryfall. Dodaj reguły poniżej, aby dostosować.",
+ "moveUp": "Przesuń w górę",
+ "moveDown": "Przesuń w dół",
+ "remove": "Usuń",
+ "unreachable": "Reguły poniżej „{{rule}}” są nieosiągalne — zawsze pasuje.",
+ "addRule": "Dodaj regułę",
+ "setInputPlaceholder": "Kod lub nazwa zestawu…",
+ "addSet": "Dodaj zestaw",
+ "rulesPriorityNote": "Reguły są sprawdzane od góry do dołu. Wygrywa pierwsze dopasowanie. „Wydanie źródłowe” używa zestawu z paczki draftowej lub importu talii, gdy jest dostępny. Nadpisania dla poszczególnych kart (prawy przycisk myszy w kreatorze talii) zawsze mają priorytet.",
+ "setEntry": "Zestaw: {{name}} ({{code}})",
+ "rules": {
+ "sourcePrinting": "Wydanie źródłowe",
+ "newest": "Najnowsze wydanie",
+ "oldest": "Najstarsze wydanie",
+ "preferBorderless": "Preferuj bez ramki",
+ "preferExtended": "Preferuj grafikę rozszerzoną"
+ }
+ }
+}
diff --git a/client/src/i18n/locales/pt/common.json b/client/src/i18n/locales/pt/common.json
index d53de92bd6..da6511555a 100644
--- a/client/src/i18n/locales/pt/common.json
+++ b/client/src/i18n/locales/pt/common.json
@@ -198,5 +198,11 @@
},
"turnBanner": {
"turn": "Turno {{number}}"
+ },
+ "chrome": {
+ "back": "Voltar",
+ "settings": "Configurações",
+ "languageSettings": "Idioma ({{lang}}) — abrir configurações",
+ "languageTitle": "Idioma: {{lang}}"
}
}
diff --git a/client/src/i18n/locales/pt/draft.json b/client/src/i18n/locales/pt/draft.json
index cbb1dcf879..99038d5460 100644
--- a/client/src/i18n/locales/pt/draft.json
+++ b/client/src/i18n/locales/pt/draft.json
@@ -39,7 +39,9 @@
"overrideResult": "Substituir Resultado",
"versusPair": "{{a}} v {{b}}",
"kickReplace": "Expulsar + Substituir",
- "replaceWithBot": "Substituir {{name}} por Bot"
+ "replaceWithBot": "Substituir {{name}} por Bot",
+ "endDraft": "Encerrar Draft",
+ "endDraftConfirm": "Encerrar este draft para todos? Isso nao pode ser desfeito."
},
"manaCurve": {
"title": "Curva de Mana"
@@ -60,7 +62,8 @@
"botDifficulty": "Dificuldade dos Bots",
"chooseSet": "Escolha uma Coleção",
"noPools": "Nenhum pool de draft disponível. Execute primeiro o pipeline de dados do draft.",
- "setIconAlt": "Ícone da coleção {{name}}"
+ "setIconAlt": "Ícone da coleção {{name}}",
+ "loadFailed": "Falha ao carregar as coleções"
},
"pack": {
"confirmPick": "Confirmar Escolha",
diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json
index 47e026ba0d..9f8daa9d59 100644
--- a/client/src/i18n/locales/pt/game.json
+++ b/client/src/i18n/locales/pt/game.json
@@ -8,6 +8,8 @@
"preview": {
"holdCtrlBack": "Segure Ctrl para a face traseira",
"holdCtrlFront": "Segure Ctrl para a face dianteira",
+ "holdCtrlFlip": "Segure Ctrl para virar",
+ "flip": "Virar",
"altParsedAbilities": "Alt: habilidades processadas",
"debugId": "ID: {{id}}",
"engineParse": "Análise do Motor",
@@ -307,7 +309,9 @@
"statHand": "Mão",
"statCreatures": "Criaturas",
"statLands": "Terrenos",
- "statOther": "Outros"
+ "statOther": "Outros",
+ "compactHud": "HUD de oponente compacto. Reduz cada oponente a uma única linha fina (nome + vida); toque em um oponente para focar todo o campo de batalha dele.",
+ "expandHud": "Expandir HUD de oponente. Mostra a mão e o detalhamento do campo de batalha de cada oponente."
},
"combat": {
"attackAll": "Atacar Todos",
@@ -380,7 +384,9 @@
"nounTarget": "alvo",
"confirmTap": "Confirmar Virar ({{selected}}/{{count}})",
"keepCurrentTargets": "Manter Alvos Atuais",
- "skip": "Pular"
+ "skip": "Pular",
+ "chooseNewTarget": "Escolha o novo alvo da mágica",
+ "chooseNewTargetForSpell": "Escolha um novo alvo para {{spell}}"
},
"log": {
"title": "Registro do Jogo",
@@ -1247,6 +1253,9 @@
"proliferateSubtitle": "Escolha qualquer número de permanentes e jogadores com marcadores. Cada alvo escolhido recebe mais um marcador de cada tipo já presente.",
"chooseObjectsTitle": "Escolher Permanentes",
"chooseObjectsSubtitle": "Escolha qualquer número de permanentes. Você paga um custo por cada um escolhido.",
+ "selectAll": "Todos",
+ "selectNone": "Nenhum",
+ "selectMine": "Meu lado",
"confirm": "Confirmar"
},
"replacement": {
@@ -1260,7 +1269,8 @@
"scopeSingle": "Escolha um novo alvo para a mágica",
"scopeMulti": "Escolha novos alvos para a mágica",
"subtitle": "{{scope}}. Atual: {{current}}",
- "confirm": "Confirmar"
+ "confirm": "Confirmar",
+ "badgeNewTarget": "Novo alvo"
},
"separatePiles": {
"chooserName": "Jogador {{number}}",
@@ -1321,5 +1331,27 @@
"subtitleLabel": "Escolha um rótulo para {{name}}",
"subtitleVoteRemaining": "Dê um voto ({{count}} restantes)",
"subtitleVote": "Dê seu voto"
+ },
+ "advance": {
+ "resolve": "Resolver",
+ "passPriority": "Passar prioridade",
+ "toPhase": "Para {{phase}}"
+ },
+ "outsideGame": {
+ "fromExile": "Do exílio",
+ "fromSideboard": "Do sideboard"
+ },
+ "phaseName": {
+ "Upkeep": "Manutenção",
+ "Draw": "Compra",
+ "PreCombatMain": "Fase principal 1",
+ "BeginCombat": "Início do combate",
+ "DeclareAttackers": "Declarar atacantes",
+ "DeclareBlockers": "Declarar bloqueadores",
+ "CombatDamage": "Dano de combate",
+ "EndCombat": "Fim do combate",
+ "PostCombatMain": "Fase principal 2",
+ "End": "Etapa final",
+ "Cleanup": "Limpeza"
}
}
diff --git a/client/src/i18n/locales/pt/menu.json b/client/src/i18n/locales/pt/menu.json
index f4f5fd4391..59c29c3219 100644
--- a/client/src/i18n/locales/pt/menu.json
+++ b/client/src/i18n/locales/pt/menu.json
@@ -4,7 +4,14 @@
},
"aiDifficulty": {
"label": "Dificuldade da IA",
- "ariaLabel": "Dificuldade da IA: {{difficulty}}"
+ "ariaLabel": "Dificuldade da IA: {{difficulty}}",
+ "levels": {
+ "VeryEasy": "Muito fácil",
+ "Easy": "Fácil",
+ "Medium": "Médio",
+ "Hard": "Difícil",
+ "VeryHard": "Muito difícil"
+ }
},
"bracketFilter": {
"label": "Filtro de bracket"
@@ -205,7 +212,8 @@
"browseAll": "Explorar Todos",
"loadMore": "Carregar Mais",
"selectedDeck": "Deck selecionado",
- "chooseDeckToContinue": "Escolha um deck para continuar"
+ "chooseDeckToContinue": "Escolha um deck para continuar",
+ "filterAll": "Todos"
},
"subscriptions": {
"emptyTitle": "Nenhuma inscrição em feed",
@@ -213,5 +221,13 @@
"feedMeta_one": "· {{count}} deck · Atualizado em {{date}}",
"feedMeta_other": "· {{count}} decks · Atualizado em {{date}}",
"error": "Erro: {{error}}"
+ },
+ "myDecksPage": {
+ "eyebrow": "Decks",
+ "title": "Decks.",
+ "description": "Abra uma lista salva, importe uma nova ou continue no construtor de decks."
+ },
+ "loadGameState": {
+ "readFailed": "Falha ao ler o arquivo"
}
}
diff --git a/client/src/i18n/locales/pt/settings.json b/client/src/i18n/locales/pt/settings.json
index d7e2e9bd00..422392eb62 100644
--- a/client/src/i18n/locales/pt/settings.json
+++ b/client/src/i18n/locales/pt/settings.json
@@ -32,6 +32,15 @@
"random": "Aleatório",
"customUrl": "URL personalizada",
"none": "Nenhum"
+ },
+ "cardSizeOptions": {
+ "small": "Pequeno",
+ "medium": "Médio",
+ "large": "Grande"
+ },
+ "logDefaultOptions": {
+ "open": "Aberto",
+ "closed": "Fechado"
}
},
"visual": {
@@ -45,7 +54,12 @@
"clearArtOverrides_one": "Limpar Todas as Substituições de Arte ({{count}})",
"clearArtOverrides_other": "Limpar Todas as Substituições de Arte ({{count}})",
"clearArtOverridesConfirm_one": "Limpar todas as {{count}} substituição de arte?",
- "clearArtOverridesConfirm_other": "Limpar todas as {{count}} substituições de arte?"
+ "clearArtOverridesConfirm_other": "Limpar todas as {{count}} substituições de arte?",
+ "vfxQualityOptions": {
+ "full": "Completa",
+ "reduced": "Reduzida",
+ "minimal": "Mínima"
+ }
},
"audio": {
"title": "Áudio",
@@ -106,7 +120,17 @@
"slowest": "Mais lento",
"resetSliderLabel": "Redefinir {{label}} para o padrão",
"atDefault": "No padrão",
- "resetToDefault": "Redefinir para o padrão"
+ "resetToDefault": "Redefinir para o padrão",
+ "labels": {
+ "effects": "Ritmo dos efeitos",
+ "combat": "Ritmo de combate",
+ "banners": "Ritmo dos banners"
+ },
+ "descriptions": {
+ "effects": "Conjuração de mágicas, mudanças de zona, mortes, mudanças de vida, marcadores, virar/desvirar.",
+ "combat": "Tempo do dano de combate — quanto tempo bloqueadores e atacantes permanecem antes de o dano ser resolvido.",
+ "banners": "Exibição do banner de início de turno."
+ }
},
"artChain": {
"emptyState": "Usando a arte padrão do Scryfall. Adicione regras abaixo para personalizar.",
diff --git a/client/src/i18n/resources.test.ts b/client/src/i18n/resources.test.ts
index 12873b9ad6..6bc84a208c 100644
--- a/client/src/i18n/resources.test.ts
+++ b/client/src/i18n/resources.test.ts
@@ -1,7 +1,53 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
import { describe, expect, it } from "vitest";
import { detectInitialLanguage, resources, SUPPORTED_LNGS } from "./resources";
+const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales");
+
+/** Every `/.json` catalog on disk, as absolute paths. Reads the dir
+ * tree directly (not the Vite glob) so encoding checks see raw bytes. */
+function localeCatalogFiles(): string[] {
+ return readdirSync(LOCALES_DIR, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .flatMap((dir) =>
+ readdirSync(join(LOCALES_DIR, dir.name))
+ .filter((file) => file.endsWith(".json"))
+ .map((file) => join(LOCALES_DIR, dir.name, file)),
+ );
+}
+
+/** Collect every leaf key path in a namespace tree, prefixed with the namespace
+ * (`game.modeChoice.confirm`). Recurses into nested objects; treats strings (and
+ * any non-object value) as leaves. */
+function flattenLeafKeys(
+ tree: Record,
+ prefix: string,
+ out: Set,
+): void {
+ for (const [key, value] of Object.entries(tree)) {
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
+ flattenLeafKeys(value as Record, path, out);
+ } else {
+ out.add(path);
+ }
+ }
+}
+
+/** The full namespace-prefixed leaf-key set for one locale, across every catalog
+ * the glob discovered for it. */
+function localeKeySet(lng: string): Set {
+ const keys = new Set();
+ for (const [ns, tree] of Object.entries(resources[lng] ?? {})) {
+ flattenLeafKeys(tree as Record, ns, keys);
+ }
+ return keys;
+}
+
// Gate test (plan §9 Phase 0 step 1): proves Vite's import.meta.glob runs under
// vitest's transform pipeline AND that the reshape yields { lng: { ns: {...} } }.
// Both the runtime catalogs and the "don't mock t, keep getByText" test strategy
@@ -23,3 +69,58 @@ describe("i18n resources", () => {
expect(SUPPORTED_LNGS as readonly string[]).toContain(detectInitialLanguage());
});
});
+
+// Key-parity gate: `en` is the typing oracle, so every other shipped locale must
+// carry the exact same namespace-prefixed leaf keys — no missing translations and
+// no orphaned keys. A namespace-prefixed set comparison catches both a single
+// dropped leaf and a wholesale missing/extra catalog file in one diff. Strict
+// equality includes plural suffixes (`_one`/`_other`); the catalogs mirror en's
+// structure, so a new CLDR plural category surfacing here is a deliberate review
+// signal, not a false failure.
+describe("i18n locale key parity", () => {
+ const enKeys = localeKeySet("en");
+
+ it("en (the oracle) has a non-empty key set", () => {
+ expect(enKeys.size).toBeGreaterThan(0);
+ });
+
+ for (const lng of SUPPORTED_LNGS) {
+ if (lng === "en") continue;
+ it(`${lng} has exactly the same keys as en`, () => {
+ const localeKeys = localeKeySet(lng);
+ const missing = [...enKeys].filter((k) => !localeKeys.has(k)).sort();
+ const extra = [...localeKeys].filter((k) => !enKeys.has(k)).sort();
+ // toEqual surfaces the offending keys directly in the failure diff.
+ expect({ missing, extra }).toEqual({ missing: [], extra: [] });
+ });
+ }
+});
+
+// Encoding gate: catalogs use literal UTF-8 characters (not `\uXXXX` escapes) so
+// translations stay human-readable and reviewable. The cost of literals is
+// encoding drift — a file saved as Latin-1, or mojibake pasted in — so enforce
+// that every catalog is valid, BOM-free UTF-8. This reads raw bytes; the parsed
+// `resources` glob cannot see encoding because Vite already decoded it.
+describe("i18n locale file encoding", () => {
+ const files = localeCatalogFiles();
+
+ it("discovers catalog files to validate", () => {
+ expect(files.length).toBeGreaterThan(0);
+ });
+
+ for (const file of files) {
+ const rel = file.slice(LOCALES_DIR.length + 1);
+ it(`${rel} is valid, BOM-free UTF-8`, () => {
+ const bytes = readFileSync(file);
+ // A UTF-8 BOM (EF BB BF) is valid UTF-8 but trips some JSON tooling.
+ expect([...bytes.subarray(0, 3)]).not.toEqual([0xef, 0xbb, 0xbf]);
+ // fatal:true throws on any malformed UTF-8 byte sequence.
+ expect(() =>
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes),
+ ).not.toThrow();
+ // A baked-in replacement char (U+FFFD) signals earlier corruption.
+ const replacementChar = String.fromCharCode(0xfffd);
+ expect(new TextDecoder("utf-8").decode(bytes)).not.toContain(replacementChar);
+ });
+ }
+});
diff --git a/client/src/i18n/resources.ts b/client/src/i18n/resources.ts
index d66a4e2dcd..5c46479ed1 100644
--- a/client/src/i18n/resources.ts
+++ b/client/src/i18n/resources.ts
@@ -9,7 +9,7 @@ const modules = import.meta.glob("./locales/*/*.json", {
/** Languages the app ships chrome catalogs for. English is the typing oracle and
* the `fallbackLng`; the others may lag without breaking the build. */
-export const SUPPORTED_LNGS = ["en", "es", "fr", "de", "it", "pt"] as const;
+export const SUPPORTED_LNGS = ["en", "es", "fr", "de", "it", "pt", "pl"] as const;
export type SupportedLng = (typeof SUPPORTED_LNGS)[number];
/** `{ en: { common: {...}, ... }, es: {...}, ... }` reshaped from the flat glob
diff --git a/client/src/pages/DraftPodPage.tsx b/client/src/pages/DraftPodPage.tsx
index c70e73eb5a..c8cdd17add 100644
--- a/client/src/pages/DraftPodPage.tsx
+++ b/client/src/pages/DraftPodPage.tsx
@@ -374,6 +374,7 @@ function MatchInProgressView() {
? matchPairing.botName
: matchPairing.opponentName
: null;
+ const isBotMatch = matchPairing?.type === "Bot";
const isHost = matchPairing?.type === "HumanHost";
return (
@@ -387,18 +388,24 @@ function MatchInProgressView() {
{t("podPhaseView.versusOpponent", { name: opponentName })}
-
- {isHost
- ? t("podPhaseView.youAreHosting")
- : t("podPhaseView.connectingOpponent")}
-
+ {!isBotMatch && (
+
+ {isHost
+ ? t("podPhaseView.youAreHosting")
+ : t("podPhaseView.connectingOpponent")}
+
+ )}
{
void startMatch().then((gameId) => {
if (gameId) navigate(`/game/${gameId}?mode=draft-match`);
});
}}
- className={menuButtonClass({ tone: "emerald", size: "sm" })}
+ className={menuButtonClass({
+ tone: "emerald",
+ size: "sm",
+ className: isBotMatch ? "mt-3" : undefined,
+ })}
>
{t("formatPicker.startMatch")}
@@ -692,14 +699,6 @@ export function DraftPodPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- void leave(true);
- resetPod();
- };
- }, [leave, resetPod]);
-
useEffect(() => {
if (searchParams.get("resume") !== "1") return;
void resumeHostedPod();
@@ -715,7 +714,7 @@ export function DraftPodPage() {
return (
-
navigate("/") : undefined} />
+
{phaseContent(phase, handleLeave)}
diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx
index 5251b13048..9cd9d5e831 100644
--- a/client/src/pages/GamePage.tsx
+++ b/client/src/pages/GamePage.tsx
@@ -109,6 +109,7 @@ import {
useMultiplayerStore,
type PlayerSlot,
} from "../stores/multiplayerStore.ts";
+import { useMultiplayerDraftStore } from "../stores/multiplayerDraftStore.ts";
import { GameProvider } from "../providers/GameProvider.tsx";
import { useCanActForWaitingState, usePerspectivePlayerId, usePlayerId } from "../hooks/usePlayerId.ts";
import { abilityChoiceLabel, formatAbilityCost } from "../viewmodel/costLabel.ts";
@@ -2057,6 +2058,7 @@ function GameOverScreen({
const source = searchParams.get("source");
const draftId = searchParams.get("draftId");
const isDraft = source === "draft" && !!draftId;
+ const isDraftPodMatch = mode === "draft-match";
const gameId = useGameStore((s) => s.gameId);
const [resultRecorded, setResultRecorded] = useState(false);
@@ -2072,6 +2074,17 @@ function GameOverScreen({
});
}, [isDraft, gameId, isDraw, isVictory, resultRecorded]);
+ useEffect(() => {
+ if (!isDraftPodMatch || resultRecorded) return;
+ void useMultiplayerDraftStore
+ .getState()
+ .reportActiveMatchGameResult(winner)
+ .then(() => setResultRecorded(true))
+ .catch((err) => {
+ console.error("[GameOverScreen] failed to report draft pod match result:", err);
+ });
+ }, [isDraftPodMatch, resultRecorded, winner]);
+
const handleRematch = () => {
const newId = crypto.randomUUID();
// Preserve the original launch configuration (format, players, match,
@@ -2175,6 +2188,19 @@ function GameOverScreen({
? t("gamePage.gameOver.backToDraft")
: t("gamePage.gameOver.continueRun")}
+ ) : isDraftPodMatch ? (
+
navigate("/draft-pod")}
+ className={gameButtonClass({
+ tone: isVictory ? "amber" : "slate",
+ size: "lg",
+ disabled: !resultRecorded,
+ className: "w-full justify-center sm:w-auto sm:min-w-[12rem]",
+ })}
+ >
+ {t("gamePage.gameOver.backToDraft")}
+
) : isOnlineMode ? (
navigate("/?view=lobby")}
diff --git a/client/src/pages/MyDecksPage.tsx b/client/src/pages/MyDecksPage.tsx
index 54547f6742..e6764a7c7e 100644
--- a/client/src/pages/MyDecksPage.tsx
+++ b/client/src/pages/MyDecksPage.tsx
@@ -1,4 +1,5 @@
import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useAudioContext } from "../audio/useAudioContext";
@@ -10,6 +11,7 @@ import { useCardDataStore } from "../stores/cardDataStore";
export function MyDecksPage() {
const navigate = useNavigate();
+ const { t } = useTranslation("menu");
useAudioContext("deck_builder");
// Warm the shared card DB so deck compat/coverage scans below are instant.
@@ -28,9 +30,9 @@ export function MyDecksPage() {
();
+
+ const localPlayerId = matchPairing?.type === "HumanGuest" ? 1 : 0;
+ const opponentPlayerId = localPlayerId === 0 ? 1 : 0;
+ let opponentName = randomAvatars[1]?.name ?? "Opponent";
+ if (matchPairing) {
+ opponentName = matchPairing.type === "Bot"
+ ? matchPairing.botName
+ : matchPairing.opponentName;
+ }
+ names.set(localPlayerId, "You");
+ names.set(opponentPlayerId, opponentName);
+
+ useMultiplayerStore.setState({
+ activePlayerId: localPlayerId,
+ playerNames: names,
+ playerAvatars: new Map(),
+ });
+
+ const avatarCards = new Map([
+ [localPlayerId, randomAvatars[localPlayerId]?.cardName ?? randomAvatars[0]?.cardName],
+ [opponentPlayerId, avatarCardNameForName(opponentName) ?? randomAvatars[opponentPlayerId]?.cardName],
+ ]);
+ for (const [playerId, cardName] of avatarCards) {
+ if (!cardName) continue;
+ fetchAvatarArtUrl(cardName).then((url) => {
+ if (!url || avatarGeneration !== generation) return;
+ const next = new Map(useMultiplayerStore.getState().playerAvatars);
+ next.set(playerId, url);
+ useMultiplayerStore.setState({ playerAvatars: next });
+ });
+ }
+}
+
function playerNamesRecordToMap(playerNames: Record): Map {
const names = new Map();
for (const [playerId, name] of Object.entries(playerNames)) {
@@ -408,6 +451,8 @@ export function GameProvider({
if (!isOnline && !isP2P) {
if (mode === "ai") {
setupRandomAvatars(playerCount ?? 2, gameId);
+ } else if (mode === "draft-match") {
+ setupDraftMatchAvatars(gameId);
} else {
useMultiplayerStore.setState({ playerNames: new Map(), playerAvatars: new Map() });
}
diff --git a/client/src/services/__tests__/scryfall.test.ts b/client/src/services/__tests__/scryfall.test.ts
index e9cadefb3b..fd05cbf1d0 100644
--- a/client/src/services/__tests__/scryfall.test.ts
+++ b/client/src/services/__tests__/scryfall.test.ts
@@ -78,36 +78,6 @@ describe("normalizeCardName", () => {
});
});
-describe("buildScryfallQuery", () => {
- it("adds a single set filter", async () => {
- const { buildScryfallQuery } = await loadScryfallModule();
-
- expect(buildScryfallQuery({
- text: "lightning",
- sets: ["DMU"],
- format: "standard",
- })).toBe("lightning set:dmu f:standard");
- });
-
- it("groups multiple set filters with OR", async () => {
- const { buildScryfallQuery } = await loadScryfallModule();
-
- expect(buildScryfallQuery({
- type: "Artifact",
- sets: ["DMU", "BRO"],
- format: "standard",
- })).toBe("t:Artifact (set:dmu OR set:bro) f:standard");
- });
-
- it("deduplicates and trims set filters", async () => {
- const { buildScryfallQuery } = await loadScryfallModule();
-
- expect(buildScryfallQuery({
- sets: [" dmu ", "DMU", "bro"],
- })).toBe("(set:dmu OR set:bro)");
- });
-});
-
describe("scryfallLegalityKey", () => {
it("uses Scryfall legality keys for constructed formats", async () => {
const { scryfallLegalityKey } = await loadScryfallModule();
diff --git a/client/src/services/engineRuntime.ts b/client/src/services/engineRuntime.ts
index ae26f27e91..c161411411 100644
--- a/client/src/services/engineRuntime.ts
+++ b/client/src/services/engineRuntime.ts
@@ -3,6 +3,11 @@ import type {
TokenCharacteristics,
TokenImageRef,
} from "../adapter/types";
+import {
+ buildLocalSearchCard,
+ loadScryfallData,
+ type ScryfallCard,
+} from "./scryfall";
type EngineModule = typeof import("@wasm/engine");
@@ -90,6 +95,74 @@ export async function getCardParseDetails(cardName: string) {
return engine.get_card_parse_details(cardName);
}
+/**
+ * A deck-builder card search. Mirrors the engine's `CardSearchQuery`
+ * (`crates/engine/src/database/search.rs`). All fields optional; an empty query
+ * matches nothing — callers gate on "has criteria" first.
+ */
+export interface CardSearchQuery {
+ text?: string;
+ /** WUBRG color letters the card's colors must include (superset match). */
+ colors?: string[];
+ /** A type word (core type, supertype, or subtype). */
+ type?: string;
+ cmcMax?: number;
+ /** Set codes; card must have a printing in at least one. */
+ sets?: string[];
+ /** A legality-format key (e.g. `"modern"`); card must be legal in it. */
+ legalFormat?: string;
+ limit?: number;
+}
+
+/** Engine result shape — rules data only (see `CardSearchResult` in the engine). */
+interface EngineCardSearchResult {
+ name: string;
+ oracle_id: string | null;
+ mana_value: number;
+ color_identity: string[];
+ legalities: Record;
+}
+
+interface EngineCardSearchResults {
+ results: EngineCardSearchResult[];
+ total: number;
+}
+
+/**
+ * Search the local card database through the engine. The engine is the single
+ * authority for the rules data search filters on (legality, sets, types, mana
+ * value, colors); the frontend hydrates artwork and type lines from the local
+ * Scryfall image map. No network search ever leaves the device.
+ */
+export async function searchCards(
+ query: CardSearchQuery,
+): Promise<{ cards: ScryfallCard[]; total: number }> {
+ await ensureCardDatabase();
+ // Hydration of artwork/type line needs the image map resolved.
+ await loadScryfallData();
+ const engine = await loadEngineModule();
+ const { results, total } = engine.search_cards_js({
+ text: query.text ?? "",
+ colors: query.colors ?? [],
+ type_line: query.type ?? "",
+ cmc_max: query.cmcMax ?? null,
+ sets: query.sets ?? [],
+ legal_format: query.legalFormat ?? null,
+ limit: query.limit ?? null,
+ }) as EngineCardSearchResults;
+
+ const cards = results.map((result) =>
+ buildLocalSearchCard({
+ oracleId: result.oracle_id ?? undefined,
+ name: result.name,
+ cmc: result.mana_value,
+ colorIdentity: result.color_identity,
+ legalities: result.legalities,
+ }),
+ );
+ return { cards, total };
+}
+
export async function getCardRulings(cardName: string): Promise {
await ensureCardDatabase();
const engine = await loadEngineModule();
diff --git a/client/src/services/playerAvatars.ts b/client/src/services/playerAvatars.ts
index 274c98aa44..9e50934c25 100644
--- a/client/src/services/playerAvatars.ts
+++ b/client/src/services/playerAvatars.ts
@@ -45,6 +45,18 @@ export function assignRandomAvatars(playerCount: number, seed?: number | string)
}));
}
+export function assignAvatarForSeat(
+ playerCount: number,
+ seat: number,
+ seed?: number | string,
+): PlayerAvatar | null {
+ return assignRandomAvatars(playerCount, seed)[seat] ?? null;
+}
+
+export function avatarCardNameForName(name: string): string | null {
+ return PLANESWALKER_IDENTITIES.find((id) => id.name === name)?.cardName ?? null;
+}
+
export async function fetchAvatarArtUrl(cardName: string): Promise {
try {
return await fetchCardImageUrl(cardName, 0, "art_crop");
diff --git a/client/src/services/scryfall.ts b/client/src/services/scryfall.ts
index a7aa942d2b..2b1f5bc12a 100644
--- a/client/src/services/scryfall.ts
+++ b/client/src/services/scryfall.ts
@@ -54,7 +54,7 @@ let printingsDataPromise: Promise | null = null;
let tokenImagesDataPromise: Promise | null = null;
let scryfallQueue: Promise = Promise.resolve();
-function loadScryfallData(): Promise {
+export function loadScryfallData(): Promise {
if (!scryfallDataPromise) {
scryfallDataPromise = fetch(__SCRYFALL_DATA_URL__)
.then((r) => r.json() as Promise)
@@ -163,6 +163,16 @@ export function isCardImageRotatedSync(oracleId: string, cardName: string): bool
return isSidewaysLayout(entry?.layout);
}
+/** Kamigawa-style flip cards (Scryfall `layout: "flip"`) print both halves in a
+ * single image, the alternate half rotated 180°. The preview lets the user spin
+ * the image to read that half; this reports whether a card is that layout. */
+export function isCardImageFlipLayoutSync(oracleId: string, cardName: string): boolean {
+ if (!scryfallDataResolved) return false;
+ const entry = scryfallDataResolved[oracleId.toLowerCase()]
+ ?? scryfallDataResolved[normalizeCardName(cardName).toLowerCase()];
+ return isFlipLayout(entry?.layout);
+}
+
const SCRYFALL_DELAY_MS = 100;
const MAX_RETRIES = 3;
const BASE_BACKOFF_MS = 1000;
@@ -178,6 +188,10 @@ function isSidewaysLayout(layout: string | undefined): boolean {
return layout === "split";
}
+function isFlipLayout(layout: string | undefined): boolean {
+ return layout === "flip";
+}
+
export interface ScryfallCard {
id?: string;
name: string;
@@ -344,6 +358,47 @@ export async function fetchCardData(cardName: string): Promise {
};
}
+/**
+ * Engine-authoritative fields for a card-search result. The engine owns these
+ * (mana value, color identity, legality) — see `crates/engine/src/database/search.rs`.
+ */
+export interface LocalSearchCardOverrides {
+ oracleId?: string;
+ name: string;
+ cmc: number;
+ colorIdentity: string[];
+ legalities: Record;
+}
+
+/**
+ * Build a display `ScryfallCard` for an engine search result. Rules data comes
+ * from the engine (the `overrides`); presentation data — artwork, printed type
+ * line, colors, mana cost, keywords — is hydrated from the already-loaded local
+ * image map, keyed by `oracleId` (falling back to name). Requires
+ * `loadScryfallData()` to have resolved; returns a usable card even when the
+ * image entry is missing (the grid renders a text-tile fallback).
+ */
+export function buildLocalSearchCard(overrides: LocalSearchCardOverrides): ScryfallCard {
+ const entry =
+ (overrides.oracleId
+ ? scryfallDataResolved?.[overrides.oracleId.toLowerCase()]
+ : undefined) ?? scryfallDataResolved?.[overrides.name.toLowerCase()];
+ const face = entry?.faces[0];
+ return {
+ name: entry?.name ?? overrides.name,
+ mana_cost: entry?.mana_cost ?? "",
+ cmc: overrides.cmc,
+ type_line: entry?.type_line ?? "",
+ colors: entry?.colors ?? [],
+ color_identity: overrides.colorIdentity,
+ keywords: entry?.keywords ?? [],
+ legalities: overrides.legalities,
+ image_uris: face
+ ? { art_crop: face.art_crop, normal: face.normal, small: face.normal, large: face.normal }
+ : undefined,
+ };
+}
+
function getImageUrl(
card: ScryfallCard,
size: ImageSize,
@@ -610,66 +665,6 @@ function buildTokenColorClause(colors: string[] | undefined | null): string {
return colorStr ? ` c=${colorStr}` : " c=c";
}
-/**
- * Search Scryfall for cards matching query. Uses rate limiting and handles 429s.
- */
-export async function searchScryfall(
- query: string,
- signal?: AbortSignal,
-): Promise<{ cards: ScryfallCard[]; total: number }> {
- const url = `https://api.scryfall.com/cards/search?q=${encodeURIComponent(query)}`;
- const response = await rateLimitedFetch(url);
-
- if (signal?.aborted) {
- return { cards: [], total: 0 };
- }
-
- if (response.status === 404) {
- return { cards: [], total: 0 };
- }
-
- if (!response.ok) {
- throw new Error(`Scryfall search error: ${response.status}`);
- }
-
- const data: ScryfallSearchResponse = await response.json();
- return { cards: data.data, total: data.total_cards };
-}
-
-/** Build Scryfall query string from filter options. */
-export function buildScryfallQuery(options: {
- text?: string;
- colors?: string[];
- type?: string;
- cmcMax?: number;
- cmcMin?: number;
- sets?: string[];
- format?: string;
-}): string {
- const parts: string[] = [];
-
- if (options.text) parts.push(options.text);
- if (options.colors?.length) parts.push(`c:${options.colors.join("")}`);
- if (options.type) parts.push(`t:${options.type}`);
- if (options.cmcMin !== undefined) parts.push(`cmc>=${options.cmcMin}`);
- if (options.cmcMax !== undefined) parts.push(`cmc<=${options.cmcMax}`);
- if (options.sets?.length) {
- const uniqueSetCodes = [...new Set(
- options.sets
- .map((setCode) => setCode.trim().toLowerCase())
- .filter(Boolean),
- )];
- if (uniqueSetCodes.length === 1) {
- parts.push(`set:${uniqueSetCodes[0]}`);
- } else if (uniqueSetCodes.length > 1) {
- parts.push(`(${uniqueSetCodes.map((setCode) => `set:${setCode}`).join(" OR ")})`);
- }
- }
- if (options.format) parts.push(`f:${options.format}`);
-
- return parts.join(" ");
-}
-
/** Get the best image URI for a card (handles double-faced cards). */
export function getCardImageSmall(card: ScryfallCard): string {
return card.image_uris?.small
diff --git a/client/src/stores/__tests__/draftPodStore.test.ts b/client/src/stores/__tests__/draftPodStore.test.ts
new file mode 100644
index 0000000000..1f803bea76
--- /dev/null
+++ b/client/src/stores/__tests__/draftPodStore.test.ts
@@ -0,0 +1,112 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ clearActiveDraftPod: vi.fn(),
+ loadActiveDraftPod: vi.fn(),
+ loadDraftHostSession: vi.fn(),
+ multiplayerState: {
+ role: null as "host" | "guest" | null,
+ phase: "idle",
+ roomCode: null as string | null,
+ hostDraft: vi.fn(),
+ },
+}));
+
+vi.mock("../../services/draftPersistence", () => ({
+ clearActiveDraftPod: mocks.clearActiveDraftPod,
+ loadActiveDraftPod: mocks.loadActiveDraftPod,
+ loadDraftHostSession: mocks.loadDraftHostSession,
+}));
+
+vi.mock("../multiplayerDraftStore", () => ({
+ useMultiplayerDraftStore: {
+ getState: () => mocks.multiplayerState,
+ },
+}));
+
+import { useDraftPodStore } from "../draftPodStore";
+
+const activeMeta = {
+ id: "draft-1",
+ roomCode: "ABCDE",
+ kind: "Premier" as const,
+ podSize: 8,
+ hostDisplayName: "Host",
+ tournamentFormat: "Swiss" as const,
+ podPolicy: "Competitive" as const,
+ phase: "matchInProgress" as const,
+ pickCount: 42,
+ updatedAt: Date.now(),
+};
+
+const persistedSession = {
+ persistenceId: "draft-1",
+ roomCode: "ABCDE",
+ kind: "Premier" as const,
+ podSize: 8,
+ hostDisplayName: "Host",
+ tournamentFormat: "Swiss" as const,
+ podPolicy: "Competitive" as const,
+ seatTokens: { 0: "host" },
+ seatNames: { 0: "Host" },
+ kickedTokens: [],
+ draftStarted: true,
+ draftCode: "ABCDE",
+ draftSessionJson: "{}",
+ setPoolJson: "{}",
+};
+
+describe("draftPodStore", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.multiplayerState.role = null;
+ mocks.multiplayerState.phase = "idle";
+ mocks.multiplayerState.roomCode = null;
+ mocks.multiplayerState.hostDraft = vi.fn(async () => {});
+ useDraftPodStore.getState().reset();
+ });
+
+ describe("resumeHostedPod", () => {
+ it("deduplicates concurrent resume calls for the same hosted pod", async () => {
+ let resolveSession!: (session: typeof persistedSession) => void;
+ const sessionPromise = new Promise((resolve) => {
+ resolveSession = resolve;
+ });
+ mocks.loadActiveDraftPod.mockReturnValue(activeMeta);
+ mocks.loadDraftHostSession.mockReturnValue(sessionPromise);
+
+ const first = useDraftPodStore.getState().resumeHostedPod();
+ const second = useDraftPodStore.getState().resumeHostedPod();
+ resolveSession(persistedSession);
+ await Promise.all([first, second]);
+
+ expect(mocks.loadDraftHostSession).toHaveBeenCalledTimes(1);
+ expect(mocks.multiplayerState.hostDraft).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not re-host when the saved pod is already live in memory", async () => {
+ mocks.multiplayerState.role = "host";
+ mocks.multiplayerState.phase = "matchInProgress";
+ mocks.multiplayerState.roomCode = "ABCDE";
+ mocks.loadActiveDraftPod.mockReturnValue(activeMeta);
+
+ await useDraftPodStore.getState().resumeHostedPod();
+
+ expect(mocks.loadDraftHostSession).not.toHaveBeenCalled();
+ expect(mocks.multiplayerState.hostDraft).not.toHaveBeenCalled();
+ });
+
+ it("retries resume when matching host state is not live", async () => {
+ mocks.multiplayerState.role = "host";
+ mocks.multiplayerState.phase = "error";
+ mocks.multiplayerState.roomCode = "ABCDE";
+ mocks.loadActiveDraftPod.mockReturnValue(activeMeta);
+ mocks.loadDraftHostSession.mockResolvedValue(persistedSession);
+
+ await useDraftPodStore.getState().resumeHostedPod();
+
+ expect(mocks.loadDraftHostSession).toHaveBeenCalledOnce();
+ expect(mocks.multiplayerState.hostDraft).toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/client/src/stores/__tests__/multiplayerDraftStore.test.ts b/client/src/stores/__tests__/multiplayerDraftStore.test.ts
index 7a31bd6c13..1e8747badf 100644
--- a/client/src/stores/__tests__/multiplayerDraftStore.test.ts
+++ b/client/src/stores/__tests__/multiplayerDraftStore.test.ts
@@ -28,6 +28,7 @@ const mockHostAdapter = {
kickPlayer: vi.fn(),
requestPause: vi.fn(),
requestResume: vi.fn(),
+ overrideMatchResult: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
status: "idle" as const,
roomCode: null,
@@ -180,6 +181,128 @@ describe("multiplayerDraftStore", () => {
expect(state.total).toBe(8);
expect(state.seats).toHaveLength(1);
});
+
+ it("projects restored MatchInProgress views into match phase", async () => {
+ await useMultiplayerDraftStore.getState().hostDraft({
+ setPoolJson: "{}",
+ kind: "Premier",
+ podSize: 8,
+ hostDisplayName: "Host",
+ tournamentFormat: "Swiss",
+ podPolicy: "Competitive",
+ });
+
+ const view = mockView("MatchInProgress");
+ capturedHostEventHandler!({ type: "viewUpdated", view });
+
+ const state = useMultiplayerDraftStore.getState();
+ expect(state.phase).toBe("matchInProgress");
+ expect(state.view).toBe(view);
+ });
+
+ it("handles host-seat Bo3 prompt messages", async () => {
+ await useMultiplayerDraftStore.getState().hostDraft({
+ setPoolJson: "{}",
+ kind: "Traditional",
+ podSize: 8,
+ hostDisplayName: "Host",
+ tournamentFormat: "Swiss",
+ podPolicy: "Competitive",
+ });
+
+ capturedHostEventHandler!({
+ type: "bo3ChoosePlayDraw",
+ matchId: "match-1",
+ gameNumber: 2,
+ score: { p0_wins: 0, p1_wins: 1, draws: 0 },
+ timerMs: 10_000,
+ });
+
+ let state = useMultiplayerDraftStore.getState();
+ expect(state.playDrawPrompt).toEqual({
+ matchId: "match-1",
+ gameNumber: 2,
+ score: { p0_wins: 0, p1_wins: 1, draws: 0 },
+ timerMs: 10_000,
+ });
+ expect(state.timerRemainingMs).toBe(10_000);
+
+ capturedHostEventHandler!({
+ type: "bo3GameStart",
+ matchId: "match-1",
+ gameNumber: 2,
+ firstPlayerSeat: 0,
+ });
+
+ state = useMultiplayerDraftStore.getState();
+ expect(state.phase).toBe("matchInProgress");
+ expect(state.playDrawPrompt).toBeNull();
+ expect(state.sideboardSubmitted).toBe(false);
+ });
+
+ it("reports active bot match results back to the pod host", async () => {
+ await useMultiplayerDraftStore.getState().hostDraft({
+ setPoolJson: "{}",
+ kind: "Premier",
+ podSize: 8,
+ hostDisplayName: "Host",
+ tournamentFormat: "Swiss",
+ podPolicy: "Competitive",
+ });
+
+ useMultiplayerDraftStore.setState({
+ matchPairing: {
+ type: "Bot",
+ matchId: "match-1",
+ round: 1,
+ localSeat: 0,
+ botSeat: 4,
+ botName: "Chandra",
+ deckPayload: {
+ player: { main_deck: [], sideboard: [], commander: [] },
+ opponent: { main_deck: [], sideboard: [], commander: [] },
+ ai_decks: [],
+ },
+ matchConfig: { match_type: "Bo1" },
+ },
+ });
+
+ await useMultiplayerDraftStore.getState().reportActiveMatchGameResult(1);
+
+ expect(mockHostAdapter.overrideMatchResult).toHaveBeenCalledWith("match-1", 4);
+ });
+
+ it("reports active match concessions as opponent wins", async () => {
+ await useMultiplayerDraftStore.getState().hostDraft({
+ setPoolJson: "{}",
+ kind: "Premier",
+ podSize: 8,
+ hostDisplayName: "Host",
+ tournamentFormat: "Swiss",
+ podPolicy: "Competitive",
+ });
+
+ useMultiplayerDraftStore.setState({
+ matchPairing: {
+ type: "Bot",
+ matchId: "match-2",
+ round: 1,
+ localSeat: 0,
+ botSeat: 5,
+ botName: "Jace",
+ deckPayload: {
+ player: { main_deck: [], sideboard: [], commander: [] },
+ opponent: { main_deck: [], sideboard: [], commander: [] },
+ ai_decks: [],
+ },
+ matchConfig: { match_type: "Bo1" },
+ },
+ });
+
+ await useMultiplayerDraftStore.getState().reportActiveMatchConcession();
+
+ expect(mockHostAdapter.overrideMatchResult).toHaveBeenCalledWith("match-2", 5);
+ });
});
describe("joinDraft", () => {
diff --git a/client/src/stores/draftPodStore.ts b/client/src/stores/draftPodStore.ts
index 673d2fbc7e..078cf14d0e 100644
--- a/client/src/stores/draftPodStore.ts
+++ b/client/src/stores/draftPodStore.ts
@@ -108,6 +108,8 @@ function normalizePodConfig(config: PodConfig): PodConfig {
return config;
}
+let resumeHostedPodPromise: Promise | null = null;
+
// ── Store ──────────────────────────────────────────────────────────────
export const useDraftPodStore = create()(
@@ -188,46 +190,68 @@ export const useDraftPodStore = create()(
},
resumeHostedPod: async () => {
- const meta = loadActiveDraftPod();
- if (!meta) {
- set({ configError: "No draft pod to resume" });
- return;
+ if (resumeHostedPodPromise) {
+ return resumeHostedPodPromise;
}
- const persisted = await loadDraftHostSession(meta.id);
- if (!persisted) {
- clearActiveDraftPod();
- set({ configError: "Saved draft pod was not found" });
- return;
- }
+ resumeHostedPodPromise = (async () => {
+ const meta = loadActiveDraftPod();
+ if (!meta) {
+ set({ configError: "No draft pod to resume" });
+ return;
+ }
+
+ const activeDraft = useMultiplayerDraftStore.getState();
+ if (
+ activeDraft.role === "host" &&
+ activeDraft.phase !== "idle" &&
+ activeDraft.phase !== "error" &&
+ activeDraft.roomCode === meta.roomCode
+ ) {
+ return;
+ }
+
+ const persisted = await loadDraftHostSession(meta.id);
+ if (!persisted) {
+ clearActiveDraftPod();
+ set({ configError: "Saved draft pod was not found" });
+ return;
+ }
- set({
- config: {
- setCode: "",
- setName: "Draft Pod",
+ set({
+ config: {
+ setCode: "",
+ setName: "Draft Pod",
+ kind: persisted.kind,
+ podSize: persisted.podSize,
+ tournamentFormat: persisted.tournamentFormat,
+ podPolicy: persisted.podPolicy,
+ },
+ hostDisplayName: persisted.hostDisplayName,
+ setPoolJson: persisted.setPoolJson,
+ loadingPool: false,
+ configError: null,
+ });
+
+ const hostConfig: DraftPodHostConfig = {
+ setPoolJson: persisted.setPoolJson,
kind: persisted.kind,
podSize: persisted.podSize,
+ hostDisplayName: persisted.hostDisplayName,
tournamentFormat: persisted.tournamentFormat,
podPolicy: persisted.podPolicy,
- },
- hostDisplayName: persisted.hostDisplayName,
- setPoolJson: persisted.setPoolJson,
- loadingPool: false,
- configError: null,
- });
-
- const hostConfig: DraftPodHostConfig = {
- setPoolJson: persisted.setPoolJson,
- kind: persisted.kind,
- podSize: persisted.podSize,
- hostDisplayName: persisted.hostDisplayName,
- tournamentFormat: persisted.tournamentFormat,
- podPolicy: persisted.podPolicy,
- persistenceId: persisted.persistenceId,
- preferredRoomCode: persisted.roomCode || undefined,
- };
+ persistenceId: persisted.persistenceId,
+ preferredRoomCode: persisted.roomCode || undefined,
+ };
- await useMultiplayerDraftStore.getState().hostDraft(hostConfig);
+ await useMultiplayerDraftStore.getState().hostDraft(hostConfig);
+ })();
+
+ try {
+ await resumeHostedPodPromise;
+ } finally {
+ resumeHostedPodPromise = null;
+ }
},
joinPod: async () => {
diff --git a/client/src/stores/multiplayerDraftStore.ts b/client/src/stores/multiplayerDraftStore.ts
index c44f8631ab..aa3bf21a9b 100644
--- a/client/src/stores/multiplayerDraftStore.ts
+++ b/client/src/stores/multiplayerDraftStore.ts
@@ -45,6 +45,7 @@ import {
type ActiveDraftPodMeta,
type ActiveDraftPodPhase,
} from "../services/draftPersistence";
+import { FORMAT_DEFAULTS } from "./multiplayerStore";
// ── Types ──────────────────────────────────────────────────────────────
@@ -153,7 +154,11 @@ interface MultiplayerDraftActions {
/** Both: start the match for the current pairing. */
startMatch: () => Promise;
/** Both: report a match result back to the pod host. */
- reportMatchResult: (matchId: string, winnerSeat: number | null) => void;
+ reportMatchResult: (matchId: string, winnerSeat: number | null) => Promise;
+ /** Both: report the active game result using the current draft match pairing. */
+ reportActiveMatchGameResult: (gameWinner: number | null) => Promise;
+ /** Both: concede the active draft match and report the opponent as winner. */
+ reportActiveMatchConcession: () => Promise;
/** Host: advance to the next round (Casual mode). */
advanceRound: () => void;
/** Host: override a match result (Casual mode). */
@@ -173,6 +178,8 @@ interface MultiplayerDraftActions {
let activeHostAdapter: DraftPodHostAdapter | null = null;
let activeGuestAdapter: DraftPodGuestAdapter | null = null;
let activeMatchController: GameLoopController | null = null;
+const reportedMatchResultIds = new Set();
+const DRAFT_MATCH_FORMAT_CONFIG = FORMAT_DEFAULTS.Limited;
const RARITY_SCORE: Record = {
mythic: 4,
@@ -254,6 +261,12 @@ function guestWinnerSeatForLaunch(launch: DraftMatchLaunch, gameWinner: number |
return gameWinner === 0 ? opponentSeatForLaunch(launch) : launch.localSeat;
}
+function winnerSeatForGameResult(launch: DraftMatchLaunch, gameWinner: number | null): number | null {
+ if (gameWinner === null) return null;
+ const localGamePlayerId = launch.type === "HumanGuest" ? 1 : 0;
+ return gameWinner === localGamePlayerId ? launch.localSeat : opponentSeatForLaunch(launch);
+}
+
function disposeMatchController(): void {
activeMatchController?.dispose();
activeMatchController = null;
@@ -333,6 +346,49 @@ function activePhaseForHostStatus(status: DraftPodHostStatus): ActiveDraftPodPha
}
}
+function phaseForDraftViewStatus(status: DraftPlayerView["status"]): MultiplayerDraftPhase {
+ switch (status) {
+ case "Lobby":
+ return "lobby";
+ case "Drafting":
+ case "Paused":
+ return "drafting";
+ case "Deckbuilding":
+ return "deckbuilding";
+ case "Pairing":
+ return "pairing";
+ case "MatchInProgress":
+ return "matchInProgress";
+ case "RoundComplete":
+ return "roundComplete";
+ case "Complete":
+ return "complete";
+ case "Abandoned":
+ return "error";
+ }
+}
+
+function activePhaseForDraftViewStatus(status: DraftPlayerView["status"]): ActiveDraftPodPhase | null {
+ switch (status) {
+ case "Lobby":
+ return "lobby";
+ case "Drafting":
+ case "Paused":
+ return "drafting";
+ case "Deckbuilding":
+ return "deckbuilding";
+ case "Pairing":
+ case "RoundComplete":
+ return "pairing";
+ case "MatchInProgress":
+ return "matchInProgress";
+ case "Complete":
+ return "complete";
+ case "Abandoned":
+ return null;
+ }
+}
+
/** Dispose the active match adapter (P2PHostAdapter or P2PGuestAdapter). */
function disposeMatchAdapter(set: SetFn): void {
const state = useMultiplayerDraftStore.getState();
@@ -417,11 +473,7 @@ export const useMultiplayerDraftStore = create<
await adapter.initialize(config);
if (config.persistenceId) {
const view = get().view;
- const phase: ActiveDraftPodPhase = view?.status === "Deckbuilding"
- ? "deckbuilding"
- : view?.status === "Drafting"
- ? "drafting"
- : "lobby";
+ const phase = view ? activePhaseForDraftViewStatus(view.status) ?? "lobby" : "lobby";
saveActiveDraftPod({
id: config.persistenceId,
roomCode: adapter.roomCode ?? config.preferredRoomCode ?? "",
@@ -548,9 +600,10 @@ export const useMultiplayerDraftStore = create<
},
startMatch: async () => {
- const { matchPairing } = get();
+ const { matchPairing, matchAdapter } = get();
if (!matchPairing) return null;
const gameId = `draft-match-${matchPairing.matchId}`;
+ if (matchAdapter) return gameId;
try {
if (matchPairing.type === "HumanHost") {
@@ -569,7 +622,7 @@ export const useMultiplayerDraftStore = create<
host.peer,
host.onGuestConnected,
2, // 1v1 match
- undefined,
+ DRAFT_MATCH_FORMAT_CONFIG,
matchPairing.matchConfig,
);
@@ -591,7 +644,7 @@ export const useMultiplayerDraftStore = create<
if (wf.type === "GameOver") {
// Match is complete — report result to pod host
const winnerSeat = winnerSeatForLaunch(matchPairing, wf.data.winner);
- get().reportMatchResult(matchPairing.matchId, winnerSeat);
+ void get().reportMatchResult(matchPairing.matchId, winnerSeat);
} else if (wf.type === "BetweenGamesSideboard") {
// Between games in Bo3 — bridge to draft pod host for sideboard orchestration.
const score = wf.data.score;
@@ -625,7 +678,7 @@ export const useMultiplayerDraftStore = create<
if (event.type === "gameOver") {
// Connection-level failure — report as match loss
const winnerSeat = winnerSeatForLaunch(matchPairing, event.winner);
- get().reportMatchResult(matchPairing.matchId, winnerSeat);
+ void get().reportMatchResult(matchPairing.matchId, winnerSeat);
}
});
@@ -664,7 +717,7 @@ export const useMultiplayerDraftStore = create<
if (wf.type === "GameOver") {
// Guest reports as backup (host's report is authoritative)
const winnerSeat = guestWinnerSeatForLaunch(matchPairing, wf.data.winner);
- get().reportMatchResult(matchPairing.matchId, winnerSeat);
+ void get().reportMatchResult(matchPairing.matchId, winnerSeat);
}
// BetweenGamesSideboard: guest receives sideboard prompt via draft pod channel
// (handled by bo3SideboardPrompt event from P2PDraftGuest), not here.
@@ -672,7 +725,7 @@ export const useMultiplayerDraftStore = create<
if (event.type === "gameOver") {
// Connection failure — report as match loss
const winnerSeat = guestWinnerSeatForLaunch(matchPairing, event.winner);
- get().reportMatchResult(matchPairing.matchId, winnerSeat);
+ void get().reportMatchResult(matchPairing.matchId, winnerSeat);
}
});
@@ -687,7 +740,7 @@ export const useMultiplayerDraftStore = create<
await matchAdapter.initialize();
const initResult = await matchAdapter.initializeGame(
matchPairing.deckPayload,
- undefined,
+ DRAFT_MATCH_FORMAT_CONFIG,
2,
matchPairing.matchConfig,
);
@@ -704,11 +757,33 @@ export const useMultiplayerDraftStore = create<
reportMatchResult: (matchId, winnerSeat) => {
const { role } = get();
+ if (reportedMatchResultIds.has(matchId)) return Promise.resolve();
if (role === "host" && activeHostAdapter) {
- void activeHostAdapter.overrideMatchResult(matchId, winnerSeat);
+ reportedMatchResultIds.add(matchId);
+ return activeHostAdapter.overrideMatchResult(matchId, winnerSeat).catch((err) => {
+ reportedMatchResultIds.delete(matchId);
+ throw err;
+ });
} else if (role === "guest" && activeGuestAdapter) {
+ reportedMatchResultIds.add(matchId);
activeGuestAdapter.sendMatchResult(matchId, winnerSeat);
}
+ return Promise.resolve();
+ },
+
+ reportActiveMatchGameResult: async (gameWinner) => {
+ const { matchPairing, reportMatchResult } = get();
+ if (!matchPairing) return;
+ await reportMatchResult(
+ matchPairing.matchId,
+ winnerSeatForGameResult(matchPairing, gameWinner),
+ );
+ },
+
+ reportActiveMatchConcession: async () => {
+ const { matchPairing, reportMatchResult } = get();
+ if (!matchPairing) return;
+ await reportMatchResult(matchPairing.matchId, opponentSeatForLaunch(matchPairing));
},
advanceRound: () => {
@@ -766,6 +841,7 @@ export const useMultiplayerDraftStore = create<
leave: async (preserveSession = false) => {
// Dispose match adapter first (game P2P connection)
disposeMatchAdapter(set);
+ reportedMatchResultIds.clear();
if (activeHostAdapter) {
await activeHostAdapter.dispose({ preserveSession });
@@ -783,6 +859,7 @@ export const useMultiplayerDraftStore = create<
reset: () => {
disposeMatchAdapter(set);
+ reportedMatchResultIds.clear();
set(initialState);
},
}));
@@ -860,16 +937,17 @@ function handleHostEvent(event: DraftPodHostEvent, set: SetFn): void {
break;
case "viewUpdated":
set({
+ phase: phaseForDraftViewStatus(event.view.status),
view: event.view,
timerRemainingMs: event.view.timer_remaining_ms ?? null,
standings: event.view.standings ?? [],
currentRound: event.view.current_round ?? 0,
pairings: event.view.pairings ?? [],
});
- saveDraftPodProgress(
- event.view.status === "Deckbuilding" ? "deckbuilding" : "drafting",
- event.view,
- );
+ {
+ const activePhase = activePhaseForDraftViewStatus(event.view.status);
+ if (activePhase) saveDraftPodProgress(activePhase, event.view);
+ }
break;
case "lobbyUpdate":
set({ joined: event.joined, total: event.total, seats: event.seats });
@@ -888,6 +966,12 @@ function handleHostEvent(event: DraftPodHostEvent, set: SetFn): void {
set({ phase: "pairing" });
saveDraftPodProgress("pairing");
break;
+ case "draftPaused":
+ set({ paused: true, pauseReason: event.reason });
+ break;
+ case "draftResumed":
+ set({ paused: false, pauseReason: null });
+ break;
case "pairingsGenerated":
set({ phase: "matchInProgress", currentRound: event.round, pairings: event.pairings });
saveDraftPodProgress("matchInProgress");
@@ -929,6 +1013,40 @@ function handleHostEvent(event: DraftPodHostEvent, set: SetFn): void {
set({ phase: "matchInProgress", sideboardPrompt: null, playDrawPrompt: null, sideboardSubmitted: false });
saveDraftPodProgress("matchInProgress");
break;
+ case "bo3SideboardPrompt":
+ set({
+ phase: "betweenGames",
+ sideboardPrompt: {
+ matchId: event.matchId,
+ gameNumber: event.gameNumber,
+ score: event.score,
+ loserSeat: event.loserSeat,
+ timerMs: event.timerMs,
+ },
+ sideboardSubmitted: false,
+ playDrawPrompt: null,
+ timerRemainingMs: event.timerMs > 0 ? event.timerMs : null,
+ });
+ break;
+ case "bo3ChoosePlayDraw":
+ set({
+ playDrawPrompt: {
+ matchId: event.matchId,
+ gameNumber: event.gameNumber,
+ score: event.score,
+ timerMs: event.timerMs,
+ },
+ timerRemainingMs: event.timerMs > 0 ? event.timerMs : null,
+ });
+ break;
+ case "bo3GameStart":
+ set({
+ phase: "matchInProgress",
+ sideboardPrompt: null,
+ playDrawPrompt: null,
+ sideboardSubmitted: false,
+ });
+ break;
}
}
@@ -949,6 +1067,7 @@ function handleGuestEvent(event: DraftPodGuestEvent, set: SetFn): void {
break;
case "viewUpdated":
set({
+ phase: phaseForDraftViewStatus(event.view.status),
view: event.view,
timerRemainingMs: event.view.timer_remaining_ms ?? null,
standings: event.view.standings ?? [],
diff --git a/client/src/wasm/engine_wasm.d.ts b/client/src/wasm/engine_wasm.d.ts
index 3ff876015a..89c06b57e5 100644
--- a/client/src/wasm/engine_wasm.d.ts
+++ b/client/src/wasm/engine_wasm.d.ts
@@ -242,6 +242,15 @@ export function restore_game_state(json_str: string): void;
*/
export function resume_multiplayer_host_state(json_str: string): void;
+/**
+ * Search the loaded card database. The engine is the single authority for the
+ * rules data search filters on — format legality, set membership, card types,
+ * mana value, and colors — so deck-builder search runs here, never as a
+ * third-party API call. Returns `{ results, total }` (see `CardSearchResults`),
+ * or an error if the database is not loaded or the query is malformed.
+ */
+export function search_cards_js(query: any): any;
+
/**
* Select an action from merged scores using softmax.
* Called after collecting scored candidates from parallel workers and merging.
@@ -323,6 +332,7 @@ export interface InitOutput {
readonly resolve_all: (a: number, b: number, c: number, d: number) => [number, number, number];
readonly restore_game_state: (a: number, b: number) => [number, number];
readonly resume_multiplayer_host_state: (a: number, b: number) => [number, number];
+ readonly search_cards_js: (a: any) => [number, number, number];
readonly select_action_from_scores: (a: number, b: number, c: number, d: number, e: bigint) => [number, number, number];
readonly set_multiplayer_mode: (a: number) => void;
readonly sideboardPolicyForFormat: (a: any) => [number, number, number];
diff --git a/crates/draft-core/src/session.rs b/crates/draft-core/src/session.rs
index 13abd044a4..e5d6f6b71e 100644
--- a/crates/draft-core/src/session.rs
+++ b/crates/draft-core/src/session.rs
@@ -64,7 +64,9 @@ pub fn apply(
winner_seat,
} => apply_report_match_result(session, match_id, winner_seat),
DraftAction::AdvanceRound => apply_advance_round(session),
- DraftAction::ReplaceSeatWithBot { seat } => apply_replace_seat_with_bot(session, seat),
+ DraftAction::ReplaceSeatWithBot { seat, name } => {
+ apply_replace_seat_with_bot(session, seat, name)
+ }
}
}
@@ -229,6 +231,7 @@ fn generate_swiss_pairings(
players: [*p1, *p2],
match_id: format!("r{round}-t{table}"),
status: PairingStatus::Pending,
+ winner: None,
})
.collect()
}
@@ -249,6 +252,7 @@ fn generate_se_pairings(session: &DraftSession, round: u8) -> Vec
players: [p1, p2],
match_id: format!("r{round}-t{table}"),
status: PairingStatus::Pending,
+ winner: None,
}
})
.collect()
@@ -261,25 +265,9 @@ fn generate_se_pairings(session: &DraftSession, round: u8) -> Vec
.filter(|p| p.round == prev_round && p.status == PairingStatus::Complete)
.collect();
- // Winners: for each completed pairing, the winner is the player with more match_wins
- // We determine winners from match records
let winners: Vec = prev_pairings
.iter()
- .map(|p| {
- let r0 = session.match_records.get(&p.players[0]);
- let r1 = session.match_records.get(&p.players[1]);
- let w0 = r0.map_or(0, |r| r.match_wins);
- let w1 = r1.map_or(0, |r| r.match_wins);
- // In SE, the match result determines the winner. We look at which player
- // gained a match_win most recently. Since match records are cumulative,
- // we check: the player with more match_wins is the winner. If tied,
- // player[0] advances (shouldn't happen with proper result reporting).
- if w0 >= w1 {
- p.players[0]
- } else {
- p.players[1]
- }
- })
+ .filter_map(|p| p.result_winner(&session.match_records))
.collect();
// Pair adjacent winners
@@ -294,6 +282,7 @@ fn generate_se_pairings(session: &DraftSession, round: u8) -> Vec
players: [chunk[0], chunk[1]],
match_id: format!("r{round}-t{table}"),
status: PairingStatus::Pending,
+ winner: None,
})
} else {
None
@@ -303,12 +292,71 @@ fn generate_se_pairings(session: &DraftSession, round: u8) -> Vec
}
}
+fn apply_match_record_result(
+ records: &mut HashMap,
+ players: [PlayerId; 2],
+ winner: Option,
+) {
+ match winner {
+ Some(winner_pid) => {
+ let loser_pid = if players[0] == winner_pid {
+ players[1]
+ } else {
+ players[0]
+ };
+ ensure_match_record(records, winner_pid).match_wins += 1;
+ ensure_match_record(records, winner_pid).wins += 1;
+ ensure_match_record(records, loser_pid).match_losses += 1;
+ ensure_match_record(records, loser_pid).losses += 1;
+ }
+ None => {
+ for pid in players {
+ ensure_match_record(records, pid).draws += 1;
+ }
+ }
+ }
+}
+
+fn undo_match_record_result(
+ records: &mut HashMap,
+ players: [PlayerId; 2],
+ winner: Option,
+) {
+ match winner {
+ Some(winner_pid) => {
+ let loser_pid = if players[0] == winner_pid {
+ players[1]
+ } else {
+ players[0]
+ };
+ if let Some(record) = records.get_mut(&winner_pid) {
+ record.match_wins = record.match_wins.saturating_sub(1);
+ record.wins = record.wins.saturating_sub(1);
+ }
+ if let Some(record) = records.get_mut(&loser_pid) {
+ record.match_losses = record.match_losses.saturating_sub(1);
+ record.losses = record.losses.saturating_sub(1);
+ }
+ }
+ None => {
+ for pid in players {
+ if let Some(record) = records.get_mut(&pid) {
+ record.draws = record.draws.saturating_sub(1);
+ }
+ }
+ }
+ }
+}
+
fn apply_report_match_result(
session: &mut DraftSession,
match_id: String,
winner_seat: Option,
) -> Result, DraftError> {
- if session.status != DraftStatus::MatchInProgress {
+ if !matches!(
+ session.status,
+ DraftStatus::MatchInProgress | DraftStatus::RoundComplete
+ ) {
return Err(DraftError::InvalidTransition {
from: session.status,
action: "ReportMatchResult".to_string(),
@@ -324,29 +372,52 @@ fn apply_report_match_result(
match_id: match_id.clone(),
})?;
- session.pairings[pairing_idx].status = PairingStatus::Complete;
- let players = session.pairings[pairing_idx].players;
+ let pairing_round = session.pairings[pairing_idx].round;
+ if pairing_round != session.current_round {
+ return Err(DraftError::PairingNotInCurrentRound {
+ match_id,
+ current_round: session.current_round,
+ });
+ }
- // Update match records
- match winner_seat {
+ if session.config.tournament_format == TournamentFormat::SingleElimination
+ && winner_seat.is_none()
+ {
+ return Err(DraftError::MatchWinnerRequired { match_id });
+ }
+
+ let players = session.pairings[pairing_idx].players;
+ let previous_status = session.pairings[pairing_idx].status;
+ let previous_winner = session.pairings[pairing_idx].result_winner(&session.match_records);
+ let winner_pid = match winner_seat {
Some(winner) => {
- let winner_pid = seat_player_id(session, winner);
- let loser_pid = if players[0] == winner_pid {
- players[1]
- } else {
- players[0]
- };
- ensure_match_record(&mut session.match_records, winner_pid).match_wins += 1;
- ensure_match_record(&mut session.match_records, loser_pid).match_losses += 1;
- }
- None => {
- // Draw
- for &pid in &players {
- ensure_match_record(&mut session.match_records, pid).draws += 1;
+ let pod_size = session.seats.len() as u8;
+ if winner >= pod_size {
+ return Err(DraftError::SeatOutOfRange {
+ seat: winner,
+ pod_size,
+ });
+ }
+ let pid = seat_player_id(session, winner);
+ if !players.contains(&pid) {
+ return Err(DraftError::SeatNotInPairing {
+ seat: winner,
+ match_id,
+ });
}
+ Some(pid)
}
+ None => None,
+ };
+
+ if previous_status == PairingStatus::Complete {
+ undo_match_record_result(&mut session.match_records, players, previous_winner);
}
+ session.pairings[pairing_idx].status = PairingStatus::Complete;
+ session.pairings[pairing_idx].winner = winner_pid;
+ apply_match_record_result(&mut session.match_records, players, winner_pid);
+
let mut deltas = vec![DraftDelta::MatchResultRecorded {
match_id,
winner_seat,
@@ -408,19 +479,15 @@ fn apply_advance_round(session: &mut DraftSession) -> Result, Dr
fn apply_replace_seat_with_bot(
session: &mut DraftSession,
seat: u8,
+ name: Option,
) -> Result, DraftError> {
let pod_size = session.seats.len() as u8;
if seat >= pod_size {
return Err(DraftError::SeatOutOfRange { seat, pod_size });
}
- let old_name = match &session.seats[seat as usize] {
- DraftSeat::Human { display_name, .. } => display_name.clone(),
- DraftSeat::Bot { name } => name.clone(),
- };
-
session.seats[seat as usize] = DraftSeat::Bot {
- name: format!("Bot (was {old_name})"),
+ name: name.unwrap_or_else(|| format!("Seat {}", seat + 1)),
};
Ok(vec![DraftDelta::SeatReplacedWithBot { seat }])
@@ -959,6 +1026,7 @@ mod tests {
{
pairing.status = PairingStatus::Complete;
let winner = pairing.players[i % 2];
+ pairing.winner = Some(winner);
ensure_match_record(&mut session.match_records, winner).match_wins += 1;
let loser = pairing.players[(i + 1) % 2];
ensure_match_record(&mut session.match_records, loser).match_losses += 1;
@@ -1041,6 +1109,97 @@ mod tests {
assert_eq!(pairings[3].players, [PlayerId(3), PlayerId(4)]);
}
+ #[test]
+ fn single_elimination_advances_pairing_winners() {
+ let config = DraftConfig {
+ source: DraftSource::Set {
+ code: "TST".to_string(),
+ },
+ set_code: "TST".to_string(),
+ kind: DraftKind::Premier,
+ pod_size: 8,
+ cards_per_pack: 14,
+ pack_count: 3,
+ min_deck_size: 40,
+ addable_cards: DeckAddableCards::standard_basics(),
+ rng_seed: 42,
+ tournament_format: TournamentFormat::SingleElimination,
+ pod_policy: PodPolicy::Competitive,
+ spectator_visibility: SpectatorVisibility::default(),
+ };
+ let seats: Vec = (0..8)
+ .map(|i| DraftSeat::Human {
+ player_id: PlayerId(i),
+ display_name: format!("Player {i}"),
+ connected: true,
+ })
+ .collect();
+ let mut session = DraftSession::new(config, seats, "SE-TEST".to_string());
+ session.status = DraftStatus::Deckbuilding;
+
+ apply(
+ &mut session,
+ DraftAction::GeneratePairings { round: 1 },
+ None,
+ )
+ .unwrap();
+
+ for (match_id, winner_seat) in [("r1-t0", 7), ("r1-t1", 6), ("r1-t2", 2), ("r1-t3", 4)] {
+ apply(
+ &mut session,
+ DraftAction::ReportMatchResult {
+ match_id: match_id.to_string(),
+ winner_seat: Some(winner_seat),
+ },
+ None,
+ )
+ .unwrap();
+ }
+
+ assert_eq!(session.status, DraftStatus::RoundComplete);
+
+ apply(&mut session, DraftAction::AdvanceRound, None).unwrap();
+ apply(
+ &mut session,
+ DraftAction::GeneratePairings { round: 2 },
+ None,
+ )
+ .unwrap();
+
+ let pairings: Vec<_> = session.pairings.iter().filter(|p| p.round == 2).collect();
+ assert_eq!(pairings.len(), 2);
+ assert_eq!(pairings[0].players, [PlayerId(7), PlayerId(6)]);
+ assert_eq!(pairings[1].players, [PlayerId(2), PlayerId(4)]);
+ }
+
+ #[test]
+ fn single_elimination_rejects_match_without_winner() {
+ let (mut session, _) = test_session(8);
+ session.status = DraftStatus::Deckbuilding;
+ session.config.tournament_format = TournamentFormat::SingleElimination;
+
+ apply(
+ &mut session,
+ DraftAction::GeneratePairings { round: 1 },
+ None,
+ )
+ .unwrap();
+
+ let result = apply(
+ &mut session,
+ DraftAction::ReportMatchResult {
+ match_id: "r1-t0".to_string(),
+ winner_seat: None,
+ },
+ None,
+ );
+
+ assert!(matches!(
+ result,
+ Err(DraftError::MatchWinnerRequired { .. })
+ ));
+ }
+
#[test]
fn test_report_result_updates_records() {
let (mut session, _) = test_session(8);
@@ -1053,33 +1212,270 @@ mod tests {
)
.unwrap();
- // Report seat 0 wins match r1-t0
+ let pairing = session
+ .pairings
+ .iter()
+ .find(|p| p.match_id == "r1-t0")
+ .unwrap()
+ .clone();
+ let winner_pid = pairing.players[0];
+
apply(
&mut session,
DraftAction::ReportMatchResult {
match_id: "r1-t0".to_string(),
- winner_seat: Some(0),
+ winner_seat: Some(winner_pid.0),
},
None,
)
.unwrap();
- let winner_record = session.match_records.get(&PlayerId(0)).unwrap();
+ let winner_record = session.match_records.get(&winner_pid).unwrap();
assert_eq!(winner_record.match_wins, 1);
+ assert_eq!(winner_record.wins, 1);
- // Find the loser (the other player in the pairing)
let pairing = session
.pairings
.iter()
.find(|p| p.match_id == "r1-t0")
.unwrap();
- let loser_pid = if pairing.players[0] == PlayerId(0) {
+ assert_eq!(pairing.winner, Some(winner_pid));
+ let loser_pid = if pairing.players[0] == winner_pid {
pairing.players[1]
} else {
pairing.players[0]
};
let loser_record = session.match_records.get(&loser_pid).unwrap();
assert_eq!(loser_record.match_losses, 1);
+ assert_eq!(loser_record.losses, 1);
+ }
+
+ #[test]
+ fn report_match_result_replaces_previous_result() {
+ let (mut session, _) = test_session(8);
+ session.status = DraftStatus::Deckbuilding;
+
+ apply(
+ &mut session,
+ DraftAction::GeneratePairings { round: 1 },
+ None,
+ )
+ .unwrap();
+
+ let pairing = session
+ .pairings
+ .iter()
+ .find(|p| p.match_id == "r1-t0")
+ .unwrap()
+ .clone();
+ let first_winner = pairing.players[0];
+ let second_winner = pairing.players[1];
+
+ apply(
+ &mut session,
+ DraftAction::ReportMatchResult {
+ match_id: pairing.match_id.clone(),
+ winner_seat: Some(first_winner.0),
+ },
+ None,
+ )
+ .unwrap();
+ apply(
+ &mut session,
+ DraftAction::ReportMatchResult {
+ match_id: pairing.match_id.clone(),
+ winner_seat: Some(second_winner.0),
+ },
+ None,
+ )
+ .unwrap();
+
+ let first_record = session.match_records.get(&first_winner).unwrap();
+ assert_eq!(first_record.match_wins, 0);
+ assert_eq!(first_record.match_losses, 1);
+ assert_eq!(first_record.wins, 0);
+ assert_eq!(first_record.losses, 1);
+
+ let second_record = session.match_records.get(&second_winner).unwrap();
+ assert_eq!(second_record.match_wins, 1);
+ assert_eq!(second_record.match_losses, 0);
+ assert_eq!(second_record.wins, 1);
+ assert_eq!(second_record.losses, 0);
+
+ let updated_pairing = session
+ .pairings
+ .iter()
+ .find(|p| p.match_id == pairing.match_id)
+ .unwrap();
+ assert_eq!(updated_pairing.winner, Some(second_winner));
+ }
+
+ #[test]
+ fn report_match_result_replaces_legacy_completed_result() {
+ let (mut session, _) = test_session(8);
+ session.status = DraftStatus::Deckbuilding;
+
+ apply(
+ &mut session,
+ DraftAction::GeneratePairings { round: 1 },
+ None,
+ )
+ .unwrap();
+
+ let pairing = session
+ .pairings
+ .iter()
+ .find(|p| p.match_id == "r1-t0")
+ .unwrap()
+ .clone();
+ let first_winner = pairing.players[0];
+ let second_winner = pairing.players[1];
+
+ session
+ .pairings
+ .iter_mut()
+ .find(|p| p.match_id == pairing.match_id)
+ .unwrap()
+ .status = PairingStatus::Complete;
+ ensure_match_record(&mut session.match_records, first_winner).match_wins = 1;
+ ensure_match_record(&mut session.match_records, first_winner).wins = 1;
+ ensure_match_record(&mut session.match_records, second_winner).match_losses = 1;
+ ensure_match_record(&mut session.match_records, second_winner).losses = 1;
+
+ apply(
+ &mut session,
+ DraftAction::ReportMatchResult {
+ match_id: pairing.match_id.clone(),
+ winner_seat: Some(second_winner.0),
+ },
+ None,
+ )
+ .unwrap();
+
+ let first_record = session.match_records.get(&first_winner).unwrap();
+ assert_eq!(first_record.match_wins, 0);
+ assert_eq!(first_record.wins, 0);
+ assert_eq!(first_record.match_losses, 1);
+ assert_eq!(first_record.losses, 1);
+
+ let second_record = session.match_records.get(&second_winner).unwrap();
+ assert_eq!(second_record.match_wins, 1);
+ assert_eq!(second_record.wins, 1);
+ assert_eq!(second_record.match_losses, 0);
+ assert_eq!(second_record.losses, 0);
+ }
+
+ #[test]
+ fn report_match_result_can_override_after_round_complete() {
+ let (mut session, _) = test_session(8);
+ session.status = DraftStatus::Deckbuilding;
+
+ apply(
+ &mut session,
+ DraftAction::GeneratePairings { round: 1 },
+ None,
+ )
+ .unwrap();
+
+ let results: Vec<(String, u8)> = session
+ .pairings
+ .iter()
+ .filter(|p| p.round == 1)
+ .map(|p| (p.match_id.clone(), p.players[0].0))
+ .collect();
+
+ for (match_id, winner_seat) in results {
+ apply(
+ &mut session,
+ DraftAction::ReportMatchResult {
+ match_id,
+ winner_seat: Some(winner_seat),
+ },
+ None,
+ )
+ .unwrap();
+ }
+
+ assert_eq!(session.status, DraftStatus::RoundComplete);
+
+ let pairing = session
+ .pairings
+ .iter()
+ .find(|p| p.match_id == "r1-t0")
+ .unwrap()
+ .clone();
+
+ apply(
+ &mut session,
+ DraftAction::ReportMatchResult {
+ match_id: pairing.match_id.clone(),
+ winner_seat: Some(pairing.players[1].0),
+ },
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(session.status, DraftStatus::RoundComplete);
+ let updated_pairing = session
+ .pairings
+ .iter()
+ .find(|p| p.match_id == pairing.match_id)
+ .unwrap();
+ assert_eq!(updated_pairing.winner, Some(pairing.players[1]));
+ }
+
+ #[test]
+ fn report_match_result_rejects_non_current_round_pairing() {
+ let (mut session, _) = test_session(8);
+ session.status = DraftStatus::Deckbuilding;
+
+ apply(
+ &mut session,
+ DraftAction::GeneratePairings { round: 1 },
+ None,
+ )
+ .unwrap();
+
+ let results: Vec<(String, u8)> = session
+ .pairings
+ .iter()
+ .filter(|p| p.round == 1)
+ .map(|p| (p.match_id.clone(), p.players[0].0))
+ .collect();
+
+ for (match_id, winner_seat) in results {
+ apply(
+ &mut session,
+ DraftAction::ReportMatchResult {
+ match_id,
+ winner_seat: Some(winner_seat),
+ },
+ None,
+ )
+ .unwrap();
+ }
+
+ apply(&mut session, DraftAction::AdvanceRound, None).unwrap();
+ apply(
+ &mut session,
+ DraftAction::GeneratePairings { round: 2 },
+ None,
+ )
+ .unwrap();
+
+ let result = apply(
+ &mut session,
+ DraftAction::ReportMatchResult {
+ match_id: "r1-t0".to_string(),
+ winner_seat: Some(0),
+ },
+ None,
+ );
+
+ assert!(matches!(
+ result,
+ Err(DraftError::PairingNotInCurrentRound { .. })
+ ));
}
#[test]
@@ -1094,14 +1490,19 @@ mod tests {
)
.unwrap();
- // Report all 4 match results
- for i in 0..4 {
- let match_id = format!("r1-t{i}");
+ let results: Vec<(String, u8)> = session
+ .pairings
+ .iter()
+ .filter(|p| p.round == 1)
+ .map(|p| (p.match_id.clone(), p.players[0].0))
+ .collect();
+
+ for (match_id, winner_seat) in results {
apply(
&mut session,
DraftAction::ReportMatchResult {
match_id,
- winner_seat: Some(i as u8),
+ winner_seat: Some(winner_seat),
},
None,
)
@@ -1144,7 +1545,10 @@ mod tests {
let deltas = apply(
&mut session,
- DraftAction::ReplaceSeatWithBot { seat: 3 },
+ DraftAction::ReplaceSeatWithBot {
+ seat: 3,
+ name: Some("Chandra".to_string()),
+ },
None,
)
.unwrap();
@@ -1152,7 +1556,7 @@ mod tests {
assert!(deltas.contains(&DraftDelta::SeatReplacedWithBot { seat: 3 }));
assert!(matches!(
&session.seats[3],
- DraftSeat::Bot { name } if name == "Bot (was Player 3)"
+ DraftSeat::Bot { name } if name == "Chandra"
));
}
@@ -1162,7 +1566,10 @@ mod tests {
let result = apply(
&mut session,
- DraftAction::ReplaceSeatWithBot { seat: 10 },
+ DraftAction::ReplaceSeatWithBot {
+ seat: 10,
+ name: None,
+ },
None,
);
assert!(matches!(
diff --git a/crates/draft-core/src/types.rs b/crates/draft-core/src/types.rs
index a58a0d0fbd..dcbd0e8637 100644
--- a/crates/draft-core/src/types.rs
+++ b/crates/draft-core/src/types.rs
@@ -269,6 +269,8 @@ pub enum DraftAction {
/// Casual mode: host replaces a human seat with a bot.
ReplaceSeatWithBot {
seat: u8,
+ #[serde(default)]
+ name: Option,
},
}
@@ -321,6 +323,12 @@ pub enum DraftError {
ValidationFailed { errors: Vec },
#[error("pairing not found: {match_id}")]
PairingNotFound { match_id: String },
+ #[error("pairing {match_id} is not in current round {current_round}")]
+ PairingNotInCurrentRound { match_id: String, current_round: u8 },
+ #[error("single-elimination match {match_id} requires a winner")]
+ MatchWinnerRequired { match_id: String },
+ #[error("seat {seat} is not in pairing {match_id}")]
+ SeatNotInPairing { seat: u8, match_id: String },
#[error("{format:?} requires {required} seats, got {actual}")]
UnsupportedTournamentSize {
format: TournamentFormat,
@@ -397,6 +405,33 @@ pub struct DraftPairing {
pub players: [PlayerId; 2],
pub match_id: String,
pub status: PairingStatus,
+ #[serde(default)]
+ pub winner: Option,
+}
+
+impl DraftPairing {
+ pub fn result_winner(&self, records: &HashMap) -> Option {
+ self.winner
+ .or_else(|| self.infer_winner_from_records(records))
+ }
+
+ fn infer_winner_from_records(
+ &self,
+ records: &HashMap,
+ ) -> Option {
+ if self.status != PairingStatus::Complete {
+ return None;
+ }
+
+ let w0 = records.get(&self.players[0]).map_or(0, |r| r.match_wins);
+ let w1 = records.get(&self.players[1]).map_or(0, |r| r.match_wins);
+
+ match w0.cmp(&w1) {
+ std::cmp::Ordering::Greater => Some(self.players[0]),
+ std::cmp::Ordering::Less => Some(self.players[1]),
+ std::cmp::Ordering::Equal => None,
+ }
+ }
}
/// The full state of a draft session.
diff --git a/crates/draft-core/src/view.rs b/crates/draft-core/src/view.rs
index 46249bf4ed..8f5d45e6cf 100644
--- a/crates/draft-core/src/view.rs
+++ b/crates/draft-core/src/view.rs
@@ -365,22 +365,15 @@ fn compute_pairing_views(session: &DraftSession) -> Vec {
.cloned()
.unwrap_or((0, "Unknown".to_string()));
- // Determine winner seat from the match status + records
- let winner_seat = if p.status == PairingStatus::Complete {
- let r0 = session.match_records.get(&p.players[0]);
- let r1 = session.match_records.get(&p.players[1]);
- let w0 = r0.map_or(0, |r| r.match_wins);
- let w1 = r1.map_or(0, |r| r.match_wins);
- if w0 > w1 {
+ let winner_seat = p.result_winner(&session.match_records).and_then(|winner| {
+ if winner == p.players[0] {
Some(seat_a)
- } else if w1 > w0 {
+ } else if winner == p.players[1] {
Some(seat_b)
} else {
- None // draw or equal
+ None
}
- } else {
- None
- };
+ });
PairingView {
round: p.round,
@@ -741,12 +734,18 @@ mod tests {
)
.unwrap();
- // Report seat 0 wins
+ let winner_pid = session
+ .pairings
+ .iter()
+ .find(|p| p.match_id == "r1-t0")
+ .unwrap()
+ .players[0];
+
session::apply(
&mut session,
DraftAction::ReportMatchResult {
match_id: "r1-t0".to_string(),
- winner_seat: Some(0),
+ winner_seat: Some(winner_pid.0),
},
None,
)
@@ -755,10 +754,13 @@ mod tests {
let view = filter_for_player(&session, 0);
assert!(!view.standings.is_empty());
- // Player 0 should have match_wins = 1
- let p0_standing = view.standings.iter().find(|s| s.seat_index == 0).unwrap();
- assert_eq!(p0_standing.match_wins, 1);
- assert_eq!(p0_standing.match_losses, 0);
+ let winner_standing = view
+ .standings
+ .iter()
+ .find(|s| s.seat_index == winner_pid.0)
+ .unwrap();
+ assert_eq!(winner_standing.match_wins, 1);
+ assert_eq!(winner_standing.match_losses, 0);
// Standings should be sorted by match_wins descending
for window in view.standings.windows(2) {
@@ -828,6 +830,90 @@ mod tests {
}
}
+ #[test]
+ fn view_pairing_winner_seat_uses_pairing_result() {
+ let (mut session, _) = test_session(8);
+ session.status = DraftStatus::Deckbuilding;
+
+ session::apply(
+ &mut session,
+ DraftAction::GeneratePairings { round: 1 },
+ None,
+ )
+ .unwrap();
+
+ let pairing = session
+ .pairings
+ .iter()
+ .find(|p| p.match_id == "r1-t0")
+ .unwrap()
+ .clone();
+
+ session::apply(
+ &mut session,
+ DraftAction::ReportMatchResult {
+ match_id: pairing.match_id.clone(),
+ winner_seat: Some(pairing.players[1].0),
+ },
+ None,
+ )
+ .unwrap();
+
+ let view = filter_for_player(&session, 0);
+ let pairing_view = view
+ .pairings
+ .iter()
+ .find(|p| p.match_id == pairing.match_id)
+ .unwrap();
+ assert_eq!(pairing_view.winner_seat, Some(pairing.players[1].0));
+ }
+
+ #[test]
+ fn view_pairing_winner_seat_infers_legacy_completed_result() {
+ let (mut session, _) = test_session(8);
+ session.status = DraftStatus::Deckbuilding;
+
+ session::apply(
+ &mut session,
+ DraftAction::GeneratePairings { round: 1 },
+ None,
+ )
+ .unwrap();
+
+ let pairing = session
+ .pairings
+ .iter()
+ .find(|p| p.match_id == "r1-t0")
+ .unwrap()
+ .clone();
+
+ session
+ .pairings
+ .iter_mut()
+ .find(|p| p.match_id == pairing.match_id)
+ .unwrap()
+ .status = PairingStatus::Complete;
+ session.match_records.insert(
+ pairing.players[1],
+ DraftMatchRecord {
+ player: pairing.players[1],
+ wins: 1,
+ losses: 0,
+ draws: 0,
+ match_wins: 1,
+ match_losses: 0,
+ },
+ );
+
+ let view = filter_for_player(&session, 0);
+ let pairing_view = view
+ .pairings
+ .iter()
+ .find(|p| p.match_id == pairing.match_id)
+ .unwrap();
+ assert_eq!(pairing_view.winner_seat, Some(pairing.players[1].0));
+ }
+
#[test]
fn pairing_view_score_fields_default_to_none() {
// BO3-06: PairingView score_a/score_b are None when match not started.
diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs
index d09b9d3644..1f230b183b 100644
--- a/crates/engine-wasm/src/lib.rs
+++ b/crates/engine-wasm/src/lib.rs
@@ -6,7 +6,7 @@ use serde::Serialize;
use wasm_bindgen::prelude::*;
use engine::ai_support::{auto_pass_recommended, legal_actions_for_viewer, legal_actions_full};
-use engine::database::CardDatabase;
+use engine::database::{CardDatabase, CardSearchQuery};
use engine::game::engine::apply;
use engine::game::{
estimate_bracket, evaluate_deck_compatibility, filter_state_for_viewer, finalize_public_state,
@@ -236,6 +236,26 @@ pub fn get_card_face_data(name: &str) -> JsValue {
})
}
+/// Search the loaded card database. The engine is the single authority for the
+/// rules data search filters on — format legality, set membership, card types,
+/// mana value, and colors — so deck-builder search runs here, never as a
+/// third-party API call. Returns `{ results, total }` (see `CardSearchResults`),
+/// or an error if the database is not loaded or the query is malformed.
+#[wasm_bindgen]
+pub fn search_cards_js(query: JsValue) -> Result {
+ let query: CardSearchQuery = serde_wasm_bindgen::from_value(query)
+ .map_err(|e| JsValue::from_str(&format!("Invalid search query: {e}")))?;
+ CARD_DB.with(|cell| {
+ let db = cell.borrow();
+ let Some(db) = db.as_ref() else {
+ return Err(JsValue::from_str(
+ "Card database not loaded. Call load_card_database first.",
+ ));
+ };
+ Ok(to_js(&db.search(&query)))
+ })
+}
+
/// Returns the official WotC rulings for a card as a JS array of `{date, text}`
/// objects. Returns an empty array if the card is not found, the database is
/// not loaded, or the card has no rulings (back faces of multi-face cards
diff --git a/crates/engine/src/database/mod.rs b/crates/engine/src/database/mod.rs
index 3126a50cde..99a1ff2a0f 100644
--- a/crates/engine/src/database/mod.rs
+++ b/crates/engine/src/database/mod.rs
@@ -5,10 +5,12 @@ pub mod forge;
pub mod legality;
pub mod mtgjson;
pub mod oracle_loader;
+pub mod search;
pub mod synthesis;
pub use bracket_lists::{BracketLists, BracketSignals};
pub use card_db::CardDatabase;
+pub use search::{CardSearchQuery, CardSearchResult, CardSearchResults};
/// Single authority for "is this card runnable by the engine right now?"
///
diff --git a/crates/engine/src/database/search.rs b/crates/engine/src/database/search.rs
new file mode 100644
index 0000000000..c0073e83de
--- /dev/null
+++ b/crates/engine/src/database/search.rs
@@ -0,0 +1,537 @@
+//! Local card search over the loaded `CardDatabase`.
+//!
+//! This is the single authority for deck-builder card search. The engine owns
+//! the rules data the search filters on — format legality (banned-list data),
+//! set membership, card types (CR 205), mana value (CR 202.3), and a card's
+//! colors (CR 105.2, derived from its mana symbols and color indicator) — so
+//! search lives here rather than as a third-party HTTP call from the display
+//! layer.
+//!
+//! Results carry only rules data (name, oracle id, mana value, color identity,
+//! legalities). Presentation data (artwork, printed type line) is hydrated by
+//! the frontend from its local Scryfall image map, keyed by the returned
+//! `oracle_id`.
+
+use std::collections::{BTreeMap, HashSet};
+
+use serde::{Deserialize, Serialize};
+
+use super::card_db::CardDatabase;
+use super::legality::LegalityFormat;
+use crate::types::card::CardFace;
+use crate::types::card_type::CardType;
+use crate::types::mana::{ManaColor, ManaCost};
+
+/// Default page size, mirroring Scryfall's search page size so the grid renders
+/// a comparable result count. `total` still reports the full match count.
+const DEFAULT_LIMIT: usize = 175;
+
+/// A card-search request from the deck builder. All fields are optional; an
+/// all-empty query matches every card (each filter is skipped when empty), so
+/// callers gate on "has criteria" before searching.
+#[derive(Debug, Default, Deserialize)]
+pub struct CardSearchQuery {
+ /// Free text, matched word-by-word (AND) against name + oracle text + type.
+ #[serde(default)]
+ pub text: String,
+ /// WUBRG color letters the card's colors must include (superset match,
+ /// mirroring Scryfall's `c:` operator — CR 105.2).
+ #[serde(default)]
+ pub colors: Vec,
+ /// A type word (core type, supertype, or subtype), matched case-insensitively.
+ #[serde(default)]
+ pub type_line: String,
+ /// Inclusive upper bound on mana value (CR 202.3).
+ #[serde(default)]
+ pub cmc_max: Option,
+ /// Set codes; the card must have a printing in at least one. Format
+ /// legality is a separate filter (`legal_format`).
+ #[serde(default)]
+ pub sets: Vec,
+ /// A legality-format key (e.g. `"modern"`); the card must be `legal` in it.
+ #[serde(default)]
+ pub legal_format: Option,
+ /// Max results returned (defaults to [`DEFAULT_LIMIT`]); `total` is unbounded.
+ #[serde(default)]
+ pub limit: Option,
+}
+
+/// One matching card. Rules data only — the frontend hydrates artwork and the
+/// printed type line from its local image map using `oracle_id`.
+#[derive(Debug, Serialize)]
+pub struct CardSearchResult {
+ pub name: String,
+ pub oracle_id: Option,
+ pub mana_value: u32,
+ pub color_identity: Vec<&'static str>,
+ pub legalities: BTreeMap,
+}
+
+/// A page of results plus the full match count (which may exceed `results.len()`
+/// when the page limit truncates).
+#[derive(Debug, Serialize)]
+pub struct CardSearchResults {
+ pub results: Vec,
+ pub total: usize,
+}
+
+impl CardDatabase {
+ /// Filter the loaded cards by the query, deduplicating multi-face cards by
+ /// oracle id. Name/text matches sort ahead of incidental oracle-text hits,
+ /// then alphabetically.
+ pub fn search(&self, query: &CardSearchQuery) -> CardSearchResults {
+ let needle = query.text.trim().to_lowercase();
+ let words: Vec<&str> = needle.split_whitespace().collect();
+ let requested_colors: Vec = query
+ .colors
+ .iter()
+ .filter_map(|c| parse_color_letter(c))
+ .collect();
+ let type_needle = query.type_line.trim().to_lowercase();
+ let legal_format = query
+ .legal_format
+ .as_deref()
+ .and_then(LegalityFormat::from_key);
+ let requested_sets: Vec = query.sets.iter().map(|s| s.to_uppercase()).collect();
+
+ let mut seen_oracle: HashSet<&str> = HashSet::new();
+ // (relevance rank, lowercased name for tiebreak, result)
+ let mut matched: Vec<(u8, String, CardSearchResult)> = Vec::new();
+
+ for (key, face) in self.face_index.iter() {
+ let name_lower = face.name.to_lowercase();
+
+ if !words.is_empty() && !text_matches(&name_lower, face, &words) {
+ continue;
+ }
+ if !requested_colors.is_empty() {
+ let colors = face_colors(face);
+ if !requested_colors.iter().all(|c| colors.contains(c)) {
+ continue;
+ }
+ }
+ if !type_needle.is_empty() && !type_matches(&face.card_type, &type_needle) {
+ continue;
+ }
+ if let Some(max) = query.cmc_max {
+ if face.mana_cost.mana_value() > max {
+ continue;
+ }
+ }
+ if let Some(format) = legal_format {
+ let legal = self
+ .legalities
+ .get(key)
+ .and_then(|m| m.get(&format))
+ .is_some_and(|status| status.is_legal());
+ if !legal {
+ continue;
+ }
+ }
+ if !requested_sets.is_empty() {
+ let in_set = self
+ .printings_index
+ .get(key)
+ .is_some_and(|sets| sets.iter().any(|s| requested_sets.contains(s)));
+ if !in_set {
+ continue;
+ }
+ }
+
+ // Deduplicate multi-face cards: keep the first matching face per
+ // oracle id. The frontend re-derives the combined display name from
+ // the image map, so which face won here doesn't affect display.
+ if let Some(oracle_id) = face.scryfall_oracle_id.as_deref() {
+ if !seen_oracle.insert(oracle_id) {
+ continue;
+ }
+ }
+
+ let rank = if needle.is_empty() || name_lower.contains(&needle) {
+ 0
+ } else {
+ 1
+ };
+ matched.push((rank, name_lower, self.build_result(key, face)));
+ }
+
+ let total = matched.len();
+ matched.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
+
+ let limit = query.limit.unwrap_or(DEFAULT_LIMIT);
+ let results = matched
+ .into_iter()
+ .take(limit)
+ .map(|(_, _, result)| result)
+ .collect();
+
+ CardSearchResults { results, total }
+ }
+
+ fn build_result(&self, key: &str, face: &CardFace) -> CardSearchResult {
+ let legalities = self
+ .legalities
+ .get(key)
+ .map(|m| {
+ m.iter()
+ .map(|(format, status)| {
+ (
+ format.as_key().to_string(),
+ status.as_export_str().to_string(),
+ )
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+
+ CardSearchResult {
+ name: face.name.clone(),
+ oracle_id: face.scryfall_oracle_id.clone(),
+ mana_value: face.mana_cost.mana_value(),
+ color_identity: face
+ .color_identity
+ .iter()
+ .copied()
+ .map(color_letter)
+ .collect(),
+ legalities,
+ }
+ }
+}
+
+/// Word-AND match across the card's name, oracle text, and type line — the
+/// fields Scryfall's default full-text search covers.
+fn text_matches(name_lower: &str, face: &CardFace, words: &[&str]) -> bool {
+ let mut haystack = name_lower.to_string();
+ if let Some(text) = &face.oracle_text {
+ haystack.push(' ');
+ haystack.push_str(&text.to_lowercase());
+ }
+ words.iter().all(|w| haystack.contains(w))
+}
+
+/// A card's colors per CR 105.2: the colors of its mana-cost symbols, plus any
+/// color indicator (`color_override`). Hybrid symbols count for each color.
+fn face_colors(face: &CardFace) -> Vec {
+ let mut colors: Vec = Vec::new();
+ if let ManaCost::Cost { shards, .. } = &face.mana_cost {
+ for &color in &ManaColor::ALL {
+ if shards.iter().any(|s| s.contributes_to(color)) {
+ colors.push(color);
+ }
+ }
+ }
+ if let Some(indicator) = &face.color_override {
+ for &color in indicator {
+ if !colors.contains(&color) {
+ colors.push(color);
+ }
+ }
+ }
+ colors
+}
+
+/// Case-insensitive type match against core types, supertypes, and subtypes.
+fn type_matches(card_type: &CardType, needle: &str) -> bool {
+ card_type
+ .core_types
+ .iter()
+ .any(|t| t.to_string().to_lowercase().contains(needle))
+ || card_type
+ .supertypes
+ .iter()
+ .any(|t| t.to_string().to_lowercase().contains(needle))
+ || card_type
+ .subtypes
+ .iter()
+ .any(|s| s.to_lowercase().contains(needle))
+}
+
+fn parse_color_letter(letter: &str) -> Option {
+ match letter.trim().to_uppercase().as_str() {
+ "W" => Some(ManaColor::White),
+ "U" => Some(ManaColor::Blue),
+ "B" => Some(ManaColor::Black),
+ "R" => Some(ManaColor::Red),
+ "G" => Some(ManaColor::Green),
+ _ => None,
+ }
+}
+
+fn color_letter(color: ManaColor) -> &'static str {
+ match color {
+ ManaColor::White => "W",
+ ManaColor::Blue => "U",
+ ManaColor::Black => "B",
+ ManaColor::Red => "R",
+ ManaColor::Green => "G",
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::{json, Value};
+
+ /// Build one export entry. `shards`/`generic` form the mana cost (shard and
+ /// `color_identity` names use the engine's full color spelling, e.g.
+ /// `"Red"`, matching `card-data.json`), and `legalities`/`printings` drive
+ /// the legality and set filters.
+ #[allow(clippy::too_many_arguments)]
+ fn card(
+ name: &str,
+ oracle_id: &str,
+ shards: &[&str],
+ generic: u32,
+ core_type: &str,
+ color_identity: &[&str],
+ oracle_text: &str,
+ legalities: Value,
+ printings: &[&str],
+ ) -> Value {
+ json!({
+ "name": name,
+ "mana_cost": { "type": "Cost", "shards": shards, "generic": generic },
+ "card_type": { "supertypes": [], "core_types": [core_type], "subtypes": [] },
+ "power": null, "toughness": null, "loyalty": null, "defense": null,
+ "oracle_text": oracle_text,
+ "non_ability_text": null, "flavor_name": null,
+ "keywords": [], "abilities": [], "triggers": [],
+ "static_abilities": [], "replacements": [],
+ "color_override": null,
+ "color_identity": color_identity,
+ "scryfall_oracle_id": oracle_id,
+ "legalities": legalities,
+ "printings": printings,
+ })
+ }
+
+ fn db_from(cards: &[(&str, Value)]) -> CardDatabase {
+ let map: serde_json::Map = cards
+ .iter()
+ .map(|(key, value)| (key.to_string(), value.clone()))
+ .collect();
+ CardDatabase::from_json_str(&Value::Object(map).to_string()).unwrap()
+ }
+
+ fn result_names(results: &CardSearchResults) -> Vec {
+ results.results.iter().map(|r| r.name.clone()).collect()
+ }
+
+ fn sample_db() -> CardDatabase {
+ db_from(&[
+ (
+ "lightning bolt",
+ card(
+ "Lightning Bolt",
+ "o-bolt",
+ &["Red"],
+ 0,
+ "Instant",
+ &["Red"],
+ "Lightning Bolt deals 3 damage to any target.",
+ json!({ "modern": "legal" }),
+ &["LEA", "M10"],
+ ),
+ ),
+ (
+ "grizzly bears",
+ card(
+ "Grizzly Bears",
+ "o-bears",
+ &["Green"],
+ 1,
+ "Creature",
+ &["Green"],
+ "",
+ json!({ "modern": "legal" }),
+ &["LEA"],
+ ),
+ ),
+ (
+ "shock",
+ card(
+ "Shock",
+ "o-shock",
+ &["Red"],
+ 0,
+ "Instant",
+ &["Red"],
+ "Shock deals 2 damage to any target.",
+ json!({ "modern": "banned" }),
+ &["M10"],
+ ),
+ ),
+ ])
+ }
+
+ #[test]
+ fn text_matches_name_only() {
+ let res = sample_db().search(&CardSearchQuery {
+ text: "bolt".into(),
+ ..Default::default()
+ });
+ assert_eq!(result_names(&res), vec!["Lightning Bolt"]);
+ }
+
+ #[test]
+ fn text_matches_oracle_text_and_ranks_name_hits_first() {
+ // "damage" hits the oracle text of both Bolt and Shock (neither name),
+ // so both return, ordered alphabetically.
+ let res = sample_db().search(&CardSearchQuery {
+ text: "damage".into(),
+ ..Default::default()
+ });
+ assert_eq!(result_names(&res), vec!["Lightning Bolt", "Shock"]);
+ }
+
+ #[test]
+ fn color_filter_is_superset_match() {
+ let db = db_from(&[
+ (
+ "azorius",
+ card(
+ "Azorius Card",
+ "o-az",
+ &["White", "Blue"],
+ 0,
+ "Creature",
+ &["White", "Blue"],
+ "",
+ json!({}),
+ &[],
+ ),
+ ),
+ (
+ "white",
+ card(
+ "White Card",
+ "o-w",
+ &["White"],
+ 0,
+ "Creature",
+ &["White"],
+ "",
+ json!({}),
+ &[],
+ ),
+ ),
+ ]);
+ // {W} matches both the mono-white card and the WU card (superset).
+ let res = db.search(&CardSearchQuery {
+ colors: vec!["W".into()],
+ ..Default::default()
+ });
+ assert_eq!(result_names(&res), vec!["Azorius Card", "White Card"]);
+ // {W}{U} matches only the WU card; mono-white lacks blue.
+ let res = db.search(&CardSearchQuery {
+ colors: vec!["W".into(), "U".into()],
+ ..Default::default()
+ });
+ assert_eq!(result_names(&res), vec!["Azorius Card"]);
+ }
+
+ #[test]
+ fn type_filter_matches_core_type() {
+ let res = sample_db().search(&CardSearchQuery {
+ type_line: "creature".into(),
+ ..Default::default()
+ });
+ assert_eq!(result_names(&res), vec!["Grizzly Bears"]);
+ }
+
+ #[test]
+ fn cmc_max_is_inclusive_upper_bound() {
+ // Bolt/Shock are mana value 1; Grizzly Bears is {1}{G} = 2.
+ let res = sample_db().search(&CardSearchQuery {
+ cmc_max: Some(1),
+ ..Default::default()
+ });
+ assert_eq!(result_names(&res), vec!["Lightning Bolt", "Shock"]);
+ }
+
+ #[test]
+ fn legal_format_excludes_banned_and_unknown() {
+ // Shock is banned in modern; Bolt and Bears are legal.
+ let res = sample_db().search(&CardSearchQuery {
+ legal_format: Some("modern".into()),
+ ..Default::default()
+ });
+ assert_eq!(result_names(&res), vec!["Grizzly Bears", "Lightning Bolt"]);
+ }
+
+ #[test]
+ fn sets_filter_requires_a_printing_in_set() {
+ let res = sample_db().search(&CardSearchQuery {
+ sets: vec!["m10".into()], // case-insensitive
+ ..Default::default()
+ });
+ assert_eq!(result_names(&res), vec!["Lightning Bolt", "Shock"]);
+ }
+
+ #[test]
+ fn multi_face_cards_dedupe_by_oracle_id() {
+ let db = db_from(&[
+ (
+ "front face",
+ card(
+ "Front Face",
+ "o-dfc",
+ &["Blue"],
+ 1,
+ "Creature",
+ &["Blue"],
+ "",
+ json!({}),
+ &[],
+ ),
+ ),
+ (
+ "back face",
+ card(
+ "Back Face",
+ "o-dfc",
+ &[],
+ 0,
+ "Land",
+ &[],
+ "",
+ json!({}),
+ &[],
+ ),
+ ),
+ ]);
+ let res = db.search(&CardSearchQuery {
+ text: "face".into(),
+ ..Default::default()
+ });
+ assert_eq!(res.results.len(), 1, "both faces share an oracle id");
+ assert_eq!(res.total, 1);
+ }
+
+ #[test]
+ fn limit_truncates_results_but_total_is_full_count() {
+ let res = sample_db().search(&CardSearchQuery {
+ limit: Some(1),
+ ..Default::default()
+ });
+ assert_eq!(res.results.len(), 1);
+ assert_eq!(res.total, 3, "total reflects all matches, not the page");
+ }
+
+ #[test]
+ fn result_carries_engine_authoritative_fields() {
+ let res = sample_db().search(&CardSearchQuery {
+ text: "lightning bolt".into(),
+ ..Default::default()
+ });
+ let bolt = &res.results[0];
+ assert_eq!(bolt.oracle_id.as_deref(), Some("o-bolt"));
+ assert_eq!(bolt.mana_value, 1);
+ assert_eq!(bolt.color_identity, vec!["R"]);
+ assert_eq!(
+ bolt.legalities.get("modern").map(String::as_str),
+ Some("legal")
+ );
+ }
+}
diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs
index 4d96ad1dd6..358a4aff84 100644
--- a/crates/engine/src/database/synthesis.rs
+++ b/crates/engine/src/database/synthesis.rs
@@ -1948,11 +1948,11 @@ fn is_annihilator_attack_trigger(t: &TriggerDefinition) -> bool {
}
/// Idempotency-shape predicate for `synthesize`-installed Evolve triggers.
-/// `TriggerMode::Evolved` is unique to the Evolve keyword, so the mode alone
+/// `TriggerMode::Evolve` is unique to the Evolve keyword, so the mode alone
/// uniquely identifies a synthesized Evolve trigger — no execute-shape check
/// is needed (or wanted: it would disambiguate nothing).
fn is_evolve_trigger(t: &TriggerDefinition) -> bool {
- matches!(t.mode, TriggerMode::Evolved)
+ matches!(t.mode, TriggerMode::Evolve)
}
fn is_myriad_attack_trigger(t: &TriggerDefinition) -> bool {
@@ -2185,7 +2185,7 @@ fn build_annihilator_trigger(n: u32) -> TriggerDefinition {
/// satisfied for free by `triggers_for` being invoked per keyword instance.
fn build_evolve_trigger() -> TriggerDefinition {
// CR 122.1: put a single +1/+1 counter on the Evolve creature itself.
- let put_counter = AbilityDefinition::new(
+ let mut put_counter = AbilityDefinition::new(
AbilityKind::Spell,
Effect::PutCounter {
counter_type: CounterType::Plus1Plus1,
@@ -2194,6 +2194,7 @@ fn build_evolve_trigger() -> TriggerDefinition {
},
)
.description("Put a +1/+1 counter on this creature".to_string());
+ put_counter.ability_tag = Some(AbilityTag::Evolve);
// CR 702.100a "and/or": fire if the entering creature's power is greater
// OR its toughness is greater than this creature's. CR 603.4: this is the
@@ -2234,7 +2235,7 @@ fn build_evolve_trigger() -> TriggerDefinition {
// `valid_card` selects any creature the trigger controller controls
// (including the Evolve creature itself — its self-vs-self P/T comparison
// yields equal values, so the strict-`GT` intervening-if filters it out).
- TriggerDefinition::new(TriggerMode::Evolved)
+ TriggerDefinition::new(TriggerMode::Evolve)
.destination(Zone::Battlefield)
.valid_card(TargetFilter::Typed(
TypedFilter::creature().controller(ControllerRef::You),
diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs
index 733519d0aa..222b4129f7 100644
--- a/crates/engine/src/game/ability_utils.rs
+++ b/crates/engine/src/game/ability_utils.rs
@@ -40,6 +40,7 @@ pub fn build_resolved_from_def_with_targets(
) -> ResolvedAbility {
let mut resolved =
ResolvedAbility::new(*def.effect.clone(), targets, source_id, controller).kind(def.kind);
+ resolved.context.ability_tag = def.ability_tag;
if let Some(sub) = &def.sub_ability {
resolved = resolved.sub_ability(build_resolved_from_def(sub, source_id, controller));
}
@@ -364,6 +365,59 @@ pub fn compute_unavailable_modes(
unavailable
}
+/// CR 700.2d: Extends `unavailable_modes` with mode indices whose targeting
+/// requirements cannot be satisfied on the current board. For each mode not
+/// already marked unavailable, builds the resolved ability for that single mode,
+/// computes its target slots, and checks whether a legal target assignment
+/// exists. Modes that require targets but have no legal assignment are appended
+/// to `unavailable_modes`.
+///
+/// This prevents the softlock where a player (or AI) selects a mode with no
+/// legal targets, causing `pending_trigger` to be consumed and then the
+/// targeting step to fail irrecoverably.
+pub fn filter_modes_by_target_legality(
+ state: &GameState,
+ source_id: ObjectId,
+ controller: PlayerId,
+ mode_abilities: &[AbilityDefinition],
+ modal: &ModalChoice,
+ unavailable_modes: &mut Vec,
+) {
+ let target_constraints = target_constraints_from_modal(modal);
+ for mode_idx in 0..modal.mode_count {
+ if unavailable_modes.contains(&mode_idx) {
+ continue;
+ }
+ let Some(def) = mode_abilities.get(mode_idx) else {
+ continue;
+ };
+ let resolved = build_resolved_from_def(def, source_id, controller);
+ let target_slots = match build_target_slots(state, &resolved) {
+ Ok(slots) => slots,
+ Err(_) => {
+ // build_target_slots returns Err when no legal targets exist
+ // for a required targeting slot — mark mode unavailable.
+ unavailable_modes.push(mode_idx);
+ continue;
+ }
+ };
+ // A mode with no target slots does not require targeting — always legal.
+ if target_slots.is_empty() {
+ continue;
+ }
+ if !has_legal_target_assignment_for_ability(
+ state,
+ &resolved,
+ &target_slots,
+ &target_constraints,
+ ) {
+ unavailable_modes.push(mode_idx);
+ }
+ }
+ unavailable_modes.sort_unstable();
+ unavailable_modes.dedup();
+}
+
/// Records chosen mode indices for NoRepeat constraint enforcement.
/// CR 700.2: Inserts into per-turn and/or per-game tracking maps.
pub fn record_modal_mode_choices(
diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs
index 77b703bbeb..1d23c3b971 100644
--- a/crates/engine/src/game/casting.rs
+++ b/crates/engine/src/game/casting.rs
@@ -7158,7 +7158,15 @@ pub fn handle_activate_ability(
));
}
}
- let unavailable_modes = compute_unavailable_modes(state, source_id, &modal);
+ let mut unavailable_modes = compute_unavailable_modes(state, source_id, &modal);
+ super::ability_utils::filter_modes_by_target_legality(
+ state,
+ source_id,
+ player,
+ &ability_def.mode_abilities,
+ &modal,
+ &mut unavailable_modes,
+ );
// CR 700.2a / CR 700.2e: `AbilityModeChoice.player` is threaded
// downstream as the activated ability's controller (cost payment,
// stack `controller`, target selection — see `engine_modes.rs`), so
diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs
index c845703774..372361ffbd 100644
--- a/crates/engine/src/game/coverage.rs
+++ b/crates/engine/src/game/coverage.rs
@@ -71,6 +71,10 @@ fn is_data_carrying_static(mode: &StaticMode) -> bool {
| StaticMode::SuppressTriggers { .. }
// CR 603.2d: DoubleTriggers carries the `TriggerCause` predicate.
| StaticMode::DoubleTriggers { .. }
+ // CR 508.1c + CR 509.1b: Combat declaration caps carry the maximum
+ // count and are enforced by combat.rs declaration validation.
+ | StaticMode::MaxAttackersEachCombat { .. }
+ | StaticMode::MaxBlockersEachCombat { .. }
// CR 107.4f: PayLifeAsColoredMana carries the `ManaColor` axis
// (K'rrik = Black; future printings any other color).
| StaticMode::PayLifeAsColoredMana { .. }
@@ -5155,7 +5159,7 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) {
QuantityRef::TokensCreatedThisTurn { .. } => ("TokensCreatedThisTurn", Handled),
QuantityRef::PlayerActionsThisTurn { .. } => ("PlayerActionsThisTurn", Handled),
QuantityRef::DungeonsCompleted => ("DungeonsCompleted", Unhandled),
- QuantityRef::TargetZoneCardCount { .. } => ("TargetZoneCardCount", Unhandled),
+ QuantityRef::TargetZoneCardCount { .. } => ("TargetZoneCardCount", Handled),
QuantityRef::CostXPaid => ("CostXPaid", Handled),
QuantityRef::KickerCount => ("KickerCount", Handled),
QuantityRef::ConvokedCreatureCount => ("ConvokedCreatureCount", Handled),
@@ -8772,6 +8776,20 @@ mod tests {
);
}
+ #[test]
+ fn target_zone_card_count_quantity_feature_is_marked_handled() {
+ let (name, support) = quantity_ref_feature(&QuantityRef::TargetZoneCardCount {
+ zone: ZoneRef::Library,
+ });
+
+ assert_eq!(name, "TargetZoneCardCount");
+ assert_eq!(
+ support,
+ FeatureSupport::Handled,
+ "TargetZoneCardCount is resolved by game::quantity and should not block coverage",
+ );
+ }
+
// -----------------------------------------------------------------------
// Semantic audit tests
// -----------------------------------------------------------------------
@@ -9718,4 +9736,32 @@ mod tests {
gaps
);
}
+
+ /// CR 508.1c + CR 509.1b: declaration-cap statics carry the maximum
+ /// creature count and are enforced by combat declaration validation rather
+ /// than exact registry-key lookup. Silent Arbiter is the canonical paired
+ /// attacker/blocker cap card.
+ #[test]
+ fn max_combat_creature_statics_have_no_coverage_gap() {
+ let mut face = make_face();
+ face.oracle_text = Some(
+ "No more than one creature can attack each combat.\nNo more than one creature can block each combat."
+ .to_string(),
+ );
+ face.static_abilities.push(
+ StaticDefinition::new(StaticMode::MaxAttackersEachCombat { max: 1 })
+ .description("No more than one creature can attack each combat.".to_string()),
+ );
+ face.static_abilities.push(
+ StaticDefinition::new(StaticMode::MaxBlockersEachCombat { max: 1 })
+ .description("No more than one creature can block each combat.".to_string()),
+ );
+
+ let gaps = card_face_gaps(&face);
+ assert!(
+ gaps.is_empty(),
+ "Max combat creature statics should be fully supported, but got gaps: {:?}",
+ gaps
+ );
+ }
}
diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs
index a86acb4c45..864fc88915 100644
--- a/crates/engine/src/game/effects/counters.rs
+++ b/crates/engine/src/game/effects/counters.rs
@@ -3,7 +3,8 @@ use std::collections::HashSet;
use crate::game::game_object::GameObject;
use crate::game::replacement::{self, ReplacementResult};
use crate::types::ability::{
- CounterTransferMode, Effect, EffectError, EffectKind, ResolvedAbility, TargetFilter, TargetRef,
+ AbilityTag, CounterTransferMode, Effect, EffectError, EffectKind, ResolvedAbility,
+ TargetFilter, TargetRef,
};
#[cfg(test)]
use crate::types::counter::parse_counter_type;
@@ -323,6 +324,7 @@ pub fn resolve_add(
if let Some(distribution) = &ability.distribution {
for (target, count) in distribution {
if let crate::types::ability::TargetRef::Object(obj_id) = target {
+ let event_start = events.len();
add_counter_with_replacement(
state,
ability.controller,
@@ -331,11 +333,19 @@ pub fn resolve_add(
*count,
events,
);
+ emit_evolved_event_for_counter_addition(
+ ability,
+ events,
+ event_start,
+ *obj_id,
+ &counter_type,
+ );
}
}
} else {
let targets = resolve_defined_or_targets(state, ability);
for obj_id in targets {
+ let event_start = events.len();
add_counter_with_replacement(
state,
ability.controller,
@@ -344,6 +354,13 @@ pub fn resolve_add(
counter_num,
events,
);
+ emit_evolved_event_for_counter_addition(
+ ability,
+ events,
+ event_start,
+ obj_id,
+ &counter_type,
+ );
}
}
@@ -355,6 +372,33 @@ pub fn resolve_add(
Ok(())
}
+fn emit_evolved_event_for_counter_addition(
+ ability: &ResolvedAbility,
+ events: &mut Vec,
+ event_start: usize,
+ object_id: ObjectId,
+ counter_type: &CounterType,
+) {
+ if ability.context.ability_tag != Some(AbilityTag::Evolve)
+ || *counter_type != CounterType::Plus1Plus1
+ {
+ return;
+ }
+ let evolved = events[event_start..].iter().any(|event| {
+ matches!(
+ event,
+ GameEvent::CounterAdded {
+ object_id: added_to,
+ counter_type: CounterType::Plus1Plus1,
+ count
+ } if *added_to == object_id && *count > 0
+ )
+ });
+ if evolved {
+ events.push(GameEvent::Evolved { object_id });
+ }
+}
+
/// CR 122.1: Place counters on all battlefield objects matching a filter (no targeting).
pub fn resolve_add_all(
state: &mut GameState,
diff --git a/crates/engine/src/game/effects/sacrifice.rs b/crates/engine/src/game/effects/sacrifice.rs
index 5df3d0aa95..e0776b94fe 100644
--- a/crates/engine/src/game/effects/sacrifice.rs
+++ b/crates/engine/src/game/effects/sacrifice.rs
@@ -764,6 +764,50 @@ mod tests {
}
}
+ #[test]
+ fn parent_target_controller_scope_uses_damage_event_source_controller() {
+ let mut state = GameState::new_two_player(42);
+ let damage_source = create_object(
+ &mut state,
+ CardId(1),
+ PlayerId(1),
+ "Damage Source".to_string(),
+ Zone::Stack,
+ );
+ let own = create_object(
+ &mut state,
+ CardId(2),
+ PlayerId(0),
+ "Mine".to_string(),
+ Zone::Battlefield,
+ );
+ let source_controller_permanent = create_object(
+ &mut state,
+ CardId(3),
+ PlayerId(1),
+ "Theirs".to_string(),
+ Zone::Battlefield,
+ );
+ state.current_trigger_event = Some(GameEvent::DamageDealt {
+ source_id: damage_source,
+ target: TargetRef::Player(PlayerId(0)),
+ amount: 1,
+ is_combat: false,
+ excess: 0,
+ });
+ let ability = make_scoped_sacrifice_ability(ControllerRef::ParentTargetController, vec![]);
+ let mut events = Vec::new();
+
+ resolve(&mut state, &ability, &mut events).unwrap();
+
+ assert!(state.battlefield.contains(&own));
+ assert!(!state.battlefield.contains(&source_controller_permanent));
+ assert!(state.players[1]
+ .graveyard
+ .contains(&source_controller_permanent));
+ assert_eq!(state.last_effect_count, Some(1));
+ }
+
#[test]
fn scoped_player_scope_uses_trigger_event_player() {
let mut state = GameState::new_two_player(42);
diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs
index fb6fb6261b..aee65a857c 100644
--- a/crates/engine/src/game/engine.rs
+++ b/crates/engine/src/game/engine.rs
@@ -4027,10 +4027,19 @@ pub(super) fn begin_pending_trigger_target_selection(
modal,
&crate::types::ability::SpellContext::default(),
);
- let unavailable_modes = compute_unavailable_modes(state, trigger.source_id, &modal);
+ let mut unavailable_modes = compute_unavailable_modes(state, trigger.source_id, &modal);
+ super::ability_utils::filter_modes_by_target_legality(
+ state,
+ trigger.source_id,
+ trigger.controller,
+ &trigger.mode_abilities,
+ &modal,
+ &mut unavailable_modes,
+ );
- // CR 700.2: All modes already chosen — ability cannot be put on the stack
- // without a mode selection. Clear pending trigger and skip.
+ // CR 700.2d: All modes unavailable (previously chosen OR no legal
+ // targets) — ability cannot be put on the stack. Clear pending
+ // trigger and skip.
if unavailable_modes.len() >= modal.mode_count {
state.pending_trigger = None;
return Ok(None);
diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs
index 124d9f22f5..3e29b54203 100644
--- a/crates/engine/src/game/filter.rs
+++ b/crates/engine/src/game/filter.rs
@@ -191,6 +191,19 @@ fn scoped_player_or_controller(
ability.and_then(|a| a.scoped_player).or(source_controller)
}
+fn parent_target_controller_player(
+ state: &GameState,
+ ability: Option<&ResolvedAbility>,
+) -> Option {
+ ability.and_then(|a| {
+ crate::game::targeting::resolve_effect_player_ref(
+ state,
+ a,
+ &TargetFilter::ParentTargetController,
+ )
+ })
+}
+
fn controller_ref_player(
state: &GameState,
source_id: ObjectId,
@@ -208,9 +221,7 @@ fn controller_ref_player(
TargetRef::Object(_) => None,
})
}),
- ControllerRef::ParentTargetController => {
- ability.and_then(|a| crate::game::ability_utils::parent_target_controller(a, state))
- }
+ ControllerRef::ParentTargetController => parent_target_controller_player(state, ability),
ControllerRef::DefendingPlayer => {
crate::game::combat::defending_player_for_attacker(state, source_id)
}
@@ -520,9 +531,7 @@ fn filter_inner_for_object(
}
}
ControllerRef::ParentTargetController => {
- let target_player = ability.and_then(|a| {
- crate::game::ability_utils::parent_target_controller(a, state)
- });
+ let target_player = parent_target_controller_player(state, ability);
match target_player {
Some(pid) if pid == obj.controller => {}
_ => return false,
@@ -815,9 +824,7 @@ fn zone_change_filter_inner(
}
}
ControllerRef::ParentTargetController => {
- let target_player = ability.and_then(|a| {
- crate::game::ability_utils::parent_target_controller(a, state)
- });
+ let target_player = parent_target_controller_player(state, ability);
match target_player {
Some(pid) if pid == record.controller => {}
_ => return false,
@@ -1955,10 +1962,10 @@ fn matches_filter_prop(
})
})
.is_some_and(|pid| pid == obj.owner),
- ControllerRef::ParentTargetController => source
- .ability
- .and_then(|a| crate::game::ability_utils::parent_target_controller(a, state))
- .is_some_and(|pid| pid == obj.owner),
+ ControllerRef::ParentTargetController => {
+ parent_target_controller_player(state, source.ability)
+ .is_some_and(|pid| pid == obj.owner)
+ }
ControllerRef::DefendingPlayer => {
crate::game::combat::defending_player_for_attacker(state, source.id)
.is_some_and(|pid| pid == obj.owner)
@@ -2466,10 +2473,10 @@ fn zone_change_record_matches_property(
})
})
.is_some_and(|pid| pid == record.owner),
- ControllerRef::ParentTargetController => source
- .ability
- .and_then(|a| crate::game::ability_utils::parent_target_controller(a, state))
- .is_some_and(|pid| pid == record.owner),
+ ControllerRef::ParentTargetController => {
+ parent_target_controller_player(state, source.ability)
+ .is_some_and(|pid| pid == record.owner)
+ }
ControllerRef::DefendingPlayer => {
crate::game::combat::defending_player_for_attacker(state, source.id)
.is_some_and(|pid| pid == record.owner)
@@ -2654,10 +2661,10 @@ fn attachment_controller_matches(
})
})
.is_some_and(|pid| pid == attachment_controller),
- Some(ControllerRef::ParentTargetController) => source
- .ability
- .and_then(|a| crate::game::ability_utils::parent_target_controller(a, state))
- .is_some_and(|pid| pid == attachment_controller),
+ Some(ControllerRef::ParentTargetController) => {
+ parent_target_controller_player(state, source.ability)
+ .is_some_and(|pid| pid == attachment_controller)
+ }
Some(ControllerRef::DefendingPlayer) => {
combat::defending_player_for_attacker(state, source.id)
.is_some_and(|pid| pid == attachment_controller)
diff --git a/crates/engine/src/game/log.rs b/crates/engine/src/game/log.rs
index 8652bacb47..40fd2adc75 100644
--- a/crates/engine/src/game/log.rs
+++ b/crates/engine/src/game/log.rs
@@ -141,6 +141,7 @@ fn categorize(event: &GameEvent) -> LogCategory {
| GameEvent::PlayerPhasedIn { .. }
| GameEvent::DamageCleared { .. }
| GameEvent::CounterAdded { .. }
+ | GameEvent::Evolved { .. }
| GameEvent::CounterRemoved { .. }
| GameEvent::Transformed { .. }
| GameEvent::TurnedFaceUp { .. }
@@ -281,6 +282,7 @@ fn format_segments(event: &GameEvent, state: &GameState) -> Vec {
} => {
let label = match ability_tag {
AbilityTag::Boast => " activates boast: ",
+ AbilityTag::Evolve => " activates evolve: ",
AbilityTag::Exhaust => " activates exhaust: ",
AbilityTag::Outlast => " activates outlast: ",
};
@@ -607,6 +609,10 @@ fn format_segments(event: &GameEvent, state: &GameState) -> Vec {
card_seg(state, *object_id),
],
+ GameEvent::Evolved { object_id } => {
+ vec![card_seg(state, *object_id), text(" evolved")]
+ }
+
GameEvent::CounterRemoved {
object_id,
counter_type,
diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs
index 148b6cdacd..be0d9f1c76 100644
--- a/crates/engine/src/game/restrictions.rs
+++ b/crates/engine/src/game/restrictions.rs
@@ -530,6 +530,7 @@ fn effective_activation_limit(
};
let keyword = match tag {
AbilityTag::Boast => "boast",
+ AbilityTag::Evolve => "evolve",
AbilityTag::Exhaust => "exhaust",
AbilityTag::Outlast => "outlast",
};
diff --git a/crates/engine/src/game/targeting.rs b/crates/engine/src/game/targeting.rs
index fc2a9055ba..f31fbbf4c8 100644
--- a/crates/engine/src/game/targeting.rs
+++ b/crates/engine/src/game/targeting.rs
@@ -679,6 +679,7 @@ pub(crate) fn extract_source_from_event(
// trigger fires from; `source_id` is the permanent tapped for mana.
GameEvent::TappedForMana { source_id, .. } => Some(*source_id),
GameEvent::CounterAdded { object_id, .. } => Some(*object_id),
+ GameEvent::Evolved { object_id } => Some(*object_id),
GameEvent::CounterRemoved { object_id, .. } => Some(*object_id),
GameEvent::TokenCreated { object_id, .. } => Some(*object_id),
GameEvent::CreatureDestroyed { object_id } => Some(*object_id),
@@ -804,6 +805,10 @@ pub(crate) fn extract_amount_from_event(event: &crate::types::events::GameEvent)
GameEvent::CounterAdded { count, .. } => Some(*count as i32),
GameEvent::CounterRemoved { count, .. } => Some(*count as i32),
GameEvent::Discarded { .. } => Some(1),
+ // CR 508.1m + CR 603.2c: Batched attack-trigger context stores the
+ // attackers that satisfied the trigger subject, so "that many" reads
+ // the size of that contextual attack event.
+ GameEvent::AttackersDeclared { attacker_ids, .. } => Some(attacker_ids.len() as i32),
// CR 706.2: the final number of a die roll is its result. Lets
// `EventContextAmount` resolve "where X is the result" pump effects.
GameEvent::DieRolled { result, .. } => Some(*result as i32),
diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs
index cb3c6fb8e0..9164f41cde 100644
--- a/crates/engine/src/game/trigger_matchers.rs
+++ b/crates/engine/src/game/trigger_matchers.rs
@@ -20,7 +20,8 @@ pub fn trigger_matcher(mode: TriggerMode) -> Option {
// .destination(Battlefield); valid_card filtering and the power/toughness
// intervening-if (CR 603.4) are handled downstream by
// zone_change_clause_matches / check_trigger_condition respectively.
- TriggerMode::ChangesZone | TriggerMode::Evolved => match_changes_zone,
+ TriggerMode::ChangesZone | TriggerMode::Evolve => match_changes_zone,
+ TriggerMode::Evolved => match_evolved,
TriggerMode::ChangesZoneAll => match_changes_zone_all,
TriggerMode::DamageDone
| TriggerMode::DamageDoneOnce
@@ -361,6 +362,7 @@ pub fn build_trigger_registry() -> HashMap {
TriggerMode::Exerted,
// TriggerMode::Crewed — moved to real matcher below
// TriggerMode::Saddled — moved to real matcher below
+ // TriggerMode::Evolve — moved to real matcher below
// TriggerMode::Evolved — moved to real matcher below
TriggerMode::Enlisted,
TriggerMode::Adapt,
@@ -406,7 +408,10 @@ pub fn build_trigger_registry() -> HashMap {
// .destination(Battlefield); valid_card filtering and the power/toughness
// intervening-if (CR 603.4) are handled downstream by
// zone_change_clause_matches / check_trigger_condition respectively.
- r.insert(TriggerMode::Evolved, match_changes_zone);
+ r.insert(TriggerMode::Evolve, match_changes_zone);
+ // CR 702.100b: "Whenever [a creature] evolves" fires only when the
+ // evolve ability's resolution actually put one or more +1/+1 counters on it.
+ r.insert(TriggerMode::Evolved, match_evolved);
// CR 702.122d: Crew trigger matchers
r.insert(TriggerMode::Crewed, match_vehicle_crewed);
@@ -980,24 +985,7 @@ fn attack_target_matches(
source_id: ObjectId,
) -> bool {
if let Some(filter) = trigger.attack_target_filter.as_ref() {
- let type_matches = matches!(
- (filter, target),
- (
- crate::types::triggers::AttackTargetFilter::Player,
- crate::game::combat::AttackTarget::Player(_)
- ) | (
- crate::types::triggers::AttackTargetFilter::Planeswalker,
- crate::game::combat::AttackTarget::Planeswalker(_)
- ) | (
- crate::types::triggers::AttackTargetFilter::PlayerOrPlaneswalker,
- crate::game::combat::AttackTarget::Player(_)
- | crate::game::combat::AttackTarget::Planeswalker(_)
- ) | (
- crate::types::triggers::AttackTargetFilter::Battle,
- crate::game::combat::AttackTarget::Battle(_)
- )
- );
- if !type_matches {
+ if !attack_target_type_matches(target, filter) {
return false;
}
}
@@ -1011,7 +999,30 @@ fn attack_target_matches(
}
}
-fn attack_target_defending_player(
+pub(super) fn attack_target_type_matches(
+ target: crate::game::combat::AttackTarget,
+ filter: &crate::types::triggers::AttackTargetFilter,
+) -> bool {
+ matches!(
+ (filter, target),
+ (
+ crate::types::triggers::AttackTargetFilter::Player,
+ crate::game::combat::AttackTarget::Player(_)
+ ) | (
+ crate::types::triggers::AttackTargetFilter::Planeswalker,
+ crate::game::combat::AttackTarget::Planeswalker(_)
+ ) | (
+ crate::types::triggers::AttackTargetFilter::PlayerOrPlaneswalker,
+ crate::game::combat::AttackTarget::Player(_)
+ | crate::game::combat::AttackTarget::Planeswalker(_)
+ ) | (
+ crate::types::triggers::AttackTargetFilter::Battle,
+ crate::game::combat::AttackTarget::Battle(_)
+ )
+ )
+}
+
+pub(super) fn attack_target_defending_player(
state: &GameState,
target: crate::game::combat::AttackTarget,
fallback_defending_player: PlayerId,
@@ -1167,6 +1178,19 @@ pub(super) fn match_counter_added(
}
}
+pub(super) fn match_evolved(
+ event: &GameEvent,
+ trigger: &TriggerDefinition,
+ source_id: ObjectId,
+ state: &GameState,
+) -> bool {
+ if let GameEvent::Evolved { object_id } = event {
+ valid_card_matches(trigger, state, *object_id, source_id)
+ } else {
+ false
+ }
+}
+
pub(super) fn match_counter_removed(
event: &GameEvent,
trigger: &TriggerDefinition,
@@ -2225,11 +2249,26 @@ pub(super) fn match_you_attack(
source_id: ObjectId,
state: &GameState,
) -> bool {
- let GameEvent::AttackersDeclared { attacker_ids, .. } = event else {
- return false;
+ !matching_you_attack_pairs(event, trigger, source_id, state).is_empty()
+}
+
+pub(super) fn matching_you_attack_pairs(
+ event: &GameEvent,
+ trigger: &TriggerDefinition,
+ source_id: ObjectId,
+ state: &GameState,
+) -> Vec<(ObjectId, crate::game::combat::AttackTarget)> {
+ let GameEvent::AttackersDeclared {
+ attacker_ids,
+ defending_player,
+ attacks,
+ ..
+ } = event
+ else {
+ return Vec::new();
};
if attacker_ids.is_empty() {
- return false;
+ return Vec::new();
}
// CR 506.2: the active player is the attacking player; all attackers in
// a single AttackersDeclared batch share one controller.
@@ -2237,29 +2276,55 @@ pub(super) fn match_you_attack(
.iter()
.find_map(|id| state.objects.get(id).map(|o| o.controller))
else {
- return false;
+ return Vec::new();
};
// CR 603.2c: the player-scope gate (valid_target). No filter ⇒ legacy
// "attackers controlled by the trigger's source controller" semantics.
- let player_ok = if trigger.valid_target.is_some() {
- valid_player_matches(trigger, state, attacking_player, source_id)
- } else {
- let source_controller = state.objects.get(&source_id).map(|o| o.controller);
- Some(attacking_player) == source_controller
+ let player_ok = match trigger.valid_target.as_ref() {
+ // Parser legacy for "one or more creatures attack a player": the
+ // attacked-player type is represented as `TargetFilter::Player`, not
+ // as attacking-player scope. The per-attack target filter below handles
+ // it, so keep the attacking-player gate permissive here.
+ Some(TargetFilter::Player) => true,
+ Some(_) => valid_player_matches(trigger, state, attacking_player, source_id),
+ None => {
+ let source_controller = state.objects.get(&source_id).map(|o| o.controller);
+ Some(attacking_player) == source_controller
+ }
};
if !player_ok {
- return false;
+ return Vec::new();
}
- // CR 508.1 + CR 603.2c: the attacker-type gate (valid_card). "Attack with one
- // or more " fires iff at least one attacker in the batch matches the
- // type filter. No filter ⇒ any attacker (current behavior preserved).
- match &trigger.valid_card {
- None => true,
- Some(filter) => attacker_ids
- .iter()
- .any(|id| target_filter_matches_object(state, *id, filter, source_id)),
- }
+ attacker_ids
+ .iter()
+ .filter_map(|id| {
+ if trigger
+ .valid_card
+ .as_ref()
+ .is_some_and(|filter| !target_filter_matches_object(state, *id, filter, source_id))
+ {
+ return None;
+ }
+ let target = attacks
+ .iter()
+ .find_map(|(attacker_id, target)| (*attacker_id == *id).then_some(*target))
+ .unwrap_or(crate::game::combat::AttackTarget::Player(*defending_player));
+ if trigger
+ .attack_target_filter
+ .as_ref()
+ .is_some_and(|filter| !attack_target_type_matches(target, filter))
+ {
+ return None;
+ }
+ if matches!(trigger.valid_target, Some(TargetFilter::Player))
+ && !matches!(target, crate::game::combat::AttackTarget::Player(_))
+ {
+ return None;
+ }
+ Some((*id, target))
+ })
+ .collect()
}
/// CR 725.1: Matches when a player becomes the monarch.
diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs
index 5e3f02e380..779414009f 100644
--- a/crates/engine/src/game/triggers.rs
+++ b/crates/engine/src/game/triggers.rs
@@ -176,10 +176,59 @@ fn matching_batched_trigger_events(
check_trigger_condition(state, condition, controller, Some(obj_id), Some(candidate))
})
})
- .cloned()
+ .filter_map(|candidate| {
+ contextual_batched_trigger_event(state, candidate, trig_def, obj_id)
+ })
.collect()
}
+fn contextual_batched_trigger_event(
+ state: &GameState,
+ event: &GameEvent,
+ trig_def: &TriggerDefinition,
+ obj_id: ObjectId,
+) -> Option {
+ let GameEvent::AttackersDeclared {
+ defending_player, ..
+ } = event
+ else {
+ return Some(event.clone());
+ };
+
+ let matching_attacks = match trig_def.mode {
+ TriggerMode::Attacks => {
+ super::trigger_matchers::matching_attack_events(event, trig_def, obj_id, state)
+ .into_iter()
+ .flat_map(|event| match event {
+ GameEvent::AttackersDeclared { attacks, .. } => attacks,
+ _ => Vec::new(),
+ })
+ .collect()
+ }
+ TriggerMode::YouAttack => {
+ super::trigger_matchers::matching_you_attack_pairs(event, trig_def, obj_id, state)
+ }
+ _ => return Some(event.clone()),
+ };
+
+ let matching_attackers: Vec<_> = matching_attacks
+ .iter()
+ .map(|(attacker_id, _)| *attacker_id)
+ .collect();
+ if matching_attackers.is_empty() {
+ return None;
+ }
+
+ // CR 603.2c + CR 608.2c: batched "one or more ... attack" triggers fire
+ // once, but later "that many" text refers to the members of that matching
+ // event subset, not every attacker in the declaration.
+ Some(GameEvent::AttackersDeclared {
+ attacker_ids: matching_attackers,
+ defending_player: *defending_player,
+ attacks: matching_attacks,
+ })
+}
+
#[allow(clippy::too_many_arguments)]
fn collect_matching_triggers(
state: &GameState,
@@ -3483,6 +3532,27 @@ pub(crate) fn extract_target_filter_from_effect(effect: &Effect) -> Option<&Targ
}
}
}
+ // CR 115.1 / CR 115.1d: Only effects that use the word "target" require stack-time target
+ // selection. `TargetFilter::Any` is a sentinel value meaning "broadcast to all
+ // matching permanents at resolution time" — it is never a declared target on any
+ // effect. The one exception is `DealDamage`, which uses `TargetFilter::Any` to
+ // represent the "any target" wording in damage-dealing spells and abilities (e.g.
+ // "deals 3 damage to any target"), where the player does choose a single target
+ // from the combined pool of creatures, planeswalkers, and players.
+ //
+ // For all other effects, `TargetFilter::Any` arises in two ways: (a) as a mass
+ // broadcast where the Oracle text contains no "target" keyword (e.g. "creatures
+ // get -N/-M until end of turn"), or (b) as an unthreaded subject sentinel produced
+ // by a sub-parser before the calling parser threads the real subject (SelfRef,
+ // ParentTarget, etc.). In both cases no player-chosen target is required.
+ // Generating a slot for `Any` causes a spurious WaitingFor::TriggerTargetSelection
+ // entry that players and the AI cannot resolve, producing a hard freeze (issue #824
+ // class).
+ if effect.target_filter() == Some(&TargetFilter::Any)
+ && !matches!(effect, Effect::DealDamage { .. })
+ {
+ return None;
+ }
effect.target_filter().filter(|t| !t.is_context_ref())
}
// ---------------------------------------------------------------------------
@@ -3499,9 +3569,10 @@ pub mod tests {
AggregateFunction, ChosenAttribute, ChosenSubtypeKind, CommanderOwnership, Comparator,
ContinuousModification, ControllerRef, DelayedTriggerCondition, Duration, Effect,
FilterProp, GainLifePlayer, KickerVariant, MultiTargetSpec, PaymentCost, PlayerFilter,
- PlayerScope, QuantityExpr, QuantityRef, ResolvedAbility, SharedQuality,
- SharedQualityRelation, StaticCondition, StaticDefinition, TargetFilter, TargetRef,
- TriggerCondition, TriggerConstraint, TriggerDefinition, TypeFilter, TypedFilter,
+ PlayerScope, PtStat, PtValueScope, QuantityExpr, QuantityRef, ResolvedAbility,
+ SearchSelectionConstraint, SharedQuality, SharedQualityRelation, StaticCondition,
+ StaticDefinition, TargetFilter, TargetRef, TriggerCondition, TriggerConstraint,
+ TriggerDefinition, TypeFilter, TypedFilter,
};
use crate::types::actions::GameAction;
use crate::types::card_type::CoreType;
@@ -4148,6 +4219,74 @@ pub mod tests {
);
}
+ /// CR 702.100b: A creature "evolves" only after its evolve ability
+ /// resolves and actually puts one or more +1/+1 counters on it. This is
+ /// distinct from the CR 702.100a ETB trigger event that starts the evolve
+ /// ability.
+ #[test]
+ fn test_evolved_trigger_fires_after_evolve_counter_is_added() {
+ let mut state = setup();
+ state.active_player = PlayerId(0);
+ state.priority_player = PlayerId(0);
+ let evolver = make_evolve_creature(&mut state, PlayerId(0), "Evolve 2/2", 2, 2);
+ let entrant = make_creature(&mut state, PlayerId(0), "Bigger 3/3", 3, 3);
+
+ let draw = AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::Draw {
+ count: QuantityExpr::Fixed { value: 1 },
+ target: TargetFilter::Controller,
+ },
+ );
+ let evolved_trigger = TriggerDefinition::new(TriggerMode::Evolved)
+ .valid_card(TargetFilter::SelfRef)
+ .execute(draw)
+ .description("Whenever this creature evolves, draw a card.".to_string());
+ state
+ .objects
+ .get_mut(&evolver)
+ .unwrap()
+ .trigger_definitions
+ .push(evolved_trigger.clone());
+ std::sync::Arc::make_mut(
+ &mut state
+ .objects
+ .get_mut(&evolver)
+ .unwrap()
+ .base_trigger_definitions,
+ )
+ .push(evolved_trigger);
+
+ process_triggers(
+ &mut state,
+ &[zone_changed_event(
+ entrant,
+ Zone::Stack,
+ Zone::Battlefield,
+ vec![CoreType::Creature],
+ vec![],
+ )],
+ );
+ assert_eq!(state.stack.len(), 1, "only the evolve ETB trigger fires");
+
+ let mut events = Vec::new();
+ crate::game::stack::resolve_top(&mut state, &mut events);
+ assert!(
+ events.iter().any(|event| matches!(
+ event,
+ GameEvent::Evolved { object_id } if *object_id == evolver
+ )),
+ "evolve resolution must emit the CR 702.100b evolved event"
+ );
+
+ process_triggers(&mut state, &events);
+ assert_eq!(
+ state.stack.len(),
+ 1,
+ "the separate 'whenever this evolves' trigger must now fire"
+ );
+ }
+
/// CR 702.100a: a creature whose power AND toughness are both not greater
/// (smaller, or equal) does NOT trigger Evolve — the intervening-if uses
/// strict greater-than, not greater-or-equal.
@@ -8265,6 +8404,79 @@ pub mod tests {
);
}
+ // === CR 115.1 / CR 115.1d: extract_target_filter mass-effect (Any-filter) tests ===
+
+ /// CR 115.1 / CR 115.1d: `Pump { target: Any }` is a mass broadcast effect — no word
+ /// "target" in Oracle text, so no stack-time target slot should be generated.
+ /// This is also used as a sentinel value by `try_parse_pump` before the
+ /// calling parser threads a real subject (issue #824 class).
+ #[test]
+ fn extract_target_skips_pump_with_any_filter() {
+ use crate::types::ability::PtValue;
+ let effect = Effect::Pump {
+ power: PtValue::Fixed(-2),
+ toughness: PtValue::Fixed(-2),
+ target: TargetFilter::Any,
+ };
+ assert!(
+ extract_target_filter_from_effect(&effect).is_none(),
+ "Pump{{target: Any}} is a mass effect — must not generate a target slot"
+ );
+ }
+
+ /// CR 115.1 / CR 115.1d: `Pump { target: Typed(Creature) }` is a genuinely targeted
+ /// effect ("target creature gets +N/+M") — must still generate a slot.
+ #[test]
+ fn extract_target_keeps_pump_with_typed_filter() {
+ use crate::types::ability::PtValue;
+ let effect = Effect::Pump {
+ power: PtValue::Fixed(2),
+ toughness: PtValue::Fixed(2),
+ target: TargetFilter::Typed(TypedFilter::creature()),
+ };
+ assert!(
+ extract_target_filter_from_effect(&effect).is_some(),
+ "Pump with a typed target filter must still generate a target slot"
+ );
+ }
+
+ /// CR 115.1 / CR 115.1d: `GenericEffect { target: Some(Any) }` is a mass continuous
+ /// modification ("each creature gets -2/-2 until end of turn") produced by
+ /// `build_layer_effect_until`. No word "target" in Oracle text, so no slot.
+ #[test]
+ fn extract_target_skips_generic_effect_with_any_filter() {
+ // StaticMode is imported below the test section; use fully qualified path.
+ let effect = Effect::GenericEffect {
+ static_abilities: vec![StaticDefinition::new(
+ crate::types::statics::StaticMode::Continuous,
+ )],
+ duration: Some(Duration::UntilEndOfTurn),
+ target: Some(TargetFilter::Any),
+ };
+ assert!(
+ extract_target_filter_from_effect(&effect).is_none(),
+ "GenericEffect{{target: Some(Any)}} is a mass effect — must not generate a target slot (issue #824)"
+ );
+ }
+
+ /// CR 115.1 / CR 115.1d: `GenericEffect { target: Some(Typed(Creature)) }` is a
+ /// targeted continuous modification ("target creature gains haste") — must
+ /// still generate a slot.
+ #[test]
+ fn extract_target_keeps_generic_effect_with_typed_filter() {
+ let effect = Effect::GenericEffect {
+ static_abilities: vec![StaticDefinition::new(
+ crate::types::statics::StaticMode::Continuous,
+ )],
+ duration: Some(Duration::UntilEndOfTurn),
+ target: Some(TargetFilter::Typed(TypedFilter::creature())),
+ };
+ assert!(
+ extract_target_filter_from_effect(&effect).is_some(),
+ "GenericEffect with a typed target must still generate a target slot"
+ );
+ }
+
// === CR 603.2g + CR 603.6a + CR 700.4: SuppressTriggers integration tests ===
use crate::types::statics::{StaticMode, SuppressedTriggerEvent};
@@ -11554,6 +11766,222 @@ pub mod tests {
);
}
+ /// Issue #1055: The Earth King stores "that many" as EventContextAmount.
+ /// For a batched attack trigger, that amount is the number of attackers
+ /// that matched the trigger subject, not the raw number of all attackers.
+ #[test]
+ fn issue_1055_batched_attack_search_uses_matching_attacker_count() {
+ use crate::game::combat::AttackTarget;
+
+ fn add_basic_land(state: &mut GameState, name: &str) -> ObjectId {
+ let id = create_object(
+ state,
+ CardId(state.next_object_id),
+ PlayerId(0),
+ name.to_string(),
+ Zone::Library,
+ );
+ let obj = state.objects.get_mut(&id).unwrap();
+ obj.card_types.core_types.push(CoreType::Land);
+ obj.card_types
+ .supertypes
+ .push(crate::types::card_type::Supertype::Basic);
+ id
+ }
+
+ let mut state = setup();
+ state.active_player = PlayerId(0);
+ state.priority_player = PlayerId(0);
+
+ let source = make_creature(&mut state, PlayerId(0), "The Earth King", 4, 4);
+ let mut trigger =
+ TriggerDefinition::new(TriggerMode::YouAttack).execute(AbilityDefinition::new(
+ AbilityKind::Database,
+ Effect::SearchLibrary {
+ filter: TargetFilter::Typed(TypedFilter::land().properties(vec![
+ FilterProp::HasSupertype {
+ value: crate::types::card_type::Supertype::Basic,
+ },
+ ])),
+ count: QuantityExpr::up_to(QuantityExpr::Ref {
+ qty: QuantityRef::EventContextAmount,
+ }),
+ reveal: false,
+ target_player: None,
+ selection_constraint: SearchSelectionConstraint::None,
+ split: None,
+ },
+ ));
+ trigger.batched = true;
+ trigger.valid_card = Some(TargetFilter::Typed(
+ TypedFilter::creature()
+ .controller(ControllerRef::You)
+ .properties(vec![FilterProp::PtComparison {
+ stat: PtStat::Power,
+ scope: PtValueScope::Current,
+ comparator: Comparator::GE,
+ value: QuantityExpr::Fixed { value: 4 },
+ }]),
+ ));
+ state
+ .objects
+ .get_mut(&source)
+ .unwrap()
+ .trigger_definitions
+ .push(trigger);
+
+ let big_one = make_creature(&mut state, PlayerId(0), "Big One", 4, 4);
+ let big_two = make_creature(&mut state, PlayerId(0), "Big Two", 5, 5);
+ let small = make_creature(&mut state, PlayerId(0), "Small", 3, 3);
+ add_basic_land(&mut state, "Plains");
+ add_basic_land(&mut state, "Island");
+ add_basic_land(&mut state, "Forest");
+
+ process_triggers(
+ &mut state,
+ &[GameEvent::AttackersDeclared {
+ attacker_ids: vec![big_one, big_two, small],
+ defending_player: PlayerId(1),
+ attacks: vec![
+ (big_one, AttackTarget::Player(PlayerId(1))),
+ (big_two, AttackTarget::Player(PlayerId(1))),
+ (small, AttackTarget::Player(PlayerId(1))),
+ ],
+ }],
+ );
+
+ assert_eq!(state.stack.len(), 1, "The Earth King trigger should fire");
+ let mut events = Vec::new();
+ crate::game::stack::resolve_top(&mut state, &mut events);
+
+ match &state.waiting_for {
+ WaitingFor::SearchChoice {
+ count,
+ up_to,
+ cards,
+ ..
+ } => {
+ assert_eq!(*count, 2, "two power-4+ attackers set the search cap");
+ assert!(*up_to, "The Earth King's search is up to that many");
+ assert_eq!(cards.len(), 3, "all three basic lands remain legal choices");
+ }
+ other => panic!("expected SearchChoice, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn batched_attack_context_filters_attacked_target_type() {
+ use crate::game::combat::AttackTarget;
+
+ fn add_basic_land(state: &mut GameState, name: &str) {
+ let id = create_object(
+ state,
+ CardId(state.next_object_id),
+ PlayerId(0),
+ name.to_string(),
+ Zone::Library,
+ );
+ let obj = state.objects.get_mut(&id).unwrap();
+ obj.card_types.core_types.push(CoreType::Land);
+ obj.card_types
+ .supertypes
+ .push(crate::types::card_type::Supertype::Basic);
+ }
+
+ let mut state = setup();
+ state.active_player = PlayerId(0);
+ state.priority_player = PlayerId(0);
+
+ let source = make_creature(&mut state, PlayerId(0), "Attack Trigger Source", 4, 4);
+ let mut trigger =
+ TriggerDefinition::new(TriggerMode::YouAttack).execute(AbilityDefinition::new(
+ AbilityKind::Database,
+ Effect::SearchLibrary {
+ filter: TargetFilter::Typed(TypedFilter::land().properties(vec![
+ FilterProp::HasSupertype {
+ value: crate::types::card_type::Supertype::Basic,
+ },
+ ])),
+ count: QuantityExpr::up_to(QuantityExpr::Ref {
+ qty: QuantityRef::EventContextAmount,
+ }),
+ reveal: false,
+ target_player: None,
+ selection_constraint: SearchSelectionConstraint::None,
+ split: None,
+ },
+ ));
+ trigger.batched = true;
+ trigger.valid_card = Some(TargetFilter::Typed(TypedFilter::creature().properties(
+ vec![FilterProp::PtComparison {
+ stat: PtStat::Power,
+ scope: PtValueScope::Current,
+ comparator: Comparator::GE,
+ value: QuantityExpr::Fixed { value: 4 },
+ }],
+ )));
+ trigger.attack_target_filter = Some(AttackTargetFilter::Player);
+ state
+ .objects
+ .get_mut(&source)
+ .unwrap()
+ .trigger_definitions
+ .push(trigger);
+
+ let attacks_player = make_creature(&mut state, PlayerId(0), "Attacks Player", 4, 4);
+ let attacks_planeswalker =
+ make_creature(&mut state, PlayerId(0), "Attacks Planeswalker", 5, 5);
+ let small_attacks_player =
+ make_creature(&mut state, PlayerId(0), "Small Attacks Player", 3, 3);
+ let planeswalker = create_object(
+ &mut state,
+ CardId(9000),
+ PlayerId(1),
+ "Target Planeswalker".to_string(),
+ Zone::Battlefield,
+ );
+ state
+ .objects
+ .get_mut(&planeswalker)
+ .unwrap()
+ .card_types
+ .core_types
+ .push(CoreType::Planeswalker);
+ add_basic_land(&mut state, "Plains");
+ add_basic_land(&mut state, "Island");
+ add_basic_land(&mut state, "Forest");
+
+ process_triggers(
+ &mut state,
+ &[GameEvent::AttackersDeclared {
+ attacker_ids: vec![attacks_player, attacks_planeswalker, small_attacks_player],
+ defending_player: PlayerId(1),
+ attacks: vec![
+ (attacks_player, AttackTarget::Player(PlayerId(1))),
+ (
+ attacks_planeswalker,
+ AttackTarget::Planeswalker(planeswalker),
+ ),
+ (small_attacks_player, AttackTarget::Player(PlayerId(1))),
+ ],
+ }],
+ );
+
+ assert_eq!(state.stack.len(), 1, "filtered attack trigger should fire");
+ let mut events = Vec::new();
+ crate::game::stack::resolve_top(&mut state, &mut events);
+
+ match &state.waiting_for {
+ WaitingFor::SearchChoice { count, .. } => {
+ assert_eq!(
+ *count, 1,
+ "only the power-4+ creature attacking a player contributes to that-many"
+ );
+ }
+ other => panic!("expected SearchChoice, got {other:?}"),
+ }
+ }
+
/// Issue #501 (class coverage): The Tenth Doctor's `Allons-y!` attack
/// trigger exiles cards until a nonland is exiled, puts three time counters
/// on it, and grants it Suspend — the same runtime-granted-Suspend pattern
diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs
index 0e1ba3b91f..3ce5313dba 100644
--- a/crates/engine/src/parser/oracle_effect/imperative.rs
+++ b/crates/engine/src/parser/oracle_effect/imperative.rs
@@ -2820,6 +2820,56 @@ pub(super) fn parse_utility_imperative_ast(
return Some(UtilityImperativeAst::Transform { target });
}
}
+ // CR 613.4d: switch power and toughness — two surface forms (sibling branches):
+ // - prepositional: "switch the power and toughness of " (Inversion
+ // Behemoth class — supports the "(each of) any number of target X"
+ // distribution, with multi_target recovered by
+ // `extract_switch_pt_multi_target` in the post-parse fixup. Authorizing
+ // rule for variable-count targeting: CR 115.1d.)
+ // - possessive: "switch 's power and toughness" (single-target
+ // class — Inversion of Fortune, Twiddle's siblings).
+ // Try prepositional first so the more specific "the power and toughness of"
+ // shape is consumed before the bare "switch " form runs.
+ if let Some((_, rest)) = nom_on_lower(text, lower, |input| {
+ value((), tag("switch the power and toughness of ")).parse(input)
+ }) {
+ // Strip the optional "each of " and "any number of " distribution
+ // prefixes so `parse_target` sees a bare target phrase. The quantifier
+ // itself is recovered as a `MultiTargetSpec` in mod.rs via
+ // `extract_switch_pt_multi_target` (parallel to the DealDamage / Double
+ // counter fixups). Walking the lowercased view in lock-step with the
+ // original text preserves casing for `parse_target`.
+ let rest_lower = rest.to_ascii_lowercase();
+ let mut consumed = 0usize;
+ if let Ok((after, _)) = tag::<_, _, OracleError<'_>>("each of ").parse(rest_lower.as_str())
+ {
+ consumed = rest_lower.len() - after.len();
+ }
+ let after_each_lower = &rest_lower[consumed..];
+ if let Ok((after, _)) =
+ tag::<_, _, OracleError<'_>>("any number of ").parse(after_each_lower)
+ {
+ consumed += after_each_lower.len() - after.len();
+ }
+ let target_text = &rest[consumed..];
+ let (target, rem) = parse_target_with_ctx(target_text, ctx);
+ let rem_lower = rem.trim_start().to_ascii_lowercase();
+ // The trailing duration ("until end of turn") is stripped upstream by
+ // `strip_trailing_duration`; in that case `rem` is empty. Accept either
+ // form so the branch also matches when this parser is invoked directly
+ // on text that retains the duration (e.g. unit tests).
+ let rem_after_duration = tag::<_, _, OracleError<'_>>("until end of turn")
+ .parse(rem_lower.as_str())
+ .map(|(rest, _)| rest)
+ .unwrap_or(rem_lower.as_str());
+ let mut terminal = alt((
+ value((), eof),
+ value((), all_consuming(tag::<_, _, OracleError<'_>>("."))),
+ ));
+ if terminal.parse(rem_after_duration).is_ok() {
+ return Some(UtilityImperativeAst::SwitchPT { target });
+ }
+ }
// CR 613.4d: "switch [target]'s power and toughness"
if let Some((_, rest)) =
nom_on_lower(text, lower, |input| value((), tag("switch ")).parse(input))
diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs
index 932adef8d4..9dd165f285 100644
--- a/crates/engine/src/parser/oracle_effect/mod.rs
+++ b/crates/engine/src/parser/oracle_effect/mod.rs
@@ -2265,16 +2265,14 @@ fn parse_event_context_ref_with_ctx<'a>(
ctx: &ParseContext,
) -> Option<(TargetFilter, &'a str)> {
let (target, rest) = parse_event_context_ref(text)?;
- let target = if matches!(
- (&target, ctx.relative_player_scope.as_ref()),
- (
- TargetFilter::TriggeringPlayer,
- Some(ControllerRef::ScopedPlayer)
- )
- ) {
- TargetFilter::ScopedPlayer
- } else {
- target
+ let target = match (&target, ctx.relative_player_scope.as_ref()) {
+ (TargetFilter::TriggeringPlayer, Some(ControllerRef::ScopedPlayer)) => {
+ TargetFilter::ScopedPlayer
+ }
+ (TargetFilter::TriggeringPlayer, Some(ControllerRef::ParentTargetController)) => {
+ TargetFilter::ParentTargetController
+ }
+ _ => target,
};
Some((target, rest))
}
@@ -6112,6 +6110,13 @@ fn lower_imperative_clause(text: &str, ctx: &mut ParseContext) -> ParsedEffectCl
if matches!(clause.effect, Effect::DealDamage { .. }) && clause.multi_target.is_none() {
clause.multi_target = extract_deal_damage_multi_target(text);
}
+ // CR 115.1d: Post-parse fixup for SwitchPT prepositional form. The
+ // imperative parser strips "any number of" / "each of" to keep `parse_target`
+ // bare, so the MultiTargetSpec is rebuilt from the original text here
+ // (parallel to the DealDamage / Double counter fixups above).
+ if matches!(clause.effect, Effect::SwitchPT { .. }) && clause.multi_target.is_none() {
+ clause.multi_target = extract_switch_pt_multi_target(text);
+ }
if matches!(
clause.effect,
Effect::Double {
@@ -14964,6 +14969,45 @@ fn extract_deal_damage_multi_target(text: &str) -> Option {
multi_target
}
+/// CR 115.1d + CR 613.4d: Recover the `MultiTargetSpec` for the prepositional
+/// SwitchPT form ("switch the power and toughness of "). The
+/// imperative parser strips "each of" and "any number of" so `parse_target`
+/// sees a bare target phrase; this helper rebuilds the spec from the original
+/// text. Mirrors `extract_double_counter_multi_target` — the only axis of
+/// variation is the verb prefix.
+fn extract_switch_pt_multi_target(text: &str) -> Option {
+ let lower = text.to_lowercase();
+ let (_, target_text) = preceded(
+ tag::<_, _, OracleError<'_>>("switch the power and toughness of "),
+ rest,
+ )
+ .parse(lower.as_str())
+ .ok()?;
+ // The distribution prefix "each of " is optional ("switch ... of each of
+ // any number of target creatures" vs "switch ... of any number of target
+ // creatures"); both surface the same MultiTargetSpec.
+ let after_each_of = tag::<_, _, OracleError<'_>>("each of ")
+ .parse(target_text)
+ .map(|(rest, _)| rest)
+ .unwrap_or(target_text);
+ if let Ok((after_any_number, _)) =
+ tag::<_, _, OracleError<'_>>("any number of ").parse(after_each_of)
+ {
+ if alt((
+ tag::<_, _, OracleError<'_>>("target "),
+ tag("other target "),
+ tag("another target "),
+ ))
+ .parse(after_any_number)
+ .is_ok()
+ {
+ return Some(MultiTargetSpec::unlimited(0));
+ }
+ }
+ let (_, multi_target) = strip_optional_target_prefix(after_each_of);
+ multi_target
+}
+
fn extract_double_counter_multi_target(text: &str) -> Option {
let lower = text.to_lowercase();
let (_, target_text) = preceded(
@@ -32181,6 +32225,42 @@ mod tests {
);
}
+ /// CR 613.4d: prepositional surface form ("switch the power and toughness
+ /// of target creature") — single-target sibling of the possessive form.
+ #[test]
+ fn effect_switch_pt_prepositional_single_target() {
+ let e = parse_effect("switch the power and toughness of target creature until end of turn");
+ assert!(
+ matches!(e, Effect::SwitchPT { .. }),
+ "expected SwitchPT, got: {e:?}"
+ );
+ }
+
+ /// CR 613.4d + CR 115.1d: Inversion Behemoth class — "switch the power and
+ /// toughness of each of any number of target creatures" must parse to
+ /// `Effect::SwitchPT` carrying a typed creature filter, with
+ /// `MultiTargetSpec::unlimited(0)` recovered on the clause so the spell
+ /// allows any number of targets.
+ #[test]
+ fn effect_switch_pt_any_number_of_target_creatures_is_multi_targeted() {
+ let clause = parse_effect_clause(
+ "switch the power and toughness of each of any number of target creatures until end of turn",
+ &mut ParseContext::default(),
+ );
+
+ assert_eq!(clause.multi_target, Some(MultiTargetSpec::unlimited(0)));
+ assert!(
+ matches!(
+ clause.effect,
+ Effect::SwitchPT {
+ target: TargetFilter::Typed(_),
+ }
+ ),
+ "expected SwitchPT {{ target: Typed(..) }}, got: {:?}",
+ clause.effect
+ );
+ }
+
/// CR 608.2c: Period-separated pronoun reference ("Tap target creature. Put two stun
/// counters on it.") must resolve "it" to ParentTarget, not SelfRef. The sub_ability's
/// counter effect should target the same creature as the tap effect.
diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs
index 140514aa69..3fa8e5e9b9 100644
--- a/crates/engine/src/parser/oracle_effect/subject.rs
+++ b/crates/engine/src/parser/oracle_effect/subject.rs
@@ -27,7 +27,7 @@ use super::super::oracle_static::{
classify_block_exception, parse_additive_type_clause_modifications,
parse_chosen_qualifier_subject, parse_continuous_modifications, parse_static_line_multi,
};
-use super::super::oracle_target::{parse_target, parse_type_phrase};
+use super::super::oracle_target::{parse_target, parse_target_with_ctx, parse_type_phrase};
use super::super::oracle_util::{
parse_number, TextPair, SELF_REF_PARSE_ONLY_PHRASES, SELF_REF_TYPE_PHRASES,
};
@@ -626,7 +626,7 @@ pub(super) fn parse_subject_application(
.parse(lower.as_str())
.is_ok()
{
- let (filter, _) = parse_target(&subject["another ".len()..]);
+ let (filter, _) = parse_target_with_ctx(&subject["another ".len()..], ctx);
let filter = add_another_property(filter);
return subject_filter_application(filter, true);
}
@@ -634,7 +634,16 @@ pub(super) fn parse_subject_application(
.parse(lower.as_str())
.is_ok()
{
- let (filter, _) = parse_target(subject);
+ // CR 109.4 + CR 115.1 + CR 603.2: thread the parse context so that
+ // controller-suffix resolution inside `parse_target` (notably the
+ // "that player controls" relative reference) can see the enclosing
+ // trigger's `relative_player_scope` and emit
+ // `ControllerRef::TargetPlayer` for the attacked / damaged player
+ // instead of falling back to `You`. Without `ctx`, the subject-form
+ // path of "target creature that player controls becomes …" (Gornog,
+ // the Red Reaper) silently bound the target to the trigger
+ // controller's own creatures.
+ let (filter, _) = parse_target_with_ctx(subject, ctx);
return subject_filter_application(filter, true);
}
if tag::<_, _, OracleError<'_>>("up to ")
@@ -643,7 +652,7 @@ pub(super) fn parse_subject_application(
{
let (target_text, multi_target) = super::strip_optional_target_prefix(subject);
if multi_target.is_some() {
- let (filter, _) = parse_target(target_text);
+ let (filter, _) = parse_target_with_ctx(target_text, ctx);
let mut application = subject_filter_application(filter, true)?;
application.multi_target = multi_target;
return Some(application);
@@ -661,7 +670,7 @@ pub(super) fn parse_subject_application(
.parse(after_prefix)
.is_ok()
{
- let (filter, _) = parse_target(target_text);
+ let (filter, _) = parse_target_with_ctx(target_text, ctx);
let mut application = subject_filter_application(filter, true)?;
application.multi_target = Some(MultiTargetSpec::unlimited(0));
return Some(application);
@@ -683,7 +692,7 @@ pub(super) fn parse_subject_application(
{
let consumed = lower.len() - after_prefix.len();
let target_text = &subject[consumed..];
- let (filter, _) = parse_target(target_text);
+ let (filter, _) = parse_target_with_ctx(target_text, ctx);
let mut application = subject_filter_application(filter, true)?;
application.multi_target = Some(MultiTargetSpec::fixed(min, max));
return Some(application);
@@ -862,6 +871,11 @@ pub(super) fn parse_subject_application(
})
} else if matches!(ctx.relative_player_scope, Some(ControllerRef::ScopedPlayer)) {
TargetFilter::ScopedPlayer
+ } else if matches!(
+ ctx.relative_player_scope,
+ Some(ControllerRef::ParentTargetController)
+ ) {
+ TargetFilter::ParentTargetController
} else if ctx.subject.is_some() {
ctx_filter
} else {
@@ -3222,6 +3236,22 @@ mod tests {
assert_eq!(result.unwrap().affected, TargetFilter::TriggeringPlayer);
}
+ #[test]
+ fn parse_subject_that_player_trigger_context_honors_parent_target_controller_scope() {
+ let mut ctx = ParseContext {
+ subject: Some(TargetFilter::SelfRef),
+ relative_player_scope: Some(ControllerRef::ParentTargetController),
+ ..ParseContext::default()
+ };
+ let result = parse_subject_application("that player", &mut ctx);
+
+ assert!(result.is_some());
+ assert_eq!(
+ result.unwrap().affected,
+ TargetFilter::ParentTargetController
+ );
+ }
+
// CR 115.1d: "any number of target" subject prefix tests
#[test]
fn parse_subject_any_number_of_target_creatures() {
@@ -3259,6 +3289,49 @@ mod tests {
assert_eq!(app.multi_target, Some(MultiTargetSpec::unlimited(0)),);
}
+ #[test]
+ fn parse_subject_another_target_honors_relative_player_scope() {
+ let mut ctx = ParseContext {
+ relative_player_scope: Some(ControllerRef::TargetPlayer),
+ ..ParseContext::default()
+ };
+ let result =
+ parse_subject_application("another target creature that player controls", &mut ctx);
+ assert!(result.is_some());
+ let app = result.unwrap();
+ assert!(
+ matches!(app.affected, TargetFilter::Typed(ref t)
+ if t.type_filters.contains(&TypeFilter::Creature)
+ && t.controller == Some(ControllerRef::TargetPlayer)
+ && t.properties.iter().any(|prop| matches!(prop, FilterProp::Another))),
+ "should parse another creature controlled by target player, got {:?}",
+ app.affected
+ );
+ }
+
+ #[test]
+ fn parse_subject_up_to_one_target_honors_relative_player_scope() {
+ let mut ctx = ParseContext {
+ relative_player_scope: Some(ControllerRef::TargetPlayer),
+ ..ParseContext::default()
+ };
+ let result =
+ parse_subject_application("up to one target creature that player controls", &mut ctx);
+ assert!(result.is_some());
+ let app = result.unwrap();
+ assert!(
+ matches!(app.affected, TargetFilter::Typed(ref t)
+ if t.type_filters.contains(&TypeFilter::Creature)
+ && t.controller == Some(ControllerRef::TargetPlayer)),
+ "should parse creature controlled by target player, got {:?}",
+ app.affected
+ );
+ assert_eq!(
+ app.multi_target,
+ Some(MultiTargetSpec::up_to(QuantityExpr::Fixed { value: 1 }))
+ );
+ }
+
#[test]
fn parse_subject_any_number_of_target_players() {
let mut ctx = ParseContext::default();
diff --git a/crates/engine/src/parser/oracle_nom/quantity.rs b/crates/engine/src/parser/oracle_nom/quantity.rs
index 19dd5b0cd4..e682c7b3d0 100644
--- a/crates/engine/src/parser/oracle_nom/quantity.rs
+++ b/crates/engine/src/parser/oracle_nom/quantity.rs
@@ -4468,6 +4468,26 @@ mod tests {
assert_eq!(rest, "");
}
+ #[test]
+ fn test_parse_half_your_library_rounded_up() {
+ let (rest, q) = parse_quantity("half your library, rounded up").unwrap();
+ assert_eq!(
+ q,
+ QuantityExpr::DivideRounded {
+ inner: Box::new(QuantityExpr::Ref {
+ qty: QuantityRef::ZoneCardCount {
+ zone: ZoneRef::Library,
+ card_types: Vec::new(),
+ scope: CountScope::Controller,
+ },
+ }),
+ divisor: 2,
+ rounding: RoundingMode::Up,
+ }
+ );
+ assert_eq!(rest, "");
+ }
+
/// Legacy Oracle text for life-loss cards used "his or her life" before
/// the 2014 "their" reword. Resolves to the same `TargetLifeTotal` ref.
#[test]
diff --git a/crates/engine/src/parser/oracle_static.rs b/crates/engine/src/parser/oracle_static.rs
index 055488a8a9..34c8a37570 100644
--- a/crates/engine/src/parser/oracle_static.rs
+++ b/crates/engine/src/parser/oracle_static.rs
@@ -10307,11 +10307,24 @@ fn parse_all_subject_are_color(tp: &TextPair<'_>, description: &str) -> Option Option> {
+ // CR 105.2: "all colors" / "every color" means the full WUBRG set.
+ if let Ok((rest, _)) = alt((
+ tag::<_, _, OracleError<'_>>("all colors"),
+ tag("every color"),
+ ))
+ .parse(text)
+ {
+ if rest.is_empty() {
+ return Some(ManaColor::ALL.to_vec());
+ }
+ }
+
if let Some(rest) = nom_tag_lower(text, text, "colorless") {
if rest.is_empty() {
return Some(Vec::new());
@@ -18388,6 +18401,17 @@ mod tests {
);
}
+ #[test]
+ fn static_all_creatures_are_all_colors() {
+ let def = parse_static_line("All creatures are all colors.").unwrap();
+ assert_eq!(
+ def.modifications,
+ vec![ContinuousModification::SetColor {
+ colors: ManaColor::ALL.to_vec()
+ }]
+ );
+ }
+
#[test]
fn static_all_subject_are_color_falls_through_to_land_type_change() {
// Regression guard: "All lands are Plains." has a non-color predicate,
@@ -18455,6 +18479,18 @@ mod tests {
assert!(def.characteristic_defining);
}
+ #[test]
+ fn static_self_is_all_colors_cda() {
+ let def = parse_static_line("~ is all colors.").unwrap();
+ assert_eq!(
+ def.modifications,
+ vec![ContinuousModification::SetColor {
+ colors: ManaColor::ALL.to_vec()
+ }]
+ );
+ assert!(def.characteristic_defining);
+ }
+
// --- Group A: Chosen color/type creature pump ---
#[test]
diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs
index 4a27d7ddda..3a7c5b990a 100644
--- a/crates/engine/src/parser/oracle_trigger.rs
+++ b/crates/engine/src/parser/oracle_trigger.rs
@@ -462,14 +462,18 @@ fn strip_first_time_each_turn_qualifier(condition: &str) -> (String, bool) {
}
/// CR 109.4 + CR 115.1 + CR 506.2: Detect a trigger condition that introduces
-/// a player target — currently the "you/an opponent/a player attack(s) a player"
-/// family. When this returns true, follow-on possessive references inside the
-/// effect ("that player controls/owns") refer to that introduced player and the
-/// parser pushes a relative-player scope so they emit `ControllerRef::TargetPlayer`.
+/// a player target — currently the "[subject] attack(s) a player" family
+/// (CR 506.2 / CR 508.1a) and the "[subject] deals [combat] damage to a player"
+/// family (CR 120.3). When this returns true, follow-on possessive references
+/// inside the effect ("that player controls/owns") refer to that introduced
+/// player and the parser pushes a relative-player scope so they emit
+/// `ControllerRef::TargetPlayer`.
///
-/// Built from composable nom alternatives so adding new condition shapes
-/// (combat-damage-to-a-player, "deals damage to a player", etc.) is a one-line
-/// change to the inner `alt()`.
+/// Built from composable nom alternatives so adding new condition shapes is a
+/// one-line change to the inner `alt()`. The attack/damage scans both accept
+/// any subject prefix (verb-phrase only), so relative-clause subjects like
+/// "one or more Warriors you control" and "a creature you control" match
+/// without needing an explicit-actor variant per subject shape.
fn condition_introduces_target_player(cond_lower: &str) -> bool {
use nom::bytes::complete::tag;
use nom::combinator::value;
@@ -523,6 +527,21 @@ fn condition_introduces_target_player(cond_lower: &str) -> bool {
}
}
}
+ // CR 506.2 + CR 508.1a: "[anything] attack[s] a player" — same subject
+ // permissiveness as the damage scan below. Covers cases where the
+ // actor is wrapped in a relative clause that the explicit actor branch
+ // above cannot match, e.g. "one or more Warriors you control attack a
+ // player" (Gornog, the Red Reaper) or "a creature you control attacks
+ // a player". The verb phrase alone is unambiguous in trigger-condition
+ // text — "attack" never appears as a noun before "a player" here.
+ if let Ok((after_verb, ())) = parse_attack_verb(remaining) {
+ if tag::<_, _, OracleError<'_>>("a player")
+ .parse(after_verb)
+ .is_ok()
+ {
+ return true;
+ }
+ }
// CR 120.3: "[anything] deals [combat] damage to a player" — introduces
// the damaged player as the target-referring player. The subject can be
// SelfRef ("~"), equipped creature ("equipped creature"), or any typed
@@ -546,6 +565,38 @@ fn condition_introduces_target_player(cond_lower: &str) -> bool {
false
}
+fn condition_introduces_damage_source_controller_player(cond_lower: &str) -> bool {
+ let input = cond_lower.trim_start();
+ let input = alt((
+ value((), tag::<_, _, OracleError<'_>>("whenever ")),
+ value((), tag("when ")),
+ ))
+ .parse(input)
+ .map(|(rest, _)| rest)
+ .unwrap_or(input);
+ let Ok((rest, source_filter)) = parse_damage_source_subject(input) else {
+ return false;
+ };
+ let TargetFilter::Typed(TypedFilter {
+ controller: Some(ControllerRef::Opponent),
+ ..
+ }) = source_filter
+ else {
+ return false;
+ };
+ let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("deals ").parse(rest) else {
+ return false;
+ };
+ let Ok((after_damage, _)) = parse_damage_predicate_tail(rest) else {
+ return false;
+ };
+
+ matches!(
+ parse_damage_to_qualifier(after_damage),
+ Some(TargetFilter::Controller)
+ )
+}
+
/// Parse a full trigger line into a TriggerDefinition.
/// Input: a line starting with "When", "Whenever", or "At".
/// The card_name is used for self-reference substitution.
@@ -640,7 +691,9 @@ pub(crate) fn parse_trigger_line_with_index_ir(
// CR 109.4 + CR 115.1 + CR 506.2: Set relative-player scope for
// TargetPlayer resolution inside the trigger effect body.
- if condition_introduces_target_player(&cond_lower) {
+ if condition_introduces_damage_source_controller_player(&cond_lower) {
+ effect_ctx.relative_player_scope = Some(ControllerRef::ParentTargetController);
+ } else if condition_introduces_target_player(&cond_lower) {
effect_ctx.relative_player_scope = Some(ControllerRef::TargetPlayer);
} else if condition_introduces_scoped_phase_player(&cond_lower) {
effect_ctx.relative_player_scope = Some(ControllerRef::ScopedPlayer);
@@ -3526,12 +3579,23 @@ fn parse_event_verb_start(input: &str) -> OracleResult<'_, ()> {
parse_event_phrase("create "),
parse_event_word("create"),
));
+ let simple_event_verbs = alt((
+ // CR 702.100b + CR 701.44b: SimpleEvent verbs that may appear in
+ // compound triggers (e.g. "~ evolves or dies").
+ parse_event_word("evolves"),
+ parse_event_phrase("evolve "),
+ parse_event_word("explores"),
+ parse_event_phrase("explore "),
+ parse_event_word("exploits"),
+ parse_event_word("mutates"),
+ parse_event_word("transforms"),
+ ));
let player_actions = alt((
passive_player_actions,
sacrifice_discard_actions,
play_cast_create_actions,
));
- alt((combat_or_zone, player_actions)).parse(input)
+ alt((combat_or_zone, player_actions, simple_event_verbs)).parse(input)
}
fn parse_bare_shared_event_verb(input: &str) -> OracleResult<'_, ()> {
@@ -3907,6 +3971,9 @@ pub(crate) fn parse_trigger_condition(
fn execute_references_target_player(effect: &crate::types::ability::Effect) -> bool {
fn filter_references(filter: &TargetFilter) -> bool {
match filter {
+ // CR 115.1: Bare `Player` target means the effect explicitly
+ // targets a player (e.g. "target player mills ...").
+ TargetFilter::Player => true,
TargetFilter::Typed(TypedFilter { controller, .. }) => {
matches!(controller, Some(ControllerRef::TargetPlayer))
}
@@ -4960,6 +5027,9 @@ fn try_parse_event(
Exploits,
/// CR 701.44b: A permanent "explores" after the explore process completes.
Explores,
+ /// CR 702.100b: A creature "evolves" when +1/+1 counters are put on it
+ /// as a result of its evolve ability resolving.
+ Evolves,
Transforms,
Stations,
SaddlesOrCrews,
@@ -5026,6 +5096,9 @@ fn try_parse_event(
// CR 701.44b: "explores" / "explore" — explore trigger
value(SimpleEvent::Explores, tag("explores")),
value(SimpleEvent::Explores, tag("explore")),
+ // CR 702.100b: "evolves" / "evolve" — evolve trigger
+ value(SimpleEvent::Evolves, tag("evolves")),
+ value(SimpleEvent::Evolves, tag("evolve")),
// CR 712.14: "transforms" / "transforms into"
value(SimpleEvent::Transforms, tag("transforms")),
// CR 702.184a: "stations ~" — actor-side Station trigger.
@@ -5114,6 +5187,15 @@ fn try_parse_event(
def.mode = TriggerMode::Explored;
def.valid_card = Some(subject.clone());
}
+ SimpleEvent::Evolves => {
+ if !remaining.trim().is_empty() {
+ return None;
+ }
+ // CR 702.100b: "evolves" fires when +1/+1 counters are put on
+ // the creature as a result of its evolve ability resolving.
+ def.mode = TriggerMode::Evolved;
+ def.valid_card = Some(subject.clone());
+ }
SimpleEvent::Transforms => {
def.mode = TriggerMode::Transformed;
def.valid_source = Some(subject.clone());
@@ -5489,8 +5571,21 @@ fn parse_damage_source_subject(input: &str) -> OracleResult<'_, TargetFilter> {
// misclassify the subject.
let (rest, color) = opt(terminated(nom_primitives::parse_color, tag(" "))).parse(rest)?;
+ // CR 109.4: Head noun — "source" (any), a card type, or a negated type
+ // prefix ("noncreature source"). The negated variant uses
+ // `TypeFilter::Non(Box::new(…))` so the runtime filter excludes that type.
let (rest, head_type) = alt((
- value(None, tag::<_, _, OracleError<'_>>("source")),
+ value(
+ Some(TypeFilter::Non(Box::new(TypeFilter::Creature))),
+ (
+ tag::<_, _, OracleError<'_>>("noncreature source"),
+ opt(tag("s")),
+ ),
+ ),
+ value(
+ None,
+ (tag::<_, _, OracleError<'_>>("source"), opt(tag("s"))),
+ ),
value(Some(TypeFilter::Creature), tag("creature")),
value(Some(TypeFilter::Artifact), tag("artifact")),
value(Some(TypeFilter::Enchantment), tag("enchantment")),
@@ -5500,12 +5595,19 @@ fn parse_damage_source_subject(input: &str) -> OracleResult<'_, TargetFilter> {
))
.parse(rest)?;
- // Optional " you control" controller scope. Absence → no controller
- // restriction (matches any source — Phyrexian Obliterator class, deferred).
- let (rest, controller) = opt(value(
- ControllerRef::You,
- tag::<_, _, OracleError<'_>>(" you control"),
- ))
+ // Optional controller scope. Absence → no controller restriction
+ // (matches any source — Phyrexian Obliterator class, deferred).
+ // CR 109.4: "a source you control" / "a source an opponent controls".
+ let (rest, controller) = opt(alt((
+ value(
+ ControllerRef::You,
+ tag::<_, _, OracleError<'_>>(" you control"),
+ ),
+ value(
+ ControllerRef::Opponent,
+ tag::<_, _, OracleError<'_>>(" an opponent controls"),
+ ),
+ )))
.parse(rest)?;
// Require trailing space before the "deals" verb so we don't match
@@ -9896,6 +9998,84 @@ mod tests {
assert_eq!(def.mode, TriggerMode::Attacks);
}
+ /// CR 107.1 + CR 701.17a: Attack triggers can use the shared fractional
+ /// quantity parser to mill the targeted player's library.
+ #[test]
+ fn trigger_attacks_target_player_mills_half_their_library_rounded_up() {
+ use crate::types::ability::{RoundingMode, ZoneRef};
+
+ let def = parse_trigger_line(
+ "Whenever this creature attacks, target player mills half their library, rounded up.",
+ "Fleet Swallower",
+ );
+ assert_eq!(def.mode, TriggerMode::Attacks);
+ assert_eq!(def.valid_target, Some(TargetFilter::Player));
+
+ let execute = def.execute.as_ref().expect("trigger should have effect");
+ match execute.effect.as_ref() {
+ Effect::Mill {
+ count,
+ target,
+ destination,
+ } => {
+ assert_eq!(*target, TargetFilter::Player);
+ assert_eq!(*destination, Zone::Graveyard);
+ assert_eq!(
+ *count,
+ QuantityExpr::DivideRounded {
+ inner: Box::new(QuantityExpr::Ref {
+ qty: QuantityRef::TargetZoneCardCount {
+ zone: ZoneRef::Library,
+ },
+ }),
+ divisor: 2,
+ rounding: RoundingMode::Up,
+ },
+ );
+ }
+ other => panic!("Expected Mill, got {other:?}"),
+ }
+ }
+
+ /// CR 107.1 + CR 701.17a: Sibling coverage — "rounded down" variant
+ /// exercises the same fractional quantity parser with the opposite rounding
+ /// mode, ensuring both arms of the `RoundingMode` axis are verified.
+ #[test]
+ fn trigger_attacks_target_player_mills_half_their_library_rounded_down() {
+ use crate::types::ability::{RoundingMode, ZoneRef};
+
+ let def = parse_trigger_line(
+ "Whenever this creature attacks, target player mills half their library, rounded down.",
+ "Test Card",
+ );
+ assert_eq!(def.mode, TriggerMode::Attacks);
+
+ let execute = def.execute.as_ref().expect("trigger should have effect");
+ match execute.effect.as_ref() {
+ Effect::Mill {
+ count,
+ target,
+ destination,
+ } => {
+ assert_eq!(*target, TargetFilter::Player);
+ assert_eq!(*destination, Zone::Graveyard);
+ assert_eq!(
+ *count,
+ QuantityExpr::DivideRounded {
+ inner: Box::new(QuantityExpr::Ref {
+ qty: QuantityRef::TargetZoneCardCount {
+ zone: ZoneRef::Library,
+ },
+ }),
+ divisor: 2,
+ rounding: RoundingMode::Down,
+ },
+ );
+ }
+ other => panic!("Expected Mill, got {other:?}"),
+ }
+ }
+
#[test]
fn trigger_attacks_and_isnt_blocked_uses_unblocked_mode_and_combat_duration() {
let def = parse_trigger_line(
@@ -10024,8 +10204,44 @@ mod tests {
);
}
+ #[test]
+ fn trigger_evolves_self() {
+ let def = parse_trigger_line(
+ "Whenever ~ evolves, put a +1/+1 counter on each other creature you control with a +1/+1 counter on it.",
+ "Renegade Krasis",
+ );
+ assert_eq!(def.mode, TriggerMode::Evolved);
+ assert_eq!(def.valid_card, Some(TargetFilter::SelfRef));
+ }
+ #[test]
+ fn trigger_evolves_creature_you_control() {
+ let def = parse_trigger_line(
+ "Whenever a creature you control evolves, draw a card.",
+ "Test Card",
+ );
+ assert_eq!(def.mode, TriggerMode::Evolved);
+ assert_eq!(
+ def.valid_card,
+ Some(TargetFilter::Typed(
+ TypedFilter::creature().controller(crate::types::ability::ControllerRef::You)
+ ))
+ );
+ }
+ #[test]
+ fn trigger_evolve_plural() {
+ let def = parse_trigger_line(
+ "Whenever one or more creatures you control evolve, draw a card.",
+ "Test Card",
+ );
+ assert_eq!(def.mode, TriggerMode::Evolved);
+ assert_eq!(
+ def.valid_card,
+ Some(TargetFilter::Typed(
+ TypedFilter::creature().controller(crate::types::ability::ControllerRef::You)
+ ))
+ );
+ }
// --- Subject decomposition tests ---
-
#[test]
fn trigger_another_creature_you_control_enters() {
let def = parse_trigger_line(
@@ -15614,6 +15830,86 @@ mod tests {
assert_eq!(def.valid_target, Some(TargetFilter::Player));
}
+ #[test]
+ fn trigger_source_opponent_controls_deals_damage_to_you() {
+ let def = parse_trigger_line(
+ "Whenever a source an opponent controls deals damage to you, you may put that many +1/+1 counters on ~.",
+ "Retaliator Griffin",
+ );
+ assert_eq!(def.mode, TriggerMode::DamageDone);
+ assert_eq!(def.damage_kind, DamageKindFilter::Any);
+ assert_eq!(def.damage_amount, None);
+ assert_eq!(
+ def.valid_source,
+ Some(TargetFilter::Typed(
+ TypedFilter::default().controller(ControllerRef::Opponent)
+ ))
+ );
+ assert_eq!(def.valid_target, Some(TargetFilter::Controller));
+ }
+
+ #[test]
+ fn trigger_noncreature_source_you_control_deals_damage() {
+ let def = parse_trigger_line(
+ "Whenever a noncreature source you control deals damage, you gain that much life.",
+ "Tamanoa",
+ );
+ assert_eq!(def.mode, TriggerMode::DamageDone);
+ assert_eq!(def.damage_kind, DamageKindFilter::Any);
+ assert_eq!(def.damage_amount, None);
+ assert_eq!(
+ def.valid_source,
+ Some(TargetFilter::Typed(
+ TypedFilter::new(TypeFilter::Non(Box::new(TypeFilter::Creature)))
+ .controller(ControllerRef::You)
+ ))
+ );
+ assert_eq!(def.valid_target, None);
+ }
+
+ #[test]
+ fn trigger_source_opponent_controls_deals_damage_to_you_michiko() {
+ let def = parse_trigger_line(
+ "Whenever a source an opponent controls deals damage to you, that player sacrifices a permanent.",
+ "Michiko Konda, Truth Seeker",
+ );
+ assert_eq!(def.mode, TriggerMode::DamageDone);
+ assert_eq!(
+ def.valid_source,
+ Some(TargetFilter::Typed(
+ TypedFilter::default().controller(ControllerRef::Opponent)
+ ))
+ );
+ assert_eq!(def.valid_target, Some(TargetFilter::Controller));
+ let execute = def.execute.as_ref().expect("trigger execute");
+ match execute.effect.as_ref() {
+ Effect::Sacrifice { target, .. } => match target {
+ TargetFilter::Typed(TypedFilter {
+ type_filters,
+ controller: Some(ControllerRef::ParentTargetController),
+ ..
+ }) => assert_eq!(type_filters.as_slice(), [TypeFilter::Permanent]),
+ other => panic!("expected source-controller sacrifice filter, got {other:?}"),
+ },
+ other => panic!("expected Sacrifice, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn trigger_noncreature_source_deals_damage_to_player() {
+ let def = parse_trigger_line(
+ "Whenever a noncreature source deals damage to a player, draw a card.",
+ "Test",
+ );
+ assert_eq!(def.mode, TriggerMode::DamageDone);
+ assert_eq!(
+ def.valid_source,
+ Some(TargetFilter::Typed(TypedFilter::new(TypeFilter::Non(
+ Box::new(TypeFilter::Creature)
+ ))))
+ );
+ assert_eq!(def.valid_target, Some(TargetFilter::Player));
+ }
// ── Work Item 4: Transforms Into Self ─────────────────────────
#[test]
@@ -19672,6 +19968,38 @@ mod tests {
}
}
+ /// CR 109.4 + CR 115.1 + CR 506.2: Gornog's "Whenever one or more Warriors
+ /// you control attack a player, target creature that player controls
+ /// becomes a Coward" introduces the attacked player through a relative-
+ /// clause subject ("Warriors you control") rather than a bare actor. The
+ /// effect's "that player controls" must still resolve to
+ /// `ControllerRef::TargetPlayer` so the UI offers the attacked player's
+ /// creatures, not the trigger controller's. Regression test for #1054 — the
+ /// subject-permissive scan in `condition_introduces_target_player` is the
+ /// fix site.
+ #[test]
+ fn gornog_one_or_more_warriors_attack_uses_target_player_controller() {
+ use crate::types::ability::Effect;
+
+ let def = parse_trigger_line(
+ "Whenever one or more Warriors you control attack a player, target creature that player controls becomes a Coward.",
+ "Gornog, the Red Reaper",
+ );
+ assert_eq!(def.mode, TriggerMode::YouAttack);
+ let execute = def.execute.as_deref().expect("execute ability");
+ match execute.effect.as_ref() {
+ Effect::GenericEffect { target, .. } => match target {
+ Some(TargetFilter::Typed(t)) => assert_eq!(
+ t.controller,
+ Some(ControllerRef::TargetPlayer),
+ "GenericEffect target should reference the attacked player",
+ ),
+ other => panic!("expected Some(Typed) target filter, got {other:?}"),
+ },
+ other => panic!("expected GenericEffect, got {other:?}"),
+ }
+ }
+
/// Negative scope test — a non-attack-player trigger ("Whenever you draw a
/// card") MUST NOT push the relative-player scope, so "that player controls"
/// inside the effect (synthetic but exercising the parser) still defaults to
diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs
index 344818843b..bb93a4baba 100644
--- a/crates/engine/src/types/ability.rs
+++ b/crates/engine/src/types/ability.rs
@@ -7982,6 +7982,8 @@ impl<'de> Deserialize<'de> for ModalSelectionCondition {
pub enum AbilityTag {
/// CR 702.142a: This ability originated from a Boast keyword definition.
Boast,
+ /// CR 702.100a: This ability originated from an Evolve keyword definition.
+ Evolve,
/// CR 702.177a: This ability originated from an Exhaust keyword definition.
Exhaust,
/// CR 702.107a: This ability originated from an Outlast keyword definition.
@@ -8994,6 +8996,10 @@ pub struct SpellContext {
/// the resolution-time battlefield.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub controller_controlled_as_cast: Vec,
+ /// CR 702: Keyword origin carried from the parsed/synthesized definition
+ /// into runtime resolution for keyword-specific events.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub ability_tag: Option,
}
impl SpellContext {
diff --git a/crates/engine/src/types/events.rs b/crates/engine/src/types/events.rs
index ca26d73dd4..849102e9a9 100644
--- a/crates/engine/src/types/events.rs
+++ b/crates/engine/src/types/events.rs
@@ -322,6 +322,11 @@ pub enum GameEvent {
counter_type: CounterType,
count: u32,
},
+ /// CR 702.100b: A creature evolved because one or more +1/+1 counters were
+ /// put on it as a result of its evolve ability resolving.
+ Evolved {
+ object_id: ObjectId,
+ },
CounterRemoved {
object_id: ObjectId,
counter_type: CounterType,
diff --git a/crates/engine/src/types/triggers.rs b/crates/engine/src/types/triggers.rs
index ca88593195..2a2a309564 100644
--- a/crates/engine/src/types/triggers.rs
+++ b/crates/engine/src/types/triggers.rs
@@ -216,7 +216,9 @@ pub enum TriggerMode {
/// (e.g., "an ability of an artifact source") live on `valid_card` — both
/// reuse existing infrastructure shared with `KeywordAbilityActivated`.
AbilityActivated,
- /// CR 702.100: Evolve trigger — when a creature enters with greater power/toughness.
+ /// CR 702.100a: Evolve keyword trigger — when a creature enters with greater power/toughness.
+ Evolve,
+ /// CR 702.100b: Triggers when a creature evolves.
Evolved,
/// CR 701.44: Triggers when a creature explores.
Explored,
@@ -434,6 +436,7 @@ impl FromStr for TriggerMode {
"Enlisted" => TriggerMode::Enlisted,
"AttacksOrBlocks" => TriggerMode::AttacksOrBlocks,
"EntersOrAttacks" => TriggerMode::EntersOrAttacks,
+ "Evolve" => TriggerMode::Evolve,
"Evolved" => TriggerMode::Evolved,
"ExcessDamage" => TriggerMode::ExcessDamage,
"ExcessDamageAll" => TriggerMode::ExcessDamageAll,
@@ -697,6 +700,7 @@ mod tests {
"ElementalBend",
"Enlisted",
"EntersOrAttacks",
+ "Evolve",
"Evolved",
"ExcessDamage",
"ExcessDamageAll",
diff --git a/crates/engine/tests/integration/kaito_integration.rs b/crates/engine/tests/integration/kaito_integration.rs
index 98893e1fbe..5563210087 100644
--- a/crates/engine/tests/integration/kaito_integration.rs
+++ b/crates/engine/tests/integration/kaito_integration.rs
@@ -529,10 +529,22 @@ fn kaito_surveil_and_draw() {
.hand
.len();
- // Activate 0 loyalty ability (Surveil 2, then draw for each opponent who lost life)
+ // Activate 0 loyalty ability (Surveil 2, then draw for each opponent who lost life).
+ // Resolve by effect shape instead of hardcoded index; this test runner object
+ // can carry pre-existing abilities from card data.
+ let surveil_ability_index = {
+ let state = runner.state();
+ let kaito = state.objects.get(&kaito_id).unwrap();
+ kaito
+ .abilities
+ .iter()
+ .position(|ability| matches!(*ability.effect, Effect::Surveil { .. }))
+ .expect("Kaito should have a surveil loyalty ability")
+ };
+
let result = runner.act(GameAction::ActivateAbility {
source_id: kaito_id,
- ability_index: 1, // Second ability = surveil+draw
+ ability_index: surveil_ability_index,
});
assert!(
result.is_ok(),
diff --git a/deploy/card-bot-push.sh b/deploy/card-bot-push.sh
new file mode 100755
index 0000000000..b98e9f114c
--- /dev/null
+++ b/deploy/card-bot-push.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Build the card-bot image locally, ship it to the VPS over SSH, and (re)start
+# the container. Mirrors deploy/push.sh (phase-server), but auto-detects how the
+# SSH user reaches docker: directly if it can, else via `sudo docker`. (A fresh
+# setup-vps.sh box grants passwordless `sudo docker`; this box puts the deploy
+# user in the docker group instead.)
+#
+# The long-lived container needs NO secrets: the public key is baked into
+# config.ts and signature follow-ups use the per-interaction webhook token. The
+# bot token is only needed to (re)register the slash command — a one-off
+# `docker run` reading an env file on the host (app id / guild id / public key
+# are all baked-in defaults, so the token is all that's required):
+# /etc/phase-card-bot.env → CARD_BOT_TOKEN (secret, from the Discord portal)
+#
+# Usage: ./deploy/card-bot-push.sh (HOST defaults to the phase-vps ssh alias)
+
+HOST="${CARD_BOT_HOST:-phase-vps}"
+IMAGE="phase-card-bot:local"
+ENV_FILE="/etc/phase-card-bot.env"
+
+# Remote prelude: choose `docker` vs `sudo docker` for this SSH user.
+detect='D=docker; docker info >/dev/null 2>&1 || D="sudo docker";'
+
+wait_for_health_remote='for _ in $(seq 1 30); do
+ if curl -fsS http://127.0.0.1:9375/health >/dev/null; then healthy=1; break; fi
+ sleep 1
+done
+if [ "${healthy:-0}" != "1" ]; then
+ $D logs --tail 50 phase-card-bot || true
+ exit 1
+fi'
+
+echo "Building ${IMAGE}..."
+# --platform linux/amd64: the VPS is x86_64 even when building from Apple Silicon.
+# --provenance=false keeps the image in the classic format the host's older
+# Docker (20.10.x) can `docker load`.
+docker buildx build --platform linux/amd64 --provenance=false --load \
+ -f deploy/card-bot.Dockerfile -t "$IMAGE" scripts/card-bot
+
+echo "Uploading image to ${HOST}..."
+docker save "$IMAGE" | ssh "${HOST}" "${detect} \$D load"
+
+echo "Deploying..."
+ssh "${HOST}" "${detect} \
+ (\$D stop phase-card-bot || true) \
+ && (\$D rm phase-card-bot || true) \
+ && \$D run -d \
+ --name phase-card-bot \
+ --restart unless-stopped \
+ -p 127.0.0.1:9375:9375 \
+ ${IMAGE} \
+ && echo 'Waiting for health...' \
+ && ${wait_for_health_remote} \
+ && \$D ps --filter name=phase-card-bot --filter status=running"
+
+echo "Done — phase-card-bot deployed to ${HOST}"
+echo "If the /card command shape changed, register it once with:"
+echo " ssh ${HOST} \"${detect} \\\$D run --rm --env-file ${ENV_FILE} ${IMAGE} bun run card-bot/register.ts\""
diff --git a/deploy/card-bot.Dockerfile b/deploy/card-bot.Dockerfile
new file mode 100644
index 0000000000..7311129c8d
--- /dev/null
+++ b/deploy/card-bot.Dockerfile
@@ -0,0 +1,22 @@
+# syntax=docker/dockerfile:1
+#
+# Discord /card parse-breakdown bot. Dependency-free Bun service — it fetches
+# coverage-data.json from R2 and card images from Scryfall at runtime, so no
+# data or npm packages are baked in. Build context is scripts/card-bot/:
+# docker build -f deploy/card-bot.Dockerfile -t phase-card-bot scripts/card-bot
+
+FROM oven/bun:1.3-alpine AS runtime
+
+WORKDIR /app
+COPY --chown=bun:bun . ./card-bot
+
+ENV CARD_BOT_PORT=9375
+EXPOSE 9375
+
+USER bun
+
+# Generous start period: the first request warms the ~52MB coverage export.
+HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
+ CMD wget -qO- "http://127.0.0.1:${CARD_BOT_PORT}/health" >/dev/null 2>&1 || exit 1
+
+CMD ["bun", "run", "card-bot/index.ts"]
diff --git a/deploy/phase-server.nginx.conf b/deploy/phase-server.nginx.conf
index 76b0eab4c8..15a4797cc5 100644
--- a/deploy/phase-server.nginx.conf
+++ b/deploy/phase-server.nginx.conf
@@ -18,6 +18,16 @@ server {
proxy_send_timeout 3600s;
}
+ # Discord card-bot (separate container on :9375). The trailing slash on
+ # proxy_pass strips the /card-bot/ prefix, so Discord's configured endpoint
+ # https:///card-bot/interactions reaches the bot as POST /interactions.
+ location /card-bot/ {
+ proxy_pass http://127.0.0.1:9375/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+
location / {
proxy_pass http://phase_server;
proxy_set_header Host $host;
diff --git a/scripts/card-bot/config.ts b/scripts/card-bot/config.ts
new file mode 100644
index 0000000000..77e58f8535
--- /dev/null
+++ b/scripts/card-bot/config.ts
@@ -0,0 +1,87 @@
+// Card-bot configuration. Dependency-free: reads everything from Bun.env,
+// mirroring the rest of scripts/ (no package.json, no node_modules).
+
+/**
+ * Which R2-published build to read parse data from.
+ * - `release` → bucket root (https:///coverage-data.json)
+ * - `preview` → the preview/staging site's data (https:///staging/coverage-data.json)
+ *
+ * These map to the same paths the deployed frontends read (release.yml → root,
+ * deploy.yml staging → /staging). The Scryfall image is build-independent.
+ */
+export type Build = "release" | "preview";
+
+export const BUILDS: readonly Build[] = ["release", "preview"] as const;
+
+export function isBuild(value: string): value is Build {
+ return (BUILDS as readonly string[]).includes(value);
+}
+
+/** Public R2 base, overridable for self-host / testing. No trailing slash. */
+const R2_BASE = (
+ Bun.env.CARD_BOT_R2_BASE ?? "https://pub-fc5b5c2c6e774356ae3e730bb0326394.r2.dev"
+).replace(/\/+$/, "");
+
+/** Per-build path prefix under the R2 base. */
+const BUILD_PREFIX: Record = {
+ release: "",
+ preview: "/staging",
+};
+
+/** URL of the coverage export (carries per-card `parse_details`) for a build. */
+export function coverageUrl(build: Build): string {
+ return `${R2_BASE}${BUILD_PREFIX[build]}/coverage-data.json`;
+}
+
+/** URL of the tiny build-provenance manifest (commit, mtgjson date) for a build. */
+export function metaUrl(build: Build): string {
+ return `${R2_BASE}${BUILD_PREFIX[build]}/card-data-meta.json`;
+}
+
+/**
+ * URL of the Scryfall image/metadata export — the same file the app reads
+ * (services/scryfall.ts). Build-independent (it's Scryfall bulk data, not engine
+ * output), so we just read the freshest copy from the default build's path.
+ */
+export function scryfallDataUrl(): string {
+ return `${R2_BASE}${BUILD_PREFIX[DEFAULT_BUILD]}/scryfall-data.json`;
+}
+
+/** Default build when the user omits the option — preview tracks latest main. */
+export const DEFAULT_BUILD: Build = "preview";
+
+function required(name: string): string {
+ const value = Bun.env[name];
+ if (!value) throw new Error(`Missing required env var ${name}`);
+ return value;
+}
+
+// Public identifiers for the dedicated card-bot Discord app. Both are non-secret
+// (the public key exists to be shared for signature verification; the app id
+// appears in every interaction), so they're baked in as overridable defaults —
+// only the bot token and guild id need to be supplied per host.
+const DEFAULT_APP_ID = "1508547877331271892";
+const DEFAULT_PUBLIC_KEY =
+ "7cde1d5e7a717be0d222f93526e40aeb17165e02521a2471e5b6794e2e0f328e";
+// The phase.rs community server. Non-secret (a guild id is visible to members);
+// baked in so registration needs no config beyond the bot token.
+const DEFAULT_GUILD_ID = "1485498006781427802";
+
+/** Discord application credentials (the dedicated card-bot app, not the bug bot). */
+export const discord = {
+ /** Bot token (secret) — only needed to register slash commands (register.ts). */
+ token: () => required("CARD_BOT_TOKEN"),
+ /** Ed25519 public key — verifies inbound interaction signatures. */
+ publicKey: () => Bun.env.CARD_BOT_PUBLIC_KEY || DEFAULT_PUBLIC_KEY,
+ /** Application (client) id. */
+ appId: () => Bun.env.CARD_BOT_APP_ID || DEFAULT_APP_ID,
+ /** Guild to register the command in (instant propagation, single-server bot). */
+ guildId: () => Bun.env.CARD_BOT_GUILD_ID || DEFAULT_GUILD_ID,
+};
+
+/** HTTP port the interactions server listens on (behind nginx on 127.0.0.1). */
+export const PORT = Number(Bun.env.CARD_BOT_PORT ?? 9375);
+
+/** Identifies the bot to Scryfall per their API etiquette. */
+export const SCRYFALL_USER_AGENT =
+ Bun.env.CARD_BOT_USER_AGENT ?? "phase-rs-card-bot/1.0 (+https://phase-rs.dev)";
diff --git a/scripts/card-bot/coverageData.ts b/scripts/card-bot/coverageData.ts
new file mode 100644
index 0000000000..32b099edbc
--- /dev/null
+++ b/scripts/card-bot/coverageData.ts
@@ -0,0 +1,225 @@
+// Per-build cache of the engine's coverage export (coverage-data.json on R2).
+//
+// Each card entry carries `parse_details` — the exact `ParsedItem` tree the
+// frontend's Alt-hover "ENGINE PARSE" overlay renders (engine-authoritative,
+// built by crates/engine/src/game/coverage.rs). We read it straight from R2 so
+// the bot never re-derives parse logic and always reflects the deployed build.
+//
+// Memory: the file is ~52MB. We parse it once per (re)load, then keep each card
+// as a compact JSON string keyed by lowercased name — a lookup parses one small
+// entry on demand. This avoids holding a 34k-node live object graph resident
+// (the OOM shape noted in project memory) while keeping steady RAM ≈ file size.
+
+import {
+ type Build,
+ DEFAULT_BUILD,
+ coverageUrl,
+ metaUrl,
+} from "./config";
+
+/** A node in the engine's hierarchical parse tree. Mirrors Rust `ParsedItem`. */
+export interface ParsedItem {
+ category: "keyword" | "ability" | "trigger" | "static" | "replacement" | "cost";
+ label: string;
+ source_text?: string | null;
+ supported: boolean;
+ details?: [string, string][];
+ children?: ParsedItem[];
+}
+
+/** A single card's entry in coverage-data.json `cards[]`. */
+export interface CoverageEntry {
+ card_name: string;
+ set_code?: string;
+ oracle_text?: string | null;
+ supported: boolean;
+ gap_count: number;
+ gap_details?: { handler: string; source_text: string }[];
+ parse_details: ParsedItem[];
+ printings?: string[];
+}
+
+/** Build provenance from card-data-meta.json (shown in the embed footer). */
+export interface BuildMeta {
+ commit_short?: string;
+ mtgjson_date?: string;
+ data_hash?: string;
+ generated_at?: string;
+}
+
+interface BuildCache {
+ /** lowercased card name → JSON.stringify(CoverageEntry). */
+ byName: Map;
+ /** Display-cased names, sorted, for autocomplete. */
+ names: string[];
+ meta: BuildMeta | null;
+ etag: string | null;
+ lastAccess: number;
+ checkedAt: number;
+}
+
+/** How long a cached build serves before a background revalidation is kicked off. */
+const REFRESH_MS = 5 * 60 * 1000;
+/** Idle non-default builds are evicted after this long without access. */
+const IDLE_MS = 30 * 60 * 1000;
+
+const caches = new Map();
+const loading = new Map>();
+
+async function fetchMeta(build: Build): Promise {
+ try {
+ const res = await fetch(metaUrl(build), { headers: { "cache-control": "no-cache" } });
+ if (!res.ok) return null;
+ return (await res.json()) as BuildMeta;
+ } catch {
+ return null;
+ }
+}
+
+interface FetchedCoverage {
+ byName: Map;
+ names: string[];
+ etag: string | null;
+}
+
+/**
+ * Fetches and indexes coverage-data.json. With `prevEtag`, sends a conditional
+ * request; returns null on 304 (caller keeps its existing index).
+ */
+async function fetchCoverage(
+ build: Build,
+ prevEtag: string | null,
+): Promise {
+ const res = await fetch(coverageUrl(build), {
+ headers: prevEtag ? { "If-None-Match": prevEtag } : {},
+ });
+ if (res.status === 304) return null;
+ if (!res.ok) {
+ throw new Error(`coverage fetch ${build} → ${res.status} ${res.statusText}`);
+ }
+
+ const etag = res.headers.get("etag");
+ const json = (await res.json()) as { cards?: CoverageEntry[] };
+ const cards = json.cards ?? [];
+
+ const byName = new Map();
+ const names: string[] = [];
+ for (const entry of cards) {
+ if (!entry?.card_name) continue;
+ byName.set(entry.card_name.toLowerCase(), JSON.stringify(entry));
+ names.push(entry.card_name);
+ }
+ names.sort((a, b) => a.localeCompare(b));
+ return { byName, names, etag };
+}
+
+async function loadBuild(build: Build): Promise {
+ const [fetched, meta] = await Promise.all([fetchCoverage(build, null), fetchMeta(build)]);
+ // fetched is non-null here: no prevEtag means no 304 path.
+ const now = Date.now();
+ const cache: BuildCache = {
+ byName: fetched!.byName,
+ names: fetched!.names,
+ meta,
+ etag: fetched!.etag,
+ lastAccess: now,
+ checkedAt: now,
+ };
+ caches.set(build, cache);
+ console.log(`[coverage] loaded ${build}: ${cache.byName.size} cards`);
+ return cache;
+}
+
+/** Stale-while-revalidate: refresh an already-loaded build in the background. */
+async function revalidate(build: Build): Promise {
+ const cache = caches.get(build);
+ if (!cache) return;
+ cache.checkedAt = Date.now(); // mark attempt now so we don't stack revalidations
+ const [fetched, meta] = await Promise.all([
+ fetchCoverage(build, cache.etag),
+ fetchMeta(build),
+ ]);
+ if (fetched) {
+ cache.byName = fetched.byName;
+ cache.names = fetched.names;
+ cache.etag = fetched.etag;
+ }
+ if (meta) cache.meta = meta;
+}
+
+/**
+ * Returns a ready build cache. Serves a loaded build immediately (kicking off a
+ * background revalidation when stale); otherwise loads it, deduping concurrent
+ * callers so the 52MB fetch happens once.
+ */
+async function ensureBuild(build: Build): Promise {
+ const existing = caches.get(build);
+ if (existing) {
+ existing.lastAccess = Date.now();
+ if (Date.now() - existing.checkedAt > REFRESH_MS) {
+ void revalidate(build).catch(() => {});
+ }
+ return existing;
+ }
+
+ let pending = loading.get(build);
+ if (!pending) {
+ pending = loadBuild(build);
+ loading.set(build, pending);
+ void pending.finally(() => loading.delete(build));
+ }
+ return pending;
+}
+
+/** Looks up one card's parsed coverage entry, or null if unknown. */
+export async function lookupCard(
+ build: Build,
+ name: string,
+): Promise {
+ const cache = await ensureBuild(build);
+ const raw = cache.byName.get(name.toLowerCase());
+ return raw ? (JSON.parse(raw) as CoverageEntry) : null;
+}
+
+/** Returns the build's provenance manifest (may be null if unavailable). */
+export async function getMeta(build: Build): Promise {
+ const cache = await ensureBuild(build);
+ return cache.meta;
+}
+
+/** Autocomplete: up to `limit` card names matching `query` (prefix first). */
+export async function suggestNames(
+ build: Build,
+ query: string,
+ limit = 25,
+): Promise {
+ const cache = await ensureBuild(build);
+ const q = query.trim().toLowerCase();
+ if (!q) return cache.names.slice(0, limit);
+
+ const prefix: string[] = [];
+ const contains: string[] = [];
+ for (const name of cache.names) {
+ const lower = name.toLowerCase();
+ if (lower.startsWith(q)) prefix.push(name);
+ else if (lower.includes(q)) contains.push(name);
+ if (prefix.length >= limit) break;
+ }
+ return [...prefix, ...contains].slice(0, limit);
+}
+
+/** Pre-loads the default build so the common path never pays a cold-fetch. */
+export async function warmDefaultBuild(): Promise {
+ await ensureBuild(DEFAULT_BUILD);
+}
+
+/** Periodically drops idle non-default builds to bound memory. */
+export function startEvictionLoop(): ReturnType {
+ return setInterval(() => {
+ const now = Date.now();
+ for (const [build, cache] of caches) {
+ if (build === DEFAULT_BUILD) continue;
+ if (now - cache.lastAccess > IDLE_MS) caches.delete(build);
+ }
+ }, IDLE_MS);
+}
diff --git a/scripts/card-bot/discord.ts b/scripts/card-bot/discord.ts
new file mode 100644
index 0000000000..8e28cdffdb
--- /dev/null
+++ b/scripts/card-bot/discord.ts
@@ -0,0 +1,137 @@
+// Minimal Discord interactions plumbing, dependency-free (matches scripts/).
+// Ed25519 verification uses Bun's WebCrypto; REST uses fetch.
+
+const API = "https://discord.com/api/v10";
+
+/** Interaction request types (Discord `InteractionType`). */
+export const InteractionType = {
+ PING: 1,
+ APPLICATION_COMMAND: 2,
+ APPLICATION_COMMAND_AUTOCOMPLETE: 4,
+} as const;
+
+/** Interaction response types (Discord `InteractionResponseType`). */
+export const ResponseType = {
+ PONG: 1,
+ CHANNEL_MESSAGE_WITH_SOURCE: 4,
+ DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE: 5,
+ APPLICATION_COMMAND_AUTOCOMPLETE_RESULT: 8,
+} as const;
+
+/** Slash-command option types we use. */
+export const OptionType = {
+ STRING: 3,
+} as const;
+
+export interface InteractionOption {
+ name: string;
+ type: number;
+ value?: string | number | boolean;
+ focused?: boolean;
+}
+
+export interface Interaction {
+ type: number;
+ application_id: string;
+ token: string;
+ data?: {
+ name: string;
+ options?: InteractionOption[];
+ };
+}
+
+function hexToBytes(hex: string): Uint8Array {
+ const bytes = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < bytes.length; i++) {
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
+ }
+ return bytes;
+}
+
+const keyCache = new Map>();
+function importKey(publicKeyHex: string): Promise {
+ let key = keyCache.get(publicKeyHex);
+ if (!key) {
+ key = crypto.subtle.importKey(
+ "raw",
+ hexToBytes(publicKeyHex),
+ { name: "Ed25519" },
+ false,
+ ["verify"],
+ );
+ keyCache.set(publicKeyHex, key);
+ }
+ return key;
+}
+
+/**
+ * Verifies a Discord interaction request signature over `timestamp + rawBody`.
+ * Returns false on any malformed input — never throws.
+ */
+export async function verifyRequest(
+ publicKeyHex: string,
+ signatureHex: string | null,
+ timestamp: string | null,
+ rawBody: string,
+): Promise {
+ if (!signatureHex || !timestamp) return false;
+ try {
+ const key = await importKey(publicKeyHex);
+ const message = new TextEncoder().encode(timestamp + rawBody);
+ return await crypto.subtle.verify(
+ { name: "Ed25519" },
+ key,
+ hexToBytes(signatureHex),
+ message,
+ );
+ } catch {
+ return false;
+ }
+}
+
+/** Bulk-overwrites the guild's command set (instant propagation). */
+export async function registerGuildCommand(
+ appId: string,
+ guildId: string,
+ token: string,
+ command: unknown,
+): Promise {
+ const res = await fetch(`${API}/applications/${appId}/guilds/${guildId}/commands`, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bot ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify([command]),
+ });
+ if (!res.ok) {
+ throw new Error(`registerGuildCommand → ${res.status}: ${await res.text()}`);
+ }
+}
+
+/** Edits the original (deferred) interaction response with the final content. */
+export async function editOriginalResponse(
+ appId: string,
+ interactionToken: string,
+ body: unknown,
+): Promise {
+ const url = `${API}/webhooks/${appId}/${interactionToken}/messages/@original`;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const res = await fetch(url, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(10000),
+ });
+ if (res.status === 429) {
+ const { retry_after } = (await res.json()) as { retry_after: number };
+ await Bun.sleep(Math.ceil(retry_after * 1000) + 100);
+ continue;
+ }
+ if (!res.ok) {
+ throw new Error(`editOriginalResponse → ${res.status}: ${await res.text()}`);
+ }
+ return;
+ }
+ throw new Error("editOriginalResponse → exhausted retries");
+}
diff --git a/scripts/card-bot/index.ts b/scripts/card-bot/index.ts
new file mode 100644
index 0000000000..b0fd11b13e
--- /dev/null
+++ b/scripts/card-bot/index.ts
@@ -0,0 +1,160 @@
+// Discord HTTP-interactions server for the /card parse-breakdown bot.
+//
+// Flow: Discord POSTs each interaction here (behind nginx on 127.0.0.1). We
+// verify the Ed25519 signature, then:
+// • PING → PONG
+// • /card → defer, then follow up with the parse embed
+// • autocomplete → name suggestions from the (warm) default build
+//
+// Deferring the command guarantees we never hit Discord's 3s response window,
+// even on a cold preview load or a slow Scryfall call.
+
+import {
+ DEFAULT_BUILD,
+ PORT,
+ discord,
+ isBuild,
+ type Build,
+} from "./config";
+import {
+ getMeta,
+ lookupCard,
+ startEvictionLoop,
+ suggestNames,
+ warmDefaultBuild,
+} from "./coverageData";
+import {
+ type Interaction,
+ type InteractionOption,
+ InteractionType,
+ ResponseType,
+ editOriginalResponse,
+ verifyRequest,
+} from "./discord";
+import { renderCardEmbed, renderNotFound } from "./render";
+import { lookupScryfall, warmScryfall } from "./scryfall";
+
+const PUBLIC_KEY = discord.publicKey();
+
+function json(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+function optionValue(options: InteractionOption[] | undefined, name: string): string | undefined {
+ const opt = options?.find((o) => o.name === name);
+ return typeof opt?.value === "string" ? opt.value : undefined;
+}
+
+function resolveBuild(raw: string | undefined): Build {
+ return raw && isBuild(raw) ? raw : DEFAULT_BUILD;
+}
+
+/** Builds and delivers the parse embed for a deferred /card interaction. */
+async function deliverCard(interaction: Interaction): Promise {
+ const options = interaction.data?.options;
+ const name = optionValue(options, "name")?.trim() ?? "";
+ const build = resolveBuild(optionValue(options, "build"));
+
+ const t0 = performance.now();
+ try {
+ const entry = await lookupCard(build, name);
+ const t1 = performance.now();
+ if (!entry) {
+ await editOriginalResponse(interaction.application_id, interaction.token, {
+ embeds: [renderNotFound(name, build)],
+ });
+ return;
+ }
+
+ const [scry, meta] = await Promise.all([
+ lookupScryfall(entry.card_name),
+ getMeta(build),
+ ]);
+ const tScry = performance.now();
+ await editOriginalResponse(interaction.application_id, interaction.token, {
+ embeds: [renderCardEmbed(entry, scry, build, meta)],
+ });
+ const t2 = performance.now();
+ console.log(
+ `[card] ${name} (${build}): lookup=${Math.round(t1 - t0)}ms scry+meta=${Math.round(tScry - t1)}ms send=${Math.round(t2 - tScry)}ms total=${Math.round(t2 - t0)}ms`,
+ );
+ } catch (err) {
+ console.error(`deliverCard(${name}, ${build}) failed:`, err);
+ await editOriginalResponse(interaction.application_id, interaction.token, {
+ content: `Something went wrong looking up **${name}**. Try again in a moment.`,
+ }).catch(() => {});
+ }
+}
+
+/** Suggestions only begin once this many characters are typed. */
+const MIN_AUTOCOMPLETE_CHARS = 2;
+
+/** Synchronous autocomplete: suggest names from the warm default build. */
+async function autocomplete(interaction: Interaction): Promise {
+ const focused = interaction.data?.options?.find((o) => o.focused);
+ const query = typeof focused?.value === "string" ? focused.value : "";
+ // Hold off until a couple of characters are typed — the lookup is in-memory
+ // and cheap, but 0–1 chars just returns arbitrary names, not useful matches.
+ const choices =
+ query.trim().length < MIN_AUTOCOMPLETE_CHARS
+ ? []
+ : (await suggestNames(DEFAULT_BUILD, query)).map((n) => ({ name: n, value: n }));
+ return json({
+ type: ResponseType.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT,
+ data: { choices },
+ });
+}
+
+async function handleInteraction(req: Request): Promise {
+ const signature = req.headers.get("X-Signature-Ed25519");
+ const timestamp = req.headers.get("X-Signature-Timestamp");
+ const rawBody = await req.text();
+
+ if (!(await verifyRequest(PUBLIC_KEY, signature, timestamp, rawBody))) {
+ return new Response("invalid request signature", { status: 401 });
+ }
+
+ const interaction = JSON.parse(rawBody) as Interaction;
+
+ if (interaction.type === InteractionType.PING) {
+ return json({ type: ResponseType.PONG });
+ }
+
+ if (interaction.type === InteractionType.APPLICATION_COMMAND_AUTOCOMPLETE) {
+ return autocomplete(interaction);
+ }
+
+ if (interaction.type === InteractionType.APPLICATION_COMMAND) {
+ // Defer immediately; the follow-up edit carries the embed.
+ void deliverCard(interaction);
+ return json({ type: ResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE });
+ }
+
+ return json({ error: "unsupported interaction type" }, 400);
+}
+
+// Warm the default build in the BACKGROUND, and start serving immediately so a
+// restart has no closed-port window. A query that lands mid-warm dedupes onto
+// the in-flight load (the deferred response covers the wait) instead of failing.
+void warmDefaultBuild().catch((err) => console.error("warm-up failed:", err));
+void warmScryfall().catch((err) => console.error("scryfall warm-up failed:", err));
+startEvictionLoop();
+
+Bun.serve({
+ port: PORT,
+ async fetch(req) {
+ const { pathname } = new URL(req.url);
+ if (req.method === "GET" && pathname === "/health") {
+ return new Response("ok");
+ }
+ if (req.method === "POST") {
+ return handleInteraction(req);
+ }
+ return new Response("not found", { status: 404 });
+ },
+});
+
+console.log(`card-bot listening on :${PORT} (default build: ${DEFAULT_BUILD})`);
diff --git a/scripts/card-bot/manaEmoji.json b/scripts/card-bot/manaEmoji.json
new file mode 100644
index 0000000000..1e84b23abc
--- /dev/null
+++ b/scripts/card-bot/manaEmoji.json
@@ -0,0 +1,84 @@
+{
+ "{T}": "<:manat:1508554369619071106>",
+ "{Q}": "<:manaq:1508554371993047202>",
+ "{E}": "<:manae:1508554374245253241>",
+ "{P}": "<:manap:1508554376317370509>",
+ "{PW}": "<:manapw:1508554378427109509>",
+ "{CHAOS}": "<:manachaos:1508554380620726304>",
+ "{A}": "<:manaa:1508554382923534530>",
+ "{TK}": "<:manatk:1508554385825992934>",
+ "{X}": "<:manax:1508554387910295722>",
+ "{Y}": "<:manay:1508554390024355980>",
+ "{Z}": "<:manaz:1508554392373035028>",
+ "{0}": "<:mana0:1508554394566791370>",
+ "{1}": "<:mana1:1508554396303228989>",
+ "{2}": "<:mana2:1508554398920474775>",
+ "{3}": "<:mana3:1508554401445445644>",
+ "{4}": "<:mana4:1508554404041850991>",
+ "{5}": "<:mana5:1508554406138871948>",
+ "{6}": "<:mana6:1508554408340754493>",
+ "{7}": "<:mana7:1508554410765320324>",
+ "{8}": "<:mana8:1508554413206274209>",
+ "{9}": "<:mana9:1508554415328723096>",
+ "{10}": "<:mana10:1508554417622880267>",
+ "{11}": "<:mana11:1508554419787010120>",
+ "{12}": "<:mana12:1508554422123364473>",
+ "{13}": "<:mana13:1508554424375836934>",
+ "{14}": "<:mana14:1508554426426720269>",
+ "{15}": "<:mana15:1508554428700168385>",
+ "{16}": "<:mana16:1508554430826676344>",
+ "{17}": "<:mana17:1508554433007714437>",
+ "{18}": "<:mana18:1508554435360592134>",
+ "{19}": "<:mana19:1508554437600215243>",
+ "{20}": "<:mana20:1508554439588315320>",
+ "{100}": "<:mana100:1508554441840660674>",
+ "{1000000}": "<:mana1000000:1508554443816308889>",
+ "{W/U}": "<:manawu:1508554446622167124>",
+ "{W/B}": "<:manawb:1508554449281355826>",
+ "{B/R}": "<:manabr:1508554451613646890>",
+ "{B/G}": "<:manabg:1508554453589033003>",
+ "{U/B}": "<:manaub:1508554456500011008>",
+ "{U/R}": "<:manaur:1508554458341052618>",
+ "{R/G}": "<:manarg:1508554460836663468>",
+ "{R/W}": "<:manarw:1508554462862512330>",
+ "{G/W}": "<:managw:1508554465941258241>",
+ "{G/U}": "<:managu:1508554468441198602>",
+ "{B/G/P}": "<:manabgp:1508554470647398531>",
+ "{B/R/P}": "<:manabrp:1508554472744423474>",
+ "{G/U/P}": "<:managup:1508554474988376105>",
+ "{G/W/P}": "<:managwp:1508554477085392927>",
+ "{R/G/P}": "<:manargp:1508554478977159393>",
+ "{R/W/P}": "<:manarwp:1508554481493606560>",
+ "{U/B/P}": "<:manaubp:1508554483716853880>",
+ "{U/R/P}": "<:manaurp:1508554485704687728>",
+ "{W/B/P}": "<:manawbp:1508554488112484503>",
+ "{W/U/P}": "<:manawup:1508554490532597831>",
+ "{C/W}": "<:manacw:1508554492801450125>",
+ "{C/U}": "<:manacu:1508554494945005618>",
+ "{C/B}": "<:manacb:1508554496899420403>",
+ "{C/R}": "<:manacr:1508554499160150141>",
+ "{C/G}": "<:manacg:1508554501601104043>",
+ "{2/W}": "<:mana2w:1508554503643987979>",
+ "{2/U}": "<:mana2u:1508554505619378187>",
+ "{2/B}": "<:mana2b:1508554508106600478>",
+ "{2/R}": "<:mana2r:1508554510409269270>",
+ "{2/G}": "<:mana2g:1508554512690843872>",
+ "{H}": "<:manah:1508554514905567303>",
+ "{W/P}": "<:manawp:1508554517149646888>",
+ "{U/P}": "<:manaup:1508554518907064483>",
+ "{B/P}": "<:manabp:1508554521591283783>",
+ "{R/P}": "<:manarp:1508554523453686013>",
+ "{G/P}": "<:managp:1508554526003564544>",
+ "{C/P}": "<:manacp:1508554528092328047>",
+ "{HW}": "<:manahw:1508554530294468708>",
+ "{HR}": "<:manahr:1508554532538548376>",
+ "{W}": "<:manaw:1508554534690230436>",
+ "{U}": "<:manau:1508554537047162890>",
+ "{B}": "<:manab:1508554539089793196>",
+ "{R}": "<:manar:1508554541799440424>",
+ "{G}": "<:manag:1508554544169357492>",
+ "{C}": "<:manac:1508554546178293981>",
+ "{S}": "<:manas:1508554548506263783>",
+ "{L}": "<:manal:1508554550691238010>",
+ "{D}": "<:manad:1508554553081987143>"
+}
diff --git a/scripts/card-bot/register.ts b/scripts/card-bot/register.ts
new file mode 100644
index 0000000000..6489511c35
--- /dev/null
+++ b/scripts/card-bot/register.ts
@@ -0,0 +1,34 @@
+// One-time (re)registration of the /card guild command. Run after changing the
+// command shape: `bun scripts/card-bot/register.ts`.
+//
+// Guild-scoped → instant propagation on the single community server.
+
+import { discord } from "./config";
+import { OptionType, registerGuildCommand } from "./discord";
+
+const command = {
+ name: "card",
+ description: "Show how the phase.rs engine parses a card",
+ options: [
+ {
+ type: OptionType.STRING,
+ name: "name",
+ description: "Card name",
+ required: true,
+ autocomplete: true,
+ },
+ {
+ type: OptionType.STRING,
+ name: "build",
+ description: "Which build's parse data to read (default: preview)",
+ required: false,
+ choices: [
+ { name: "preview", value: "preview" },
+ { name: "release", value: "release" },
+ ],
+ },
+ ],
+};
+
+await registerGuildCommand(discord.appId(), discord.guildId(), discord.token(), command);
+console.log("Registered /card guild command.");
diff --git a/scripts/card-bot/render.ts b/scripts/card-bot/render.ts
new file mode 100644
index 0000000000..c182e91cd0
--- /dev/null
+++ b/scripts/card-bot/render.ts
@@ -0,0 +1,236 @@
+// Renders a card's engine parse into a Discord embed that echoes the frontend's
+// Alt-hover "ENGINE PARSE" overlay (client/src/components/card/CardPreview.tsx).
+//
+// Markdown layout (not a code block): the card's oracle text shows once as a `>`
+// blockquote, then a compact one-line-per-item tree. Color comes from emoji —
+// category squares (overlay palette), mana pips, the coverage meter — because
+// Discord has no theme-adaptive text color (only ANSI, which is theme-fragile).
+
+import type { Build } from "./config";
+import type { BuildMeta, CoverageEntry, ParsedItem } from "./coverageData";
+import manaEmojiRaw from "./manaEmoji.json";
+import type { ScryfallCard } from "./scryfall";
+
+// symbol ("{R}") → custom application-emoji markup ("<:manar:id>"). Populated by
+// upload-emoji.ts; empty until then, in which case mana falls back to unicode.
+const MANA_EMOJI = manaEmojiRaw as Record;
+
+// Category → abbreviation + a colored square matching the overlay palette
+// (violet/sky/amber/teal/orange/rose). Unicode emoji render in color in any
+// theme — unlike ANSI or custom <:emoji:> (which don't render in markdown spots).
+const CATEGORY: Record = {
+ keyword: { abbr: "KW", square: "🟪" },
+ ability: { abbr: "EFF", square: "🟦" },
+ trigger: { abbr: "TRG", square: "🟨" },
+ static: { abbr: "STC", square: "🟩" },
+ replacement: { abbr: "RPL", square: "🟧" },
+ cost: { abbr: "CST", square: "🟥" },
+};
+
+// Emerald / amber, matching the overlay's support colors.
+const COLOR_OK = 0x10b981;
+const COLOR_GAPS = 0xf59e0b;
+
+const DESCRIPTION_MAX = 4096;
+const DETAIL_VALUE_MAX = 120;
+const SOURCE_TEXT_MAX = 160;
+const ORACLE_MAX = 600;
+const EM = "\u2003"; // em space (regular spaces collapse in markdown)
+
+const truncate = (s: string, max: number) =>
+ s.length > max ? `${s.slice(0, max - 1)}…` : s;
+
+// MTG mana → emoji pips. `replace` (not match+join) preserves separators like the
+// " // " between split/adventure faces. Generic costs stay plain digits; hybrid /
+// Phyrexian / X fall back to {…} when not uploaded.
+const COLOR_PIP: Record = {
+ W: "⚪",
+ U: "🔵",
+ B: "⚫",
+ R: "🔴",
+ G: "🟢",
+ C: "◇",
+ S: "❄️",
+};
+
+// Replaces every `{…}` symbol token in arbitrary text with its emoji — works on
+// mana costs, oracle text ("{T}: Add {G}"), and quoted fragments alike. Safe to
+// run anywhere markdown emoji render (description, `>` blockquotes), but NOT
+// inside inline-code/```pre``` spans, where Discord shows the literal markup.
+function symbolize(text: string): string {
+ return text.replace(/\{[^}]+\}/g, (t) => {
+ if (MANA_EMOJI[t]) return MANA_EMOJI[t]; // true pip (incl. tap/hybrid/Phyrexian)
+ const sym = t.slice(1, -1);
+ if (COLOR_PIP[sym]) return COLOR_PIP[sym]; // unicode fallback
+ if (/^\d+$/.test(sym)) return sym;
+ return t;
+ });
+}
+
+const formatMana = (cost: string | null): string | null => (cost ? symbolize(cost) : null);
+
+// A 10-segment coverage bar echoing the Alt overlay's emerald/amber meter.
+function supportMeter(supported: number, total: number): string {
+ const segs = 10;
+ const filled =
+ total === 0 ? segs : Math.max(0, Math.min(segs, Math.round((supported / total) * segs)));
+ return "🟩".repeat(filled) + "🟧".repeat(segs - filled);
+}
+
+interface Counts {
+ supported: number;
+ total: number;
+}
+
+/** Recursively counts supported vs total nodes (the overlay's "3/5" fraction). */
+function countItems(items: ParsedItem[], acc: Counts = { supported: 0, total: 0 }): Counts {
+ for (const item of items) {
+ acc.total += 1;
+ if (item.supported) acc.supported += 1;
+ if (item.children?.length) countItems(item.children, acc);
+ }
+ return acc;
+}
+
+/** The card's oracle text as a `>` blockquote, with `{…}` symbols as emoji. */
+function oracleQuote(text: string): string {
+ return symbolize(truncate(text.trim(), ORACLE_MAX))
+ .split("\n")
+ .map((l) => `> ${l}`)
+ .join("\n");
+}
+
+/** One compact markdown line per parse item, em-space indented by depth. */
+function treeLines(items: ParsedItem[], depth = 0, out: string[] = []): string[] {
+ for (const item of items) {
+ const indent = EM.repeat(depth);
+ const cat = CATEGORY[item.category];
+ if (item.supported) {
+ let line = `${indent}${cat.square} **${cat.abbr}** ${symbolize(item.label)}`;
+ if (item.details?.length) {
+ // Detail values stay in inline-code chips — emoji can't render there, so
+ // these keep the raw `{…}` text (intentional: code is the "verbatim" lane).
+ const detail = item.details
+ .map(([k, v]) => `${k} \`${truncate(v, DETAIL_VALUE_MAX)}\``)
+ .join(" · ");
+ line += ` · ${detail}`;
+ }
+ out.push(line);
+ } else {
+ // Unsupported lines read clearly as "engine couldn't handle this": a
+ // leading ❌ (distinct from the category squares) and a struck-through
+ // label, with the unparsed Oracle fragment quoted (symbols → emoji).
+ let line = `${indent}❌ **${cat.abbr}** ~~${symbolize(item.label)}~~`;
+ if (item.source_text)
+ line += ` — “${symbolize(truncate(item.source_text, SOURCE_TEXT_MAX))}”`;
+ out.push(line);
+ }
+ if (item.children?.length) treeLines(item.children, depth + 1, out);
+ }
+ return out;
+}
+
+/** Joins tree lines within the description budget, noting any dropped tail. */
+function fitLines(lines: string[], budget: number): string {
+ const kept: string[] = [];
+ let used = 0;
+ let dropped = 0;
+ for (const line of lines) {
+ if (used + line.length + 1 > budget) {
+ dropped = lines.length - kept.length;
+ break;
+ }
+ kept.push(line);
+ used += line.length + 1;
+ }
+ let body = kept.join("\n");
+ if (dropped > 0) body += `\n-# … ${dropped} more line(s) — see in-app`;
+ return body;
+}
+
+/**
+ * The header block, top of the description:
+ * line 1 — **Card Name** (linked to Scryfall) · mana cost
+ * line 2 — **Type line**
+ * then — oracle text as a `>` blockquote
+ *
+ * The name lives here (not the embed `title`) so the mana emoji can sit beside
+ * it — custom emoji don't render in an embed title, only in description text.
+ */
+function cardHeader(entry: CoverageEntry, scry: ScryfallCard | null): string {
+ const link = scry?.scryfallUri;
+ const name = `**${entry.card_name}**`;
+ const mana = formatMana(scry?.manaCost ?? null);
+
+ const lines: string[] = [];
+ lines.push(mana ? `${link ? `[${name}](${link})` : name} ${mana}` : link ? `[${name}](${link})` : name);
+ if (scry?.typeLine) lines.push(`**${scry.typeLine}**`);
+ if (entry.oracle_text) lines.push(oracleQuote(entry.oracle_text));
+ return lines.join("\n");
+}
+
+function footerText(build: Build, meta: BuildMeta | null): string {
+ const bits = [build.toUpperCase()];
+ if (meta?.commit_short) bits.push(meta.commit_short);
+ if (meta?.mtgjson_date) bits.push(`MTGJSON ${meta.mtgjson_date}`);
+ return bits.join(" · ");
+}
+
+/** A Discord embed object (the subset we populate). */
+export interface Embed {
+ author?: { name: string };
+ title?: string;
+ url?: string;
+ description?: string;
+ color?: number;
+ thumbnail?: { url: string };
+ footer?: { text: string };
+}
+
+/** Builds the parse-breakdown embed for a found card. */
+export function renderCardEmbed(
+ entry: CoverageEntry,
+ scry: ScryfallCard | null,
+ build: Build,
+ meta: BuildMeta | null,
+): Embed {
+ const counts = countItems(entry.parse_details);
+ const ok = entry.supported;
+
+ const meter = supportMeter(counts.supported, counts.total);
+ const summary = ok
+ ? `${meter} ${counts.supported}/${counts.total} · fully supported`
+ : `${meter} ${counts.supported}/${counts.total} · ${entry.gap_count} gap${entry.gap_count === 1 ? "" : "s"}`;
+
+ // Header (name · mana / type / oracle) sits up top; the coverage meter moves
+ // down to introduce the parse tree it summarizes.
+ const head = cardHeader(entry, scry);
+
+ let description: string;
+ if (entry.parse_details.length === 0) {
+ description = `${head}\n\n${summary}\n\n*Vanilla — no parsed abilities*`;
+ } else {
+ const budget = DESCRIPTION_MAX - head.length - summary.length - 4; // "\n\n" + "\n\n"
+ description = `${head}\n\n${summary}\n\n${fitLines(treeLines(entry.parse_details), budget)}`;
+ }
+
+ return {
+ author: { name: "ENGINE PARSE" },
+ description,
+ color: ok ? COLOR_OK : COLOR_GAPS,
+ // Top-right thumbnail: compact beside the parse, click/tap to expand to the
+ // full card. Discord auto-reflows it on mobile.
+ thumbnail: scry?.image ? { url: scry.image } : undefined,
+ footer: { text: footerText(build, meta) },
+ };
+}
+
+/** Builds the "card not found" embed. */
+export function renderNotFound(name: string, build: Build): Embed {
+ return {
+ author: { name: "ENGINE PARSE" },
+ title: name,
+ description: `No card named **${name}** in the \`${build}\` build's parse data. Check the spelling, or pick a suggestion from autocomplete.`,
+ color: COLOR_GAPS,
+ };
+}
diff --git a/scripts/card-bot/scryfall.ts b/scripts/card-bot/scryfall.ts
new file mode 100644
index 0000000000..43615be696
--- /dev/null
+++ b/scripts/card-bot/scryfall.ts
@@ -0,0 +1,119 @@
+// Card image + header info, sourced exactly like the app (services/scryfall.ts):
+// load scryfall-data.json from R2, cache it, and do pure in-memory lookups. No
+// live Scryfall API — no per-card network call, no rate limits, no stalls.
+//
+// The stable manifest path is overwritten in place each deploy, so we keep the
+// cache fresh with the same ETag stale-while-revalidate the coverage cache uses
+// (hourly — Scryfall bulk data only changes on set releases).
+
+import { scryfallDataUrl } from "./config";
+
+interface RawEntry {
+ oracle_id: string;
+ faces?: Array<{ normal: string; art_crop: string }>;
+ name: string;
+ mana_cost?: string;
+ type_line?: string;
+}
+
+export interface ScryfallCard {
+ /** Canonical display name (with " // " for multi-face cards). */
+ name: string;
+ /** Normal-size front-face image, or null. */
+ image: string | null;
+ /** e.g. "Creature — Bear"; null if unavailable. */
+ typeLine: string | null;
+ /** e.g. "{1}{G}" — includes " // " between split/adventure faces. */
+ manaCost: string | null;
+ /** Scryfall page link (by oracle id). */
+ scryfallUri: string | null;
+}
+
+const REFRESH_MS = 60 * 60 * 1000;
+
+interface Cache {
+ map: Map;
+ etag: string | null;
+ checkedAt: number;
+}
+
+let cache: Cache | null = null;
+let loading: Promise | null = null;
+
+function compact(e: RawEntry): ScryfallCard {
+ return {
+ name: e.name,
+ image: e.faces?.[0]?.normal ?? null,
+ typeLine: e.type_line ?? null,
+ manaCost: e.mana_cost ?? null,
+ scryfallUri: e.oracle_id
+ ? `https://scryfall.com/search?q=oracleid%3A${e.oracle_id}`
+ : null,
+ };
+}
+
+type Fetched = { map: Map; etag: string | null };
+
+/** Fetches + indexes the export. Returns "unchanged" on 304, null on error. */
+async function fetchCards(prevEtag: string | null): Promise {
+ try {
+ const res = await fetch(scryfallDataUrl(), {
+ headers: prevEtag ? { "If-None-Match": prevEtag } : {},
+ signal: AbortSignal.timeout(30000),
+ });
+ if (res.status === 304) return "unchanged";
+ if (!res.ok) return null;
+ const etag = res.headers.get("etag");
+ const raw = (await res.json()) as Record;
+ const map = new Map();
+ for (const [key, entry] of Object.entries(raw)) map.set(key, compact(entry));
+ console.log(`[scryfall] loaded ${map.size} entries`);
+ return { map, etag };
+ } catch (err) {
+ console.error("[scryfall] fetch failed:", err);
+ return null;
+ }
+}
+
+async function loadCache(): Promise {
+ const fetched = await fetchCards(null);
+ if (fetched && fetched !== "unchanged") {
+ cache = { map: fetched.map, etag: fetched.etag, checkedAt: Date.now() };
+ }
+ return cache;
+}
+
+/** Stale-while-revalidate: refresh the cache in the background when stale. */
+async function revalidate(): Promise {
+ if (!cache) return;
+ cache.checkedAt = Date.now();
+ const fetched = await fetchCards(cache.etag);
+ if (fetched && fetched !== "unchanged") {
+ cache.map = fetched.map;
+ cache.etag = fetched.etag;
+ }
+}
+
+async function ensure(): Promise {
+ if (cache) {
+ if (Date.now() - cache.checkedAt > REFRESH_MS) void revalidate().catch(() => {});
+ return cache;
+ }
+ if (!loading) {
+ loading = loadCache().finally(() => {
+ loading = null;
+ });
+ }
+ return loading;
+}
+
+/** Looks up a card's image + header from the cached Scryfall export. */
+export async function lookupScryfall(name: string): Promise {
+ const c = await ensure();
+ return c?.map.get(name.toLowerCase()) ?? null;
+}
+
+/** Pre-loads the Scryfall export so the first query is instant. */
+export async function warmScryfall(): Promise {
+ await ensure();
+}
diff --git a/scripts/card-bot/smoke.ts b/scripts/card-bot/smoke.ts
new file mode 100644
index 0000000000..104edaa1a8
--- /dev/null
+++ b/scripts/card-bot/smoke.ts
@@ -0,0 +1,33 @@
+// Local render smoke test — no Discord credentials needed.
+// bun scripts/card-bot/smoke.ts "Lightning Bolt"
+// bun scripts/card-bot/smoke.ts "Snapcaster Mage" path/to/coverage-data.json
+//
+// Loads the local coverage export, renders the embed for a card, and prints the
+// description with live ANSI so the terminal shows the same colors Discord will.
+
+import type { CoverageEntry } from "./coverageData";
+import { renderCardEmbed } from "./render";
+import { lookupScryfall } from "./scryfall";
+
+const name = process.argv[2] ?? "Lightning Bolt";
+const file = process.argv[3] ?? "client/public/coverage-data.json";
+
+const data = JSON.parse(await Bun.file(file).text()) as { cards: CoverageEntry[] };
+const entry = data.cards.find(
+ (c) => c.card_name?.toLowerCase() === name.toLowerCase(),
+);
+if (!entry) {
+ console.error(`"${name}" not found in ${file}`);
+ process.exit(1);
+}
+
+const scry = await lookupScryfall(entry.card_name);
+const embed = renderCardEmbed(entry, scry, "release", {
+ commit_short: "abc1234",
+ mtgjson_date: "2026-04-18",
+});
+
+console.log("=== embed (description elided) ===");
+console.log(JSON.stringify({ ...embed, description: "‹see below›" }, null, 2));
+console.log("\n=== description, ANSI live ===\n");
+console.log(embed.description);
diff --git a/scripts/card-bot/upload-emoji.ts b/scripts/card-bot/upload-emoji.ts
new file mode 100644
index 0000000000..1e91d56eb4
--- /dev/null
+++ b/scripts/card-bot/upload-emoji.ts
@@ -0,0 +1,91 @@
+// Bulk-creates application-owned emoji for MTG mana symbols, then writes the
+// symbol → emoji-markup map the renderer uses. Run once after rasterizing PNGs
+// (see the tmp/mana-emoji fetch step):
+// bun scripts/card-bot/upload-emoji.ts [dir=tmp/mana-emoji]
+//
+// Needs CARD_BOT_TOKEN (the bot token) — Bun auto-loads it from .env. Idempotent:
+// re-running reuses emoji already created on the app.
+//
+// Application emoji (up to 2000/app) work in any server the bot is in and don't
+// consume per-server emoji slots. Their IDs are stable, so the generated
+// manaEmoji.json is committed + baked into the image — the runtime container
+// stays secretless (it never needs the token to render pips).
+
+import { discord } from "./config";
+
+const API = "https://discord.com/api/v10";
+const DIR = process.argv[2] ?? "tmp/mana-emoji";
+const OUT_MAP = "scripts/card-bot/manaEmoji.json";
+const APP_ID = discord.appId();
+const TOKEN = discord.token();
+
+interface AppEmoji {
+ id: string;
+ name: string;
+}
+
+async function api(method: string, path: string, body?: unknown): Promise {
+ for (;;) {
+ const res = await fetch(`${API}${path}`, {
+ method,
+ headers: {
+ Authorization: `Bot ${TOKEN}`,
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
+ },
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ });
+ if (res.status === 429) {
+ const { retry_after } = (await res.json()) as { retry_after: number };
+ await Bun.sleep(Math.ceil(retry_after * 1000) + 100);
+ continue;
+ }
+ if (!res.ok) {
+ throw new Error(`${method} ${path} → ${res.status}: ${await res.text()}`);
+ }
+ return (res.status === 204 ? null : await res.json()) as T;
+ }
+}
+
+async function listExisting(): Promise> {
+ const body = await api<{ items: AppEmoji[] }>("GET", `/applications/${APP_ID}/emojis`);
+ const map = new Map();
+ for (const e of body.items ?? []) map.set(e.name, e.id);
+ return map;
+}
+
+async function dataUri(path: string): Promise {
+ const bytes = new Uint8Array(await Bun.file(path).arrayBuffer());
+ return `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`;
+}
+
+const symbolMap = (await Bun.file(`${DIR}/_symbol-map.json`).json()) as Record;
+const existing = await listExisting();
+
+const result: Record = {};
+let created = 0;
+let reused = 0;
+
+for (const [symbol, name] of Object.entries(symbolMap)) {
+ let id = existing.get(name);
+ if (id) {
+ reused += 1;
+ } else {
+ const png = `${DIR}/${name}.png`;
+ if (!(await Bun.file(png).exists())) {
+ console.warn(`missing PNG for ${symbol} (${name}) — skipping`);
+ continue;
+ }
+ const emoji = await api("POST", `/applications/${APP_ID}/emojis`, {
+ name,
+ image: await dataUri(png),
+ });
+ id = emoji.id;
+ created += 1;
+ await Bun.sleep(300); // gentle with the create rate limit
+ }
+ result[symbol] = `<:${name}:${id}>`;
+}
+
+await Bun.write(OUT_MAP, `${JSON.stringify(result, null, 2)}\n`);
+console.log(`Application emoji: ${created} created, ${reused} reused.`);
+console.log(`Wrote ${Object.keys(result).length} mappings → ${OUT_MAP}`);