👁️
5
fork

Configure Feed

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

tentative import export framework

+3091 -2
+2 -2
src/components/deck/BulkEditPreview.tsx
··· 73 73 const primaryFace = getPrimaryFace(line.cardData); 74 74 75 75 const bgClass = line.isImperfect 76 - ? "bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/30" 76 + ? "bg-amber-50 dark:bg-amber-900/40 hover:bg-amber-100 dark:hover:bg-amber-900/50" 77 77 : line.isNew 78 - ? "bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30" 78 + ? "bg-green-50 dark:bg-green-900/40 hover:bg-green-100 dark:hover:bg-green-900/50" 79 79 : "hover:bg-gray-100 dark:hover:bg-slate-800"; 80 80 81 81 return (
+148
src/lib/deck-formats/__tests__/detect.test.ts
··· 1 + import { readFileSync } from "node:fs"; 2 + import { join } from "node:path"; 3 + import { describe, expect, it } from "vitest"; 4 + import { detectFormat } from "../detect"; 5 + 6 + const fixturesDir = join(__dirname, "fixtures"); 7 + 8 + function readFixture(subdir: string, filename: string): string { 9 + return readFileSync(join(fixturesDir, subdir, filename), "utf-8"); 10 + } 11 + 12 + describe("detectFormat", () => { 13 + describe("XMage format", () => { 14 + it("detects XMage by [SET:num] pattern before card name", () => { 15 + const text = readFixture("xmage", "uw-miracles.dck"); 16 + expect(detectFormat(text)).toBe("xmage"); 17 + }); 18 + 19 + it("detects XMage even with SB: prefix lines", () => { 20 + const text = readFixture("xmage", "affinity.dck"); 21 + expect(detectFormat(text)).toBe("xmage"); 22 + }); 23 + }); 24 + 25 + describe("Archidekt format", () => { 26 + it("detects Archidekt by inline [Sideboard] and [Commander] markers", () => { 27 + const text = readFixture("archidekt", "txt-with-categories.txt"); 28 + expect(detectFormat(text)).toBe("archidekt"); 29 + }); 30 + 31 + it("detects Archidekt by ^Tag^ color markers", () => { 32 + const text = `1x Sol Ring (cmm) 647 ^Have,#37d67a^`; 33 + expect(detectFormat(text)).toBe("archidekt"); 34 + }); 35 + }); 36 + 37 + describe("MTGGoldfish format", () => { 38 + it("detects MTGGoldfish by [SET] after card name", () => { 39 + const text = readFixture("mtggoldfish", "exact-versions.txt"); 40 + expect(detectFormat(text)).toBe("mtggoldfish"); 41 + }); 42 + 43 + it("detects MTGGoldfish with <variant> markers", () => { 44 + const text = `3 Enduring Curiosity <extended> [DSK] 45 + 4 Tishana's Tidebinder <borderless> [LCI]`; 46 + expect(detectFormat(text)).toBe("mtggoldfish"); 47 + }); 48 + }); 49 + 50 + describe("Deckstats format", () => { 51 + it("detects Deckstats by //Section comments", () => { 52 + const text = readFixture("deckstats", "commander-with-categories.dec"); 53 + expect(detectFormat(text)).toBe("deckstats"); 54 + }); 55 + 56 + it("detects Deckstats by # !Commander marker", () => { 57 + const text = `1 Black Waltz No. 3 # !Commander 58 + 1 Lightning Bolt`; 59 + expect(detectFormat(text)).toBe("deckstats"); 60 + }); 61 + }); 62 + 63 + describe("TappedOut format", () => { 64 + it("detects TappedOut by Nx quantity pattern", () => { 65 + const text = readFixture("tappedout", "arena-export.txt"); 66 + expect(detectFormat(text)).toBe("tappedout"); 67 + }); 68 + 69 + it("detects TappedOut with lowercase x", () => { 70 + const text = `4x Lightning Bolt 71 + 2x Counterspell`; 72 + expect(detectFormat(text)).toBe("tappedout"); 73 + }); 74 + }); 75 + 76 + describe("Moxfield format", () => { 77 + it("detects Moxfield by *F* foil markers", () => { 78 + const text = readFixture("moxfield", "commander-with-foils.txt"); 79 + expect(detectFormat(text)).toBe("moxfield"); 80 + }); 81 + 82 + it("detects Moxfield by #tag patterns (without inline section markers)", () => { 83 + const text = `1 Sol Ring (CMM) 647 #ramp #staple 84 + 1 Lightning Bolt (2XM) 141 #removal`; 85 + expect(detectFormat(text)).toBe("moxfield"); 86 + }); 87 + }); 88 + 89 + describe("Arena format", () => { 90 + it("detects Arena by Deck/Sideboard section headers", () => { 91 + const text = `Deck 92 + 4 Lightning Bolt (2XM) 141 93 + 4 Counterspell (IMA) 52 94 + 95 + Sideboard 96 + 2 Pyroblast (EMA) 142`; 97 + expect(detectFormat(text)).toBe("arena"); 98 + }); 99 + 100 + it("detects Arena by Commander section header", () => { 101 + const text = `Commander 102 + 1 Atraxa, Praetors' Voice (CM2) 10 103 + 104 + Deck 105 + 1 Sol Ring (CMM) 647`; 106 + expect(detectFormat(text)).toBe("arena"); 107 + }); 108 + }); 109 + 110 + describe("Generic format", () => { 111 + it("falls back to generic for plain card lists", () => { 112 + const text = readFixture("mtggoldfish", "simple.txt"); 113 + expect(detectFormat(text)).toBe("generic"); 114 + }); 115 + 116 + it("returns generic for minimal card list", () => { 117 + const text = readFixture("edge-cases", "minimal.txt"); 118 + expect(detectFormat(text)).toBe("generic"); 119 + }); 120 + 121 + it("returns generic for empty input", () => { 122 + expect(detectFormat("")).toBe("generic"); 123 + }); 124 + 125 + it("returns generic for whitespace-only input", () => { 126 + expect(detectFormat(" \n\n ")).toBe("generic"); 127 + }); 128 + }); 129 + 130 + describe("detection priority", () => { 131 + it("prefers XMage over other formats when [SET:num] present", () => { 132 + const text = `4 [2XM:141] Lightning Bolt 133 + SB: 2 [EMA:142] Pyroblast`; 134 + expect(detectFormat(text)).toBe("xmage"); 135 + }); 136 + 137 + it("prefers Archidekt over Moxfield when [Sideboard] markers present", () => { 138 + const text = `1x Sol Ring (CMM) 647 [Sideboard] #ramp`; 139 + expect(detectFormat(text)).toBe("archidekt"); 140 + }); 141 + 142 + it("prefers MTGGoldfish when [SET] appears after name (not before)", () => { 143 + const text = `4 Lightning Bolt [2XM] 144 + 2 Counterspell [IMA]`; 145 + expect(detectFormat(text)).toBe("mtggoldfish"); 146 + }); 147 + }); 148 + });
+95
src/lib/deck-formats/__tests__/fixtures/archidekt/arena-export.txt
··· 1 + 1 Heroic Intervention () 1872 2 + 1 Chocobo Kick () 178 3 + 1 Soul's Majesty () 131 4 + 1 Prishe's Wanderings () 193 5 + 1 Ramunap Excavator () 241 6 + 1 Quirion Beastcaller () 703 7 + 1 Torgal, A Fine Hound () 208 8 + 1 Reclamation Sage (FDN) 231 9 + 1 Warden of the Grove (TDM) 166 10 + 1 Rishkar, Peema Renegade () 708 11 + 1 The Great Henge (CMM) 294 12 + 1 Kodama's Reach (CMM) 300 13 + 1 Mosswort Bridge () 415 14 + 1 Guardian Augmenter (C21) 62 15 + 1 Crop Rotation () 154 16 + 1 Command Beacon (CMR) 349 17 + 1 Thundering Mightmare () 37 18 + 1 Rogue's Passage (CMM) 426 19 + 1 Basilisk Collar () 300 20 + 1 Yavimaya, Cradle of Growth (MH2) 261 21 + 1 Harmonize () C20-173 22 + 1 Invigorate () A25-173 23 + 1 Berserk () 175 24 + 1 Emerald Medallion (CMM) 379 25 + 1 Rishkar's Expertise () 130 26 + 1 Field of Ruin () 400 27 + 1 Hunter's Prowess () 192 28 + 1 Ghost Quarter () CM2-253 29 + 1 Ram Through (CMM) 314 30 + 1 Primal Order () 92 31 + 1 Mithril Coat (LTR) 379 32 + 1 Dryad Arbor () 277 33 + 1 Cultivate (CMM) 889 34 + 1 Snakeskin Veil (CMM) 323 35 + 1 Blanchwood Armor (BRO) 171 36 + 1 Raised by Giants () 250 37 + 1 Witch's Clinic (C21) 81 38 + 1 Viridian Revel (SOM) 132 39 + 1 Sol Ring (LTC) 284 40 + 1 Staff of Titania () 50 41 + 1 Birds of Paradise () 483 42 + 1 Delighted Halfling (LTR) 158 43 + 1 War Room () 310 44 + 1 Windswept Heath (MH3) 466 45 + 1 Bridgeworks Battle (MH3) 249 46 + 1 Terrasymbiosis (EOE) 210 47 + 1 Ozolith, the Shattered Spire (MOM) 198 48 + 1 Tifa Lockhart () 567 49 + 1 Nature's Claim () 47 50 + 1 The Immortal Sun (CMM) 393 51 + 1 Sylvan Library () 179 52 + 18 Forest (ONS) 349 53 + 1 The Earth Crystal () 184 54 + 1 Champion's Helm (CMM) 375 55 + 1 The Ozolith (IKO) 237 56 + 1 Kami of Whispered Hopes (MOM) 196 57 + 1 Ouroboroid (EOE) 201 58 + 1 Defiler of Vigor (DMU) 160 59 + 1 Biophagus () 87 60 + 1 Branching Evolution (MH3) 285 61 + 1 Forgotten Ancient () 304 62 + 1 Inscription of Abundance () 186 63 + 1 Innkeeper's Talent (BLB) 180 64 + 1 Ordeal of Nylea (FDN) 641 65 + 1 Court of Garenbrig () 25 66 + 1 Archdruid's Charm (MKM) 151 67 + 1 Tyvar's Stand (ONE) 190 68 + 1 Wooded Foothills (KTK) 249 69 + 1 Terramorphic Expanse (ECC) 169 70 + 1 Vibrant Cityscape () 188 71 + 1 Tyrite Sanctum (CMM) 1049 72 + 1 Verdant Catacombs (MM3) 249 73 + 1 Fabled Passage (ELD) 244 74 + 1 Shifting Woodland (MH3) 228 75 + 1 Springbloom Druid (MH1) 181 76 + 1 Eternal Witness () 227 77 + 1 Bala Ged Recovery () ZNR-180 78 + 1 Tireless Tracker (EOC) 110 79 + 1 Hardened Scales () 307 80 + 1 Evolution Sage (ECC) 105 81 + 1 Summon: Fenrir () 372 82 + 1 Wood Elves (LTC) 263 83 + 1 Evolution Witness (MH3) 424 84 + 85 + 1 Blossoming Defense () 173 86 + 1 Primal Bellow (ZEN) 176 87 + 1 Alhammarret's Archive (C21) 233 88 + 1 Three Visits (CMM) 913 89 + 1 Cankerbloom (ONE) 294 90 + 1 Arcane Signet (LTC) 273 91 + 1 Rampant Growth (CMM) 908 92 + 1 Ruinous Intrusion (LCC) 255 93 + 1 Caged Sun () 231 94 + 1 Nature's Lore (EOC) 101 95 + 1 Unnatural Restoration (ONE) 191
+128
src/lib/deck-formats/__tests__/fixtures/archidekt/txt-with-categories.txt
··· 1 + 1x Alhammarret's Archive (c21) 233 [Sideboard] ^Cutting Board,#ffff02^ 2 + 1x Alpha Authority (gtc) 114 [Maybeboard{noDeck}{noPrice},Enchantment] ^Have,#37d67a^ 3 + 1x Arcane Signet (ltc) 273 [Sideboard,Artifact] ^Have,#37d67a^ 4 + 1x Archdruid's Charm (mkm) 151 [Instant] ^Have,#37d67a^ 5 + 1x Bala Ged Recovery // Bala Ged Sanctuary (plst) ZNR-180 [Land] ^Have,#37d67a^ 6 + 1x Basilisk Collar (clb) 300 [Artifact] ^Have,#37d67a^ 7 + 1x Beast Whisperer (clu) 158 [Maybeboard{noDeck}{noPrice},Sideboard] 8 + 1x Berserk (cn2) 175 [Instant] ^Have,#37d67a^ 9 + 1x Biophagus (40k) 87 [Creature] ^Have,#37d67a^ 10 + 1x Birds of Paradise (fic) 483 *F* [Creature] ^Have,#37d67a^ 11 + 1x Bitterthorn, Nissa's Animus (moc) 45 [Maybeboard{noDeck}{noPrice}] 12 + 1x Blanchwood Armor (bro) 171 [Enchantment] ^Have,#37d67a^ 13 + 1x Blossoming Defense (scd) 173 [Sideboard,Instant] ^Cutting Board,#ffff02^ 14 + 1x Bone Sabres (40k) 88 [Maybeboard{noDeck}{noPrice},Artifact] ^Don't Have,#f47373^ 15 + 1x Branching Evolution (mh3) 285 [Enchantment] ^Have,#37d67a^ 16 + 1x Bridgeworks Battle // Tanglespan Bridgeworks (mh3) 249 [Land] ^Have,#37d67a^ 17 + 1x Bristly Bill, Spine Sower (otj) 157 [Maybeboard{noDeck}{noPrice},Sideboard] 18 + 1x Caged Sun (40k) 231 [Sideboard,Artifact] ^Cutting Board,#ffff02^ 19 + 1x Cankerbloom (one) 294 *F* [Sideboard,Creature] ^Have,#37d67a^ 20 + 1x Case of the Locked Hothouse (mkm) 155 [Maybeboard{noDeck}{noPrice},Enchantment] 21 + 1x Champion's Helm (cmm) 375 [Artifact] ^Have,#37d67a^ 22 + 1x Chocobo Kick (fin) 178 [Sorcery] ^Have,#37d67a^ 23 + 1x Command Beacon (cmr) 349 [Land] ^Have,#37d67a^ 24 + 1x Court of Garenbrig (woc) 25 [Enchantment] ^Have,#37d67a^ 25 + 1x Crop Rotation (dmr) 154 [Instant] ^Have,#37d67a^ 26 + 1x Cultivate (cmm) 889 [Sorcery] ^Have,#37d67a^ 27 + 1x Cycle of Renewal (tla) 170 [Maybeboard{noDeck}{noPrice},Instant] 28 + 1x Defend the Rider (dft) 157 [Maybeboard{noDeck}{noPrice},Instant] 29 + 1x Defiler of Vigor (dmu) 160 [Creature] ^Have,#37d67a^ 30 + 1x Delighted Halfling (ltr) 158 [Creature] ^Have,#37d67a^ 31 + 1x Dryad Arbor (tsr) 277 [Land] ^Have,#37d67a^ 32 + 1x Emerald Medallion (cmm) 379 [Artifact] ^Have,#37d67a^ 33 + 1x Eternal Witness (m3c) 227 [Creature,Sideboard] 34 + 1x Evolution Sage (ecc) 105 [Creature] ^Have,#37d67a^ 35 + 1x Evolution Witness (mh3) 424 [Creature,Sideboard] 36 + 1x Fabled Passage (eld) 244 [Land] ^Have,#37d67a^ 37 + 1x Farhaven Elf (ltc) 243 [Maybeboard{noDeck}{noPrice},Sideboard] 38 + 1x Field of Ruin (moc) 400 [Land] ^Have,#37d67a^ 39 + 18x Forest (ons) 349 [Land] ^Have,#37d67a^ 40 + 1x Forgotten Ancient (fic) 304 [Creature] ^Have,#37d67a^ 41 + 1x Ghost Quarter (plst) CM2-253 [Land] ^Have,#37d67a^ 42 + 1x Guardian Augmenter (c21) 62 [Creature] ^Have,#37d67a^ 43 + 1x Hardened Scales (fic) 307 [Enchantment] ^Have,#37d67a^ 44 + 1x Harmonize (plst) C20-173 [Sorcery] ^Have,#37d67a^ 45 + 1x Herd Heirloom (tdm) 144 [Maybeboard{noDeck}{noPrice},Sideboard] 46 + 1x Heroic Intervention (sld) 1872 *F* [Instant] ^Have,#37d67a^ 47 + 1x Hunter's Insight (cmm) 296 [Maybeboard{noDeck}{noPrice},Instant] 48 + 1x Hunter's Prowess (scd) 192 [Sorcery] ^Have,#37d67a^ 49 + 1x Innkeeper's Talent (blb) 180 [Enchantment] ^Have,#37d67a^ 50 + 1x Inscription of Abundance (dsc) 186 [Instant] ^Have,#37d67a^ 51 + 1x Invigorate (plst) A25-173 [Instant] ^Have,#37d67a^ 52 + 1x Ivy Lane Denizen (j25) 674 [Maybeboard{noDeck}{noPrice},Sideboard] 53 + 1x Kami of Whispered Hopes (mom) 196 [Creature] ^Have,#37d67a^ 54 + 1x Kodama's Reach (cmm) 300 [Sorcery] ^Have,#37d67a^ 55 + 1x Lifestream's Blessing (fic) 67 [Maybeboard{noDeck}{noPrice},Instant] 56 + 1x Mightform Harmonizer (eoe) 200 [Maybeboard{noDeck}{noPrice}] 57 + 1x Misty Rainforest (mh2) 438 [Maybeboard{noDeck}{noPrice},Land] 58 + 1x Mithril Coat (ltr) 379 [Artifact] ^Have,#37d67a^ 59 + 1x Mosswort Bridge (moc) 415 [Land] ^Have,#37d67a^ 60 + 1x Nature's Claim (fca) 47 [Instant] ^Have,#37d67a^ 61 + 1x Nature's Lore (eoc) 101 [Sideboard,Sorcery] ^Cutting Board,#ffff02^ 62 + 1x Ordeal of Nylea (fdn) 641 [Enchantment] ^Have,#37d67a^ 63 + 1x Origin of Metalbending (tla) 187 [Maybeboard{noDeck}{noPrice},Instant] 64 + 1x Ouroboroid (eoe) 201 [Creature] ^Have,#37d67a^ 65 + 1x Ozolith, the Shattered Spire (mom) 198 [Artifact] ^Have,#37d67a^ 66 + 1x Primal Bellow (zen) 176 [Sideboard,Instant] ^Cutting Board,#ffff02^ 67 + 1x Primal Order (hml) 92 [Enchantment] ^Have,#37d67a^ 68 + 1x Primeval Bounty (fdn) 644 [Maybeboard{noDeck}{noPrice}] 69 + 1x Prishe's Wanderings (fin) 193 [Instant,Maybeboard{noDeck}{noPrice}] ^Have,#37d67a^ 70 + 1x Purestrain Genestealer (40k) 97 [Maybeboard{noDeck}{noPrice},Sideboard] 71 + 1x Quirion Beastcaller (j25) 703 [Creature] 72 + 1x Raised by Giants (clb) 250 [Enchantment] ^Have,#37d67a^ 73 + 1x Ram Through (cmm) 314 [Instant] ^Have,#37d67a^ 74 + 1x Rampant Growth (cmm) 908 [Sideboard,Sorcery] ^Cutting Board,#ffff02^ 75 + 1x Ramunap Excavator (m3c) 241 [Creature] 76 + 1x Reclamation Sage (fdn) 231 [Creature] 77 + 1x Retreat to Kazandu (j25) 707 [Maybeboard{noDeck}{noPrice},Sideboard] 78 + 1x Rhonas's Monument (plst) AKH-236 [Maybeboard{noDeck}{noPrice},Artifact] 79 + 1x Ride the Shoopuf (fin) 197 [Maybeboard{noDeck}{noPrice}] 80 + 1x Rishkar, Peema Renegade (j25) 708 [Creature] 81 + 1x Rishkar's Expertise (woc) 130 [Sorcery] ^Have,#37d67a^ 82 + 1x Roaring Earth (neo) 204 [Maybeboard{noDeck}{noPrice},Sideboard] 83 + 1x Rogue's Passage (cmm) 426 [Land] ^Have,#37d67a^ 84 + 1x Ruinous Intrusion (lcc) 255 [Sideboard,Instant] ^Have,#37d67a^ 85 + 1x Scythecat Cub (j25) 24 [Maybeboard{noDeck}{noPrice}] 86 + 1x Shifting Woodland (mh3) 228 [Land] ^Have,#37d67a^ 87 + 1x Sixth Sense (akh) 187 [Maybeboard{noDeck}{noPrice},Sideboard,Enchantment] 88 + 1x Snakeskin Veil (cmm) 323 [Instant] ^Have,#37d67a^ 89 + 1x Sol Ring (ltc) 284 [Artifact] ^Have,#37d67a^ 90 + 1x Soul's Majesty (nec) 131 [Sorcery] ^Have,#37d67a^ 91 + 1x Springbloom Druid (mh1) 181 [Creature] ^Have,#37d67a^ 92 + 1x Staff of Titania (brc) 50 [Artifact] ^Have,#37d67a^ 93 + 1x Summon: Fenrir (fin) 372 [Creature] 94 + 1x Sutina, Speaker of the Tajuru (j25) 56 [Maybeboard{noDeck}{noPrice},Creature] 95 + 1x Sword of the Animist (fic) 362 [Maybeboard{noDeck}{noPrice}] 96 + 1x Sylvan Library (dmr) 179 [Enchantment] ^Have,#37d67a^ 97 + 1x Sylvan Ranger (woc) 134 [Maybeboard{noDeck}{noPrice},Sideboard] 98 + 1x Sylvan Safekeeper (mh3) 287 [Maybeboard{noDeck}{noPrice},Creature] 99 + 1x Talon Gates of Madara (m3c) 134 [Maybeboard{noDeck}{noPrice},Land] 100 + 1x Terramorphic Expanse (ecc) 169 [Land] ^Have,#37d67a^ 101 + 1x Terrasymbiosis (eoe) 210 [Enchantment] ^Have,#37d67a^ 102 + 1x The Earth Crystal (fin) 184 [Artifact] ^Have,#37d67a^ 103 + 1x The Great Henge (cmm) 294 [Artifact] 104 + 1x The Immortal Sun (cmm) 393 [Artifact] ^Have,#37d67a^ 105 + 1x The Legend of Kyoshi // Avatar Kyoshi (tla) 186 [Maybeboard{noDeck}{noPrice},Enchantment] 106 + 1x The Ozolith (iko) 237 [Artifact] ^Have,#37d67a^ 107 + 1x The Skullspore Nexus (lci) 212 [Maybeboard{noDeck}{noPrice},Artifact] 108 + 1x Three Visits (cmm) 913 [Sideboard,Sorcery] ^Cutting Board,#ffff02^ 109 + 1x Thundering Mightmare (voc) 37 [Creature] ^Have,#37d67a^ 110 + 1x Tifa Lockhart (fin) 567 *F* [Commander{top}] ^Have,#37d67a^ 111 + 1x Tireless Provisioner (j25) 728 [Maybeboard{noDeck}{noPrice},Creature] 112 + 1x Tireless Tracker (eoc) 110 [Creature] ^Have,#37d67a^ 113 + 1x Torgal, A Fine Hound (fin) 208 [Creature] 114 + 1x Traveling Chocobo (fin) 210 [Maybeboard{noDeck}{noPrice},Creature] 115 + 1x Tyrite Sanctum (cmm) 1049 [Land] ^Have,#37d67a^ 116 + 1x Tyvar's Stand (one) 190 [Instant] ^Have,#37d67a^ 117 + 1x Unnatural Restoration (one) 191 [Sideboard,Sorcery] ^Cutting Board,#ffff02^ 118 + 1x Urza's Cave (mh3) 234 [Maybeboard{noDeck}{noPrice},Land] 119 + 1x Verdant Catacombs (mm3) 249 [Land] ^Have,#37d67a^ 120 + 1x Vibrant Cityscape (spm) 188 [Land] ^Have,#37d67a^ 121 + 1x Viridian Revel (som) 132 [Enchantment] ^Have,#37d67a^ 122 + 1x War Room (mkc) 310 [Land] ^Have,#37d67a^ 123 + 1x Warden of the Grove (tdm) 166 [Creature] 124 + 1x Windswept Heath (mh3) 466 *F* [Land] ^Have,#37d67a^ 125 + 1x Witch's Clinic (c21) 81 [Land] ^Have,#37d67a^ 126 + 1x Wood Elves (ltc) 263 [Creature] 127 + 1x Wooded Foothills (ktk) 249 [Land] ^Have,#37d67a^ 128 + 1x Yavimaya, Cradle of Growth (mh2) 261 [Land] ^Have,#37d67a^
+85
src/lib/deck-formats/__tests__/fixtures/arena/pedh-commander.txt
··· 1 + About 2 + Name hamza levered etf 3 + 4 + Commander 5 + 1 Hamza, Guardian of Arashin 6 + 7 + Deck 8 + 1 Alabaster Host Intercessor 9 + 1 Ambitious Dragonborn 10 + 1 Annoyed Altisaur 11 + 1 Arcane Signet 12 + 1 Arcbound Mouser 13 + 1 Arctic Treeline 14 + 1 Ash Barrens 15 + 1 Avacyn's Pilgrim 16 + 1 Basking Broodscale 17 + 1 Bloom Hulk 18 + 1 Blossoming Sands 19 + 1 Botanical Plaza 20 + 1 Captivating Cave 21 + 1 Cave of Temptation 22 + 1 Command Tower 23 + 1 Contagious Vorrac 24 + 1 Crib Swap 25 + 1 Deepwood Denizen 26 + 1 Devoted Druid 27 + 1 Dread Linnorm 28 + 1 Drix Fatemaker 29 + 1 Druidic Ritual 30 + 1 Duskshell Crawler 31 + 1 Elvish Mystic 32 + 1 Etched Cornfield 33 + 1 Evolution Witness 34 + 1 Experiment One 35 + 1 Fertilid 36 + 1 Fierce Empath 37 + 11 Forest 38 + 1 Gnarlid Colony 39 + 1 Grapple with the Past 40 + 1 Guardian Naga 41 + 1 Heritage Reclamation 42 + 1 Idyllic Grange 43 + 1 Ilysian Caryatid 44 + 1 Iron Apprentice 45 + 1 Ivy Elemental 46 + 1 Journey to Nowhere 47 + 1 Khalni Garden 48 + 1 Now for Wrath, Now for Ruin! 49 + 1 Nyxborn Hydra 50 + 1 Owlbear 51 + 6 Plains 52 + 1 Pollenbright Druid 53 + 1 Pridemalkin 54 + 1 Radiant Grove 55 + 1 Ram Through 56 + 1 Rust Goliath 57 + 1 Salt Road Packbeast 58 + 1 Secluded Steppe 59 + 1 Selesnya Signet 60 + 1 Servant of the Scale 61 + 1 Smell Fear 62 + 1 Snakeskin Veil 63 + 1 Spectacular Tactics 64 + 1 Star Pupil 65 + 1 Suburban Sanctuary 66 + 1 Sunshower Druid 67 + 1 Thornglint Bridge 68 + 1 Thraben Charm 69 + 1 Tranquil Expanse 70 + 1 Tranquil Thicket 71 + 1 Travel Preparations 72 + 1 Tuinvale Treefolk 73 + 1 Tuskguard Captain 74 + 1 Weftblade Enhancer 75 + 1 You Meet in a Tavern 76 + 77 + Sideboard 78 + 1 Ainok Bond-Kin 79 + 1 Farseek 80 + 1 Longshot Squad 81 + 1 Shoulder to Shoulder 82 + 1 Thirsting Roots 83 + 1 Titanic Brawl 84 + 1 Weapon Rack 85 + 1 Winding Way
+182
src/lib/deck-formats/__tests__/fixtures/deckstats/commander-with-categories.dec
··· 1 + //NAME: Buuurrrrnnnn from deckstats.net 2 + 3 + //Main 4 + 1 Black Waltz No. 3 # !Commander 5 + 6 + //burn 7 + 1 Agate Instigator 8 + 1 Aria of Flame 9 + 1 Artist's Talent 10 + 1 Ashling, Flame Dancer 11 + 1 Black Mage's Rod 12 + 1 Blisterspit Gremlin 13 + 1 Bloodchief Ascension 14 + 1 Bontu's Monument 15 + 1 Burning Vengeance 16 + 1 Cabal Paladin 17 + 1 Caldera Pyremaw 18 + 1 Cinder Pyromancer 19 + 1 Circle of Power 20 + 1 Cornered by Black Mages 21 + 1 Coruscation Mage 22 + 1 Curse of Fool's Wisdom 23 + 1 Defiler of Instinct 24 + 1 Devoted Duelist 25 + 1 Dynavolt Tower 26 + 1 Electrostatic Field 27 + 1 Erebor Flamesmith 28 + 1 Fiery Inscription 29 + 1 Firebrand Archer 30 + 1 Firespitter Whelp 31 + 1 Geistflame Reservoir 32 + 1 Guttersnipe 33 + 1 Invasion of Regatha // Disciples of the Inferno 34 + 1 Kessig Flamebreather 35 + 1 Kindlespark Duo 36 + 1 Kuja, Genome Sorcerer // Trance Kuja, Fate Defied 37 + 1 Lambholt Raconteur // Lambholt Ravager 38 + 1 Mysidian Elder 39 + 1 Overlord of the Boilerbilges 40 + 1 Professor Onyx 41 + 1 Pyromancer's Assault 42 + 1 Queen Brahne 43 + 1 Rockslide Sorcerer 44 + 1 Rottenmouth Viper 45 + 1 Sawblade Scamp 46 + 1 Sheoldred, the Apocalypse 47 + 1 Shrine of Burning Rage 48 + 1 Sphinx-Bone Wand 49 + 1 Star Athlete 50 + 1 Teapot Slinger 51 + 1 Thermo-Alchemist 52 + 1 Tor Wauki the Younger 53 + 1 Transpose 54 + 1 Urabrask // The Great Work 55 + 1 Vial Smasher the Fierce 56 + 1 Virtue of Courage // Embereth Blaze 57 + 1 Weaver of Lightning 58 + 59 + //Non creature spell 60 + 1 Basilisk Collar 61 + 1 Burst Lightning 62 + 1 Char 63 + 1 Eternal Thirst 64 + 1 Leech Gauntlet 65 + 1 Mask of Griselbrand 66 + 1 Poet's Quill 67 + 1 Resurrection Orb 68 + 1 Shadowspear 69 + 70 + //whenever you cast non creature spell shit 71 + 1 Bothersome Quasit 72 + 1 Burning Prophet 73 + 1 Dragon's Rage Channeler 74 + 1 Fandaniel, Telophoroi Ascian 75 + 1 Garland, Knight of Cornelia // Chaos, the Endless 76 + 1 Harnesser of Storms 77 + 1 Levitating Statue 78 + 1 Manaform Hellkite 79 + 1 Pyroceratops 80 + 1 Rite of the Dragoncaller 81 + 1 Scroll of the Masters 82 + 1 Shambling Cie'th 83 + 1 Spellgorger Weird 84 + 1 Storm-Kiln Artist 85 + 1 Sunbird's Invocation 86 + 1 Vindictive Flamestoker 87 + 88 + //truc cool 89 + 1 Chandra's Incinerator 90 + 1 Harmonic Prodigy 91 + 92 + //land 93 + 1 Lindblum, Industrial Regency // Mage Siege 94 + 95 + //truc rigolo 96 + 1 Arcane Bombardment 97 + 1 Birgi, God of Storytelling // Harnfel, Horn of Bounty 98 + 1 Chandra, Hope's Beacon 99 + 1 Double Vision 100 + 1 Surge to Victory 101 + 1 Terror of the Peaks 102 + 103 + //reduce cost 104 + 1 Primal Amulet // Primal Wellspring 105 + 106 + //draw 107 + 1 Ambition's Cost 108 + 1 Ancient Craving 109 + 1 Bad Deal 110 + 1 Big Score 111 + 1 Bitter Reunion 112 + 1 Black Market Connections 113 + 1 Cathartic Reunion 114 + 1 Cosmos Elixir 115 + 1 Cruel Truths 116 + 1 Crushing Disappointment 117 + 1 Cut of the Profits 118 + 1 Demand Answers 119 + 1 Demon's Due 120 + 1 Diresight 121 + 1 Electric Revelation 122 + 1 Faithless Looting 123 + 1 Font of Mythos 124 + 1 Funeral Rites 125 + 1 Grab the Prize 126 + 1 Greed 127 + 1 Gruesome Realization 128 + 1 Hoarder's Greed 129 + 1 Howling Mine 130 + 1 Imposing Grandeur 131 + 1 Infectious Inquiry 132 + 1 Laughing Mad 133 + 1 Live Fast 134 + 1 Magmatic Insight 135 + 1 Monument to Endurance 136 + 1 Night's Whisper 137 + 1 Path of the Pyromancer 138 + 1 Phyrexian Arena 139 + 1 Pointed Discussion 140 + 1 Promise of Power 141 + 1 Racers' Scoreboard 142 + 1 Read the Bones 143 + 1 Risky Shortcut 144 + 1 Seize the Spoils 145 + 1 Skeletal Scrying 146 + 147 + //gestion 148 + 1 Invoke Despair 149 + 150 + //dmg en plus 151 + 1 Dictate of the Twin Gods 152 + 1 Fiendish Duo 153 + 1 Fiery Emancipation 154 + 1 Ojer Axonil, Deepest Might // Temple of Power 155 + 1 Sawhorn Nemesis 156 + 1 Solphim, Mayhem Dominus 157 + 1 Torbran, Thane of Red Fell 158 + 1 Twinflame Tyrant 159 + 160 + //sort de burn 161 + 1 Boltwave 162 + 1 Breath of Malfegor 163 + 1 Chandra's Ignition 164 + 1 Collective Defiance 165 + 1 Cut // Ribbons 166 + 1 Delayed Blast Fireball 167 + 1 End the Festivities 168 + 1 Farideh's Fireball 169 + 1 Fiery Confluence 170 + 1 Iron Maiden 171 + 1 Misers' Cage 172 + 1 Mob Verdict 173 + 1 Oath of Mages 174 + 1 Price of Knowledge 175 + 1 Sizzle 176 + 1 Skull Rend 177 + 1 Snort 178 + 1 Spiteful Repossession 179 + 1 Tectonic Hazard 180 + 1 The Elder Dragon War 181 + 1 Vicious Rumors 182 + 1 Wheel of Torture
+20
src/lib/deck-formats/__tests__/fixtures/deckstats/generic-simple.txt
··· 1 + Main 2 + 3 Dollmaker's Shop // Porcelain Gallery 3 + 2 Dusk Rose Reliquary 4 + 4 Floodfarm Verge 5 + 4 Island 6 + 2 Lonely Arroyo 7 + 3 Mandible Justiciar 8 + 3 Mendicant Core, Guidelight 9 + 2 Mirrex 10 + 4 Nesting Bot 11 + 3 Perilous Snare 12 + 5 Plains 13 + 2 Restless Anchorage 14 + 4 Seachrome Coast 15 + 4 Surge Engine 16 + 3 Tidus, Blitzball Star 17 + 4 Tinker's Tote 18 + 2 Unctus, Grand Metatect 19 + 3 Urza, Prince of Kroog 20 + 3 Yotian Frontliner
+180
src/lib/deck-formats/__tests__/fixtures/deckstats/generic-txt.txt
··· 1 + Main 2 + 1 Black Waltz No. 3 # !Commander 3 + 4 + burn 5 + 1 Agate Instigator 6 + 1 Aria of Flame 7 + 1 Artist's Talent 8 + 1 Ashling, Flame Dancer 9 + 1 Black Mage's Rod 10 + 1 Blisterspit Gremlin 11 + 1 Bloodchief Ascension 12 + 1 Bontu's Monument 13 + 1 Burning Vengeance 14 + 1 Cabal Paladin 15 + 1 Caldera Pyremaw 16 + 1 Cinder Pyromancer 17 + 1 Circle of Power 18 + 1 Cornered by Black Mages 19 + 1 Coruscation Mage 20 + 1 Curse of Fool's Wisdom 21 + 1 Defiler of Instinct 22 + 1 Devoted Duelist 23 + 1 Dynavolt Tower 24 + 1 Electrostatic Field 25 + 1 Erebor Flamesmith 26 + 1 Fiery Inscription 27 + 1 Firebrand Archer 28 + 1 Firespitter Whelp 29 + 1 Geistflame Reservoir 30 + 1 Guttersnipe 31 + 1 Invasion of Regatha // Disciples of the Inferno 32 + 1 Kessig Flamebreather 33 + 1 Kindlespark Duo 34 + 1 Kuja, Genome Sorcerer // Trance Kuja, Fate Defied 35 + 1 Lambholt Raconteur // Lambholt Ravager 36 + 1 Mysidian Elder 37 + 1 Overlord of the Boilerbilges 38 + 1 Professor Onyx 39 + 1 Pyromancer's Assault 40 + 1 Queen Brahne 41 + 1 Rockslide Sorcerer 42 + 1 Rottenmouth Viper 43 + 1 Sawblade Scamp 44 + 1 Sheoldred, the Apocalypse 45 + 1 Shrine of Burning Rage 46 + 1 Sphinx-Bone Wand 47 + 1 Star Athlete 48 + 1 Teapot Slinger 49 + 1 Thermo-Alchemist 50 + 1 Tor Wauki the Younger 51 + 1 Transpose 52 + 1 Urabrask // The Great Work 53 + 1 Vial Smasher the Fierce 54 + 1 Virtue of Courage // Embereth Blaze 55 + 1 Weaver of Lightning 56 + 57 + Non creature spell 58 + 1 Basilisk Collar 59 + 1 Burst Lightning 60 + 1 Char 61 + 1 Eternal Thirst 62 + 1 Leech Gauntlet 63 + 1 Mask of Griselbrand 64 + 1 Poet's Quill 65 + 1 Resurrection Orb 66 + 1 Shadowspear 67 + 68 + whenever you cast non creature spell shit 69 + 1 Bothersome Quasit 70 + 1 Burning Prophet 71 + 1 Dragon's Rage Channeler 72 + 1 Fandaniel, Telophoroi Ascian 73 + 1 Garland, Knight of Cornelia // Chaos, the Endless 74 + 1 Harnesser of Storms 75 + 1 Levitating Statue 76 + 1 Manaform Hellkite 77 + 1 Pyroceratops 78 + 1 Rite of the Dragoncaller 79 + 1 Scroll of the Masters 80 + 1 Shambling Cie'th 81 + 1 Spellgorger Weird 82 + 1 Storm-Kiln Artist 83 + 1 Sunbird's Invocation 84 + 1 Vindictive Flamestoker 85 + 86 + truc cool 87 + 1 Chandra's Incinerator 88 + 1 Harmonic Prodigy 89 + 90 + land 91 + 1 Lindblum, Industrial Regency // Mage Siege 92 + 93 + truc rigolo 94 + 1 Arcane Bombardment 95 + 1 Birgi, God of Storytelling // Harnfel, Horn of Bounty 96 + 1 Chandra, Hope's Beacon 97 + 1 Double Vision 98 + 1 Surge to Victory 99 + 1 Terror of the Peaks 100 + 101 + reduce cost 102 + 1 Primal Amulet // Primal Wellspring 103 + 104 + draw 105 + 1 Ambition's Cost 106 + 1 Ancient Craving 107 + 1 Bad Deal 108 + 1 Big Score 109 + 1 Bitter Reunion 110 + 1 Black Market Connections 111 + 1 Cathartic Reunion 112 + 1 Cosmos Elixir 113 + 1 Cruel Truths 114 + 1 Crushing Disappointment 115 + 1 Cut of the Profits 116 + 1 Demand Answers 117 + 1 Demon's Due 118 + 1 Diresight 119 + 1 Electric Revelation 120 + 1 Faithless Looting 121 + 1 Font of Mythos 122 + 1 Funeral Rites 123 + 1 Grab the Prize 124 + 1 Greed 125 + 1 Gruesome Realization 126 + 1 Hoarder's Greed 127 + 1 Howling Mine 128 + 1 Imposing Grandeur 129 + 1 Infectious Inquiry 130 + 1 Laughing Mad 131 + 1 Live Fast 132 + 1 Magmatic Insight 133 + 1 Monument to Endurance 134 + 1 Night's Whisper 135 + 1 Path of the Pyromancer 136 + 1 Phyrexian Arena 137 + 1 Pointed Discussion 138 + 1 Promise of Power 139 + 1 Racers' Scoreboard 140 + 1 Read the Bones 141 + 1 Risky Shortcut 142 + 1 Seize the Spoils 143 + 1 Skeletal Scrying 144 + 145 + gestion 146 + 1 Invoke Despair 147 + 148 + dmg en plus 149 + 1 Dictate of the Twin Gods 150 + 1 Fiendish Duo 151 + 1 Fiery Emancipation 152 + 1 Ojer Axonil, Deepest Might // Temple of Power 153 + 1 Sawhorn Nemesis 154 + 1 Solphim, Mayhem Dominus 155 + 1 Torbran, Thane of Red Fell 156 + 1 Twinflame Tyrant 157 + 158 + sort de burn 159 + 1 Boltwave 160 + 1 Breath of Malfegor 161 + 1 Chandra's Ignition 162 + 1 Collective Defiance 163 + 1 Cut // Ribbons 164 + 1 Delayed Blast Fireball 165 + 1 End the Festivities 166 + 1 Farideh's Fireball 167 + 1 Fiery Confluence 168 + 1 Iron Maiden 169 + 1 Misers' Cage 170 + 1 Mob Verdict 171 + 1 Oath of Mages 172 + 1 Price of Knowledge 173 + 1 Sizzle 174 + 1 Skull Rend 175 + 1 Snort 176 + 1 Spiteful Repossession 177 + 1 Tectonic Hazard 178 + 1 The Elder Dragon War 179 + 1 Vicious Rumors 180 + 1 Wheel of Torture
+22
src/lib/deck-formats/__tests__/fixtures/deckstats/simple.dec
··· 1 + //NAME: Artifacts from deckstats.net 2 + 3 + //Main 4 + 3 Dollmaker's Shop // Porcelain Gallery 5 + 2 Dusk Rose Reliquary 6 + 4 Floodfarm Verge 7 + 4 Island 8 + 2 Lonely Arroyo 9 + 3 Mandible Justiciar 10 + 3 Mendicant Core, Guidelight 11 + 2 Mirrex 12 + 4 Nesting Bot 13 + 3 Perilous Snare 14 + 5 Plains 15 + 2 Restless Anchorage 16 + 4 Seachrome Coast 17 + 4 Surge Engine 18 + 3 Tidus, Blitzball Star 19 + 4 Tinker's Tote 20 + 2 Unctus, Grand Metatect 21 + 3 Urza, Prince of Kroog 22 + 3 Yotian Frontliner
+8
src/lib/deck-formats/__tests__/fixtures/edge-cases/collector-numbers.txt
··· 1 + 1 Lightning Bolt (STA) 62★ 2 + 1 Demonic Tutor (STA) 27★ 3 + 1 Lightning Bolt (STA) 62 4 + 1 Plains (UNF) 235a 5 + 1 Plains (UNF) 235b 6 + 1 Forest (UNF) 239a 7 + 1 Sol Ring (CMM) 647 8 + 1 Sol Ring (CMM) 826
+7
src/lib/deck-formats/__tests__/fixtures/edge-cases/duplicate-printings.txt
··· 1 + 1 Lightning Bolt (2XM) 141 2 + 1 Lightning Bolt (M10) 146 3 + 1 Lightning Bolt (M11) 149 4 + 1 Lightning Bolt (STA) 62 5 + 1 Sol Ring (CMM) 647 6 + 1 Sol Ring (CMM) 826 7 + 1 Sol Ring (MPS) 24
+8
src/lib/deck-formats/__tests__/fixtures/edge-cases/empty-lines.txt
··· 1 + 4 Lightning Bolt 2 + 3 + 4 + 2 Counterspell 5 + 6 + 1 Sol Ring 7 + 8 + 4 Brainstorm
+5
src/lib/deck-formats/__tests__/fixtures/edge-cases/minimal.txt
··· 1 + Lightning Bolt 2 + Counterspell 3 + Sol Ring 4 + Brainstorm 5 + Llanowar Elves
+4
src/lib/deck-formats/__tests__/fixtures/edge-cases/mixed-case.txt
··· 1 + 4 Lightning Bolt (2xm) 141 2 + 1 Sol Ring (CMM) 826 3 + 1 counterspell (IMA) 52 4 + 4 BRAINSTORM (EMA) 40
+4
src/lib/deck-formats/__tests__/fixtures/edge-cases/numbers-in-names.txt
··· 1 + 1 1996 World Champion 2 + 4 +2 Mace 3 + 1 2HD 4 + 1 B.O.B. (Bevy of Beebles)
+10
src/lib/deck-formats/__tests__/fixtures/edge-cases/special-chars.txt
··· 1 + 1 Ach! Hans, Run! 2 + 1 Who // What // When // Where // Why 3 + 1 _____ 4 + 1 _____ (UNF) 106 5 + 1 +2 Mace 6 + 1 +2 Mace (AFR) 1 7 + 4 "Ach! Hans, Run!" 8 + 1 Erase (Not the Urza's Legacy One) 9 + 1 Our Market Research Shows That Players Like Really Long Card Names So We Made this Card to Have the Absolute Longest Card Name Ever Elemental 10 + 1 The Ultimate Nightmare of Wizards of the Coast® Customer Service
+6
src/lib/deck-formats/__tests__/fixtures/edge-cases/split-cards.txt
··· 1 + 4 Fire // Ice 2 + 1 Fire // Ice (MH2) 290 3 + 2 Wear // Tear (DGM) 135 4 + 1 Turn // Burn 5 + 3 Colossal Badger / Dig Deep (CLB) 223 6 + 1 Assure // Assemble (GRN) 221
+84
src/lib/deck-formats/__tests__/fixtures/moxfield/bulk-edit-with-tags.txt
··· 1 + 1 Alabaster Host Intercessor (MOM) 3 #!removal 2 + 1 Ambitious Dragonborn (CLB) 213 #payoffs / big creatures 3 + 1 Annoyed Altisaur (2X2) 134 #payoffs / big creatures 4 + 1 Aquastrand Spider (MM2) 140 #!counter creatures 5 + 1 Arbor Elf (LTC) 232 #!dorks 6 + 1 Arcane Signet (FIC) 334 #!ramp 7 + 1 Arcbound Mouser (MH2) 3 #!counter creatures 8 + 1 Arctic Treeline (KHM) 249 9 + 1 Ash Barrens (PIP) 253 10 + 1 Avacyn's Pilgrim (ISD) 170 #!dorks 11 + 1 Basking Broodscale (MH3) 145 12 + 1 Bloom Hulk (WAR) 154 #counters 13 + 1 Blossoming Sands (KTK) 231 14 + 1 Bond Beetle (M13) 161 #!counter creatures 15 + 1 Botanical Plaza (SNC) 247 16 + 1 Captivating Cave (LCI) 268 17 + 1 Cave of Temptation (MH1) 237 18 + 1 Citanul Woodreaders (PLST) DDR-4 #!card advantage and friends 19 + 1 Colossal Badger / Dig Deep (CLB) 223 #!counters #payoffs / big creatures 20 + 1 Command Tower (CMR) 350 21 + 1 Contagious Vorrac (ONE) 164 #counters 22 + 1 Crib Swap (2XM) 12 #!removal 23 + 1 Cytospawn Shambler (DIS) 82 #!counter creatures 24 + 1 Deepwood Denizen (MH2) 155 #!card advantage and friends 25 + 1 Devoted Druid (NCC) 286 #!dorks 26 + 1 Dread Linnorm / Scale Deflection (CLB) 225 #!counters #!payoffs / big creatures #!protection 27 + 1 Drix Fatemaker (EOE) 178 #!counter effects #counters 28 + 1 Druidic Ritual (CLB) 227 #!card advantage and friends 29 + 1 Duskshell Crawler (J25) 653 #counter creatures #!counter creatures #!counter effects 30 + 1 Elvish Mystic (CMM) 284 #!dorks 31 + 1 Etched Cornfield (DSK) 258 32 + 1 Evolution Witness (MH3) 151 33 + 1 Experiment One (2X2) 146 #counter creatures 34 + 1 Fertilid (CMR) 226 #!counter creatures #!ramp 35 + 1 Fierce Empath (M21) 181 #!card advantage and friends 36 + 11 Forest (J25) 93 37 + 1 Forge of Heroes (FIC) 395 38 + 1 Fyndhorn Elves (CMR) 228 #!dorks 39 + 1 Generous Gift (CMM) 624 #!removal 40 + 1 Gnarlid Colony (FDN) 224 #!counter effects 41 + 1 Grapple with the Past (EMN) 160 #!card advantage and friends 42 + 1 Guardian Naga / Banishing Coils (CLB) 23 #payoffs / big creatures #!removal 43 + 1 Heritage Reclamation (TDM) 145 #card advantage and friends #!removal 44 + 1 Idyllic Grange (ELD) 246 45 + 1 Ilysian Caryatid (THB) 174 #!dorks 46 + 1 Iron Apprentice (NEO) 248 #!counter creatures 47 + 1 Ivy Elemental (IMA) 170 #!payoffs / big creatures 48 + 1 Journey to Nowhere (OTP) 3 #!removal 49 + 1 Khalni Garden (PLST) DDR-28 50 + 1 Now for Wrath, Now for Ruin! (LTR) 24 #!counters 51 + 1 Nyxborn Hydra (MH3) 164 #!payoffs / big creatures 52 + 1 Oblivion Ring (M13) 22 #!removal 53 + 1 Opal Palace (CMR) 352 54 + 1 Owlbear (AFR) 331 #!card advantage and friends 55 + 6 Plains (J25) 82 56 + 1 Pollenbright Druid (WAR) 173 #counter creatures #!counter creatures #!counters 57 + 1 Predatory Hunger (EXO) 117 #!counters 58 + 1 Pridemalkin (M21) 196 #counter creatures #!counter effects 59 + 1 Radiant Grove (DMU) 253 60 + 1 Ram Through (PLST) IKO-170 #!removal 61 + 1 Rust Goliath (BRO) 204 #payoffs / big creatures 62 + 1 Salt Road Packbeast (TDM) 23 #!card advantage and friends 63 + 1 Scrounging Bandar (CMR) 252 #!counter creatures 64 + 1 Secluded Steppe (CMR) 491 65 + 1 Selesnya Sanctuary (ZNC) 140 66 + 1 Selesnya Signet (TDC) 324 #!ramp 67 + 1 Servant of the Scale (J22) 727 #!counter creatures 68 + 1 Simic Initiate (DIS) 92 #!counter creatures 69 + 1 Smell Fear (MH2) 173 #!removal 70 + 1 Snakeskin Veil (TDM) 159 #!counters #!protection 71 + 1 Spectacular Tactics (SPM) 15 #!counters #!protection #!removal 72 + 1 Spike Drone (TMP) 258 #!counter creatures 73 + 1 Star Pupil (J25) 259 #!counter creatures 74 + 1 Suburban Sanctuary (SPM) 185 75 + 1 Sunshower Druid (BLB) 195 #!counter creatures 76 + 1 Thornglint Bridge (MH2) 258 77 + 1 Thraben Charm (MH3) 45 #!removal 78 + 1 Tranquil Expanse (C18) 289 79 + 1 Tranquil Thicket (BLC) 350 80 + 1 Travel Preparations (2X2) 162 #!counters 81 + 1 Tuinvale Treefolk / Oaken Boon (ELD) 180 #!counters #!payoffs / big creatures 82 + 1 Tuskguard Captain (CMM) 328 #!counter effects 83 + 1 Weftblade Enhancer (EOE) 44 #!counters 84 + 1 You Meet in a Tavern (CLB) 263 #card advantage and friends
+100
src/lib/deck-formats/__tests__/fixtures/moxfield/cedh-storm.txt
··· 1 + 1 Rograkh, Son of Rohgahh (CMR) 197 2 + 1 Silas Renn, Seeker Adept (PZ2) 51 3 + 1 Ad Nauseam (ALA) 63 4 + 1 Ancient Tomb (UMA) 236 5 + 1 Arcane Signet (NCC) 360 6 + 1 Arid Mesa (ZEN) 211 7 + 1 Badlands (3ED) 282 8 + 1 Beseech the Mirror (WOE) 82 9 + 1 Birgi, God of Storytelling / Harnfel, Horn of Bounty (KHM) 123 10 + 1 Blood Crypt (DIS) 171 11 + 1 Bloodstained Mire (ONS) 313 12 + 1 Borne Upon a Wind (LTR) 44 13 + 1 Brain Freeze (SCG) 29 14 + 1 Cabal Ritual (TOR) 51 15 + 1 Chain of Vapor (ONS) 73 16 + 1 Chrome Mox (MRD) 152 17 + 1 City of Traitors (EXO) 143 18 + 1 Command Tower (CMD) 269 19 + 1 Culling the Weak (EXO) 55 20 + 1 Dark Ritual (MMQ) 129 21 + 1 Daze (NEM) 30 22 + 1 Deadly Rollick (C20) 42 23 + 1 Defense Grid (ULG) 125 24 + 1 Deflecting Swat (C20) 50 25 + 1 Demonic Consultation (ICE) 121 26 + 1 Demonic Counsel (DSK) 92 27 + 1 Demonic Tutor (UMA) 93 28 + 1 Diabolic Intent (PLS) 42 29 + 1 Fierce Guardianship (C20) 35 30 + 1 Final Fortune (7ED) 182 31 + 1 Flare of Duplication (MH3) 119 32 + 1 Flooded Strand (ONS) 316 33 + 1 Flusterstorm (CMD) 46 34 + 1 Force of Negation (MH1) 52 35 + 1 Force of Will (EMA) 49 36 + 1 Gamble (EMA) 132 37 + 1 Gemstone Caverns (TSP) 274 38 + 1 Gitaxian Probe (NPH) 35 39 + 1 Grim Monolith (ULG) 126 40 + 1 Hexing Squelcher (ECL) 145 41 + 1 Imperial Seal (2X2) 79 42 + 1 Infernal Plunge (ISD) 148 43 + 1 Jeska's Will (CMR) 187 44 + 1 Last Chance (DMR) 127 45 + 1 Lion's Eye Diamond (MIR) 307 46 + 1 Lotus Petal (TMP) 294 47 + 1 Mana Vault (UMA) 229 48 + 1 Marsh Flats (ZEN) 219 49 + 1 Mental Misstep (NPH) 38 50 + 1 Mindbreak Trap (ZEN) 57 51 + 1 Mistrise Village (TDM) 261 52 + 1 Misty Rainforest (ZEN) 220 53 + 1 Mnemonic Betrayal (GRN) 189 54 + 1 Mox Amber (DOM) 224 55 + 1 Mox Diamond (STH) 138 56 + 1 Mox Opal (SOM) 179 57 + 1 Mystic Remora (DMR) 59 58 + 1 Mystical Tutor (EMA) 62 59 + 1 Necrodominance (MH3) 411 60 + 1 Necropotence (EMA) 98 61 + 1 Otawara, Soaring City (NEO) 271 62 + 1 Pact of Negation (FUT) 42 63 + 1 Paradise Mantle (5DN) 142 64 + 1 Phyrexian Tower (UMA) 248 65 + 1 Polluted Delta (ONS) 321 66 + 1 Praetor's Grasp (NPH) 71 67 + 1 Pyroblast (EMA) 142 68 + 1 Ragavan, Nimble Pilferer (MH2) 138 69 + 1 Red Elemental Blast (A25) 147 70 + 1 Rhystic Study (PCY) 45 71 + 1 Rite of Flame (CSP) 96 72 + 1 Scalding Tarn (ZEN) 223 73 + 1 Simian Spirit Guide (PLC) 122 74 + 1 Snap (ULG) 43 75 + 1 Sol Ring (C20) 252 76 + 1 Spider-Punk (SPM) 92 77 + 1 Springleaf Drum (LRW) 261 78 + 1 Starting Town (FIN) 289 79 + 1 Steam Vents (GPT) 164 80 + 1 Tainted Pact (ODY) 164 81 + 1 Talisman of Creativity (MH1) 231 82 + 1 Talisman of Dominance (MRD) 253 83 + 1 Talisman of Indulgence (MRD) 255 84 + 1 Thassa's Oracle (THB) 73 85 + 1 Timetwister (2ED) 85 86 + 1 Undercity Sewers (MKM) 270 87 + 1 Underground Sea (3ED) 290 88 + 1 Underworld Breach (PTHB) 161p 89 + 1 Valley Floodcaller (BLB) 79 90 + 1 Vampiric Tutor (EMA) 112 91 + 1 Verdant Catacombs (ZEN) 229 92 + 1 Volcanic Island (3ED) 291 93 + 1 Warrior's Oath (2X2) 130 94 + 1 Watery Grave (RAV) 286 95 + 1 Wheel of Fortune (3ED) 185 96 + 1 Wheel of Misfortune (CMR) 211 97 + 1 Windfall (USG) 111 98 + 1 Wishclaw Talisman (ELD) 110 99 + 1 Wooded Foothills (ONS) 330 100 + 1 Yawgmoth's Will (USG) 171
+98
src/lib/deck-formats/__tests__/fixtures/moxfield/commander-with-foils.txt
··· 1 + 1 Edgar Markov (C17) 36 *F* 2 + 1 Agadeem's Awakening / Agadeem, the Undercrypt (ZNR) 90 3 + 1 Akroma's Will (CMR) 615 4 + 1 Arid Mesa (MM3) 229 5 + 1 Banner of Kinship (FDN) 484 6 + 1 Battlefield Forge (ORI) 244 *F* 7 + 1 Black Market Connections (CLB) 620 8 + 1 Blazemire Verge (PDSK) 256p 9 + 1 Bleachbone Verge (DFT) 250 10 + 1 Blood Artist (LCC) 182 11 + 1 Blood Crypt (RTR) 238 12 + 1 Blood Seeker (DSC) 77 13 + 1 Bloodletter of Aclazotz (LCI) 92 14 + 1 Bloodline Keeper / Lord of Lineage (ISD) 90 15 + 1 Bloodstained Mire (KTK) 230 16 + 1 Bloodthirsty Conqueror (FDN) 58 17 + 1 Captivating Vampire (C17) 104 18 + 1 Castle Dracula (VOW) 403 *F* 19 + 1 Castle Locthwain (ELD) 241 20 + 1 Caves of Koilos (ORI) 245 21 + 1 Cemetery Gatekeeper (VOW) 304 22 + 1 Changeling Outcast (MH1) 82 23 + 1 Charismatic Conqueror (LCC) 70 24 + 1 Command Tower (C17) 242 25 + 1 Cordial Vampire (MH1) 83 26 + 1 Cori Mountain Monastery (TDM) 252 27 + 1 Cover of Darkness (ACR) 163 28 + 1 Creeping Bloodsucker (J25) 415 29 + 1 Crippling Fear (KHM) 82 30 + 1 Cruel Celebrant (WAR) 188 31 + 1 Demonic Tutor (UMA) 93 32 + 1 Despark (WAR) 190 33 + 1 Diabolic Intent (BBD) 141 34 + 1 Dusk Legion Zealot (RIX) 70 35 + 1 Everything Comes to Dust (WHO) 19 36 + 1 Exotic Orchard (C16) 295 37 + 1 Exquisite Blood (AVR) 102 38 + 1 Falkenrath Pit Fighter (MID) 137 39 + 1 Florian, Voldaren Scion (MID) 318 40 + 1 Forerunner of the Legion (RIX) 9 41 + 1 Fracture (STX) 188 *F* 42 + 1 Gifted Aetherborn (SCH) 13 43 + 1 Godless Shrine (GTC) 242 44 + 1 Haunted Ridge (MID) 263 45 + 1 Indulgent Aristocrat (SOI) 118 46 + 1 Insatiable Avarice (OTJ) 325 47 + 1 Ivora, Insatiable Heir (J25) 50 48 + 1 Legion Lieutenant (RIX) 163 49 + 1 Lethal Scheme (LCC) 201 50 + 1 Luxury Suite (BBD) 82 51 + 1 Mana Confluence (JOU) 163 52 + 1 Marauding Blight-Priest (ZNR) 112 53 + 1 Markov Baron (MAT) 14 54 + 1 Marsh Flats (ZEN) 219 55 + 1 Master of Dark Rites (LCC) 51 56 + 1 Midgar, City of Mako / Reactor Raid (FIN) 313 57 + 1 Minas Tirith (LTR) 341 58 + 1 Mountain (VOW) 274 59 + 1 Necropotence (IMA) 98 60 + 1 Night's Whisper (VOC) 133 61 + 1 Olivia's Wrath (VOC) 20 62 + 1 Path to Exile (BLC) 147 63 + 1 Plains (VOW) 268 64 + 1 Preacher of the Schism (LCI) 367 65 + 1 Prismatic Vista (MH1) 244 66 + 1 Reflecting Pool (CNS) 210 67 + 1 Rite of Oblivion (MID) 237 68 + 1 Ruthless Lawbringer (OTJ) 229 69 + 1 Sacred Foundry (GTC) 245 70 + 1 Sanctum Seeker (XLN) 120 71 + 1 Savai Triome (IKO) 312 72 + 1 Shadow Alley Denizen (GTC) 76 73 + 1 Shared Animosity (E02) 29 74 + 1 Shattered Sanctum (VOW) 264 75 + 1 Silent Clearing (MH1) 246 76 + 1 Skullclamp (C17) 222 77 + 1 Sol Ring (C17) 223 78 + 1 Spectator Seating (CMR) 711 79 + 1 Sulfurous Springs (7ED) 345 80 + 1 Sunbaked Canyon (MH1) 247 81 + 1 Sunbillow Verge (DFT) 264 82 + 1 Sundown Pass (VOW) 266 83 + 3 Swamp (VOW) 273 84 + 1 Swords to Plowshares (C17) 76 85 + 1 Takenuma, Abandoned Mire (PNEO) 278p 86 + 1 Taunt from the Rampart (LTC) 151 87 + 1 Three Tree City (BLB) 260 88 + 1 Twilight Prophet (RIX) 88 89 + 1 Urborg, Tomb of Yawgmoth (UMA) 254 90 + 1 Vampire Gourmand (FDN) 74 91 + 1 Vampire Nocturnus (M13) 113 *F* 92 + 1 Vampire of the Dire Moon (M20) 120 93 + 1 Vampire Socialite (MID) 249 94 + 1 Vampiric Tutor (6ED) 161 95 + 1 Vault of Champions (CMR) 715 96 + 1 Vito, Thorn of the Dusk Rose (M21) 127 *F* 97 + 1 Welcoming Vampire (VOW) 287 98 + 1 Winds of Abandon (MH1) 37
+101
src/lib/deck-formats/__tests__/fixtures/moxfield/pedh-with-sideboard.txt
··· 1 + 1 Hamza, Guardian of Arashin (CMM) 339 *F* 2 + 1 Alabaster Host Intercessor (MOM) 3 3 + 1 Ambitious Dragonborn (CLB) 213 4 + 1 Annoyed Altisaur (2X2) 134 5 + 1 Aquastrand Spider (MM2) 140 6 + 1 Arbor Elf (LTC) 232 7 + 1 Arcane Signet (FIC) 334 8 + 1 Arcbound Mouser (MH2) 3 9 + 1 Arctic Treeline (KHM) 249 10 + 1 Ash Barrens (PIP) 253 11 + 1 Avacyn's Pilgrim (ISD) 170 12 + 1 Basking Broodscale (MH3) 145 13 + 1 Bloom Hulk (WAR) 154 14 + 1 Blossoming Sands (KTK) 231 15 + 1 Bond Beetle (M13) 161 16 + 1 Botanical Plaza (SNC) 247 17 + 1 Captivating Cave (LCI) 268 18 + 1 Cave of Temptation (MH1) 237 19 + 1 Citanul Woodreaders (PLST) DDR-4 20 + 1 Colossal Badger / Dig Deep (CLB) 223 21 + 1 Command Tower (CMR) 350 22 + 1 Contagious Vorrac (ONE) 164 23 + 1 Crib Swap (2XM) 12 24 + 1 Cytospawn Shambler (DIS) 82 25 + 1 Deepwood Denizen (MH2) 155 26 + 1 Devoted Druid (NCC) 286 27 + 1 Dread Linnorm / Scale Deflection (CLB) 225 28 + 1 Drix Fatemaker (EOE) 178 29 + 1 Druidic Ritual (CLB) 227 30 + 1 Duskshell Crawler (J25) 653 31 + 1 Elvish Mystic (CMM) 284 32 + 1 Etched Cornfield (DSK) 258 33 + 1 Evolution Witness (MH3) 151 34 + 1 Experiment One (2X2) 146 35 + 1 Fertilid (CMR) 226 36 + 1 Fierce Empath (M21) 181 37 + 11 Forest (J25) 93 38 + 1 Forge of Heroes (FIC) 395 39 + 1 Fyndhorn Elves (CMR) 228 40 + 1 Generous Gift (CMM) 624 41 + 1 Gnarlid Colony (FDN) 224 42 + 1 Grapple with the Past (EMN) 160 43 + 1 Guardian Naga / Banishing Coils (CLB) 23 44 + 1 Heritage Reclamation (TDM) 145 45 + 1 Idyllic Grange (ELD) 246 46 + 1 Ilysian Caryatid (THB) 174 47 + 1 Iron Apprentice (NEO) 248 48 + 1 Ivy Elemental (IMA) 170 49 + 1 Journey to Nowhere (OTP) 3 50 + 1 Khalni Garden (PLST) DDR-28 51 + 1 Now for Wrath, Now for Ruin! (LTR) 24 52 + 1 Nyxborn Hydra (MH3) 164 53 + 1 Oblivion Ring (M13) 22 54 + 1 Opal Palace (CMR) 352 55 + 1 Owlbear (AFR) 331 56 + 6 Plains (J25) 82 57 + 1 Pollenbright Druid (WAR) 173 58 + 1 Predatory Hunger (EXO) 117 59 + 1 Pridemalkin (M21) 196 60 + 1 Radiant Grove (DMU) 253 61 + 1 Ram Through (PLST) IKO-170 62 + 1 Rust Goliath (BRO) 204 63 + 1 Salt Road Packbeast (TDM) 23 64 + 1 Scrounging Bandar (CMR) 252 65 + 1 Secluded Steppe (CMR) 491 66 + 1 Selesnya Sanctuary (ZNC) 140 67 + 1 Selesnya Signet (TDC) 324 68 + 1 Servant of the Scale (J22) 727 69 + 1 Simic Initiate (DIS) 92 70 + 1 Smell Fear (MH2) 173 71 + 1 Snakeskin Veil (TDM) 159 72 + 1 Spectacular Tactics (SPM) 15 73 + 1 Spike Drone (TMP) 258 74 + 1 Star Pupil (J25) 259 75 + 1 Suburban Sanctuary (SPM) 185 76 + 1 Sunshower Druid (BLB) 195 77 + 1 Thornglint Bridge (MH2) 258 78 + 1 Thraben Charm (MH3) 45 79 + 1 Tranquil Expanse (C18) 289 80 + 1 Tranquil Thicket (BLC) 350 81 + 1 Travel Preparations (2X2) 162 82 + 1 Tuinvale Treefolk / Oaken Boon (ELD) 180 83 + 1 Tuskguard Captain (CMM) 328 84 + 1 Weftblade Enhancer (EOE) 44 85 + 1 You Meet in a Tavern (CLB) 263 86 + 87 + SIDEBOARD: 88 + 1 Ainok Bond-Kin (2X2) 5 89 + 1 Farseek (BLC) 119 90 + 1 Forced Adaptation (RVR) 140 91 + 1 Longshot Squad (KTK) 140 92 + 1 Master Chef (CLB) 241 93 + 1 Night Soil (FEM) 71b 94 + 1 Shinewend (MOR) 23 95 + 1 Shoulder to Shoulder (BBD) 105 96 + 1 Sporeback Troll (DIS) 94 97 + 1 Thirsting Roots (ONE) 185 98 + 1 Titanic Brawl (RVR) 158 99 + 1 Unbounded Potential (MH2) 36 100 + 1 Weapon Rack (ELD) 236 101 + 1 Winding Way (PLST) MH1-193
+100
src/lib/deck-formats/__tests__/fixtures/mtggoldfish/commander.txt
··· 1 + 1 Abhorrent Oculus 2 + 1 Adarkar Wastes 3 + 1 Ajani, Nacatl Pariah 4 + 1 Aragorn, King of Gondor 5 + 1 Arena of Glory 6 + 1 Arid Mesa 7 + 1 Bloodstained Mire 8 + 1 Brainstorm 9 + 1 Brazen Borrower 10 + 1 Cathar Commando 11 + 1 Chain Lightning 12 + 1 Command Tower 13 + 1 Counterspell 14 + 1 Daze 15 + 1 Deserted Beach 16 + 1 Eiganjo, Seat of the Empire 17 + 1 Elegant Parlor 18 + 1 Fable of the Mirror-Breaker 19 + 1 Faerie Mastermind 20 + 1 Fear of Missing Out 21 + 1 Flame Slash 22 + 1 Flooded Strand 23 + 1 Floodfarm Verge 24 + 1 Force Spike 25 + 1 Force of Negation 26 + 1 Forth Eorlingas! 27 + 1 Fury 28 + 1 Galvanic Discharge 29 + 1 Gitaxian Probe 30 + 1 Giver of Runes 31 + 1 Glacial Fortress 32 + 1 Hallowed Fountain 33 + 1 Hullbreacher 34 + 2 Island 35 + 1 Lightning Bolt 36 + 1 Lightning Greaves 37 + 1 Lorien Revealed 38 + 1 Lose Focus 39 + 1 Malcolm, Alluring Scoundrel 40 + 1 Mana Confluence 41 + 1 Mana Leak 42 + 1 Mana Tithe 43 + 1 Marsh Flats 44 + 1 Memory Lapse 45 + 1 Mental Misstep 46 + 1 Meticulous Archive 47 + 1 Miscalculation 48 + 1 Misty Rainforest 49 + 1 Mother of Runes 50 + 1 Mountain 51 + 1 Murktide Regent 52 + 1 No More Lies 53 + 1 Ocelot Pride 54 + 1 Otawara, Soaring City 55 + 1 Oust 56 + 1 Parallax Wave 57 + 1 Phelia, Exuberant Shepherd 58 + 1 Phlage, Titan of Fire's Fury 59 + 1 Pippin, Guard of the Citadel 60 + 1 Plains 61 + 1 Plateau 62 + 1 Polluted Delta 63 + 1 Prismatic Ending 64 + 1 Prismatic Vista 65 + 1 Pyrogoyf 66 + 1 Razorgrass Ambush 67 + 1 Reflecting Pool 68 + 1 Remand 69 + 1 Reprieve 70 + 1 Restless Anchorage 71 + 1 Riverpyre Verge 72 + 1 Sacred Foundry 73 + 1 Scalding Tarn 74 + 1 Sink into Stupor 75 + 1 Skyclave Apparition 76 + 1 Snapcaster Mage 77 + 1 Solitude 78 + 1 Spectacular Spider-Man 79 + 1 Spell Snare 80 + 1 Steam Vents 81 + 1 Stormcarved Coast 82 + 1 Subtlety 83 + 1 Sulfur Falls 84 + 1 Sunbillow Verge 85 + 1 Swords to Plowshares 86 + 1 Tale's End 87 + 1 Teferi, Time Raveler 88 + 1 The Wandering Emperor 89 + 1 Thundering Falls 90 + 1 True-Name Nemesis 91 + 1 Tundra 92 + 1 Unholy Heat 93 + 1 Vendilion Clique 94 + 1 Voice of Victory 95 + 1 Volcanic Island 96 + 1 Wan Shi Tong, Librarian 97 + 1 Wash Away 98 + 1 Windswept Heath 99 + 1 Wooded Foothills 100 +
+35
src/lib/deck-formats/__tests__/fixtures/mtggoldfish/exact-versions.txt
··· 1 + 2 Bitter Triumph [TDC] 2 + 2 Cecil, Dark Knight [FIN] 3 + 1 Day of Black Sun [TLA] 4 + 4 Deep-Cavern Bat [LCI] 5 + 3 Enduring Curiosity <extended> [DSK] 6 + 4 Floodpits Drowner [DSK] 7 + 1 Fountainport [BLB] 8 + 4 Gloomlake Verge [DSK] 9 + 1 Heartless Act [TLA] 10 + 4 Island <251> [THB] 11 + 4 Kaito, Bane of Nightmares [DSK] 12 + 3 Multiversal Passage <borderless> [SPM] 13 + 1 Phantom Interference [OTJ] 14 + 2 Preacher of the Schism [LCI] 15 + 2 Restless Reef [LCI] 16 + 1 Shoot the Sheriff [OTJ] 17 + 2 Soulstone Sanctuary [FDN] 18 + 4 Spyglass Siren [LCI] 19 + 1 Stab [FDN] 20 + 5 Swamp <252> [THB] 21 + 4 Tishana's Tidebinder <borderless> [LCI] 22 + 1 Tragic Trajectory [EOE] 23 + 4 Watery Grave [EOE] 24 + 25 + 2 Annul [KHM] 26 + 1 Day of Black Sun [TLA] 27 + 2 Duress [DMR] 28 + 1 Essence Scatter [DMU] 29 + 1 Ghost Vacuum [DSK] 30 + 1 Negate [AER] 31 + 1 Soul-Guide Lantern [EOC] 32 + 1 Spell Pierce [2X2] 33 + 2 Stab [FDN] 34 + 2 Strategic Betrayal [TDM] 35 + 1 The Unagi of Kyoshi Island [TLA]
+35
src/lib/deck-formats/__tests__/fixtures/mtggoldfish/simple.txt
··· 1 + 2 Bitter Triumph 2 + 2 Cecil, Dark Knight 3 + 1 Day of Black Sun 4 + 4 Deep-Cavern Bat 5 + 3 Enduring Curiosity 6 + 4 Floodpits Drowner 7 + 1 Fountainport 8 + 4 Gloomlake Verge 9 + 1 Heartless Act 10 + 4 Island 11 + 4 Kaito, Bane of Nightmares 12 + 3 Multiversal Passage 13 + 1 Phantom Interference 14 + 2 Preacher of the Schism 15 + 2 Restless Reef 16 + 1 Shoot the Sheriff 17 + 2 Soulstone Sanctuary 18 + 4 Spyglass Siren 19 + 1 Stab 20 + 5 Swamp 21 + 4 Tishana's Tidebinder 22 + 1 Tragic Trajectory 23 + 4 Watery Grave 24 + 25 + 2 Annul 26 + 1 Day of Black Sun 27 + 2 Duress 28 + 1 Essence Scatter 29 + 1 Ghost Vacuum 30 + 1 Negate 31 + 1 Soul-Guide Lantern 32 + 1 Spell Pierce 33 + 2 Stab 34 + 2 Strategic Betrayal 35 + 1 The Unagi of Kyoshi Island
+33
src/lib/deck-formats/__tests__/fixtures/tappedout/arena-export.txt
··· 1 + About 2 + Name Scoops 5: This Is The Part Where I Kill You 3 + 4 + 5 + Deck 6 + 3x Abundant Growth (ECC) 97 7 + 1x Breeding Pool (EOE) 251 8 + 2x Charming Prince (FDN) 568 9 + 3x Coiling Oracle (BLC) 250 10 + 4x Ephemerate (MAR) 44 11 + 2x Eternal Witness (M3C) 227 12 + 2x Evolving Wilds (ECL) 264 13 + 1x Fabled Passage (TLE) 57 14 + 4x Flare of Denial (MH3) 62 15 + 1x Flickerwisp (2X2) 11 16 + 2x Forest (TMT) 257 17 + 4x Island (TMT) 254 18 + 1x Overgrown Farmland (TDC) 381 19 + 2x Overlord of the Floodpits (DSK) 68 20 + 2x Overlord of the Hauntwoods (DSK) 194 21 + 2x Overlord of the Mistmoors (DSK) 23 22 + 3x Path to Exile (ECC) 65 23 + 4x Phelia, Exuberant Shepherd (MH3) 40 24 + 4x Plains (TMT) 253 25 + 1x Port Town (FIC) 412 26 + 1x Prairie Stream (FIC) 413 27 + 3x Soulherder (H1R) 30 28 + 2x Starfield Vocalist (EOE) 78 29 + 1x Temple Garden (ECL) 268 30 + 3x Watcher for Tomorrow (MH1) 76 31 + 2x Witch Enchanter (MH3) 239 32 + 33 +
+28
src/lib/deck-formats/__tests__/fixtures/tappedout/simple.txt
··· 1 + 3 Abundant Growth 2 + 1 Breeding Pool 3 + 2 Charming Prince 4 + 3 Coiling Oracle 5 + 4 Ephemerate 6 + 2 Eternal Witness 7 + 2 Evolving Wilds 8 + 1 Fabled Passage 9 + 4 Flare of Denial 10 + 1 Flickerwisp 11 + 2 Forest 12 + 4 Island 13 + 1 Overgrown Farmland 14 + 2 Overlord of the Floodpits 15 + 2 Overlord of the Hauntwoods 16 + 2 Overlord of the Mistmoors 17 + 3 Path to Exile 18 + 4 Phelia, Exuberant Shepherd 19 + 4 Plains 20 + 1 Port Town 21 + 1 Prairie Stream 22 + 3 Soulherder 23 + 2 Starfield Vocalist 24 + 1 Temple Garden 25 + 3 Watcher for Tomorrow 26 + 2 Witch Enchanter 27 + 28 +
+27
src/lib/deck-formats/__tests__/fixtures/xmage/affinity.dck
··· 1 + NAME:[MOD] Big Affinity 2 + 1 [RTR:231] Pithing Needle 3 + 3 [ORI:79] Thopter Spy Network 4 + 4 [M15:242] Darksteel Citadel 5 + 4 [DD3D:32] Island 6 + 4 [MBS:145] Inkmoth Nexus 7 + 3 [SOM:179] Mox Opal 8 + 4 [DST:163] Blinkmoth Nexus 9 + 4 [NPH:76] Vault Skirge 10 + 4 [SOM:154] Etched Champion 11 + 4 [MMA:198] Arcbound Ravager 12 + 2 [SOM:48] Trinket Mage 13 + 1 [ORI:229] Hangarback Walker 14 + 4 [5DN:139] Myr Servitor 15 + 4 [MRD:253] Talisman of Dominance 16 + 1 [WWK:122] Basilisk Collar 17 + 3 [5DN:113] Cranial Plating 18 + 3 [SOM:223] Wurmcoil Engine 19 + 3 [MRD:256] Talisman of Progress 20 + 4 [MRD:281] Glimmervoid 21 + SB: 4 [NPH:159] Spellskite 22 + SB: 1 [DKA:149] Grafdigger's Cage 23 + SB: 2 [NPH:57] Dismember 24 + SB: 1 [MMA:64] Spell Snare 25 + SB: 3 [MRD:150] Chalice of the Void 26 + SB: 2 [NPH:86] Gut Shot 27 + SB: 2 [ALA:218] Relic of Progenitus
+27
src/lib/deck-formats/__tests__/fixtures/xmage/rg-wildfire.dck
··· 1 + NAME:[MOD] R/G Wildfire 2 + 4 [BNG:157] Astral Cornucopia 3 + 4 [ROE:174] Ancient Stirrings 4 + 4 [SOM:179] Mox Opal 5 + 4 [DST:164] Darksteel Citadel 6 + 1 [ARENA:79] Forest 7 + 4 [WWK:127] Lodestone Golem 8 + 4 [9ED:228] Wildfire 9 + 4 [MM2:212] Everflowing Chalice 10 + 1 [FUT:176] Grove of the Burnwillows 11 + 1 [NPH:47] Tezzeret's Gambit 12 + 4 [SOM:225] Copperline Gorge 13 + 4 [BOK:165] Tendo Ice Bridge 14 + 4 [NPH:160] Surge Node 15 + 4 [MRD:150] Chalice of the Void 16 + 4 [DST:107] Coretapper 17 + 4 [MMA:223] Glimmervoid 18 + 2 [SOM:223] Wurmcoil Engine 19 + 3 [SOM:205] Steel Hellkite 20 + SB: 3 [MRD:249] Sun Droplet 21 + SB: 2 [NPH:159] Spellskite 22 + SB: 1 [UGIN:1] Ugin, the Spirit Dragon 23 + SB: 1 [ORI:148] Ghirapur AEther Grid 24 + SB: 2 [ISD:127] Ancient Grudge 25 + SB: 1 [BNG:143] Unravel the AEther 26 + SB: 3 [MMA:213] Relic of Progenitus 27 + SB: 2 [8ED:210] Pyroclasm
+30
src/lib/deck-formats/__tests__/fixtures/xmage/uw-miracles.dck
··· 1 + NAME:[MOD] UW Miracles 2 + 2 [FUT:52] Logic Knot 3 + 1 [RAV:287] Plains 4 + 2 [ROE:59] Deprive 5 + 3 [M11:225] Glacial Fortress 6 + 2 [AVR:20] Entreat the Angels 7 + 4 [5DN:36] Serum Visions 8 + 4 [ZEN:220] Misty Rainforest 9 + 3 [RTR:241] Hallowed Fountain 10 + 4 [RAV:292] Island 11 + 4 [KTK:233] Flooded Strand 12 + 4 [ROE:86] See Beyond 13 + 3 [MMA:197] Aether Vial 14 + 4 [ISD:78] Snapcaster Mage 15 + 4 [CON:15] Path to Exile 16 + 4 [M14:228] Mutavault 17 + 4 [M11:33] Squadron Hawk 18 + 4 [AVR:38] Terminus 19 + 2 [BFZ:29] Gideon, Ally of Zendikar 20 + 2 [ORI:60] Jace, Vryn's Prodigy 21 + SB: 1 [RTR:231] Pithing Needle 22 + SB: 2 [MMA:213] Relic of Progenitus 23 + SB: 2 [ISD:36] Stony Silence 24 + SB: 2 [EMN:13] Blessed Alliance 25 + SB: 2 [ZEN:70] Spreading Seas 26 + SB: 2 [KLD:40] Ceremonious Rejection 27 + SB: 2 [M11:9] Celestial Purge 28 + SB: 2 [10E:88] Hurkyl's Recall 29 + LAYOUT MAIN:(1,6)(CMC,false,50)|([KTK:233],[KTK:233],[KTK:233],[KTK:233],[M11:225],[M11:225],[M11:225],[RTR:241],[RTR:241],[RTR:241],[RAV:292],[RAV:292],[RAV:292],[RAV:292],[ZEN:220],[ZEN:220],[ZEN:220],[ZEN:220],[M14:228],[M14:228],[M14:228],[M14:228],[RAV:287])([CON:15],[CON:15],[CON:15],[CON:15],[MMA:197],[MMA:197],[MMA:197],[5DN:36],[5DN:36],[5DN:36],[5DN:36])([ORI:60],[ORI:60],[ROE:86],[ROE:86],[ROE:86],[ROE:86],[ISD:78],[ISD:78],[ISD:78],[ISD:78],[M11:33],[M11:33],[M11:33],[M11:33],[ROE:59],[ROE:59],[FUT:52],[FUT:52])([AVR:20],[AVR:20])([BFZ:29],[BFZ:29])([AVR:38],[AVR:38],[AVR:38],[AVR:38]) 30 + LAYOUT SIDEBOARD:(1,1)(NONE,false,50)|([EMN:13],[EMN:13],[M11:9],[M11:9],[KLD:40],[KLD:40],[10E:88],[10E:88],[RTR:231],[MMA:213],[MMA:213],[ZEN:70],[ZEN:70],[ISD:36],[ISD:36])
+677
src/lib/deck-formats/__tests__/parse.test.ts
··· 1 + import { readFileSync } from "node:fs"; 2 + import { join } from "node:path"; 3 + import { describe, expect, it } from "vitest"; 4 + import { parseCardLine, parseDeck } from "../parse"; 5 + 6 + const fixturesDir = join(__dirname, "fixtures"); 7 + 8 + function readFixture(subdir: string, filename: string): string { 9 + return readFileSync(join(fixturesDir, subdir, filename), "utf-8"); 10 + } 11 + 12 + describe("parseCardLine", () => { 13 + describe("basic parsing", () => { 14 + it("parses quantity and name", () => { 15 + const result = parseCardLine("4 Lightning Bolt"); 16 + expect(result).toEqual({ 17 + quantity: 4, 18 + name: "Lightning Bolt", 19 + tags: [], 20 + raw: "4 Lightning Bolt", 21 + }); 22 + }); 23 + 24 + it("defaults quantity to 1 when not specified", () => { 25 + const result = parseCardLine("Sol Ring"); 26 + expect(result).toEqual({ 27 + quantity: 1, 28 + name: "Sol Ring", 29 + tags: [], 30 + raw: "Sol Ring", 31 + }); 32 + }); 33 + 34 + it("returns null for empty lines", () => { 35 + expect(parseCardLine("")).toBeNull(); 36 + expect(parseCardLine(" ")).toBeNull(); 37 + }); 38 + }); 39 + 40 + describe("Arena format: (SET) number", () => { 41 + it("parses set code in parentheses", () => { 42 + const result = parseCardLine("1 Lightning Bolt (2XM)"); 43 + expect(result?.setCode).toBe("2XM"); 44 + expect(result?.name).toBe("Lightning Bolt"); 45 + }); 46 + 47 + it("parses set code and collector number", () => { 48 + const result = parseCardLine("4 Lightning Bolt (2XM) 141"); 49 + expect(result?.setCode).toBe("2XM"); 50 + expect(result?.collectorNumber).toBe("141"); 51 + }); 52 + 53 + it("normalizes set code to uppercase", () => { 54 + const result = parseCardLine("1 Lightning Bolt (2xm) 141"); 55 + expect(result?.setCode).toBe("2XM"); 56 + }); 57 + }); 58 + 59 + describe("MTGGoldfish format: [SET] after name", () => { 60 + it("parses set code in square brackets", () => { 61 + const result = parseCardLine("4 Lightning Bolt [2XM]"); 62 + expect(result?.setCode).toBe("2XM"); 63 + expect(result?.name).toBe("Lightning Bolt"); 64 + }); 65 + 66 + it("parses with <variant> marker", () => { 67 + const result = parseCardLine("3 Enduring Curiosity <extended> [DSK]"); 68 + expect(result?.setCode).toBe("DSK"); 69 + expect(result?.name).toBe("Enduring Curiosity"); 70 + }); 71 + 72 + it("parses collector number in angle brackets", () => { 73 + const result = parseCardLine("4 Island <251> [THB]"); 74 + expect(result?.setCode).toBe("THB"); 75 + expect(result?.collectorNumber).toBe("251"); 76 + expect(result?.name).toBe("Island"); 77 + }); 78 + }); 79 + 80 + describe("XMage format: [SET:num] before name", () => { 81 + it("parses set and collector number before name", () => { 82 + const result = parseCardLine("4 [2XM:141] Lightning Bolt"); 83 + expect(result?.setCode).toBe("2XM"); 84 + expect(result?.collectorNumber).toBe("141"); 85 + expect(result?.name).toBe("Lightning Bolt"); 86 + }); 87 + 88 + it("parses set without collector number", () => { 89 + const result = parseCardLine("4 [ZEN] Misty Rainforest"); 90 + expect(result?.setCode).toBe("ZEN"); 91 + expect(result?.collectorNumber).toBeUndefined(); 92 + expect(result?.name).toBe("Misty Rainforest"); 93 + }); 94 + }); 95 + 96 + describe("TappedOut format: Nx quantity", () => { 97 + it("parses quantity with x suffix", () => { 98 + const result = parseCardLine("4x Lightning Bolt"); 99 + expect(result?.quantity).toBe(4); 100 + expect(result?.name).toBe("Lightning Bolt"); 101 + }); 102 + 103 + it("parses with set code", () => { 104 + const result = parseCardLine("3x Abundant Growth (ECC) 97"); 105 + expect(result?.quantity).toBe(3); 106 + expect(result?.setCode).toBe("ECC"); 107 + expect(result?.collectorNumber).toBe("97"); 108 + }); 109 + }); 110 + 111 + describe("Moxfield format: tags and foil markers", () => { 112 + it("parses #tags", () => { 113 + const result = parseCardLine("1 Sol Ring (CMM) 647 #ramp #staple"); 114 + expect(result?.tags).toEqual(["ramp", "staple"]); 115 + }); 116 + 117 + it("strips *F* foil marker", () => { 118 + const result = parseCardLine("1 Edgar Markov (C17) 36 *F*"); 119 + expect(result?.name).toBe("Edgar Markov"); 120 + expect(result?.setCode).toBe("C17"); 121 + }); 122 + 123 + it("strips *A* alter marker", () => { 124 + const result = parseCardLine("1 Sol Ring (CMM) 647 *F* *A* #ramp"); 125 + expect(result?.tags).toEqual(["ramp"]); 126 + }); 127 + 128 + it("handles #! global tag prefix", () => { 129 + const result = parseCardLine("1 Sol Ring #!staple #ramp"); 130 + expect(result?.tags).toEqual(["staple", "ramp"]); 131 + }); 132 + }); 133 + 134 + describe("Archidekt format: extras stripped", () => { 135 + it("strips ^Tag^ color markers", () => { 136 + const result = parseCardLine("1x Sol Ring (cmm) 647 ^Have,#37d67a^"); 137 + expect(result?.name).toBe("Sol Ring"); 138 + expect(result?.setCode).toBe("CMM"); 139 + }); 140 + }); 141 + 142 + describe("split cards", () => { 143 + it("parses Fire // Ice", () => { 144 + const result = parseCardLine("4 Fire // Ice (MH2) 290"); 145 + expect(result?.name).toBe("Fire // Ice"); 146 + expect(result?.setCode).toBe("MH2"); 147 + }); 148 + 149 + it("parses adventure cards with /", () => { 150 + const result = parseCardLine( 151 + "1 Agadeem's Awakening / Agadeem, the Undercrypt (ZNR) 90", 152 + ); 153 + expect(result?.name).toBe( 154 + "Agadeem's Awakening / Agadeem, the Undercrypt", 155 + ); 156 + }); 157 + }); 158 + 159 + describe("special characters", () => { 160 + it("parses cards with punctuation", () => { 161 + const result = parseCardLine("1 Ach! Hans, Run!"); 162 + expect(result?.name).toBe("Ach! Hans, Run!"); 163 + }); 164 + 165 + it("parses cards with + in name", () => { 166 + const result = parseCardLine("4 +2 Mace (AFR) 1"); 167 + expect(result?.name).toBe("+2 Mace"); 168 + }); 169 + 170 + it("parses special collector numbers", () => { 171 + const result = parseCardLine("1 Lightning Bolt (STA) 62★"); 172 + expect(result?.collectorNumber).toBe("62★"); 173 + }); 174 + 175 + it("parses collector numbers with letters", () => { 176 + const result = parseCardLine("1 Blazemire Verge (PDSK) 256p"); 177 + expect(result?.collectorNumber).toBe("256p"); 178 + }); 179 + }); 180 + }); 181 + 182 + describe("parseDeck", () => { 183 + describe("section handling", () => { 184 + it("parses Arena format with Deck/Sideboard headers", () => { 185 + const text = `Deck 186 + 4 Lightning Bolt (2XM) 141 187 + 4 Counterspell (IMA) 52 188 + 189 + Sideboard 190 + 2 Pyroblast (EMA) 142`; 191 + 192 + const result = parseDeck(text); 193 + expect(result.mainboard).toHaveLength(2); 194 + expect(result.sideboard).toHaveLength(1); 195 + expect(result.mainboard[0].name).toBe("Lightning Bolt"); 196 + expect(result.sideboard[0].name).toBe("Pyroblast"); 197 + }); 198 + 199 + it("parses Commander section", () => { 200 + const text = `Commander 201 + 1 Atraxa (CM2) 10 202 + 203 + Deck 204 + 1 Sol Ring`; 205 + 206 + const result = parseDeck(text); 207 + expect(result.commander).toHaveLength(1); 208 + expect(result.mainboard).toHaveLength(1); 209 + }); 210 + 211 + it("treats Companion as sideboard", () => { 212 + const text = `Companion 213 + 1 Lurrus (IKO) 226 214 + 215 + Deck 216 + 4 Card`; 217 + 218 + const result = parseDeck(text); 219 + expect(result.sideboard).toHaveLength(1); 220 + expect(result.sideboard[0].name).toBe("Lurrus"); 221 + }); 222 + 223 + it("parses full Arena export with Commander/Deck/Sideboard", () => { 224 + const text = readFixture("arena", "pedh-commander.txt"); 225 + const result = parseDeck(text); 226 + 227 + const countCards = (cards: { quantity: number }[]) => 228 + cards.reduce((sum, c) => sum + c.quantity, 0); 229 + 230 + // Commander, Deck, and Sideboard sections all present 231 + expect(countCards(result.commander)).toBe(1); 232 + expect(result.commander[0].name).toBe("Hamza, Guardian of Arashin"); 233 + 234 + // Mainboard includes 11 Forest + 6 Plains 235 + expect(countCards(result.mainboard)).toBe(83); 236 + expect(countCards(result.sideboard)).toBe(8); 237 + 238 + // Check specific cards - Arena format has no set codes 239 + const forest = result.mainboard.find((c) => c.name === "Forest"); 240 + expect(forest?.quantity).toBe(11); 241 + expect(forest?.setCode).toBeUndefined(); 242 + 243 + // Sideboard card 244 + const farseek = result.sideboard.find((c) => c.name === "Farseek"); 245 + expect(farseek).toBeDefined(); 246 + }); 247 + }); 248 + 249 + describe("XMage format", () => { 250 + it("parses XMage deck with SB: prefix", () => { 251 + const text = readFixture("xmage", "uw-miracles.dck"); 252 + const result = parseDeck(text); 253 + 254 + const countCards = (cards: { quantity: number }[]) => 255 + cards.reduce((sum, c) => sum + c.quantity, 0); 256 + 257 + // 19 entries mainboard, 8 entries sideboard 258 + expect(result.mainboard).toHaveLength(19); 259 + expect(result.sideboard).toHaveLength(8); 260 + // Total card counts 261 + expect(countCards(result.mainboard)).toBe(60); 262 + expect(countCards(result.sideboard)).toBe(15); 263 + expect(result.name).toBe("[MOD] UW Miracles"); 264 + 265 + // Check specific cards have correct set/collector from [SET:num] format 266 + const logicKnot = result.mainboard.find((c) => c.name === "Logic Knot"); 267 + expect(logicKnot).toBeDefined(); 268 + expect(logicKnot?.quantity).toBe(2); 269 + expect(logicKnot?.setCode).toBe("FUT"); 270 + expect(logicKnot?.collectorNumber).toBe("52"); 271 + 272 + // Check sideboard card 273 + const relic = result.sideboard.find((c) => 274 + c.name.includes("Relic of Progenitus"), 275 + ); 276 + expect(relic).toBeDefined(); 277 + expect(relic?.setCode).toBe("MMA"); 278 + }); 279 + }); 280 + 281 + describe("Moxfield format", () => { 282 + it("parses Moxfield commander deck with foils", () => { 283 + const text = readFixture("moxfield", "commander-with-foils.txt"); 284 + const result = parseDeck(text); 285 + 286 + // 98 entries, no Commander section header so all mainboard 287 + expect(result.mainboard).toHaveLength(98); 288 + expect(result.commander).toHaveLength(0); 289 + 290 + // Verify total card count (some entries have quantity > 1) 291 + const totalCards = result.mainboard.reduce( 292 + (sum, c) => sum + c.quantity, 293 + 0, 294 + ); 295 + expect(totalCards).toBe(100); 296 + 297 + // Check *F* marker stripped from card name 298 + const edgar = result.mainboard.find((c) => 299 + c.name.includes("Edgar Markov"), 300 + ); 301 + expect(edgar).toBeDefined(); 302 + expect(edgar?.name).toBe("Edgar Markov"); 303 + expect(edgar?.setCode).toBe("C17"); 304 + expect(edgar?.collectorNumber).toBe("36"); 305 + 306 + // Check MDFC preserves full name 307 + const agadeem = result.mainboard.find((c) => 308 + c.name.includes("Agadeem's Awakening"), 309 + ); 310 + expect(agadeem?.name).toBe( 311 + "Agadeem's Awakening / Agadeem, the Undercrypt", 312 + ); 313 + expect(agadeem?.setCode).toBe("ZNR"); 314 + }); 315 + 316 + it("parses SIDEBOARD: section", () => { 317 + const text = readFixture("moxfield", "pedh-with-sideboard.txt"); 318 + const result = parseDeck(text); 319 + 320 + const countCards = (cards: { quantity: number }[]) => 321 + cards.reduce((sum, c) => sum + c.quantity, 0); 322 + 323 + // Has mainboard and sideboard, no commander section (Hamza is in mainboard) 324 + // Mainboard includes 11 Forest + 6 Plains + other cards = 100 325 + expect(countCards(result.mainboard)).toBe(100); 326 + expect(countCards(result.sideboard)).toBe(14); 327 + expect(result.commander).toHaveLength(0); 328 + 329 + // Check specific mainboard card with split name 330 + const badger = result.mainboard.find((c) => 331 + c.name.includes("Colossal Badger"), 332 + ); 333 + expect(badger?.name).toBe("Colossal Badger / Dig Deep"); 334 + expect(badger?.setCode).toBe("CLB"); 335 + expect(badger?.collectorNumber).toBe("223"); 336 + 337 + // Check sideboard card 338 + const farseek = result.sideboard.find((c) => c.name === "Farseek"); 339 + expect(farseek).toBeDefined(); 340 + expect(farseek?.setCode).toBe("BLC"); 341 + }); 342 + 343 + it("preserves tags including multi-word tags", () => { 344 + const text = readFixture("moxfield", "bulk-edit-with-tags.txt"); 345 + const result = parseDeck(text); 346 + 347 + // Check single-word tag with ! prefix (global) 348 + const signet = result.mainboard.find((c) => 349 + c.name.includes("Arcane Signet"), 350 + ); 351 + expect(signet?.tags).toContain("ramp"); 352 + 353 + // Check multi-word tag 354 + const dragonborn = result.mainboard.find((c) => 355 + c.name.includes("Ambitious Dragonborn"), 356 + ); 357 + expect(dragonborn?.tags).toContain("payoffs / big creatures"); 358 + 359 + // Check card with multiple tags 360 + const badger = result.mainboard.find((c) => 361 + c.name.includes("Colossal Badger"), 362 + ); 363 + expect(badger?.tags).toContain("counters"); 364 + expect(badger?.tags).toContain("payoffs / big creatures"); 365 + 366 + // Card without tags should have empty array 367 + const treeline = result.mainboard.find((c) => 368 + c.name.includes("Arctic Treeline"), 369 + ); 370 + expect(treeline?.tags).toEqual([]); 371 + }); 372 + }); 373 + 374 + describe("Archidekt format", () => { 375 + it("parses Archidekt with inline section markers", () => { 376 + const text = readFixture("archidekt", "txt-with-categories.txt"); 377 + const result = parseDeck(text); 378 + 379 + const countCards = (cards: { quantity: number }[]) => 380 + cards.reduce((sum, c) => sum + c.quantity, 0); 381 + 382 + // Verify total card counts based on inline [Sideboard], [Commander{top}], [Maybeboard{...}] 383 + expect(countCards(result.commander)).toBe(1); 384 + expect(countCards(result.mainboard)).toBe(99); 385 + expect(countCards(result.sideboard)).toBe(11); 386 + expect(countCards(result.maybeboard)).toBe(34); 387 + 388 + // Commander identified by [Commander{top}] marker 389 + expect(result.commander[0].name).toBe("Tifa Lockhart"); 390 + }); 391 + }); 392 + 393 + describe("Deckstats format", () => { 394 + it("parses Deckstats with //Section comments", () => { 395 + const text = readFixture("deckstats", "commander-with-categories.dec"); 396 + const result = parseDeck(text); 397 + 398 + const countCards = (cards: { quantity: number }[]) => 399 + cards.reduce((sum, c) => sum + c.quantity, 0); 400 + 401 + // Commander from # !Commander marker, rest in mainboard categories 402 + // Fixture has 157 cards total (not a legal 100-card commander deck) 403 + expect(countCards(result.commander)).toBe(1); 404 + expect(countCards(result.mainboard)).toBe(156); 405 + expect(countCards(result.sideboard)).toBe(0); 406 + expect(result.commander[0].name).toBe("Black Waltz No. 3"); 407 + }); 408 + }); 409 + 410 + describe("MTGGoldfish format", () => { 411 + it("parses simple format with blank line sideboard separator", () => { 412 + const text = readFixture("mtggoldfish", "simple.txt"); 413 + const result = parseDeck(text); 414 + 415 + const countCards = (cards: { quantity: number }[]) => 416 + cards.reduce((sum, c) => sum + c.quantity, 0); 417 + 418 + // 60-card main + 15-card sideboard 419 + expect(countCards(result.mainboard)).toBe(60); 420 + expect(countCards(result.sideboard)).toBe(15); 421 + }); 422 + 423 + it("parses exact versions with [SET] markers", () => { 424 + const text = readFixture("mtggoldfish", "exact-versions.txt"); 425 + const result = parseDeck(text); 426 + 427 + const countCards = (cards: { quantity: number }[]) => 428 + cards.reduce((sum, c) => sum + c.quantity, 0); 429 + 430 + // Same deck with set codes 431 + expect(countCards(result.mainboard)).toBe(60); 432 + expect(countCards(result.sideboard)).toBe(15); 433 + 434 + // Check set codes were parsed 435 + const cardWithSet = result.mainboard.find((c) => c.setCode); 436 + expect(cardWithSet?.setCode).toBeDefined(); 437 + }); 438 + }); 439 + 440 + describe("edge cases", () => { 441 + it("handles empty input", () => { 442 + const result = parseDeck(""); 443 + expect(result.mainboard).toHaveLength(0); 444 + expect(result.sideboard).toHaveLength(0); 445 + }); 446 + 447 + it("handles mixed case set codes", () => { 448 + const text = readFixture("edge-cases", "mixed-case.txt"); 449 + const result = parseDeck(text); 450 + 451 + expect(result.mainboard).toHaveLength(4); 452 + 453 + // All set codes should be normalized to uppercase 454 + for (const card of result.mainboard) { 455 + if (card.setCode) { 456 + expect(card.setCode).toBe(card.setCode.toUpperCase()); 457 + } 458 + } 459 + }); 460 + 461 + it("handles split cards", () => { 462 + const text = readFixture("edge-cases", "split-cards.txt"); 463 + const result = parseDeck(text); 464 + 465 + expect(result.mainboard).toHaveLength(6); 466 + expect(result.mainboard.some((c) => c.name.includes("//"))).toBe(true); 467 + expect(result.mainboard.some((c) => c.name.includes("/"))).toBe(true); 468 + }); 469 + 470 + it("handles minimal card list (names only)", () => { 471 + const text = readFixture("edge-cases", "minimal.txt"); 472 + const result = parseDeck(text); 473 + 474 + expect(result.mainboard).toHaveLength(5); 475 + // All should default to quantity 1 476 + expect(result.mainboard.every((c) => c.quantity === 1)).toBe(true); 477 + }); 478 + }); 479 + 480 + describe("quantity edge cases", () => { 481 + it("treats quantity 0 as skipping the line", () => { 482 + const result = parseCardLine("0 Lightning Bolt"); 483 + // quantity 0 should still parse but with qty clamped to 1 484 + expect(result?.quantity).toBe(1); 485 + }); 486 + 487 + it("handles leading zeros in quantity", () => { 488 + const result = parseCardLine("04 Lightning Bolt"); 489 + expect(result?.quantity).toBe(4); 490 + expect(result?.name).toBe("Lightning Bolt"); 491 + }); 492 + 493 + it("handles very large quantities", () => { 494 + const result = parseCardLine("9999 Relentless Rats"); 495 + expect(result?.quantity).toBe(9999); 496 + expect(result?.name).toBe("Relentless Rats"); 497 + }); 498 + 499 + it("defaults to 1 for non-numeric start", () => { 500 + const result = parseCardLine("Lightning Bolt"); 501 + expect(result?.quantity).toBe(1); 502 + }); 503 + }); 504 + 505 + describe("section handling edge cases", () => { 506 + it("handles multiple consecutive blank lines", () => { 507 + const text = `Deck 508 + 4 Lightning Bolt 509 + 510 + 511 + Sideboard 512 + 2 Pyroblast`; 513 + 514 + const result = parseDeck(text); 515 + expect(result.mainboard).toHaveLength(1); 516 + expect(result.sideboard).toHaveLength(1); 517 + }); 518 + 519 + it("puts cards before any section header into mainboard", () => { 520 + const text = `4 Lightning Bolt 521 + 2 Counterspell 522 + 523 + Sideboard 524 + 1 Pyroblast`; 525 + 526 + const result = parseDeck(text); 527 + expect(result.mainboard).toHaveLength(2); 528 + expect(result.sideboard).toHaveLength(1); 529 + }); 530 + 531 + it("handles duplicate section markers", () => { 532 + const text = `Deck 533 + 4 Lightning Bolt 534 + 535 + Deck 536 + 2 Counterspell 537 + 538 + Sideboard 539 + 1 Pyroblast`; 540 + 541 + const result = parseDeck(text); 542 + // Both Deck sections should go to mainboard 543 + expect(result.mainboard).toHaveLength(2); 544 + expect(result.sideboard).toHaveLength(1); 545 + }); 546 + 547 + it("handles section headers with trailing whitespace", () => { 548 + const text = `Sideboard 549 + 1 Pyroblast`; 550 + 551 + const result = parseDeck(text); 552 + expect(result.sideboard).toHaveLength(1); 553 + }); 554 + 555 + it("does not confuse card names with section headers", () => { 556 + // Hypothetical card named "Deck" or containing "Sideboard" 557 + const text = `1 Deck of Many Things 558 + 1 Sideboard Strategist`; 559 + 560 + const result = parseDeck(text); 561 + expect(result.mainboard).toHaveLength(2); 562 + expect(result.mainboard[0].name).toBe("Deck of Many Things"); 563 + expect(result.mainboard[1].name).toBe("Sideboard Strategist"); 564 + }); 565 + }); 566 + 567 + describe("collector number edge cases", () => { 568 + it("parses collector numbers with letter suffixes", () => { 569 + const result = parseCardLine("1 Night Soil (FEM) 71b"); 570 + expect(result?.collectorNumber).toBe("71b"); 571 + expect(result?.setCode).toBe("FEM"); 572 + }); 573 + 574 + it("parses promo set codes like PLST", () => { 575 + const result = parseCardLine("1 Citanul Woodreaders (PLST) DDR-4"); 576 + expect(result?.setCode).toBe("PLST"); 577 + expect(result?.collectorNumber).toBe("DDR-4"); 578 + }); 579 + 580 + it("parses star collector numbers", () => { 581 + const result = parseCardLine("1 Lightning Bolt (STA) 62★"); 582 + expect(result?.collectorNumber).toBe("62★"); 583 + }); 584 + 585 + it("parses collector numbers with p suffix (promo)", () => { 586 + const result = parseCardLine("1 Blazemire Verge (PDSK) 256p"); 587 + expect(result?.collectorNumber).toBe("256p"); 588 + expect(result?.setCode).toBe("PDSK"); 589 + }); 590 + }); 591 + 592 + describe("malformed input", () => { 593 + it("returns null for lines that are just numbers", () => { 594 + expect(parseCardLine("4")).toBeNull(); 595 + expect(parseCardLine("100")).toBeNull(); 596 + expect(parseCardLine("4x")).toBeNull(); 597 + }); 598 + 599 + it("handles lines with only whitespace", () => { 600 + const result = parseCardLine(" "); 601 + expect(result).toBeNull(); 602 + }); 603 + 604 + it("handles tab characters", () => { 605 + const result = parseCardLine("4\tLightning Bolt"); 606 + expect(result?.quantity).toBe(4); 607 + expect(result?.name).toBe("Lightning Bolt"); 608 + }); 609 + 610 + it("parses partial format (quantity and name only)", () => { 611 + const text = `4 Lightning Bolt 612 + 2 Counterspell ( 613 + 1 Sol Ring`; 614 + 615 + const result = parseDeck(text); 616 + expect(result.mainboard).toHaveLength(3); 617 + // Malformed "(", line should still parse somehow 618 + expect(result.mainboard[1].name).toContain("Counterspell"); 619 + }); 620 + }); 621 + 622 + describe("format conflict resolution", () => { 623 + it("handles deck with both [SET] and #tags", () => { 624 + const text = `4 Lightning Bolt [2XM] #removal 625 + 2 Counterspell [IMA] #counter`; 626 + 627 + const result = parseDeck(text); 628 + expect(result.mainboard).toHaveLength(2); 629 + 630 + const bolt = result.mainboard[0]; 631 + expect(bolt.name).toBe("Lightning Bolt"); 632 + expect(bolt.setCode).toBe("2XM"); 633 + expect(bolt.tags).toContain("removal"); 634 + }); 635 + 636 + it("handles deck with (SET) and #tags together", () => { 637 + const text = `1 Sol Ring (CMM) 647 #ramp #staple`; 638 + 639 + const result = parseDeck(text); 640 + expect(result.mainboard[0].setCode).toBe("CMM"); 641 + expect(result.mainboard[0].collectorNumber).toBe("647"); 642 + expect(result.mainboard[0].tags).toEqual(["ramp", "staple"]); 643 + }); 644 + }); 645 + 646 + describe("card name edge cases", () => { 647 + it("parses cards with commas in name", () => { 648 + const result = parseCardLine("1 Ach! Hans, Run!"); 649 + expect(result?.name).toBe("Ach! Hans, Run!"); 650 + }); 651 + 652 + it("parses cards with apostrophes", () => { 653 + const result = parseCardLine("1 Agadeem's Awakening (ZNR) 90"); 654 + expect(result?.name).toBe("Agadeem's Awakening"); 655 + }); 656 + 657 + it("parses cards with + in name", () => { 658 + const result = parseCardLine("4 +2 Mace (AFR) 1"); 659 + expect(result?.name).toBe("+2 Mace"); 660 + }); 661 + 662 + it("parses cards starting with numbers", () => { 663 + // Real card: "1996 World Champion" 664 + const result = parseCardLine("1 1996 World Champion"); 665 + expect(result?.quantity).toBe(1); 666 + expect(result?.name).toBe("1996 World Champion"); 667 + }); 668 + 669 + it("handles very long card names", () => { 670 + const result = parseCardLine( 671 + "1 Asmoranomardicadaistinaculdacar (MH2) 186", 672 + ); 673 + expect(result?.name).toBe("Asmoranomardicadaistinaculdacar"); 674 + expect(result?.setCode).toBe("MH2"); 675 + }); 676 + }); 677 + });
+187
src/lib/deck-formats/__tests__/sections.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { extractInlineSection, parseSectionMarker } from "../sections"; 3 + 4 + describe("parseSectionMarker", () => { 5 + describe("Arena-style section headers", () => { 6 + it("recognizes 'Deck' as mainboard", () => { 7 + expect(parseSectionMarker("Deck")).toEqual({ 8 + section: "mainboard", 9 + consumeLine: true, 10 + }); 11 + }); 12 + 13 + it("recognizes 'Sideboard' as sideboard", () => { 14 + expect(parseSectionMarker("Sideboard")).toEqual({ 15 + section: "sideboard", 16 + consumeLine: true, 17 + }); 18 + }); 19 + 20 + it("recognizes 'Commander' as commander", () => { 21 + expect(parseSectionMarker("Commander")).toEqual({ 22 + section: "commander", 23 + consumeLine: true, 24 + }); 25 + }); 26 + 27 + it("is case-insensitive", () => { 28 + expect(parseSectionMarker("SIDEBOARD")).toEqual({ 29 + section: "sideboard", 30 + consumeLine: true, 31 + }); 32 + expect(parseSectionMarker("deck")).toEqual({ 33 + section: "mainboard", 34 + consumeLine: true, 35 + }); 36 + }); 37 + 38 + it("handles whitespace", () => { 39 + expect(parseSectionMarker(" Sideboard ")).toEqual({ 40 + section: "sideboard", 41 + consumeLine: true, 42 + }); 43 + }); 44 + }); 45 + 46 + describe("Deckstats //Section comments", () => { 47 + it("recognizes //Main as mainboard", () => { 48 + expect(parseSectionMarker("//Main")).toEqual({ 49 + section: "mainboard", 50 + consumeLine: true, 51 + }); 52 + }); 53 + 54 + it("recognizes //Mainboard as mainboard", () => { 55 + expect(parseSectionMarker("//Mainboard")).toEqual({ 56 + section: "mainboard", 57 + consumeLine: true, 58 + }); 59 + }); 60 + 61 + it("recognizes //Sideboard as sideboard", () => { 62 + expect(parseSectionMarker("//Sideboard")).toEqual({ 63 + section: "sideboard", 64 + consumeLine: true, 65 + }); 66 + }); 67 + 68 + it("recognizes //Maybeboard as maybeboard", () => { 69 + expect(parseSectionMarker("//Maybeboard")).toEqual({ 70 + section: "maybeboard", 71 + consumeLine: true, 72 + }); 73 + }); 74 + 75 + it("treats other // comments as mainboard (custom categories)", () => { 76 + // Custom categories like //burn, //draw should stay in mainboard 77 + expect(parseSectionMarker("//burn")).toEqual({ 78 + section: "mainboard", 79 + consumeLine: true, 80 + }); 81 + }); 82 + }); 83 + 84 + describe("TappedOut About/Name header", () => { 85 + it("recognizes 'About' line as consumable", () => { 86 + // TappedOut arena export starts with "About" line 87 + const result = parseSectionMarker("About"); 88 + expect(result?.consumeLine).toBe(true); 89 + }); 90 + }); 91 + 92 + describe("non-section lines", () => { 93 + it("returns null for card lines", () => { 94 + expect(parseSectionMarker("4 Lightning Bolt")).toBeNull(); 95 + }); 96 + 97 + it("returns null for card lines with set codes", () => { 98 + expect(parseSectionMarker("4 Lightning Bolt (2XM) 141")).toBeNull(); 99 + }); 100 + 101 + it("returns null for empty lines", () => { 102 + expect(parseSectionMarker("")).toBeNull(); 103 + }); 104 + 105 + it("returns null for XMage NAME: lines", () => { 106 + // NAME: is metadata, not a section marker 107 + expect(parseSectionMarker("NAME:[MOD] UW Miracles")).toBeNull(); 108 + }); 109 + 110 + it("returns null for XMage LAYOUT lines", () => { 111 + expect(parseSectionMarker("LAYOUT MAIN:(1,6)")).toBeNull(); 112 + }); 113 + }); 114 + }); 115 + 116 + describe("extractInlineSection", () => { 117 + describe("Archidekt inline markers", () => { 118 + it("extracts [Sideboard] marker", () => { 119 + const result = extractInlineSection( 120 + "1x Sol Ring (cmm) 647 [Sideboard] ^Have^", 121 + ); 122 + expect(result.section).toBe("sideboard"); 123 + expect(result.cardLine).toBe("1x Sol Ring (cmm) 647 ^Have^"); 124 + }); 125 + 126 + it("extracts [Commander{top}] marker", () => { 127 + const result = extractInlineSection( 128 + "1x Tifa Lockhart (fin) 567 *F* [Commander{top}] ^Have^", 129 + ); 130 + expect(result.section).toBe("commander"); 131 + expect(result.cardLine).toBe("1x Tifa Lockhart (fin) 567 *F* ^Have^"); 132 + }); 133 + 134 + it("extracts [Maybeboard{noDeck}{noPrice}] marker", () => { 135 + const result = extractInlineSection( 136 + "1x Alpha Authority (gtc) 114 [Maybeboard{noDeck}{noPrice}]", 137 + ); 138 + expect(result.section).toBe("maybeboard"); 139 + expect(result.cardLine).toBe("1x Alpha Authority (gtc) 114 "); 140 + }); 141 + 142 + it("handles multiple category markers (takes first section)", () => { 143 + // Archidekt can have multiple categories like [Maybeboard{...},Enchantment] 144 + const result = extractInlineSection( 145 + "1x Card (set) 1 [Maybeboard{noDeck},Sideboard,Creature]", 146 + ); 147 + expect(result.section).toBe("maybeboard"); 148 + }); 149 + }); 150 + 151 + describe("Deckstats # !Commander marker", () => { 152 + it("extracts # !Commander marker", () => { 153 + const result = extractInlineSection("1 Black Waltz No. 3 # !Commander"); 154 + expect(result.section).toBe("commander"); 155 + expect(result.cardLine).toBe("1 Black Waltz No. 3"); 156 + }); 157 + }); 158 + 159 + describe("XMage SB: prefix", () => { 160 + it("extracts SB: prefix", () => { 161 + const result = extractInlineSection("SB: 2 [EMA:142] Pyroblast"); 162 + expect(result.section).toBe("sideboard"); 163 + expect(result.cardLine).toBe("2 [EMA:142] Pyroblast"); 164 + }); 165 + 166 + it("handles SB: with extra whitespace", () => { 167 + const result = extractInlineSection("SB: 3 Counterspell"); 168 + expect(result.section).toBe("sideboard"); 169 + expect(result.cardLine).toBe("3 Counterspell"); 170 + }); 171 + }); 172 + 173 + describe("lines without inline sections", () => { 174 + it("returns original line when no inline section", () => { 175 + const result = extractInlineSection("4 Lightning Bolt (2XM) 141"); 176 + expect(result.section).toBeUndefined(); 177 + expect(result.cardLine).toBe("4 Lightning Bolt (2XM) 141"); 178 + }); 179 + 180 + it("does not confuse [SET] with [Sideboard]", () => { 181 + // MTGGoldfish format: [SET] after name 182 + const result = extractInlineSection("4 Lightning Bolt [2XM]"); 183 + expect(result.section).toBeUndefined(); 184 + expect(result.cardLine).toBe("4 Lightning Bolt [2XM]"); 185 + }); 186 + }); 187 + });
+95
src/lib/deck-formats/detect.ts
··· 1 + /** 2 + * Format detection for deck lists 3 + * 4 + * Detection order matters - check most distinctive patterns first. 5 + */ 6 + 7 + import type { DeckFormat } from "./types"; 8 + 9 + /** 10 + * Detect the format of a deck list from its text content. 11 + * 12 + * Detection priority (most specific first): 13 + * 1. XMage: [SET:num] before card name 14 + * 2. Archidekt: inline [Sideboard]/[Commander] markers or ^Tag^ colors 15 + * 3. MTGGoldfish: [SET] after card name (not before) 16 + * 4. Deckstats: //Section comments or # !Commander 17 + * 5. TappedOut: Nx quantity pattern (e.g., 4x Card) 18 + * 6. Moxfield: *F* foil markers or #tags (without inline section markers) 19 + * 7. Arena: Deck/Sideboard/Commander section headers on own line 20 + * 8. Generic: fallback for plain card lists 21 + */ 22 + export function detectFormat(text: string): DeckFormat { 23 + if (!text.trim()) { 24 + return "generic"; 25 + } 26 + 27 + const lines = text.split("\n"); 28 + 29 + // XMage: [SET:num] pattern BEFORE card name (most distinctive) 30 + // e.g., "4 [2XM:141] Lightning Bolt" 31 + if (lines.some((l) => /^\d+\s+\[\w{2,5}:\d+\]/.test(l.trim()))) { 32 + return "xmage"; 33 + } 34 + 35 + // Also check for XMage SB: lines with [SET:num] 36 + if (lines.some((l) => /^SB:\s*\d+\s+\[\w{2,5}:\d+\]/.test(l.trim()))) { 37 + return "xmage"; 38 + } 39 + 40 + // Archidekt: inline section markers [Sideboard], [Commander{...}], [Maybeboard{...}] 41 + // or ^Tag,#color^ markers 42 + if ( 43 + lines.some( 44 + (l) => 45 + /\[(?:Sideboard|Commander|Maybeboard)/.test(l) || /\^[^^]+\^/.test(l), 46 + ) 47 + ) { 48 + return "archidekt"; 49 + } 50 + 51 + // MTGGoldfish exact versions: [SET] AFTER card name 52 + // e.g., "4 Lightning Bolt [2XM]" or "4 Card <extended> [SET]" 53 + // Must not match XMage pattern (which has [SET:num] BEFORE name) 54 + if ( 55 + lines.some((l) => { 56 + const trimmed = l.trim(); 57 + // Match: ends with [SET] (2-5 chars, uppercase) 58 + // But not XMage pattern (which starts with quantity then [SET:num]) 59 + return ( 60 + /\s\[[A-Z0-9]{2,5}\]\s*$/.test(trimmed) && 61 + !/^\d+\s+\[\w+:\d+\]/.test(trimmed) 62 + ); 63 + }) 64 + ) { 65 + return "mtggoldfish"; 66 + } 67 + 68 + // Deckstats: //Section comments or # !Commander marker 69 + if (lines.some((l) => /^\/\/\w+/.test(l.trim()) || /# !Commander/.test(l))) { 70 + return "deckstats"; 71 + } 72 + 73 + // TappedOut: Nx quantity pattern (e.g., "4x Card Name") 74 + if (lines.some((l) => /^\d+x\s+/i.test(l.trim()))) { 75 + return "tappedout"; 76 + } 77 + 78 + // Moxfield: *F* foil markers or #tags 79 + // Check for *F* or *A* markers, or #tag patterns 80 + // (but not if Archidekt markers already matched) 81 + if (lines.some((l) => /\*[FA]\*/.test(l) || /#\w+/.test(l))) { 82 + return "moxfield"; 83 + } 84 + 85 + // Arena: section headers on their own line 86 + // "Deck", "Sideboard", "Commander", "Companion" 87 + if ( 88 + lines.some((l) => /^(Deck|Sideboard|Commander|Companion)$/i.test(l.trim())) 89 + ) { 90 + return "arena"; 91 + } 92 + 93 + // Generic: plain card list, no distinctive markers 94 + return "generic"; 95 + }
+221
src/lib/deck-formats/parse.ts
··· 1 + /** 2 + * Universal deck list parser 3 + * 4 + * Handles multiple formats: 5 + * - Arena: `4 Name (SET) 123` 6 + * - TappedOut: `4x Name` 7 + * - XMage: `4 [SET:123] Name` 8 + * - MTGGoldfish: `4 Name [SET]` or `4 Name <variant> [SET]` 9 + * - Moxfield: `4 Name (SET) 123 *F* #tag` 10 + * - Archidekt: `4x Name (set) 123 [Section] ^Tag^` 11 + * - Deckstats: `4 Name # !Commander` 12 + */ 13 + 14 + import { detectFormat } from "./detect"; 15 + import { extractInlineSection, parseSectionMarker } from "./sections"; 16 + import type { 17 + DeckFormat, 18 + DeckSection, 19 + ParsedCardLine, 20 + ParsedDeck, 21 + } from "./types"; 22 + 23 + /** 24 + * Parse a single line of card text. 25 + * 26 + * Handles all format variations for quantity, set code, and collector number. 27 + * Tries patterns in order of specificity - most distinctive first. 28 + */ 29 + export function parseCardLine(line: string): ParsedCardLine | null { 30 + let remaining = line.trim(); 31 + if (!remaining) { 32 + return null; 33 + } 34 + 35 + // Strip *F* (foil) and *A* (alter) markers 36 + remaining = remaining.replace(/\s*\*[FA]\*\s*/g, " ").trim(); 37 + 38 + // Strip ^Tag,#color^ markers (Archidekt) 39 + remaining = remaining.replace(/\s*\^[^^]+\^\s*/g, " ").trim(); 40 + 41 + // Extract <collector#> from MTGGoldfish variant markers before stripping 42 + let variantCollectorNumber: string | undefined; 43 + const collectorInVariant = remaining.match(/<(\d+[a-z★†]?)>/i); 44 + if (collectorInVariant) { 45 + variantCollectorNumber = collectorInVariant[1]; 46 + } 47 + // Strip <variant> markers (MTGGoldfish) 48 + remaining = remaining 49 + .replace(/<[^>]+>/g, " ") 50 + .replace(/\s+/g, " ") 51 + .trim(); 52 + 53 + // Extract tags (#tag #!global #multi word tag) 54 + // Tags start at first # and go to end of line (after stripping other markers) 55 + let tags: string[] = []; 56 + const firstHashIndex = remaining.indexOf("#"); 57 + if (firstHashIndex !== -1) { 58 + const tagsPart = remaining.slice(firstHashIndex); 59 + remaining = remaining.slice(0, firstHashIndex).trim(); 60 + 61 + // Split by # and process each tag 62 + tags = Array.from( 63 + new Set( 64 + tagsPart 65 + .split("#") 66 + .map((t) => t.trim()) 67 + .filter((t) => t.length > 0) 68 + .map((t) => (t.startsWith("!") ? t.slice(1).trim() : t)), 69 + ), 70 + ); 71 + } 72 + 73 + // Parse quantity: "4 Name" or "4x Name" 74 + let quantity = 1; 75 + const quantityMatch = remaining.match(/^(\d+)x?\s+/i); 76 + if (quantityMatch) { 77 + quantity = Math.max(1, Number.parseInt(quantityMatch[1], 10)); 78 + remaining = remaining.slice(quantityMatch[0].length); 79 + } 80 + 81 + // Try XMage format first: [SET:123] or [SET] before name (most distinctive) 82 + const xmageMatch = remaining.match(/^\[([A-Z0-9]{2,5}):?(\d+)?\]\s+(.+)$/i); 83 + if (xmageMatch) { 84 + return { 85 + quantity, 86 + name: xmageMatch[3].trim(), 87 + setCode: xmageMatch[1].toUpperCase(), 88 + collectorNumber: xmageMatch[2], 89 + tags: [...new Set(tags)], 90 + raw: line.trim(), 91 + }; 92 + } 93 + 94 + // Try MTGGoldfish format: Name [SET] at end 95 + const goldfishMatch = remaining.match(/^(.+?)\s+\[([A-Z0-9]{2,5})\]\s*$/i); 96 + if (goldfishMatch) { 97 + return { 98 + quantity, 99 + name: goldfishMatch[1].trim(), 100 + setCode: goldfishMatch[2].toUpperCase(), 101 + collectorNumber: variantCollectorNumber, 102 + tags: [...new Set(tags)], 103 + raw: line.trim(), 104 + }; 105 + } 106 + 107 + // Try Arena/Moxfield format: Name (SET) 123 108 + const arenaMatch = remaining.match( 109 + /^(.+?)\s+\(([A-Z0-9]{2,5})\)(?:\s+(\S+))?\s*$/i, 110 + ); 111 + if (arenaMatch) { 112 + return { 113 + quantity, 114 + name: arenaMatch[1].trim(), 115 + setCode: arenaMatch[2].toUpperCase(), 116 + collectorNumber: arenaMatch[3], 117 + tags: [...new Set(tags)], 118 + raw: line.trim(), 119 + }; 120 + } 121 + 122 + // No set code - just card name 123 + const name = remaining.trim(); 124 + 125 + // Reject malformed lines with no actual card name 126 + // Also reject lines that are just quantity prefixes without actual card names 127 + // (e.g., "4", "4x", "100") - these are clearly malformed 128 + if (!name || /^\d+x?$/i.test(name)) { 129 + return null; 130 + } 131 + 132 + return { 133 + quantity, 134 + name, 135 + tags: [...new Set(tags)], 136 + raw: line.trim(), 137 + }; 138 + } 139 + 140 + /** 141 + * Parse a complete deck list with sections. 142 + * 143 + * Auto-detects format if not specified. Uses format hint to resolve 144 + * ambiguous situations (e.g., blank line handling). 145 + */ 146 + export function parseDeck(text: string, formatHint?: DeckFormat): ParsedDeck { 147 + const format = formatHint ?? detectFormat(text); 148 + const lines = text.split("\n"); 149 + 150 + const deck: ParsedDeck = { 151 + commander: [], 152 + mainboard: [], 153 + sideboard: [], 154 + maybeboard: [], 155 + format, 156 + }; 157 + 158 + let currentSection: DeckSection = "mainboard"; 159 + let sawBlankLine = false; 160 + let hasExplicitSections = false; 161 + 162 + for (const line of lines) { 163 + const trimmed = line.trim(); 164 + 165 + // Check for section marker (Arena headers, //Section, etc.) 166 + const sectionResult = parseSectionMarker(trimmed); 167 + if (sectionResult) { 168 + if (sectionResult.consumeLine) { 169 + currentSection = sectionResult.section; 170 + hasExplicitSections = true; 171 + sawBlankLine = false; 172 + continue; 173 + } 174 + } 175 + 176 + // Handle blank lines 177 + if (!trimmed) { 178 + sawBlankLine = true; 179 + continue; 180 + } 181 + 182 + // XMage NAME: metadata line 183 + if (trimmed.startsWith("NAME:")) { 184 + deck.name = trimmed.slice(5).trim(); 185 + continue; 186 + } 187 + 188 + // Skip XMage LAYOUT lines 189 + if (trimmed.startsWith("LAYOUT ")) { 190 + continue; 191 + } 192 + 193 + // Check for inline section markers (SB:, [Sideboard], # !Commander) 194 + const inlineResult = extractInlineSection(trimmed); 195 + let effectiveSection: DeckSection = inlineResult.section ?? currentSection; 196 + const cardLine = inlineResult.cardLine; 197 + 198 + // Format-specific: blank line as sideboard separator 199 + // Only for MTGGoldfish/generic when no explicit sections exist 200 + if ( 201 + sawBlankLine && 202 + !hasExplicitSections && 203 + !inlineResult.section && 204 + currentSection === "mainboard" && 205 + deck.mainboard.length > 0 && 206 + (format === "mtggoldfish" || format === "generic") 207 + ) { 208 + currentSection = "sideboard"; 209 + effectiveSection = "sideboard"; 210 + } 211 + sawBlankLine = false; 212 + 213 + // Parse the card line 214 + const parsed = parseCardLine(cardLine); 215 + if (parsed) { 216 + deck[effectiveSection].push(parsed); 217 + } 218 + } 219 + 220 + return deck; 221 + }
+158
src/lib/deck-formats/sections.ts
··· 1 + /** 2 + * Section marker parsing for deck lists 3 + * 4 + * Handles various ways different formats indicate deck sections: 5 + * - Arena: "Deck", "Sideboard", "Commander" on their own line 6 + * - Deckstats: //Section comments 7 + * - Archidekt: inline [Sideboard], [Commander{...}] markers 8 + * - XMage: SB: prefix 9 + */ 10 + 11 + import type { 12 + DeckSection, 13 + InlineSectionResult, 14 + SectionMarkerResult, 15 + } from "./types"; 16 + 17 + /** 18 + * Parse a line to see if it's a section marker. 19 + * 20 + * Returns the section and whether to consume the line (not parse as a card). 21 + * Returns null if the line is not a section marker. 22 + */ 23 + export function parseSectionMarker(line: string): SectionMarkerResult | null { 24 + const trimmed = line.trim(); 25 + 26 + if (!trimmed) { 27 + return null; 28 + } 29 + 30 + // Arena-style section headers (on their own line, optionally with colon) 31 + const arenaMatch = 32 + /^(Deck|Sideboard|Commander|Companion|Maybeboard):?$/i.exec(trimmed); 33 + if (arenaMatch) { 34 + const section = normalizeSectionName(arenaMatch[1]); 35 + return { section, consumeLine: true }; 36 + } 37 + 38 + // Deckstats //Section comments 39 + if (trimmed.startsWith("//")) { 40 + const sectionName = trimmed.slice(2).trim().toLowerCase(); 41 + 42 + // Map known section names 43 + if (sectionName === "main" || sectionName === "mainboard") { 44 + return { section: "mainboard", consumeLine: true }; 45 + } 46 + if (sectionName === "sideboard" || sectionName === "side") { 47 + return { section: "sideboard", consumeLine: true }; 48 + } 49 + if (sectionName === "maybeboard" || sectionName === "maybe") { 50 + return { section: "maybeboard", consumeLine: true }; 51 + } 52 + if (sectionName === "commander") { 53 + return { section: "commander", consumeLine: true }; 54 + } 55 + 56 + // Other // comments are custom categories - treat as mainboard, consume line 57 + return { section: "mainboard", consumeLine: true }; 58 + } 59 + 60 + // TappedOut "About" header line (and "Name ..." line) 61 + if (/^About$/i.test(trimmed) || /^Name\s+/i.test(trimmed)) { 62 + // These are metadata lines, consume but don't change section 63 + return { section: "mainboard", consumeLine: true }; 64 + } 65 + 66 + // XMage metadata lines - consume but don't treat as section 67 + if (trimmed.startsWith("NAME:") || trimmed.startsWith("LAYOUT ")) { 68 + return null; 69 + } 70 + 71 + // Deckstats generic txt uses plain section names without // 72 + // e.g., "Main", "burn", "draw" as category headers 73 + // Only match if it's a single word and a known section 74 + const plainSectionMatch = 75 + /^(Main|Mainboard|Sideboard|Maybeboard|Commander)$/i.exec(trimmed); 76 + if (plainSectionMatch) { 77 + const section = normalizeSectionName(plainSectionMatch[1]); 78 + return { section, consumeLine: true }; 79 + } 80 + 81 + return null; 82 + } 83 + 84 + /** 85 + * Extract inline section markers from a card line. 86 + * 87 + * Handles: 88 + * - Archidekt: [Sideboard], [Commander{top}], [Maybeboard{noDeck}{noPrice}] 89 + * - Deckstats: # !Commander 90 + * - XMage/Deckstats: SB: prefix 91 + * 92 + * Returns the section (if found) and the card line with the marker removed. 93 + */ 94 + export function extractInlineSection(line: string): InlineSectionResult { 95 + let cardLine = line; 96 + let section: DeckSection | undefined; 97 + 98 + // XMage/Deckstats SB: prefix 99 + const sbPrefixMatch = /^SB:\s*(.*)$/i.exec(cardLine); 100 + if (sbPrefixMatch) { 101 + return { 102 + section: "sideboard", 103 + cardLine: sbPrefixMatch[1], 104 + }; 105 + } 106 + 107 + // Deckstats # !Commander marker 108 + const commanderMarkerMatch = /\s*#\s*!Commander\s*$/i.exec(cardLine); 109 + if (commanderMarkerMatch) { 110 + return { 111 + section: "commander", 112 + cardLine: cardLine.slice(0, commanderMarkerMatch.index), 113 + }; 114 + } 115 + 116 + // Archidekt inline section markers: [Sideboard], [Commander{...}], [Maybeboard{...}] 117 + // These can appear with other category info like [Maybeboard{noDeck},Creature] 118 + const archidektMatch = 119 + /\[(Sideboard|Commander|Maybeboard)(?:\{[^}]*\})?[^\]]*\]/i.exec(cardLine); 120 + if (archidektMatch) { 121 + section = normalizeSectionName(archidektMatch[1]); 122 + // Remove the entire [...] marker 123 + cardLine = 124 + cardLine.slice(0, archidektMatch.index) + 125 + cardLine.slice(archidektMatch.index + archidektMatch[0].length); 126 + } 127 + 128 + return { 129 + section, 130 + cardLine, 131 + }; 132 + } 133 + 134 + /** 135 + * Normalize various section name variations to our standard names. 136 + */ 137 + function normalizeSectionName(name: string): DeckSection { 138 + const lower = name.toLowerCase(); 139 + 140 + switch (lower) { 141 + case "deck": 142 + case "main": 143 + case "mainboard": 144 + return "mainboard"; 145 + case "side": 146 + case "sideboard": 147 + case "companion": 148 + // Companion is rules-wise part of sideboard 149 + return "sideboard"; 150 + case "maybe": 151 + case "maybeboard": 152 + return "maybeboard"; 153 + case "commander": 154 + return "commander"; 155 + default: 156 + return "mainboard"; 157 + } 158 + }
+141
src/lib/deck-formats/types.ts
··· 1 + /** 2 + * Deck format types for multi-format import/export 3 + */ 4 + 5 + /** 6 + * Supported deck formats for import/export 7 + * 8 + * - arena: MTG Arena format (de facto standard) 9 + * - moxfield: Moxfield format (Arena + foil markers + tags) 10 + * - archidekt: Archidekt text export (inline section markers) 11 + * - mtggoldfish: MTGGoldfish exact versions (square brackets for set) 12 + * - xmage: XMage .dck format (set before name) 13 + * - deckstats: Deckstats.net format (comment sections) 14 + * - tappedout: TappedOut format (quantity with x suffix) 15 + * - generic: Plain card list (quantity + name only) 16 + */ 17 + export type DeckFormat = 18 + | "arena" 19 + | "moxfield" 20 + | "archidekt" 21 + | "mtggoldfish" 22 + | "xmage" 23 + | "deckstats" 24 + | "tappedout" 25 + | "generic"; 26 + 27 + /** 28 + * Sections in a parsed deck 29 + */ 30 + export type DeckSection = 31 + | "commander" 32 + | "mainboard" 33 + | "sideboard" 34 + | "maybeboard"; 35 + 36 + /** 37 + * A parsed card line with all extracted information 38 + */ 39 + export interface ParsedCardLine { 40 + /** Number of copies (defaults to 1) */ 41 + quantity: number; 42 + /** Card name as written */ 43 + name: string; 44 + /** Set code if specified (normalized to uppercase) */ 45 + setCode?: string; 46 + /** Collector number if specified */ 47 + collectorNumber?: string; 48 + /** Tags (Moxfield #tag style) */ 49 + tags: string[]; 50 + /** Original raw line text */ 51 + raw: string; 52 + } 53 + 54 + /** 55 + * A fully parsed deck with all sections 56 + */ 57 + export interface ParsedDeck { 58 + /** Commander zone cards */ 59 + commander: ParsedCardLine[]; 60 + /** Main deck cards */ 61 + mainboard: ParsedCardLine[]; 62 + /** Sideboard cards */ 63 + sideboard: ParsedCardLine[]; 64 + /** Maybeboard cards */ 65 + maybeboard: ParsedCardLine[]; 66 + /** Detected or specified format */ 67 + format?: DeckFormat; 68 + /** Deck name if found in file (e.g., XMage NAME: line) */ 69 + name?: string; 70 + } 71 + 72 + /** 73 + * Result of parsing a section marker line 74 + */ 75 + export interface SectionMarkerResult { 76 + /** The section this marker indicates */ 77 + section: DeckSection; 78 + /** Whether to consume the line (don't parse as card) */ 79 + consumeLine: boolean; 80 + } 81 + 82 + /** 83 + * Result of extracting inline section from a card line 84 + */ 85 + export interface InlineSectionResult { 86 + /** Section extracted from inline marker (e.g., [Sideboard]) */ 87 + section?: DeckSection; 88 + /** The card line with inline marker removed */ 89 + cardLine: string; 90 + } 91 + 92 + /** 93 + * Options for parsing a deck 94 + */ 95 + export interface ParseOptions { 96 + /** Override format detection */ 97 + format?: DeckFormat; 98 + /** Default section for cards without explicit section */ 99 + defaultSection?: DeckSection; 100 + } 101 + 102 + /** 103 + * Options for exporting a deck 104 + */ 105 + export interface ExportOptions { 106 + /** Include maybeboard in export (default: false) */ 107 + includeMaybeboard?: boolean; 108 + /** Include tags in export (only for formats that support them) */ 109 + includeTags?: boolean; 110 + } 111 + 112 + /** 113 + * Create an empty ParsedDeck 114 + */ 115 + export function emptyParsedDeck(): ParsedDeck { 116 + return { 117 + commander: [], 118 + mainboard: [], 119 + sideboard: [], 120 + maybeboard: [], 121 + }; 122 + } 123 + 124 + /** 125 + * Get total card count across all sections 126 + */ 127 + export function totalCards(deck: ParsedDeck): number { 128 + return ( 129 + sumQuantities(deck.commander) + 130 + sumQuantities(deck.mainboard) + 131 + sumQuantities(deck.sideboard) + 132 + sumQuantities(deck.maybeboard) 133 + ); 134 + } 135 + 136 + /** 137 + * Sum quantities of parsed card lines 138 + */ 139 + function sumQuantities(cards: ParsedCardLine[]): number { 140 + return cards.reduce((sum, card) => sum + card.quantity, 0); 141 + }