Partner commanders: Partner, Partner—<variant>, Partner with, Background, Doctor

A Commander/Brawl deck can now have two commanders when their abilities
allow it. Partner abilities are parsed from oracle text (Scryfall's keyword
list files every "Partner—Survivors"-style variant under plain "Partner"),
and a second search box offers only cards that can pair with the commander.
The legality check accepts a valid pair and checks color identity against
both; suggestions and decklist import/export handle both commanders.

Closes #4
This commit is contained in:
2026-10-23 22:29:56 -05:00
parent 5f19ddfa4a
commit bfad4c1f27
11 changed files with 526 additions and 54 deletions

View File

@@ -1,6 +1,17 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor, libraryKey, PreviewContext } from "../api";
import {
api,
CardRef,
COLORS,
Deck,
DeckEntry,
DeckImportResult,
legalStatusFor,
libraryKey,
Partner,
PreviewContext,
} from "../api";
import {
CardSearchBox,
CardThumb,
@@ -201,11 +212,55 @@ function CommanderHero({
<span className="commander-hero-type">{card.type_line}</span>
<ColorPips mask={card.color_identity} />
</div>
<IconButton icon={Icons.close} label="Clear commander" onClick={onClear} />
<IconButton icon={Icons.close} label={`Clear ${card.name} as commander`} onClick={onClear} />
</div>
);
}
/** Whether two cards can be commanders together. Mirrors `pairs_with` in
* crates/mtgc-core/src/partner.rs — keep the two in step. */
function pairsWith(a: DeckEntry, b: DeckEntry): boolean {
const p = a.partner;
const q = b.partner;
if (!p || !q) return false;
switch (p.kind) {
case "partner":
return q.kind === "partner" && a.oracle_id !== b.oracle_id;
case "variant":
return q.kind === "variant" && q.name === p.name && a.oracle_id !== b.oracle_id;
case "with":
return q.kind === "with" && p.name === b.name && q.name === a.name;
case "chooses_background":
return q.kind === "background";
case "background":
return q.kind === "chooses_background";
case "doctors_companion":
return q.kind === "doctor";
case "doctor":
return q.kind === "doctors_companion";
}
}
/** What the second-commander search box asks for, given the first's ability. */
function partnerPlaceholder(partner: Partner): string {
switch (partner.kind) {
case "chooses_background":
return "Search to add a Background…";
case "background":
return "Search to add a commander that chooses a Background…";
case "doctors_companion":
return "Search to add a Doctor…";
case "doctor":
return "Search to add a Doctor's companion…";
case "with":
return `Search to add ${partner.name}…`;
case "variant":
return `Search to add a partner (${partner.name})…`;
case "partner":
return "Search to add a partner…";
}
}
// How long a card decremented to 0 copies lingers (dimmed, still adjustable)
// before actually dropping out of the deck — same grace-period pattern as
// the Library tab, so a mistaken removal can be undone with +.
@@ -273,13 +328,15 @@ function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[
);
}
// Two commanders are allowed when their partner abilities pair them up.
const commanders = cards.filter((e) => e.is_commander);
if (commanders.length === 0) {
reasons.push(`No commander is set.`);
} else if (commanders.length > 1) {
reasons.push(`More than one card is marked as commander.`);
} else if (commanders.length > 2) {
reasons.push(`More than two cards are marked as commanders.`);
} else if (commanders.length === 2 && !pairsWith(commanders[0], commanders[1])) {
reasons.push(`${commanders[0].name} and ${commanders[1].name} can't be commanders together.`);
}
const commander = commanders[0];
for (const e of cards) {
if (e.is_commander) continue;
@@ -290,12 +347,14 @@ function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[
}
}
if (commander) {
if (commanders.length > 0) {
const identity = commanders.reduce((mask, e) => mask | e.color_identity, 0);
const whose = commanders.length > 1 ? "commanders'" : "commander's";
for (const e of cards) {
if (e.is_commander) continue;
const outsideIdentity = e.color_identity & ~commander.color_identity;
const outsideIdentity = e.color_identity & ~identity;
if (outsideIdentity !== 0) {
reasons.push(`${e.name}'s color identity is outside the commander's color identity.`);
reasons.push(`${e.name}'s color identity is outside the ${whose} color identity.`);
}
}
}
@@ -389,6 +448,8 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
const [anyResults, setAnyResults] = useState<CardRef[]>([]);
const [cmdQuery, setCmdQuery] = useState("");
const [cmdResults, setCmdResults] = useState<CardRef[]>([]);
const [partnerQuery, setPartnerQuery] = useState("");
const [partnerResults, setPartnerResults] = useState<CardRef[]>([]);
const [maybeQuery, setMaybeQuery] = useState("");
const [maybeResults, setMaybeResults] = useState<CardRef[]>([]);
@@ -497,6 +558,16 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
return () => clearTimeout(t);
}, [cmdQuery]);
// The primary commander, whose partner ability the second search box serves.
const partnerOf =
deck?.commander_oracle_id ?? contents.find((e) => e.is_commander && !e.is_maybeboard)?.oracle_id ?? null;
useEffect(() => {
const q = partnerQuery.trim();
if (!q || !partnerOf) return setPartnerResults([]);
const t = setTimeout(() => api.searchPartners(partnerOf, q, 12).then(setPartnerResults), 80);
return () => clearTimeout(t);
}, [partnerQuery, partnerOf]);
async function createDeck() {
if (!newName.trim()) return;
const d = await api.createDeck(newName.trim(), null);
@@ -563,13 +634,16 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
}
/** Move a card between the mainboard and the maybeboard. Clears commander
* status (and the deck's commander, if this was it) when maybeboarding. */
* status (dropping it from the deck's commanders) when maybeboarding. */
async function setMaybeboard(entry: DeckEntry, isMaybeboard: boolean) {
if (selected === null) return;
const isCommander = isMaybeboard ? false : entry.is_commander;
await api.setDeckCard(selected, entry.oracle_id, entry.quantity, isCommander, isMaybeboard);
if (isMaybeboard && deck?.commander_oracle_id === entry.oracle_id) {
await api.setDeckCommander(selected, null);
if (isMaybeboard && entry.is_commander) {
await api.setDeckCommanders(
selected,
commanders.filter((e) => e.oracle_id !== entry.oracle_id).map((e) => e.oracle_id),
);
await loadDecks();
}
await loadContents();
@@ -721,16 +795,40 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
// commander — covers both "not in the deck yet" and "was maybeboarded".
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
await api.setDeckCard(selected, card.oracle_id, existing?.quantity ?? 1, true, false);
await api.setDeckCommander(selected, card.oracle_id);
// Keep the partner only if it can still pair with the new commander; if
// not, it stays in the deck as an ordinary card.
const picked = (await api.deckContents(selected)).find((e) => e.oracle_id === card.oracle_id);
const keepPartner =
partnerEntry && partnerEntry.oracle_id !== card.oracle_id && picked && pairsWith(picked, partnerEntry);
await api.setDeckCommanders(
selected,
keepPartner ? [card.oracle_id, partnerEntry.oracle_id] : [card.oracle_id],
);
setCmdQuery("");
setCmdResults([]);
await loadContents();
await loadDecks();
}
async function clearCommander() {
async function addPartner(card: CardRef) {
if (selected === null || !commanderEntry) return;
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
await api.setDeckCard(selected, card.oracle_id, existing?.quantity ?? 1, true, false);
await api.setDeckCommanders(selected, [commanderEntry.oracle_id, card.oracle_id]);
setPartnerQuery("");
setPartnerResults([]);
await loadContents();
await loadDecks();
}
/** Stop treating a card as a commander; it stays in the deck. Clearing the
* primary commander promotes its partner. */
async function clearCommander(entry: DeckEntry) {
if (selected === null) return;
await api.setDeckCommander(selected, null);
await api.setDeckCommanders(
selected,
commanders.filter((e) => e.oracle_id !== entry.oracle_id).map((e) => e.oracle_id),
);
await loadContents();
await loadDecks();
}
@@ -747,7 +845,13 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0 && matchesFilter(e));
const unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0 && matchesFilter(e));
const maybeboard = contents.filter((e) => e.is_maybeboard && matchesFilter(e));
const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id);
// The primary commander first, then its partner, if any.
const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck?.commander_oracle_id);
const commanders = contents
.filter((e) => e.is_commander && !e.is_maybeboard)
.sort((a, b) => isPrimary(b) - isPrimary(a));
const commanderEntry: DeckEntry | undefined = commanders[0];
const partnerEntry: DeckEntry | undefined = commanders[1];
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
@@ -977,7 +1081,9 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
<div className="deck-top-grid">
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
<div className="deck-top-commander">
<CommanderHero card={commanderEntry} onClear={clearCommander} preview={preview} />
{commanders.map((c) => (
<CommanderHero key={c.oracle_id} card={c} onClear={() => clearCommander(c)} preview={preview} />
))}
</div>
)}
@@ -1009,6 +1115,15 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
results={cmdResults}
onPick={setCommander}
/>
{commanderEntry?.partner && !partnerEntry && (
<CardSearchBox
placeholder={partnerPlaceholder(commanderEntry.partner)}
value={partnerQuery}
onChange={setPartnerQuery}
results={partnerResults}
onPick={addPartner}
/>
)}
</div>
)}
</div>