Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0ede60e
Add City of Traitors: wire 'play another land' trigger prefix
May 24, 2026
442c5f6
Add Ring tempts you trigger parser
Whovencroft May 24, 2026
c00e857
Fix #949 parse Faerie Fencing cast-time pump
matthewevans May 24, 2026
06969da
ci: disable docker build record artifact
matthewevans May 24, 2026
8e54d44
Fix #966 parse scoped life loss polarity
matthewevans May 24, 2026
f912434
Fix #963 keep self-bounce upkeep triggers battlefield-hosted
matthewevans May 24, 2026
5c81460
Fix #940 parse targeted keyword choice grants
matthewevans May 24, 2026
2d49ad7
Fix #956 parse fixed land subtype trigger conditions
matthewevans May 24, 2026
aef8c61
Fix #958 #959 #960 preserve tap sources during activation payment
matthewevans May 24, 2026
002b27a
Add bulk activation for Treasure-style mana abilities
matthewevans May 24, 2026
b499f1d
Add Copy badge + discoverable Sandbox Tools (debug panel)
matthewevans May 24, 2026
6cad348
Fix #972 honor conditional mana replacement
matthewevans May 24, 2026
6263238
Stop running mtgish-import tests in pre-push and CI
matthewevans May 24, 2026
03c6038
Add die roll trigger parser
Whovencroft May 24, 2026
929503b
Add dungeon completion trigger parser
Whovencroft May 24, 2026
d0257d6
Merge remote-tracking branch 'origin/main' into codex/pr-979-review
matthewevans May 24, 2026
83b5531
Add explore trigger parser
Whovencroft May 24, 2026
ff94695
test(PR-979): cover City of Traitors trigger matching
matthewevans May 24, 2026
608e0b6
Merge remote-tracking branch 'origin/main' into codex/pr-979-review
matthewevans May 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ cargo fmt --all -- --check
echo " [rust] cargo clippy"
cargo clippy --workspace --all-targets --features engine/proptest -- -D warnings

echo " [rust] mtgish ordering manifest coverage"
cargo test -p mtgish-import --test manifest_coverage every_list_field_is_in_ordering_manifest

echo " [rust] card-data-validate release check"
cargo check --release --bin card-data-validate

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
# caught before it lands on the branch that feeds staging/preview.
env:
PROPTEST_CASES: ${{ github.event_name == 'pull_request' && '32' || '256' }}
run: cargo nextest run --workspace --exclude phase-tauri --features engine/proptest --status-level fail --final-status-level fail
run: cargo nextest run --workspace --exclude phase-tauri --exclude mtgish-import --features engine/proptest --status-level fail --final-status-level fail

card-data-gate:
name: Card data (generate, validate, coverage)
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,8 @@ jobs:

- name: Build and push release image
uses: docker/build-push-action@v6
env:
DOCKER_BUILD_RECORD_UPLOAD: false
with:
context: .
push: true
Expand Down
24 changes: 22 additions & 2 deletions client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,14 @@ export interface GameObject {
available_mana_pips?: ManaPip[];
casting_permissions?: CastingPermission[];
is_emblem?: boolean;
/**
* CR 111.1: Whether this object is a token (not a card). Independent of
* `display_source`: a token-copy of a real card (Twinflame, Helm of the
* Host) carries `is_token = true` AND `display_source = "Card"`, so it
* renders visually identical to the printed card. Combine the two to flag
* such copies (`is_token && display_source !== "Token"`).
*/
is_token?: boolean;
/**
* Image-lookup routing hint from the engine. "Card" → look up the image
* in the real-card database (default; also covers token-copies of real
Expand Down Expand Up @@ -1012,7 +1020,19 @@ export type WaitingFor =
| { type: "ExileFromBattlefieldForManaAbility"; data: { player: PlayerId; count: number; permanents: ObjectId[]; pending_mana_ability: unknown } }
| { type: "SacrificeForManaAbility"; data: { player: PlayerId; count: number; permanents: ObjectId[]; pending_mana_ability: unknown } }
| { type: "PayManaAbilityMana"; data: { player: PlayerId; options: ManaType[][]; pending_mana_ability: unknown } }
| { type: "ChooseManaColor"; data: { player: PlayerId; choice: ManaChoicePrompt; context: unknown } }
| {
type: "ChooseManaColor";
data: {
player: PlayerId;
choice: ManaChoicePrompt;
// CR 605.3a: Only the ManaAbility context carries the bulk-activation
// siblings the UI reads (omitted from the wire when empty). The heavy
// PendingManaAbility / ResolvedAbility payloads stay opaque here.
context:
| { type: "ManaAbility"; data: { batch_siblings?: ObjectId[] } }
| { type: "ResolvingEffect"; data: unknown };
};
}
| { type: "TapCreaturesForSpellCost"; data: { player: PlayerId; count: number; creatures: ObjectId[]; pending_cast: PendingCast } }
| { type: "ExileForCost"; data: { player: PlayerId; zone: ExileCostSourceZone; count: number; cards: ObjectId[]; pending_cast: PendingCast } }
| { type: "CollectEvidenceChoice"; data: { player: PlayerId; minimum_mana_value: number; cards: ObjectId[]; resume: unknown } }
Expand Down Expand Up @@ -1370,7 +1390,7 @@ export type GameAction =
| { type: "ChooseX"; data: { value: number } }
| { type: "SubmitPayAmount"; data: { amount: number } }
| { type: "SubmitPhyrexianChoices"; data: { choices: ShardChoice[] } }
| { type: "ChooseManaColor"; data: { choice: ManaChoice } }
| { type: "ChooseManaColor"; data: { choice: ManaChoice; count?: number } }
| { type: "PayManaAbilityMana"; data: { payment: ManaType[] } }
| { type: "CastPreparedCopy"; data: { source: ObjectId } }
| { type: "CastParadigmCopy"; data: { source: ObjectId } }
Expand Down
25 changes: 25 additions & 0 deletions client/src/components/board/PermanentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,16 @@ export const PermanentCard = memo(function PermanentCard({ objectId, attachments
// PlayerArea; both can be active independently.
const isPhasedOut = obj.phase_status?.status === "PhasedOut";

// CR 707.2: A token-copy of a real card (Twinflame, Helm of the Host, or a
// debug `CreateTokenCopy`) is `is_token` yet keeps `display_source = "Card"`,
// so it renders pixel-identical to the printed permanent. Flag it so the
// board carries a "Copy" badge — generic tokens (Treasure, Goblin) already
// read as tokens via their distinct generic-token art and are excluded.
// CR 708.2: a face-down permanent has no characteristics other than those
// its face-down rule grants, so never surface "Copy" on it — that would leak
// that it's a token-copy (matches the `!face_down` guard on the keyword strip).
const isCopy = obj.is_token === true && obj.display_source !== "Token" && !obj.face_down;

// Filter out loyalty counters — shown separately as the loyalty badge
const counters = Object.entries(obj.counters).filter((entry): entry is [string, number] => entry[1] != null && entry[0] !== "loyalty");

Expand Down Expand Up @@ -627,6 +637,21 @@ export const PermanentCard = memo(function PermanentCard({ objectId, attachments
</div>
)}

{/* CR 707.2: "Copy" badge for token-copies of real cards — these are
pixel-identical to the printed permanent, so without this tag there's
no way to tell a copy apart from the original on the board. Hidden
while the card is a valid target (the lime "Target" tag owns the
corner during targeting) and shifted down under attack to clear the
⚔ badge — same coordination the Target tag uses. */}
{isCopy && !isValidTarget && (
<div
className={`pointer-events-none absolute left-1 ${isUnderAttack ? "top-7" : "top-1"} z-20 rounded bg-indigo-600/90 px-1 py-0.5 text-[9px] font-black uppercase leading-none tracking-wide text-white ring-1 ring-black/60 shadow-[0_1px_4px_rgba(0,0,0,0.6)]`}
title="Token copy of a real card"
>
Copy
</div>
)}

{/* Debug-panel preview highlight — fuchsia neon ring + animated pulse.
Triggered when an ObjectSelect option in the debug panel is hovered
(`debugHighlightedObjectId` state). Deliberately loud and visually
Expand Down
9 changes: 5 additions & 4 deletions client/src/components/chrome/DebugActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,15 @@ export function DebugActions() {
</h3>
<button
onClick={toggleDebugInteractionMode}
title="Click Mode: when ON, click any card on the board, in a hand, or in a zone viewer to open a debug menu for it (move zones, modify P/T, add counters, remove) instead of playing it normally. A banner appears at the top while it's active."
className={
"rounded-full border px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider transition-colors " +
"rounded-full border px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-wider transition-colors " +
(debugInteractionMode
? "border-amber-500/60 bg-amber-500/20 text-amber-300"
: "border-gray-700 bg-transparent text-gray-600 hover:border-gray-600 hover:text-gray-500")
? "border-amber-500/70 bg-amber-500/25 text-amber-200"
: "border-amber-600/40 bg-transparent text-amber-500/80 hover:border-amber-500/60 hover:bg-amber-500/10 hover:text-amber-300")
}
>
{debugInteractionMode ? "Click Mode ON" : "Click Mode"}
{debugInteractionMode ? "Click Mode ON" : "Click Mode"}
</button>
</div>
<div className="mb-2 flex flex-wrap gap-1">
Expand Down
38 changes: 38 additions & 0 deletions client/src/components/chrome/DebugCreateActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,41 @@ function CustomTokenForm({ onDispatch }: Props) {
);
}

// Copy an existing permanent via the engine's real CR 707.2 copy-token
// resolver (`Effect::CopyTokenOf`). The engine already owns every nuance —
// copiable-value snapshotting, legendary-rule SBAs, ETB triggers — so this
// form is a thin source+owner picker over the `CreateTokenCopy` debug action.
function CopyPermanentForm({ onDispatch }: Props) {
const [sourceId, setSourceId] = useState<ObjectId | null>(null);
const [owner, setOwner] = useState<PlayerId>(0);

return (
<>
<ObjectSelect
value={sourceId}
onChange={setSourceId}
// Copies are made of permanents; restrict the picker to the battlefield
// so the list isn't cluttered with hand/library/graveyard objects.
filter={(obj) => obj.zone === "Battlefield"}
label="Copy Of"
placeholder="Pick a permanent…"
/>
<FieldRow label="Owner">
<PlayerSelect value={owner} onChange={setOwner} />
</FieldRow>
<SubmitButton
onClick={() => {
if (sourceId == null) return;
onDispatch({ type: "CreateTokenCopy", data: { source_id: sourceId, owner } });
}}
disabled={sourceId == null}
>
Create Copy
</SubmitButton>
</>
);
}

export function DebugCreateActions({ onDispatch }: Props) {
const { expanded, toggle } = useAccordion();

Expand All @@ -658,6 +693,9 @@ export function DebugCreateActions({ onDispatch }: Props) {
<AccordionItem label="Create Token (Custom)" expanded={expanded === "token-custom"} onToggle={() => toggle("token-custom")}>
<CustomTokenForm onDispatch={onDispatch} />
</AccordionItem>
<AccordionItem label="Copy Permanent" expanded={expanded === "copy"} onToggle={() => toggle("copy")}>
<CopyPermanentForm onDispatch={onDispatch} />
</AccordionItem>
</div>
);
}
5 changes: 4 additions & 1 deletion client/src/components/chrome/DebugPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,10 @@ export function DebugPanel() {
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
const prevSnapshotLenRef = useRef(0);

const [activeTab, setActiveTab] = useState<"console" | "actions">("console");
// Tab lives in uiStore so external entry points (Sandbox Tools nudge/button)
// can open the panel straight to "actions" via `openSandboxTools()`.
const activeTab = useUiStore((s) => s.debugPanelTab);
const setActiveTab = useUiStore((s) => s.setDebugPanelTab);
const canRestoreCheckpoints = gameMode === "ai" || gameMode === "local";

const handleRestore = useCallback(async (state: GameState) => {
Expand Down
30 changes: 30 additions & 0 deletions client/src/components/chrome/GameMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ interface GameMenuProps {
onSettingsClick: () => void;
onHelpClick: () => void;
onConcede?: () => void;
/** Show the always-visible Sandbox Tools button. Gated by the caller to
* game modes where debug actions actually work (vs-AI, local, or a
* multiplayer sandbox). */
showSandboxTools?: boolean;
onSandboxToolsClick?: () => void;
}

export function GameMenu({
Expand All @@ -28,6 +33,8 @@ export function GameMenu({
onSettingsClick,
onHelpClick,
onConcede,
showSandboxTools,
onSandboxToolsClick,
}: GameMenuProps) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
Expand Down Expand Up @@ -77,6 +84,29 @@ export function GameMenu({
</button>
<VolumeControl variant="game" />
<FullscreenButton variant="game" />
{showSandboxTools && onSandboxToolsClick && (
<button
onClick={onSandboxToolsClick}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-gray-800/80 text-amber-300/90 transition-colors hover:bg-gray-700/80 hover:text-amber-200"
aria-label="Sandbox Tools"
title="Sandbox Tools — set up any board state (`)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
>
<path d="M8 2.5v4.2L4 14.2a1.6 1.6 0 0 0 1.45 2.3h9.1A1.6 1.6 0 0 0 16 14.2L12 6.7V2.5" />
<path d="M7 2.5h6" />
<path d="M6.3 11.5h7.4" />
</svg>
</button>
)}
{isOnlineMode && <ConnectionDot />}
</div>
{open && (
Expand Down
43 changes: 43 additions & 0 deletions client/src/components/help/SandboxToolsNudge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { usePreferencesStore } from "../../stores/preferencesStore.ts";
import { useUiStore } from "../../stores/uiStore.ts";

/**
* First-run nudge that introduces the Sandbox Tools panel (the engine's debug
* actions). In vs-AI and local games the panel is always live, so a new player
* can set up any board state — add cards, tokens, counters, life, copy
* permanents, jump phases. Mirrors {@link FlowHelpNudge}: one-time, dismissible,
* persisted via `preferencesStore.dismissedSandboxToolsNudge`.
*/
export function SandboxToolsNudge() {
const openSandboxTools = useUiStore((s) => s.openSandboxTools);
const setDismissed = usePreferencesStore((s) => s.setDismissedSandboxToolsNudge);

return (
<div className="max-w-[min(24rem,calc(100vw-1.25rem))] rounded-[18px] border border-amber-300/25 bg-slate-950/86 p-3 text-sm text-slate-100 shadow-[0_24px_64px_rgba(15,23,42,0.55)] backdrop-blur-xl">
<p className="leading-5">
Set up any board state with <span className="font-semibold text-amber-200">Sandbox Tools</span> — add cards
and tokens, change life and counters, copy permanents, or jump phases. Open it anytime with the{" "}
<kbd className="rounded bg-white/10 px-1 font-mono text-xs">`</kbd> key.
</p>
<div className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setDismissed(true)}
className="rounded-lg px-3 py-1.5 text-xs font-semibold text-slate-400 transition hover:bg-white/8 hover:text-slate-200"
>
Dismiss
</button>
<button
type="button"
onClick={() => {
setDismissed(true);
openSandboxTools();
}}
className="rounded-lg bg-amber-400 px-3 py-1.5 text-xs font-semibold text-slate-950 transition hover:bg-amber-300"
>
Open Sandbox Tools
</button>
</div>
</div>
);
}
69 changes: 63 additions & 6 deletions client/src/components/modal/CardChoiceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3044,7 +3044,18 @@ function ManaColorChoiceModal({ data }: { data: ChooseManaColor["data"] }) {
/>
);
}
return <ManaSingleColorChoiceModal options={data.choice.data.options} />;
// CR 605.3a: When the source is a mana ability with identical, choice-free
// twins (the player's other Treasures, etc.), the engine reports them in
// `context.batch_siblings`. Offer a quantity stepper so one color choice can
// bulk-activate up to `siblings + 1` sources. `+ 1` counts the just-tapped
// source already paid for before this prompt.
const batchMax =
data.context.type === "ManaAbility"
? (data.context.data.batch_siblings?.length ?? 0) + 1
: 1;
return (
<ManaSingleColorChoiceModal options={data.choice.data.options} batchMax={batchMax} />
);
}

function PayManaAbilityManaModal({ data }: { data: PayManaAbilityMana["data"] }) {
Expand All @@ -3058,26 +3069,43 @@ function PayManaAbilityManaModal({ data }: { data: PayManaAbilityMana["data"] })
);
}

function ManaSingleColorChoiceModal({ options }: { options: ManaType[] }) {
function ManaSingleColorChoiceModal({
options,
batchMax = 1,
}: {
options: ManaType[];
batchMax?: number;
}) {
const dispatch = useGameDispatch();
const [selected, setSelected] = useState<ManaType | null>(null);
// CR 605.3a: how many identical sources to activate with the chosen color.
const [count, setCount] = useState(1);

const handleConfirm = useCallback(() => {
if (selected) {
dispatch({
type: "ChooseManaColor",
data: { choice: { type: "SingleColor", data: selected } },
data: { choice: { type: "SingleColor", data: selected }, count },
});
}
}, [dispatch, selected]);
}, [dispatch, selected, count]);

const canBatch = batchMax > 1;
const confirmLabel = selected && count > 1 ? `Add ${count}` : "Confirm";

return (
<ChoiceOverlay
title="Choose Mana Color"
subtitle="Select which color of mana to produce"
subtitle={
canBatch
? "Select a color, then how many sources to tap"
: "Select which color of mana to produce"
}
widthClassName="w-fit max-w-full"
maxWidthClassName="max-w-md"
footer={<ConfirmButton onClick={handleConfirm} disabled={selected === null} />}
footer={
<ConfirmButton onClick={handleConfirm} disabled={selected === null} label={confirmLabel} />
}
>
<div className="mx-auto flex w-fit items-center justify-center gap-3 px-4 py-4 sm:gap-5 sm:px-6 sm:py-6">
{options.map((color, index) => {
Expand All @@ -3099,6 +3127,35 @@ function ManaSingleColorChoiceModal({ options }: { options: ManaType[] }) {
);
})}
</div>
{canBatch && (
<div className="mx-auto mb-4 flex w-fit items-center gap-4">
<span className="text-sm text-white/70">How many?</span>
<div className="flex items-center gap-3">
<button
type="button"
aria-label="Tap fewer"
disabled={count <= 1}
onClick={() => setCount((c) => Math.max(1, c - 1))}
className="flex h-9 w-9 items-center justify-center rounded-full border border-white/20 text-xl leading-none text-white transition hover:border-white/40 disabled:opacity-30"
>
</button>
<span className="w-8 text-center text-lg font-semibold tabular-nums text-white">
{count}
</span>
<button
type="button"
aria-label="Tap more"
disabled={count >= batchMax}
onClick={() => setCount((c) => Math.min(batchMax, c + 1))}
className="flex h-9 w-9 items-center justify-center rounded-full border border-white/20 text-xl leading-none text-white transition hover:border-white/40 disabled:opacity-30"
>
+
</button>
<span className="text-sm text-white/50">/ {batchMax}</span>
</div>
</div>
)}
</ChoiceOverlay>
);
}
Expand Down
Loading