diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts
index 48cbb35e4d..9983fe29f6 100644
--- a/client/src/adapter/types.ts
+++ b/client/src/adapter/types.ts
@@ -2137,6 +2137,7 @@ export type DebugAction =
attach_to?: AttachTarget;
run_etb: boolean;
nonlegendary: boolean;
+ count: number;
};
}
| { type: "RemoveObject"; data: { object_id: ObjectId } }
@@ -2169,11 +2170,12 @@ export type DebugAction =
data: {
request: DebugTokenRequest;
run_etb: boolean;
+ count: number;
};
}
| {
type: "CreateTokenCopy";
- data: { source_id: ObjectId; owner: PlayerId; nonlegendary: boolean };
+ data: { source_id: ObjectId; owner: PlayerId; nonlegendary: boolean; count: number };
};
// CR 117.3d: priority-yield preference types, mirroring the engine's
@@ -2462,6 +2464,18 @@ export type GameEvent =
| { type: "PermanentSacrificed"; data: { object_id: ObjectId; player_id: PlayerId } }
| { type: "ArmyAmassed"; data: { object_id: ObjectId; source_id: ObjectId; controller: PlayerId } }
| { type: "EffectResolved"; data: { kind: string; source_id: ObjectId } }
+ // CR 701.22a: the engine records only public scry placement counts, never
+ // card identities, so presentation can show the completed outcome safely.
+ | {
+ type: "PlayerPerformedAction";
+ data: {
+ player_id: PlayerId;
+ action: string;
+ look_count?: number;
+ scry_bottom_count?: number;
+ scry_top_count?: number;
+ };
+ }
| { type: "AttackersDeclared"; data: { attacker_ids: ObjectId[]; defending_player: PlayerId; attacks?: [ObjectId, AttackTarget][] } }
| { type: "BlockersDeclared"; data: { assignments: [ObjectId, ObjectId][] } }
| { type: "BecomesTarget"; data: { target: TargetRef; source_id: ObjectId } }
diff --git a/client/src/adapter/ws-adapter.ts b/client/src/adapter/ws-adapter.ts
index 68f85d690e..7ea6557644 100644
--- a/client/src/adapter/ws-adapter.ts
+++ b/client/src/adapter/ws-adapter.ts
@@ -202,6 +202,9 @@ export class NativeEngineVersionMismatchError extends Error {
* `crates/server-core/src/protocol.rs`. Bump in lockstep when either side
* adds, removes, renames, or changes the type of a protocol variant field.
*
+ * 25 — DebugCardEntries added a serialized, private resolution frame for
+ * multi-card sandbox battlefield entries that pause for replacement or
+ * as-enters choices. Old peers cannot deserialize that GameState shape.
* 24 — DerivedViews.unbounded_families carries the engine-owned per-seat family
* collapse state behind each ∞ badge. A CAPABILITY bump, not a parse bump:
* the field is serde-optional, but this client deleted its row-flag
@@ -228,7 +231,7 @@ export class NativeEngineVersionMismatchError extends Error {
* into a MulliganDecisionPhase::BottomCards sub-phase on
* WaitingFor::MulliganDecision.
*/
-export const PROTOCOL_VERSION = 24;
+export const PROTOCOL_VERSION = 25;
/**
* Lowest server protocol version this client will accept in the handshake.
diff --git a/client/src/components/animation/ScryOutcomeOverlay.tsx b/client/src/components/animation/ScryOutcomeOverlay.tsx
new file mode 100644
index 0000000000..ee82423adc
--- /dev/null
+++ b/client/src/components/animation/ScryOutcomeOverlay.tsx
@@ -0,0 +1,52 @@
+import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
+import { useTranslation } from "react-i18next";
+
+import { usePlayerId } from "../../hooks/usePlayerId.ts";
+import { getOpponentDisplayName } from "../../stores/multiplayerStore.ts";
+import { useUiStore } from "../../stores/uiStore.ts";
+
+/**
+ * Brief, board-visible confirmation of a completed scry. The engine event
+ * supplies the public placement counts; this component only presents them.
+ */
+export function ScryOutcomeOverlay() {
+ const outcome = useUiStore((state) => state.scryOutcome);
+ const playerId = usePlayerId();
+ const shouldReduceMotion = useReducedMotion();
+ const { t } = useTranslation();
+
+ const player = outcome
+ ? outcome.playerId === playerId
+ ? t("scryOutcome.you")
+ : getOpponentDisplayName(outcome.playerId)
+ : "";
+
+ return (
+
+ {outcome && (
+
+
+
+ {t("scryOutcome.title")}
+
+
+ {t("scryOutcome.result", {
+ player,
+ top: outcome.topCount,
+ bottom: outcome.bottomCount,
+ })}
+
+
+
+ )}
+
+ );
+}
diff --git a/client/src/components/animation/__tests__/ScryOutcomeOverlay.test.tsx b/client/src/components/animation/__tests__/ScryOutcomeOverlay.test.tsx
new file mode 100644
index 0000000000..c0fa3e4e9b
--- /dev/null
+++ b/client/src/components/animation/__tests__/ScryOutcomeOverlay.test.tsx
@@ -0,0 +1,31 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { useUiStore } from "../../../stores/uiStore.ts";
+import { ScryOutcomeOverlay } from "../ScryOutcomeOverlay.tsx";
+
+beforeEach(() => {
+ useUiStore.getState().resetScryOutcome();
+});
+
+afterEach(() => {
+ cleanup();
+ useUiStore.getState().resetScryOutcome();
+});
+
+describe("ScryOutcomeOverlay", () => {
+ it("shows the public top and bottom placement outcome", () => {
+ useUiStore.setState({ scryOutcome: { playerId: 1, topCount: 1, bottomCount: 2 } });
+
+ render();
+
+ expect(screen.getByText("Scry complete")).toBeInTheDocument();
+ expect(screen.getByTestId("scry-outcome")).toHaveTextContent("Opp 2 — 1 on top · 2 on bottom");
+ });
+
+ it("renders nothing when there is no completed scry outcome", () => {
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/client/src/components/chrome/DebugCardContextMenu.tsx b/client/src/components/chrome/DebugCardContextMenu.tsx
index 3339973c71..62153275e6 100644
--- a/client/src/components/chrome/DebugCardContextMenu.tsx
+++ b/client/src/components/chrome/DebugCardContextMenu.tsx
@@ -8,6 +8,7 @@ import type {
ObjectId,
Zone,
} from "../../adapter/types";
+import { formatCounterType } from "../../viewmodel/cardProps";
import { useGameStore } from "../../stores/gameStore";
import { useUiStore } from "../../stores/uiStore";
import { useGameDispatch } from "../../hooks/useGameDispatch";
@@ -145,10 +146,6 @@ function DebugCardContextMenuInner({
const onBattlefield = obj.zone === "Battlefield";
const isCreature = obj.card_types?.core_types?.includes("Creature") ?? false;
- const isPlaneswalker = obj.card_types?.core_types?.includes("Planeswalker") ?? false;
- const isClass = obj.card_types?.subtypes?.includes("Class") ?? false;
- const isSaga = obj.card_types?.subtypes?.includes("Saga") ?? false;
- const hasLoreCounters = isClass || isSaga;
const hasSummoningSickness = obj.has_summoning_sickness ?? false;
const currentKeywords = obj.keywords ?? [];
@@ -248,18 +245,22 @@ function DebugCardContextMenuInner({
{/* Counter actions */}
{onBattlefield && (
- {isCreature && (
- <>
-
-
- >
- )}
- {isPlaneswalker && (
-
- )}
- {hasLoreCounters && (
-
- )}
+ {Object.entries(obj.counters ?? {})
+ .flatMap(([counterType, count]) => {
+ const current = count ?? 0;
+ return current > 0
+ ? [
+ ,
+ ]
+ : [];
+ })}
)}
diff --git a/client/src/components/chrome/DebugCreateActions.tsx b/client/src/components/chrome/DebugCreateActions.tsx
index c8975baea5..5521f6ae0e 100644
--- a/client/src/components/chrome/DebugCreateActions.tsx
+++ b/client/src/components/chrome/DebugCreateActions.tsx
@@ -150,6 +150,7 @@ function CreateCardForm({ onDispatch }: Props) {
const [cardName, setCardName] = useState("");
const [owner, setOwner] = useState(0);
const [zone, setZone] = useState("Hand");
+ const [count, setCount] = useState(1);
// Gate the ETB pipeline for battlefield spawns. Checked = run replacements +
// ETB triggers + SBAs (engine default); unchecked = raw placement. Only sent
// meaningfully for Battlefield — the engine ignores it for other zones.
@@ -231,6 +232,9 @@ function CreateCardForm({ onDispatch }: Props) {
+
+
+
{showAttachPicker && (
<>
{info.canTargetPlayer && info.canTargetObject && (
@@ -281,6 +285,7 @@ function CreateCardForm({ onDispatch }: Props) {
attach_to: buildAttachTo(),
run_etb: runEtb,
nonlegendary,
+ count,
},
})
}
@@ -393,6 +398,7 @@ export function buildCatalogTokenDebugAction({
counterType,
counterCount,
runEtb,
+ count,
powerOverride,
toughnessOverride,
}: {
@@ -401,6 +407,7 @@ export function buildCatalogTokenDebugAction({
counterType: CounterType;
counterCount: number;
runEtb: boolean;
+ count: number;
powerOverride?: number | null;
toughnessOverride?: number | null;
}): CreateTokenDebugAction | null {
@@ -424,6 +431,7 @@ export function buildCatalogTokenDebugAction({
},
},
run_etb: runEtb,
+ count,
},
};
}
@@ -440,6 +448,7 @@ function CatalogTokenForm({ onDispatch }: Props) {
const [counterType, setCounterType] = useState("P1P1");
const [counterCount, setCounterCount] = useState(0);
const [runEtb, setRunEtb] = useState(true);
+ const [count, setCount] = useState(1);
useEffect(() => {
listTokenPresets()
@@ -527,6 +536,7 @@ function CatalogTokenForm({ onDispatch }: Props) {
counterType,
counterCount,
runEtb,
+ count,
powerOverride,
toughnessOverride,
});
@@ -552,6 +562,9 @@ function CatalogTokenForm({ onDispatch }: Props) {
+
+
+
{orderedGroups.length === 0 && (
No presets match.
@@ -649,6 +662,7 @@ function CustomTokenForm({ onDispatch }: Props) {
const [counterType, setCounterType] = useState
("P1P1");
const [counterCount, setCounterCount] = useState(0);
const [runEtb, setRunEtb] = useState(true);
+ const [count, setCount] = useState(1);
const toggleCoreType = (ct: CoreType) => {
setCoreTypes((prev) =>
@@ -693,6 +707,7 @@ function CustomTokenForm({ onDispatch }: Props) {
},
},
run_etb: runEtb,
+ count,
},
});
};
@@ -713,6 +728,9 @@ function CustomTokenForm({ onDispatch }: Props) {
+
+
+
@@ -779,6 +797,7 @@ function CopyPermanentForm({ onDispatch }: Props) {
const [sourceId, setSourceId] = useState(null);
const [owner, setOwner] = useState(0);
const [nonlegendary, setNonlegendary] = useState(false);
+ const [count, setCount] = useState(1);
return (
<>
@@ -794,6 +813,9 @@ function CopyPermanentForm({ onDispatch }: Props) {
+
+
+
(null);
const [owner, setOwner] = useState(0);
const [nonlegendary, setNonlegendary] = useState(false);
+ const [count, setCount] = useState(1);
return (
<>
@@ -150,6 +152,9 @@ function CreateTokenCopyForm({ onDispatch }: Props) {
+
+
+
@@ -207,12 +212,24 @@ function ModifyCountersForm({ onDispatch }: Props) {
const [objectId, setObjectId] = useState(null);
const [counterType, setCounterType] = useState("P1P1");
const [delta, setDelta] = useState(1);
+ const object = useGameStore((s) =>
+ objectId == null ? undefined : s.gameState?.objects[objectId],
+ );
+ const counterTypes = useMemo(
+ () => Array.from(new Set([...COUNTER_TYPES, ...Object.keys(object?.counters ?? {})])),
+ [object?.counters],
+ );
return (
<>
-
+
diff --git a/client/src/components/chrome/__tests__/DebugCreateActions.test.ts b/client/src/components/chrome/__tests__/DebugCreateActions.test.ts
index b3c2e20e5f..a293f71d45 100644
--- a/client/src/components/chrome/__tests__/DebugCreateActions.test.ts
+++ b/client/src/components/chrome/__tests__/DebugCreateActions.test.ts
@@ -42,6 +42,7 @@ describe("DebugCreateActions catalog token payloads", () => {
counterType: "P1P1",
counterCount: 0,
runEtb: true,
+ count: 1,
});
expect(action?.data.request.data).toEqual({
@@ -63,6 +64,7 @@ describe("DebugCreateActions catalog token payloads", () => {
counterType: "P1P1",
counterCount: 0,
runEtb: true,
+ count: 1,
powerOverride: 4,
}),
).toBeNull();
@@ -74,6 +76,7 @@ describe("DebugCreateActions catalog token payloads", () => {
counterType: "P1P1",
counterCount: 0,
runEtb: true,
+ count: 1,
powerOverride: 4,
toughnessOverride: 5,
})?.data.request.data,
diff --git a/client/src/components/chrome/__tests__/DebugPanel.sandboxCapability.test.tsx b/client/src/components/chrome/__tests__/DebugPanel.sandboxCapability.test.tsx
index 873d63ec74..ded5936bb5 100644
--- a/client/src/components/chrome/__tests__/DebugPanel.sandboxCapability.test.tsx
+++ b/client/src/components/chrome/__tests__/DebugPanel.sandboxCapability.test.tsx
@@ -232,6 +232,7 @@ describe("DebugPanel — desktop solo capability", () => {
attach_to: undefined,
run_etb: true,
nonlegendary: true,
+ count: 1,
},
},
});
diff --git a/client/src/components/chrome/debugFields.tsx b/client/src/components/chrome/debugFields.tsx
index fe1d5cb0b4..3379760d27 100644
--- a/client/src/components/chrome/debugFields.tsx
+++ b/client/src/components/chrome/debugFields.tsx
@@ -89,20 +89,22 @@ export function SelectInput({
value,
onChange,
options,
+ getOptionLabel,
}: {
value: T;
onChange: (v: T) => void;
options: readonly T[];
+ getOptionLabel?: (option: T) => string;
}) {
const items = useMemo(
- () => options.map((opt) => ({ value: opt, label: opt })),
- [options],
+ () => options.map((opt) => ({ value: opt, label: getOptionLabel?.(opt) ?? opt })),
+ [getOptionLabel, options],
);
return (
onChange(next as T)}
diff --git a/client/src/game/__tests__/diceContest.test.ts b/client/src/game/__tests__/diceContest.test.ts
index decf0eb697..4b6041114d 100644
--- a/client/src/game/__tests__/diceContest.test.ts
+++ b/client/src/game/__tests__/diceContest.test.ts
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GameEvent } from "../../adapter/types";
import { usePreferencesStore } from "../../stores/preferencesStore";
import { useUiStore } from "../../stores/uiStore";
-import { flashInGameRolls, flashStartingPlayerContest } from "../diceContest";
+import { flashCompletedScry, flashInGameRolls, flashStartingPlayerContest } from "../diceContest";
const die = (player_id: number, sides: number, result: number): GameEvent => ({
type: "DieRolled",
@@ -20,11 +20,22 @@ const contest = (rounds: [number, number][][], winner: number): GameEvent => ({
type: "StartingPlayerContest",
data: { rounds: rounds.map((rolls) => ({ rolls })), winner },
});
+const scry = (player_id: number, top: number, bottom: number): GameEvent => ({
+ type: "PlayerPerformedAction",
+ data: {
+ player_id,
+ action: "Scry",
+ look_count: top + bottom,
+ scry_top_count: top,
+ scry_bottom_count: bottom,
+ },
+});
beforeEach(() => {
vi.useFakeTimers();
usePreferencesStore.setState({ animationSpeedMultiplier: 1 });
useUiStore.setState({ diceRoll: null, diceRollQueue: [] });
+ useUiStore.getState().resetScryOutcome();
});
afterEach(() => {
@@ -123,6 +134,32 @@ describe("flashStartingPlayerContest", () => {
});
});
+describe("flashCompletedScry", () => {
+ it("publishes engine-provided top and bottom counts without inspecting cards", () => {
+ flashCompletedScry([scry(1, 1, 2)]);
+
+ expect(useUiStore.getState().scryOutcome).toEqual({
+ playerId: 1,
+ topCount: 1,
+ bottomCount: 2,
+ });
+
+ vi.advanceTimersByTime(4_000);
+ expect(useUiStore.getState().scryOutcome).toBeNull();
+ });
+
+ it("does not show a result for an incomplete or unrelated player action", () => {
+ flashCompletedScry([
+ {
+ type: "PlayerPerformedAction",
+ data: { player_id: 1, action: "Scry", look_count: 3, scry_bottom_count: 2 },
+ },
+ ]);
+
+ expect(useUiStore.getState().scryOutcome).toBeNull();
+ });
+});
+
describe("flashInGameRolls", () => {
it("groups consecutive dice into one ability payload (e.g. Krark's Thumb double)", () => {
flashInGameRolls([die(0, 6, 3), die(0, 6, 5)]);
diff --git a/client/src/game/diceContest.ts b/client/src/game/diceContest.ts
index 3ed0b8fe01..5b2c0c077a 100644
--- a/client/src/game/diceContest.ts
+++ b/client/src/game/diceContest.ts
@@ -4,6 +4,14 @@ import { useUiStore } from "../stores/uiStore";
type DieRolledEvent = Extract;
type CoinFlippedEvent = Extract;
type StartingPlayerContestEvent = Extract;
+type ScryEvent = Extract;
+type CompletedScryEvent = ScryEvent & {
+ data: ScryEvent["data"] & {
+ action: "Scry";
+ scry_top_count: number;
+ scry_bottom_count: number;
+ };
+};
/**
* Fire the starting-player contest overlay from a game-start event batch.
@@ -75,3 +83,24 @@ export function flashInGameRolls(events: GameEvent[]): void {
flash({ kind: "coin", playerId: coin.data.player_id, won: coin.data.won, context: "ability" });
}
}
+
+/**
+ * Surface a completed scry using the engine's public top/bottom counts. A
+ * partially-resolved scry has no event yet, and non-scry player actions have
+ * no display effect.
+ */
+export function flashCompletedScry(events: GameEvent[]): void {
+ const scry = events.find(
+ (event): event is CompletedScryEvent =>
+ event.type === "PlayerPerformedAction" &&
+ event.data.action === "Scry" &&
+ event.data.scry_top_count !== undefined &&
+ event.data.scry_bottom_count !== undefined,
+ );
+ if (!scry) return;
+ useUiStore.getState().flashScryOutcome({
+ playerId: scry.data.player_id,
+ topCount: scry.data.scry_top_count,
+ bottomCount: scry.data.scry_bottom_count,
+ });
+}
diff --git a/client/src/i18n/locales/de/common.json b/client/src/i18n/locales/de/common.json
index fe615840ec..172ffac8a2 100644
--- a/client/src/i18n/locales/de/common.json
+++ b/client/src/i18n/locales/de/common.json
@@ -5,6 +5,11 @@
"close": "Schließen",
"closeNamed": "{{name}} schließen"
},
+ "scryOutcome": {
+ "title": "Spähen abgeschlossen",
+ "you": "Du",
+ "result": "{{player}} — {{top}} oben · {{bottom}} unten"
+ },
"modal": {
"defaultEyebrow": "Workspace-Werkzeug"
},
diff --git a/client/src/i18n/locales/de/settings.json b/client/src/i18n/locales/de/settings.json
index d5699bd6af..1cdce031ef 100644
--- a/client/src/i18n/locales/de/settings.json
+++ b/client/src/i18n/locales/de/settings.json
@@ -167,9 +167,10 @@
"lastSynced": "Zuletzt synchronisiert {{time}}",
"never": "nie",
"conflictTitle": "Beide Versionen haben Änderungen",
- "conflictBody": "Dieses Gerät und dein Cloud-Konto enthalten beide Daten, und wir können nicht feststellen, welche aktuell sind. Wähle, welche Kopie behalten werden soll – die andere wird ersetzt.",
+ "conflictBody": "Dieses Gerät und dein Cloud-Konto enthalten beide Daten, und wir können nicht feststellen, welche aktuell sind. Wähle eine Kopie oder behalte beide Decksammlungen.",
"keepCloud": "Cloud verwenden",
"keepLocal": "Dieses Gerät behalten",
+ "keepBothDecks": "Beide Decksammlungen behalten",
"savesNote": "Spielstände und Caches bleiben auf diesem Gerät.",
"diffDecks": "Decks: {{added}} hinzugefügt, {{modified}} geändert, {{removed}} entfernt",
"diffPrefs": "Einstellungen unterscheiden sich",
diff --git a/client/src/i18n/locales/en/common.json b/client/src/i18n/locales/en/common.json
index 89813765e6..bbdd363ee7 100644
--- a/client/src/i18n/locales/en/common.json
+++ b/client/src/i18n/locales/en/common.json
@@ -5,6 +5,11 @@
"close": "Close",
"closeNamed": "Close {{name}}"
},
+ "scryOutcome": {
+ "title": "Scry complete",
+ "you": "You",
+ "result": "{{player}} — {{top}} on top · {{bottom}} on bottom"
+ },
"modal": {
"defaultEyebrow": "Workspace Tool"
},
diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json
index c8dd7ccf2e..b089f77da1 100644
--- a/client/src/i18n/locales/en/settings.json
+++ b/client/src/i18n/locales/en/settings.json
@@ -171,9 +171,10 @@
"lastSynced": "Last synced {{time}}",
"never": "never",
"conflictTitle": "Both copies have changes",
- "conflictBody": "This device and your cloud account both have data and we can't tell which is current. Pick which copy to keep — the other will be replaced.",
+ "conflictBody": "This device and your cloud account both have data and we can't tell which is current. Choose one copy, or keep both deck collections.",
"keepCloud": "Use cloud",
"keepLocal": "Keep this device",
+ "keepBothDecks": "Keep both deck collections",
"savesNote": "Game saves and caches stay on this device.",
"diffDecks": "Decks: {{added}} added, {{modified}} changed, {{removed}} removed",
"diffPrefs": "Preferences differ",
diff --git a/client/src/i18n/locales/es/common.json b/client/src/i18n/locales/es/common.json
index 6af8a72802..9b97b9d202 100644
--- a/client/src/i18n/locales/es/common.json
+++ b/client/src/i18n/locales/es/common.json
@@ -5,6 +5,11 @@
"close": "Cerrar",
"closeNamed": "Cerrar {{name}}"
},
+ "scryOutcome": {
+ "title": "Adivinación completada",
+ "you": "Tú",
+ "result": "{{player}} — {{top}} arriba · {{bottom}} abajo"
+ },
"modal": {
"defaultEyebrow": "Herramienta del espacio de trabajo"
},
diff --git a/client/src/i18n/locales/es/settings.json b/client/src/i18n/locales/es/settings.json
index e23ea850ed..378704e47b 100644
--- a/client/src/i18n/locales/es/settings.json
+++ b/client/src/i18n/locales/es/settings.json
@@ -167,9 +167,10 @@
"lastSynced": "Última sincronización: {{time}}",
"never": "nunca",
"conflictTitle": "Ambas copias tienen cambios",
- "conflictBody": "Este dispositivo y tu cuenta en la nube tienen datos y no podemos determinar cuál es la actual. Elige qué copia conservar; la otra se reemplazará.",
+ "conflictBody": "Este dispositivo y tu cuenta en la nube tienen datos y no podemos determinar cuál es la actual. Elige una copia o conserva ambas colecciones de mazos.",
"keepCloud": "Usar la nube",
"keepLocal": "Conservar este dispositivo",
+ "keepBothDecks": "Conservar ambas colecciones de mazos",
"savesNote": "Las partidas guardadas y las cachés permanecen en este dispositivo.",
"diffDecks": "Mazos: {{added}} añadidos, {{modified}} modificados, {{removed}} eliminados",
"diffPrefs": "Las preferencias difieren",
diff --git a/client/src/i18n/locales/fr/common.json b/client/src/i18n/locales/fr/common.json
index 1020974f9f..72e09485cc 100644
--- a/client/src/i18n/locales/fr/common.json
+++ b/client/src/i18n/locales/fr/common.json
@@ -5,6 +5,11 @@
"close": "Fermer",
"closeNamed": "Fermer {{name}}"
},
+ "scryOutcome": {
+ "title": "Regard terminé",
+ "you": "Vous",
+ "result": "{{player}} — {{top}} au-dessus · {{bottom}} au-dessous"
+ },
"modal": {
"defaultEyebrow": "Outil d'espace de travail"
},
diff --git a/client/src/i18n/locales/fr/settings.json b/client/src/i18n/locales/fr/settings.json
index 35f06d13eb..7ba2fb5abc 100644
--- a/client/src/i18n/locales/fr/settings.json
+++ b/client/src/i18n/locales/fr/settings.json
@@ -167,9 +167,10 @@
"lastSynced": "Dernière synchronisation {{time}}",
"never": "jamais",
"conflictTitle": "Les deux copies ont des modifications",
- "conflictBody": "Cet appareil et ton compte cloud contiennent tous les deux des données, et nous ne pouvons pas déterminer laquelle est à jour. Choisis la copie à conserver ; l'autre sera remplacée.",
+ "conflictBody": "Cet appareil et ton compte cloud contiennent tous les deux des données, et nous ne pouvons pas déterminer laquelle est à jour. Choisis une copie ou conserve les deux collections de decks.",
"keepCloud": "Utiliser le cloud",
"keepLocal": "Conserver cet appareil",
+ "keepBothDecks": "Conserver les deux collections de decks",
"savesNote": "Les sauvegardes de parties et les caches restent sur cet appareil.",
"diffDecks": "Decks : {{added}} ajoutés, {{modified}} modifiés, {{removed}} retirés",
"diffPrefs": "Les préférences diffèrent",
diff --git a/client/src/i18n/locales/it/common.json b/client/src/i18n/locales/it/common.json
index 4156a1caf7..0ef9a2cdfa 100644
--- a/client/src/i18n/locales/it/common.json
+++ b/client/src/i18n/locales/it/common.json
@@ -5,6 +5,11 @@
"close": "Chiudi",
"closeNamed": "Chiudi {{name}}"
},
+ "scryOutcome": {
+ "title": "Scry completato",
+ "you": "Tu",
+ "result": "{{player}} — {{top}} in cima · {{bottom}} in fondo"
+ },
"modal": {
"defaultEyebrow": "Strumento dello spazio di lavoro"
},
diff --git a/client/src/i18n/locales/it/settings.json b/client/src/i18n/locales/it/settings.json
index ab047477e3..e37f034956 100644
--- a/client/src/i18n/locales/it/settings.json
+++ b/client/src/i18n/locales/it/settings.json
@@ -167,9 +167,10 @@
"lastSynced": "Ultima sincronizzazione {{time}}",
"never": "mai",
"conflictTitle": "Entrambe le copie hanno modifiche",
- "conflictBody": "Questo dispositivo e il tuo account cloud contengono entrambi dati e non possiamo stabilire quale sia aggiornato. Scegli quale copia mantenere; l'altra verrà sostituita.",
+ "conflictBody": "Questo dispositivo e il tuo account cloud contengono entrambi dati e non possiamo stabilire quale sia aggiornata. Scegli una copia o conserva entrambe le raccolte di mazzi.",
"keepCloud": "Usa il cloud",
"keepLocal": "Mantieni questo dispositivo",
+ "keepBothDecks": "Mantieni entrambe le raccolte di mazzi",
"savesNote": "I salvataggi delle partite e le cache restano su questo dispositivo.",
"diffDecks": "Mazzi: {{added}} aggiunti, {{modified}} modificati, {{removed}} rimossi",
"diffPrefs": "Le preferenze differiscono",
diff --git a/client/src/i18n/locales/pl/common.json b/client/src/i18n/locales/pl/common.json
index b7b0111f1f..f4b89710d3 100644
--- a/client/src/i18n/locales/pl/common.json
+++ b/client/src/i18n/locales/pl/common.json
@@ -5,6 +5,11 @@
"close": "Zamknij",
"closeNamed": "Zamknij {{name}}"
},
+ "scryOutcome": {
+ "title": "Wróżenie zakończone",
+ "you": "Ty",
+ "result": "{{player}} — {{top}} na górze · {{bottom}} na dole"
+ },
"modal": {
"defaultEyebrow": "Narzędzie warsztatu"
},
diff --git a/client/src/i18n/locales/pl/settings.json b/client/src/i18n/locales/pl/settings.json
index 1f3db39bf0..bf4b176bb9 100644
--- a/client/src/i18n/locales/pl/settings.json
+++ b/client/src/i18n/locales/pl/settings.json
@@ -167,9 +167,10 @@
"lastSynced": "Ostatnia synchronizacja: {{time}}",
"never": "nigdy",
"conflictTitle": "Obie kopie mają zmiany",
- "conflictBody": "To urządzenie i Twoje konto w chmurze zawierają dane, a nie możemy ustalić, która kopia jest aktualna. Wybierz, którą zachować — druga zostanie zastąpiona.",
+ "conflictBody": "To urządzenie i Twoje konto w chmurze zawierają dane, a nie możemy ustalić, która kopia jest aktualna. Wybierz jedną kopię albo zachowaj obie kolekcje talii.",
"keepCloud": "Użyj chmury",
"keepLocal": "Zachowaj to urządzenie",
+ "keepBothDecks": "Zachowaj obie kolekcje talii",
"savesNote": "Zapisy gier i pamięć podręczna pozostają na tym urządzeniu.",
"diffDecks": "Talie: {{added}} dodanych, {{modified}} zmienionych, {{removed}} usuniętych",
"diffPrefs": "Preferencje są różne",
diff --git a/client/src/i18n/locales/pt/common.json b/client/src/i18n/locales/pt/common.json
index 7f6c2c41ff..1e521286fd 100644
--- a/client/src/i18n/locales/pt/common.json
+++ b/client/src/i18n/locales/pt/common.json
@@ -5,6 +5,11 @@
"close": "Fechar",
"closeNamed": "Fechar {{name}}"
},
+ "scryOutcome": {
+ "title": "Vidência concluída",
+ "you": "Você",
+ "result": "{{player}} — {{top}} no topo · {{bottom}} no fundo"
+ },
"modal": {
"defaultEyebrow": "Ferramenta de Workspace"
},
diff --git a/client/src/i18n/locales/pt/settings.json b/client/src/i18n/locales/pt/settings.json
index a7d3cac0ca..b77b1808d2 100644
--- a/client/src/i18n/locales/pt/settings.json
+++ b/client/src/i18n/locales/pt/settings.json
@@ -167,9 +167,10 @@
"lastSynced": "Última sincronização {{time}}",
"never": "nunca",
"conflictTitle": "Ambas as cópias têm alterações",
- "conflictBody": "Este dispositivo e a sua conta na nuvem têm dados e não conseguimos determinar qual está atualizada. Escolha qual cópia manter; a outra será substituída.",
+ "conflictBody": "Este dispositivo e a sua conta na nuvem têm dados e não conseguimos determinar qual está atualizada. Escolha uma cópia ou mantenha ambas as coleções de decks.",
"keepCloud": "Usar a nuvem",
"keepLocal": "Manter este dispositivo",
+ "keepBothDecks": "Manter ambas as coleções de decks",
"savesNote": "Os jogos guardados e as caches permanecem neste dispositivo.",
"diffDecks": "Decks: {{added}} adicionados, {{modified}} alterados, {{removed}} removidos",
"diffPrefs": "As preferências diferem",
diff --git a/client/src/network/__tests__/protocol.test.ts b/client/src/network/__tests__/protocol.test.ts
index 03bf4a02c1..d2d422c151 100644
--- a/client/src/network/__tests__/protocol.test.ts
+++ b/client/src/network/__tests__/protocol.test.ts
@@ -36,8 +36,8 @@ const viewerInteractionWithProducedMana = {
} as never;
describe("encodeWireMessage / decodeWireMessage", () => {
- it("pins the P2P wire protocol to v17", () => {
- expect(WIRE_PROTOCOL_VERSION).toBe(17);
+ it("pins the P2P wire protocol to v18", () => {
+ expect(WIRE_PROTOCOL_VERSION).toBe(18);
});
it("defaults shortcut actions for a legacy payload created before the additive field", () => {
diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts
index 809f35e842..74c920187d 100644
--- a/client/src/network/protocol.ts
+++ b/client/src/network/protocol.ts
@@ -100,6 +100,9 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult
* 9 — Meld pair and attacking-entry choices after mana-payment preview variants.
* 8 — Mana-payment preview request/response variants.
* 7 — PrecastCopyShortcut action and its two WaitingFor variants.
+ * 18 — DebugCardEntries added a serialized, private resolution frame for
+ * multi-card sandbox battlefield entries that pause for replacement or
+ * as-enters choices. Old peers cannot deserialize that GameState shape.
* 17 — Bound draft-match concession request. A Traditional-draft guest
* asks its match authority to settle the match; it must not send a
* game-level concession through the ordinary P2P path.
@@ -107,7 +110,7 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult
* sub-phase on WaitingFor::MulliganDecision; the MulliganBottomCards
* variant was removed
*/
-export const WIRE_PROTOCOL_VERSION = 17 as const;
+export const WIRE_PROTOCOL_VERSION = 18 as const;
export type P2PMessage = P2PAuthorityWire & (
| { type: "guest_deck"; deckData: unknown; displayName?: string; reservationToken?: string }
diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx
index fcee8d27ba..7a19ffb6e0 100644
--- a/client/src/pages/GamePage.tsx
+++ b/client/src/pages/GamePage.tsx
@@ -41,6 +41,7 @@ import { AnimationOverlay } from "../components/animation/AnimationOverlay.tsx";
import { RevealOverlay } from "../components/animation/RevealOverlay.tsx";
import { TurnBanner } from "../components/animation/TurnBanner.tsx";
import { DiceRollOverlay } from "../components/animation/DiceRollOverlay.tsx";
+import { ScryOutcomeOverlay } from "../components/animation/ScryOutcomeOverlay.tsx";
import { flashStartingPlayerContest } from "../game/diceContest.ts";
import { loopDetectionModeFromQuery } from "../game/loopDetectionMode.ts";
import { BattlefieldBackground } from "../components/board/BattlefieldBackground.tsx";
@@ -1824,6 +1825,7 @@ function GamePageContent({
+
{/* Combat SVG overlays: blocker assignments + attack target arrows */}
diff --git a/client/src/services/__tests__/backup.test.ts b/client/src/services/__tests__/backup.test.ts
index d609ae9324..4a8b5cd2e4 100644
--- a/client/src/services/__tests__/backup.test.ts
+++ b/client/src/services/__tests__/backup.test.ts
@@ -1,6 +1,12 @@
import { beforeEach, describe, expect, it } from "vitest";
-import { applyBackup, buildBackup, importBackupFromFile, type PhaseBackupV1 } from "../backup";
+import {
+ applyBackup,
+ buildBackup,
+ importBackupFromFile,
+ mergeDeckCollections,
+ type PhaseBackupV1,
+} from "../backup";
import { DECK_FOLDERS_KEY, STORAGE_KEY_PREFIX } from "../../constants/storage";
beforeEach(() => {
@@ -62,3 +68,67 @@ describe("backup — deck folders", () => {
expect(result.decksImported).toBe(1);
});
});
+
+describe("mergeDeckCollections", () => {
+ const backup = (decks: Record): PhaseBackupV1 => ({
+ version: 1,
+ exportedAt: new Date(0).toISOString(),
+ preferences: "local preferences",
+ decks,
+ deckMetadata: "local metadata",
+ deckFolders: "local folders",
+ activeDeck: "Local Deck",
+ feedSubscriptions: "local feeds",
+ feedDeckOrigins: "local origins",
+ });
+
+ it("keeps both conflicting decks with a unique cloud name", () => {
+ const merged = mergeDeckCollections(
+ backup({ Shared: "local", "Shared (Cloud)": "prior cloud copy" }),
+ backup({ Shared: "cloud", Remote: "remote" }),
+ );
+
+ expect(merged.decks).toEqual({
+ Shared: "local",
+ "Shared (Cloud)": "prior cloud copy",
+ "Shared (Cloud 2)": "cloud",
+ Remote: "remote",
+ });
+ expect(merged.preferences).toBe("local preferences");
+ });
+
+ it("deduplicates cloud decks whose contents already match", () => {
+ const merged = mergeDeckCollections(
+ backup({ Shared: "same" }),
+ backup({ Shared: "same" }),
+ );
+
+ expect(merged.decks).toEqual({ Shared: "same" });
+ });
+
+ it("keeps metadata, origins, and folders for renamed cloud decks", () => {
+ const local = backup({ Shared: "local" });
+ local.deckMetadata = JSON.stringify({ Shared: { addedAt: 1, folderId: "local-folder" } });
+ local.deckFolders = JSON.stringify([{ id: "local-folder", name: "Local", order: 0 }]);
+ local.feedDeckOrigins = JSON.stringify({ Shared: "local-feed" });
+ const cloud = backup({ Shared: "cloud" });
+ cloud.deckMetadata = JSON.stringify({ Shared: { addedAt: 2, starred: true, folderId: "cloud-folder" } });
+ cloud.deckFolders = JSON.stringify([{ id: "cloud-folder", name: "Cloud", order: 0 }]);
+ cloud.feedDeckOrigins = JSON.stringify({ Shared: "cloud-feed" });
+
+ const merged = mergeDeckCollections(local, cloud);
+
+ expect(JSON.parse(merged.deckMetadata ?? "{}")).toMatchObject({
+ Shared: { folderId: "local-folder" },
+ "Shared (Cloud)": { folderId: "cloud-folder", starred: true },
+ });
+ expect(JSON.parse(merged.feedDeckOrigins ?? "{}")).toMatchObject({
+ Shared: "local-feed",
+ "Shared (Cloud)": "cloud-feed",
+ });
+ expect(JSON.parse(merged.deckFolders ?? "[]")).toEqual([
+ { id: "local-folder", name: "Local", order: 0 },
+ { id: "cloud-folder", name: "Cloud", order: 0 },
+ ]);
+ });
+});
diff --git a/client/src/services/backup.ts b/client/src/services/backup.ts
index 90139f8eb1..72e7d7b384 100644
--- a/client/src/services/backup.ts
+++ b/client/src/services/backup.ts
@@ -21,6 +21,8 @@ import {
isUserOwnedStorageKey,
PREFERENCES_KEY,
STORAGE_KEY_PREFIX,
+ type DeckFolder,
+ type DeckMeta,
} from "../constants/storage";
/** Versioned envelope. Future shapes go in a `PhaseBackupV2 | …` union. */
@@ -49,6 +51,153 @@ export interface PhaseBackupV1 {
export type PhaseBackup = PhaseBackupV1;
+/**
+ * Reconcile deck collections without discarding either device's deck data.
+ *
+ * The local collection keeps its names. A cloud deck with the same name but
+ * different contents is retained under a stable, unique "(Cloud)" suffix;
+ * exact duplicates need only one copy. Profile-level fields deliberately stay
+ * local because their opaque serialized formats do not have a safe structural
+ * merge contract.
+ */
+export function mergeDeckCollections(
+ local: PhaseBackup,
+ cloud: PhaseBackup,
+): PhaseBackupV1 {
+ const decks = { ...local.decks };
+ const cloudDeckNames = new Map();
+
+ for (const [name, raw] of Object.entries(cloud.decks)) {
+ const existing = decks[name];
+ if (existing === undefined || existing === raw) {
+ decks[name] = raw;
+ cloudDeckNames.set(name, name);
+ continue;
+ }
+
+ let suffix = 1;
+ let mergedName = `${name} (Cloud)`;
+ while (decks[mergedName] !== undefined) {
+ suffix += 1;
+ mergedName = `${name} (Cloud ${suffix})`;
+ }
+ decks[mergedName] = raw;
+ cloudDeckNames.set(name, mergedName);
+ }
+
+ const { folders, folderIds } = mergeFolders(local.deckFolders, cloud.deckFolders);
+ const deckMetadata = mergeDeckMetadata(
+ local.deckMetadata,
+ cloud.deckMetadata,
+ cloudDeckNames,
+ folderIds,
+ );
+ const feedDeckOrigins = mergeDeckRecord(
+ local.feedDeckOrigins,
+ cloud.feedDeckOrigins,
+ cloudDeckNames,
+ );
+
+ return {
+ ...local,
+ exportedAt: new Date().toISOString(),
+ decks,
+ deckMetadata,
+ deckFolders: folders,
+ feedDeckOrigins,
+ };
+}
+
+function parseRecord(raw: string | null): Record | null {
+ if (raw == null) return {};
+ try {
+ const value: unknown = JSON.parse(raw);
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+function parseFolders(raw: string | null | undefined): DeckFolder[] | null {
+ if (raw == null) return [];
+ try {
+ const value: unknown = JSON.parse(raw);
+ return Array.isArray(value) ? (value as DeckFolder[]) : null;
+ } catch {
+ return null;
+ }
+}
+
+function mergeFolders(
+ localRaw: string | null | undefined,
+ cloudRaw: string | null | undefined,
+): { folders: string | null; folderIds: Map } {
+ const local = parseFolders(localRaw);
+ const cloud = parseFolders(cloudRaw);
+ if (local === null || cloud === null) {
+ return { folders: localRaw ?? null, folderIds: new Map() };
+ }
+
+ const merged = [...local];
+ const folderIds = new Map();
+ for (const folder of cloud) {
+ const localFolder = merged.find((candidate) => candidate.id === folder.id);
+ if (localFolder === undefined || (localFolder.name === folder.name && localFolder.order === folder.order)) {
+ if (localFolder === undefined) merged.push(folder);
+ folderIds.set(folder.id, folder.id);
+ continue;
+ }
+
+ let suffix = 1;
+ let id = `${folder.id}-cloud`;
+ while (merged.some((candidate) => candidate.id === id)) {
+ suffix += 1;
+ id = `${folder.id}-cloud-${suffix}`;
+ }
+ merged.push({ ...folder, id });
+ folderIds.set(folder.id, id);
+ }
+ return { folders: JSON.stringify(merged), folderIds };
+}
+
+function mergeDeckMetadata(
+ localRaw: string | null,
+ cloudRaw: string | null,
+ cloudDeckNames: ReadonlyMap,
+ folderIds: ReadonlyMap,
+): string | null {
+ const local = parseRecord(localRaw);
+ const cloud = parseRecord(cloudRaw);
+ if (local === null || cloud === null) return localRaw;
+
+ for (const [name, meta] of Object.entries(cloud)) {
+ const mergedName = cloudDeckNames.get(name);
+ if (mergedName === undefined || local[mergedName] !== undefined) continue;
+ const folderId = meta.folderId === undefined ? undefined : (folderIds.get(meta.folderId) ?? meta.folderId);
+ local[mergedName] = { ...meta, ...(folderId === undefined ? {} : { folderId }) };
+ }
+ return JSON.stringify(local);
+}
+
+function mergeDeckRecord(
+ localRaw: string | null,
+ cloudRaw: string | null,
+ cloudDeckNames: ReadonlyMap,
+): string | null {
+ const local = parseRecord(localRaw);
+ const cloud = parseRecord(cloudRaw);
+ if (local === null || cloud === null) return localRaw;
+
+ for (const [name, value] of Object.entries(cloud)) {
+ const mergedName = cloudDeckNames.get(name);
+ if (mergedName === undefined || local[mergedName] !== undefined) continue;
+ local[mergedName] = value;
+ }
+ return JSON.stringify(local);
+}
+
/** Build a backup envelope by snapshotting all user-owned localStorage keys. */
export function buildBackup(): PhaseBackupV1 {
const decks: Record = {};
diff --git a/client/src/stores/__tests__/cloudSyncStore.test.ts b/client/src/stores/__tests__/cloudSyncStore.test.ts
index 2faed5b4b6..1176a50314 100644
--- a/client/src/stores/__tests__/cloudSyncStore.test.ts
+++ b/client/src/stores/__tests__/cloudSyncStore.test.ts
@@ -7,15 +7,17 @@ import type {
} from "../../services/cloudSync";
// Hoisted mock fns so the vi.mock factories below can reference them.
-const { buildBackupMock, applyBackupMock, getProvider } = vi.hoisted(() => ({
+const { buildBackupMock, applyBackupMock, mergeDeckCollectionsMock, getProvider } = vi.hoisted(() => ({
buildBackupMock: vi.fn(),
applyBackupMock: vi.fn(),
+ mergeDeckCollectionsMock: vi.fn(),
getProvider: vi.fn(),
}));
vi.mock("../../services/backup", () => ({
buildBackup: buildBackupMock,
applyBackup: applyBackupMock,
+ mergeDeckCollections: mergeDeckCollectionsMock,
}));
vi.mock("../../services/cloudSync", () => ({
getCloudSyncProvider: getProvider,
@@ -249,3 +251,26 @@ describe("cloudSyncStore.syncNow reconciliation", () => {
expect(provider.push).toHaveBeenLastCalledWith(expect.anything(), null);
});
});
+
+describe("cloudSyncStore.resolveConflict", () => {
+ it("publishes a merged deck collection before applying it locally", async () => {
+ const local = fakeBackup({ decks: { Local: "local" } });
+ const remoteSnapshot = remote(5);
+ const merged = fakeBackup({ decks: { Local: "local", "Cloud Deck": "cloud" } });
+ useCloudSyncStore.setState({
+ conflict: remoteSnapshot,
+ conflictDiff: null,
+ status: "conflict",
+ });
+ buildBackupMock.mockReturnValue(local);
+ mergeDeckCollectionsMock.mockReturnValue(merged);
+ provider.push.mockResolvedValue({ revision: 6, updatedAt: "t" });
+
+ await useCloudSyncStore.getState().resolveConflict("merge");
+
+ expect(mergeDeckCollectionsMock).toHaveBeenCalledWith(local, remoteSnapshot.backup);
+ expect(provider.push).toHaveBeenCalledWith(merged, 5);
+ expect(applyBackupMock).toHaveBeenCalledWith(merged, "overwrite");
+ expect(useCloudSyncStore.getState().lastSyncedRevision).toBe(6);
+ });
+});
diff --git a/client/src/stores/cloudSyncStore.ts b/client/src/stores/cloudSyncStore.ts
index 1769ee2982..4d8a1b8331 100644
--- a/client/src/stores/cloudSyncStore.ts
+++ b/client/src/stores/cloudSyncStore.ts
@@ -1,6 +1,11 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
-import { applyBackup, buildBackup, type PhaseBackup } from "../services/backup";
+import {
+ applyBackup,
+ buildBackup,
+ mergeDeckCollections,
+ type PhaseBackup,
+} from "../services/backup";
import {
getCloudSyncProvider,
SyncConflictError,
@@ -31,7 +36,7 @@ const AUTO_SYNC_DEBOUNCE_MS = 3000;
export const PROFILE_REPLACED_EVENT = "phase:profile-replaced";
export type SyncStatus = "idle" | "syncing" | "synced" | "conflict" | "error";
-export type ConflictChoice = "cloud" | "local";
+export type ConflictChoice = "cloud" | "local" | "merge";
interface CloudSyncState {
/** True when a provider is configured for this deployment. */
@@ -60,7 +65,7 @@ interface CloudSyncState {
* by another device. Answers "when did this device last sync?" honestly.
*/
lastSyncedAt: string | null;
- /** Pending remote snapshot awaiting a user keep-cloud/keep-local decision. */
+ /** Pending remote snapshot awaiting a user reconciliation decision. */
conflict: RemoteSnapshot | null;
/** Per-envelope-section diff summary for the current conflict, or null. */
conflictDiff: ConflictDiffSummary | null;
@@ -439,10 +444,20 @@ export const useCloudSyncStore = create()(
applyRemote(set, conflict);
return;
}
- // Keep this device: fast-forward over the remote we just pulled.
+
+ const local = buildBackup();
+ const next = choice === "merge"
+ ? mergeDeckCollections(local, conflict.backup)
+ : local;
+
+ // Publish first so the local profile cannot appear reconciled before
+ // the remote CAS write accepts the selected result.
set({ status: "syncing", conflict: null, conflictDiff: null });
try {
- const meta = await provider.push(buildBackup(), conflict.meta.revision);
+ const meta = await provider.push(next, conflict.meta.revision);
+ if (choice === "merge") {
+ applyMergedDeckCollection(next);
+ }
set({
status: "synced",
dirty: false,
@@ -498,3 +513,12 @@ function applyRemote(
void usePreferencesStore.persist.rehydrate();
window.dispatchEvent(new CustomEvent(PROFILE_REPLACED_EVENT));
}
+
+/** Apply the already-published local/cloud deck reconciliation without a reload. */
+function applyMergedDeckCollection(backup: PhaseBackup): void {
+ withStorageWatchSuppressed(() => {
+ applyBackup(backup, "overwrite");
+ });
+ void usePreferencesStore.persist.rehydrate();
+ window.dispatchEvent(new CustomEvent(PROFILE_REPLACED_EVENT));
+}
diff --git a/client/src/stores/uiStore.ts b/client/src/stores/uiStore.ts
index efe24f1280..a666f88b2f 100644
--- a/client/src/stores/uiStore.ts
+++ b/client/src/stores/uiStore.ts
@@ -65,6 +65,14 @@ export type DiceRollPayload =
context: "startingPlayer" | "ability";
};
+/** A completed, public scry outcome. Counts originate in the engine event; the
+ * UI only controls how long the outcome remains visible. */
+export interface ScryOutcomePayload {
+ playerId: PlayerId;
+ topCount: number;
+ bottomCount: number;
+}
+
/** Direct-manipulation state for the mobile hand's held-card preview. The
* engine-authored action set determines `playable` / whether `castReady` may
* ever become true; offsets and the release threshold are presentation only. */
@@ -119,6 +127,7 @@ function flushPendingShow(): void {
// payload; `diceRollQueue` holds the pending ones. Distinct from the board-event
// step queue (animationStore) — that coordinates spatial per-object effects.
let diceAdvanceTimer: ReturnType | null = null;
+let scryOutcomeTimer: ReturnType | null = null;
// CR 103.1: the starting-player contest determines who's on the play — a moment
// the player should acknowledge, not one that flashes by. It holds on screen
@@ -191,6 +200,8 @@ interface UiStoreState {
/** Pending dice/coin overlays behind the active one. Simultaneous or
* back-to-back rolls play serially instead of clobbering. */
diceRollQueue: DiceRollPayload[];
+ /** Latest engine-authored public scry result, temporarily shown on board. */
+ scryOutcome: ScryOutcomePayload | null;
focusedOpponent: number | null;
pendingAbilityChoice: { objectId: ObjectId; actions: ObjectAction[] } | null;
/** When non-null, the AttachmentsDialog is open showing every Aura
@@ -302,6 +313,10 @@ interface UiStoreActions {
/** Dismiss the current dice/coin overlay immediately (user tap-to-skip),
* advancing to the next queued roll if any. */
skipDiceRoll: () => void;
+ /** Surface one public scry outcome for a short, non-interactive board notice. */
+ flashScryOutcome: (payload: ScryOutcomePayload) => void;
+ /** Clear a visible scry result on a game-session boundary. */
+ resetScryOutcome: () => void;
setFocusedOpponent: (id: number | null) => void;
setPendingAbilityChoice: (choice: { objectId: ObjectId; actions: ObjectAction[] } | null) => void;
setEnchantmentsDialogPlayer: (id: number | null) => void;
@@ -361,6 +376,7 @@ export const useUiStore = create()((set, get) => ({
turnBannerNumber: null,
diceRoll: null,
diceRollQueue: [],
+ scryOutcome: null,
focusedOpponent: null,
pendingAbilityChoice: null,
enchantmentsDialogPlayer: null,
@@ -670,6 +686,21 @@ export const useUiStore = create()((set, get) => ({
}
advanceDiceQueue();
},
+ flashScryOutcome: (payload) => {
+ if (scryOutcomeTimer) clearTimeout(scryOutcomeTimer);
+ set({ scryOutcome: payload });
+ scryOutcomeTimer = setTimeout(() => {
+ scryOutcomeTimer = null;
+ set({ scryOutcome: null });
+ }, 4_000);
+ },
+ resetScryOutcome: () => {
+ if (scryOutcomeTimer) {
+ clearTimeout(scryOutcomeTimer);
+ scryOutcomeTimer = null;
+ }
+ set({ scryOutcome: null });
+ },
setFocusedOpponent: (id) => set({ focusedOpponent: id }),
setPendingAbilityChoice: (choice) => set({ pendingAbilityChoice: choice }),
setEnchantmentsDialogPlayer: (id) => set({ enchantmentsDialogPlayer: id }),
diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs
index ab3131e20b..d46694ce0d 100644
--- a/crates/engine-wasm/src/lib.rs
+++ b/crates/engine-wasm/src/lib.rs
@@ -28,7 +28,6 @@ use engine::game::{
validate_name_deck_for_format_full, BracketEstimate, DeckCompatibilityRequest, DeckList,
PlayerDeckList, ReplayPlayer,
};
-use engine::types::card_type::Supertype;
use engine::types::format::{DeckCopyLimit, FormatConfig, GameFormat};
use engine::types::game_state::{PersistedGameState, TrustedGameStateEnvelope, WaitingFor};
use engine::types::identifiers::ObjectId;
@@ -1323,12 +1322,22 @@ pub fn submit_action(actor: u8, action: JsValue) -> JsValue {
ref card_name,
owner,
zone,
+ count,
attach_to,
run_etb,
nonlegendary,
}) = action
{
- return handle_debug_create_card(card_name, owner, zone, attach_to, run_etb, nonlegendary);
+ return handle_debug_create_card(DebugCreateCardRequest {
+ actor,
+ card_name,
+ owner,
+ zone,
+ count,
+ attach_to,
+ run_etb,
+ nonlegendary,
+ });
}
// Cloned before `apply` consumes `action` — recorded into REPLAY_LOG only
@@ -1336,10 +1345,20 @@ pub fn submit_action(actor: u8, action: JsValue) -> JsValue {
// reaches here.
let action_for_replay = action.clone();
let is_debug_action = matches!(action, GameAction::Debug(_));
+ let is_zero_count_debug_create = matches!(
+ &action,
+ GameAction::Debug(debug_action) if debug_action.is_zero_count_create()
+ );
match with_state_mut(|state| match apply(state, actor, action) {
Ok(result) => {
- record_replay_action(is_debug_action, actor, action_for_replay);
- invalidate_ai_proposals();
+ record_replay_action(
+ is_debug_action && !is_zero_count_debug_create,
+ actor,
+ action_for_replay,
+ );
+ if !is_zero_count_debug_create {
+ invalidate_ai_proposals();
+ }
to_js(&result)
}
Err(e) => {
@@ -1409,15 +1428,19 @@ fn record_replay_action(is_debug_action: bool, actor: PlayerId, action_for_repla
});
}
-fn handle_debug_create_card(
- card_name: &str,
+struct DebugCreateCardRequest<'a> {
+ actor: PlayerId,
+ card_name: &'a str,
owner: PlayerId,
zone: engine::types::zones::Zone,
+ count: u32,
attach_to: Option,
run_etb: bool,
nonlegendary: bool,
-) -> JsValue {
- match handle_debug_create_card_inner(card_name, owner, zone, attach_to, run_etb, nonlegendary) {
+}
+
+fn handle_debug_create_card(request: DebugCreateCardRequest<'_>) -> JsValue {
+ match handle_debug_create_card_inner(request) {
Ok(result) => to_js(&result),
Err(msg) => JsValue::from_str(msg),
}
@@ -1429,20 +1452,47 @@ fn handle_debug_create_card(
/// plain `cargo test`. See `bracket_estimate_tests::estimate_bracket_inner`
/// for the same split.
fn handle_debug_create_card_inner(
- card_name: &str,
- owner: PlayerId,
- zone: engine::types::zones::Zone,
- attach_to: Option,
- run_etb: bool,
- nonlegendary: bool,
+ request: DebugCreateCardRequest<'_>,
) -> Result {
- let face = CARD_DB.with(|cell| {
+ let DebugCreateCardRequest {
+ actor,
+ card_name,
+ owner,
+ zone,
+ count,
+ attach_to,
+ run_etb,
+ nonlegendary,
+ } = request;
+ if count > engine::types::actions::MAX_DEBUG_CREATE_COUNT {
+ return Err("Engine error: debug create count exceeds the maximum");
+ }
+ if count == 0 {
+ return with_state(|state| {
+ if !state.debug_mode {
+ return Err("Engine error: Debug actions require debug_mode to be enabled");
+ }
+ if !state.debug_permitted.is_empty() && !state.debug_permitted.contains(&actor) {
+ return Err("Engine error: Debug actions require debug permission");
+ }
+ if !state.players.iter().any(|player| player.id == owner) {
+ return Err("Engine error: Debug: invalid owner player id");
+ }
+ Ok(engine::types::game_state::ActionResult {
+ events: vec![],
+ waiting_for: state.waiting_for.clone(),
+ log_entries: vec![],
+ })
+ })
+ .unwrap_or(Err(NOT_INITIALIZED_ERR));
+ }
+ let source = CARD_DB.with(|cell| {
let db = cell.borrow();
let Some(db) = db.as_ref() else {
return Err("Engine error: card database not loaded");
};
match db.get_face_by_name(card_name) {
- Some(face) => Ok(face.clone()),
+ Some(face) => Ok(engine::game::debug_card_entry_source(db, face)),
None => Err("Engine error: card not found in database"),
}
})?;
@@ -1450,6 +1500,9 @@ fn handle_debug_create_card_inner(
if !state.debug_mode {
return Err("Engine error: Debug actions require debug_mode to be enabled");
}
+ if !state.debug_permitted.is_empty() && !state.debug_permitted.contains(&actor) {
+ return Err("Engine error: Debug actions require debug permission");
+ }
if !state.players.iter().any(|p| p.id == owner) {
return Err("Engine error: Debug: invalid owner player id");
}
@@ -1461,93 +1514,18 @@ fn handle_debug_create_card_inner(
// so `export_replay_log` can't produce a log that silently omits a
// debug spawn.
REPLAY_LOG.with(|cell| cell.set(None));
- // CR 400.7: For battlefield destination, stage the object in Hand
- // first, then route through the real ETB pipeline so replacements,
- // triggers, and SBAs all fire. Direct creation in Battlefield (the
- // old path) bypassed all of these and left Auras stranded with
- // `attached_to: None` plus a `entered_battlefield_turn` stamp that
- // survived later zone moves.
- let staging_zone = if zone == engine::types::zones::Zone::Battlefield {
- engine::types::zones::Zone::Hand
- } else {
- zone
- };
- let card_id = engine::types::identifiers::CardId(state.next_object_id);
- let obj_id =
- engine::game::create_object(state, card_id, owner, face.name.clone(), staging_zone);
- let obj = state.objects.get_mut(&obj_id).expect("just created");
- engine::game::printed_cards::apply_card_face_to_object(obj, &face);
- // CR 205.4a-b: Legendary is an independent supertype. The sandbox
- // override removes only that supertype from both the base (copiable)
- // and current characteristics, preserving every other type detail.
- if nonlegendary {
- obj.base_card_types
- .supertypes
- .retain(|supertype| *supertype != Supertype::Legendary);
- obj.card_types
- .supertypes
- .retain(|supertype| *supertype != Supertype::Legendary);
- }
- state.layers_dirty.mark_full();
-
- // Hydrate `back_face` for dual-faced spawns (MDFC, Transform, Adventure,
- // Omen, Meld, Prepare). `apply_card_face_to_object` only writes the named
- // face; without this, a debug-spawned Esika, God of the Tree has no
- // Prismatic Bridge back face, so Ctrl-to-flip preview and MDFC face-choice
- // casting silently no-op until a page refresh re-runs deck hydration. This
- // is the same canonical primitive `load_and_hydrate_decks` uses, so the
- // debug-spawn path can't drift from the normal load path. The new object
- // already carries `printed_ref` (set by `apply_card_face_to_object`), which
- // rehydrate uses to resolve the card and its other face.
- CARD_DB.with(|cell| {
- if let Some(db) = cell.borrow().as_ref() {
- engine::game::printed_cards::rehydrate_game_from_card_db(state, db);
- }
- });
-
- // CR 303.4f + CR 704.5n: When the user picks an attachment target,
- // wire the host through the engine's attach resolvers BEFORE routing
- // through the ETB pipeline. The resolvers (`attach_to`,
- // `attach_to_player`) own all legality checks (CR 301.5 / 303.4i,
- // `CantBeAttached` / `CantBeEnchanted` / `CantBeEquipped` statics) and
- // back-link bookkeeping (host's `attachments` list, `layers_dirty`),
- // so the WASM bridge stays a thin transport layer with zero attachment
- // logic. Doing this pre-ETB means the post-ETB SBA pass sees the
- // attachment with a legal host instead of an orphan (CR 704.5n) and
- // any "becomes attached" trigger fires from the same resolved state
- // a real cast would produce. Only honored for Battlefield spawns —
- // Auras in Hand/Library/Exile/Graveyard have no battlefield host.
- if zone == engine::types::zones::Zone::Battlefield {
- if let Some(target) = attach_to {
- use engine::game::game_object::AttachTarget;
- match target {
- AttachTarget::Object(target_id) => {
- if state.objects.contains_key(&target_id) {
- engine::game::effects::attach::attach_to(state, obj_id, target_id);
- }
- }
- AttachTarget::Player(target_player) => {
- if state.players.iter().any(|p| p.id == target_player) {
- engine::game::effects::attach::attach_to_player(
- state,
- obj_id,
- target_player,
- );
- }
- }
- }
- }
- }
-
- let result = if zone == engine::types::zones::Zone::Battlefield {
- engine::game::route_debug_create_to_battlefield(state, obj_id, run_etb)
- } else {
- engine::types::game_state::ActionResult {
- events: vec![],
- waiting_for: state.waiting_for.clone(),
- log_entries: vec![],
- }
- };
+ let result = engine::game::create_debug_cards(
+ state,
+ engine::game::DebugCardCreateRequest {
+ source,
+ owner,
+ zone,
+ count,
+ attach_to,
+ run_etb,
+ nonlegendary,
+ },
+ );
engine::game::public_state::bump_state_revision(state);
engine::game::public_state::mark_public_state_all_dirty(state);
@@ -4589,19 +4567,30 @@ mod replay_bridge_tests {
GAME_STATE.with(|cell| cell.set(Some(state)));
assert!(has_replay_recording());
- let result = handle_debug_create_card_inner(
- "Test Card",
- PlayerId(0),
- engine::types::zones::Zone::Hand,
- None,
- true,
- true,
- );
+ let result = handle_debug_create_card_inner(DebugCreateCardRequest {
+ actor: PlayerId(0),
+ card_name: "Test Card",
+ owner: PlayerId(0),
+ zone: engine::types::zones::Zone::Hand,
+ count: 2,
+ attach_to: None,
+ run_etb: true,
+ nonlegendary: true,
+ });
assert!(
result.is_ok(),
"debug create-card should succeed in this fixture: {result:?}"
);
with_state(|state| {
+ assert_eq!(
+ state
+ .objects
+ .values()
+ .filter(|object| object.name == "Test Card")
+ .count(),
+ 2,
+ "a non-battlefield debug CreateCard batch materializes each card"
+ );
let card = state
.objects
.values()
@@ -4630,6 +4619,106 @@ mod replay_bridge_tests {
CARD_DB.with(|c| *c.borrow_mut() = None);
}
+ #[test]
+ fn debug_create_card_battlefield_batch_uses_the_engine_entry_pipeline() {
+ use engine::database::CardDatabase;
+
+ clear_game_state();
+ let db = CardDatabase::from_json_str(
+ r#"{
+ "test card": {
+ "name": "Test Card",
+ "mana_cost": { "type": "NoCost" },
+ "card_type": { "supertypes": [], "core_types": ["Creature"], "subtypes": [] },
+ "power": "1",
+ "toughness": "1",
+ "loyalty": null,
+ "defense": null,
+ "oracle_text": null,
+ "abilities": [],
+ "triggers": [],
+ "static_abilities": [],
+ "replacements": [],
+ "keywords": []
+ }
+ }"#,
+ )
+ .unwrap();
+ CARD_DB.with(|cell| *cell.borrow_mut() = Some(db));
+
+ let mut state = GameState::new_two_player(19);
+ state.debug_mode = true;
+ GAME_STATE.with(|cell| cell.set(Some(state)));
+
+ let result = handle_debug_create_card_inner(DebugCreateCardRequest {
+ actor: PlayerId(0),
+ card_name: "Test Card",
+ owner: PlayerId(0),
+ zone: engine::types::zones::Zone::Battlefield,
+ count: 2,
+ attach_to: None,
+ run_etb: true,
+ nonlegendary: false,
+ })
+ .expect("a real battlefield debug batch should succeed");
+
+ assert!(matches!(
+ result.waiting_for,
+ engine::types::game_state::WaitingFor::Priority { .. }
+ ));
+ with_state(|state| {
+ assert_eq!(
+ state
+ .objects
+ .values()
+ .filter(|object| {
+ object.name == "Test Card"
+ && object.zone == engine::types::zones::Zone::Battlefield
+ })
+ .count(),
+ 2
+ );
+ assert!(state.resolution_stack.is_empty());
+ })
+ .expect("game state should remain initialized");
+
+ clear_game_state();
+ CARD_DB.with(|cell| *cell.borrow_mut() = None);
+ }
+
+ #[test]
+ fn debug_create_card_zero_preserves_replay_recording_without_card_database() {
+ clear_game_state();
+ let mut state = GameState::new_two_player(17);
+ state.debug_mode = true;
+ REPLAY_LOG.with(|cell| {
+ cell.set(Some(ReplayLog::new(ReplayHeader {
+ format_config: state.format_config.clone(),
+ match_config: state.match_config,
+ player_count: state.players.len() as u8,
+ first_player: Some(0),
+ seed: state.rng_seed,
+ deck_data: None,
+ })))
+ });
+ GAME_STATE.with(|cell| cell.set(Some(state)));
+
+ let result = handle_debug_create_card_inner(DebugCreateCardRequest {
+ actor: PlayerId(0),
+ card_name: "not loaded",
+ owner: PlayerId(0),
+ zone: engine::types::zones::Zone::Hand,
+ count: 0,
+ attach_to: None,
+ run_etb: true,
+ nonlegendary: false,
+ });
+ assert!(result.is_ok());
+ assert!(has_replay_recording());
+
+ clear_game_state();
+ }
+
/// A non-`CreateCard` debug action (e.g. `DrawCards`) reaches
/// `record_replay_action` through the normal `submit_action` path — it
/// is not intercepted earlier the way `CreateCard` is. `reconstruct_initial_state`
diff --git a/crates/engine/src/analysis/sim.rs b/crates/engine/src/analysis/sim.rs
index 32be46e2dd..f3e8f25c9a 100644
--- a/crates/engine/src/analysis/sim.rs
+++ b/crates/engine/src/analysis/sim.rs
@@ -339,6 +339,7 @@ mod tests {
action: PlayerActionKind::Proliferate,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
},
];
@@ -410,6 +411,7 @@ mod tests {
action: PlayerActionKind::Scry,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
}],
);
assert!(acc.generic_triggers.is_empty());
diff --git a/crates/engine/src/game/effects/collect_evidence.rs b/crates/engine/src/game/effects/collect_evidence.rs
index 4c39adb711..92873ce736 100644
--- a/crates/engine/src/game/effects/collect_evidence.rs
+++ b/crates/engine/src/game/effects/collect_evidence.rs
@@ -258,6 +258,7 @@ fn complete_cost_payment(
action: PlayerActionKind::CollectEvidence,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
});
match resume {
diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs
index dc83b0c921..d6f50ed51d 100644
--- a/crates/engine/src/game/effects/counters.rs
+++ b/crates/engine/src/game/effects/counters.rs
@@ -415,6 +415,7 @@ pub(crate) fn drain_pending_counter_additions(state: &mut GameState, events: &mu
action: action.action,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
});
}
}
@@ -478,6 +479,7 @@ fn apply_pending_counter_post_action(
action,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
});
true
}
diff --git a/crates/engine/src/game/effects/draw.rs b/crates/engine/src/game/effects/draw.rs
index a4696f82fe..b4d3bdf8e0 100644
--- a/crates/engine/src/game/effects/draw.rs
+++ b/crates/engine/src/game/effects/draw.rs
@@ -464,6 +464,7 @@ fn resume_draw_sequence_outcome(
action: PlayerActionKind::Draw,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
});
}
match frame.origin {
diff --git a/crates/engine/src/game/effects/investigate.rs b/crates/engine/src/game/effects/investigate.rs
index 77477f81dd..1c30066bc6 100644
--- a/crates/engine/src/game/effects/investigate.rs
+++ b/crates/engine/src/game/effects/investigate.rs
@@ -49,6 +49,7 @@ pub fn resolve(
action: PlayerActionKind::Investigate,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
});
Ok(())
diff --git a/crates/engine/src/game/effects/life.rs b/crates/engine/src/game/effects/life.rs
index 4dc3f00632..b2d7ffb687 100644
--- a/crates/engine/src/game/effects/life.rs
+++ b/crates/engine/src/game/effects/life.rs
@@ -519,6 +519,7 @@ fn complete_pending_life_total_assignment(
action: action.action,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
});
}
}
diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs
index 23ec37deb2..f8ce062fa1 100644
--- a/crates/engine/src/game/effects/mod.rs
+++ b/crates/engine/src/game/effects/mod.rs
@@ -920,6 +920,9 @@ pub(crate) fn resume_resolution_frames(state: &mut GameState, events: &mut Vec {
token_copy::drain_pending_copy_token_resolution(state, events);
}
+ ResolutionFrame::DebugCardEntries(_) => {
+ crate::game::engine_debug::drain_pending_debug_card_entries(state, events);
+ }
ResolutionFrame::EachPlayerCopyChosen(_) => {
each_player_copy_chosen::drain_pending(state, events);
}
@@ -14299,6 +14302,7 @@ mod tests {
action: crate::types::events::PlayerActionKind::SearchedLibrary,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
});
let ability = ResolvedAbility::new(
Effect::Draw {
diff --git a/crates/engine/src/game/effects/proliferate.rs b/crates/engine/src/game/effects/proliferate.rs
index c21578a2d8..940952b012 100644
--- a/crates/engine/src/game/effects/proliferate.rs
+++ b/crates/engine/src/game/effects/proliferate.rs
@@ -82,6 +82,7 @@ fn emit_empty_proliferate_action(actor: PlayerId, events: &mut Vec) {
action: PlayerActionKind::Proliferate,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
});
}
diff --git a/crates/engine/src/game/effects/scoped_library_search.rs b/crates/engine/src/game/effects/scoped_library_search.rs
index 06de1a3c05..c77e83464a 100644
--- a/crates/engine/src/game/effects/scoped_library_search.rs
+++ b/crates/engine/src/game/effects/scoped_library_search.rs
@@ -543,6 +543,7 @@ fn prepare_scoped_group(
action: PlayerActionKind::SearchedLibrary,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
});
state.players_who_searched_library_this_turn.insert(player);
state
diff --git a/crates/engine/src/game/effects/scry.rs b/crates/engine/src/game/effects/scry.rs
index 6221e414b2..324f928234 100644
--- a/crates/engine/src/game/effects/scry.rs
+++ b/crates/engine/src/game/effects/scry.rs
@@ -295,6 +295,7 @@ mod tests {
action: crate::types::events::PlayerActionKind::Scry,
look_count: Some(2),
scry_bottom_count: Some(0),
+ scry_top_count: Some(2),
..
}
)));
@@ -339,6 +340,7 @@ mod tests {
action: crate::types::events::PlayerActionKind::Scry,
look_count: Some(2),
scry_bottom_count: Some(1),
+ scry_top_count: Some(1),
..
}
)));
diff --git a/crates/engine/src/game/effects/search_library.rs b/crates/engine/src/game/effects/search_library.rs
index 21504eb8d6..f6ae0d8e2f 100644
--- a/crates/engine/src/game/effects/search_library.rs
+++ b/crates/engine/src/game/effects/search_library.rs
@@ -542,6 +542,7 @@ pub fn resolve(
action: PlayerActionKind::SearchedLibrary,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
});
state
.players_who_searched_library_this_turn
diff --git a/crates/engine/src/game/effects/surveil.rs b/crates/engine/src/game/effects/surveil.rs
index 43473ccf16..fe345964b7 100644
--- a/crates/engine/src/game/effects/surveil.rs
+++ b/crates/engine/src/game/effects/surveil.rs
@@ -50,6 +50,7 @@ pub fn resolve(
action: PlayerActionKind::Surveil,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
});
let cards: Vec<_> = player
diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs
index fa50b0e638..f1d901f398 100644
--- a/crates/engine/src/game/engine.rs
+++ b/crates/engine/src/game/engine.rs
@@ -978,6 +978,22 @@ pub(super) fn apply_action_boundary_with_stack_limit(
mode: PublicFinalizeMode,
stack_resolution_limit: Option,
) -> Result {
+ // A zero-count debug create is intentionally a true no-op. It still passes
+ // both the ordinary action-authority check and the sandbox capability
+ // gate, but must not enter an action lifecycle frame: doing so would bump
+ // revisions, run finalization, and make the WASM adapter invalidate a
+ // replay despite no object having been requested.
+ if let GameAction::Debug(debug_action) = &action {
+ if debug_action.is_zero_count_create() {
+ check_actor_authorization(state, authenticated_actor, &action)?;
+ check_debug_action_access(state, semantic_owner)?;
+ return Ok(ActionResult {
+ events: vec![],
+ waiting_for: state.waiting_for.clone(),
+ log_entries: vec![],
+ });
+ }
+ }
let raw = apply_action_boundary_core(
state,
authenticated_actor,
@@ -4346,7 +4362,7 @@ fn drive_loop_action_iteration(
let source = match &context {
crate::types::game_state::ManaChoiceContext::ManaAbility(p) => p.source_id,
crate::types::game_state::ManaChoiceContext::ResolvingEffect(_) => {
- return Err(RecastAbort)
+ return Err(RecastAbort);
}
};
let color = pinned_mana_color_for_source(template, iteration, clone, source)?;
@@ -7009,16 +7025,10 @@ fn apply_action(
// a defense-in-depth invariant — a player not in `debug_permitted` should
// never have reached `apply`.
if let GameAction::Debug(debug_action) = action {
- if !state.debug_mode {
- return Err(EngineError::InvalidAction(
- "Debug actions require debug_mode to be enabled".into(),
- ));
- }
- if !state.debug_permitted.is_empty() && !state.debug_permitted.contains(&actor) {
- return Err(EngineError::InvalidAction(
- "Debug actions require debug permission".into(),
- ));
- }
+ check_debug_action_access(state, actor)?;
+ debug_action
+ .validate_create_count()
+ .map_err(EngineError::InvalidAction)?;
let description = debug_action.describe(state);
let mut result =
super::engine_debug::apply_debug_action(state, actor, debug_action, &mut events)?;
@@ -10795,6 +10805,7 @@ fn apply_action(
action: PlayerActionKind::Proliferate,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
});
let pending = state
.take_active_proliferate_frame()
@@ -11567,6 +11578,23 @@ fn apply_action(
})
}
+/// Sandbox capability check shared by normal debug actions and a zero-count
+/// create no-op. Keeping it at the engine boundary means transports cannot use
+/// a no-op payload to probe or bypass debug authorization.
+fn check_debug_action_access(state: &GameState, actor: PlayerId) -> Result<(), EngineError> {
+ if !state.debug_mode {
+ return Err(EngineError::InvalidAction(
+ "Debug actions require debug_mode to be enabled".into(),
+ ));
+ }
+ if !state.debug_permitted.is_empty() && !state.debug_permitted.contains(&actor) {
+ return Err(EngineError::InvalidAction(
+ "Debug actions require debug permission".into(),
+ ));
+ }
+ Ok(())
+}
+
struct RetargetSubmission<'a> {
player: PlayerId,
stack_entry_index: usize,
@@ -15963,9 +15991,11 @@ mod stage2_injector_tests {
// `:6210/:6287/:9475 => :6212/:6289/:9477`. The producers remain byte-identical.
// #7018 adds the 16-line distinct-player-scope continuation gate above all
// three producers: `:6212/:6289/:9477 => :6228/:6305/:9493`.
- "game/effects/mod.rs:6228".to_string(),
- "game/effects/mod.rs:6305".to_string(),
- "game/effects/mod.rs:9493".to_string(),
+ // This PR's three-frame debug-entry resumer extends the same
+ // shift: `:6228/:6305/:9493 => :6231/:6308/:9496`.
+ "game/effects/mod.rs:6231".to_string(),
+ "game/effects/mod.rs:6308".to_string(),
+ "game/effects/mod.rs:9496".to_string(),
// UNMOVED across the rebase, and that is itself evidence the SET did not
// move: a census that had gained or lost a producer would not leave this
// entry both byte-identical AND at the same coordinate.
@@ -16280,7 +16310,7 @@ mod stage2_injector_tests {
//
// SET PRESERVATION: unchanged. Upstream adds no line matching the needle to this file and
// neither does this branch — total still 37, partition still 5/7/25.
- "game/engine.rs:11874".to_string(),
+ "game/engine.rs:11902".to_string(),
],
"the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \
plus the two repeated-optional-payment drivers, the per-player acceptance cursor \
diff --git a/crates/engine/src/game/engine_debug.rs b/crates/engine/src/game/engine_debug.rs
index 163772e22b..aed160c845 100644
--- a/crates/engine/src/game/engine_debug.rs
+++ b/crates/engine/src/game/engine_debug.rs
@@ -5,21 +5,25 @@ use crate::types::ability::{
TargetRef,
};
use crate::types::actions::{DebugAction, DebugTokenRequest};
+use crate::types::card::CardFace;
use crate::types::card_type::Supertype;
use crate::types::counter::CounterType;
use crate::types::events::GameEvent;
-use crate::types::game_state::{ActionResult, GameState, WaitingFor};
-use crate::types::identifiers::ObjectId;
+use crate::types::game_state::{
+ ActionResult, DebugCardEntrySource, GameState, PendingDebugCardEntries, WaitingFor,
+};
+use crate::types::identifiers::{CardId, ObjectId};
use crate::types::player::{PlayerCounterKind, PlayerId};
use crate::types::proposed_event::ProposedEvent;
use crate::types::resolved_commands::ResolvedPlayerEdit;
use crate::types::zones::Zone;
-use super::effects::attach::{attach_to, attach_to_player};
+use super::effects::attach::{attach_to as attach_object_to, attach_to_player};
use super::effects::change_zone::shuffle_library;
use super::engine::EngineError;
use super::game_object::AttachTarget;
use super::zones;
+use crate::database::CardDatabase;
use crate::game::token_presets::TokenPtProvenance;
pub fn apply_debug_action(
@@ -28,6 +32,9 @@ pub fn apply_debug_action(
action: DebugAction,
events: &mut Vec,
) -> Result {
+ action
+ .validate_create_count()
+ .map_err(EngineError::InvalidAction)?;
match action {
DebugAction::MoveToZone {
object_id,
@@ -215,9 +222,13 @@ pub fn apply_debug_action(
if delta > 0 {
*obj.counters.entry(counter_type.clone()).or_insert(0) += delta as u32;
} else if delta < 0 {
- let entry = obj.counters.entry(counter_type.clone()).or_insert(0);
- *entry = entry.saturating_sub(delta.unsigned_abs());
- if *entry == 0 {
+ let remove_counter = if let Some(entry) = obj.counters.get_mut(&counter_type) {
+ *entry = entry.saturating_sub(delta.unsigned_abs());
+ *entry == 0
+ } else {
+ false
+ };
+ if remove_counter {
obj.counters.remove(&counter_type);
}
}
@@ -330,7 +341,7 @@ pub fn apply_debug_action(
match target {
AttachTarget::Object(target_id) => {
validate_object(state, target_id)?;
- attach_to(state, object_id, target_id);
+ attach_object_to(state, object_id, target_id);
}
AttachTarget::Player(target_player) => {
validate_player(state, target_player)?;
@@ -460,7 +471,11 @@ pub fn apply_debug_action(
super::triggers::process_triggers(state, events);
}
- DebugAction::CreateToken { request, run_etb } => {
+ DebugAction::CreateToken {
+ request,
+ count,
+ run_etb,
+ } => {
let (owner, characteristics, enter_with_counters, preset_image_ref) = match request {
DebugTokenRequest::Preset {
preset_id,
@@ -535,7 +550,7 @@ pub fn apply_debug_action(
spec: Box::new(spec),
copy: None,
enter_tapped: crate::types::proposed_event::EtbTapState::Unspecified,
- count: 1,
+ count,
applied: HashSet::new(),
};
match super::replacement::replace_event(state, proposed, events) {
@@ -579,6 +594,7 @@ pub fn apply_debug_action(
DebugAction::CreateTokenCopy {
source_id,
owner,
+ count,
nonlegendary,
} => {
validate_object(state, source_id)?;
@@ -590,7 +606,10 @@ pub fn apply_debug_action(
source_filter: None,
enters_attacking: false,
tapped: false,
- count: QuantityExpr::Fixed { value: 1 },
+ count: QuantityExpr::Fixed {
+ value: i32::try_from(count)
+ .expect("debug create count is bounded below i32::MAX"),
+ },
extra_keywords: vec![],
additional_modifications: nonlegendary
.then_some(ContinuousModification::RemoveSupertype {
@@ -776,7 +795,6 @@ pub fn route_debug_create_to_battlefield(
applied: HashSet::new(),
};
- let mut waiting_for = state.waiting_for.clone();
match replacement::replace_event(state, proposed, &mut events) {
ReplacementResult::Execute(event) => {
// CR 614.12a: a Devour as-enters sacrifice may surface its own
@@ -795,7 +813,6 @@ pub fn route_debug_create_to_battlefield(
super::effects::change_zone::ZoneDeliveryResult::Done => {}
super::effects::change_zone::ZoneDeliveryResult::NeedsChoice(player) => {
replacement::park_waiting_for(state, player);
- waiting_for = state.waiting_for.clone();
}
}
super::triggers::process_triggers(state, &events); // CR 603: Process triggers
@@ -803,17 +820,213 @@ pub fn route_debug_create_to_battlefield(
}
ReplacementResult::Prevented => {}
ReplacementResult::NeedsChoice(player) => {
- waiting_for = replacement::replacement_choice_waiting_for(player, state);
+ state.waiting_for = replacement::replacement_choice_waiting_for(player, state);
+ }
+ }
+
+ ActionResult {
+ events,
+ waiting_for: state.waiting_for.clone(),
+ log_entries: vec![],
+ }
+}
+
+/// Bind a debug card request to its complete printed characteristics before a
+/// batch can pause. The source can then survive save/restore without a later
+/// lookup through the adapter-owned card database.
+pub fn debug_card_entry_source(db: &CardDatabase, face: &CardFace) -> DebugCardEntrySource {
+ DebugCardEntrySource {
+ face: face.clone(),
+ back_face: super::printed_cards::back_face_for_card_face(db, face),
+ }
+}
+
+/// Engine input for one debug Create Card request after the transport has
+/// resolved its requested name into a face-complete private source.
+#[derive(Debug, Clone)]
+pub struct DebugCardCreateRequest {
+ pub source: DebugCardEntrySource,
+ pub owner: PlayerId,
+ pub zone: Zone,
+ pub count: u32,
+ pub attach_to: Option,
+ pub run_etb: bool,
+ pub nonlegendary: bool,
+}
+
+/// Create one or more debug cards from a previously bound source. Non-
+/// battlefield creation and explicitly raw battlefield placement complete
+/// synchronously. Real battlefield entries drain serially through the private
+/// resolution frame below.
+pub fn create_debug_cards(state: &mut GameState, request: DebugCardCreateRequest) -> ActionResult {
+ let DebugCardCreateRequest {
+ source,
+ owner,
+ zone,
+ count,
+ attach_to,
+ run_etb,
+ nonlegendary,
+ } = request;
+ let mut events = Vec::new();
+ if count == 0 {
+ return ActionResult {
+ events,
+ waiting_for: state.waiting_for.clone(),
+ log_entries: vec![],
+ };
+ }
+
+ if zone != Zone::Battlefield || !run_etb {
+ for _ in 0..count {
+ let initial_zone = if zone == Zone::Battlefield {
+ Zone::Hand
+ } else {
+ zone
+ };
+ let object_id = materialize_debug_card(
+ state,
+ &source,
+ owner,
+ if zone == Zone::Battlefield {
+ attach_to
+ } else {
+ None
+ },
+ nonlegendary,
+ initial_zone,
+ );
+ if zone == Zone::Battlefield {
+ let entry = route_debug_create_to_battlefield(state, object_id, false);
+ events.extend(entry.events);
+ }
}
+ return ActionResult {
+ events,
+ waiting_for: state.waiting_for.clone(),
+ log_entries: vec![],
+ };
}
+ drain_debug_card_entries(
+ state,
+ PendingDebugCardEntries {
+ source,
+ owner,
+ attach_to,
+ nonlegendary,
+ remaining: count,
+ },
+ &mut events,
+ );
ActionResult {
events,
- waiting_for,
+ waiting_for: state.waiting_for.clone(),
log_entries: vec![],
}
}
+/// Resume the active real-entry debug batch after its exact replacement or
+/// as-enters child has completed.
+pub(crate) fn drain_pending_debug_card_entries(state: &mut GameState, events: &mut Vec) {
+ let Some(pending) = state
+ .take_active_debug_card_entries()
+ .expect("debug-card resumer may consume only its active frame")
+ else {
+ return;
+ };
+ drain_debug_card_entries(state, pending, events);
+}
+
+fn drain_debug_card_entries(
+ state: &mut GameState,
+ mut pending: PendingDebugCardEntries,
+ events: &mut Vec,
+) {
+ while pending.remaining > 0 && matches!(state.waiting_for, WaitingFor::Priority { .. }) {
+ let child_stack_start = state.resolution_stack.len();
+ let object_id = materialize_debug_card(
+ state,
+ &pending.source,
+ pending.owner,
+ pending.attach_to,
+ pending.nonlegendary,
+ Zone::Hand,
+ );
+ pending.remaining -= 1;
+ let entry = route_debug_create_to_battlefield(state, object_id, true);
+ events.extend(entry.events);
+ state.waiting_for = entry.waiting_for;
+
+ if !matches!(state.waiting_for, WaitingFor::Priority { .. })
+ || state.resolution_stack.len() > child_stack_start
+ {
+ if state.resolution_stack.len() > child_stack_start {
+ state
+ .insert_debug_card_entries_parent_at_child_boundary(pending, child_stack_start)
+ .expect("debug-card parent must sit below the entry child stack");
+ } else {
+ state.push_debug_card_entries(pending);
+ }
+ return;
+ }
+ }
+}
+
+fn materialize_debug_card(
+ state: &mut GameState,
+ source: &DebugCardEntrySource,
+ owner: PlayerId,
+ attach_to: Option,
+ nonlegendary: bool,
+ initial_zone: Zone,
+) -> ObjectId {
+ // CR 400.7: The object receives an identity only at the point its own
+ // entry starts; unattempted batch members are not game objects yet.
+ let card_id = CardId(state.next_object_id);
+ let object_id = zones::create_object(
+ state,
+ card_id,
+ owner,
+ source.face.name.clone(),
+ initial_zone,
+ );
+ let object = state
+ .objects
+ .get_mut(&object_id)
+ .expect("just-created debug card");
+ super::printed_cards::apply_card_face_to_object(object, &source.face);
+ object.back_face = source.back_face.clone();
+ // CR 205.4a-b: The sandbox override removes only the legendary
+ // supertype from both copiable and current characteristics.
+ if nonlegendary {
+ object
+ .base_card_types
+ .supertypes
+ .retain(|supertype| *supertype != Supertype::Legendary);
+ object
+ .card_types
+ .supertypes
+ .retain(|supertype| *supertype != Supertype::Legendary);
+ }
+ state.layers_dirty.mark_full();
+
+ if let Some(target) = attach_to {
+ match target {
+ AttachTarget::Object(target_id) if state.objects.contains_key(&target_id) => {
+ attach_object_to(state, object_id, target_id);
+ }
+ AttachTarget::Player(player_id)
+ if state.players.iter().any(|player| player.id == player_id) =>
+ {
+ attach_to_player(state, object_id, player_id);
+ }
+ AttachTarget::Object(_) | AttachTarget::Player(_) => {}
+ }
+ }
+ object_id
+}
+
fn validate_object(state: &GameState, object_id: ObjectId) -> Result<(), EngineError> {
if !state.objects.contains_key(&object_id) {
return Err(EngineError::InvalidAction(format!(
@@ -848,15 +1061,20 @@ mod tests {
use super::*;
use crate::game::game_object::BackFaceData;
use crate::game::zones::create_object;
- use crate::types::ability::{AbilityDefinition, AbilityKind};
+ use crate::game::{apply_as_current, filter_state_for_viewer};
+ use crate::types::ability::{
+ AbilityDefinition, AbilityKind, ReplacementDefinition, ReplacementMode,
+ };
use crate::types::actions::GameAction;
use crate::types::card::LayoutKind;
use crate::types::definitions::Definitions;
use crate::types::format::FormatConfig;
+ use crate::types::game_state::PersistedGameState;
use crate::types::identifiers::CardId;
use crate::types::keywords::Keyword;
use crate::types::mana::{ManaColor, ManaCost};
use crate::types::proposed_event::TokenCharacteristics;
+ use crate::types::replacements::ReplacementEvent;
use crate::types::CoreType;
fn sandbox_state() -> GameState {
@@ -865,6 +1083,176 @@ mod tests {
state
}
+ #[test]
+ fn debug_create_card_batch_enters_battlefield_serially() {
+ let mut state = sandbox_state();
+ let source = DebugCardEntrySource {
+ face: CardFace {
+ name: "Debug Batch Creature".into(),
+ ..Default::default()
+ },
+ back_face: None,
+ };
+
+ let result = create_debug_cards(
+ &mut state,
+ DebugCardCreateRequest {
+ source,
+ owner: PlayerId(0),
+ zone: Zone::Battlefield,
+ count: 2,
+ attach_to: None,
+ run_etb: true,
+ nonlegendary: false,
+ },
+ );
+
+ assert!(matches!(result.waiting_for, WaitingFor::Priority { .. }));
+ assert!(state.resolution_stack.is_empty());
+ assert_eq!(
+ state
+ .objects
+ .values()
+ .filter(|object| {
+ object.name == "Debug Batch Creature" && object.zone == Zone::Battlefield
+ })
+ .count(),
+ 2
+ );
+ }
+
+ #[test]
+ fn debug_card_entry_batch_persists_its_unmaterialized_source() {
+ let mut state = sandbox_state();
+ state.push_debug_card_entries(PendingDebugCardEntries {
+ source: DebugCardEntrySource {
+ face: CardFace {
+ name: "Persisted Debug Card".into(),
+ ..Default::default()
+ },
+ back_face: None,
+ },
+ owner: PlayerId(0),
+ attach_to: None,
+ nonlegendary: false,
+ remaining: 1,
+ });
+
+ let serialized = serde_json::to_string(&state).expect("debug batch should serialize");
+ let restored: GameState =
+ serde_json::from_str(&serialized).expect("debug batch should deserialize");
+ let pending = restored
+ .active_debug_card_entries()
+ .expect("serialized debug batch should remain active");
+ assert_eq!(pending.remaining, 1);
+ assert_eq!(pending.source.face.name, "Persisted Debug Card");
+ assert!(restored
+ .objects
+ .values()
+ .all(|object| object.name != "Persisted Debug Card"));
+ }
+
+ /// CR 400.7 + CR 614.1 + CR 616.1: A sandbox batch may pause while each
+ /// card enters. Only the active entrant is materialized; the later member
+ /// stays in the private resolution frame across persistence, then enters
+ /// exactly once after the replacement choice resolves.
+ #[test]
+ fn debug_card_entry_batch_resumes_after_persisted_replacement_choice() {
+ let mut state = sandbox_state();
+ let replacement_host = create_object(
+ &mut state,
+ CardId(900),
+ PlayerId(1),
+ "Debug entry replacement".into(),
+ Zone::Battlefield,
+ );
+ state
+ .objects
+ .get_mut(&replacement_host)
+ .expect("replacement host exists")
+ .replacement_definitions
+ .push(
+ ReplacementDefinition::new(ReplacementEvent::Moved)
+ .mode(ReplacementMode::Optional { decline: None })
+ .description("Debug entry replacement".into()),
+ );
+
+ let result = create_debug_cards(
+ &mut state,
+ DebugCardCreateRequest {
+ source: DebugCardEntrySource {
+ face: CardFace {
+ name: "Paused Debug Batch Creature".into(),
+ ..Default::default()
+ },
+ back_face: None,
+ },
+ owner: PlayerId(0),
+ zone: Zone::Battlefield,
+ count: 2,
+ attach_to: None,
+ run_etb: true,
+ nonlegendary: false,
+ },
+ );
+
+ assert!(matches!(
+ result.waiting_for,
+ WaitingFor::ReplacementChoice { .. }
+ ));
+ assert_eq!(
+ state
+ .objects
+ .values()
+ .filter(|object| object.name == "Paused Debug Batch Creature")
+ .count(),
+ 1,
+ "only the entrant that is waiting on a replacement choice is materialized"
+ );
+ assert_eq!(
+ state
+ .active_debug_card_entries()
+ .expect("the remaining batch member is parked")
+ .remaining,
+ 1
+ );
+ assert!(
+ filter_state_for_viewer(&state, PlayerId(1))
+ .resolution_stack
+ .is_empty(),
+ "the private source/frame never crosses a viewer-state boundary"
+ );
+
+ let persisted = PersistedGameState::capture(state);
+ let serialized = serde_json::to_string(&persisted).expect("paused batch serializes");
+ let persisted: PersistedGameState =
+ serde_json::from_str(&serialized).expect("paused batch deserializes");
+ let mut restored = persisted.into_game_state();
+ apply_as_current(&mut restored, GameAction::ChooseReplacement { index: 0 })
+ .expect("replacement choice resumes the serial batch");
+
+ assert!(matches!(
+ restored.waiting_for,
+ WaitingFor::ReplacementChoice { .. }
+ ));
+ apply_as_current(&mut restored, GameAction::ChooseReplacement { index: 0 })
+ .expect("the remaining entrant presents and resumes its own replacement choice");
+
+ assert!(matches!(restored.waiting_for, WaitingFor::Priority { .. }));
+ assert!(restored.resolution_stack.is_empty());
+ assert_eq!(
+ restored
+ .objects
+ .values()
+ .filter(|object| {
+ object.name == "Paused Debug Batch Creature" && object.zone == Zone::Battlefield
+ })
+ .count(),
+ 2,
+ "the resumed entry and the single remaining batch member each enter once"
+ );
+ }
+
/// CR 118.3a regression: debug-added mana must route through the stamping
/// authority so each unit gets a DISTINCT, nonzero `pip_id`. A bare
/// `mana_pool.add` leaves every unit at the unstamped sentinel (0), which
@@ -975,6 +1363,7 @@ mod tests {
toughness_override: None,
enter_with_counters: Vec::new(),
},
+ count: 1,
run_etb: true,
});
let result = crate::game::engine::apply(&mut state, PlayerId(0), action)
@@ -1023,6 +1412,7 @@ mod tests {
toughness_override: None,
enter_with_counters: Vec::new(),
},
+ count: 1,
run_etb: true,
});
@@ -1043,6 +1433,7 @@ mod tests {
toughness_override: Some(5),
enter_with_counters: Vec::new(),
},
+ count: 1,
run_etb: true,
});
let result = crate::game::engine::apply(&mut state, PlayerId(0), action)
@@ -1073,6 +1464,7 @@ mod tests {
toughness_override: Some(5),
enter_with_counters: Vec::new(),
},
+ count: 1,
run_etb: true,
});
@@ -1091,6 +1483,7 @@ mod tests {
characteristics: zero_zero_creature(),
enter_with_counters: vec![(CounterType::Plus1Plus1, 2)],
},
+ count: 1,
run_etb: true,
});
let result = crate::game::engine::apply(&mut state, PlayerId(0), action)
@@ -1117,6 +1510,60 @@ mod tests {
);
}
+ #[test]
+ fn debug_create_token_batch_uses_one_replacement_event() {
+ let mut state = sandbox_state();
+ let result = crate::game::engine::apply(
+ &mut state,
+ PlayerId(0),
+ GameAction::Debug(DebugAction::CreateToken {
+ request: DebugTokenRequest::Custom {
+ owner: PlayerId(0),
+ characteristics: zero_zero_creature(),
+ enter_with_counters: vec![(CounterType::Plus1Plus1, 1)],
+ },
+ count: 2,
+ run_etb: true,
+ }),
+ )
+ .expect("a two-token debug batch should use the normal token pipeline");
+
+ assert_eq!(
+ result
+ .events
+ .iter()
+ .filter(|event| matches!(event, GameEvent::TokenCreated { .. }))
+ .count(),
+ 2,
+ "the count must reach the single CreateToken replacement event"
+ );
+ }
+
+ #[test]
+ fn debug_create_zero_is_authorized_noop_without_finalization() {
+ let mut state = sandbox_state();
+ let revision = state.state_revision;
+ let result = crate::game::engine::apply(
+ &mut state,
+ PlayerId(0),
+ GameAction::Debug(DebugAction::CreateToken {
+ request: DebugTokenRequest::Custom {
+ owner: PlayerId(0),
+ characteristics: zero_zero_creature(),
+ enter_with_counters: Vec::new(),
+ },
+ count: 0,
+ run_etb: true,
+ }),
+ )
+ .expect("an authorized zero-count create must be a no-op");
+
+ assert_eq!(state.state_revision, revision);
+ assert!(state.objects.is_empty());
+ assert!(result.events.is_empty());
+ assert!(result.log_entries.is_empty());
+ }
+
#[test]
fn debug_proliferate_starts_real_choice() {
let mut state = sandbox_state();
@@ -1179,11 +1626,22 @@ mod tests {
GameAction::Debug(DebugAction::CreateTokenCopy {
source_id,
owner: PlayerId(1),
+ count: 2,
nonlegendary: false,
}),
)
.expect("debug CreateTokenCopy should succeed");
+ assert_eq!(
+ result
+ .events
+ .iter()
+ .filter(|event| matches!(event, GameEvent::TokenCreated { .. }))
+ .count(),
+ 2,
+ "copy count must reach the existing CopyTokenOf resolver"
+ );
+
let token_id = result
.events
.iter()
@@ -1224,6 +1682,7 @@ mod tests {
GameAction::Debug(DebugAction::CreateTokenCopy {
source_id,
owner: PlayerId(0),
+ count: 1,
nonlegendary: true,
}),
)
@@ -1635,6 +2094,37 @@ mod tests {
)));
}
+ #[test]
+ fn debug_counter_decrement_handles_i32_min_without_overflow() {
+ let mut state = sandbox_state();
+ let object_id = create_object(
+ &mut state,
+ CardId(1),
+ PlayerId(0),
+ "Counter Bearer".to_string(),
+ Zone::Battlefield,
+ );
+ state
+ .objects
+ .get_mut(&object_id)
+ .unwrap()
+ .counters
+ .insert(CounterType::Generic("test".to_string()), 1);
+
+ crate::game::engine::apply(
+ &mut state,
+ PlayerId(0),
+ GameAction::Debug(DebugAction::ModifyCounters {
+ object_id,
+ counter_type: CounterType::Generic("test".to_string()),
+ delta: i32::MIN,
+ }),
+ )
+ .expect("the largest representable decrement must saturate safely");
+
+ assert!(state.objects[&object_id].counters.is_empty());
+ }
+
#[test]
fn debug_modify_absent_player_counter_emits_no_event() {
let mut state = sandbox_state();
@@ -1715,6 +2205,7 @@ mod tests {
characteristics: zero_zero_creature(),
enter_with_counters: Vec::new(),
},
+ count: 1,
run_etb: true,
});
let result = crate::game::engine::apply(&mut state, PlayerId(0), action)
diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs
index 2c085f1502..31f035a23f 100644
--- a/crates/engine/src/game/engine_replacement.rs
+++ b/crates/engine/src/game/engine_replacement.rs
@@ -1405,9 +1405,11 @@ pub(super) fn handle_replacement_choice(
}
}
}
- Ok(WaitingFor::Priority {
+ state.waiting_for = WaitingFor::Priority {
player: state.active_player,
- })
+ };
+ super::engine::resume_pending_continuation_if_priority(state, events)?;
+ Ok(state.waiting_for.clone())
}
}
}
diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs
index 5359e8fc15..7c45898bab 100644
--- a/crates/engine/src/game/engine_resolution_choices.rs
+++ b/crates/engine/src/game/engine_resolution_choices.rs
@@ -1575,6 +1575,7 @@ pub(super) fn handle_resolution_choice(
action: crate::types::events::PlayerActionKind::Scry,
look_count: Some(all_cards.len() as u32),
scry_bottom_count: Some(bottom_cards.len() as u32),
+ scry_top_count: Some(all_cards.len() as u32 - bottom_cards.len() as u32),
});
// CR 401.5 + CR 611.3a: Scry reorders the library top directly (not
// through the zone-move seam), so a continuous `TopOfLibraryMatches`
diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs
index eb895ed7ae..2e87a31108 100644
--- a/crates/engine/src/game/game_object.rs
+++ b/crates/engine/src/game/game_object.rs
@@ -211,7 +211,7 @@ pub enum PhaseOutCause {
/// Stored back-face data for double-faced cards (DFCs).
/// Populated when a Transform-layout card enters the game.
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BackFaceData {
pub name: String,
pub power: Option,
diff --git a/crates/engine/src/game/library.rs b/crates/engine/src/game/library.rs
index a548edee9e..1ebb74451f 100644
--- a/crates/engine/src/game/library.rs
+++ b/crates/engine/src/game/library.rs
@@ -116,6 +116,7 @@ pub fn apply_resolved_library_shuffle(
action: PlayerActionKind::ShuffledLibrary,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
});
Ok(())
}
@@ -177,6 +178,7 @@ mod tests {
action: PlayerActionKind::ShuffledLibrary,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
}]
);
}
diff --git a/crates/engine/src/game/log.rs b/crates/engine/src/game/log.rs
index a5847d770b..22b6f4675b 100644
--- a/crates/engine/src/game/log.rs
+++ b/crates/engine/src/game/log.rs
@@ -655,6 +655,22 @@ fn format_segments(event: &GameEvent, state: &GameState) -> Vec {
vec![player_seg(state, *player_id), text(" passes priority")]
}
+ GameEvent::PlayerPerformedAction {
+ player_id,
+ action: crate::types::events::PlayerActionKind::Scry,
+ look_count: Some(look_count),
+ scry_bottom_count: Some(scry_bottom_count),
+ ..
+ } => vec![
+ player_seg(state, *player_id),
+ text(" scries "),
+ num(*look_count as i32),
+ text(": "),
+ num(look_count.saturating_sub(*scry_bottom_count) as i32),
+ text(" on top and "),
+ num(*scry_bottom_count as i32),
+ text(" on bottom"),
+ ],
GameEvent::PlayerPerformedAction {
player_id, action, ..
} => vec![
@@ -1730,6 +1746,41 @@ mod tests {
);
}
+ #[test]
+ fn completed_scry_has_a_public_count_only_log_entry() {
+ let state = GameState::new_two_player(42);
+ let entries = resolve_log_entries(
+ &[GameEvent::PlayerPerformedAction {
+ player_id: PlayerId(0),
+ action: crate::types::events::PlayerActionKind::Scry,
+ look_count: Some(3),
+ scry_bottom_count: Some(2),
+ scry_top_count: Some(1),
+ }],
+ &state,
+ &state,
+ );
+
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].presentation.visibility, LogVisibility::Public);
+ assert_eq!(
+ entries[0].segments,
+ vec![
+ LogSegment::PlayerName {
+ name: "Player 1".to_string(),
+ player_id: PlayerId(0),
+ },
+ LogSegment::Text(" scries ".to_string()),
+ LogSegment::Number(3),
+ LogSegment::Text(": ".to_string()),
+ LogSegment::Number(1),
+ LogSegment::Text(" on top and ".to_string()),
+ LogSegment::Number(2),
+ LogSegment::Text(" on bottom".to_string()),
+ ]
+ );
+ }
+
#[test]
fn public_log_hides_hand_to_library_but_keeps_public_discard() {
use crate::types::game_state::ZoneChangeRecord;
@@ -1837,6 +1888,7 @@ mod tests {
action: PlayerActionKind::Draw,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
};
let draw_entries = resolve_log_entries(&[draw_event], &state, &state);
assert!(
@@ -1852,6 +1904,7 @@ mod tests {
action: PlayerActionKind::Scry,
look_count: Some(1),
scry_bottom_count: Some(0),
+ scry_top_count: Some(1),
};
let scry_entries = resolve_log_entries(&[scry_event], &state, &state);
assert_eq!(
diff --git a/crates/engine/src/game/mod.rs b/crates/engine/src/game/mod.rs
index af8baa31cc..f656c910bf 100644
--- a/crates/engine/src/game/mod.rs
+++ b/crates/engine/src/game/mod.rs
@@ -220,7 +220,10 @@ pub use engine::{
apply, apply_as_current, new_game, start_game, start_game_skip_mulligan,
start_game_with_starting_player, EngineError,
};
-pub use engine_debug::route_debug_create_to_battlefield;
+pub use engine_debug::{
+ create_debug_cards, debug_card_entry_source, route_debug_create_to_battlefield,
+ DebugCardCreateRequest,
+};
pub use engine_resolve_batch::{
resolve_all_fast_forward, ResolveAllCallbackDecision, ResolveAllFastForwardResult,
};
diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs
index da67332df6..b156e6c9c0 100644
--- a/crates/engine/src/game/printed_cards.rs
+++ b/crates/engine/src/game/printed_cards.rs
@@ -972,17 +972,20 @@ pub(crate) fn build_conjure_registry(
(registry, all_collected)
}
-/// CR 712 / CR 715 / CR 722: Attach the other printed face to `obj.back_face`
-/// when absent. Required for transformed zone changes (Fable of the
-/// Mirror-Breaker chapter III, Ajani flip triggers), adventurer casts, MDFC
-/// casts, and prepare spell access. Without this, `deliver_replaced_zone_change`
-/// silently skips transform when `back_face` is `None` and saga ETB lore-counter
-/// replacements fire on the front face.
-pub fn populate_back_face_if_dfc(obj: &mut GameObject, db: &CardDatabase, card_face: &CardFace) {
- if obj.back_face.is_some() {
- return;
- }
+/// CR 712 / CR 715 / CR 722: Build the other printed face for a face-complete
+/// card source. This is shared by normal database hydration and debug card
+/// batches so a paused batch can retain DFC/Adventure/Omen/Meld/Prepare data
+/// without consulting the card database again on resume.
+pub fn back_face_for_card_face(db: &CardDatabase, card_face: &CardFace) -> Option {
+ let printed_ref = printed_ref_from_face(card_face);
+ back_face_for_card_face_with_printed_ref(db, card_face, printed_ref.as_ref())
+}
+fn back_face_for_card_face_with_printed_ref(
+ db: &CardDatabase,
+ card_face: &CardFace,
+ printed_ref: Option<&PrintedCardRef>,
+) -> Option {
let second_face = db
.get_by_name(&card_face.name)
.and_then(|card_rules| match &card_rules.layout {
@@ -1010,14 +1013,11 @@ pub fn populate_back_face_if_dfc(obj: &mut GameObject, db: &CardDatabase, card_f
.as_deref()
.and_then(|id| db.get_layout_kind(id))
.unwrap_or(LayoutKind::Single);
- obj.printed_ref
- .as_ref()
+ printed_ref
.and_then(|printed_ref| db.get_other_face_by_printed_ref(printed_ref))
.map(|face| (layout_kind, face))
});
- let Some((layout_kind, face)) = second_face else {
- return;
- };
+ let (layout_kind, face) = second_face?;
let mut back = BackFaceData {
name: String::new(),
@@ -1046,7 +1046,20 @@ pub fn populate_back_face_if_dfc(obj: &mut GameObject, db: &CardDatabase, card_f
if layout_kind != LayoutKind::Single {
back.layout_kind = Some(layout_kind);
}
- obj.back_face = Some(back);
+ Some(back)
+}
+
+/// CR 712 / CR 715 / CR 722: Attach the other printed face to `obj.back_face`
+/// when absent. Required for transformed zone changes (Fable of the
+/// Mirror-Breaker chapter III, Ajani flip triggers), adventurer casts, MDFC
+/// casts, and prepare spell access. Without this, `deliver_replaced_zone_change`
+/// silently skips transform when `back_face` is `None` and saga ETB lore-counter
+/// replacements fire on the front face.
+pub fn populate_back_face_if_dfc(obj: &mut GameObject, db: &CardDatabase, card_face: &CardFace) {
+ if obj.back_face.is_none() {
+ obj.back_face =
+ back_face_for_card_face_with_printed_ref(db, card_face, obj.printed_ref.as_ref());
+ }
}
pub fn rehydrate_game_from_card_db(state: &mut GameState, db: &CardDatabase) {
diff --git a/crates/engine/src/game/targeting.rs b/crates/engine/src/game/targeting.rs
index 06c1ea9acc..71e814ba41 100644
--- a/crates/engine/src/game/targeting.rs
+++ b/crates/engine/src/game/targeting.rs
@@ -4402,6 +4402,7 @@ mod tests {
action: crate::types::events::PlayerActionKind::Scry,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
};
let result = extract_player_from_event(&event, &state);
assert_eq!(result, Some(PlayerId(1)));
diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs
index 43f74fbc3f..daa4f13b99 100644
--- a/crates/engine/src/game/trigger_matchers.rs
+++ b/crates/engine/src/game/trigger_matchers.rs
@@ -7808,6 +7808,7 @@ mod tests {
action: PlayerActionKind::SearchedLibrary,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
};
assert!(match_player_action(
&event,
@@ -7836,6 +7837,7 @@ mod tests {
action: PlayerActionKind::SearchedLibrary,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
};
assert!(!match_player_action(
&event,
@@ -7864,6 +7866,7 @@ mod tests {
action: PlayerActionKind::SearchedLibrary,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
};
assert!(match_player_action(
&event,
@@ -7892,6 +7895,7 @@ mod tests {
action: PlayerActionKind::Surveil,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
};
assert!(match_player_action(
&event,
@@ -7920,6 +7924,7 @@ mod tests {
action: PlayerActionKind::SearchedLibrary,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
};
assert!(!match_player_action(
&event,
@@ -7948,6 +7953,7 @@ mod tests {
action: PlayerActionKind::Proliferate,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
};
assert!(match_player_action(
&event,
@@ -11032,6 +11038,7 @@ mod tests {
action: PlayerActionKind::ShuffledLibrary,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
};
let trigger = make_trigger(TriggerMode::Shuffled);
assert!(match_shuffled(
@@ -11064,6 +11071,7 @@ mod tests {
action: PlayerActionKind::ShuffledLibrary,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
};
assert!(match_shuffled(
&opp_event,
@@ -11078,6 +11086,7 @@ mod tests {
action: PlayerActionKind::ShuffledLibrary,
look_count: None,
scry_bottom_count: None,
+ scry_top_count: None,
};
assert!(!match_shuffled(
&self_event,
diff --git a/crates/engine/src/types/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs
index f84e3f590b..bedb6053ed 100644
--- a/crates/engine/src/types/action_stable_order.rs
+++ b/crates/engine/src/types/action_stable_order.rs
@@ -153,9 +153,7 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering {
let GameAction::ChooseExert { exert: b0 } = b else {
unreachable!("cmp_payload: same-variant invariant");
};
- {
- cmp_val(a0, b0)
- }
+ cmp_val(a0, b0)
}
GameAction::ChooseEnlist { target: a0 } => {
let GameAction::ChooseEnlist { target: b0 } = b else {
@@ -169,7 +167,9 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering {
let GameAction::ChooseClashOpponent { opponent: b0 } = b else {
unreachable!("cmp_payload: same-variant invariant");
};
- cmp_val(a0, b0)
+ {
+ cmp_val(a0, b0)
+ }
}
GameAction::ChooseZoneOpponentChooser { opponent: a0 } => {
let GameAction::ChooseZoneOpponentChooser { opponent: b0 } = b else {
@@ -217,9 +217,7 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering {
let GameAction::ReorderHand { order: b0 } = b else {
unreachable!("cmp_payload: same-variant invariant");
};
- {
- cmp_val(a0, b0)
- }
+ cmp_val(a0, b0)
}
GameAction::TapLandForMana { selection: a0 } => {
let GameAction::TapLandForMana { selection: b0 } = b else {
@@ -243,35 +241,37 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering {
let GameAction::UntapLandForMana { object_id: b0 } = b else {
unreachable!("cmp_payload: same-variant invariant");
};
- cmp_val(a0, b0)
+ {
+ cmp_val(a0, b0)
+ }
}
GameAction::SpendPoolMana { pip_id: a0 } => {
let GameAction::SpendPoolMana { pip_id: b0 } = b else {
unreachable!("cmp_payload: same-variant invariant");
};
- {
- cmp_val(a0, b0)
- }
+ cmp_val(a0, b0)
}
GameAction::UnspendPoolMana { pip_id: a0 } => {
let GameAction::UnspendPoolMana { pip_id: b0 } = b else {
unreachable!("cmp_payload: same-variant invariant");
};
- cmp_val(a0, b0)
+ {
+ cmp_val(a0, b0)
+ }
}
GameAction::SelectCards { cards: a0 } => {
let GameAction::SelectCards { cards: b0 } = b else {
unreachable!("cmp_payload: same-variant invariant");
};
- {
- cmp_val(a0, b0)
- }
+ cmp_val(a0, b0)
}
GameAction::ChooseRemoveCounterCostDistribution { distribution: a0 } => {
let GameAction::ChooseRemoveCounterCostDistribution { distribution: b0 } = b else {
unreachable!("cmp_payload: same-variant invariant");
};
- cmp_val(a0, b0)
+ {
+ cmp_val(a0, b0)
+ }
}
GameAction::SelectCoinFlips { keep_indices: a0 } => {
let GameAction::SelectCoinFlips { keep_indices: b0 } = b else {
@@ -907,9 +907,7 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering {
let GameAction::SetPriorityYield { op: b0 } = b else {
unreachable!("cmp_payload: same-variant invariant");
};
- {
- cmp_val(a0, b0)
- }
+ cmp_val(a0, b0)
}
GameAction::SetMayTriggerAutoChoice { op: a0 } => {
let GameAction::SetMayTriggerAutoChoice { op: b0 } = b else {
@@ -983,9 +981,7 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering {
let GameAction::LearnDecision { choice: b0 } = b else {
unreachable!("cmp_payload: same-variant invariant");
};
- {
- cmp_val(a0, b0)
- }
+ cmp_val(a0, b0)
}
GameAction::SelectCategoryPermanents { choices: a0 } => {
let GameAction::SelectCategoryPermanents { choices: b0 } = b else {
@@ -1082,9 +1078,7 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering {
let GameAction::Concede { player_id: b0 } = b else {
unreachable!("cmp_payload: same-variant invariant");
};
- {
- cmp_val(a0, b0)
- }
+ cmp_val(a0, b0)
}
GameAction::DeclareShortcut {
count: a0,
@@ -1203,17 +1197,19 @@ fn cmp_debug_action_payload(a: &DebugAction, b: &DebugAction) -> Ordering {
card_name: a0,
owner: a1,
zone: a2,
- attach_to: a3,
- run_etb: a4,
- nonlegendary: a5,
+ count: a3,
+ attach_to: a4,
+ run_etb: a5,
+ nonlegendary: a6,
} => {
let DebugAction::CreateCard {
card_name: b0,
owner: b1,
zone: b2,
- attach_to: b3,
- run_etb: b4,
- nonlegendary: b5,
+ count: b3,
+ attach_to: b4,
+ run_etb: b5,
+ nonlegendary: b6,
} = b
else {
unreachable!("cmp_debug_action_payload: same-variant invariant");
@@ -1224,6 +1220,7 @@ fn cmp_debug_action_payload(a: &DebugAction, b: &DebugAction) -> Ordering {
.then_with(|| cmp_val(a3, b3))
.then_with(|| cmp_val(a4, b4))
.then_with(|| cmp_val(a5, b5))
+ .then_with(|| cmp_val(a6, b6))
}
DebugAction::RemoveObject { object_id: a0 } => {
let DebugAction::RemoveObject { object_id: b0 } = b else {
@@ -1235,9 +1232,7 @@ fn cmp_debug_action_payload(a: &DebugAction, b: &DebugAction) -> Ordering {
let DebugAction::Sacrifice { object_id: b0 } = b else {
unreachable!("cmp_debug_action_payload: same-variant invariant");
};
- {
- cmp_val(a0, b0)
- }
+ cmp_val(a0, b0)
}
DebugAction::DrawCards {
player_id: a0,
@@ -1413,9 +1408,7 @@ fn cmp_debug_action_payload(a: &DebugAction, b: &DebugAction) -> Ordering {
let DebugAction::Detach { object_id: b0 } = b else {
unreachable!("cmp_debug_action_payload: same-variant invariant");
};
- {
- cmp_val(a0, b0)
- }
+ cmp_val(a0, b0)
}
DebugAction::GrantKeyword {
object_id: a0,
@@ -1533,26 +1526,32 @@ fn cmp_debug_action_payload(a: &DebugAction, b: &DebugAction) -> Ordering {
}
DebugAction::CreateToken {
request: a0,
- run_etb: a1,
+ count: a1,
+ run_etb: a2,
} => {
let DebugAction::CreateToken {
request: b0,
- run_etb: b1,
+ count: b1,
+ run_etb: b2,
} = b
else {
unreachable!("cmp_debug_action_payload: same-variant invariant");
};
- cmp_debug_token_request(a0, b0).then_with(|| cmp_val(a1, b1))
+ cmp_debug_token_request(a0, b0)
+ .then_with(|| cmp_val(a1, b1))
+ .then_with(|| cmp_val(a2, b2))
}
DebugAction::CreateTokenCopy {
source_id: a0,
owner: a1,
- nonlegendary: a2,
+ count: a2,
+ nonlegendary: a3,
} => {
let DebugAction::CreateTokenCopy {
source_id: b0,
owner: b1,
- nonlegendary: b2,
+ count: b2,
+ nonlegendary: b3,
} = b
else {
unreachable!("cmp_debug_action_payload: same-variant invariant");
@@ -1560,6 +1559,7 @@ fn cmp_debug_action_payload(a: &DebugAction, b: &DebugAction) -> Ordering {
cmp_val(a0, b0)
.then_with(|| cmp_val(a1, b1))
.then_with(|| cmp_val(a2, b2))
+ .then_with(|| cmp_val(a3, b3))
}
}
}
diff --git a/crates/engine/src/types/actions.rs b/crates/engine/src/types/actions.rs
index ffb9eea08f..317a24f56e 100644
--- a/crates/engine/src/types/actions.rs
+++ b/crates/engine/src/types/actions.rs
@@ -1004,6 +1004,16 @@ fn default_true() -> bool {
true
}
+/// Default and maximum debug-spawn batch sizes. The ceiling is deliberately
+/// small relative to the server's 10,000-object snapshot ceiling: debug spawns
+/// can still be multiplied by ordinary token replacement effects.
+pub const MAX_DEBUG_CREATE_COUNT: u32 = 100;
+
+/// Serde default for debug create counts: legacy payloads create one object.
+fn default_debug_create_count() -> u32 {
+ 1
+}
+
/// Direct game-state manipulation actions for debugging, testing, and remediation.
/// Bypasses `WaitingFor` validation — fires from any game state without disrupting
/// the current prompt. Gated on `GameState::debug_mode`.
@@ -1033,6 +1043,11 @@ pub enum DebugAction {
card_name: String,
owner: PlayerId,
zone: Zone,
+ /// Number of card objects to create. The WASM card-database bridge
+ /// currently supports one-at-a-time materialization only, because an
+ /// entry can pause for a replacement or ETB choice.
+ #[serde(default = "default_debug_create_count")]
+ count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
attach_to: Option,
/// When `true`, route a `Battlefield` spawn through the real ETB pipeline
@@ -1178,6 +1193,10 @@ pub enum DebugAction {
/// pass are skipped — mirrors `MoveToZone { simulate: false }`.
CreateToken {
request: DebugTokenRequest,
+ /// Number of tokens proposed in one creation event. This intentionally
+ /// reaches the normal replacement pipeline as one batch.
+ #[serde(default = "default_debug_create_count")]
+ count: u32,
#[serde(default = "default_true")]
run_etb: bool,
},
@@ -1186,6 +1205,9 @@ pub enum DebugAction {
CreateTokenCopy {
source_id: ObjectId,
owner: PlayerId,
+ /// Number of token copies created by the normal copy-token resolver.
+ #[serde(default = "default_debug_create_count")]
+ count: u32,
/// Apply the existing `RemoveSupertype(Legendary)` copy modification
/// while synthesizing the token.
#[serde(default)]
@@ -1236,6 +1258,35 @@ impl DebugTokenRequest {
}
impl DebugAction {
+ /// A zero-count create request is an authorized, state-preserving no-op.
+ /// The action boundary recognizes it before lifecycle/finalization work so
+ /// UI count controls can submit zero without invalidating replays.
+ pub fn is_zero_count_create(&self) -> bool {
+ matches!(
+ self,
+ Self::CreateCard { count: 0, .. }
+ | Self::CreateToken { count: 0, .. }
+ | Self::CreateTokenCopy { count: 0, .. }
+ )
+ }
+
+ /// Rejects hostile or accidental debug spawn batches before they allocate
+ /// objects. Zero is legal and is handled as a no-op by the action boundary.
+ pub fn validate_create_count(&self) -> Result<(), String> {
+ let count = match self {
+ Self::CreateCard { count, .. }
+ | Self::CreateToken { count, .. }
+ | Self::CreateTokenCopy { count, .. } => *count,
+ _ => return Ok(()),
+ };
+ if count > MAX_DEBUG_CREATE_COUNT {
+ return Err(format!(
+ "Debug create count {count} exceeds the maximum {MAX_DEBUG_CREATE_COUNT}"
+ ));
+ }
+ Ok(())
+ }
+
/// Human-readable description of this debug action, used by the sandbox
/// audit log so all players see what an authorized debugger did. Engine
/// owns the wording so the FE remains a pure display layer.
@@ -1282,6 +1333,7 @@ impl DebugAction {
card_name,
owner,
zone,
+ count,
attach_to,
run_etb,
nonlegendary,
@@ -1296,8 +1348,9 @@ impl DebugAction {
let etb_suffix = if *run_etb { "" } else { " (no ETB)" };
let nonlegendary_suffix = if *nonlegendary { " (nonlegendary)" } else { "" };
format!(
- "CreateCard ({} for {} in {:?}{}{}{})",
+ "CreateCard ({} ×{} for {} in {:?}{}{}{})",
card_name,
+ count,
player_label(*owner),
zone,
attach_suffix,
@@ -1433,7 +1486,11 @@ impl DebugAction {
player_label(*active_player)
),
DebugAction::RunStateBasedActions => "RunStateBasedActions".to_string(),
- DebugAction::CreateToken { request, run_etb } => {
+ DebugAction::CreateToken {
+ request,
+ count,
+ run_etb,
+ } => {
let counters = if request.enter_with_counters().is_empty() {
String::new()
} else {
@@ -1464,8 +1521,9 @@ impl DebugAction {
} => characteristics.display_name.clone(),
};
format!(
- "CreateToken ({} for {}{}{})",
+ "CreateToken ({} ×{} for {}{}{})",
token_label,
+ count,
player_label(request.owner()),
counters,
etb_suffix
@@ -1474,10 +1532,12 @@ impl DebugAction {
DebugAction::CreateTokenCopy {
source_id,
owner,
+ count,
nonlegendary,
} => format!(
- "CreateTokenCopy ({} for {}{})",
+ "CreateTokenCopy ({} ×{} for {}{})",
obj(*source_id),
+ count,
player_label(*owner),
if *nonlegendary { " (nonlegendary)" } else { "" },
),
diff --git a/crates/engine/src/types/events.rs b/crates/engine/src/types/events.rs
index 3fb50c6b35..b0d13d9f51 100644
--- a/crates/engine/src/types/events.rs
+++ b/crates/engine/src/types/events.rs
@@ -1295,6 +1295,12 @@ pub enum GameEvent {
/// completed nonzero scry that left every looked-at card on top.
#[serde(default, skip_serializing_if = "Option::is_none")]
scry_bottom_count: Option,
+ /// CR 701.22a: Number of cards the player kept on top during a
+ /// completed scry. This is presentation data paired with the bottom
+ /// count; it lets observers display the public outcome without
+ /// reconstructing it from hidden-zone data.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ scry_top_count: Option,
},
/// Engine-authored diagnostic for top-card predicate
/// guesses. This is intentionally a log/debug event rather than rules input:
diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs
index 8878d85987..a3b7bd50a8 100644
--- a/crates/engine/src/types/game_state.rs
+++ b/crates/engine/src/types/game_state.rs
@@ -68,7 +68,7 @@ use crate::game::bracket_estimate::CommanderBracketTier;
use crate::game::combat::{AttackTarget, CombatState};
use crate::game::deck_loading::DeckEntry;
-use crate::game::game_object::{AttachTarget, CaseState, GameObject, PhaseStatus};
+use crate::game::game_object::{AttachTarget, BackFaceData, CaseState, GameObject, PhaseStatus};
fn default_rng() -> ChaCha20Rng {
ChaCha20Rng::seed_from_u64(0)
@@ -3648,6 +3648,31 @@ pub struct PendingCopyTokenResolution {
pub source_id: ObjectId,
}
+/// Private, face-complete source for a debug-created card that has not yet
+/// materialized as a game object. This lives only inside a resolution frame so
+/// a paused batch neither allocates an object identity nor exposes a future
+/// card in a public zone before its own entry begins.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct DebugCardEntrySource {
+ pub face: CardFace,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub back_face: Option,
+}
+
+/// CR 400.7 + CR 614.1: Remaining real battlefield entries for one debug
+/// Create Card request. Each item is materialized immediately before its own
+/// entry attempt, so replacement and as-enters choices can suspend without
+/// staging later cards in a visible zone.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PendingDebugCardEntries {
+ pub source: DebugCardEntrySource,
+ pub owner: PlayerId,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub attach_to: Option,
+ pub nonlegendary: bool,
+ pub remaining: u32,
+}
+
/// CR 616.1: Which pausing primitive of an `EachPlayerCopyChosen` per-player
/// step is currently mid-flight, so the drain resumes at the right point
/// (neither re-reading a stale token nor double-placing counters).
@@ -17389,6 +17414,36 @@ impl GameState {
.insert_copy_token_parent_at_child_boundary(pending, child_stack_start)
}
+ /// Returns the debug-card batch owner only when its typed frame owns the
+ /// stack top.
+ pub fn active_debug_card_entries(&self) -> Option<&PendingDebugCardEntries> {
+ self.resolution_stack.active_debug_card_entries()
+ }
+
+ /// Consume exactly the active debug-card batch after its current entry
+ /// finishes. A buried batch is a parent dependency, not a fallback.
+ pub fn take_active_debug_card_entries(
+ &mut self,
+ ) -> Result