👁️
1import { getCardsInSection, isKnownSection } from "@/lib/deck-types";
2import type { Card } from "@/lib/scryfall-types";
3import {
4 asRuleNumber,
5 type Rule,
6 type ValidationContext,
7 type Violation,
8 violation,
9} from "../types";
10import { getFrontFaceTypeLine, getOracleText, getTypeLine } from "../utils";
11
12/**
13 * Commander required - at least one commander
14 */
15export const commanderRequiredRule: Rule<"commanderRequired"> = {
16 id: "commanderRequired",
17 rule: asRuleNumber("903.3"),
18 ruleText:
19 "Each deck has a legendary card designated as its commander. That card must be either (a) a creature card, (b) a Vehicle card, or (c) a Spacecraft card with one or more power/toughness boxes. This designation is not a characteristic of the object represented by the card; rather, it is an attribute of the card itself. The card retains this designation even when it changes zones.",
20 category: "structure",
21 description: "Deck must have at least one commander",
22 validate(ctx: ValidationContext): Violation[] {
23 const { deck } = ctx;
24 const commanders = getCardsInSection(deck, "commander");
25 const commanderCount = commanders.reduce((sum, c) => sum + c.quantity, 0);
26
27 if (commanderCount === 0) {
28 return [violation(this, "Deck must have a commander", "error")];
29 }
30
31 return [];
32 },
33};
34
35/**
36 * Commander must be legendary creature, vehicle, or spacecraft
37 * (or any card with "can be your commander" text)
38 *
39 * As of 2024, vehicles and spacecraft can be commanders without
40 * special text - they're allowed by the base Commander rules.
41 */
42export const commanderLegendaryRule: Rule<"commanderLegendary"> = {
43 id: "commanderLegendary",
44 rule: asRuleNumber("903.3"),
45 ruleText:
46 "Each deck has a legendary card designated as its commander. That card must be either (a) a creature card, (b) a Vehicle card, or (c) a Spacecraft card with one or more power/toughness boxes. This designation is not a characteristic of the object represented by the card; rather, it is an attribute of the card itself. The card retains this designation even when it changes zones.",
47 category: "structure",
48 description:
49 "Commander must be a legendary creature, vehicle, or spacecraft with P/T",
50 validate(ctx: ValidationContext): Violation[] {
51 const { deck, cardLookup } = ctx;
52 const violations: Violation[] = [];
53 const commanders = getCardsInSection(deck, "commander");
54
55 for (const entry of commanders) {
56 const card = cardLookup(entry.scryfallId);
57 if (!card) continue;
58
59 if (!isValidCommanderType(card)) {
60 violations.push(
61 violation(this, `${card.name} is not a legendary creature`, "error", {
62 cardName: card.name,
63 oracleId: entry.oracleId,
64 section: "commander",
65 }),
66 );
67 }
68 }
69
70 return violations;
71 },
72};
73
74/**
75 * Check if card has a creature type valid for commander (creature, vehicle, spacecraft with P/T).
76 * Shared between regular Commander and PDH validation.
77 *
78 * For DFCs/MDFCs, checks the FRONT FACE only - a Saga that transforms into
79 * a creature (e.g., Behold the Unspeakable) is NOT a legal commander.
80 */
81export function hasCommanderCreatureType(card: Card): boolean {
82 const typeLine = getFrontFaceTypeLine(card).toLowerCase();
83
84 if (typeLine.includes("creature")) return true;
85 if (typeLine.includes("vehicle")) return true;
86
87 // Spacecraft need P/T box to be valid (903.3c)
88 if (typeLine.includes("spacecraft")) {
89 return card.power != null && card.toughness != null;
90 }
91
92 return false;
93}
94
95export function isValidCommanderType(card: Card): boolean {
96 // Silver-bordered and acorn cards can't be commanders in sanctioned play
97 if (card.border_color === "silver" || card.security_stamp === "acorn") {
98 return false;
99 }
100
101 const frontTypeLine = getFrontFaceTypeLine(card).toLowerCase();
102 const oracleText = getOracleText(card).toLowerCase();
103
104 const isLegendary = frontTypeLine.includes("legendary");
105 const canBeCommander = oracleText.includes("can be your commander");
106
107 // Grist-style cards: creatures in all zones except battlefield
108 // e.g., "As long as Grist isn't on the battlefield, it's a 1/1 Insect creature"
109 const isCreatureOutsideBattlefield =
110 /isn't on the battlefield.*it's a.*creature/i.test(oracleText);
111
112 // Legendary creature/vehicle/spacecraft (with P/T for spacecraft)
113 if (isLegendary && hasCommanderCreatureType(card)) {
114 return true;
115 }
116
117 // Cards with explicit "can be your commander" text
118 if (canBeCommander) {
119 return true;
120 }
121
122 // Legendary cards that are creatures outside the battlefield (Grist)
123 if (isLegendary && isCreatureOutsideBattlefield) {
124 return true;
125 }
126
127 return false;
128}
129
130/**
131 * Partner rule - validates commander pairing is legal
132 *
133 * Legal pairings:
134 * - Both have generic "Partner" (not "Partner with X")
135 * - Both have "Friends forever"
136 * - One has "Partner with [NAME]" and the other is that NAME
137 * - One has "Choose a Background" and other is a Background enchantment
138 */
139export const commanderPartnerRule: Rule<"commanderPartner"> = {
140 id: "commanderPartner",
141 rule: asRuleNumber("702.124a"),
142 ruleText:
143 "Partner abilities are keyword abilities that modify the rules for deck construction in the Commander variant (see rule 903), and they function before the game begins. Each partner ability allows you to designate two legendary cards as your commander rather than one. Each partner ability has its own requirements for those two commanders. The partner abilities are: partner, partner—[text], partner with [name], friends forever, choose a Background, and Doctor's companion.",
144 category: "structure",
145 description: "Multiple commanders must have valid partner pairing",
146 validate(ctx: ValidationContext): Violation[] {
147 const { deck, cardLookup } = ctx;
148 const commanders = getCardsInSection(deck, "commander");
149
150 // Expand commanders by quantity
151 const commanderCards: Card[] = [];
152 for (const entry of commanders) {
153 const card = cardLookup(entry.scryfallId);
154 if (!card) continue;
155 for (let i = 0; i < entry.quantity; i++) {
156 commanderCards.push(card);
157 }
158 }
159
160 if (commanderCards.length <= 1) return [];
161
162 if (commanderCards.length > 2) {
163 return [
164 violation(
165 this,
166 `Deck has ${commanderCards.length} commanders, maximum is 2`,
167 "error",
168 ),
169 ];
170 }
171
172 const [card1, card2] = commanderCards;
173 const pairingResult = validatePairing(card1, card2);
174
175 if (!pairingResult.valid) {
176 return [
177 violation(
178 this,
179 `${card1.name} and ${card2.name} cannot be paired: ${pairingResult.reason}`,
180 "error",
181 ),
182 ];
183 }
184
185 return [];
186 },
187};
188
189/**
190 * Validate if two cards can legally be paired as commanders
191 */
192function validatePairing(
193 card1: Card,
194 card2: Card,
195): { valid: true } | { valid: false; reason: string } {
196 const info1 = getPartnerInfo(card1);
197 const info2 = getPartnerInfo(card2);
198
199 // Generic partner: both must have it
200 if (info1.hasGenericPartner && info2.hasGenericPartner) {
201 return { valid: true };
202 }
203
204 // Friends forever: both must have it
205 if (info1.hasFriendsForever && info2.hasFriendsForever) {
206 return { valid: true };
207 }
208
209 // Partner with: check if they name each other (case-insensitive)
210 if (
211 info1.partnerWithName &&
212 info1.partnerWithName.toLowerCase() === card2.name.toLowerCase()
213 ) {
214 return { valid: true };
215 }
216 if (
217 info2.partnerWithName &&
218 info2.partnerWithName.toLowerCase() === card1.name.toLowerCase()
219 ) {
220 return { valid: true };
221 }
222
223 // Background pairing: one chooses background, other is background
224 if (info1.choosesBackground && info2.isBackground) {
225 return { valid: true };
226 }
227 if (info2.choosesBackground && info1.isBackground) {
228 return { valid: true };
229 }
230
231 // Doctor's companion: can pair with a Doctor (Time Lord Doctor creature)
232 if (info1.hasDoctorsCompanion && isDoctor(card2)) {
233 return { valid: true };
234 }
235 if (info2.hasDoctorsCompanion && isDoctor(card1)) {
236 return { valid: true };
237 }
238
239 // No valid pairing found
240 const getAbilityName = (info: PartnerInfo): string => {
241 if (info.hasGenericPartner) return "Partner";
242 if (info.hasFriendsForever) return "Friends forever";
243 if (info.partnerWithName) return `Partner with ${info.partnerWithName}`;
244 if (info.choosesBackground) return "Choose a Background";
245 if (info.isBackground) return "Background";
246 if (info.hasDoctorsCompanion) return "Doctor's companion";
247 return "no partner ability";
248 };
249
250 return {
251 valid: false,
252 reason: `${getAbilityName(info1)} cannot pair with ${getAbilityName(info2)}`,
253 };
254}
255
256export interface PartnerInfo {
257 hasGenericPartner: boolean;
258 hasFriendsForever: boolean;
259 partnerWithName: string | null;
260 choosesBackground: boolean;
261 isBackground: boolean;
262 hasDoctorsCompanion: boolean;
263}
264
265export function getPartnerInfo(card: Card): PartnerInfo {
266 const oracleText = getOracleText(card).toLowerCase();
267 const typeLine = getTypeLine(card).toLowerCase();
268 const keywords = card.keywords?.map((k) => k.toLowerCase()) ?? [];
269
270 // Check for "Partner with [Name]" pattern - extract the name
271 const partnerWithMatch = oracleText.match(/partner with ([^(]+)\s*\(/i);
272 const partnerWithName = partnerWithMatch ? partnerWithMatch[1].trim() : null;
273
274 // Friends forever uses "Partner—Friends forever" syntax
275 // Scryfall keyword array just shows "Partner", so we check oracle text
276 const hasFriendsForever = /partner[—-]+friends forever/i.test(oracleText);
277
278 // Generic partner has "Partner" keyword but NOT "Partner with X" or "Friends forever"
279 const hasPartnerKeyword = keywords.includes("partner");
280 const hasGenericPartner =
281 hasPartnerKeyword && !partnerWithName && !hasFriendsForever;
282
283 return {
284 hasGenericPartner,
285 hasFriendsForever,
286 partnerWithName,
287 choosesBackground: oracleText.includes("choose a background"),
288 isBackground: typeLine.includes("background"),
289 hasDoctorsCompanion: keywords.includes("doctor's companion"),
290 };
291}
292
293export function isDoctor(card: Card): boolean {
294 const typeLine = getTypeLine(card).toLowerCase();
295 return typeLine.includes("time lord") && typeLine.includes("doctor");
296}
297
298/**
299 * Color identity - all cards must match commander's color identity
300 * Errors for main deck, warnings for maybeboard
301 */
302export const colorIdentityRule: Rule<"colorIdentity"> = {
303 id: "colorIdentity",
304 rule: asRuleNumber("903.4"),
305 ruleText:
306 "The Commander variant uses color identity to determine what cards can be in a deck with a certain commander. The color identity of a card is the color or colors of any mana symbols in that card's mana cost or rules text, plus any colors defined by its characteristic-defining abilities (see rule 604.3) or color indicator (see rule 204).",
307 category: "identity",
308 description: "Cards must match commander color identity",
309 validate(ctx: ValidationContext): Violation[] {
310 const { deck, cardLookup, commanderColors } = ctx;
311
312 if (!commanderColors) return [];
313
314 const violations: Violation[] = [];
315 const allowedColors = new Set<string>(commanderColors);
316
317 for (const entry of deck.cards) {
318 if (entry.section === "commander") continue;
319
320 const card = cardLookup(entry.scryfallId);
321 if (!card) continue;
322
323 const cardIdentity = card.color_identity ?? [];
324 const invalidColors = cardIdentity.filter((c) => !allowedColors.has(c));
325
326 if (invalidColors.length > 0) {
327 const commanderStr =
328 commanderColors.length > 0 ? commanderColors.join("") : "colorless";
329 const severity = entry.section === "maybeboard" ? "warning" : "error";
330
331 violations.push(
332 violation(
333 this,
334 `${card.name} has colors outside commander identity (${invalidColors.join("")} not in ${commanderStr})`,
335 severity,
336 {
337 cardName: card.name,
338 oracleId: entry.oracleId,
339 section: isKnownSection(entry.section)
340 ? entry.section
341 : undefined,
342 },
343 ),
344 );
345 }
346 }
347
348 return violations;
349 },
350};
351
352/**
353 * Commander must be a planeswalker (Oathbreaker)
354 */
355export const commanderPlaneswalkerRule: Rule<"commanderPlaneswalker"> = {
356 id: "commanderPlaneswalker",
357 rule: asRuleNumber("906.3"),
358 ruleText:
359 "Each deck has a planeswalker card designated as its Oathbreaker. This designation is an attribute of the card itself. The card retains this designation even when it changes zones. (oathbreakermtg.org/rules)",
360 category: "structure",
361 description: "Commander must be a planeswalker (Oathbreaker)",
362 validate(ctx: ValidationContext): Violation[] {
363 const { deck, cardLookup } = ctx;
364 const violations: Violation[] = [];
365 const commanders = getCardsInSection(deck, "commander");
366
367 for (const entry of commanders) {
368 const card = cardLookup(entry.scryfallId);
369 if (!card) continue;
370
371 const typeLine = getTypeLine(card).toLowerCase();
372
373 // Skip signature spell (instant/sorcery) - that's validated separately
374 if (typeLine.includes("instant") || typeLine.includes("sorcery")) {
375 continue;
376 }
377
378 if (!typeLine.includes("planeswalker")) {
379 violations.push(
380 violation(this, `${card.name} is not a planeswalker`, "error", {
381 cardName: card.name,
382 oracleId: entry.oracleId,
383 section: "commander",
384 }),
385 );
386 }
387 }
388
389 return violations;
390 },
391};
392
393/**
394 * Signature spell requirement (Oathbreaker)
395 * Commander section must have exactly one instant or sorcery
396 * Signature spell must match oathbreaker's color identity
397 */
398export const signatureSpellRule: Rule<"signatureSpell"> = {
399 id: "signatureSpell",
400 rule: asRuleNumber("906.4"),
401 ruleText:
402 "Each deck has an instant or sorcery card designated as its Signature Spell. The Signature Spell must fall within the color identity of the Oathbreaker. (oathbreakermtg.org/rules)",
403 category: "structure",
404 description:
405 "Oathbreaker requires exactly one signature spell (instant/sorcery) within color identity",
406 validate(ctx: ValidationContext): Violation[] {
407 const { deck, cardLookup, commanderColors } = ctx;
408 const commanders = getCardsInSection(deck, "commander");
409 const violations: Violation[] = [];
410
411 let signatureSpellCount = 0;
412 const allowedColors = new Set<string>(commanderColors ?? []);
413
414 for (const entry of commanders) {
415 const card = cardLookup(entry.scryfallId);
416 if (!card) continue;
417
418 const typeLine = getTypeLine(card).toLowerCase();
419 if (typeLine.includes("instant") || typeLine.includes("sorcery")) {
420 signatureSpellCount += entry.quantity;
421
422 if (commanderColors) {
423 const spellIdentity = card.color_identity ?? [];
424 const invalidColors = spellIdentity.filter(
425 (c) => !allowedColors.has(c),
426 );
427 if (invalidColors.length > 0) {
428 const commanderStr =
429 commanderColors.length > 0
430 ? commanderColors.join("")
431 : "colorless";
432 violations.push(
433 violation(
434 this,
435 `Signature spell ${card.name} has colors outside oathbreaker color identity (${invalidColors.join("")} not in ${commanderStr})`,
436 "error",
437 {
438 cardName: card.name,
439 oracleId: entry.oracleId,
440 section: "commander",
441 },
442 ),
443 );
444 }
445 }
446 }
447 }
448
449 if (signatureSpellCount === 0) {
450 violations.push(
451 violation(
452 this,
453 "Oathbreaker deck must have a signature spell (instant/sorcery in commander zone)",
454 "error",
455 ),
456 );
457 return violations;
458 }
459
460 if (signatureSpellCount > 1) {
461 violations.push(
462 violation(
463 this,
464 `Oathbreaker deck can only have 1 signature spell, found ${signatureSpellCount}`,
465 "error",
466 ),
467 );
468 }
469
470 return violations;
471 },
472};