👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

deck validation pt3

+308 -24
+10 -3
.claude/DECK_VALIDATION.md
··· 97 97 | `colorIdentity` | 903.4 | Cards must match commander colors | 98 98 | `commanderRequired` | 903.3 | At least 1 commander | 99 99 | `commanderPartner` | 702.124 | Valid partner pairing if 2 commanders | 100 - | `commanderLegendary` | 903.3 | Commander must be legendary creature | 101 - | `commanderUncommon` | 903.3 | Commander must be uncommon (PDH) | 100 + | `commanderLegendary` | 903.3 | Commander must be legendary creature/vehicle/spacecraft | 101 + | `commanderUncommon` | 903.3 | Commander must be uncommon creature (PDH) | 102 102 | `commanderPlaneswalker` | 903.3 | Commander must be planeswalker (Oathbreaker) | 103 103 | `signatureSpell` | 903.3 | Oathbreaker signature spell requirement | 104 104 ··· 210 210 211 211 ## PDH (Pauper Commander) Notes 212 212 213 - - Commander must have an uncommon printing in paper or MTGO 213 + - Commander must be a creature (or vehicle) with an uncommon printing in paper/MTGO 214 + - Non-creature artifacts (Sol Ring, etc.) cannot be PDH commanders even if uncommon 214 215 - Arena-only uncommon downshifts don't count 215 216 - Any printing can be used if a valid uncommon exists 216 217 - Uses `legalities.paupercommander` for the 99, not `pauper` 217 218 - Commander doesn't need to be legendary (just uncommon creature) 219 + 220 + ## Commander Eligibility (2024 Rule Update) 221 + 222 + As of 2024, legendary vehicles and spacecraft are valid commanders without needing 223 + "can be your commander" text. The `isValidCommanderType` function is exported for 224 + use in `is:commander` search filtering.
+3
src/lib/__tests__/test-card-lookup.ts
··· 71 71 ); 72 72 } 73 73 74 + // log the card, so that the details of the card are shown in the error 75 + console.log(card); 76 + 74 77 return card; 75 78 } 76 79
+19
src/lib/__tests__/test-cards.json
··· 8 8 "Akki Lavarunner": "47795817-73e5-4af6-bd1e-d69b193e8e9e", 9 9 "Akki Rockspeaker": "01cd52f9-7fac-4396-bec7-d06bf683e011", 10 10 "Akoum Warrior": "afedce7b-0e18-40ad-a26a-1933fddb560d", 11 + "Aminatou, the Fateshifter": "3a30089d-cd2d-49be-9b06-7a2454117692", 11 12 "Ancient Tomb": "23467047-6dba-4498-b783-1ebc4f74b8c2", 12 13 "Apostle's Blessing": "ea393815-0202-4edc-aa1e-6885fe9b20ab", 13 14 "Arcbound Wanderer": "03436524-197a-4941-a2a1-c7c4b71c4709", ··· 21 22 "Beloved Chaplain": "a07406b7-faa1-4ed3-ab84-314c40f3f7f1", 22 23 "Bird Admirer": "58bd02ae-2676-4c9c-b24e-2bd51be8bde7", 23 24 "Birds of Paradise": "d3a0b660-358c-41bd-9cd2-41fbf3491b1a", 25 + "Bjorna, Nightfall Alchemist": "c880fbdc-bdd9-4f80-81d4-e3e1124f76ca", 24 26 "Black Knight": "9456c5b6-946d-403a-8ed0-dff9f921d98c", 25 27 "Blood Knight": "67fba605-9cfa-499c-83e0-4fbd023bcfa0", 26 28 "Blood Pet": "e05c6c80-a91a-45e0-b991-0014fd5a6472", ··· 33 35 "Cabal Coffers": "7358e164-5704-4e78-9b21-6a9bf2a968ce", 34 36 "Caldera Lake": "c737d27b-db14-4bd4-8f16-bcbd4401c47b", 35 37 "Canopy Vista": "dcb7e046-f01b-497c-88e5-57794eb30ce5", 38 + "Cecily, Haunted Mage": "a726c27d-2955-4d41-b8a6-fca654c020a2", 36 39 "Celestial Colonnade": "876ac6f6-74de-4666-84c6-83d81f054723", 37 40 "Chrome Mox": "ec3d4466-547c-4e02-b1b5-a156ec4637e9", 38 41 "City of Brass": "f25351e3-539b-4bbc-b92d-6480acf4d722", ··· 47 50 "Dryad Arbor": "e996cd67-739c-40f4-b276-0042acf26c71", 48 51 "Elvish Mystic": "3f3b2c10-21f8-4e13-be83-4ef3fa36e123", 49 52 "Elvish Spirit Guide": "6b0e23cf-7d68-4329-86db-7adc26abd86b", 53 + "Esika, God of the Tree": "92023a5d-a143-4950-a71b-d736e6b8e959", 50 54 "Everflowing Chalice": "0a79237e-0811-4a8a-bd4d-db3ca91bff22", 51 55 "Evolving Wilds": "a75445d3-1303-4bb5-89ad-26ea93fecd48", 52 56 "Exotic Orchard": "27b047e3-0d41-45e2-98e9-9391d7923a1e", ··· 62 66 "Glacial Fortress": "027dd013-baa7-4111-b3c9-f4d1414e9c45", 63 67 "Graven Cairns": "5004b84a-33b7-4f6f-b2c2-7086b9087535", 64 68 "Grim Monolith": "229d6627-1292-4ae1-8849-b0f956fa6540", 69 + "Hare Apparent": "3c1619bd-db5e-4df6-a196-0a9d62374f6d", 65 70 "Hedron Archive": "32263baa-d3f0-463f-92b3-4e9938476add", 66 71 "Hinterland Harbor": "fb5a3403-7f0b-406c-8c4f-d693be010ca6", 67 72 "Horizon Canopy": "262a5d83-506c-4781-9bc9-1a2b5d83955c", 68 73 "Inspiring Vantage": "3f17c60e-923a-4392-9da8-87d9ded009b7", 74 + "Jace, Vryn's Prodigy": "594f6881-c059-46f8-aa4e-7151d502de73", 69 75 "Karlach, Raging Tiefling": "f40ddd57-ad75-477c-bfe6-1b0ca68b88b6", 70 76 "Ketria Triome": "6bae00e8-06cf-4ac4-a1cc-757e454109fe", 71 77 "Lightning Bolt": "4457ed35-7c10-48c8-9776-456485fdf070", ··· 79 85 "Mana Vault": "736892cb-a34b-4bb9-b56c-e26e3db207a2", 80 86 "Misty Rainforest": "09dd85aa-47bc-4713-a9b9-8b52ff2285ed", 81 87 "Mox Diamond": "f3c5978a-70fa-431f-933b-b954bd0db0ea", 88 + "Mulldrifter": "24d0f5e7-0d9e-4b76-900e-a7274e80312d", 82 89 "Mystic Gate": "e9f5feb2-2c1a-46ce-885a-4f378d7d10af", 90 + "Nazgûl": "48a62778-7c11-486f-a0e1-020c283a7ef9", 83 91 "Nicol Bolas, Planeswalker": "05e9b55e-6329-48ec-b7d7-24c6b9692244", 84 92 "Ornithopter": "a3a98bc9-caa0-49b7-951c-fe4e4f54e4ba", 93 + "Parhelion II": "24d22bcb-8a77-4c47-a508-6f4bc093c1d0", 85 94 "Phyrexian Tower": "1861e642-21d5-4232-89f3-b5557f2946c1", 95 + "Pir, Imaginative Rascal": "7683c2b2-a06f-4691-9cc5-1968dc032885", 86 96 "Polluted Delta": "ef86989d-ce80-4e55-aece-7d11710eeffa", 87 97 "Prairie Stream": "5330e24a-8568-446e-840a-594cd08bd1bc", 88 98 "Priest of Gix": "d93f82ce-0eed-45cc-a7b1-50fd4cbb6152", 89 99 "Priest of Urabrask": "e4bd8910-770b-4220-8a26-2673491f4a3e", 90 100 "Prismatic Vista": "032b8a0d-491a-4a12-ab9f-689010054d5b", 91 101 "Raging Ravine": "8d38194e-b607-4ff4-9c19-0e8636d463bf", 102 + "Raised by Giants": "1682cf24-17a3-49ad-8b6f-9b7f13ebf53c", 103 + "Relentless Rats": "104ea189-14cd-420f-afdc-57b0f827ab8e", 92 104 "Scalding Tarn": "cb027150-848c-4a66-88ad-e20222304dd8", 93 105 "Seachrome Coast": "9e7a240d-dc33-47ac-9f17-77fab4c1c340", 94 106 "Selvala, Heart of the Wilds": "1d725121-e50c-42f0-9128-56802f07c89e", 95 107 "Serra Angel": "4b7ac066-e5c7-43e6-9e7e-2739b24a905d", 108 + "Seven Dwarves": "526ca4a9-3f50-4f7a-8169-2bda95792401", 109 + "Shorikai, Genesis Engine": "e2962883-6daf-4a84-af73-917afc73092c", 96 110 "Simian Spirit Guide": "44e0ffa3-8915-4c1f-8f1a-4aeea1365f07", 97 111 "Simic Growth Chamber": "046f5783-cc7b-416a-8cf6-2bcef9c2cc1a", 98 112 "Skirk Prospector": "c18013e4-0b99-44e3-a2b2-027ace68723a", ··· 102 116 "Tarmogoyf": "45900b2f-f6a9-4c42-9642-008f3c1cf6dd", 103 117 "Tel-Jilad Chosen": "de6854d1-bdb0-4b9a-b442-08d4d90a538d", 104 118 "Temple of Mystery": "7e26f0b7-20e6-46d5-8130-d98c14d6aa29", 119 + "The Eighth Doctor": "0956bc16-ff04-4e96-8059-ef62afa2405f", 105 120 "Thran Dynamo": "a699c663-8131-4045-9265-a83e86609374", 106 121 "Thran Portal": "926ce6a2-7bdd-4380-ac65-bc902ba0c284", 122 + "Thrasios, Triton Hero": "3d867016-2601-4a37-a73d-308898d3bd37", 123 + "Toothy, Imaginary Friend": "41d6cce0-b852-4d0e-aee2-081df13dd9b8", 107 124 "Toy Boat": "2baf5c37-6191-4e9b-a080-5e12f735646f", 108 125 "Tranquil Cove": "5d641bf6-0f93-4189-8dc1-ec7ea446dade", 109 126 "Treasonous Ogre": "826d4279-1576-4898-a1c2-26fd547fb0d0", 110 127 "Tropical Island": "74b7fe23-5d3a-4092-8d78-7c0eba8f6f73", 128 + "Tymna the Weaver": "d15642e4-e61c-4d29-af48-de837991245e", 111 129 "Undercity Sewers": "08d80efc-9542-4ba2-824c-c8615d8d07f2", 112 130 "Underground Sea": "4b22be3a-8ce1-47d1-b82e-6c3ccfb0548b", 113 131 "Vault Skirge": "64cf5d59-7bcd-4b0b-a160-c8468d4c0f60", 114 132 "Wall of Shards": "8fab68ad-169d-46d3-93c4-5bdee4eea2ce", 115 133 "Wastes": "05d24b0c-904a-46b6-b42a-96a4d91a0dd4", 134 + "Wilson, Refined Grizzly": "d2766fd7-5cf9-4037-9f34-9ae3982c613a", 116 135 "Worn Powerstone": "b166b670-febc-4821-855e-f8d465644c03", 117 136 "Yavimaya Coast": "40b36bc6-c185-4bda-99e7-0118953c2c97" 118 137 }
+200 -1
src/lib/deck-validation/__tests__/rules.test.ts
··· 18 18 commanderRequiredRule, 19 19 signatureSpellRule, 20 20 } from "../rules/commander"; 21 + import { commanderUncommonRule } from "../rules/rarity"; 21 22 import type { ValidationContext } from "../types"; 22 23 23 24 describe("commander rules", () => { ··· 69 70 deck: Deck, 70 71 cardList: Card[], 71 72 commanderColors?: string[], 73 + printingsMap?: Map<OracleId, Card[]>, 72 74 ): Promise<ValidationContext> { 73 75 const cardMap = new Map<ScryfallId, Card>(); 74 76 const oracleMap = new Map<OracleId, Card>(); ··· 82 84 deck, 83 85 cardLookup: (id) => cardMap.get(id), 84 86 oracleLookup: (id) => oracleMap.get(id), 85 - getPrintings: () => [], 87 + getPrintings: (id) => printingsMap?.get(id) ?? [], 86 88 format: deck.format, 87 89 commanderColors: commanderColors as ManaColor[] | undefined, 88 90 config: { legalityField: "commander" }, ··· 346 348 const violations = signatureSpellRule.validate(ctx); 347 349 expect(violations).toHaveLength(1); 348 350 expect(violations[0].message).toContain("only have 1 signature spell"); 351 + }); 352 + }); 353 + 354 + describe("commanderLegendaryRule edge cases", () => { 355 + it("allows vehicle with 'can be your commander' text", async () => { 356 + const shorikai = await cards.get("Shorikai, Genesis Engine"); 357 + const deck = makeDeck([makeCard(shorikai, "commander")]); 358 + const ctx = await makeContextWithCards(deck, [shorikai]); 359 + const violations = commanderLegendaryRule.validate(ctx); 360 + expect(violations).toHaveLength(0); 361 + }); 362 + 363 + it("allows legendary vehicle (rule change 2024)", async () => { 364 + const parhelion = await cards.get("Parhelion II"); 365 + const deck = makeDeck([makeCard(parhelion, "commander")]); 366 + const ctx = await makeContextWithCards(deck, [parhelion]); 367 + const violations = commanderLegendaryRule.validate(ctx); 368 + expect(violations).toHaveLength(0); 369 + }); 370 + 371 + it("rejects non-legendary artifact as commander", async () => { 372 + const solRing = await cards.get("Sol Ring"); 373 + const deck = makeDeck([makeCard(solRing, "commander")]); 374 + const ctx = await makeContextWithCards(deck, [solRing]); 375 + const violations = commanderLegendaryRule.validate(ctx); 376 + expect(violations).toHaveLength(1); 377 + expect(violations[0].message).toContain("not a legendary creature"); 378 + }); 379 + 380 + it("allows planeswalker with 'can be your commander' text", async () => { 381 + const aminatou = await cards.get("Aminatou, the Fateshifter"); 382 + const deck = makeDeck([makeCard(aminatou, "commander")]); 383 + const ctx = await makeContextWithCards(deck, [aminatou]); 384 + const violations = commanderLegendaryRule.validate(ctx); 385 + expect(violations).toHaveLength(0); 386 + }); 387 + 388 + it("rejects planeswalker without 'can be your commander' in Commander", async () => { 389 + const bolas = await cards.get("Nicol Bolas, Planeswalker"); 390 + const deck = makeDeck([makeCard(bolas, "commander")], "commander"); 391 + const ctx = await makeContextWithCards(deck, [bolas]); 392 + const violations = commanderLegendaryRule.validate(ctx); 393 + expect(violations).toHaveLength(1); 394 + expect(violations[0].message).toContain("not a legendary creature"); 395 + }); 396 + 397 + it("allows DFC with legendary creature on front face", async () => { 398 + const esika = await cards.get("Esika, God of the Tree"); 399 + const deck = makeDeck([makeCard(esika, "commander")]); 400 + const ctx = await makeContextWithCards(deck, [esika]); 401 + const violations = commanderLegendaryRule.validate(ctx); 402 + expect(violations).toHaveLength(0); 403 + }); 404 + 405 + it("allows creature that transforms into planeswalker", async () => { 406 + const jace = await cards.get("Jace, Vryn's Prodigy"); 407 + const deck = makeDeck([makeCard(jace, "commander")]); 408 + const ctx = await makeContextWithCards(deck, [jace]); 409 + const violations = commanderLegendaryRule.validate(ctx); 410 + expect(violations).toHaveLength(0); 411 + }); 412 + }); 413 + 414 + describe("commanderPartnerRule edge cases", () => { 415 + it("rejects background paired with non-background-choosing creature", async () => { 416 + const selvala = await cards.get("Selvala, Heart of the Wilds"); 417 + const raisedByGiants = await cards.get("Raised by Giants"); 418 + const deck = makeDeck([ 419 + makeCard(selvala, "commander"), 420 + makeCard(raisedByGiants, "commander"), 421 + ]); 422 + const ctx = await makeContextWithCards(deck, [selvala, raisedByGiants]); 423 + const violations = commanderPartnerRule.validate(ctx); 424 + expect(violations).toHaveLength(1); 425 + expect(violations[0].message).toContain("cannot be paired"); 426 + }); 427 + 428 + it("rejects two backgrounds together", async () => { 429 + const raisedByGiants = await cards.get("Raised by Giants"); 430 + const deck = makeDeck([ 431 + makeCard(raisedByGiants, "commander"), 432 + makeCard(raisedByGiants, "commander"), 433 + ]); 434 + const ctx = await makeContextWithCards(deck, [ 435 + raisedByGiants, 436 + raisedByGiants, 437 + ]); 438 + const violations = commanderPartnerRule.validate(ctx); 439 + expect(violations).toHaveLength(1); 440 + }); 441 + 442 + it("rejects doctor's companion without a doctor", async () => { 443 + const barbara = await cards.get("Barbara Wright"); 444 + const selvala = await cards.get("Selvala, Heart of the Wilds"); 445 + const deck = makeDeck([ 446 + makeCard(barbara, "commander"), 447 + makeCard(selvala, "commander"), 448 + ]); 449 + const ctx = await makeContextWithCards(deck, [barbara, selvala]); 450 + const violations = commanderPartnerRule.validate(ctx); 451 + expect(violations).toHaveLength(1); 452 + expect(violations[0].message).toContain("cannot be paired"); 453 + }); 454 + }); 455 + 456 + describe("commanderUncommonRule (PDH)", () => { 457 + function mockPrinting( 458 + card: Card, 459 + rarity: "common" | "uncommon" | "rare" | "mythic", 460 + games: string[] = ["paper"], 461 + ): Card { 462 + return { ...card, rarity, games }; 463 + } 464 + 465 + it("passes for uncommon creature", async () => { 466 + const mulldrifter = await cards.get("Mulldrifter"); 467 + const deck = makeDeck( 468 + [makeCard(mulldrifter, "commander")], 469 + "paupercommander", 470 + ); 471 + const printingsMap = new Map<OracleId, Card[]>(); 472 + printingsMap.set(mulldrifter.oracle_id, [ 473 + mockPrinting(mulldrifter, "uncommon", ["paper"]), 474 + ]); 475 + const ctx = await makeContextWithCards( 476 + deck, 477 + [mulldrifter], 478 + undefined, 479 + printingsMap, 480 + ); 481 + const violations = commanderUncommonRule.validate(ctx); 482 + expect(violations).toHaveLength(0); 483 + }); 484 + 485 + it("rejects rare-only creature", async () => { 486 + const selvala = await cards.get("Selvala, Heart of the Wilds"); 487 + const deck = makeDeck( 488 + [makeCard(selvala, "commander")], 489 + "paupercommander", 490 + ); 491 + const printingsMap = new Map<OracleId, Card[]>(); 492 + printingsMap.set(selvala.oracle_id, [ 493 + mockPrinting(selvala, "rare", ["paper"]), 494 + mockPrinting(selvala, "mythic", ["paper"]), 495 + ]); 496 + const ctx = await makeContextWithCards( 497 + deck, 498 + [selvala], 499 + undefined, 500 + printingsMap, 501 + ); 502 + const violations = commanderUncommonRule.validate(ctx); 503 + expect(violations).toHaveLength(1); 504 + expect(violations[0].message).toContain("no uncommon printing"); 505 + }); 506 + 507 + it("rejects uncommon artifact (not creature)", async () => { 508 + const solRing = await cards.get("Sol Ring"); 509 + const deck = makeDeck( 510 + [makeCard(solRing, "commander")], 511 + "paupercommander", 512 + ); 513 + const printingsMap = new Map<OracleId, Card[]>(); 514 + printingsMap.set(solRing.oracle_id, [ 515 + mockPrinting(solRing, "uncommon", ["paper"]), 516 + ]); 517 + const ctx = await makeContextWithCards( 518 + deck, 519 + [solRing], 520 + undefined, 521 + printingsMap, 522 + ); 523 + const violations = commanderUncommonRule.validate(ctx); 524 + expect(violations).toHaveLength(1); 525 + expect(violations[0].message).toContain("not a creature"); 526 + }); 527 + 528 + it("rejects arena-only uncommon", async () => { 529 + const mulldrifter = await cards.get("Mulldrifter"); 530 + const deck = makeDeck( 531 + [makeCard(mulldrifter, "commander")], 532 + "paupercommander", 533 + ); 534 + const printingsMap = new Map<OracleId, Card[]>(); 535 + printingsMap.set(mulldrifter.oracle_id, [ 536 + mockPrinting(mulldrifter, "uncommon", ["arena"]), 537 + mockPrinting(mulldrifter, "rare", ["paper"]), 538 + ]); 539 + const ctx = await makeContextWithCards( 540 + deck, 541 + [mulldrifter], 542 + undefined, 543 + printingsMap, 544 + ); 545 + const violations = commanderUncommonRule.validate(ctx); 546 + expect(violations).toHaveLength(1); 547 + expect(violations[0].message).toContain("no uncommon printing"); 349 548 }); 350 549 }); 351 550 });
+1 -1
src/lib/deck-validation/index.ts
··· 14 14 export type { RuleId } from "./rules"; 15 15 16 16 // Rules 17 - export { RULES } from "./rules"; 17 + export { isValidCommanderType, RULES } from "./rules"; 18 18 // Types 19 19 export type { 20 20 FormatConfig,
+30 -10
src/lib/deck-validation/rules/commander.ts
··· 30 30 }; 31 31 32 32 /** 33 - * Commander must be legendary creature (or planeswalker with "can be your commander") 33 + * Commander must be legendary creature, vehicle, or spacecraft 34 + * (or any card with "can be your commander" text) 35 + * 36 + * As of 2024, vehicles and spacecraft can be commanders without 37 + * special text - they're allowed by the base Commander rules. 34 38 */ 35 39 export const commanderLegendaryRule: Rule<"commanderLegendary"> = { 36 40 id: "commanderLegendary", 37 41 rule: asRuleNumber("903.3"), 38 42 category: "structure", 39 - description: "Commander must be a legendary creature", 43 + description: "Commander must be a legendary creature, vehicle, or spacecraft", 40 44 validate(ctx: ValidationContext): Violation[] { 41 45 const { deck, cardLookup } = ctx; 42 46 const violations: Violation[] = []; ··· 46 50 const card = cardLookup(entry.scryfallId); 47 51 if (!card) continue; 48 52 49 - const typeLine = getTypeLine(card).toLowerCase(); 50 - const oracleText = getOracleText(card).toLowerCase(); 51 - 52 - const isLegendaryCreature = 53 - typeLine.includes("legendary") && typeLine.includes("creature"); 54 - const canBeCommander = oracleText.includes("can be your commander"); 55 - 56 - if (!isLegendaryCreature && !canBeCommander) { 53 + if (!isValidCommanderType(card)) { 57 54 violations.push( 58 55 violation(this, `${card.name} is not a legendary creature`, "error", { 59 56 cardName: card.name, ··· 67 64 return violations; 68 65 }, 69 66 }; 67 + 68 + export function isValidCommanderType(card: Card): boolean { 69 + const typeLine = getTypeLine(card).toLowerCase(); 70 + const oracleText = getOracleText(card).toLowerCase(); 71 + 72 + const isLegendary = typeLine.includes("legendary"); 73 + const isCreature = typeLine.includes("creature"); 74 + const isVehicle = typeLine.includes("vehicle"); 75 + const isSpacecraft = typeLine.includes("spacecraft"); 76 + const canBeCommander = oracleText.includes("can be your commander"); 77 + 78 + // Legendary creatures, vehicles, or spacecraft are valid 79 + if (isLegendary && (isCreature || isVehicle || isSpacecraft)) { 80 + return true; 81 + } 82 + 83 + // Cards with explicit "can be your commander" text 84 + if (canBeCommander) { 85 + return true; 86 + } 87 + 88 + return false; 89 + } 70 90 71 91 /** 72 92 * Partner rule - validates commander pairing is legal
+1
src/lib/deck-validation/rules/index.ts
··· 15 15 commanderPartnerRule, 16 16 commanderPlaneswalkerRule, 17 17 commanderRequiredRule, 18 + isValidCommanderType, 18 19 signatureSpellRule, 19 20 } from "./commander"; 20 21
+44 -9
src/lib/deck-validation/rules/rarity.ts
··· 9 9 } from "../types"; 10 10 11 11 /** 12 - * Commander must be uncommon (Pauper Commander / PDH) 12 + * Commander must be uncommon creature (Pauper Commander / PDH) 13 13 * 14 14 * PDH rules: 15 + * - Commander must be a creature (or vehicle, since they can be commanders) 15 16 * - Commander must have been printed at uncommon in paper or MTGO 16 17 * - Arena-only downshifts don't count 17 18 * - Any printing of the card can be used if it has a valid uncommon printing ··· 21 22 id: "commanderUncommon", 22 23 rule: asRuleNumber("903.3"), 23 24 category: "structure", 24 - description: "Commander must have an uncommon printing in paper/MTGO (PDH)", 25 + description: 26 + "Commander must be an uncommon creature with printing in paper/MTGO (PDH)", 25 27 validate(ctx: ValidationContext): Violation[] { 26 - const { deck, getPrintings } = ctx; 28 + const { deck, cardLookup, getPrintings } = ctx; 27 29 const violations: Violation[] = []; 28 30 const commanders = getCardsInSection(deck, "commander"); 29 31 30 32 for (const entry of commanders) { 33 + const card = cardLookup(entry.scryfallId); 34 + if (!card) continue; 35 + 36 + const typeLine = getTypeLine(card).toLowerCase(); 37 + const isCreatureOrVehicle = 38 + typeLine.includes("creature") || typeLine.includes("vehicle"); 39 + 40 + if (!isCreatureOrVehicle) { 41 + violations.push( 42 + violation( 43 + this, 44 + `${card.name} is not a creature (PDH commanders must be uncommon creatures)`, 45 + "error", 46 + { 47 + cardName: card.name, 48 + oracleId: entry.oracleId, 49 + section: "commander", 50 + }, 51 + ), 52 + ); 53 + continue; 54 + } 55 + 31 56 const printings = getPrintings(entry.oracleId); 32 - const hasValidUncommon = printings.some((card) => 33 - isUncommonInPaperOrMtgo(card), 57 + const hasValidUncommon = printings.some((p) => 58 + isUncommonInPaperOrMtgo(p), 34 59 ); 35 60 36 61 if (!hasValidUncommon) { 37 - const card = printings[0]; 38 - const name = card?.name ?? "Unknown card"; 39 62 violations.push( 40 63 violation( 41 64 this, 42 - `${name} has no uncommon printing in paper/MTGO`, 65 + `${card.name} has no uncommon printing in paper/MTGO`, 43 66 "error", 44 67 { 45 - cardName: name, 68 + cardName: card.name, 46 69 oracleId: entry.oracleId, 47 70 section: "commander", 48 71 }, ··· 61 84 const games = card.games ?? []; 62 85 return games.includes("paper") || games.includes("mtgo"); 63 86 } 87 + 88 + function getTypeLine(card: Card): string { 89 + if (card.type_line) { 90 + return card.type_line; 91 + } 92 + 93 + if (card.card_faces) { 94 + return card.card_faces.map((face) => face.type_line ?? "").join(" // "); 95 + } 96 + 97 + return ""; 98 + }