👁️
5
fork

Configure Feed

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

deck validation pt4

+471 -11
+3
scripts/download-scryfall.ts
··· 84 84 "image_status", 85 85 "layout", 86 86 87 + // Un-set detection 88 + "security_stamp", 89 + 87 90 // Nice-to-have (flavor_text omitted - visible on card image) 88 91 // edhrec_rank omitted - goes in volatile.bin 89 92 "reprint",
+7
src/lib/__tests__/test-cards.json
··· 2 2 "$comment": "Maps card names to oracle IDs for test fixtures. Add cards here as needed for tests.", 3 3 "A-Lantern Bearer": "ba790609-7b48-4a96-a21f-5a0cfcf316a3", 4 4 "A-Llanowar Greenwidow": "57920afe-61b7-4db1-ad79-dca4f0bc281b", 5 + "Aardwolf's Advantage": "4c445455-5b91-4c22-8b3b-8cff4e97f00a", 5 6 "Aboroth": "28f70f86-19a9-4811-bc10-423a05842d39", 6 7 "Adarkar Wastes": "d5ad26cc-2bdb-46b7-b8bf-dd099d5fa09b", 8 + "Adriana's Valor": "512a9ab9-ddd4-4872-b4f4-dee56f9f5575", 7 9 "Aerathi Berserker": "5cb495a2-c683-4066-b6ee-d0b7d8843cb9", 8 10 "Akki Lavarunner": "47795817-73e5-4af6-bd1e-d69b193e8e9e", 9 11 "Akki Rockspeaker": "01cd52f9-7fac-4396-bec7-d06bf683e011", 10 12 "Akoum Warrior": "afedce7b-0e18-40ad-a26a-1933fddb560d", 13 + "Alexander Clamilton": "60875e8e-fc71-4eb3-8c2d-70f2fd1f11ae", 11 14 "Aminatou, the Fateshifter": "3a30089d-cd2d-49be-9b06-7a2454117692", 12 15 "Ancient Tomb": "23467047-6dba-4498-b783-1ebc4f74b8c2", 13 16 "Apostle's Blessing": "ea393815-0202-4edc-aa1e-6885fe9b20ab", 14 17 "Arcbound Wanderer": "03436524-197a-4941-a2a1-c7c4b71c4709", 15 18 "Arcum's Astrolabe": "ec463bde-dadf-4044-8e41-71338fd4d62f", 19 + "Atmospheric Greenhouse": "52c6afc6-aff1-46a8-b1ea-b183facec1ae", 16 20 "Axebane Ferox": "35b3ae1c-4116-49b0-89a9-7332fe9bbff0", 17 21 "Azorius Chancery": "189fc8f4-17ac-4f1d-82c8-8401445bdaf4", 18 22 "Barbara Wright": "0e61a062-f13a-4958-91de-909650c662a8", ··· 41 45 "City of Brass": "f25351e3-539b-4bbc-b92d-6480acf4d722", 42 46 "Command Tower": "0895c9b7-ae7d-4bb3-af17-3b75deb50a25", 43 47 "Concealed Courtyard": "2d899466-b1eb-4901-b626-1f2fb09b786d", 48 + "Contract from Below": "9e0ed4b0-28b5-44be-ab83-f1e8eb07138d", 44 49 "Cormela, Glamour Thief": "3cd9bac9-0abc-43d8-9f84-94f965f7a2e0", 45 50 "Cryptolith Rite": "043f869d-b11c-4c0d-9591-2bf0df7bde55", 46 51 "Dark Ritual": "53f7c868-b03e-4fc2-8dcf-a75bbfa3272b", ··· 80 85 "Llanowar Elves": "68954295-54e3-4303-a6bc-fc4547a4e3a3", 81 86 "Lotus Petal": "32e5339e-9e4f-46f8-b305-f9d6d3ba8bb5", 82 87 "Lunar Hatchling": "cb2c8ea9-c125-4742-a562-b693254152e5", 88 + "Lurrus of the Dream-Den": "3bc757c1-3adb-4321-8832-8e1cc9e687f7", 83 89 "Mana Confluence": "d0ee5bdc-2b69-4b73-9a20-ffcc18783b29", 84 90 "Mana Crypt": "2c63e4e1-89d2-4bc6-a232-94e75c4b1c8a", 85 91 "Mana Vault": "736892cb-a34b-4bb9-b56c-e26e3db207a2", ··· 113 119 "Snow-Covered Forest": "5f0d3be8-e63e-4ade-ae58-6b0c14f2ce6d", 114 120 "Sol Ring": "6ad8011d-3471-4369-9d68-b264cc027487", 115 121 "Steam Vents": "17039058-822d-409f-938c-b727a366ba63", 122 + "Stomping Ground": "16052b52-ade1-406f-a06b-ce7ea607fb63", 116 123 "Tarmogoyf": "45900b2f-f6a9-4c42-9642-008f3c1cf6dd", 117 124 "Tel-Jilad Chosen": "de6854d1-bdb0-4b9a-b442-08d4d90a538d", 118 125 "Temple of Mystery": "7e26f0b7-20e6-46d5-8130-d98c14d6aa29",
+85
src/lib/deck-validation/__tests__/companion.test.ts
··· 1 + import { beforeAll, describe, expect, it } from "vitest"; 2 + import { 3 + setupTestCards, 4 + type TestCardLookup, 5 + } from "@/lib/__tests__/test-card-lookup"; 6 + import type { Deck, DeckCard, Section } from "@/lib/deck-types"; 7 + import type { Card, OracleId, ScryfallId } from "@/lib/scryfall-types"; 8 + import { companionRule } from "../rules/base"; 9 + import type { ValidationContext } from "../types"; 10 + 11 + describe("companion rules", () => { 12 + let cards: TestCardLookup; 13 + 14 + beforeAll(async () => { 15 + cards = await setupTestCards(); 16 + }, 30_000); 17 + 18 + function makeDeck(deckCards: DeckCard[], format = "modern"): Deck { 19 + return { 20 + $type: "com.deckbelcher.deck.list", 21 + name: "Test Deck", 22 + format, 23 + cards: deckCards, 24 + createdAt: new Date().toISOString(), 25 + }; 26 + } 27 + 28 + function makeCard(card: Card, section: Section, quantity = 1): DeckCard { 29 + return { 30 + scryfallId: card.id, 31 + oracleId: card.oracle_id, 32 + section, 33 + quantity, 34 + tags: [], 35 + }; 36 + } 37 + 38 + function makeContext(deck: Deck, cardList: Card[]): ValidationContext { 39 + const cardMap = new Map<ScryfallId, Card>(); 40 + const oracleMap = new Map<OracleId, Card>(); 41 + 42 + for (const card of cardList) { 43 + cardMap.set(card.id, card); 44 + oracleMap.set(card.oracle_id, card); 45 + } 46 + 47 + return { 48 + deck, 49 + cardLookup: (id) => cardMap.get(id), 50 + oracleLookup: (id) => oracleMap.get(id), 51 + getPrintings: () => [], 52 + format: deck.format, 53 + commanderColors: undefined, 54 + config: { legalityField: "modern" }, 55 + }; 56 + } 57 + 58 + describe("Lurrus of the Dream-Den", () => { 59 + it("rejects deck with permanents over 2 mana value", async () => { 60 + const lurrus = await cards.get("Lurrus of the Dream-Den"); 61 + const selvala = await cards.get("Selvala, Heart of the Wilds"); 62 + const deck = makeDeck([ 63 + makeCard(lurrus, "sideboard"), 64 + makeCard(selvala, "mainboard"), 65 + ]); 66 + const ctx = makeContext(deck, [lurrus, selvala]); 67 + const violations = companionRule.validate(ctx); 68 + expect(violations).toHaveLength(1); 69 + expect(violations[0].message).toContain("Lurrus"); 70 + expect(violations[0].message).toContain("mana value"); 71 + }); 72 + 73 + it("allows deck with only low mana value permanents", async () => { 74 + const lurrus = await cards.get("Lurrus of the Dream-Den"); 75 + const solRing = await cards.get("Sol Ring"); 76 + const deck = makeDeck([ 77 + makeCard(lurrus, "sideboard"), 78 + makeCard(solRing, "mainboard"), 79 + ]); 80 + const ctx = makeContext(deck, [lurrus, solRing]); 81 + const violations = companionRule.validate(ctx); 82 + expect(violations).toHaveLength(0); 83 + }); 84 + }); 85 + });
+111
src/lib/deck-validation/__tests__/illegal-cards.test.ts
··· 1 + import { beforeAll, describe, expect, it } from "vitest"; 2 + import { 3 + setupTestCards, 4 + type TestCardLookup, 5 + } from "@/lib/__tests__/test-card-lookup"; 6 + import type { Deck, DeckCard, Section } from "@/lib/deck-types"; 7 + import type { Card, OracleId, ScryfallId } from "@/lib/scryfall-types"; 8 + import { 9 + anteCardRule, 10 + conspiracyCardRule, 11 + illegalCardTypeRule, 12 + } from "../rules/base"; 13 + import type { ValidationContext } from "../types"; 14 + 15 + describe("illegal card type rules", () => { 16 + let cards: TestCardLookup; 17 + 18 + beforeAll(async () => { 19 + cards = await setupTestCards(); 20 + }, 30_000); 21 + 22 + function makeDeck(deckCards: DeckCard[], format = "commander"): Deck { 23 + return { 24 + $type: "com.deckbelcher.deck.list", 25 + name: "Test Deck", 26 + format, 27 + cards: deckCards, 28 + createdAt: new Date().toISOString(), 29 + }; 30 + } 31 + 32 + function makeCard(card: Card, section: Section, quantity = 1): DeckCard { 33 + return { 34 + scryfallId: card.id, 35 + oracleId: card.oracle_id, 36 + section, 37 + quantity, 38 + tags: [], 39 + }; 40 + } 41 + 42 + function makeContext(deck: Deck, cardList: Card[]): ValidationContext { 43 + const cardMap = new Map<ScryfallId, Card>(); 44 + const oracleMap = new Map<OracleId, Card>(); 45 + 46 + for (const card of cardList) { 47 + cardMap.set(card.id, card); 48 + oracleMap.set(card.oracle_id, card); 49 + } 50 + 51 + return { 52 + deck, 53 + cardLookup: (id) => cardMap.get(id), 54 + oracleLookup: (id) => oracleMap.get(id), 55 + getPrintings: () => [], 56 + format: deck.format, 57 + commanderColors: undefined, 58 + config: { legalityField: "commander" }, 59 + }; 60 + } 61 + 62 + describe("conspiracyCardRule", () => { 63 + it("rejects conspiracy cards in constructed formats", async () => { 64 + const conspiracy = await cards.get("Adriana's Valor"); 65 + const deck = makeDeck([makeCard(conspiracy, "mainboard")], "legacy"); 66 + const ctx = makeContext(deck, [conspiracy]); 67 + const violations = conspiracyCardRule.validate(ctx); 68 + expect(violations).toHaveLength(1); 69 + expect(violations[0].message).toContain("Conspiracy"); 70 + }); 71 + }); 72 + 73 + describe("illegalCardTypeRule (silver-border/acorn)", () => { 74 + it("rejects silver-bordered cards", async () => { 75 + const silverBorder = await cards.get("Alexander Clamilton"); 76 + const deck = makeDeck([makeCard(silverBorder, "mainboard")]); 77 + const ctx = makeContext(deck, [silverBorder]); 78 + const violations = illegalCardTypeRule.validate(ctx); 79 + expect(violations).toHaveLength(1); 80 + expect(violations[0].message).toContain("silver"); 81 + }); 82 + 83 + it("rejects acorn-stamped cards", async () => { 84 + const acornCard = await cards.get("Aardwolf's Advantage"); 85 + const deck = makeDeck([makeCard(acornCard, "mainboard")]); 86 + const ctx = makeContext(deck, [acornCard]); 87 + const violations = illegalCardTypeRule.validate(ctx); 88 + expect(violations).toHaveLength(1); 89 + expect(violations[0].message).toContain("acorn"); 90 + }); 91 + 92 + it("allows normal cards", async () => { 93 + const normalCard = await cards.get("Sol Ring"); 94 + const deck = makeDeck([makeCard(normalCard, "mainboard")]); 95 + const ctx = makeContext(deck, [normalCard]); 96 + const violations = illegalCardTypeRule.validate(ctx); 97 + expect(violations).toHaveLength(0); 98 + }); 99 + }); 100 + 101 + describe("anteCardRule", () => { 102 + it("rejects ante cards", async () => { 103 + const anteCard = await cards.get("Contract from Below"); 104 + const deck = makeDeck([makeCard(anteCard, "mainboard")], "vintage"); 105 + const ctx = makeContext(deck, [anteCard]); 106 + const violations = anteCardRule.validate(ctx); 107 + expect(violations).toHaveLength(1); 108 + expect(violations[0].message).toContain("ante"); 109 + }); 110 + }); 111 + });
+78
src/lib/deck-validation/__tests__/rules.test.ts
··· 546 546 expect(violations).toHaveLength(1); 547 547 expect(violations[0].message).toContain("no uncommon printing"); 548 548 }); 549 + 550 + it("allows uncommon spacecraft as PDH commander", async () => { 551 + const spacecraft = await cards.get("Atmospheric Greenhouse"); 552 + const deck = makeDeck( 553 + [makeCard(spacecraft, "commander")], 554 + "paupercommander", 555 + ); 556 + const printingsMap = new Map<OracleId, Card[]>(); 557 + printingsMap.set(spacecraft.oracle_id, [ 558 + mockPrinting(spacecraft, "uncommon", ["paper"]), 559 + ]); 560 + const ctx = await makeContextWithCards( 561 + deck, 562 + [spacecraft], 563 + undefined, 564 + printingsMap, 565 + ); 566 + const violations = commanderUncommonRule.validate(ctx); 567 + expect(violations).toHaveLength(0); 568 + }); 569 + }); 570 + 571 + describe("signatureSpellRule color identity", () => { 572 + it("rejects signature spell outside oathbreaker color identity", async () => { 573 + const aminatou = await cards.get("Aminatou, the Fateshifter"); 574 + const lightningBolt = await cards.get("Lightning Bolt"); 575 + const deck = makeDeck( 576 + [makeCard(aminatou, "commander"), makeCard(lightningBolt, "commander")], 577 + "oathbreaker", 578 + ); 579 + const ctx = await makeContextWithCards( 580 + deck, 581 + [aminatou, lightningBolt], 582 + ["W", "U", "B"], 583 + ); 584 + const violations = signatureSpellRule.validate(ctx); 585 + expect(violations).toHaveLength(1); 586 + expect(violations[0].message).toContain("color identity"); 587 + }); 588 + 589 + it("allows signature spell within oathbreaker color identity", async () => { 590 + const bolas = await cards.get("Nicol Bolas, Planeswalker"); 591 + const lightningBolt = await cards.get("Lightning Bolt"); 592 + const deck = makeDeck( 593 + [makeCard(bolas, "commander"), makeCard(lightningBolt, "commander")], 594 + "oathbreaker", 595 + ); 596 + const ctx = await makeContextWithCards( 597 + deck, 598 + [bolas, lightningBolt], 599 + ["U", "B", "R"], 600 + ); 601 + const violations = signatureSpellRule.validate(ctx); 602 + expect(violations).toHaveLength(0); 603 + }); 604 + }); 605 + 606 + describe("colorIdentityRule basic land types (903.5d)", () => { 607 + it("rejects dual land with basic types outside commander identity", async () => { 608 + const stompingGround = await cards.get("Stomping Ground"); 609 + const deck = makeDeck([makeCard(stompingGround, "mainboard")]); 610 + const ctx = await makeContextWithCards(deck, [stompingGround], ["U"]); 611 + const violations = colorIdentityRule.validate(ctx); 612 + expect(violations).toHaveLength(1); 613 + expect(violations[0].message).toContain("outside commander identity"); 614 + }); 615 + 616 + it("allows dual land with basic types in matching identity", async () => { 617 + const stompingGround = await cards.get("Stomping Ground"); 618 + const deck = makeDeck([makeCard(stompingGround, "mainboard")]); 619 + const ctx = await makeContextWithCards( 620 + deck, 621 + [stompingGround], 622 + ["R", "G"], 623 + ); 624 + const violations = colorIdentityRule.validate(ctx); 625 + expect(violations).toHaveLength(0); 626 + }); 549 627 }); 550 628 });
+133
src/lib/deck-validation/rules/base.ts
··· 316 316 return []; 317 317 }, 318 318 }; 319 + 320 + /** 321 + * Conspiracy cards are only legal in Conspiracy Draft 322 + */ 323 + export const conspiracyCardRule: Rule<"conspiracyCard"> = { 324 + id: "conspiracyCard", 325 + rule: asRuleNumber("905.2"), 326 + category: "legality", 327 + description: "Conspiracy cards are not legal in constructed formats", 328 + validate(ctx: ValidationContext): Violation[] { 329 + const { deck, cardLookup } = ctx; 330 + const violations: Violation[] = []; 331 + 332 + for (const entry of deck.cards) { 333 + const card = cardLookup(entry.scryfallId); 334 + if (!card) continue; 335 + 336 + const typeLine = card.type_line?.toLowerCase() ?? ""; 337 + if (typeLine.includes("conspiracy")) { 338 + violations.push( 339 + violation( 340 + this, 341 + `${card.name} is a Conspiracy card and not legal in constructed formats`, 342 + "error", 343 + { 344 + cardName: card.name, 345 + oracleId: entry.oracleId, 346 + section: isKnownSection(entry.section) 347 + ? entry.section 348 + : undefined, 349 + }, 350 + ), 351 + ); 352 + } 353 + } 354 + 355 + return violations; 356 + }, 357 + }; 358 + 359 + /** 360 + * Silver-bordered and acorn-stamped cards are not tournament legal 361 + */ 362 + export const illegalCardTypeRule: Rule<"illegalCardType"> = { 363 + id: "illegalCardType", 364 + rule: asRuleNumber("100.2a"), 365 + category: "legality", 366 + description: "Silver-bordered and acorn cards are not tournament legal", 367 + validate(ctx: ValidationContext): Violation[] { 368 + const { deck, cardLookup } = ctx; 369 + const violations: Violation[] = []; 370 + 371 + for (const entry of deck.cards) { 372 + const card = cardLookup(entry.scryfallId); 373 + if (!card) continue; 374 + 375 + if (card.border_color === "silver") { 376 + violations.push( 377 + violation( 378 + this, 379 + `${card.name} is a silver-bordered card and not tournament legal`, 380 + "error", 381 + { 382 + cardName: card.name, 383 + oracleId: entry.oracleId, 384 + section: isKnownSection(entry.section) 385 + ? entry.section 386 + : undefined, 387 + }, 388 + ), 389 + ); 390 + } 391 + 392 + if (card.security_stamp === "acorn") { 393 + violations.push( 394 + violation( 395 + this, 396 + `${card.name} is an acorn card and not tournament legal`, 397 + "error", 398 + { 399 + cardName: card.name, 400 + oracleId: entry.oracleId, 401 + section: isKnownSection(entry.section) 402 + ? entry.section 403 + : undefined, 404 + }, 405 + ), 406 + ); 407 + } 408 + } 409 + 410 + return violations; 411 + }, 412 + }; 413 + 414 + /** 415 + * Ante cards are banned in all sanctioned formats 416 + */ 417 + export const anteCardRule: Rule<"anteCard"> = { 418 + id: "anteCard", 419 + rule: asRuleNumber("100.6a"), 420 + category: "legality", 421 + description: "Ante cards are banned in all sanctioned formats", 422 + validate(ctx: ValidationContext): Violation[] { 423 + const { deck, cardLookup } = ctx; 424 + const violations: Violation[] = []; 425 + 426 + for (const entry of deck.cards) { 427 + const card = cardLookup(entry.scryfallId); 428 + if (!card) continue; 429 + 430 + const oracleText = card.oracle_text?.toLowerCase() ?? ""; 431 + if (oracleText.includes("playing for ante")) { 432 + violations.push( 433 + violation( 434 + this, 435 + `${card.name} is an ante card and banned in all sanctioned formats`, 436 + "error", 437 + { 438 + cardName: card.name, 439 + oracleId: entry.oracleId, 440 + section: isKnownSection(entry.section) 441 + ? entry.section 442 + : undefined, 443 + }, 444 + ), 445 + ); 446 + } 447 + } 448 + 449 + return violations; 450 + }, 451 + };
+36 -7
src/lib/deck-validation/rules/commander.ts
··· 343 343 /** 344 344 * Signature spell requirement (Oathbreaker) 345 345 * Commander section must have exactly one instant or sorcery 346 + * Signature spell must match oathbreaker's color identity 346 347 */ 347 348 export const signatureSpellRule: Rule<"signatureSpell"> = { 348 349 id: "signatureSpell", 349 350 rule: asRuleNumber("903.3"), 350 351 category: "structure", 351 352 description: 352 - "Oathbreaker requires exactly one signature spell (instant/sorcery)", 353 + "Oathbreaker requires exactly one signature spell (instant/sorcery) within color identity", 353 354 validate(ctx: ValidationContext): Violation[] { 354 - const { deck, cardLookup } = ctx; 355 + const { deck, cardLookup, commanderColors } = ctx; 355 356 const commanders = getCardsInSection(deck, "commander"); 357 + const violations: Violation[] = []; 356 358 357 359 let signatureSpellCount = 0; 360 + const allowedColors = new Set<string>(commanderColors ?? []); 358 361 359 362 for (const entry of commanders) { 360 363 const card = cardLookup(entry.scryfallId); ··· 363 366 const typeLine = getTypeLine(card).toLowerCase(); 364 367 if (typeLine.includes("instant") || typeLine.includes("sorcery")) { 365 368 signatureSpellCount += entry.quantity; 369 + 370 + if (commanderColors) { 371 + const spellIdentity = card.color_identity ?? []; 372 + const invalidColors = spellIdentity.filter( 373 + (c) => !allowedColors.has(c), 374 + ); 375 + if (invalidColors.length > 0) { 376 + const commanderStr = 377 + commanderColors.length > 0 378 + ? commanderColors.join("") 379 + : "colorless"; 380 + violations.push( 381 + violation( 382 + this, 383 + `Signature spell ${card.name} has colors outside oathbreaker color identity (${invalidColors.join("")} not in ${commanderStr})`, 384 + "error", 385 + { 386 + cardName: card.name, 387 + oracleId: entry.oracleId, 388 + section: "commander", 389 + }, 390 + ), 391 + ); 392 + } 393 + } 366 394 } 367 395 } 368 396 369 397 if (signatureSpellCount === 0) { 370 - return [ 398 + violations.push( 371 399 violation( 372 400 this, 373 401 "Oathbreaker deck must have a signature spell (instant/sorcery in commander zone)", 374 402 "error", 375 403 ), 376 - ]; 404 + ); 405 + return violations; 377 406 } 378 407 379 408 if (signatureSpellCount > 1) { 380 - return [ 409 + violations.push( 381 410 violation( 382 411 this, 383 412 `Oathbreaker deck can only have 1 signature spell, found ${signatureSpellCount}`, 384 413 "error", 385 414 ), 386 - ]; 415 + ); 387 416 } 388 417 389 - return []; 418 + return violations; 390 419 }, 391 420 }; 392 421
+11
src/lib/deck-validation/rules/index.ts
··· 1 1 export { 2 + anteCardRule, 2 3 bannedRule, 3 4 cardLegalityRule, 5 + conspiracyCardRule, 4 6 deckSizeExactRule, 5 7 deckSizeMinRule, 8 + illegalCardTypeRule, 6 9 playsetRule, 7 10 restrictedRule, 8 11 sideboardSizeRule, ··· 22 25 export { commanderUncommonRule } from "./rarity"; 23 26 24 27 import { 28 + anteCardRule, 25 29 bannedRule, 26 30 cardLegalityRule, 31 + conspiracyCardRule, 27 32 deckSizeExactRule, 28 33 deckSizeMinRule, 34 + illegalCardTypeRule, 29 35 playsetRule, 30 36 restrictedRule, 31 37 sideboardSizeRule, ··· 52 58 cardLegality: cardLegalityRule, 53 59 banned: bannedRule, 54 60 restricted: restrictedRule, 61 + 62 + // Illegal card types 63 + conspiracyCard: conspiracyCardRule, 64 + illegalCardType: illegalCardTypeRule, 65 + anteCard: anteCardRule, 55 66 56 67 // Copy limit rules 57 68 singleton: singletonRule,
+6 -4
src/lib/deck-validation/rules/rarity.ts
··· 34 34 if (!card) continue; 35 35 36 36 const typeLine = getTypeLine(card).toLowerCase(); 37 - const isCreatureOrVehicle = 38 - typeLine.includes("creature") || typeLine.includes("vehicle"); 37 + const isCreatureOrVehicleOrSpacecraft = 38 + typeLine.includes("creature") || 39 + typeLine.includes("vehicle") || 40 + typeLine.includes("spacecraft"); 39 41 40 - if (!isCreatureOrVehicle) { 42 + if (!isCreatureOrVehicleOrSpacecraft) { 41 43 violations.push( 42 44 violation( 43 45 this, 44 - `${card.name} is not a creature (PDH commanders must be uncommon creatures)`, 46 + `${card.name} is not a creature, vehicle, or spacecraft (PDH commanders must be uncommon)`, 45 47 "error", 46 48 { 47 49 cardName: card.name,
+1
src/lib/scryfall-types.ts
··· 248 248 // Printing selection (image_uris omitted - can reconstruct from ID) 249 249 card_faces?: CardFace[]; 250 250 border_color?: BorderColor; 251 + security_stamp?: "oval" | "triangle" | "acorn" | "circle" | "arena" | "heart"; 251 252 frame?: Frame; 252 253 frame_effects?: FrameEffect[]; 253 254 finishes?: Finish[];