👁️
6
fork

Configure Feed

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

deck validation pt5

+530 -2
+2
src/lib/__tests__/test-cards.json
··· 78 78 "Inspiring Vantage": "3f17c60e-923a-4392-9da8-87d9ded009b7", 79 79 "Jace, Vryn's Prodigy": "594f6881-c059-46f8-aa4e-7151d502de73", 80 80 "Karlach, Raging Tiefling": "f40ddd57-ad75-477c-bfe6-1b0ca68b88b6", 81 + "Keruga, the Macrosage": "ca5121b2-e6d9-40bc-a2d2-05852c4efbe8", 81 82 "Ketria Triome": "6bae00e8-06cf-4ac4-a1cc-757e454109fe", 82 83 "Lightning Bolt": "4457ed35-7c10-48c8-9776-456485fdf070", 83 84 "Lion's Eye Diamond": "ee6099b0-fb1f-42f1-b862-7708c6e36d05", ··· 135 136 "Tymna the Weaver": "d15642e4-e61c-4d29-af48-de837991245e", 136 137 "Undercity Sewers": "08d80efc-9542-4ba2-824c-c8615d8d07f2", 137 138 "Underground Sea": "4b22be3a-8ce1-47d1-b82e-6c3ccfb0548b", 139 + "Valki, God of Lies": "907ae517-22d5-4ac7-bc3a-3f4d5eaeeb57", 138 140 "Vault Skirge": "64cf5d59-7bcd-4b0b-a160-c8468d4c0f60", 139 141 "Wall of Shards": "8fab68ad-169d-46d3-93c4-5bdee4eea2ce", 140 142 "Wastes": "05d24b0c-904a-46b6-b42a-96a4d91a0dd4",
+55 -1
src/lib/deck-validation/__tests__/companion.test.ts
··· 5 5 } from "@/lib/__tests__/test-card-lookup"; 6 6 import type { Deck, DeckCard, Section } from "@/lib/deck-types"; 7 7 import type { Card, OracleId, ScryfallId } from "@/lib/scryfall-types"; 8 - import { companionRule } from "../rules/base"; 8 + import { companionRule } from "../rules/companion"; 9 9 import type { ValidationContext } from "../types"; 10 10 11 11 describe("companion rules", () => { ··· 78 78 makeCard(solRing, "mainboard"), 79 79 ]); 80 80 const ctx = makeContext(deck, [lurrus, solRing]); 81 + const violations = companionRule.validate(ctx); 82 + expect(violations).toHaveLength(0); 83 + }); 84 + }); 85 + 86 + describe("Keruga, the Macrosage", () => { 87 + it("rejects deck with low MV creatures", async () => { 88 + const keruga = await cards.get("Keruga, the Macrosage"); 89 + const solRing = await cards.get("Sol Ring"); 90 + const deck = makeDeck([ 91 + makeCard(keruga, "sideboard"), 92 + makeCard(solRing, "mainboard"), 93 + ]); 94 + const ctx = makeContext(deck, [keruga, solRing]); 95 + const violations = companionRule.validate(ctx); 96 + expect(violations).toHaveLength(0); 97 + }); 98 + 99 + it("rejects MDFC creature with MV < 3 front face", async () => { 100 + const keruga = await cards.get("Keruga, the Macrosage"); 101 + const valki = await cards.get("Valki, God of Lies"); 102 + const deck = makeDeck([ 103 + makeCard(keruga, "sideboard"), 104 + makeCard(valki, "mainboard"), 105 + ]); 106 + const ctx = makeContext(deck, [keruga, valki]); 107 + const violations = companionRule.validate(ctx); 108 + expect(violations).toHaveLength(1); 109 + expect(violations[0].message).toContain("Keruga"); 110 + expect(violations[0].message).toContain("mana value"); 111 + }); 112 + 113 + it("allows deck with only MV 3+ creatures", async () => { 114 + const keruga = await cards.get("Keruga, the Macrosage"); 115 + const selvala = await cards.get("Selvala, Heart of the Wilds"); 116 + const deck = makeDeck([ 117 + makeCard(keruga, "sideboard"), 118 + makeCard(selvala, "mainboard"), 119 + ]); 120 + const ctx = makeContext(deck, [keruga, selvala]); 121 + const violations = companionRule.validate(ctx); 122 + expect(violations).toHaveLength(0); 123 + }); 124 + }); 125 + 126 + describe("no companion in sideboard", () => { 127 + it("passes when no companion is present", async () => { 128 + const solRing = await cards.get("Sol Ring"); 129 + const selvala = await cards.get("Selvala, Heart of the Wilds"); 130 + const deck = makeDeck([ 131 + makeCard(solRing, "sideboard"), 132 + makeCard(selvala, "mainboard"), 133 + ]); 134 + const ctx = makeContext(deck, [solRing, selvala]); 81 135 const violations = companionRule.validate(ctx); 82 136 expect(violations).toHaveLength(0); 83 137 });
+468
src/lib/deck-validation/rules/companion.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 + * Companion rule - validates deck meets companion's deck-building restriction 13 + * 14 + * Companions go in sideboard. If a card has the Companion keyword, 15 + * the deck must satisfy its specific restriction. 16 + * 17 + * Rule 702.139: Companion is a keyword ability that imposes a deck-building 18 + * restriction on cards in your starting deck. 19 + */ 20 + export const companionRule: Rule<"companion"> = { 21 + id: "companion", 22 + rule: asRuleNumber("702.139"), 23 + category: "structure", 24 + description: "Deck must meet companion's deck-building restriction", 25 + validate(ctx: ValidationContext): Violation[] { 26 + const { deck, cardLookup } = ctx; 27 + const violations: Violation[] = []; 28 + 29 + const sideboard = getCardsInSection(deck, "sideboard"); 30 + 31 + for (const entry of sideboard) { 32 + const card = cardLookup(entry.scryfallId); 33 + if (!card) continue; 34 + 35 + const keywords = card.keywords?.map((k) => k.toLowerCase()) ?? []; 36 + if (!keywords.includes("companion")) continue; 37 + 38 + const companionViolations = validateCompanionRestriction(card, ctx); 39 + violations.push(...companionViolations); 40 + } 41 + 42 + return violations; 43 + }, 44 + }; 45 + 46 + function validateCompanionRestriction( 47 + companion: Card, 48 + ctx: ValidationContext, 49 + ): Violation[] { 50 + const name = companion.name.toLowerCase(); 51 + 52 + if (name.includes("lurrus")) { 53 + return validateLurrus(companion, ctx); 54 + } 55 + if (name.includes("gyruda")) { 56 + return validateGyruda(companion, ctx); 57 + } 58 + if (name.includes("obosh")) { 59 + return validateObosh(companion, ctx); 60 + } 61 + if (name.includes("kaheera")) { 62 + return validateKaheera(companion, ctx); 63 + } 64 + if (name.includes("umori")) { 65 + return validateUmori(companion, ctx); 66 + } 67 + if (name.includes("jegantha")) { 68 + return validateJegantha(companion, ctx); 69 + } 70 + if (name.includes("keruga")) { 71 + return validateKeruga(companion, ctx); 72 + } 73 + if (name.includes("yorion")) { 74 + return validateYorion(companion, ctx); 75 + } 76 + if (name.includes("zirda")) { 77 + return validateZirda(companion, ctx); 78 + } 79 + if (name.includes("lutri")) { 80 + return validateLutri(companion, ctx); 81 + } 82 + 83 + return []; 84 + } 85 + 86 + function validateLurrus(_companion: Card, ctx: ValidationContext): Violation[] { 87 + const { deck, cardLookup } = ctx; 88 + const violations: Violation[] = []; 89 + const mainboard = getCardsInSection(deck, "mainboard"); 90 + 91 + for (const entry of mainboard) { 92 + const card = cardLookup(entry.scryfallId); 93 + if (!card) continue; 94 + 95 + if (!isPermanent(card)) continue; 96 + 97 + const mv = card.cmc ?? 0; 98 + if (mv > 2) { 99 + violations.push( 100 + violation( 101 + companionRule, 102 + `Lurrus companion requires all permanents to have mana value 2 or less, but ${card.name} has mana value ${mv}`, 103 + "error", 104 + { 105 + cardName: card.name, 106 + oracleId: entry.oracleId, 107 + section: "mainboard", 108 + }, 109 + ), 110 + ); 111 + } 112 + } 113 + 114 + return violations; 115 + } 116 + 117 + function validateGyruda(_companion: Card, ctx: ValidationContext): Violation[] { 118 + const { deck, cardLookup } = ctx; 119 + const violations: Violation[] = []; 120 + const mainboard = getCardsInSection(deck, "mainboard"); 121 + 122 + for (const entry of mainboard) { 123 + const card = cardLookup(entry.scryfallId); 124 + if (!card) continue; 125 + 126 + if (isLand(card)) continue; 127 + 128 + const mv = card.cmc ?? 0; 129 + if (mv % 2 !== 0) { 130 + violations.push( 131 + violation( 132 + companionRule, 133 + `Gyruda companion requires all nonland cards to have even mana value, but ${card.name} has mana value ${mv}`, 134 + "error", 135 + { 136 + cardName: card.name, 137 + oracleId: entry.oracleId, 138 + section: "mainboard", 139 + }, 140 + ), 141 + ); 142 + } 143 + } 144 + 145 + return violations; 146 + } 147 + 148 + function validateObosh(_companion: Card, ctx: ValidationContext): Violation[] { 149 + const { deck, cardLookup } = ctx; 150 + const violations: Violation[] = []; 151 + const mainboard = getCardsInSection(deck, "mainboard"); 152 + 153 + for (const entry of mainboard) { 154 + const card = cardLookup(entry.scryfallId); 155 + if (!card) continue; 156 + 157 + if (isLand(card)) continue; 158 + 159 + const mv = card.cmc ?? 0; 160 + if (mv % 2 === 0) { 161 + violations.push( 162 + violation( 163 + companionRule, 164 + `Obosh companion requires all nonland cards to have odd mana value, but ${card.name} has mana value ${mv}`, 165 + "error", 166 + { 167 + cardName: card.name, 168 + oracleId: entry.oracleId, 169 + section: "mainboard", 170 + }, 171 + ), 172 + ); 173 + } 174 + } 175 + 176 + return violations; 177 + } 178 + 179 + function validateKaheera( 180 + _companion: Card, 181 + ctx: ValidationContext, 182 + ): Violation[] { 183 + const { deck, cardLookup } = ctx; 184 + const violations: Violation[] = []; 185 + const mainboard = getCardsInSection(deck, "mainboard"); 186 + 187 + const allowedTypes = ["cat", "elemental", "nightmare", "dinosaur", "beast"]; 188 + 189 + for (const entry of mainboard) { 190 + const card = cardLookup(entry.scryfallId); 191 + if (!card) continue; 192 + 193 + const typeLine = getTypeLine(card).toLowerCase(); 194 + if (!typeLine.includes("creature")) continue; 195 + 196 + const hasAllowedType = allowedTypes.some((t) => typeLine.includes(t)); 197 + if (!hasAllowedType) { 198 + violations.push( 199 + violation( 200 + companionRule, 201 + `Kaheera companion requires all creatures to be Cat, Elemental, Nightmare, Dinosaur, or Beast, but ${card.name} is not`, 202 + "error", 203 + { 204 + cardName: card.name, 205 + oracleId: entry.oracleId, 206 + section: "mainboard", 207 + }, 208 + ), 209 + ); 210 + } 211 + } 212 + 213 + return violations; 214 + } 215 + 216 + function validateUmori(_companion: Card, ctx: ValidationContext): Violation[] { 217 + const { deck, cardLookup } = ctx; 218 + const mainboard = getCardsInSection(deck, "mainboard"); 219 + 220 + const cardTypes = [ 221 + "creature", 222 + "artifact", 223 + "enchantment", 224 + "planeswalker", 225 + "instant", 226 + "sorcery", 227 + ]; 228 + const typePresent = new Set<string>(); 229 + 230 + for (const entry of mainboard) { 231 + const card = cardLookup(entry.scryfallId); 232 + if (!card) continue; 233 + 234 + if (isLand(card)) continue; 235 + 236 + const typeLine = getTypeLine(card).toLowerCase(); 237 + for (const t of cardTypes) { 238 + if (typeLine.includes(t)) { 239 + typePresent.add(t); 240 + } 241 + } 242 + } 243 + 244 + if (typePresent.size > 1) { 245 + return [ 246 + violation( 247 + companionRule, 248 + `Umori companion requires all nonland cards to share a card type, but deck has multiple types: ${[...typePresent].join(", ")}`, 249 + "error", 250 + ), 251 + ]; 252 + } 253 + 254 + return []; 255 + } 256 + 257 + function validateJegantha( 258 + _companion: Card, 259 + ctx: ValidationContext, 260 + ): Violation[] { 261 + const { deck, cardLookup } = ctx; 262 + const violations: Violation[] = []; 263 + const mainboard = getCardsInSection(deck, "mainboard"); 264 + 265 + for (const entry of mainboard) { 266 + const card = cardLookup(entry.scryfallId); 267 + if (!card) continue; 268 + 269 + const manaCost = card.mana_cost ?? ""; 270 + if (hasRepeatedManaSymbol(manaCost)) { 271 + violations.push( 272 + violation( 273 + companionRule, 274 + `Jegantha companion requires no card to have more than one of the same mana symbol, but ${card.name} has repeated symbols in ${manaCost}`, 275 + "error", 276 + { 277 + cardName: card.name, 278 + oracleId: entry.oracleId, 279 + section: "mainboard", 280 + }, 281 + ), 282 + ); 283 + } 284 + } 285 + 286 + return violations; 287 + } 288 + 289 + function hasRepeatedManaSymbol(manaCost: string): boolean { 290 + const symbols = manaCost.match(/\{[^}]+\}/g) ?? []; 291 + const counts = new Map<string, number>(); 292 + 293 + for (const sym of symbols) { 294 + const normalized = sym.toLowerCase(); 295 + if (normalized === "{x}") continue; 296 + const current = counts.get(normalized) ?? 0; 297 + counts.set(normalized, current + 1); 298 + } 299 + 300 + for (const count of counts.values()) { 301 + if (count > 1) return true; 302 + } 303 + 304 + return false; 305 + } 306 + 307 + function validateKeruga(_companion: Card, ctx: ValidationContext): Violation[] { 308 + const { deck, cardLookup } = ctx; 309 + const violations: Violation[] = []; 310 + const mainboard = getCardsInSection(deck, "mainboard"); 311 + 312 + for (const entry of mainboard) { 313 + const card = cardLookup(entry.scryfallId); 314 + if (!card) continue; 315 + 316 + const typeLine = getTypeLine(card).toLowerCase(); 317 + const isCreatureOrPlaneswalker = 318 + typeLine.includes("creature") || typeLine.includes("planeswalker"); 319 + 320 + if (!isCreatureOrPlaneswalker) continue; 321 + 322 + const mv = card.cmc ?? 0; 323 + if (mv < 3) { 324 + violations.push( 325 + violation( 326 + companionRule, 327 + `Keruga companion requires all creatures and planeswalkers to have mana value 3 or greater, but ${card.name} has mana value ${mv}`, 328 + "error", 329 + { 330 + cardName: card.name, 331 + oracleId: entry.oracleId, 332 + section: "mainboard", 333 + }, 334 + ), 335 + ); 336 + } 337 + } 338 + 339 + return violations; 340 + } 341 + 342 + function validateYorion(_companion: Card, ctx: ValidationContext): Violation[] { 343 + const { deck, config } = ctx; 344 + const mainboard = getCardsInSection(deck, "mainboard"); 345 + const mainboardCount = mainboard.reduce((sum, c) => sum + c.quantity, 0); 346 + 347 + const minDeckSize = config.minDeckSize ?? config.deckSize ?? 60; 348 + const requiredSize = minDeckSize + 20; 349 + 350 + if (mainboardCount < requiredSize) { 351 + return [ 352 + violation( 353 + companionRule, 354 + `Yorion companion requires at least ${requiredSize} cards (20 more than minimum), but deck has ${mainboardCount}`, 355 + "error", 356 + ), 357 + ]; 358 + } 359 + 360 + return []; 361 + } 362 + 363 + function validateZirda(_companion: Card, ctx: ValidationContext): Violation[] { 364 + const { deck, cardLookup } = ctx; 365 + const violations: Violation[] = []; 366 + const mainboard = getCardsInSection(deck, "mainboard"); 367 + 368 + for (const entry of mainboard) { 369 + const card = cardLookup(entry.scryfallId); 370 + if (!card) continue; 371 + 372 + if (!isPermanent(card)) continue; 373 + 374 + const oracleText = getOracleText(card).toLowerCase(); 375 + const hasActivatedAbility = /\{[^}]*\}.*:/.test(oracleText); 376 + 377 + if (!hasActivatedAbility) { 378 + violations.push( 379 + violation( 380 + companionRule, 381 + `Zirda companion requires all permanents to have an activated ability, but ${card.name} does not`, 382 + "error", 383 + { 384 + cardName: card.name, 385 + oracleId: entry.oracleId, 386 + section: "mainboard", 387 + }, 388 + ), 389 + ); 390 + } 391 + } 392 + 393 + return violations; 394 + } 395 + 396 + function validateLutri(_companion: Card, ctx: ValidationContext): Violation[] { 397 + const { deck, cardLookup } = ctx; 398 + const mainboard = getCardsInSection(deck, "mainboard"); 399 + 400 + const namesSeen = new Set<string>(); 401 + const duplicates: string[] = []; 402 + 403 + for (const entry of mainboard) { 404 + const card = cardLookup(entry.scryfallId); 405 + if (!card) continue; 406 + 407 + if (isLand(card)) continue; 408 + 409 + if (entry.quantity > 1) { 410 + duplicates.push(card.name); 411 + continue; 412 + } 413 + 414 + if (namesSeen.has(card.name)) { 415 + duplicates.push(card.name); 416 + } 417 + namesSeen.add(card.name); 418 + } 419 + 420 + if (duplicates.length > 0) { 421 + return [ 422 + violation( 423 + companionRule, 424 + `Lutri companion requires all nonland cards to have different names, but deck has duplicates: ${duplicates.slice(0, 3).join(", ")}${duplicates.length > 3 ? "..." : ""}`, 425 + "error", 426 + ), 427 + ]; 428 + } 429 + 430 + return []; 431 + } 432 + 433 + function isPermanent(card: Card): boolean { 434 + const typeLine = getTypeLine(card).toLowerCase(); 435 + return ( 436 + typeLine.includes("creature") || 437 + typeLine.includes("artifact") || 438 + typeLine.includes("enchantment") || 439 + typeLine.includes("planeswalker") || 440 + typeLine.includes("land") || 441 + typeLine.includes("battle") 442 + ); 443 + } 444 + 445 + function isLand(card: Card): boolean { 446 + const typeLine = getTypeLine(card).toLowerCase(); 447 + return typeLine.includes("land"); 448 + } 449 + 450 + function getTypeLine(card: Card): string { 451 + if (card.type_line) { 452 + return card.type_line; 453 + } 454 + if (card.card_faces) { 455 + return card.card_faces.map((face) => face.type_line ?? "").join(" // "); 456 + } 457 + return ""; 458 + } 459 + 460 + function getOracleText(card: Card): string { 461 + if (card.oracle_text) { 462 + return card.oracle_text; 463 + } 464 + if (card.card_faces) { 465 + return card.card_faces.map((face) => face.oracle_text ?? "").join("\n"); 466 + } 467 + return ""; 468 + }
+5 -1
src/lib/deck-validation/rules/index.ts
··· 21 21 isValidCommanderType, 22 22 signatureSpellRule, 23 23 } from "./commander"; 24 - 24 + export { companionRule } from "./companion"; 25 25 export { commanderUncommonRule } from "./rarity"; 26 26 27 27 import { ··· 47 47 signatureSpellRule, 48 48 } from "./commander"; 49 49 50 + import { companionRule } from "./companion"; 50 51 import { commanderUncommonRule } from "./rarity"; 51 52 52 53 /** ··· 81 82 commanderUncommon: commanderUncommonRule, 82 83 commanderPlaneswalker: commanderPlaneswalkerRule, 83 84 signatureSpell: signatureSpellRule, 85 + 86 + // Companion rule (sideboard) 87 + companion: companionRule, 84 88 } as const; 85 89 86 90 export type RuleId = keyof typeof RULES;