👁️
1import { getCardsInSection, isKnownSection } from "@/lib/deck-types";
2import { formatDisplayName } from "@/lib/format-utils";
3import type { OracleId } from "@/lib/scryfall-types";
4import { getCopyLimit } from "../exceptions";
5import {
6 asRuleNumber,
7 type Rule,
8 type ValidationContext,
9 type Violation,
10 violation,
11} from "../types";
12
13/**
14 * Check card legality via Scryfall's legalities field
15 *
16 * Note: For pauper commander, commanders are validated separately by the
17 * commanderUncommon rule. Scryfall marks uncommon cards as "not_legal" in PDH
18 * because they can't go in the 99, but they're valid commanders.
19 */
20export const cardLegalityRule: Rule<"cardLegality"> = {
21 id: "cardLegality",
22 rule: asRuleNumber("100.2a"),
23 ruleText:
24 "In constructed play (a way of playing in which each player creates their own deck ahead of time), each deck has a minimum deck size of 60 cards. A constructed deck may contain any number of basic land cards and no more than four of any card with a particular English name other than basic land cards. For the purposes of deck construction, cards with interchangeable names have the same English name (see rule 201.3).",
25 category: "legality",
26 description: "Card must be legal in format",
27 validate(ctx: ValidationContext): Violation[] {
28 const { deck, cardLookup, config } = ctx;
29 const field = config.legalityField;
30
31 // Formats without a legality field (draft, kitchentable) skip this check
32 if (!field) return [];
33
34 const violations: Violation[] = [];
35
36 // For PDH, commanders are validated by commanderUncommon rule instead
37 const skipCommanderLegality = field === "paupercommander";
38
39 for (const entry of deck.cards) {
40 if (entry.section === "maybeboard") continue;
41 if (skipCommanderLegality && entry.section === "commander") continue;
42
43 const card = cardLookup(entry.scryfallId);
44 if (!card) continue;
45
46 const legality = card.legalities?.[field];
47 if (legality === "not_legal") {
48 violations.push(
49 violation(
50 this,
51 `${card.name} is not legal in ${formatDisplayName(field) || field}`,
52 "error",
53 {
54 cardName: card.name,
55 oracleId: entry.oracleId,
56 section: isKnownSection(entry.section)
57 ? entry.section
58 : undefined,
59 },
60 ),
61 );
62 }
63 }
64
65 return violations;
66 },
67};
68
69/**
70 * Check for banned cards
71 */
72export const bannedRule: Rule<"banned"> = {
73 id: "banned",
74 rule: asRuleNumber("MTR"),
75 ruleText:
76 "The current Magic: The Gathering Tournament Rules can be found at WPN.Wizards.com/en/rules-documents.",
77 category: "legality",
78 description: "Card is banned in format",
79 validate(ctx: ValidationContext): Violation[] {
80 const { deck, cardLookup, config } = ctx;
81 const field = config.legalityField;
82
83 // Formats without a legality field (draft, kitchentable) skip this check
84 if (!field) return [];
85
86 const violations: Violation[] = [];
87
88 for (const entry of deck.cards) {
89 if (entry.section === "maybeboard") continue;
90
91 const card = cardLookup(entry.scryfallId);
92 if (!card) continue;
93
94 const legality = card.legalities?.[field];
95 if (legality === "banned") {
96 violations.push(
97 violation(
98 this,
99 `${card.name} is banned in ${formatDisplayName(field) || field}`,
100 "error",
101 {
102 cardName: card.name,
103 oracleId: entry.oracleId,
104 section: isKnownSection(entry.section)
105 ? entry.section
106 : undefined,
107 },
108 ),
109 );
110 }
111 }
112
113 return violations;
114 },
115};
116
117/**
118 * Check for restricted cards (Vintage - max 1 copy)
119 */
120export const restrictedRule: Rule<"restricted"> = {
121 id: "restricted",
122 rule: asRuleNumber("MTR"),
123 ruleText:
124 "The current Magic: The Gathering Tournament Rules can be found at WPN.Wizards.com/en/rules-documents.",
125 category: "quantity",
126 description: "Restricted cards limited to 1 copy",
127 validate(ctx: ValidationContext): Violation[] {
128 const { deck, oracleLookup, config } = ctx;
129 const field = config.legalityField;
130
131 // Formats without a legality field (draft, kitchentable) skip this check
132 if (!field) return [];
133
134 const violations: Violation[] = [];
135 const oracleCounts = new Map<OracleId, number>();
136
137 for (const entry of deck.cards) {
138 if (entry.section === "maybeboard") continue;
139
140 const current = oracleCounts.get(entry.oracleId) ?? 0;
141 oracleCounts.set(entry.oracleId, current + entry.quantity);
142 }
143
144 for (const [oracleId, count] of oracleCounts) {
145 if (count <= 1) continue;
146
147 const card = oracleLookup(oracleId);
148 if (!card) continue;
149
150 const legality = card.legalities?.[field];
151 if (legality === "restricted") {
152 violations.push(
153 violation(
154 this,
155 `${card.name} is restricted to 1 copy, deck has ${count}`,
156 "error",
157 {
158 cardName: card.name,
159 oracleId: card.oracle_id,
160 quantity: count,
161 },
162 ),
163 );
164 }
165 }
166
167 return violations;
168 },
169};
170
171/**
172 * Singleton rule - max 1 copy (Commander variants)
173 */
174export const singletonRule: Rule<"singleton"> = {
175 id: "singleton",
176 rule: asRuleNumber("903.5b"),
177 ruleText:
178 "Other than basic lands, each card in a Commander deck must have a different English name. For the purposes of deck construction, cards with interchangeable names have the same English name (see rule 201.3).",
179 category: "quantity",
180 description: "Maximum 1 copy of each card (except basics and exceptions)",
181 validate(ctx: ValidationContext): Violation[] {
182 const { deck, oracleLookup } = ctx;
183 const violations: Violation[] = [];
184
185 const oracleCounts = new Map<OracleId, number>();
186
187 for (const entry of deck.cards) {
188 if (entry.section === "maybeboard") continue;
189
190 const current = oracleCounts.get(entry.oracleId) ?? 0;
191 oracleCounts.set(entry.oracleId, current + entry.quantity);
192 }
193
194 for (const [oracleId, count] of oracleCounts) {
195 const card = oracleLookup(oracleId);
196 if (!card) continue;
197
198 const limit = getCopyLimit(card, 1);
199 if (count > limit) {
200 violations.push(
201 violation(
202 this,
203 `${card.name} exceeds singleton limit (${count}/${limit})`,
204 "error",
205 {
206 cardName: card.name,
207 oracleId: card.oracle_id,
208 quantity: count,
209 },
210 ),
211 );
212 }
213 }
214
215 return violations;
216 },
217};
218
219/**
220 * Playset rule - max 4 copies (60-card formats)
221 */
222export const playsetRule: Rule<"playset"> = {
223 id: "playset",
224 rule: asRuleNumber("100.2a"),
225 ruleText:
226 "In constructed play (a way of playing in which each player creates their own deck ahead of time), each deck has a minimum deck size of 60 cards. A constructed deck may contain any number of basic land cards and no more than four of any card with a particular English name other than basic land cards. For the purposes of deck construction, cards with interchangeable names have the same English name (see rule 201.3).",
227 category: "quantity",
228 description: "Maximum 4 copies of each card (except basics and exceptions)",
229 validate(ctx: ValidationContext): Violation[] {
230 const { deck, oracleLookup } = ctx;
231 const violations: Violation[] = [];
232
233 const oracleCounts = new Map<OracleId, number>();
234
235 for (const entry of deck.cards) {
236 if (entry.section === "maybeboard") continue;
237
238 const current = oracleCounts.get(entry.oracleId) ?? 0;
239 oracleCounts.set(entry.oracleId, current + entry.quantity);
240 }
241
242 for (const [oracleId, count] of oracleCounts) {
243 const card = oracleLookup(oracleId);
244 if (!card) continue;
245
246 const limit = getCopyLimit(card, 4);
247 if (count > limit) {
248 violations.push(
249 violation(
250 this,
251 `${card.name} exceeds playset limit (${count}/${limit})`,
252 "error",
253 {
254 cardName: card.name,
255 oracleId: card.oracle_id,
256 quantity: count,
257 },
258 ),
259 );
260 }
261 }
262
263 return violations;
264 },
265};
266
267/**
268 * Minimum deck size (60-card formats)
269 */
270export const deckSizeMinRule: Rule<"deckSizeMin"> = {
271 id: "deckSizeMin",
272 rule: asRuleNumber("100.2a"),
273 ruleText:
274 "In constructed play (a way of playing in which each player creates their own deck ahead of time), each deck has a minimum deck size of 60 cards. A constructed deck may contain any number of basic land cards and no more than four of any card with a particular English name other than basic land cards. For the purposes of deck construction, cards with interchangeable names have the same English name (see rule 201.3).",
275 category: "structure",
276 description: "Deck must meet minimum size",
277 validate(ctx: ValidationContext): Violation[] {
278 const { deck, config } = ctx;
279 const minDeckSize = config.minDeckSize;
280
281 if (minDeckSize === undefined) return [];
282
283 const mainboard = getCardsInSection(deck, "mainboard");
284 const mainboardCount = mainboard.reduce((sum, c) => sum + c.quantity, 0);
285
286 if (mainboardCount < minDeckSize) {
287 return [
288 violation(
289 this,
290 `Deck has ${mainboardCount} cards, minimum is ${minDeckSize}`,
291 "error",
292 ),
293 ];
294 }
295
296 return [];
297 },
298};
299
300/**
301 * Exact deck size (Commander = 100)
302 */
303export const deckSizeExactRule: Rule<"deckSizeExact"> = {
304 id: "deckSizeExact",
305 rule: asRuleNumber("903.5a"),
306 ruleText:
307 "Each deck must contain exactly 100 cards, including its commander. In other words, the minimum deck size and the maximum deck size are both 100.",
308 category: "structure",
309 description: "Deck must be exactly the specified size",
310 validate(ctx: ValidationContext): Violation[] {
311 const { deck, config } = ctx;
312 const deckSize = config.deckSize;
313
314 if (deckSize === undefined) return [];
315
316 const commander = getCardsInSection(deck, "commander");
317 const mainboard = getCardsInSection(deck, "mainboard");
318
319 const commanderCount = commander.reduce((sum, c) => sum + c.quantity, 0);
320 const mainboardCount = mainboard.reduce((sum, c) => sum + c.quantity, 0);
321 const totalCount = commanderCount + mainboardCount;
322
323 if (totalCount !== deckSize) {
324 return [
325 violation(
326 this,
327 `Deck has ${totalCount} cards, must be exactly ${deckSize}`,
328 "error",
329 ),
330 ];
331 }
332
333 return [];
334 },
335};
336
337/**
338 * Sideboard size limit
339 */
340export const sideboardSizeRule: Rule<"sideboardSize"> = {
341 id: "sideboardSize",
342 rule: asRuleNumber("100.4a"),
343 ruleText:
344 "In constructed play, a sideboard may contain no more than fifteen cards. The four-card limit (see rule 100.2a) applies to the combined deck and sideboard.",
345 category: "structure",
346 description: "Sideboard cannot exceed maximum size",
347 validate(ctx: ValidationContext): Violation[] {
348 const { deck, config } = ctx;
349 const sideboardSize = config.sideboardSize;
350
351 if (sideboardSize === undefined) return [];
352
353 const sideboard = getCardsInSection(deck, "sideboard");
354 const sideboardCount = sideboard.reduce((sum, c) => sum + c.quantity, 0);
355
356 if (sideboardCount > sideboardSize) {
357 return [
358 violation(
359 this,
360 `Sideboard has ${sideboardCount} cards, maximum is ${sideboardSize}`,
361 "error",
362 ),
363 ];
364 }
365
366 return [];
367 },
368};
369
370/**
371 * Conspiracy cards are only legal in Conspiracy Draft
372 */
373export const conspiracyCardRule: Rule<"conspiracyCard"> = {
374 id: "conspiracyCard",
375 rule: asRuleNumber("315.1"),
376 ruleText:
377 "Conspiracy cards are used only in limited play, particularly in the Conspiracy Draft variant (see rule 905). Conspiracy cards aren't used in constructed play.",
378 category: "legality",
379 description: "Conspiracy cards are not legal in constructed formats",
380 validate(ctx: ValidationContext): Violation[] {
381 const { deck, cardLookup } = ctx;
382 const violations: Violation[] = [];
383
384 for (const entry of deck.cards) {
385 const card = cardLookup(entry.scryfallId);
386 if (!card) continue;
387
388 const typeLine = card.type_line?.toLowerCase() ?? "";
389 if (typeLine.includes("conspiracy")) {
390 violations.push(
391 violation(
392 this,
393 `${card.name} is a Conspiracy card and not legal in constructed formats`,
394 "error",
395 {
396 cardName: card.name,
397 oracleId: entry.oracleId,
398 section: isKnownSection(entry.section)
399 ? entry.section
400 : undefined,
401 },
402 ),
403 );
404 }
405 }
406
407 return violations;
408 },
409};
410
411/**
412 * Silver-bordered and acorn-stamped cards are not tournament legal
413 */
414export const illegalCardTypeRule: Rule<"illegalCardType"> = {
415 id: "illegalCardType",
416 rule: asRuleNumber("100.7"),
417 ruleText:
418 'Certain cards are intended for casual play and may have features and text that aren\'t covered by these rules. These include Mystery Booster playtest cards, promotional cards and cards in "Un-sets" that were printed with a silver border, and cards in the Unfinity expansion that have an acorn symbol at the bottom of the card.',
419 category: "legality",
420 description: "Silver-bordered and acorn cards are not tournament legal",
421 validate(ctx: ValidationContext): Violation[] {
422 const { deck, cardLookup } = ctx;
423 const violations: Violation[] = [];
424
425 for (const entry of deck.cards) {
426 const card = cardLookup(entry.scryfallId);
427 if (!card) continue;
428
429 if (card.border_color === "silver") {
430 violations.push(
431 violation(
432 this,
433 `${card.name} is a silver-bordered card and not tournament legal`,
434 "error",
435 {
436 cardName: card.name,
437 oracleId: entry.oracleId,
438 section: isKnownSection(entry.section)
439 ? entry.section
440 : undefined,
441 },
442 ),
443 );
444 }
445
446 if (card.security_stamp === "acorn") {
447 violations.push(
448 violation(
449 this,
450 `${card.name} is an acorn card and not tournament legal`,
451 "error",
452 {
453 cardName: card.name,
454 oracleId: entry.oracleId,
455 section: isKnownSection(entry.section)
456 ? entry.section
457 : undefined,
458 },
459 ),
460 );
461 }
462 }
463
464 return violations;
465 },
466};
467
468/**
469 * Ante cards are banned in all sanctioned formats
470 */
471export const anteCardRule: Rule<"anteCard"> = {
472 id: "anteCard",
473 rule: asRuleNumber("407.1"),
474 ruleText:
475 "Earlier versions of the Magic rules included an ante rule as a way of playing \"for keeps.\" Playing Magic games for ante is now considered an optional variation on the game, and it's allowed only where it's not forbidden by law or by other rules. Playing for ante is strictly forbidden under the Magic: The Gathering Tournament Rules (WPN.Wizards.com/en/rules-documents).",
476 category: "legality",
477 description: "Ante cards are banned in all sanctioned formats",
478 validate(ctx: ValidationContext): Violation[] {
479 const { deck, cardLookup } = ctx;
480 const violations: Violation[] = [];
481
482 for (const entry of deck.cards) {
483 const card = cardLookup(entry.scryfallId);
484 if (!card) continue;
485
486 const oracleText = card.oracle_text?.toLowerCase() ?? "";
487 if (oracleText.includes("playing for ante")) {
488 violations.push(
489 violation(
490 this,
491 `${card.name} is an ante card and banned in all sanctioned formats`,
492 "error",
493 {
494 cardName: card.name,
495 oracleId: entry.oracleId,
496 section: isKnownSection(entry.section)
497 ? entry.section
498 : undefined,
499 },
500 ),
501 );
502 }
503 }
504
505 return violations;
506 },
507};