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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user