👁️
at dev 507 lines 15 kB view raw
1import { getCardsInSection, isKnownSection } from "@/lib/deck-types"; 2import { formatDisplayName } from "@/lib/format-utils"; 3import type { OracleId } from "@/lib/scryfall-types"; 4import { getCopyLimit } from "../exceptions"; 5import { 6 asRuleNumber, 7 type Rule, 8 type ValidationContext, 9 type Violation, 10 violation, 11} from "../types"; 12 13/** 14 * Check card legality via Scryfall's legalities field 15 * 16 * Note: For pauper commander, commanders are validated separately by the 17 * commanderUncommon rule. Scryfall marks uncommon cards as "not_legal" in PDH 18 * because they can't go in the 99, but they're valid commanders. 19 */ 20export const cardLegalityRule: Rule<"cardLegality"> = { 21 id: "cardLegality", 22 rule: asRuleNumber("100.2a"), 23 ruleText: 24 "In constructed play (a way of playing in which each player creates their own deck ahead of time), each deck has a minimum deck size of 60 cards. A constructed deck may contain any number of basic land cards and no more than four of any card with a particular English name other than basic land cards. For the purposes of deck construction, cards with interchangeable names have the same English name (see rule 201.3).", 25 category: "legality", 26 description: "Card must be legal in format", 27 validate(ctx: ValidationContext): Violation[] { 28 const { deck, cardLookup, config } = ctx; 29 const field = config.legalityField; 30 31 // Formats without a legality field (draft, kitchentable) skip this check 32 if (!field) return []; 33 34 const violations: Violation[] = []; 35 36 // For PDH, commanders are validated by commanderUncommon rule instead 37 const skipCommanderLegality = field === "paupercommander"; 38 39 for (const entry of deck.cards) { 40 if (entry.section === "maybeboard") continue; 41 if (skipCommanderLegality && entry.section === "commander") continue; 42 43 const card = cardLookup(entry.scryfallId); 44 if (!card) continue; 45 46 const legality = card.legalities?.[field]; 47 if (legality === "not_legal") { 48 violations.push( 49 violation( 50 this, 51 `${card.name} is not legal in ${formatDisplayName(field) || field}`, 52 "error", 53 { 54 cardName: card.name, 55 oracleId: entry.oracleId, 56 section: isKnownSection(entry.section) 57 ? entry.section 58 : undefined, 59 }, 60 ), 61 ); 62 } 63 } 64 65 return violations; 66 }, 67}; 68 69/** 70 * Check for banned cards 71 */ 72export const bannedRule: Rule<"banned"> = { 73 id: "banned", 74 rule: asRuleNumber("MTR"), 75 ruleText: 76 "The current Magic: The Gathering Tournament Rules can be found at WPN.Wizards.com/en/rules-documents.", 77 category: "legality", 78 description: "Card is banned in format", 79 validate(ctx: ValidationContext): Violation[] { 80 const { deck, cardLookup, config } = ctx; 81 const field = config.legalityField; 82 83 // Formats without a legality field (draft, kitchentable) skip this check 84 if (!field) return []; 85 86 const violations: Violation[] = []; 87 88 for (const entry of deck.cards) { 89 if (entry.section === "maybeboard") continue; 90 91 const card = cardLookup(entry.scryfallId); 92 if (!card) continue; 93 94 const legality = card.legalities?.[field]; 95 if (legality === "banned") { 96 violations.push( 97 violation( 98 this, 99 `${card.name} is banned in ${formatDisplayName(field) || field}`, 100 "error", 101 { 102 cardName: card.name, 103 oracleId: entry.oracleId, 104 section: isKnownSection(entry.section) 105 ? entry.section 106 : undefined, 107 }, 108 ), 109 ); 110 } 111 } 112 113 return violations; 114 }, 115}; 116 117/** 118 * Check for restricted cards (Vintage - max 1 copy) 119 */ 120export const restrictedRule: Rule<"restricted"> = { 121 id: "restricted", 122 rule: asRuleNumber("MTR"), 123 ruleText: 124 "The current Magic: The Gathering Tournament Rules can be found at WPN.Wizards.com/en/rules-documents.", 125 category: "quantity", 126 description: "Restricted cards limited to 1 copy", 127 validate(ctx: ValidationContext): Violation[] { 128 const { deck, oracleLookup, config } = ctx; 129 const field = config.legalityField; 130 131 // Formats without a legality field (draft, kitchentable) skip this check 132 if (!field) return []; 133 134 const violations: Violation[] = []; 135 const oracleCounts = new Map<OracleId, number>(); 136 137 for (const entry of deck.cards) { 138 if (entry.section === "maybeboard") continue; 139 140 const current = oracleCounts.get(entry.oracleId) ?? 0; 141 oracleCounts.set(entry.oracleId, current + entry.quantity); 142 } 143 144 for (const [oracleId, count] of oracleCounts) { 145 if (count <= 1) continue; 146 147 const card = oracleLookup(oracleId); 148 if (!card) continue; 149 150 const legality = card.legalities?.[field]; 151 if (legality === "restricted") { 152 violations.push( 153 violation( 154 this, 155 `${card.name} is restricted to 1 copy, deck has ${count}`, 156 "error", 157 { 158 cardName: card.name, 159 oracleId: card.oracle_id, 160 quantity: count, 161 }, 162 ), 163 ); 164 } 165 } 166 167 return violations; 168 }, 169}; 170 171/** 172 * Singleton rule - max 1 copy (Commander variants) 173 */ 174export const singletonRule: Rule<"singleton"> = { 175 id: "singleton", 176 rule: asRuleNumber("903.5b"), 177 ruleText: 178 "Other than basic lands, each card in a Commander deck must have a different English name. For the purposes of deck construction, cards with interchangeable names have the same English name (see rule 201.3).", 179 category: "quantity", 180 description: "Maximum 1 copy of each card (except basics and exceptions)", 181 validate(ctx: ValidationContext): Violation[] { 182 const { deck, oracleLookup } = ctx; 183 const violations: Violation[] = []; 184 185 const oracleCounts = new Map<OracleId, number>(); 186 187 for (const entry of deck.cards) { 188 if (entry.section === "maybeboard") continue; 189 190 const current = oracleCounts.get(entry.oracleId) ?? 0; 191 oracleCounts.set(entry.oracleId, current + entry.quantity); 192 } 193 194 for (const [oracleId, count] of oracleCounts) { 195 const card = oracleLookup(oracleId); 196 if (!card) continue; 197 198 const limit = getCopyLimit(card, 1); 199 if (count > limit) { 200 violations.push( 201 violation( 202 this, 203 `${card.name} exceeds singleton limit (${count}/${limit})`, 204 "error", 205 { 206 cardName: card.name, 207 oracleId: card.oracle_id, 208 quantity: count, 209 }, 210 ), 211 ); 212 } 213 } 214 215 return violations; 216 }, 217}; 218 219/** 220 * Playset rule - max 4 copies (60-card formats) 221 */ 222export const playsetRule: Rule<"playset"> = { 223 id: "playset", 224 rule: asRuleNumber("100.2a"), 225 ruleText: 226 "In constructed play (a way of playing in which each player creates their own deck ahead of time), each deck has a minimum deck size of 60 cards. A constructed deck may contain any number of basic land cards and no more than four of any card with a particular English name other than basic land cards. For the purposes of deck construction, cards with interchangeable names have the same English name (see rule 201.3).", 227 category: "quantity", 228 description: "Maximum 4 copies of each card (except basics and exceptions)", 229 validate(ctx: ValidationContext): Violation[] { 230 const { deck, oracleLookup } = ctx; 231 const violations: Violation[] = []; 232 233 const oracleCounts = new Map<OracleId, number>(); 234 235 for (const entry of deck.cards) { 236 if (entry.section === "maybeboard") continue; 237 238 const current = oracleCounts.get(entry.oracleId) ?? 0; 239 oracleCounts.set(entry.oracleId, current + entry.quantity); 240 } 241 242 for (const [oracleId, count] of oracleCounts) { 243 const card = oracleLookup(oracleId); 244 if (!card) continue; 245 246 const limit = getCopyLimit(card, 4); 247 if (count > limit) { 248 violations.push( 249 violation( 250 this, 251 `${card.name} exceeds playset limit (${count}/${limit})`, 252 "error", 253 { 254 cardName: card.name, 255 oracleId: card.oracle_id, 256 quantity: count, 257 }, 258 ), 259 ); 260 } 261 } 262 263 return violations; 264 }, 265}; 266 267/** 268 * Minimum deck size (60-card formats) 269 */ 270export const deckSizeMinRule: Rule<"deckSizeMin"> = { 271 id: "deckSizeMin", 272 rule: asRuleNumber("100.2a"), 273 ruleText: 274 "In constructed play (a way of playing in which each player creates their own deck ahead of time), each deck has a minimum deck size of 60 cards. A constructed deck may contain any number of basic land cards and no more than four of any card with a particular English name other than basic land cards. For the purposes of deck construction, cards with interchangeable names have the same English name (see rule 201.3).", 275 category: "structure", 276 description: "Deck must meet minimum size", 277 validate(ctx: ValidationContext): Violation[] { 278 const { deck, config } = ctx; 279 const minDeckSize = config.minDeckSize; 280 281 if (minDeckSize === undefined) return []; 282 283 const mainboard = getCardsInSection(deck, "mainboard"); 284 const mainboardCount = mainboard.reduce((sum, c) => sum + c.quantity, 0); 285 286 if (mainboardCount < minDeckSize) { 287 return [ 288 violation( 289 this, 290 `Deck has ${mainboardCount} cards, minimum is ${minDeckSize}`, 291 "error", 292 ), 293 ]; 294 } 295 296 return []; 297 }, 298}; 299 300/** 301 * Exact deck size (Commander = 100) 302 */ 303export const deckSizeExactRule: Rule<"deckSizeExact"> = { 304 id: "deckSizeExact", 305 rule: asRuleNumber("903.5a"), 306 ruleText: 307 "Each deck must contain exactly 100 cards, including its commander. In other words, the minimum deck size and the maximum deck size are both 100.", 308 category: "structure", 309 description: "Deck must be exactly the specified size", 310 validate(ctx: ValidationContext): Violation[] { 311 const { deck, config } = ctx; 312 const deckSize = config.deckSize; 313 314 if (deckSize === undefined) return []; 315 316 const commander = getCardsInSection(deck, "commander"); 317 const mainboard = getCardsInSection(deck, "mainboard"); 318 319 const commanderCount = commander.reduce((sum, c) => sum + c.quantity, 0); 320 const mainboardCount = mainboard.reduce((sum, c) => sum + c.quantity, 0); 321 const totalCount = commanderCount + mainboardCount; 322 323 if (totalCount !== deckSize) { 324 return [ 325 violation( 326 this, 327 `Deck has ${totalCount} cards, must be exactly ${deckSize}`, 328 "error", 329 ), 330 ]; 331 } 332 333 return []; 334 }, 335}; 336 337/** 338 * Sideboard size limit 339 */ 340export const sideboardSizeRule: Rule<"sideboardSize"> = { 341 id: "sideboardSize", 342 rule: asRuleNumber("100.4a"), 343 ruleText: 344 "In constructed play, a sideboard may contain no more than fifteen cards. The four-card limit (see rule 100.2a) applies to the combined deck and sideboard.", 345 category: "structure", 346 description: "Sideboard cannot exceed maximum size", 347 validate(ctx: ValidationContext): Violation[] { 348 const { deck, config } = ctx; 349 const sideboardSize = config.sideboardSize; 350 351 if (sideboardSize === undefined) return []; 352 353 const sideboard = getCardsInSection(deck, "sideboard"); 354 const sideboardCount = sideboard.reduce((sum, c) => sum + c.quantity, 0); 355 356 if (sideboardCount > sideboardSize) { 357 return [ 358 violation( 359 this, 360 `Sideboard has ${sideboardCount} cards, maximum is ${sideboardSize}`, 361 "error", 362 ), 363 ]; 364 } 365 366 return []; 367 }, 368}; 369 370/** 371 * Conspiracy cards are only legal in Conspiracy Draft 372 */ 373export const conspiracyCardRule: Rule<"conspiracyCard"> = { 374 id: "conspiracyCard", 375 rule: asRuleNumber("315.1"), 376 ruleText: 377 "Conspiracy cards are used only in limited play, particularly in the Conspiracy Draft variant (see rule 905). Conspiracy cards aren't used in constructed play.", 378 category: "legality", 379 description: "Conspiracy cards are not legal in constructed formats", 380 validate(ctx: ValidationContext): Violation[] { 381 const { deck, cardLookup } = ctx; 382 const violations: Violation[] = []; 383 384 for (const entry of deck.cards) { 385 const card = cardLookup(entry.scryfallId); 386 if (!card) continue; 387 388 const typeLine = card.type_line?.toLowerCase() ?? ""; 389 if (typeLine.includes("conspiracy")) { 390 violations.push( 391 violation( 392 this, 393 `${card.name} is a Conspiracy card and not legal in constructed formats`, 394 "error", 395 { 396 cardName: card.name, 397 oracleId: entry.oracleId, 398 section: isKnownSection(entry.section) 399 ? entry.section 400 : undefined, 401 }, 402 ), 403 ); 404 } 405 } 406 407 return violations; 408 }, 409}; 410 411/** 412 * Silver-bordered and acorn-stamped cards are not tournament legal 413 */ 414export const illegalCardTypeRule: Rule<"illegalCardType"> = { 415 id: "illegalCardType", 416 rule: asRuleNumber("100.7"), 417 ruleText: 418 'Certain cards are intended for casual play and may have features and text that aren\'t covered by these rules. These include Mystery Booster playtest cards, promotional cards and cards in "Un-sets" that were printed with a silver border, and cards in the Unfinity expansion that have an acorn symbol at the bottom of the card.', 419 category: "legality", 420 description: "Silver-bordered and acorn cards are not tournament legal", 421 validate(ctx: ValidationContext): Violation[] { 422 const { deck, cardLookup } = ctx; 423 const violations: Violation[] = []; 424 425 for (const entry of deck.cards) { 426 const card = cardLookup(entry.scryfallId); 427 if (!card) continue; 428 429 if (card.border_color === "silver") { 430 violations.push( 431 violation( 432 this, 433 `${card.name} is a silver-bordered card and not tournament legal`, 434 "error", 435 { 436 cardName: card.name, 437 oracleId: entry.oracleId, 438 section: isKnownSection(entry.section) 439 ? entry.section 440 : undefined, 441 }, 442 ), 443 ); 444 } 445 446 if (card.security_stamp === "acorn") { 447 violations.push( 448 violation( 449 this, 450 `${card.name} is an acorn card and not tournament legal`, 451 "error", 452 { 453 cardName: card.name, 454 oracleId: entry.oracleId, 455 section: isKnownSection(entry.section) 456 ? entry.section 457 : undefined, 458 }, 459 ), 460 ); 461 } 462 } 463 464 return violations; 465 }, 466}; 467 468/** 469 * Ante cards are banned in all sanctioned formats 470 */ 471export const anteCardRule: Rule<"anteCard"> = { 472 id: "anteCard", 473 rule: asRuleNumber("407.1"), 474 ruleText: 475 "Earlier versions of the Magic rules included an ante rule as a way of playing \"for keeps.\" Playing Magic games for ante is now considered an optional variation on the game, and it's allowed only where it's not forbidden by law or by other rules. Playing for ante is strictly forbidden under the Magic: The Gathering Tournament Rules (WPN.Wizards.com/en/rules-documents).", 476 category: "legality", 477 description: "Ante cards are banned in all sanctioned formats", 478 validate(ctx: ValidationContext): Violation[] { 479 const { deck, cardLookup } = ctx; 480 const violations: Violation[] = []; 481 482 for (const entry of deck.cards) { 483 const card = cardLookup(entry.scryfallId); 484 if (!card) continue; 485 486 const oracleText = card.oracle_text?.toLowerCase() ?? ""; 487 if (oracleText.includes("playing for ante")) { 488 violations.push( 489 violation( 490 this, 491 `${card.name} is an ante card and banned in all sanctioned formats`, 492 "error", 493 { 494 cardName: card.name, 495 oracleId: entry.oracleId, 496 section: isKnownSection(entry.section) 497 ? entry.section 498 : undefined, 499 }, 500 ), 501 ); 502 } 503 } 504 505 return violations; 506 }, 507};