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, ResolutionStackError> { + self.resolution_stack.take_active_debug_card_entries() + } + + /// Park a debug-card batch as the active inner frame. + pub fn push_debug_card_entries(&mut self, pending: PendingDebugCardEntries) { + self.resolution_stack.push_debug_card_entries(pending); + } + + /// Insert a debug-card batch below the complete child stack its current + /// entry attempt created after recording the pre-entry boundary. + pub fn insert_debug_card_entries_parent_at_child_boundary( + &mut self, + pending: PendingDebugCardEntries, + child_stack_start: usize, + ) -> Result<(), ResolutionStackError> { + self.resolution_stack + .insert_debug_card_entries_parent_at_child_boundary(pending, child_stack_start) + } + /// Returns the EachPlayerCopyChosen owner only when its typed frame owns /// the stack top. pub fn active_each_player_copy_chosen(&self) -> Option<&PendingEachPlayerCopyChosen> { diff --git a/crates/engine/src/types/resolution.rs b/crates/engine/src/types/resolution.rs index 6b8207f9ec..a723267690 100644 --- a/crates/engine/src/types/resolution.rs +++ b/crates/engine/src/types/resolution.rs @@ -15,11 +15,11 @@ use crate::types::game_state::{ DrainStatus, DrawSequenceStack, GameState, GameStateDecode, GameStateDecodeMode, PendingBatchDeliveries, PendingChangeZoneIteration, PendingChooseOneOf, PendingConniveReentry, PendingContinuation, PendingCopyTokenResolution, PendingCounterAdditionQueue, - PendingCounterMoveQueue, PendingCounterRemovalQueue, PendingEachPlayerCopyChosen, - PendingLifeTotalAssignment, PendingMultiDraw, PendingPerCategoryZoneChoice, - PendingPerPlayerZoneChoice, PendingRepeatIteration, PendingRepeatUntil, PendingSpellResolution, - PendingVoteBallotIteration, PostReplacementDrain, PostReplacementDrainStack, - ResidentDrainPolicy, ResolvingTriggerContext, WaitingFor, + PendingCounterMoveQueue, PendingCounterRemovalQueue, PendingDebugCardEntries, + PendingEachPlayerCopyChosen, PendingLifeTotalAssignment, PendingMultiDraw, + PendingPerCategoryZoneChoice, PendingPerPlayerZoneChoice, PendingRepeatIteration, + PendingRepeatUntil, PendingSpellResolution, PendingVoteBallotIteration, PostReplacementDrain, + PostReplacementDrainStack, ResidentDrainPolicy, ResolvingTriggerContext, WaitingFor, }; use crate::types::identifiers::{DiscardFrameId, ObjectId}; use crate::types::player::PlayerId; @@ -192,6 +192,7 @@ pub enum ResolutionFrame { CounterRemovals(PendingCounterRemovalQueue), CounterAdditions(PendingCounterAdditionQueue), CopyToken(PendingCopyTokenResolution), + DebugCardEntries(Box), EachPlayerCopyChosen(PendingEachPlayerCopyChosen), ChooseOneOf(PendingChooseOneOf), VoteBallot(PendingVoteBallotIteration), @@ -223,6 +224,7 @@ pub enum FrameKind { CounterRemovals, CounterAdditions, CopyToken, + DebugCardEntries, EachPlayerCopyChosen, ChooseOneOf, VoteBallot, @@ -253,6 +255,7 @@ impl ResolutionFrame { Self::CounterRemovals(_) => FrameKind::CounterRemovals, Self::CounterAdditions(_) => FrameKind::CounterAdditions, Self::CopyToken(_) => FrameKind::CopyToken, + Self::DebugCardEntries(_) => FrameKind::DebugCardEntries, Self::EachPlayerCopyChosen(_) => FrameKind::EachPlayerCopyChosen, Self::ChooseOneOf(_) => FrameKind::ChooseOneOf, Self::VoteBallot(_) => FrameKind::VoteBallot, @@ -288,6 +291,7 @@ impl ResolutionFrame { | Self::CounterRemovals(_) | Self::CounterAdditions(_) | Self::CopyToken(_) + | Self::DebugCardEntries(_) | Self::EachPlayerCopyChosen(_) | Self::ChooseOneOf(_) | Self::VoteBallot(_) @@ -331,6 +335,7 @@ impl ResolutionFrame { | Self::CounterRemovals(_) | Self::CounterAdditions(_) | Self::CopyToken(_) + | Self::DebugCardEntries(_) | Self::EachPlayerCopyChosen(_) | Self::ChooseOneOf(_) | Self::VoteBallot(_) @@ -1307,6 +1312,53 @@ impl ResolutionStack { self.insert_parent_at_child_boundary(ResolutionFrame::CopyToken(pending), child_stack_start) } + /// Returns the debug-card batch owner only when it owns the stack top. + pub fn active_debug_card_entries(&self) -> Option<&PendingDebugCardEntries> { + match self.last() { + Some(ResolutionFrame::DebugCardEntries(frame)) => Some(frame), + Some(_) | None => None, + } + } + + /// Consume the exact debug-card batch owner after its child entry settles. + pub fn take_active_debug_card_entries( + &mut self, + ) -> Result, ResolutionStackError> { + match self.last() { + None => Ok(None), + Some(ResolutionFrame::DebugCardEntries(_)) => { + let ResolutionFrame::DebugCardEntries(frame) = + self.pop_expected(FrameKind::DebugCardEntries)? + else { + unreachable!("checked debug-card frame kind must match") + }; + Ok(Some(*frame)) + } + Some(frame) => Err(ResolutionStackError::UnexpectedTop { + expected: FrameKind::DebugCardEntries, + actual: frame.kind(), + }), + } + } + + /// Park a debug-card batch as the active inner frame. + pub fn push_debug_card_entries(&mut self, pending: PendingDebugCardEntries) { + self.push_inner(ResolutionFrame::DebugCardEntries(Box::new(pending))); + } + + /// Insert the debug-card batch below the exact child stack raised by one + /// card's entry attempt. + pub fn insert_debug_card_entries_parent_at_child_boundary( + &mut self, + pending: PendingDebugCardEntries, + child_stack_start: usize, + ) -> Result<(), ResolutionStackError> { + self.insert_parent_at_child_boundary( + ResolutionFrame::DebugCardEntries(Box::new(pending)), + child_stack_start, + ) + } + /// Returns the EachPlayerCopyChosen owner only when its typed frame owns /// the stack top. pub fn active_each_player_copy_chosen(&self) -> Option<&PendingEachPlayerCopyChosen> { @@ -3643,6 +3695,11 @@ fn project_frames_into_legacy_state( ResolutionFrame::CopyToken(pending) => { projected.resolution_stack.push_copy_token(pending.clone()); } + ResolutionFrame::DebugCardEntries(pending) => { + projected + .resolution_stack + .push_debug_card_entries((**pending).clone()); + } ResolutionFrame::EachPlayerCopyChosen(pending) => { projected .resolution_stack diff --git a/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs b/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs index c281ed0fe0..9d238b4861 100644 --- a/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs +++ b/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs @@ -57,6 +57,7 @@ fn occurrence(player: PlayerId) -> ConsumedTriggerEventOccurrence { action: PlayerActionKind::Scry, look_count: None, scry_bottom_count: None, + scry_top_count: None, }, occurrence: 0, } diff --git a/crates/lobby-broker/src/protocol.rs b/crates/lobby-broker/src/protocol.rs index 94aa85bc22..bddf10a7d5 100644 --- a/crates/lobby-broker/src/protocol.rs +++ b/crates/lobby-broker/src/protocol.rs @@ -43,6 +43,9 @@ pub enum ServerErrorCode { /// handshake. When making such changes, plan a deprecation window where /// both the old and new variants coexist, then bump and remove the old. /// +/// 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. The field is /// `#[serde(default)]`, so this is a capability bump rather than a parse @@ -69,7 +72,7 @@ pub enum ServerErrorCode { /// payload; mulligan bottoming folded into a /// `MulliganDecisionPhase::BottomCards` sub-phase on /// `WaitingFor::MulliganDecision`. -pub const PROTOCOL_VERSION: u32 = 24; +pub const PROTOCOL_VERSION: u32 = 25; /// Minimum protocol version accepted by lobby-only brokers at the hello /// handshake. Lobby traffic has a one-version rollout window; full game servers @@ -406,12 +409,12 @@ mod tests { #[test] fn protocol_version_tracks_priority_passing_wire_additions() { - assert_eq!(PROTOCOL_VERSION, 24); + assert_eq!(PROTOCOL_VERSION, 25); // Lobby keeps its one-version rollout window; full-game servers stay // current-only (`server_core::MIN_SUPPORTED_PROTOCOL == PROTOCOL_VERSION`), - // which is what refuses the v23-server/v24-client pairing that would - // silently drop every ∞ badge. - assert_eq!(MIN_SUPPORTED_PROTOCOL, 23); + // which is what refuses an older full-game peer whose GameState cannot + // carry a paused DebugCardEntries resolution frame. + assert_eq!(MIN_SUPPORTED_PROTOCOL, 24); } #[test] diff --git a/crates/phase-server/src/main.rs b/crates/phase-server/src/main.rs index cbcb195afd..f6d75af111 100644 --- a/crates/phase-server/src/main.rs +++ b/crates/phase-server/src/main.rs @@ -3787,6 +3787,7 @@ async fn handle_full_game_submission( submission: GameSubmission, socket: &mut WebSocket, state: &SharedState, + db: &SharedDb, draft_state: &SharedDraftState, connections: &SharedConnections, game_db: &SharedGameDb, @@ -3840,7 +3841,9 @@ async fn handle_full_game_submission( let lock_start = std::time::Instant::now(); let mut mgr = state.lock().await; let applied = match submission { - GameSubmission::Action(action) => mgr.handle_action(&game_code, &player_token, action), + GameSubmission::Action(action) => { + mgr.handle_action_with_card_db(&game_code, &player_token, action, Some(db.as_ref())) + } GameSubmission::Interaction(submission) => { mgr.handle_interaction(&game_code, &player_token, submission) } @@ -4453,6 +4456,7 @@ async fn handle_client_message( GameSubmission::Action(action), socket, state, + db, draft_state, connections, game_db, @@ -4467,6 +4471,7 @@ async fn handle_client_message( GameSubmission::Interaction(submission), socket, state, + db, draft_state, connections, game_db, diff --git a/crates/server-core/src/game_action_payload_guard.rs b/crates/server-core/src/game_action_payload_guard.rs index 5844a3c693..1db7aee593 100644 --- a/crates/server-core/src/game_action_payload_guard.rs +++ b/crates/server-core/src/game_action_payload_guard.rs @@ -10,7 +10,7 @@ //! The cap is deliberately generous — far above any realistic game state, //! including degenerate token-army boards — so it never rejects legitimate play; //! it only blocks payloads engineered to force large allocations/clones. -use engine::types::actions::{DebugAction, DebugTokenRequest, GameAction}; +use engine::types::actions::{DebugAction, DebugTokenRequest, GameAction, MAX_DEBUG_CREATE_COUNT}; use engine::types::counter::CounterType; use engine::types::game_state::{ManaChoice, ProductionOverride}; use engine::types::mana::{ManaRestriction, ManaSourceSelection, SpellCostCriterion}; @@ -362,15 +362,37 @@ fn guard_debug_token_request_payload(request: &DebugTokenRequest) -> Result<(), fn guard_debug_action_payload(action: &DebugAction) -> Result<(), String> { match action { - DebugAction::CreateCard { card_name, .. } => { + DebugAction::CreateCard { + card_name, count, .. + } => { bound_string("Debug.CreateCard.card_name", card_name)?; + bound_batch_count("Debug.CreateCard.count", *count)?; + if *count > MAX_DEBUG_CREATE_COUNT { + return Err(format!( + "Debug.CreateCard.count {count} exceeds the maximum {MAX_DEBUG_CREATE_COUNT}" + )); + } } DebugAction::AddMana { mana, .. } => { bound_list("Debug.AddMana.mana", mana.len())?; } - DebugAction::CreateToken { request, .. } => { + DebugAction::CreateToken { request, count, .. } => { + bound_batch_count("Debug.CreateToken.count", *count)?; + if *count > MAX_DEBUG_CREATE_COUNT { + return Err(format!( + "Debug.CreateToken.count {count} exceeds the maximum {MAX_DEBUG_CREATE_COUNT}" + )); + } guard_debug_token_request_payload(request)?; } + DebugAction::CreateTokenCopy { count, .. } => { + bound_batch_count("Debug.CreateTokenCopy.count", *count)?; + if *count > MAX_DEBUG_CREATE_COUNT { + return Err(format!( + "Debug.CreateTokenCopy.count {count} exceeds the maximum {MAX_DEBUG_CREATE_COUNT}" + )); + } + } DebugAction::ModifyCounters { counter_type, .. } => { guard_counter_type_payload("Debug.ModifyCounters.counter_type", counter_type)?; } @@ -401,8 +423,7 @@ fn guard_debug_action_payload(action: &DebugAction) -> Result<(), String> { | DebugAction::ModifyEnergy { .. } | DebugAction::SetInfiniteMana { .. } | DebugAction::SetPhase { .. } - | DebugAction::RunStateBasedActions - | DebugAction::CreateTokenCopy { .. } => {} + | DebugAction::RunStateBasedActions => {} } Ok(()) } diff --git a/crates/server-core/src/protocol.rs b/crates/server-core/src/protocol.rs index b7d9c20152..2106f6cf41 100644 --- a/crates/server-core/src/protocol.rs +++ b/crates/server-core/src/protocol.rs @@ -2257,15 +2257,15 @@ mod tests { } #[test] - fn protocol_version_is_24() { - assert_eq!(PROTOCOL_VERSION, 24); + fn protocol_version_is_25() { + assert_eq!(PROTOCOL_VERSION, 25); } /// The bump alone is inert — a version number nobody enforces prevents no /// pairing. This is the assertion with teeth: full-game servers accept ONLY - /// the current protocol, so a v24 client can never complete a handshake - /// with a v23 server and silently render zero ∞ badges (the family channel - /// is `#[serde(default)]`, so that loss would raise no parse error). + /// the current protocol, so an older peer cannot complete a handshake with + /// a server that may persist a paused DebugCardEntries frame it cannot + /// deserialize. /// /// REVERT-PROBE: relax to `PROTOCOL_VERSION - 1` — the exact regression /// this guards — and this test reds while `protocol_version_is_24` stays diff --git a/crates/server-core/src/session.rs b/crates/server-core/src/session.rs index f26940c3db..0f2ee624c5 100644 --- a/crates/server-core/src/session.rs +++ b/crates/server-core/src/session.rs @@ -7,12 +7,18 @@ use engine::database::legality::{validate_cedh_bracket, CedhBracketError}; use engine::database::CardDatabase; use engine::game::deck_loading::{DeckPayload, PlayerDeckPayload}; use engine::game::engine::{apply, start_game}; -use engine::game::finalize_public_state; use engine::game::interaction::{bind_interaction_authority, submit_interaction}; +use engine::game::log::resolve_log_entries; use engine::game::match_flow::apply_trusted_match_forfeit; use engine::game::preview::preview_auto_payment_sources; -use engine::game::{load_and_hydrate_decks, rehydrate_game_from_card_db}; -use engine::types::actions::GameAction; +use engine::game::public_state::{ + bump_state_revision, finalize_public_state, mark_public_state_all_dirty, +}; +use engine::game::{ + create_debug_cards, debug_card_entry_source, load_and_hydrate_decks, + rehydrate_game_from_card_db, DebugCardCreateRequest, +}; +use engine::types::actions::{DebugAction, GameAction}; use engine::types::events::GameEvent; use engine::types::format::FormatConfig; use engine::types::game_state::{GameState, PersistedGameState}; @@ -1341,6 +1347,20 @@ impl SessionManager { game_code: &str, player_token: &str, action: GameAction, + ) -> Result { + self.handle_action_with_card_db(game_code, player_token, action, None) + } + + /// Handle a game action whose transport can resolve debug card names through + /// its live card database. The engine owns materialization and entry; this + /// boundary only resolves the player-entered card name into a card face. + #[allow(clippy::type_complexity)] + pub fn handle_action_with_card_db( + &mut self, + game_code: &str, + player_token: &str, + action: GameAction, + card_db: Option<&CardDatabase>, ) -> Result { let session = self .sessions @@ -1409,6 +1429,10 @@ impl SessionManager { // legality gate: several legal action classes are combinatorial. let records_takeback = !action.is_actor_scoped_preference(); + if let GameAction::Debug(debug_action) = &action { + debug_action.validate_create_count()?; + } + let pre_action_state = records_takeback.then(|| session.state.clone()); // Set player names for log resolution. @@ -1420,10 +1444,72 @@ impl SessionManager { // `player == authorized_submitter(state)`, so a spoofed action at the // wire is rejected inside the engine as well as here. let action_type = action.variant_name(); - let result = apply(&mut session.state, player, action).map_err(|e| { - warn!(game = %game_code, player = ?player, error = %e, reason = "engine_error", "action rejected"); - format!("Engine error: {}", e) - })?; + let result = match action { + action @ GameAction::Debug(DebugAction::CreateCard { count: 0, .. }) => { + apply(&mut session.state, player, action).map_err(|e| { + warn!(game = %game_code, player = ?player, error = %e, reason = "engine_error", "action rejected"); + format!("Engine error: {}", e) + })? + } + GameAction::Debug(DebugAction::CreateCard { + card_name, + owner, + zone, + count, + attach_to, + run_etb, + nonlegendary, + }) => { + if !session.state.debug_mode { + return Err("Engine error: Debug actions require debug_mode to be enabled".to_string()); + } + if !session.state.players.iter().any(|player| player.id == owner) { + return Err("Engine error: Debug: invalid owner player id".to_string()); + } + let card_db = card_db.ok_or_else(|| { + "Debug::CreateCard requires a card database at the transport boundary".to_string() + })?; + let face = card_db + .get_face_by_name(&card_name) + .ok_or_else(|| "Engine error: card not found in database".to_string())?; + let debug_action = DebugAction::CreateCard { + card_name: card_name.clone(), + owner, + zone, + count, + attach_to, + run_etb, + nonlegendary, + }; + let description = debug_action.describe(&session.state); + let before = session.state.clone(); + let mut result = create_debug_cards( + &mut session.state, + DebugCardCreateRequest { + source: debug_card_entry_source(card_db, face), + owner, + zone, + count, + attach_to, + run_etb, + nonlegendary, + }, + ); + result.events.push(GameEvent::DebugActionUsed { + player_id: player, + description, + }); + bump_state_revision(&mut session.state); + mark_public_state_all_dirty(&mut session.state); + finalize_public_state(&mut session.state); + result.log_entries = resolve_log_entries(&result.events, &before, &session.state); + result + } + action => apply(&mut session.state, player, action).map_err(|e| { + warn!(game = %game_code, player = ?player, error = %e, reason = "engine_error", "action rejected"); + format!("Engine error: {}", e) + })?, + }; if let Some(snapshot) = pre_action_state { session.push_takeback_state(player, snapshot); } @@ -3937,6 +4023,73 @@ mod tests { .expect("supported sandbox config") } + #[test] + fn server_card_database_resolves_debug_card_batches() { + let mut mgr = SessionManager::new(); + let (code, token) = create_sandbox_game(&mut mgr); + let db = CardDatabase::from_json_str( + r#"{ + "server debug creature": { + "name": "Server Debug Creature", + "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": [] + } + }"#, + ) + .expect("debug-card fixture database parses"); + + let result = mgr + .handle_action_with_card_db( + &code, + &token, + GameAction::Debug(engine::types::actions::DebugAction::CreateCard { + card_name: "Server Debug Creature".into(), + owner: PlayerId(0), + zone: Zone::Battlefield, + count: 2, + attach_to: None, + run_etb: true, + nonlegendary: false, + }), + Some(&db), + ) + .expect("server transport resolves a debug CreateCard batch through its card database"); + + assert!( + result + .1 + .iter() + .any(|event| matches!(event, GameEvent::DebugActionUsed { .. })), + "source-bound server debug actions retain the engine audit event" + ); + assert!( + !result.3.is_empty(), + "the audit event resolves to a player-visible game-log entry" + ); + + assert_eq!( + mgr.sessions[&code] + .state + .objects + .values() + .filter(|object| { + object.name == "Server Debug Creature" && object.zone == Zone::Battlefield + }) + .count(), + 2 + ); + } + #[test] fn with_sandbox_sets_flag_and_is_idempotent() { let base = FormatConfig::standard(); @@ -4495,6 +4648,44 @@ mod tests { assert!(result.is_err()); } + #[test] + fn revoked_seat_cannot_create_debug_cards_through_server_card_database() { + let mut mgr = SessionManager::new(); + let (code, host_token) = create_sandbox_game(&mut mgr); + let (guest_token, _state) = mgr + .join_game_with_name(&code, make_deck(), "Guest".to_string()) + .expect("guest joins"); + + mgr.handle_action( + &code, + &host_token, + GameAction::RevokeDebugPermission { + player_id: PlayerId(1), + }, + ) + .expect("host revokes the guest's debug permission"); + + // The server's source-bound CreateCard path must not skip the shared + // Debug(_) admission gate before attempting card-database resolution. + let err = mgr + .handle_action_with_card_db( + &code, + &guest_token, + GameAction::Debug(DebugAction::CreateCard { + card_name: "Any Card".to_string(), + owner: PlayerId(1), + zone: Zone::Battlefield, + count: 1, + attach_to: None, + run_etb: true, + nonlegendary: false, + }), + None, + ) + .expect_err("revoked guests cannot reach the server CreateCard path"); + assert_eq!(err, "Debug actions are not permitted for this seat"); + } + #[test] fn host_can_grant_debug_to_guest() { let mut mgr = SessionManager::new(); diff --git a/crates/server-core/tests/game_action_payload_guard.rs b/crates/server-core/tests/game_action_payload_guard.rs index 9aa6d4bfe6..e6375bb114 100644 --- a/crates/server-core/tests/game_action_payload_guard.rs +++ b/crates/server-core/tests/game_action_payload_guard.rs @@ -8,7 +8,7 @@ use engine::analysis::decision_template::{ use engine::types::ability::{ Comparator, TriggerBaseSetInstanceRef, TriggerDefinitionOccurrenceRef, }; -use engine::types::actions::{DebugAction, DebugTokenRequest}; +use engine::types::actions::{DebugAction, DebugTokenRequest, MAX_DEBUG_CREATE_COUNT}; use engine::types::counter::CounterType; use engine::types::game_state::{ManaChoice, ProductionOverride, ShardChoice, YieldTarget}; use engine::types::identifiers::{CardId, ObjectIncarnationRef}; @@ -271,6 +271,7 @@ fn rejects_oversized_debug_token_counter_name() { toughness_override: None, enter_with_counters: vec![(CounterType::Generic("x".repeat(MAX_CHOICE_LEN + 1)), 1)], }, + count: 1, run_etb: true, }); @@ -288,12 +289,26 @@ fn accepts_debug_token_preset_pt_override_fields() { toughness_override: Some(5), enter_with_counters: Vec::new(), }, + count: 1, run_etb: true, }); guard_game_action_payload(&action).expect("numeric P/T overrides are semantic engine input"); } +#[test] +fn rejects_debug_create_count_above_engine_ceiling() { + let action = GameAction::Debug(DebugAction::CreateTokenCopy { + source_id: ObjectId(1), + owner: PlayerId(0), + count: MAX_DEBUG_CREATE_COUNT + 1, + nonlegendary: false, + }); + + let err = guard_game_action_payload(&action).unwrap_err(); + assert!(err.contains("Debug.CreateTokenCopy.count")); +} + #[test] fn rejects_oversized_debug_token_keyword_ast_payload() { let action = GameAction::Debug(DebugAction::CreateToken { @@ -311,6 +326,7 @@ fn rejects_oversized_debug_token_keyword_ast_payload() { }, enter_with_counters: Vec::new(), }, + count: 1, run_etb: true, }); diff --git a/scripts/check-protocol-version.mjs b/scripts/check-protocol-version.mjs index 2532a64998..524c2db62a 100644 --- a/scripts/check-protocol-version.mjs +++ b/scripts/check-protocol-version.mjs @@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const EXPECTED_PROTOCOL_VERSION = 24; +const EXPECTED_PROTOCOL_VERSION = 25; function extractVersion(source, pattern, label) { const match = source.match(pattern);