👁️
at dev 472 lines 15 kB view raw
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};