mirror of
https://github.com/sboulema/mtg-deck.git
synced 2026-07-22 06:50:39 +00:00
feat: Add Commander validations
feat: Improve deck parsing feat: Improve mana costs parsing
This commit is contained in:
parent
d15b7853da
commit
da8003bc87
12 changed files with 237 additions and 255 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": [],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 + "]");
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export const renderDecklist = async (
|
|||
const enriched = renderSection(containerEl, {
|
||||
section,
|
||||
lines: linesBySection[section] ?? [],
|
||||
commanderLines: linesBySection["Commander"] ?? linesBySection["commander"] ?? [],
|
||||
cardDataByCardId,
|
||||
hasCardInfo,
|
||||
settings,
|
||||
|
|
|
|||
|
|
@ -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<ScryfallResponse>(
|
||||
_options: RequestOptions
|
||||
): Promise<ScryfallResponse> {
|
||||
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<ScryfallResponse>(
|
||||
|
|
|
|||
|
|
@ -148,17 +148,6 @@ export interface ScryfallResponse {
|
|||
total_cards: number;
|
||||
}
|
||||
|
||||
export const getCardData = async (
|
||||
cardName: string,
|
||||
request = promiseWrappedRequest
|
||||
): Promise<ScryfallResponse> => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string, CardData>;
|
||||
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");
|
||||
|
|
|
|||
216
src/validator.ts
216
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<string, CardData>,
|
||||
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<string, CardData>,
|
||||
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<string, CardData>,
|
||||
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<string, CardData>,
|
||||
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 [];
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in a new issue