👁️
5
fork

Configure Feed

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

deck validation pt1

+1564 -1
+12 -1
src/lib/deck-types.ts
··· 6 6 import type { ComDeckbelcherDeckList } from "./lexicons/index"; 7 7 import type { Card, ManaColor, OracleId, ScryfallId } from "./scryfall-types"; 8 8 9 - export type Section = "commander" | "mainboard" | "sideboard" | "maybeboard"; 9 + export const SECTIONS = [ 10 + "commander", 11 + "mainboard", 12 + "sideboard", 13 + "maybeboard", 14 + ] as const; 15 + 16 + export type Section = (typeof SECTIONS)[number]; 17 + 18 + export function isKnownSection(s: string): s is Section { 19 + return (SECTIONS as readonly string[]).includes(s); 20 + } 10 21 11 22 /** 12 23 * App-side card entry with flat typed IDs.
+129
src/lib/deck-validation/__tests__/exceptions.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 { detectCopyException, getCopyLimit, isBasicLand } from "../exceptions"; 7 + 8 + describe("exceptions", () => { 9 + let cards: TestCardLookup; 10 + 11 + beforeAll(async () => { 12 + cards = await setupTestCards(); 13 + }, 30_000); 14 + 15 + describe("detectCopyException", () => { 16 + it("returns unlimited for Relentless Rats", async () => { 17 + const card = await cards.get("Relentless Rats"); 18 + expect(detectCopyException(card)).toEqual({ type: "unlimited" }); 19 + }); 20 + 21 + it("returns unlimited for Hare Apparent", async () => { 22 + const card = await cards.get("Hare Apparent"); 23 + expect(detectCopyException(card)).toEqual({ type: "unlimited" }); 24 + }); 25 + 26 + it("returns limited max 7 for Seven Dwarves", async () => { 27 + const card = await cards.get("Seven Dwarves"); 28 + expect(detectCopyException(card)).toEqual({ type: "limited", max: 7 }); 29 + }); 30 + 31 + it("returns limited max 9 for Nazgûl", async () => { 32 + const card = await cards.get("Nazgûl"); 33 + expect(detectCopyException(card)).toEqual({ type: "limited", max: 9 }); 34 + }); 35 + 36 + it("returns undefined for Sol Ring", async () => { 37 + const card = await cards.get("Sol Ring"); 38 + expect(detectCopyException(card)).toBeUndefined(); 39 + }); 40 + 41 + it("returns undefined for basic lands", async () => { 42 + const card = await cards.get("Forest"); 43 + expect(detectCopyException(card)).toBeUndefined(); 44 + }); 45 + }); 46 + 47 + describe("isBasicLand", () => { 48 + it("returns true for Forest", async () => { 49 + const card = await cards.get("Forest"); 50 + expect(isBasicLand(card)).toBe(true); 51 + }); 52 + 53 + it("returns true for Snow-Covered Forest", async () => { 54 + const card = await cards.get("Snow-Covered Forest"); 55 + expect(isBasicLand(card)).toBe(true); 56 + }); 57 + 58 + it("returns true for Wastes", async () => { 59 + const card = await cards.get("Wastes"); 60 + expect(isBasicLand(card)).toBe(true); 61 + }); 62 + 63 + it("returns false for Sol Ring", async () => { 64 + const card = await cards.get("Sol Ring"); 65 + expect(isBasicLand(card)).toBe(false); 66 + }); 67 + 68 + it("returns false for nonbasic lands", async () => { 69 + const card = await cards.get("Command Tower"); 70 + expect(isBasicLand(card)).toBe(false); 71 + }); 72 + 73 + it("returns false for Dryad Arbor (creature land)", async () => { 74 + const card = await cards.get("Dryad Arbor"); 75 + expect(isBasicLand(card)).toBe(false); 76 + }); 77 + }); 78 + 79 + describe("getCopyLimit", () => { 80 + describe("singleton format (default 1)", () => { 81 + it("returns Infinity for basic lands", async () => { 82 + const card = await cards.get("Forest"); 83 + expect(getCopyLimit(card, 1)).toBe(Infinity); 84 + }); 85 + 86 + it("returns Infinity for unlimited exception cards", async () => { 87 + const card = await cards.get("Relentless Rats"); 88 + expect(getCopyLimit(card, 1)).toBe(Infinity); 89 + }); 90 + 91 + it("returns 7 for Seven Dwarves", async () => { 92 + const card = await cards.get("Seven Dwarves"); 93 + expect(getCopyLimit(card, 1)).toBe(7); 94 + }); 95 + 96 + it("returns 9 for Nazgûl", async () => { 97 + const card = await cards.get("Nazgûl"); 98 + expect(getCopyLimit(card, 1)).toBe(9); 99 + }); 100 + 101 + it("returns 1 for regular cards", async () => { 102 + const card = await cards.get("Sol Ring"); 103 + expect(getCopyLimit(card, 1)).toBe(1); 104 + }); 105 + }); 106 + 107 + describe("playset format (default 4)", () => { 108 + it("returns Infinity for basic lands", async () => { 109 + const card = await cards.get("Forest"); 110 + expect(getCopyLimit(card, 4)).toBe(Infinity); 111 + }); 112 + 113 + it("returns Infinity for unlimited exception cards", async () => { 114 + const card = await cards.get("Relentless Rats"); 115 + expect(getCopyLimit(card, 4)).toBe(Infinity); 116 + }); 117 + 118 + it("returns 7 for Seven Dwarves", async () => { 119 + const card = await cards.get("Seven Dwarves"); 120 + expect(getCopyLimit(card, 4)).toBe(7); 121 + }); 122 + 123 + it("returns 4 for regular cards", async () => { 124 + const card = await cards.get("Lightning Bolt"); 125 + expect(getCopyLimit(card, 4)).toBe(4); 126 + }); 127 + }); 128 + }); 129 + });
+117
src/lib/deck-validation/exceptions.ts
··· 1 + import type { Card } from "@/lib/scryfall-types"; 2 + 3 + /** 4 + * Copy exception types for cards that bypass normal deck construction limits 5 + */ 6 + export type CopyException = 7 + | { type: "unlimited" } 8 + | { type: "limited"; max: number }; 9 + 10 + /** 11 + * Pattern for "a deck can have any number of cards named X" 12 + */ 13 + const UNLIMITED_PATTERN = /a deck can have any number of cards named/i; 14 + 15 + /** 16 + * Pattern for "a deck can have up to X cards named Y" 17 + */ 18 + const LIMITED_PATTERN = /a deck can have up to (\w+) cards named/i; 19 + 20 + /** 21 + * Word to number mapping for limited patterns 22 + */ 23 + const WORD_TO_NUMBER: Record<string, number> = { 24 + one: 1, 25 + two: 2, 26 + three: 3, 27 + four: 4, 28 + five: 5, 29 + six: 6, 30 + seven: 7, 31 + eight: 8, 32 + nine: 9, 33 + ten: 10, 34 + }; 35 + 36 + /** 37 + * Detect copy limit exception from oracle text. 38 + * Returns undefined if no exception found. 39 + */ 40 + export function detectCopyException(card: Card): CopyException | undefined { 41 + const text = getOracleText(card); 42 + 43 + if (UNLIMITED_PATTERN.test(text)) { 44 + return { type: "unlimited" }; 45 + } 46 + 47 + const limitedMatch = text.match(LIMITED_PATTERN); 48 + if (limitedMatch) { 49 + const word = limitedMatch[1].toLowerCase(); 50 + const num = WORD_TO_NUMBER[word] ?? parseInt(word, 10); 51 + if (!Number.isNaN(num)) { 52 + return { type: "limited", max: num }; 53 + } 54 + } 55 + 56 + return undefined; 57 + } 58 + 59 + /** 60 + * Check if card is a basic land (unlimited copies always allowed) 61 + */ 62 + export function isBasicLand(card: Card): boolean { 63 + const typeLine = getTypeLine(card); 64 + return typeLine.includes("Basic") && typeLine.includes("Land"); 65 + } 66 + 67 + /** 68 + * Get the maximum allowed copies for a card given format rules. 69 + * @param card The card to check 70 + * @param defaultLimit The default limit (1 for singleton, 4 for playset) 71 + * @returns The maximum copies allowed (Infinity for unlimited) 72 + */ 73 + export function getCopyLimit(card: Card, defaultLimit: number): number { 74 + if (isBasicLand(card)) { 75 + return Infinity; 76 + } 77 + 78 + const exception = detectCopyException(card); 79 + if (exception?.type === "unlimited") { 80 + return Infinity; 81 + } 82 + if (exception?.type === "limited") { 83 + return exception.max; 84 + } 85 + 86 + return defaultLimit; 87 + } 88 + 89 + /** 90 + * Get combined oracle text from card, including all faces for DFCs 91 + */ 92 + function getOracleText(card: Card): string { 93 + if (card.oracle_text) { 94 + return card.oracle_text; 95 + } 96 + 97 + if (card.card_faces) { 98 + return card.card_faces.map((face) => face.oracle_text ?? "").join("\n"); 99 + } 100 + 101 + return ""; 102 + } 103 + 104 + /** 105 + * Get type line from card, including all faces for DFCs 106 + */ 107 + function getTypeLine(card: Card): string { 108 + if (card.type_line) { 109 + return card.type_line; 110 + } 111 + 112 + if (card.card_faces) { 113 + return card.card_faces.map((face) => face.type_line ?? "").join(" // "); 114 + } 115 + 116 + return ""; 117 + }
+33
src/lib/deck-validation/index.ts
··· 1 + // Main API 2 + 3 + export type { CopyException } from "./exceptions"; 4 + // Exception detection 5 + export { 6 + detectCopyException, 7 + getCopyLimit, 8 + isBasicLand, 9 + } from "./exceptions"; 10 + export type { FormatId } from "./presets"; 11 + 12 + // Presets 13 + export { getFormatConfig, getPreset, PRESETS } from "./presets"; 14 + export type { RuleId } from "./rules"; 15 + 16 + // Rules 17 + export { RULES } from "./rules"; 18 + // Types 19 + export type { 20 + FormatConfig, 21 + Preset, 22 + Rule, 23 + RuleCategory, 24 + RuleNumber, 25 + Severity, 26 + ValidationContext, 27 + ValidationOptions, 28 + ValidationResult, 29 + Violation, 30 + } from "./types"; 31 + export { asRuleNumber, violation } from "./types"; 32 + export type { ValidateDeckParams } from "./validate"; 33 + export { validateDeck, validateDeckWithRules } from "./validate";
+164
src/lib/deck-validation/presets.ts
··· 1 + import type { RuleId } from "./rules"; 2 + import type { FormatConfig, Preset } from "./types"; 3 + 4 + /** 5 + * Reusable rule sets for common format patterns 6 + */ 7 + const SIXTY_CARD_RULES = [ 8 + "cardLegality", 9 + "banned", 10 + "playset", 11 + "deckSizeMin", 12 + "sideboardSize", 13 + ] as const satisfies readonly RuleId[]; 14 + 15 + const COMMANDER_CORE_RULES = [ 16 + "cardLegality", 17 + "banned", 18 + "singleton", 19 + "colorIdentity", 20 + "deckSizeExact", 21 + "commanderRequired", 22 + "commanderPartner", 23 + ] as const satisfies readonly RuleId[]; 24 + 25 + /** 26 + * Format preset definitions 27 + * 28 + * Each preset combines: 29 + * - rules: Which validation rules to apply 30 + * - config: Parameters for those rules (legality field, deck sizes, etc.) 31 + */ 32 + export const PRESETS = { 33 + // 60-card constructed formats 34 + standard: { 35 + rules: SIXTY_CARD_RULES, 36 + config: { legalityField: "standard", minDeckSize: 60, sideboardSize: 15 }, 37 + }, 38 + pioneer: { 39 + rules: SIXTY_CARD_RULES, 40 + config: { legalityField: "pioneer", minDeckSize: 60, sideboardSize: 15 }, 41 + }, 42 + modern: { 43 + rules: SIXTY_CARD_RULES, 44 + config: { legalityField: "modern", minDeckSize: 60, sideboardSize: 15 }, 45 + }, 46 + legacy: { 47 + rules: SIXTY_CARD_RULES, 48 + config: { legalityField: "legacy", minDeckSize: 60, sideboardSize: 15 }, 49 + }, 50 + vintage: { 51 + rules: [...SIXTY_CARD_RULES, "restricted"] as const, 52 + config: { legalityField: "vintage", minDeckSize: 60, sideboardSize: 15 }, 53 + }, 54 + pauper: { 55 + rules: SIXTY_CARD_RULES, 56 + config: { legalityField: "pauper", minDeckSize: 60, sideboardSize: 15 }, 57 + }, 58 + 59 + // Commander variants (100-card singleton) 60 + commander: { 61 + rules: [...COMMANDER_CORE_RULES, "commanderLegendary"] as const, 62 + config: { legalityField: "commander", deckSize: 100 }, 63 + }, 64 + paupercommander: { 65 + rules: [...COMMANDER_CORE_RULES, "commanderUncommon"] as const, 66 + config: { legalityField: "paupercommander", deckSize: 100 }, 67 + }, 68 + duel: { 69 + rules: [...COMMANDER_CORE_RULES, "commanderLegendary"] as const, 70 + config: { legalityField: "duel", deckSize: 100 }, 71 + }, 72 + predh: { 73 + rules: [...COMMANDER_CORE_RULES, "commanderLegendary"] as const, 74 + config: { legalityField: "predh", deckSize: 100 }, 75 + }, 76 + 77 + // Oathbreaker (60-card singleton with planeswalker commander) 78 + oathbreaker: { 79 + rules: [ 80 + "cardLegality", 81 + "banned", 82 + "singleton", 83 + "colorIdentity", 84 + "deckSizeExact", 85 + "commanderPlaneswalker", 86 + "signatureSpell", 87 + ] as const, 88 + config: { legalityField: "legacy", deckSize: 60 }, 89 + }, 90 + 91 + // Brawl variants (60-card singleton) 92 + brawl: { 93 + rules: [ 94 + "cardLegality", 95 + "banned", 96 + "singleton", 97 + "deckSizeExact", 98 + "commanderRequired", 99 + "commanderLegendary", 100 + ] as const, 101 + config: { legalityField: "brawl", deckSize: 60 }, 102 + }, 103 + standardbrawl: { 104 + rules: [ 105 + "cardLegality", 106 + "banned", 107 + "singleton", 108 + "deckSizeExact", 109 + "commanderRequired", 110 + "commanderLegendary", 111 + ] as const, 112 + config: { legalityField: "standardbrawl", deckSize: 60 }, 113 + }, 114 + 115 + // Arena formats 116 + historic: { 117 + rules: SIXTY_CARD_RULES, 118 + config: { legalityField: "historic", minDeckSize: 60, sideboardSize: 15 }, 119 + }, 120 + timeless: { 121 + rules: SIXTY_CARD_RULES, 122 + config: { legalityField: "timeless", minDeckSize: 60, sideboardSize: 15 }, 123 + }, 124 + alchemy: { 125 + rules: SIXTY_CARD_RULES, 126 + config: { legalityField: "alchemy", minDeckSize: 60, sideboardSize: 15 }, 127 + }, 128 + gladiator: { 129 + rules: ["cardLegality", "banned", "singleton", "deckSizeMin"] as const, 130 + config: { legalityField: "gladiator", minDeckSize: 100 }, 131 + }, 132 + 133 + // Retro formats 134 + premodern: { 135 + rules: SIXTY_CARD_RULES, 136 + config: { legalityField: "premodern", minDeckSize: 60, sideboardSize: 15 }, 137 + }, 138 + oldschool: { 139 + rules: [...SIXTY_CARD_RULES, "restricted"] as const, 140 + config: { legalityField: "oldschool", minDeckSize: 60, sideboardSize: 15 }, 141 + }, 142 + 143 + // Other 144 + penny: { 145 + rules: SIXTY_CARD_RULES, 146 + config: { legalityField: "penny", minDeckSize: 60, sideboardSize: 15 }, 147 + }, 148 + } as const satisfies Record<string, Preset<RuleId>>; 149 + 150 + export type FormatId = keyof typeof PRESETS; 151 + 152 + /** 153 + * Get the preset for a format, if one exists 154 + */ 155 + export function getPreset(format: string): Preset<RuleId> | undefined { 156 + return PRESETS[format as FormatId]; 157 + } 158 + 159 + /** 160 + * Get the config for a format, if one exists 161 + */ 162 + export function getFormatConfig(format: string): FormatConfig | undefined { 163 + return getPreset(format)?.config; 164 + }
+318
src/lib/deck-validation/rules/base.ts
··· 1 + import { getCardsInSection, isKnownSection } from "@/lib/deck-types"; 2 + import type { OracleId } from "@/lib/scryfall-types"; 3 + import { getCopyLimit } from "../exceptions"; 4 + import { 5 + asRuleNumber, 6 + type Rule, 7 + type ValidationContext, 8 + type Violation, 9 + violation, 10 + } from "../types"; 11 + 12 + /** 13 + * Check card legality via Scryfall's legalities field 14 + */ 15 + export const cardLegalityRule: Rule<"cardLegality"> = { 16 + id: "cardLegality", 17 + rule: asRuleNumber("100.2a"), 18 + category: "legality", 19 + description: "Card must be legal in format", 20 + validate(ctx: ValidationContext): Violation[] { 21 + const { deck, cardLookup, config } = ctx; 22 + const violations: Violation[] = []; 23 + const field = config.legalityField; 24 + 25 + for (const entry of deck.cards) { 26 + if (entry.section === "maybeboard") continue; 27 + 28 + const card = cardLookup(entry.scryfallId); 29 + if (!card) continue; 30 + 31 + const legality = card.legalities?.[field]; 32 + if (legality === "not_legal") { 33 + violations.push( 34 + violation(this, `${card.name} is not legal in ${field}`, "error", { 35 + cardName: card.name, 36 + oracleId: entry.oracleId, 37 + section: isKnownSection(entry.section) ? entry.section : undefined, 38 + }), 39 + ); 40 + } 41 + } 42 + 43 + return violations; 44 + }, 45 + }; 46 + 47 + /** 48 + * Check for banned cards 49 + */ 50 + export const bannedRule: Rule<"banned"> = { 51 + id: "banned", 52 + rule: asRuleNumber("100.6a"), 53 + category: "legality", 54 + description: "Card is banned in format", 55 + validate(ctx: ValidationContext): Violation[] { 56 + const { deck, cardLookup, config } = ctx; 57 + const violations: Violation[] = []; 58 + const field = config.legalityField; 59 + 60 + for (const entry of deck.cards) { 61 + if (entry.section === "maybeboard") continue; 62 + 63 + const card = cardLookup(entry.scryfallId); 64 + if (!card) continue; 65 + 66 + const legality = card.legalities?.[field]; 67 + if (legality === "banned") { 68 + violations.push( 69 + violation(this, `${card.name} is banned in ${field}`, "error", { 70 + cardName: card.name, 71 + oracleId: entry.oracleId, 72 + section: isKnownSection(entry.section) ? entry.section : undefined, 73 + }), 74 + ); 75 + } 76 + } 77 + 78 + return violations; 79 + }, 80 + }; 81 + 82 + /** 83 + * Check for restricted cards (Vintage - max 1 copy) 84 + */ 85 + export const restrictedRule: Rule<"restricted"> = { 86 + id: "restricted", 87 + rule: asRuleNumber("100.6b"), 88 + category: "quantity", 89 + description: "Restricted cards limited to 1 copy", 90 + validate(ctx: ValidationContext): Violation[] { 91 + const { deck, oracleLookup, config } = ctx; 92 + const violations: Violation[] = []; 93 + const field = config.legalityField; 94 + 95 + const oracleCounts = new Map<OracleId, number>(); 96 + 97 + for (const entry of deck.cards) { 98 + if (entry.section === "maybeboard") continue; 99 + 100 + const current = oracleCounts.get(entry.oracleId) ?? 0; 101 + oracleCounts.set(entry.oracleId, current + entry.quantity); 102 + } 103 + 104 + for (const [oracleId, count] of oracleCounts) { 105 + if (count <= 1) continue; 106 + 107 + const card = oracleLookup(oracleId); 108 + if (!card) continue; 109 + 110 + const legality = card.legalities?.[field]; 111 + if (legality === "restricted") { 112 + violations.push( 113 + violation( 114 + this, 115 + `${card.name} is restricted to 1 copy, deck has ${count}`, 116 + "error", 117 + { 118 + cardName: card.name, 119 + oracleId: card.oracle_id, 120 + quantity: count, 121 + }, 122 + ), 123 + ); 124 + } 125 + } 126 + 127 + return violations; 128 + }, 129 + }; 130 + 131 + /** 132 + * Singleton rule - max 1 copy (Commander variants) 133 + */ 134 + export const singletonRule: Rule<"singleton"> = { 135 + id: "singleton", 136 + rule: asRuleNumber("903.5b"), 137 + category: "quantity", 138 + description: "Maximum 1 copy of each card (except basics and exceptions)", 139 + validate(ctx: ValidationContext): Violation[] { 140 + const { deck, oracleLookup } = ctx; 141 + const violations: Violation[] = []; 142 + 143 + const oracleCounts = new Map<OracleId, number>(); 144 + 145 + for (const entry of deck.cards) { 146 + if (entry.section === "maybeboard") continue; 147 + 148 + const current = oracleCounts.get(entry.oracleId) ?? 0; 149 + oracleCounts.set(entry.oracleId, current + entry.quantity); 150 + } 151 + 152 + for (const [oracleId, count] of oracleCounts) { 153 + const card = oracleLookup(oracleId); 154 + if (!card) continue; 155 + 156 + const limit = getCopyLimit(card, 1); 157 + if (count > limit) { 158 + violations.push( 159 + violation( 160 + this, 161 + `${card.name} exceeds singleton limit (${count}/${limit})`, 162 + "error", 163 + { 164 + cardName: card.name, 165 + oracleId: card.oracle_id, 166 + quantity: count, 167 + }, 168 + ), 169 + ); 170 + } 171 + } 172 + 173 + return violations; 174 + }, 175 + }; 176 + 177 + /** 178 + * Playset rule - max 4 copies (60-card formats) 179 + */ 180 + export const playsetRule: Rule<"playset"> = { 181 + id: "playset", 182 + rule: asRuleNumber("100.2a"), 183 + category: "quantity", 184 + description: "Maximum 4 copies of each card (except basics and exceptions)", 185 + validate(ctx: ValidationContext): Violation[] { 186 + const { deck, oracleLookup } = ctx; 187 + const violations: Violation[] = []; 188 + 189 + const oracleCounts = new Map<OracleId, number>(); 190 + 191 + for (const entry of deck.cards) { 192 + if (entry.section === "maybeboard") continue; 193 + 194 + const current = oracleCounts.get(entry.oracleId) ?? 0; 195 + oracleCounts.set(entry.oracleId, current + entry.quantity); 196 + } 197 + 198 + for (const [oracleId, count] of oracleCounts) { 199 + const card = oracleLookup(oracleId); 200 + if (!card) continue; 201 + 202 + const limit = getCopyLimit(card, 4); 203 + if (count > limit) { 204 + violations.push( 205 + violation( 206 + this, 207 + `${card.name} exceeds playset limit (${count}/${limit})`, 208 + "error", 209 + { 210 + cardName: card.name, 211 + oracleId: card.oracle_id, 212 + quantity: count, 213 + }, 214 + ), 215 + ); 216 + } 217 + } 218 + 219 + return violations; 220 + }, 221 + }; 222 + 223 + /** 224 + * Minimum deck size (60-card formats) 225 + */ 226 + export const deckSizeMinRule: Rule<"deckSizeMin"> = { 227 + id: "deckSizeMin", 228 + rule: asRuleNumber("100.2a"), 229 + category: "structure", 230 + description: "Deck must meet minimum size", 231 + validate(ctx: ValidationContext): Violation[] { 232 + const { deck, config } = ctx; 233 + const minDeckSize = config.minDeckSize; 234 + 235 + if (minDeckSize === undefined) return []; 236 + 237 + const mainboard = getCardsInSection(deck, "mainboard"); 238 + const mainboardCount = mainboard.reduce((sum, c) => sum + c.quantity, 0); 239 + 240 + if (mainboardCount < minDeckSize) { 241 + return [ 242 + violation( 243 + this, 244 + `Deck has ${mainboardCount} cards, minimum is ${minDeckSize}`, 245 + "error", 246 + ), 247 + ]; 248 + } 249 + 250 + return []; 251 + }, 252 + }; 253 + 254 + /** 255 + * Exact deck size (Commander = 100) 256 + */ 257 + export const deckSizeExactRule: Rule<"deckSizeExact"> = { 258 + id: "deckSizeExact", 259 + rule: asRuleNumber("903.5a"), 260 + category: "structure", 261 + description: "Deck must be exactly the specified size", 262 + validate(ctx: ValidationContext): Violation[] { 263 + const { deck, config } = ctx; 264 + const deckSize = config.deckSize; 265 + 266 + if (deckSize === undefined) return []; 267 + 268 + const commander = getCardsInSection(deck, "commander"); 269 + const mainboard = getCardsInSection(deck, "mainboard"); 270 + 271 + const commanderCount = commander.reduce((sum, c) => sum + c.quantity, 0); 272 + const mainboardCount = mainboard.reduce((sum, c) => sum + c.quantity, 0); 273 + const totalCount = commanderCount + mainboardCount; 274 + 275 + if (totalCount !== deckSize) { 276 + return [ 277 + violation( 278 + this, 279 + `Deck has ${totalCount} cards, must be exactly ${deckSize}`, 280 + "error", 281 + ), 282 + ]; 283 + } 284 + 285 + return []; 286 + }, 287 + }; 288 + 289 + /** 290 + * Sideboard size limit 291 + */ 292 + export const sideboardSizeRule: Rule<"sideboardSize"> = { 293 + id: "sideboardSize", 294 + rule: asRuleNumber("100.4a"), 295 + category: "structure", 296 + description: "Sideboard cannot exceed maximum size", 297 + validate(ctx: ValidationContext): Violation[] { 298 + const { deck, config } = ctx; 299 + const sideboardSize = config.sideboardSize; 300 + 301 + if (sideboardSize === undefined) return []; 302 + 303 + const sideboard = getCardsInSection(deck, "sideboard"); 304 + const sideboardCount = sideboard.reduce((sum, c) => sum + c.quantity, 0); 305 + 306 + if (sideboardCount > sideboardSize) { 307 + return [ 308 + violation( 309 + this, 310 + `Sideboard has ${sideboardCount} cards, maximum is ${sideboardSize}`, 311 + "error", 312 + ), 313 + ]; 314 + } 315 + 316 + return []; 317 + }, 318 + };
+395
src/lib/deck-validation/rules/commander.ts
··· 1 + import { getCardsInSection, isKnownSection } from "@/lib/deck-types"; 2 + import type { Card } from "@/lib/scryfall-types"; 3 + import { 4 + asRuleNumber, 5 + type Rule, 6 + type ValidationContext, 7 + type Violation, 8 + violation, 9 + } from "../types"; 10 + 11 + /** 12 + * Commander required - at least one commander 13 + */ 14 + export const commanderRequiredRule: Rule<"commanderRequired"> = { 15 + id: "commanderRequired", 16 + rule: asRuleNumber("903.3"), 17 + category: "structure", 18 + description: "Deck must have at least one commander", 19 + validate(ctx: ValidationContext): Violation[] { 20 + const { deck } = ctx; 21 + const commanders = getCardsInSection(deck, "commander"); 22 + const commanderCount = commanders.reduce((sum, c) => sum + c.quantity, 0); 23 + 24 + if (commanderCount === 0) { 25 + return [violation(this, "Deck must have a commander", "error")]; 26 + } 27 + 28 + return []; 29 + }, 30 + }; 31 + 32 + /** 33 + * Commander must be legendary creature (or planeswalker with "can be your commander") 34 + */ 35 + export const commanderLegendaryRule: Rule<"commanderLegendary"> = { 36 + id: "commanderLegendary", 37 + rule: asRuleNumber("903.3"), 38 + category: "structure", 39 + description: "Commander must be a legendary creature", 40 + validate(ctx: ValidationContext): Violation[] { 41 + const { deck, cardLookup } = ctx; 42 + const violations: Violation[] = []; 43 + const commanders = getCardsInSection(deck, "commander"); 44 + 45 + for (const entry of commanders) { 46 + const card = cardLookup(entry.scryfallId); 47 + if (!card) continue; 48 + 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) { 57 + violations.push( 58 + violation(this, `${card.name} is not a legendary creature`, "error", { 59 + cardName: card.name, 60 + oracleId: entry.oracleId, 61 + section: "commander", 62 + }), 63 + ); 64 + } 65 + } 66 + 67 + return violations; 68 + }, 69 + }; 70 + 71 + /** 72 + * Partner rule - validates commander pairing is legal 73 + * 74 + * Legal pairings: 75 + * - Both have generic "Partner" (not "Partner with X") 76 + * - Both have "Friends forever" 77 + * - One has "Partner with [NAME]" and the other is that NAME 78 + * - One has "Choose a Background" and other is a Background enchantment 79 + */ 80 + export const commanderPartnerRule: Rule<"commanderPartner"> = { 81 + id: "commanderPartner", 82 + rule: asRuleNumber("702.124"), 83 + category: "structure", 84 + description: "Multiple commanders must have valid partner pairing", 85 + validate(ctx: ValidationContext): Violation[] { 86 + const { deck, cardLookup } = ctx; 87 + const commanders = getCardsInSection(deck, "commander"); 88 + 89 + // Expand commanders by quantity 90 + const commanderCards: Card[] = []; 91 + for (const entry of commanders) { 92 + const card = cardLookup(entry.scryfallId); 93 + if (!card) continue; 94 + for (let i = 0; i < entry.quantity; i++) { 95 + commanderCards.push(card); 96 + } 97 + } 98 + 99 + if (commanderCards.length <= 1) return []; 100 + 101 + if (commanderCards.length > 2) { 102 + return [ 103 + violation( 104 + this, 105 + `Deck has ${commanderCards.length} commanders, maximum is 2`, 106 + "error", 107 + ), 108 + ]; 109 + } 110 + 111 + const [card1, card2] = commanderCards; 112 + const pairingResult = validatePairing(card1, card2); 113 + 114 + if (!pairingResult.valid) { 115 + return [ 116 + violation( 117 + this, 118 + `${card1.name} and ${card2.name} cannot be paired: ${pairingResult.reason}`, 119 + "error", 120 + ), 121 + ]; 122 + } 123 + 124 + return []; 125 + }, 126 + }; 127 + 128 + /** 129 + * Validate if two cards can legally be paired as commanders 130 + */ 131 + function validatePairing( 132 + card1: Card, 133 + card2: Card, 134 + ): { valid: true } | { valid: false; reason: string } { 135 + const info1 = getPartnerInfo(card1); 136 + const info2 = getPartnerInfo(card2); 137 + 138 + // Generic partner: both must have it 139 + if (info1.hasGenericPartner && info2.hasGenericPartner) { 140 + return { valid: true }; 141 + } 142 + 143 + // Friends forever: both must have it 144 + if (info1.hasFriendsForever && info2.hasFriendsForever) { 145 + return { valid: true }; 146 + } 147 + 148 + // Partner with: check if they name each other 149 + if (info1.partnerWithName && info1.partnerWithName === card2.name) { 150 + return { valid: true }; 151 + } 152 + if (info2.partnerWithName && info2.partnerWithName === card1.name) { 153 + return { valid: true }; 154 + } 155 + 156 + // Background pairing: one chooses background, other is background 157 + if (info1.choosesBackground && info2.isBackground) { 158 + return { valid: true }; 159 + } 160 + if (info2.choosesBackground && info1.isBackground) { 161 + return { valid: true }; 162 + } 163 + 164 + // Doctor's companion: can pair with a Doctor (Time Lord Doctor creature) 165 + if (info1.hasDoctorsCompanion && isDoctor(card2)) { 166 + return { valid: true }; 167 + } 168 + if (info2.hasDoctorsCompanion && isDoctor(card1)) { 169 + return { valid: true }; 170 + } 171 + 172 + // No valid pairing found 173 + const getAbilityName = (info: PartnerInfo): string => { 174 + if (info.hasGenericPartner) return "Partner"; 175 + if (info.hasFriendsForever) return "Friends forever"; 176 + if (info.partnerWithName) return `Partner with ${info.partnerWithName}`; 177 + if (info.choosesBackground) return "Choose a Background"; 178 + if (info.isBackground) return "Background"; 179 + if (info.hasDoctorsCompanion) return "Doctor's companion"; 180 + return "no partner ability"; 181 + }; 182 + 183 + return { 184 + valid: false, 185 + reason: `${getAbilityName(info1)} cannot pair with ${getAbilityName(info2)}`, 186 + }; 187 + } 188 + 189 + interface PartnerInfo { 190 + hasGenericPartner: boolean; 191 + hasFriendsForever: boolean; 192 + partnerWithName: string | null; 193 + choosesBackground: boolean; 194 + isBackground: boolean; 195 + hasDoctorsCompanion: boolean; 196 + } 197 + 198 + function getPartnerInfo(card: Card): PartnerInfo { 199 + const oracleText = getOracleText(card).toLowerCase(); 200 + const typeLine = getTypeLine(card).toLowerCase(); 201 + const keywords = card.keywords?.map((k) => k.toLowerCase()) ?? []; 202 + 203 + // Check for "Partner with [Name]" pattern - extract the name 204 + const partnerWithMatch = oracleText.match(/partner with ([^(]+)\s*\(/i); 205 + const partnerWithName = partnerWithMatch ? partnerWithMatch[1].trim() : null; 206 + 207 + // Generic partner has "Partner" keyword but NOT "Partner with X" in oracle text 208 + const hasPartnerKeyword = keywords.includes("partner"); 209 + const hasGenericPartner = hasPartnerKeyword && !partnerWithName; 210 + 211 + return { 212 + hasGenericPartner, 213 + hasFriendsForever: keywords.includes("friends forever"), 214 + partnerWithName, 215 + choosesBackground: oracleText.includes("choose a background"), 216 + isBackground: typeLine.includes("background"), 217 + hasDoctorsCompanion: keywords.includes("doctor's companion"), 218 + }; 219 + } 220 + 221 + function isDoctor(card: Card): boolean { 222 + const typeLine = getTypeLine(card).toLowerCase(); 223 + return typeLine.includes("time lord") && typeLine.includes("doctor"); 224 + } 225 + 226 + /** 227 + * Color identity - all cards must match commander's color identity 228 + * Errors for main deck, warnings for maybeboard 229 + */ 230 + export const colorIdentityRule: Rule<"colorIdentity"> = { 231 + id: "colorIdentity", 232 + rule: asRuleNumber("903.4"), 233 + category: "identity", 234 + description: "Cards must match commander color identity", 235 + validate(ctx: ValidationContext): Violation[] { 236 + const { deck, cardLookup, commanderColors } = ctx; 237 + 238 + if (!commanderColors) return []; 239 + 240 + const violations: Violation[] = []; 241 + const allowedColors = new Set<string>(commanderColors); 242 + 243 + for (const entry of deck.cards) { 244 + if (entry.section === "commander") continue; 245 + 246 + const card = cardLookup(entry.scryfallId); 247 + if (!card) continue; 248 + 249 + const cardIdentity = card.color_identity ?? []; 250 + const invalidColors = cardIdentity.filter((c) => !allowedColors.has(c)); 251 + 252 + if (invalidColors.length > 0) { 253 + const commanderStr = 254 + commanderColors.length > 0 ? commanderColors.join("") : "colorless"; 255 + const severity = entry.section === "maybeboard" ? "warning" : "error"; 256 + 257 + violations.push( 258 + violation( 259 + this, 260 + `${card.name} has colors outside commander identity (${invalidColors.join("")} not in ${commanderStr})`, 261 + severity, 262 + { 263 + cardName: card.name, 264 + oracleId: entry.oracleId, 265 + section: isKnownSection(entry.section) 266 + ? entry.section 267 + : undefined, 268 + }, 269 + ), 270 + ); 271 + } 272 + } 273 + 274 + return violations; 275 + }, 276 + }; 277 + 278 + /** 279 + * Commander must be a planeswalker (Oathbreaker) 280 + */ 281 + export const commanderPlaneswalkerRule: Rule<"commanderPlaneswalker"> = { 282 + id: "commanderPlaneswalker", 283 + rule: asRuleNumber("903.3"), 284 + category: "structure", 285 + description: "Commander must be a planeswalker (Oathbreaker)", 286 + validate(ctx: ValidationContext): Violation[] { 287 + const { deck, cardLookup } = ctx; 288 + const violations: Violation[] = []; 289 + const commanders = getCardsInSection(deck, "commander"); 290 + 291 + for (const entry of commanders) { 292 + const card = cardLookup(entry.scryfallId); 293 + if (!card) continue; 294 + 295 + const typeLine = getTypeLine(card).toLowerCase(); 296 + 297 + // Skip signature spell (instant/sorcery) - that's validated separately 298 + if (typeLine.includes("instant") || typeLine.includes("sorcery")) { 299 + continue; 300 + } 301 + 302 + if (!typeLine.includes("planeswalker")) { 303 + violations.push( 304 + violation(this, `${card.name} is not a planeswalker`, "error", { 305 + cardName: card.name, 306 + oracleId: entry.oracleId, 307 + section: "commander", 308 + }), 309 + ); 310 + } 311 + } 312 + 313 + return violations; 314 + }, 315 + }; 316 + 317 + /** 318 + * Signature spell requirement (Oathbreaker) 319 + * Commander section must have exactly one instant or sorcery 320 + */ 321 + export const signatureSpellRule: Rule<"signatureSpell"> = { 322 + id: "signatureSpell", 323 + rule: asRuleNumber("903.3"), 324 + category: "structure", 325 + description: 326 + "Oathbreaker requires exactly one signature spell (instant/sorcery)", 327 + validate(ctx: ValidationContext): Violation[] { 328 + const { deck, cardLookup } = ctx; 329 + const commanders = getCardsInSection(deck, "commander"); 330 + 331 + let signatureSpellCount = 0; 332 + 333 + for (const entry of commanders) { 334 + const card = cardLookup(entry.scryfallId); 335 + if (!card) continue; 336 + 337 + const typeLine = getTypeLine(card).toLowerCase(); 338 + if (typeLine.includes("instant") || typeLine.includes("sorcery")) { 339 + signatureSpellCount += entry.quantity; 340 + } 341 + } 342 + 343 + if (signatureSpellCount === 0) { 344 + return [ 345 + violation( 346 + this, 347 + "Oathbreaker deck must have a signature spell (instant/sorcery in commander zone)", 348 + "error", 349 + ), 350 + ]; 351 + } 352 + 353 + if (signatureSpellCount > 1) { 354 + return [ 355 + violation( 356 + this, 357 + `Oathbreaker deck can only have 1 signature spell, found ${signatureSpellCount}`, 358 + "error", 359 + ), 360 + ]; 361 + } 362 + 363 + return []; 364 + }, 365 + }; 366 + 367 + /** 368 + * Get combined oracle text from card, including all faces for DFCs 369 + */ 370 + function getOracleText(card: Card): string { 371 + if (card.oracle_text) { 372 + return card.oracle_text; 373 + } 374 + 375 + if (card.card_faces) { 376 + return card.card_faces.map((face) => face.oracle_text ?? "").join("\n"); 377 + } 378 + 379 + return ""; 380 + } 381 + 382 + /** 383 + * Get type line from card, including all faces for DFCs 384 + */ 385 + function getTypeLine(card: Card): string { 386 + if (card.type_line) { 387 + return card.type_line; 388 + } 389 + 390 + if (card.card_faces) { 391 + return card.card_faces.map((face) => face.type_line ?? "").join(" // "); 392 + } 393 + 394 + return ""; 395 + }
+74
src/lib/deck-validation/rules/index.ts
··· 1 + export { 2 + bannedRule, 3 + cardLegalityRule, 4 + deckSizeExactRule, 5 + deckSizeMinRule, 6 + playsetRule, 7 + restrictedRule, 8 + sideboardSizeRule, 9 + singletonRule, 10 + } from "./base"; 11 + 12 + export { 13 + colorIdentityRule, 14 + commanderLegendaryRule, 15 + commanderPartnerRule, 16 + commanderPlaneswalkerRule, 17 + commanderRequiredRule, 18 + signatureSpellRule, 19 + } from "./commander"; 20 + 21 + export { commanderUncommonRule } from "./rarity"; 22 + 23 + import { 24 + bannedRule, 25 + cardLegalityRule, 26 + deckSizeExactRule, 27 + deckSizeMinRule, 28 + playsetRule, 29 + restrictedRule, 30 + sideboardSizeRule, 31 + singletonRule, 32 + } from "./base"; 33 + 34 + import { 35 + colorIdentityRule, 36 + commanderLegendaryRule, 37 + commanderPartnerRule, 38 + commanderPlaneswalkerRule, 39 + commanderRequiredRule, 40 + signatureSpellRule, 41 + } from "./commander"; 42 + 43 + import { commanderUncommonRule } from "./rarity"; 44 + 45 + /** 46 + * All available rules, keyed by rule ID. 47 + * Use `keyof typeof RULES` for typed rule IDs. 48 + */ 49 + export const RULES = { 50 + // Card pool rules 51 + cardLegality: cardLegalityRule, 52 + banned: bannedRule, 53 + restricted: restrictedRule, 54 + 55 + // Copy limit rules 56 + singleton: singletonRule, 57 + playset: playsetRule, 58 + 59 + // Structure rules 60 + deckSizeMin: deckSizeMinRule, 61 + deckSizeExact: deckSizeExactRule, 62 + sideboardSize: sideboardSizeRule, 63 + 64 + // Commander rules 65 + colorIdentity: colorIdentityRule, 66 + commanderRequired: commanderRequiredRule, 67 + commanderPartner: commanderPartnerRule, 68 + commanderLegendary: commanderLegendaryRule, 69 + commanderUncommon: commanderUncommonRule, 70 + commanderPlaneswalker: commanderPlaneswalkerRule, 71 + signatureSpell: signatureSpellRule, 72 + } as const; 73 + 74 + export type RuleId = keyof typeof RULES;
+63
src/lib/deck-validation/rules/rarity.ts
··· 1 + import { getCardsInSection } from "@/lib/deck-types"; 2 + import type { Card } from "@/lib/scryfall-types"; 3 + import { 4 + asRuleNumber, 5 + type Rule, 6 + type ValidationContext, 7 + type Violation, 8 + violation, 9 + } from "../types"; 10 + 11 + /** 12 + * Commander must be uncommon (Pauper Commander / PDH) 13 + * 14 + * PDH rules: 15 + * - Commander must have been printed at uncommon in paper or MTGO 16 + * - Arena-only downshifts don't count 17 + * - Any printing of the card can be used if it has a valid uncommon printing 18 + * - Commander doesn't need to be legendary (just uncommon creature) 19 + */ 20 + export const commanderUncommonRule: Rule<"commanderUncommon"> = { 21 + id: "commanderUncommon", 22 + rule: asRuleNumber("903.3"), 23 + category: "structure", 24 + description: "Commander must have an uncommon printing in paper/MTGO (PDH)", 25 + validate(ctx: ValidationContext): Violation[] { 26 + const { deck, getPrintings } = ctx; 27 + const violations: Violation[] = []; 28 + const commanders = getCardsInSection(deck, "commander"); 29 + 30 + for (const entry of commanders) { 31 + const printings = getPrintings(entry.oracleId); 32 + const hasValidUncommon = printings.some((card) => 33 + isUncommonInPaperOrMtgo(card), 34 + ); 35 + 36 + if (!hasValidUncommon) { 37 + const card = printings[0]; 38 + const name = card?.name ?? "Unknown card"; 39 + violations.push( 40 + violation( 41 + this, 42 + `${name} has no uncommon printing in paper/MTGO`, 43 + "error", 44 + { 45 + cardName: name, 46 + oracleId: entry.oracleId, 47 + section: "commander", 48 + }, 49 + ), 50 + ); 51 + } 52 + } 53 + 54 + return violations; 55 + }, 56 + }; 57 + 58 + function isUncommonInPaperOrMtgo(card: Card): boolean { 59 + if (card.rarity !== "uncommon") return false; 60 + 61 + const games = card.games ?? []; 62 + return games.includes("paper") || games.includes("mtgo"); 63 + }
+128
src/lib/deck-validation/types.ts
··· 1 + import type { Deck, Section } from "@/lib/deck-types"; 2 + import type { 3 + Card, 4 + ManaColor, 5 + OracleId, 6 + ScryfallId, 7 + } from "@/lib/scryfall-types"; 8 + 9 + /** 10 + * MTG Comprehensive Rules citation (e.g., "100.2a", "903.5c") 11 + * Branded type for type safety 12 + */ 13 + export type RuleNumber = string & { readonly __brand: "RuleNumber" }; 14 + 15 + export function asRuleNumber(rule: string): RuleNumber { 16 + return rule as RuleNumber; 17 + } 18 + 19 + /** 20 + * Categories for grouping related rules 21 + */ 22 + export type RuleCategory = "legality" | "quantity" | "identity" | "structure"; 23 + 24 + /** 25 + * Severity level for violations 26 + */ 27 + export type Severity = "error" | "warning"; 28 + 29 + /** 30 + * A single rule violation with context 31 + */ 32 + export interface Violation { 33 + ruleId: string; 34 + rule: RuleNumber; 35 + category: RuleCategory; 36 + cardName?: string; 37 + oracleId?: OracleId; 38 + section?: Section; 39 + quantity?: number; 40 + message: string; 41 + severity: Severity; 42 + } 43 + 44 + /** 45 + * Result of validating a deck 46 + */ 47 + export interface ValidationResult { 48 + valid: boolean; 49 + violations: Violation[]; 50 + byCard: Map<OracleId, Violation[]>; 51 + byRule: Map<RuleNumber, Violation[]>; 52 + } 53 + 54 + /** 55 + * Context passed to rule validators 56 + */ 57 + export interface ValidationContext { 58 + deck: Deck; 59 + cardLookup: (id: ScryfallId) => Card | undefined; 60 + oracleLookup: (id: OracleId) => Card | undefined; 61 + getPrintings: (id: OracleId) => Card[]; 62 + format: string | undefined; 63 + commanderColors: ManaColor[] | undefined; 64 + config: FormatConfig; 65 + } 66 + 67 + /** 68 + * Per-format configuration parameters 69 + */ 70 + export interface FormatConfig { 71 + legalityField: string; 72 + minDeckSize?: number; 73 + deckSize?: number; 74 + sideboardSize?: number; 75 + } 76 + 77 + /** 78 + * Rule definition 79 + */ 80 + export interface Rule<Id extends string = string> { 81 + id: Id; 82 + rule: RuleNumber; 83 + category: RuleCategory; 84 + description: string; 85 + validate: (ctx: ValidationContext) => Violation[]; 86 + } 87 + 88 + /** 89 + * Options for validation 90 + */ 91 + export interface ValidationOptions { 92 + disabledRules?: Set<string>; 93 + disabledCategories?: Set<RuleCategory>; 94 + configOverrides?: Partial<FormatConfig>; 95 + includeMaybeboard?: boolean; 96 + } 97 + 98 + /** 99 + * Format preset combining rules and config 100 + */ 101 + export interface Preset<RuleId extends string = string> { 102 + rules: readonly RuleId[]; 103 + config: FormatConfig; 104 + } 105 + 106 + /** 107 + * Helper to create a violation 108 + */ 109 + export function violation( 110 + rule: Rule, 111 + message: string, 112 + severity: Severity, 113 + context?: { 114 + cardName?: string; 115 + oracleId?: OracleId; 116 + section?: Section; 117 + quantity?: number; 118 + }, 119 + ): Violation { 120 + return { 121 + ruleId: rule.id, 122 + rule: rule.rule, 123 + category: rule.category, 124 + message, 125 + severity, 126 + ...context, 127 + }; 128 + }
+131
src/lib/deck-validation/validate.ts
··· 1 + import type { Deck } from "@/lib/deck-types"; 2 + import { getCommanderColorIdentity } from "@/lib/deck-types"; 3 + import type { Card, OracleId, ScryfallId } from "@/lib/scryfall-types"; 4 + import { getPreset } from "./presets"; 5 + import { RULES, type RuleId } from "./rules"; 6 + import type { 7 + FormatConfig, 8 + RuleNumber, 9 + ValidationContext, 10 + ValidationOptions, 11 + ValidationResult, 12 + Violation, 13 + } from "./types"; 14 + 15 + export interface ValidateDeckParams { 16 + deck: Deck; 17 + cardLookup: (id: ScryfallId) => Card | undefined; 18 + oracleLookup: (id: OracleId) => Card | undefined; 19 + getPrintings: (id: OracleId) => Card[]; 20 + options?: ValidationOptions; 21 + } 22 + 23 + /** 24 + * Validate a deck against format rules. 25 + * 26 + * Uses the deck's format field to determine which rules to apply. 27 + * Returns violations grouped by card and rule for easy display. 28 + */ 29 + export function validateDeck(params: ValidateDeckParams): ValidationResult { 30 + const { deck, options = {} } = params; 31 + 32 + const format = deck.format; 33 + const preset = format ? getPreset(format) : undefined; 34 + 35 + if (!preset) { 36 + return { 37 + valid: true, 38 + violations: [], 39 + byCard: new Map(), 40 + byRule: new Map(), 41 + }; 42 + } 43 + 44 + return validateDeckWithRules({ 45 + ...params, 46 + rules: preset.rules, 47 + config: preset.config, 48 + options, 49 + }); 50 + } 51 + 52 + /** 53 + * Validate a deck with custom rules instead of format preset. 54 + * 55 + * Use this when you need to apply specific rules regardless of format, 56 + * or when the deck doesn't have a format set. 57 + */ 58 + export function validateDeckWithRules( 59 + params: ValidateDeckParams & { 60 + rules: readonly RuleId[]; 61 + config: FormatConfig; 62 + }, 63 + ): ValidationResult { 64 + const { 65 + deck, 66 + cardLookup, 67 + oracleLookup, 68 + getPrintings, 69 + rules, 70 + config, 71 + options = {}, 72 + } = params; 73 + 74 + const commanderColors = getCommanderColorIdentity(deck, cardLookup); 75 + 76 + const ctx: ValidationContext = { 77 + deck, 78 + cardLookup, 79 + oracleLookup, 80 + getPrintings, 81 + format: deck.format, 82 + commanderColors, 83 + config: { ...config, ...options.configOverrides }, 84 + }; 85 + 86 + const violations: Violation[] = []; 87 + 88 + for (const ruleId of rules) { 89 + if (options.disabledRules?.has(ruleId)) continue; 90 + 91 + const rule = RULES[ruleId]; 92 + if (options.disabledCategories?.has(rule.category)) continue; 93 + 94 + const ruleViolations = rule.validate(ctx); 95 + violations.push(...ruleViolations); 96 + } 97 + 98 + const validityViolations = options.includeMaybeboard 99 + ? violations 100 + : violations.filter((v) => v.section !== "maybeboard"); 101 + 102 + const hasErrors = validityViolations.some((v) => v.severity === "error"); 103 + 104 + return { 105 + valid: !hasErrors, 106 + violations, 107 + byCard: groupByCard(violations), 108 + byRule: groupByRule(violations), 109 + }; 110 + } 111 + 112 + function groupByCard(violations: Violation[]): Map<OracleId, Violation[]> { 113 + const map = new Map<OracleId, Violation[]>(); 114 + for (const v of violations) { 115 + if (!v.oracleId) continue; 116 + const existing = map.get(v.oracleId) ?? []; 117 + existing.push(v); 118 + map.set(v.oracleId, existing); 119 + } 120 + return map; 121 + } 122 + 123 + function groupByRule(violations: Violation[]): Map<RuleNumber, Violation[]> { 124 + const map = new Map<RuleNumber, Violation[]>(); 125 + for (const v of violations) { 126 + const existing = map.get(v.rule) ?? []; 127 + existing.push(v); 128 + map.set(v.rule, existing); 129 + } 130 + return map; 131 + }