diff --git a/README.md b/README.md index 90f959d..47b9541 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ The following rules are validated in the section footer: - **Deck size** — flags when the deck is too small or too large for the format - **Sideboard size** — flags when the sideboard exceeds the maximum for the format - **Number of copies** - flags when a card is included more than the allowed times (4/1), handles cards like Hare Apparent! +- **Commander rules** - flags when a card is not a legal commander, or cards in the deck that or outside of the color identity # ⚙️ Settings diff --git a/jest/fixtures/scryfall-data.ts b/jest/fixtures/scryfall-data.ts index c826ae4..7981777 100644 --- a/jest/fixtures/scryfall-data.ts +++ b/jest/fixtures/scryfall-data.ts @@ -1,185 +1,3 @@ -import { ScryfallResponse } from "../../src/scryfall"; - -export const EXAMPLE_SCRYFALL_RESPONSE_1: ScryfallResponse = { - "object": "list", - "total_cards": 1, - "has_more": false, - "data": [ - { - "object": "card", - "id": "abff6c81-65a4-48fa-ba8f-580f87b0344a", - "oracle_id": "edd531b9-f615-4399-8c8c-1c5e18c4acbf", - "multiverse_ids": [ - 534807, - 534808 - ], - "mtgo_id": 93028, - "arena_id": 78378, - "tcgplayer_id": 248082, - "cardmarket_id": 574906, - "name": "Delver of Secrets // Insectile Aberration", - "lang": "en", - "released_at": "2021-09-24", - "uri": "https://api.scryfall.com/cards/abff6c81-65a4-48fa-ba8f-580f87b0344a", - "scryfall_uri": "https://scryfall.com/card/mid/47/delver-of-secrets-insectile-aberration?utm_source=api", - "layout": "transform", - "highres_image": true, - "image_status": "highres_scan", - "cmc": 1, - "type_line": "Creature — Human Wizard // Creature — Human Insect", - "color_identity": [ - "U" - ], - "keywords": [ - "Flying", - "Transform" - ], - "card_faces": [ - { - "object": "card_face", - "name": "Delver of Secrets", - "mana_cost": "{U}", - "type_line": "Creature — Human Wizard", - "oracle_text": "At the beginning of your upkeep, look at the top card of your library. You may reveal that card. If an instant or sorcery card is revealed this way, transform Delver of Secrets.", - "colors": [ - "U" - ], - "power": "1", - "toughness": "1", - "flavor_text": "\"If my hypothesis is correct . . .\"", - "artist": "Matt Stewart", - "artist_id": "20871267-2d8a-41d5-b03a-be3d557c5734", - "illustration_id": "9b572479-d64a-4d61-bd09-2c9a95138ddb", - "image_uris": { - "small": "https://cards.scryfall.io/small/front/a/b/abff6c81-65a4-48fa-ba8f-580f87b0344a.jpg?1634347351", - "normal": "https://cards.scryfall.io/normal/front/a/b/abff6c81-65a4-48fa-ba8f-580f87b0344a.jpg?1634347351", - "large": "https://cards.scryfall.io/large/front/a/b/abff6c81-65a4-48fa-ba8f-580f87b0344a.jpg?1634347351", - "png": "https://cards.scryfall.io/png/front/a/b/abff6c81-65a4-48fa-ba8f-580f87b0344a.png?1634347351", - "art_crop": "https://cards.scryfall.io/art_crop/front/a/b/abff6c81-65a4-48fa-ba8f-580f87b0344a.jpg?1634347351", - "border_crop": "https://cards.scryfall.io/border_crop/front/a/b/abff6c81-65a4-48fa-ba8f-580f87b0344a.jpg?1634347351" - } - }, - { - "object": "card_face", - "name": "Insectile Aberration", - "flavor_name": "", - "mana_cost": "", - "type_line": "Creature — Human Insect", - "oracle_text": "Flying", - "colors": [ - "U" - ], - "color_indicator": [ - "U" - ], - "power": "3", - "toughness": "2", - "flavor_text": "\"Aha! Yes! Now I hear it, the beautiful poetry of the lights in the sky. I must reach the moon and ask what it all means!\"", - "artist": "Matt Stewart", - "artist_id": "20871267-2d8a-41d5-b03a-be3d557c5734", - "illustration_id": "83f0906b-d0e5-4725-ab5b-e33ed3387123", - "image_uris": { - "small": "https://cards.scryfall.io/small/back/a/b/abff6c81-65a4-48fa-ba8f-580f87b0344a.jpg?1634347351", - "normal": "https://cards.scryfall.io/normal/back/a/b/abff6c81-65a4-48fa-ba8f-580f87b0344a.jpg?1634347351", - "large": "https://cards.scryfall.io/large/back/a/b/abff6c81-65a4-48fa-ba8f-580f87b0344a.jpg?1634347351", - "png": "https://cards.scryfall.io/png/back/a/b/abff6c81-65a4-48fa-ba8f-580f87b0344a.png?1634347351", - "art_crop": "https://cards.scryfall.io/art_crop/back/a/b/abff6c81-65a4-48fa-ba8f-580f87b0344a.jpg?1634347351", - "border_crop": "https://cards.scryfall.io/border_crop/back/a/b/abff6c81-65a4-48fa-ba8f-580f87b0344a.jpg?1634347351" - } - } - ], - "legalities": { - "standard": "legal", - "future": "legal", - "historic": "legal", - "gladiator": "legal", - "pioneer": "legal", - "explorer": "legal", - "modern": "legal", - "legacy": "legal", - "pauper": "legal", - "vintage": "legal", - "penny": "legal", - "commander": "legal", - "brawl": "legal", - "historicbrawl": "legal", - "alchemy": "legal", - "paupercommander": "legal", - "duel": "legal", - "oldschool": "not_legal", - "premodern": "not_legal" - }, - "games": [ - "arena", - "paper", - "mtgo" - ], - "reserved": false, - "foil": true, - "nonfoil": true, - "finishes": [ - "nonfoil", - "foil" - ], - "oversized": false, - "promo": false, - "reprint": true, - "variation": false, - "set_id": "44b8eb8f-fa23-401a-98b5-1fbb9871128e", - "set": "mid", - "set_name": "Innistrad: Midnight Hunt", - "set_type": "expansion", - "set_uri": "https://api.scryfall.com/sets/44b8eb8f-fa23-401a-98b5-1fbb9871128e", - "set_search_uri": "https://api.scryfall.com/cards/search?order=set&q=e%3Amid&unique=prints", - "scryfall_set_uri": "https://scryfall.com/sets/mid?utm_source=api", - "rulings_uri": "https://api.scryfall.com/cards/abff6c81-65a4-48fa-ba8f-580f87b0344a/rulings", - "prints_search_uri": "https://api.scryfall.com/cards/search?order=released&q=oracleid%3Aedd531b9-f615-4399-8c8c-1c5e18c4acbf&unique=prints", - "collector_number": "47", - "digital": false, - "rarity": "uncommon", - "artist": "Matt Stewart", - "artist_ids": [ - "20871267-2d8a-41d5-b03a-be3d557c5734" - ], - "border_color": "black", - "frame": "2015", - "frame_effects": [ - "sunmoondfc" - ], - "full_art": false, - "textless": false, - "booster": true, - "story_spotlight": false, - "edhrec_rank": 11230, - "penny_rank": 99, - "preview": { - "source": "Hareruya", - "source_uri": "https://article.hareruyamtg.com/article/54478", - "previewed_at": "2021-09-07" - }, - "prices": { - "usd": "0.41", - "usd_foil": "0.72", - "usd_etched": null, - "eur": "0.55", - "eur_foil": "1.34", - "tix": "0.03" - }, - "related_uris": { - "gatherer": "https://gatherer.wizards.com/Pages/Card/Details.aspx?multiverseid=534807", - "tcgplayer_infinite_articles": "https://infinite.tcgplayer.com/search?contentMode=article&game=magic&partner=scryfall&q=Delver+of+Secrets+%2F%2F+Insectile+Aberration&utm_campaign=affiliate&utm_medium=api&utm_source=scryfall", - "tcgplayer_infinite_decks": "https://infinite.tcgplayer.com/search?contentMode=deck&game=magic&partner=scryfall&q=Delver+of+Secrets+%2F%2F+Insectile+Aberration&utm_campaign=affiliate&utm_medium=api&utm_source=scryfall", - "edhrec": "https://edhrec.com/route/?cc=Delver+of+Secrets" - }, - "purchase_uris": { - "tcgplayer": "https://www.tcgplayer.com/product/248082?page=1&utm_campaign=affiliate&utm_medium=api&utm_source=scryfall", - "cardmarket": "https://www.cardmarket.com/en/Magic/Products/Search?referrer=scryfall&searchString=Delver+of+Secrets&utm_campaign=card_prices&utm_medium=text&utm_source=scryfall", - "cardhoarder": "https://www.cardhoarder.com/cards/93028?affiliate_id=scryfall&ref=card-profile&utm_campaign=affiliate&utm_medium=card&utm_source=scryfall" - } - } - ] -}; - export const EXAMPLE_MULTI_CARD_RESPONSE = { "object": "list", "not_found": [], diff --git a/manifest.json b/manifest.json index 4e01696..e952d1b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "mtg-deck", "name": "MTG Deck", - "version": "1.11.75", + "version": "1.11.76", "minAppVersion": "0.15.0", "description": "Display your MTG decks and card lists in your notes.", "author": "Samir Boulema", diff --git a/package.json b/package.json index 27fd86e..a436633 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mtg-deck", - "version": "1.11.75", + "version": "1.11.76", "description": "Display your MTG decks and card lists in your notes.", "main": "main.js", "scripts": { diff --git a/src/parser.ts b/src/parser.ts index 27a9348..0c3726f 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2,7 +2,7 @@ import { CardCounts, nameToId } from "./collection"; import { CardIdentifier } from "./scryfall"; import { COMMENT_DELIMITER, Line } from "./types"; -const lineMatchRE = /^(\d+)\s+(.*?)(\s+\(([A-Za-z0-9]{3})\)\s+0*(\d+))?$/; +const lineMatchRE = /^(\d+)x?\s+(.*?)(\s+\(([A-Za-z0-9]{3})\)\s+0*(\d+))?$/; const blankLineRE = /^\s+$/; const headingMatchRE = new RegExp("^[^[0-9|" + COMMENT_DELIMITER + "]"); diff --git a/src/renderer.ts b/src/renderer.ts index 1ff5090..44d3f92 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -70,6 +70,7 @@ export const renderDecklist = async ( const enriched = renderSection(containerEl, { section, lines: linesBySection[section] ?? [], + commanderLines: linesBySection["Commander"] ?? linesBySection["commander"] ?? [], cardDataByCardId, hasCardInfo, settings, diff --git a/src/scryfall.spec.ts b/src/scryfall.spec.ts index c1dde78..b48a4e9 100644 --- a/src/scryfall.spec.ts +++ b/src/scryfall.spec.ts @@ -1,25 +1,8 @@ import { describe, expect, test } from "@jest/globals"; -import { getCardData, getMultipleCardData, RequestOptions } from "./scryfall"; -import { - EXAMPLE_MULTI_CARD_RESPONSE, - EXAMPLE_SCRYFALL_RESPONSE_1, -} from "../jest/fixtures/scryfall-data"; +import { getMultipleCardData, RequestOptions } from "./scryfall"; +import { EXAMPLE_MULTI_CARD_RESPONSE } from "../jest/fixtures/scryfall-data"; describe("Scryfall", () => { - describe("#getCardData()", () => { - test("for a single card", async () => { - function httpReq( - _options: RequestOptions - ): Promise { - return Promise.resolve( - EXAMPLE_SCRYFALL_RESPONSE_1 as unknown as ScryfallResponse - ); - } - const data = await getCardData("Delver of Secrets", httpReq); - expect(data).toEqual(EXAMPLE_SCRYFALL_RESPONSE_1); - }); - }); - describe("#getMultipleCardData()", () => { test("for a single card", async () => { function httpReq( diff --git a/src/scryfall.ts b/src/scryfall.ts index 5fea42b..9ce2cc9 100644 --- a/src/scryfall.ts +++ b/src/scryfall.ts @@ -148,17 +148,6 @@ export interface ScryfallResponse { total_cards: number; } -export const getCardData = async ( - cardName: string, - request = promiseWrappedRequest -): Promise => { - const query = new URLSearchParams({ q: cardName }).toString(); - const params: RequestOptions = { - url: `https://api.scryfall.com/cards/search?${query}`, - }; - return request(params); -}; - export const getMultipleCardData = async ( cardIdentifiers: CardIdentifier[], request = promiseWrappedRequest diff --git a/src/section-renderer.ts b/src/section-renderer.ts index 8a15d66..2e7fae5 100644 --- a/src/section-renderer.ts +++ b/src/section-renderer.ts @@ -7,11 +7,12 @@ import { cardTypeIcons, cardTypeOrder, getTypeCounts, getTypeOrder, sortLines } import { setupCardPreview } from "./card-preview"; import { buildCardGrid, setupGridToggle } from "./card-grid"; import { sanitizeHTMLToDom } from "obsidian"; -import { validateDecklist, validateSideboard } from "./validator"; +import { validateCommanderZone, validateDecklist, validateSideboard } from "./validator"; export interface SectionRenderContext { section: string; lines: Line[]; + commanderLines: Line[]; cardDataByCardId: Record; hasCardInfo: boolean; settings: ObsidianPluginMtgSettings; @@ -28,6 +29,7 @@ export const renderSection = ( const { section, lines, + commanderLines, cardDataByCardId, hasCardInfo, settings, @@ -143,17 +145,25 @@ export const renderSection = ( if (cardManaCost) { cardManaCost - .replace(/\//g, "") - .split("{") - .slice(1) - .forEach((part) => { - cardCostEl.createEl("img", { - attr: { - src: `https://svgs.scryfall.io/card-symbols/${part.slice(0, -1)}.svg`, - width: 18, - height: 18, - }, - }); + .split("//") + .map(part => part.trim()) + .forEach((part, index) => { + if (index > 0) { + cardCostEl.createSpan({ text: " // " }); + } + part + .replace(/\//g, "") + .split("{") + .slice(1) + .forEach(symbol => { + cardCostEl.createEl("img", { + attr: { + src: `https://svgs.scryfall.io/card-symbols/${symbol.slice(0, -1)}.svg`, + width: 18, + height: 18, + }, + }); + }); }); } } @@ -327,9 +337,16 @@ export const renderSection = ( // Validation errors in footer if (format) { - const errors = section.toLowerCase() === "sideboard" - ? validateSideboard(sortedLines, cardDataByCardId, format).errors - : validateDecklist(sortedLines, cardDataByCardId, format).errors; + const errors = (() => { + switch (section.toLowerCase()) { + case "sideboard": + return validateSideboard(sortedLines, cardDataByCardId, format).errors; + case "commander": + return validateCommanderZone(sortedLines, cardDataByCardId, format).errors; + default: + return validateDecklist(sortedLines, commanderLines, cardDataByCardId, format).errors; + } + })(); if (errors.length > 0) { const validationRow = sectionListFoot.createEl("tr"); diff --git a/src/validator.ts b/src/validator.ts index f0dc79d..6430fa6 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -2,7 +2,7 @@ import { nameToId } from "./collection"; import { CardData } from "./scryfall"; import { Line } from "./types"; -export type ValidationErrorType = "banned" | "not_legal" | "restricted" | "deck_size" | "sideboard_size" | "max_copies"; +export type ValidationErrorType = "banned" | "not_legal" | "restricted" | "deck_size" | "sideboard_size" | "max_copies" | "commander_zone" | "color_identity"; export interface ValidationError { type: ValidationErrorType; @@ -21,31 +21,32 @@ export interface FormatDefinition { maxDeckSize?: number; maxSideboardSize?: number; maxCopies: number; + commandZone: boolean; } export const FORMATS: FormatDefinition[] = [ - { name: "standard", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, - { name: "future", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, - { name: "historic", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, - { name: "timeless", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, - { name: "gladiator", minDeckSize: 100, maxDeckSize: 100, maxCopies: 1 }, - { name: "pioneer", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, - { name: "modern", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, - { name: "legacy", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, - { name: "pauper", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, - { name: "vintage", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, - { name: "penny", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, - { name: "commander", minDeckSize: 100, maxDeckSize: 100, maxCopies: 1 }, - { name: "oathbreaker", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 1 }, - { name: "standardbrawl", minDeckSize: 60, maxCopies: 1 }, - { name: "brawl", minDeckSize: 60, maxCopies: 1 }, - { name: "alchemy", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, - { name: "paupercommander", minDeckSize: 100, maxDeckSize: 100, maxCopies: 1 }, - { name: "duel", minDeckSize: 100, maxDeckSize: 100, maxCopies: 1 }, - { name: "oldschool", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, - { name: "premodern", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, - { name: "predh", minDeckSize: 100, maxDeckSize: 100, maxCopies: 1 }, - { name: "tlr", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4 }, + { name: "standard", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, + { name: "future", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, + { name: "historic", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, + { name: "timeless", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, + { name: "gladiator", minDeckSize: 100, maxDeckSize: 100, maxCopies: 1, commandZone: true }, + { name: "pioneer", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, + { name: "modern", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, + { name: "legacy", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, + { name: "pauper", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, + { name: "vintage", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, + { name: "penny", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, + { name: "commander", minDeckSize: 100, maxDeckSize: 100, maxCopies: 1, commandZone: true }, + { name: "oathbreaker", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 1, commandZone: true }, + { name: "standardbrawl", minDeckSize: 60, maxCopies: 1, commandZone: true }, + { name: "brawl", minDeckSize: 60, maxCopies: 1, commandZone: true }, + { name: "alchemy", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, + { name: "paupercommander", minDeckSize: 100, maxDeckSize: 100, maxCopies: 1, commandZone: true }, + { name: "duel", minDeckSize: 100, maxDeckSize: 100, maxCopies: 1, commandZone: true }, + { name: "oldschool", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, + { name: "premodern", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, + { name: "predh", minDeckSize: 100, maxDeckSize: 100, maxCopies: 1, commandZone: true }, + { name: "tlr", minDeckSize: 60, maxSideboardSize: 15, maxCopies: 4, commandZone: false }, ]; const validateDeckSize = (lines: Line[], format: string): ValidationError[] => { @@ -111,6 +112,7 @@ const getLegalityErrors = ( export const validateDecklist = ( lines: Line[], + commanderLines: Line[], cardDataByCardId: Record, format: string ): ValidationResult => { @@ -125,6 +127,8 @@ export const validateDecklist = ( }), ...validateDeckSize(lines, format), ...validateMaxCopies(lines, cardDataByCardId, format), + ...validateColorIdentity(lines, commanderLines, cardDataByCardId, format), + ...validateCommanderInDeck(lines, commanderLines, format), ]; return { format, errors }; @@ -143,6 +147,19 @@ export const validateSideboard = ( return { format, errors }; }; +export const validateCommanderZone = ( + lines: Line[], + cardDataByCardId: Record, + format: string +): ValidationResult => { + const errors = [ + ...validateCommanderZoneSize(lines, format), + ...validateCommanderZoneType(lines, cardDataByCardId, format), + ]; + + return { format, errors }; +}; + export const validateSideboardSize = ( lines: Line[], format: string @@ -205,6 +222,159 @@ const validateMaxCopies = ( }]; } + return []; + }); +}; + +const validateCommanderZoneSize = ( + commanderLines: Line[], + format: string +): ValidationError[] => { + const formatDef = FORMATS.find(f => f.name === format.toLowerCase()); + if (!formatDef?.commandZone) return []; + + const errors: ValidationError[] = []; + const cardLines = commanderLines.filter(line => line.lineType === "card"); + + if (cardLines.length === 0) { + errors.push({ + type: "commander_zone", + message: `No commander found in the Commander section`, + }); + return errors; + } + + if (cardLines.length > 1) { + errors.push({ + type: "commander_zone", + message: `Commander zone has ${cardLines.length} cards, expected 1`, + }); + } + + return errors; +}; + +const validateCommanderZoneType = ( + commanderLines: Line[], + cardDataByCardId: Record, + format: string +): ValidationError[] => { + const formatDef = FORMATS.find(f => f.name === format.toLowerCase()); + + if (!formatDef?.commandZone) { + return []; + } + + const errors: ValidationError[] = []; + const cardLines = commanderLines.filter(line => line.lineType === "card"); + + cardLines.forEach(line => { + const cardId = nameToId(line.cardName!); + const cardInfo = cardDataByCardId[cardId]; + + if (!cardInfo?.type_line) { + return; + } + + const isValidCommander = + (cardInfo.type_line.includes("Legendary") && ( + cardInfo.type_line.includes("Creature") || + cardInfo.type_line.includes("Vehicle") || + cardInfo.type_line.includes("Spacecraft") + )) || cardInfo.type_line.includes("Planeswalker"); + + if (!isValidCommander) { + errors.push({ + type: "commander_zone", + message: `${line.cardName} is not a valid commander`, + cardName: line.cardName, + }); + } + }); + + return errors; +}; + +const validateColorIdentity = ( + lines: Line[], + commanderLines: Line[], + cardDataByCardId: Record, + format: string +): ValidationError[] => { + const formatDef = FORMATS.find(f => f.name === format.toLowerCase()); + if (!formatDef?.commandZone) return []; + + const commanderCardLines = commanderLines.filter(line => line.lineType === "card"); + if (commanderCardLines.length === 0) return []; + + // Get combined color identity of all commanders + const commanderColorIdentity = new Set( + commanderCardLines.flatMap(line => { + const cardId = nameToId(line.cardName!); + return cardDataByCardId[cardId]?.color_identity ?? []; + }) + ); + + return lines + .filter(line => line.lineType === "card" && line.cardName) + .flatMap(line => { + const cardId = nameToId(line.cardName!); + const cardInfo = cardDataByCardId[cardId]; + if (!cardInfo) return []; + + const invalidColors = (cardInfo.color_identity ?? []) + .filter(color => !commanderColorIdentity.has(color)); + + if (invalidColors.length > 0) { + return [{ + type: "color_identity" as ValidationErrorType, + message: `${line.cardName} has color identity [${invalidColors.join(", ")}] outside commander's color identity`, + cardName: line.cardName, + }]; + } + + return []; + }); +}; + +/** + * Validates that each card in the commander zone is also present in the main deck. + * In commander formats, the commander must be listed in both the command zone + * and the 99-card deck. + * Only runs for formats with a command zone (e.g. Commander, EDH, Brawl). + * + * @param lines - The main deck lines to check against + * @param commanderLines - The lines from the Commander section + * @param format - The format to validate against + * @returns A list of validation errors for commanders not found in the deck + */ +const validateCommanderInDeck = ( + lines: Line[], + commanderLines: Line[], + format: string +): ValidationError[] => { + const formatDef = FORMATS.find(f => f.name === format.toLowerCase()); + + if (!formatDef?.commandZone) { + return []; + } + + return commanderLines + .filter(line => line.lineType === "card" && line.cardName) + .flatMap(line => { + const isInDeck = lines.some( + deckLine => deckLine.lineType === "card" && + deckLine.cardName === line.cardName + ); + + if (!isInDeck) { + return [{ + type: "commander_zone" as ValidationErrorType, + message: `${line.cardName} is in the command zone but not in the deck`, + cardName: line.cardName, + }]; + } + return []; }); }; \ No newline at end of file diff --git a/styles.css b/styles.css index 4d88e11..4d42b0f 100644 --- a/styles.css +++ b/styles.css @@ -60,7 +60,9 @@ .decklist__validation-error--not_legal, .decklist__validation-error--deck_size, .decklist__validation-error--sideboard_size, -.decklist__validation-error--max_copies { +.decklist__validation-error--max_copies, +.decklist__validation-error--commander_zone, +.decklist__validation-error--color_identity { color: #aa3333; } diff --git a/versions.json b/versions.json index 2be4391..3fe65d3 100644 --- a/versions.json +++ b/versions.json @@ -20,5 +20,6 @@ "1.11.72": "0.15.0", "1.11.73": "0.15.0", "1.11.74": "0.15.0", - "1.11.75": "0.15.0" + "1.11.75": "0.15.0", + "1.11.76": "0.15.0" } \ No newline at end of file