diff --git a/CONTEXT.md b/CONTEXT.md index fdf4ddf..75ce665 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -33,9 +33,12 @@ A single line in a **Deck** — `(Card, Zone, Category?, Printing?, quantity, is _Avoid_: deck entry, slot, card-in-deck. When the surrounding code is unambiguous, "card" is acceptable shorthand — but at module interfaces, prefer **DeckCard**. **Zone**: -Where a **DeckCard** sits inside a **Deck**: `MAINBOARD`, `SIDEBOARD`, `CONSIDERING`, or `COMMANDER`. `CONSIDERING` is maindeck's name for the on-deck/maybeboard concept. +Where a **DeckCard** sits inside a **Deck**: `MAINBOARD`, `SIDEBOARD`, `CONSIDERING`, `COMMANDER`, or `COMPANION`. `CONSIDERING` is maindeck's name for the on-deck/maybeboard concept. _Avoid_: maybeboard (use `CONSIDERING`), board (ambiguous with mainboard). +**Companion**: +A **Card** placed in the `COMPANION` **Zone** whose deckbuilding restriction the rest of the deck (`MAINBOARD` + `COMMANDER`) must satisfy. There is a fixed set of ten companions; each restriction is a condition from the card's oracle text (e.g. Lurrus: every permanent has mana value ≤ 2). The restrictions are **not** present in Scryfall's structured data — only the `Companion` keyword is — so they are encoded as a name-keyed predicate registry in `lib/deck/legality/companions.ts` and validated as part of `fullLegality`. + **Category**: A user-defined free-text grouping within a **Zone** (e.g. "Ramp", "Removal"). Distinct from **CardType** (Creature/Instant/...) and from **Format**. @@ -57,7 +60,7 @@ The rules system a **Deck** is built under (`COMMANDER`, `STANDARD`, `MODERN`, . A **Format** that allows at most one copy of each non-basic **Card**: `COMMANDER`, `BRAWL`, `OATHBREAKER`. **Legality**: -The result of validating a **Deck** against its **Format** — a list of `LegalityIssue`s (banned/restricted card, color-identity violation, deck-size, singleton, sideboard size). +The result of validating a **Deck** against its **Format** — a list of `LegalityIssue`s (banned/restricted card, color-identity violation, deck-size, singleton, sideboard size, companion restriction). _Avoid_: validation result (too generic), check. **Bracket**: diff --git a/README.md b/README.md index 3bf1f29..1c8e61b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A fast, no-nonsense Magic: The Gathering deckbuilder. No ads, no feature sprawl, ## features 🚀 -1. drag-and-drop deck building across mainboard, sideboard, considering, and commander zones +1. drag-and-drop deck building across mainboard, sideboard, considering, commander, and companion zones 2. format legality + Commander bracket detection 3. deck stats — mana curve, color breakdown, type distribution, opening-hand simulator 4. printing picker with per-set prices (USD/EUR, foil variants) diff --git a/app/_components/builder/__tests__/move-card-menu.test.tsx b/app/_components/builder/__tests__/move-card-menu.test.tsx index 8dc757b..7d66cda 100644 --- a/app/_components/builder/__tests__/move-card-menu.test.tsx +++ b/app/_components/builder/__tests__/move-card-menu.test.tsx @@ -6,6 +6,7 @@ describe("orderZoneOptions", () => { const order = orderZoneOptions(false).map((o) => o.value); expect(order).toEqual([ "COMMANDER", + "COMPANION", "MAINBOARD", "SIDEBOARD", "CONSIDERING", @@ -15,6 +16,7 @@ describe("orderZoneOptions", () => { it("moves Commander to the bottom when a commander is already set", () => { const order = orderZoneOptions(true).map((o) => o.value); expect(order).toEqual([ + "COMPANION", "MAINBOARD", "SIDEBOARD", "CONSIDERING", @@ -27,6 +29,6 @@ describe("orderZoneOptions", () => { const unset = orderZoneOptions(false) .map((o) => o.value) .filter((v) => v !== "COMMANDER"); - expect(set.slice(0, 3)).toEqual(unset); + expect(set.slice(0, -1)).toEqual(unset); }); }); diff --git a/app/_components/builder/card-row-sortable.tsx b/app/_components/builder/card-row-sortable.tsx index cf1631f..a7750c6 100644 --- a/app/_components/builder/card-row-sortable.tsx +++ b/app/_components/builder/card-row-sortable.tsx @@ -36,6 +36,7 @@ const ROW_ZONE_BY_KEY: Record = { "2": "MAINBOARD", "3": "SIDEBOARD", "4": "CONSIDERING", + "5": "COMPANION", }; function shouldIgnoreRowKeyEvent(e: React.KeyboardEvent): boolean { diff --git a/app/_components/builder/move-card-menu.tsx b/app/_components/builder/move-card-menu.tsx index 061dda7..3cdbf95 100644 --- a/app/_components/builder/move-card-menu.tsx +++ b/app/_components/builder/move-card-menu.tsx @@ -48,6 +48,7 @@ type ZoneOption = { value: Zone; label: string; key: string }; const ZONE_OPTIONS: ZoneOption[] = [ { value: "COMMANDER", label: "Commander", key: "c" }, + { value: "COMPANION", label: "Companion", key: "o" }, { value: "MAINBOARD", label: "Mainboard", key: "m" }, { value: "SIDEBOARD", label: "Sideboard", key: "s" }, { value: "CONSIDERING", label: "Considering", key: "i" }, diff --git a/app/_components/builder/sideboard-considering-dnd.tsx b/app/_components/builder/sideboard-considering-dnd.tsx index b84da07..639f81e 100644 --- a/app/_components/builder/sideboard-considering-dnd.tsx +++ b/app/_components/builder/sideboard-considering-dnd.tsx @@ -168,6 +168,11 @@ export function SideboardConsideringDnd({ sortKey, sortDir, ); + const companion = sortCards( + cards.filter((c) => c.zone === "COMPANION"), + sortKey, + sortDir, + ); const subcategoryNames = [...deck.categories] .sort((a, b) => a.sortOrder - b.sortOrder) @@ -176,7 +181,24 @@ export function SideboardConsideringDnd({ const commanderSet = cards.some((c) => c.zone === Zone.COMMANDER); return ( -
+
+ {companion.length > 0 && ( + + )} +
+
); } diff --git a/app/_components/builder/sideboard-considering.tsx b/app/_components/builder/sideboard-considering.tsx index bef75c9..c7c8490 100644 --- a/app/_components/builder/sideboard-considering.tsx +++ b/app/_components/builder/sideboard-considering.tsx @@ -120,13 +120,33 @@ export function SideboardConsidering({ sortKey, sortDir, ); + const companion = sortCards( + cards.filter((c) => c.zone === "COMPANION"), + sortKey, + sortDir, + ); const subcategoryNames = [...deck.categories] .sort((a, b) => a.sortOrder - b.sortOrder) .map((c) => c.name); return ( -
+
+ {companion.length > 0 && ( + + )} +
+
); } diff --git a/app/_components/deck/deck-history-list.tsx b/app/_components/deck/deck-history-list.tsx index 6795b64..46f945d 100644 --- a/app/_components/deck/deck-history-list.tsx +++ b/app/_components/deck/deck-history-list.tsx @@ -21,6 +21,7 @@ const ZONE_LABEL: Record = { SIDEBOARD: "Sideboard", CONSIDERING: "Considering", COMMANDER: "Commander", + COMPANION: "Companion", }; export function DeckHistoryList({ @@ -210,7 +211,13 @@ function groupByZone( list.push(d); byZone.set(d.zone, list); } - const zones: Zone[] = ["COMMANDER", "MAINBOARD", "SIDEBOARD", "CONSIDERING"]; + const zones: Zone[] = [ + "COMMANDER", + "COMPANION", + "MAINBOARD", + "SIDEBOARD", + "CONSIDERING", + ]; return zones .filter((z) => byZone.has(z)) .map((zone) => ({ diff --git a/app/_components/header-search/deck-mode-bar.tsx b/app/_components/header-search/deck-mode-bar.tsx index d8fd063..8995719 100644 --- a/app/_components/header-search/deck-mode-bar.tsx +++ b/app/_components/header-search/deck-mode-bar.tsx @@ -61,6 +61,7 @@ import { const ZONE_LABEL: Record = { COMMANDER: "Commander", + COMPANION: "Companion", MAINBOARD: "Mainboard", SIDEBOARD: "Sideboard", CONSIDERING: "Considering", diff --git a/app/_components/hotkeys/registry.ts b/app/_components/hotkeys/registry.ts index ed15870..bf5c910 100644 --- a/app/_components/hotkeys/registry.ts +++ b/app/_components/hotkeys/registry.ts @@ -31,6 +31,7 @@ const SHORTCUTS: ShortcutEntry[] = [ { id: "row.zone.mainboard", keys: ["2"], label: "Move to Mainboard", group: "Deck row" }, { id: "row.zone.sideboard", keys: ["3"], label: "Move to Sideboard", group: "Deck row" }, { id: "row.zone.considering", keys: ["4"], label: "Move to Considering", group: "Deck row" }, + { id: "row.zone.companion", keys: ["5"], label: "Move to Companion", group: "Deck row" }, { id: "row.printing", keys: ["p"], label: "Change printing", group: "Deck row" }, { id: "row.detail", keys: ["⏎"], label: "Open card detail", group: "Deck row" }, { id: "row.delete", keys: ["⌫"], label: "Remove from deck", group: "Deck row" }, @@ -39,6 +40,7 @@ const SHORTCUTS: ShortcutEntry[] = [ { id: "move.remove", keys: ["-"], label: "Remove one", group: "Move card menu" }, { id: "move.printing", keys: ["p"], label: "Change printing", group: "Move card menu" }, { id: "move.commander", keys: ["c"], label: "Commander zone", group: "Move card menu" }, + { id: "move.companion", keys: ["o"], label: "Companion zone", group: "Move card menu" }, { id: "move.mainboard", keys: ["m"], label: "Mainboard zone", group: "Move card menu" }, { id: "move.sideboard", keys: ["s"], label: "Sideboard zone", group: "Move card menu" }, { id: "move.considering", keys: ["i"], label: "Considering zone", group: "Move card menu" }, diff --git a/docs/adr/0002-deck-illegality-is-a-ui-state.md b/docs/adr/0002-deck-illegality-is-a-ui-state.md index 6fafe72..c7d6e9f 100644 --- a/docs/adr/0002-deck-illegality-is-a-ui-state.md +++ b/docs/adr/0002-deck-illegality-is-a-ui-state.md @@ -4,7 +4,7 @@ Deck illegality — singleton violations, color-identity mismatches, banned Card `prisma/migrations/20260501010000_deck_card_allow_multi_printing/migration.sql` codified this by dropping the `UNIQUE INDEX` on `(deck_id, card_id, zone, category)`. That index was the last schema-level barrier preventing duplicate **Card** rows in a **Zone**. Its removal was required for two reasons: users legitimately want multiple **Printings** of the same **Card** in a **Deck** (e.g. nine Swamps, each from a different set, each with a distinct `printingId`), and app-side de-duplication of unpinned **DeckCard** rows — where two rows reference the same **Card** in the same **Zone** with no **Printing** pin — is enforced in `lib/deck/mutation/apply.ts` rather than at the schema layer. -**Format** + **Bracket** legality lives in `lib/deck/legality/` and is computed on read. It produces a list of `LegalityIssue`s covering singleton violations (COMMANDER / BRAWL / OATHBREAKER allow at most one non-basic **Card** per **Deck**), color-identity violations, banned/restricted **Cards**, deck-size rules, and sideboard limits. The **Bracket** tier is computed from game-changer count and is Commander-only; it is not part of **Format** legality. +**Format** + **Bracket** legality lives in `lib/deck/legality/` and is computed on read. It produces a list of `LegalityIssue`s covering singleton violations (COMMANDER / BRAWL / OATHBREAKER allow at most one non-basic **Card** per **Deck**), color-identity violations (scanned across MAINBOARD, COMMANDER, and COMPANION zones), banned/restricted **Cards**, deck-size rules, sideboard limits, and companion deckbuilding restrictions (a name-keyed predicate registry in `lib/deck/legality/companions.ts` because Scryfall does not expose the restrictions in structured form). The **Bracket** tier is computed from game-changer count and is Commander-only; it is not part of **Format** legality. Future auditors should not introduce a partial `UNIQUE INDEX` on `(deck_id, card_id, zone)` to "fix" singleton enforcement. Such an index would break legitimate multi-**Printing** rows and fight the editor UX, which is designed to let users build illegal **Decks** and see indicators rather than be blocked. The schema is permissive on purpose; correctness is the responsibility of the legality layer and the mutation layer, not the DB. diff --git a/lib/deck/__tests__/add-intent.test.ts b/lib/deck/__tests__/add-intent.test.ts index 96c3fd4..edcd4d5 100644 --- a/lib/deck/__tests__/add-intent.test.ts +++ b/lib/deck/__tests__/add-intent.test.ts @@ -43,7 +43,7 @@ describe("parseAddCardInput", () => { }); describe("buildAddDestinations", () => { - it("includes mainboard, sideboard, considering and create-category by default", () => { + it("includes mainboard, sideboard, considering, companion and create-category by default", () => { const items = buildAddDestinations({ format: Format.STANDARD, categories: [], @@ -54,8 +54,15 @@ describe("buildAddDestinations", () => { "dest-mainboard", "dest-zone", "dest-zone", + "dest-zone", "dest-create-category", ]); + const zones = items + .filter((i): i is Extract => + i.kind === "dest-zone", + ) + .map((i) => i.zone); + expect(zones).toEqual([Zone.SIDEBOARD, Zone.CONSIDERING, Zone.COMPANION]); }); it("inserts a mainboard entry per category, in order", () => { diff --git a/lib/deck/add-intent.ts b/lib/deck/add-intent.ts index 7bd946c..6ececb0 100644 --- a/lib/deck/add-intent.ts +++ b/lib/deck/add-intent.ts @@ -44,6 +44,7 @@ export function buildAddDestinations({ } items.push({ kind: "dest-zone", zone: Zone.SIDEBOARD }); items.push({ kind: "dest-zone", zone: Zone.CONSIDERING }); + items.push({ kind: "dest-zone", zone: Zone.COMPANION }); if (format === Format.COMMANDER) { items.push({ kind: "dest-zone", diff --git a/lib/deck/io/__tests__/serialize.test.ts b/lib/deck/io/__tests__/serialize.test.ts index e207ad8..300c4b4 100644 --- a/lib/deck/io/__tests__/serialize.test.ts +++ b/lib/deck/io/__tests__/serialize.test.ts @@ -500,6 +500,34 @@ describe("toArena", () => { expect(cards.find((c) => c.name === "Lightning Bolt")?.quantity).toBe(4); expect(cards.find((c) => c.name === "Duress")?.zone).toBe("SIDEBOARD"); }); + + it("emits 'Companion' section before Deck when a companion is present", () => { + const lurrusCard = makeCard({ id: 5, name: "Lurrus of the Dream-Den" }); + const deck = makeDeck([ + makeDeckCard({ + id: "dc1", + deckId: "deck1", + cardId: 5, + card: lurrusCard, + quantity: 1, + zone: "COMPANION", + }), + makeDeckCard({ + id: "dc2", + deckId: "deck1", + cardId: 1, + card: boltCard, + quantity: 4, + zone: "MAINBOARD", + }), + ]); + const result = toArena(deck); + const lines = result.split("\n"); + expect(lines[0]).toBe("Companion"); + expect(lines[1]).toBe("1 Lurrus of the Dream-Den"); + expect(lines[2]).toBe(""); + expect(lines[3]).toBe("Deck"); + }); }); describe("toMaindeckJson", () => { diff --git a/lib/deck/io/adapters/_shared.ts b/lib/deck/io/adapters/_shared.ts index 63150de..cc9a10a 100644 --- a/lib/deck/io/adapters/_shared.ts +++ b/lib/deck/io/adapters/_shared.ts @@ -19,6 +19,8 @@ const SECTION_MARKERS: SectionMarker[] = [ { pattern: /^maybeboard\s*:?\s*$/i, zone: Zone.CONSIDERING }, { pattern: /^\/\/\s*commander\s*$/i, zone: Zone.COMMANDER }, { pattern: /^commander\s*:?\s*$/i, zone: Zone.COMMANDER }, + { pattern: /^\/\/\s*companion\s*$/i, zone: Zone.COMPANION }, + { pattern: /^companion\s*:?\s*$/i, zone: Zone.COMPANION }, ]; const LOOKS_LIKE_CARD = /^\d+\s+\S/; @@ -65,6 +67,7 @@ export function parseLineBased( export const ZONE_ORDER: Zone[] = [ "COMMANDER", + "COMPANION", "MAINBOARD", "SIDEBOARD", "CONSIDERING", @@ -75,6 +78,7 @@ export const ZONE_LABEL: Record = { SIDEBOARD: "Sideboard", CONSIDERING: "Considering", COMMANDER: "Commander", + COMPANION: "Companion", }; export function groupByZone( diff --git a/lib/deck/io/adapters/arena.ts b/lib/deck/io/adapters/arena.ts index be38abc..d14624f 100644 --- a/lib/deck/io/adapters/arena.ts +++ b/lib/deck/io/adapters/arena.ts @@ -40,7 +40,13 @@ function serialize(deck: DeckWithCards): string { } for (const dc of byZone.get("SIDEBOARD") ?? []) sideLines.push(lineFor(dc)); + const companionLines = (byZone.get("COMPANION") ?? []).map(lineFor); + if (companionLines.length > 0) { + lines.push("Companion"); + lines.push(...companionLines); + } if (mainLines.length > 0) { + if (lines.length > 0) lines.push(""); lines.push("Deck"); lines.push(...mainLines); } diff --git a/lib/deck/io/serialize.ts b/lib/deck/io/serialize.ts index 1838c72..69f6f30 100644 --- a/lib/deck/io/serialize.ts +++ b/lib/deck/io/serialize.ts @@ -1,10 +1,8 @@ -import type { Zone } from "@/lib/generated/prisma/enums"; import { arenaAdapter, textAdapter } from "./adapters"; +import { ZONE_ORDER } from "./adapters/_shared"; import type { MaindeckJson } from "./adapters/json"; import type { DeckWithCards } from "./adapters/types"; -const ZONE_ORDER: Zone[] = ["COMMANDER", "MAINBOARD", "SIDEBOARD", "CONSIDERING"]; - export function toPlainText(deck: DeckWithCards): string { return textAdapter.serialize(deck); } diff --git a/lib/deck/legality.test.ts b/lib/deck/legality.test.ts index a72263d..c2f7af1 100644 --- a/lib/deck/legality.test.ts +++ b/lib/deck/legality.test.ts @@ -546,6 +546,84 @@ describe("validateDeck — Commander color identity", () => { expect(violations[0]?.kind === "color_identity_violation" && violations[0].cardName).toBe("Lightning Bolt"); expect(violations[0]?.kind === "color_identity_violation" && violations[0].offending).toContain("R"); }); + + it("flags an off-color companion under a B/G commander", () => { + const cards: MinimalDeckCard[] = [ + makeDeckCard( + "Baba Lysaga", + 1, + Zone.COMMANDER, + { commander: "legal" }, + "Legendary Creature — Human Warlock", + ["B", "G"], + ), + makeDeckCard( + "Jegantha, the Wellspring", + 1, + Zone.COMPANION, + { commander: "legal" }, + "Legendary Creature — Elemental Elk", + ["W", "U", "B", "R", "G"], + ), + ...Array.from({ length: 99 }, (_, i) => + makeDeckCard( + `Unique Card ${i}`, + 1, + Zone.MAINBOARD, + { commander: "legal" }, + "Creature — Human", + ["B"], + ), + ), + ]; + const deck = makeDeck(Format.COMMANDER, cards); + const result = validateDeck(deck); + const violation = result.issues.find( + (i) => i.kind === "color_identity_violation", + ); + expect(violation).toBeDefined(); + expect(violation?.kind === "color_identity_violation" && violation.cardName).toBe("Jegantha, the Wellspring"); + }); + + it("does not flag an on-color companion", () => { + const cards: MinimalDeckCard[] = [ + makeDeckCard( + "Baba Lysaga", + 1, + Zone.COMMANDER, + { commander: "legal" }, + "Legendary Creature — Human Warlock", + ["B", "G"], + ), + makeDeckCard( + "Umori, the Collector", + 1, + Zone.COMPANION, + { commander: "legal" }, + "Legendary Creature — Ooze", + ["B", "G"], + ), + ...Array.from({ length: 99 }, (_, i) => + makeDeckCard( + `Unique Card ${i}`, + 1, + Zone.MAINBOARD, + { commander: "legal" }, + "Creature — Human", + ["B"], + ), + ), + ]; + const deck = makeDeck(Format.COMMANDER, cards); + const result = validateDeck(deck); + expect( + result.issues.some( + (i) => + i.kind === "color_identity_violation" && + i.cardName === "Umori, the Collector", + ), + ).toBe(false); + }); }); // --------------------------------------------------------------------------- diff --git a/lib/deck/legality/__tests__/companions.test.ts b/lib/deck/legality/__tests__/companions.test.ts new file mode 100644 index 0000000..df6ef47 --- /dev/null +++ b/lib/deck/legality/__tests__/companions.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, it } from "vitest"; +import { Format, Zone } from "@/lib/generated/prisma/enums"; +import { snapshotFromCards } from "@/lib/deck/mutation/snapshot"; +import { fullLegality } from "@/lib/deck/legality"; +import { + COMPANION_NAMES, + companionRestrictions, + companionRule, +} from "../companions"; +import { formatLegalityIssue } from "../shared"; +import type { SnapshotCard } from "@/lib/deck/mutation/types"; + +let nextId = 0; + +function card( + name: string, + opts: Partial = {}, +): SnapshotCard { + nextId += 1; + return { + id: `dc-${nextId}`, + cardId: nextId, + cardName: name, + quantity: 1, + zone: Zone.MAINBOARD, + category: null, + typeLine: "Creature — Human", + colorIdentity: [], + legalities: {}, + printingId: null, + isFoil: false, + cmc: 0, + manaCost: null, + oracleText: null, + ...opts, + }; +} + +function companion(name: string): SnapshotCard { + return card(name, { zone: Zone.COMPANION, typeLine: "Legendary Creature" }); +} + +function run(cards: SnapshotCard[], format: Format = Format.MODERN) { + return companionRule(snapshotFromCards({ format, cards })); +} + +describe("companion zone gating", () => { + it("returns no issues when the companion zone is empty", () => { + expect(run([card("Llanowar Elves", { cmc: 1 })])).toEqual([]); + }); + + it("flags a card in the companion zone that is not a known companion", () => { + const issues = run([companion("Llanowar Elves")]); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + kind: "companion_violation", + cardName: "Llanowar Elves", + reason: "not a companion", + }); + }); + + it("flags having more than one companion while still checking each", () => { + const issues = run([ + companion("Lurrus of the Dream-Den"), + companion("Jegantha, the Wellspring"), + card("Cheap Creature", { cmc: 2, typeLine: "Creature — Cat" }), + ]); + const tooMany = issues.filter( + (i) => + i.kind === "companion_violation" && + i.reason === "a deck may have only one companion", + ); + expect(tooMany).toHaveLength(1); + }); + + it("does not flag too-many when only one companion is present", () => { + const issues = run([ + companion("Lurrus of the Dream-Den"), + card("Cheap Creature", { cmc: 2, typeLine: "Creature — Cat" }), + ]); + expect( + issues.some( + (i) => + i.kind === "companion_violation" && + i.reason === "a deck may have only one companion", + ), + ).toBe(false); + }); +}); + +describe("Gyruda, Doom of Depths — even mana value", () => { + it("is legal when every card has an even mana value", () => { + const issues = run([ + companion("Gyruda, Doom of Depths"), + card("Even One", { cmc: 2 }), + card("Even Four", { cmc: 4 }), + ]); + expect(issues).toEqual([]); + }); + + it("is illegal when a card has an odd mana value", () => { + const issues = run([ + companion("Gyruda, Doom of Depths"), + card("Odd Card", { cmc: 3 }), + ]); + expect(issues).toHaveLength(1); + expect(issues[0]?.kind).toBe("companion_violation"); + }); + + it("treats null cmc as 0 (even — legal)", () => { + expect( + run([ + companion("Gyruda, Doom of Depths"), + card("Unknown Cost", { cmc: null }), + ]), + ).toEqual([]); + }); +}); + +describe("Obosh, the Preypiercer — odd mana value", () => { + it("ignores lands but flags even-cost nonland cards", () => { + const issues = run([ + companion("Obosh, the Preypiercer"), + card("Mountain", { cmc: 0, typeLine: "Basic Land — Mountain" }), + card("Odd Spell", { cmc: 1, typeLine: "Instant" }), + ]); + expect(issues).toEqual([]); + + const bad = run([ + companion("Obosh, the Preypiercer"), + card("Even Spell", { cmc: 2, typeLine: "Instant" }), + ]); + expect(bad).toHaveLength(1); + }); +}); + +describe("Keruga, the Macrosage — nonland mana value 3+", () => { + it("allows lands of any cost but requires nonlands at 3+", () => { + expect( + run([ + companion("Keruga, the Macrosage"), + card("Forest", { cmc: 0, typeLine: "Basic Land — Forest" }), + card("Big", { cmc: 3, typeLine: "Sorcery" }), + ]), + ).toEqual([]); + expect( + run([ + companion("Keruga, the Macrosage"), + card("Small", { cmc: 2, typeLine: "Instant" }), + ]), + ).toHaveLength(1); + }); +}); + +describe("Lurrus of the Dream-Den — permanents mana value 2 or less", () => { + it("ignores instants/sorceries but flags expensive permanents", () => { + expect( + run([ + companion("Lurrus of the Dream-Den"), + card("Cheap Creature", { cmc: 2, typeLine: "Creature — Cat" }), + card("Expensive Spell", { cmc: 6, typeLine: "Sorcery" }), + ]), + ).toEqual([]); + expect( + run([ + companion("Lurrus of the Dream-Den"), + card("Big Creature", { cmc: 5, typeLine: "Creature — Beast" }), + ]), + ).toHaveLength(1); + }); +}); + +describe("Lutri, the Spellchaser — singleton", () => { + it("flags a duplicated nonbasic but allows duplicate basics", () => { + expect( + run([ + companion("Lutri, the Spellchaser"), + card("Island", { quantity: 10, typeLine: "Basic Land — Island" }), + card("Opt", { quantity: 1, typeLine: "Instant" }), + ]), + ).toEqual([]); + expect( + run([ + companion("Lutri, the Spellchaser"), + card("Opt", { quantity: 2, typeLine: "Instant" }), + ]), + ).toHaveLength(1); + }); +}); + +describe("Jegantha, the Wellspring — no repeated mana symbol", () => { + it("allows distinct symbols but flags a repeated colored symbol", () => { + expect( + run([ + companion("Jegantha, the Wellspring"), + card("Multi", { manaCost: "{1}{W}{U}", typeLine: "Instant" }), + ]), + ).toEqual([]); + expect( + run([ + companion("Jegantha, the Wellspring"), + card("Double Green", { manaCost: "{G}{G}", typeLine: "Creature — Elf" }), + ]), + ).toHaveLength(1); + }); +}); + +describe("Kaheera, the Orphanguard — creature types", () => { + it("allows the listed types and noncreatures, flags others", () => { + expect( + run([ + companion("Kaheera, the Orphanguard"), + card("Savannah Lions", { typeLine: "Creature — Cat" }), + card("Disenchant", { typeLine: "Instant" }), + ]), + ).toEqual([]); + expect( + run([ + companion("Kaheera, the Orphanguard"), + card("Goblin Guide", { typeLine: "Creature — Goblin" }), + ]), + ).toHaveLength(1); + }); + + it("flags a creature with no subtype (no — in type line)", () => { + expect( + run([ + companion("Kaheera, the Orphanguard"), + card("Shapeless", { typeLine: "Creature" }), + ]), + ).toHaveLength(1); + }); +}); + +describe("Umori, the Collector — shared card type", () => { + it("is legal when all nonland cards share a type, illegal otherwise", () => { + expect( + run([ + companion("Umori, the Collector"), + card("Bolt", { typeLine: "Instant" }), + card("Opt", { typeLine: "Instant" }), + ]), + ).toEqual([]); + expect( + run([ + companion("Umori, the Collector"), + card("Bolt", { typeLine: "Instant" }), + card("Bear", { typeLine: "Creature — Bear" }), + ]), + ).toHaveLength(1); + }); + + it("is legal when only lands are present (nonland list empty)", () => { + expect( + run([ + companion("Umori, the Collector"), + card("Forest", { typeLine: "Basic Land — Forest" }), + card("Mountain", { typeLine: "Basic Land — Mountain" }), + ]), + ).toEqual([]); + }); + + it("treats a card with null typeLine as having no shared type", () => { + expect( + run([ + companion("Umori, the Collector"), + card("Unknown", { typeLine: null }), + ]), + ).toHaveLength(1); + }); +}); + +describe("Yorion, Sky Nomad — oversized deck", () => { + it("requires 20 cards above the format minimum", () => { + const big = run([ + companion("Yorion, Sky Nomad"), + card("Filler", { quantity: 80, typeLine: "Creature — Bird" }), + ]); + expect(big).toEqual([]); + const small = run([ + companion("Yorion, Sky Nomad"), + card("Filler", { quantity: 60, typeLine: "Creature — Bird" }), + ]); + expect(small).toHaveLength(1); + }); + + it("uses commander deck size (100) as the minimum for commander format", () => { + expect( + run( + [ + companion("Yorion, Sky Nomad"), + card("Filler", { quantity: 120, typeLine: "Creature — Bird" }), + ], + Format.COMMANDER, + ), + ).toEqual([]); + expect( + run( + [ + companion("Yorion, Sky Nomad"), + card("Filler", { quantity: 100, typeLine: "Creature — Bird" }), + ], + Format.COMMANDER, + ), + ).toHaveLength(1); + }); +}); + +describe("Zirda, the Dawnwaker — permanents with activated abilities", () => { + it("flags a permanent with no activated ability", () => { + expect( + run([ + companion("Zirda, the Dawnwaker"), + card("Tapper", { + typeLine: "Creature — Human", + oracleText: "{T}: Tap target creature.", + }), + card("Mountain", { typeLine: "Basic Land — Mountain" }), + ]), + ).toEqual([]); + expect( + run([ + companion("Zirda, the Dawnwaker"), + card("Vanilla", { typeLine: "Creature — Bear", oracleText: "" }), + ]), + ).toHaveLength(1); + }); + + it("flags a creature with null oracle text (no activated ability)", () => { + expect( + run([ + companion("Zirda, the Dawnwaker"), + card("Blank", { typeLine: "Creature — Bear" }), + ]), + ).toHaveLength(1); + }); +}); + +describe("companion restriction only judges mainboard + commander", () => { + it("ignores cards in the sideboard and considering zones", () => { + const issues = run([ + companion("Gyruda, Doom of Depths"), + card("Even", { cmc: 2 }), + card("Odd Sideboard", { cmc: 3, zone: Zone.SIDEBOARD }), + card("Odd Considering", { cmc: 1, zone: Zone.CONSIDERING }), + ]); + expect(issues).toEqual([]); + }); +}); + +describe("companion legality flows through fullLegality + formatting", () => { + it("surfaces a companion_violation from validateDeck-style checks", () => { + const snap = snapshotFromCards({ + format: Format.MODERN, + cards: [ + companion("Gyruda, Doom of Depths"), + card("Odd Card", { cmc: 1 }), + ], + }); + const issues = fullLegality(snap); + const violation = issues.find((i) => i.kind === "companion_violation"); + expect(violation).toBeDefined(); + expect(formatLegalityIssue(violation!)).toContain( + "Companion restriction not met", + ); + }); +}); + +describe("registry", () => { + it("covers all ten companions", () => { + expect(COMPANION_NAMES.size).toBe(10); + expect(Object.keys(companionRestrictions)).toHaveLength(10); + }); +}); diff --git a/lib/deck/legality/companions.ts b/lib/deck/legality/companions.ts new file mode 100644 index 0000000..97432be --- /dev/null +++ b/lib/deck/legality/companions.ts @@ -0,0 +1,331 @@ +import { Format, Zone } from "@/lib/generated/prisma/enums"; +import type { + DeckSnapshot, + LegalityIssue, + SnapshotCard, +} from "@/lib/deck/mutation/types"; +import { isBasicLandCard } from "./shared"; +import { COMMANDER_DECK_SIZE, SIXTY_CARD_MIN } from "./constants"; + +/** + * Companion deckbuilding restrictions. + * + * These conditions come from each card's oracle text (the MTG comprehensive + * rules), NOT from Scryfall's structured data — Scryfall exposes the + * `Companion` keyword but not the machine-readable restriction. There is a + * fixed, closed set of ten companions, so the restrictions are encoded here as + * predicates keyed by card name. See `docs/agents/domain.md` / + * `CONTEXT.md` for the sourcing decision. + */ + +/** The subset of a card needed to judge a companion restriction. */ +type JudgedCard = { + name: string; + typeLine: string | null; + cmc: number | null; + manaCost: string | null; + oracleText: string | null; + quantity: number; +}; + +type RestrictionContext = { format: Format }; + +type RestrictionResult = { ok: boolean; reason: string }; + +type CompanionRestriction = { + /** Short human-readable summary of the deckbuilding restriction. */ + summary: string; + check: (cards: JudgedCard[], ctx: RestrictionContext) => RestrictionResult; +}; + +const OK: RestrictionResult = { ok: true, reason: "" }; + +const PERMANENT_TYPES = new Set([ + "Artifact", + "Battle", + "Creature", + "Enchantment", + "Land", + "Planeswalker", +]); + +const CARD_TYPES = [ + "Artifact", + "Battle", + "Creature", + "Enchantment", + "Instant", + "Kindred", + "Land", + "Planeswalker", + "Sorcery", +] as const; + +function manaValue(card: JudgedCard): number { + return card.cmc ?? 0; +} + +function isLand(typeLine: string | null): boolean { + return !!typeLine && /\bLand\b/.test(typeLine); +} + +function cardTypesOf(typeLine: string | null): string[] { + if (!typeLine) return []; + /* c8 ignore next */ + const front = typeLine.split("—")[0] ?? typeLine; + return CARD_TYPES.filter((t) => new RegExp(`\\b${t}\\b`).test(front)); +} + +function isPermanent(typeLine: string | null): boolean { + return cardTypesOf(typeLine).some((t) => PERMANENT_TYPES.has(t)); +} + +function creatureSubtypesOf(typeLine: string | null): string[] { + if (!typeLine || !typeLine.includes("—")) return []; + /* c8 ignore next */ + const back = typeLine.split("—")[1] ?? ""; + return back + .trim() + .split(/\s+/) + .filter(Boolean); +} + +/** Tokenise a mana cost like `{2}{G}{G}` into its symbol strings. */ +function manaSymbols(manaCost: string | null): string[] { + if (!manaCost) return []; + return [...manaCost.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]!); +} + +/** Generic/variable symbols don't count toward Jegantha's restriction. */ +function isGenericSymbol(symbol: string): boolean { + return /^\d+$/.test(symbol) || /^[XYZ]$/.test(symbol); +} + +function hasActivatedAbility(card: JudgedCard): boolean { + // Basic lands have the intrinsic "{T}: Add ..." mana ability even though + // their oracle text is empty on Scryfall. + if (isBasicLandCard(card.typeLine, card.name)) return true; + const oracle = card.oracleText ?? ""; + if (/:\s/.test(oracle)) return true; + return /\b(Equip|Crew|Reconfigure)\b/.test(oracle); +} + +function formatMinimum(format: Format): number { + switch (format) { + case Format.COMMANDER: + case Format.OATHBREAKER: + return COMMANDER_DECK_SIZE; + default: + return SIXTY_CARD_MIN; + } +} + +const KAHEERA_TYPES = new Set([ + "Cat", + "Elemental", + "Nightmare", + "Dinosaur", + "Beast", +]); + +export const companionRestrictions: Record = { + "Gyruda, Doom of Depths": { + summary: "Every card in your deck has an even mana value.", + check: (cards) => { + const bad = cards.find((c) => manaValue(c) % 2 !== 0); + return bad + ? { ok: false, reason: `${bad.name} has an odd mana value` } + : OK; + }, + }, + "Jegantha, the Wellspring": { + summary: "No card has more than one of the same mana symbol in its cost.", + check: (cards) => { + for (const c of cards) { + const counts = new Map(); + for (const sym of manaSymbols(c.manaCost)) { + if (isGenericSymbol(sym)) continue; + counts.set(sym, (counts.get(sym) ?? 0) + 1); + } + if ([...counts.values()].some((n) => n > 1)) { + return { ok: false, reason: `${c.name} repeats a mana symbol` }; + } + } + return OK; + }, + }, + "Kaheera, the Orphanguard": { + summary: + "Each creature card is a Cat, Elemental, Nightmare, Dinosaur, and/or Beast.", + check: (cards) => { + for (const c of cards) { + if (!cardTypesOf(c.typeLine).includes("Creature")) continue; + const subs = creatureSubtypesOf(c.typeLine); + const hasChangeling = /\bChangeling\b/i.test(c.oracleText ?? ""); + if (!hasChangeling && !subs.some((s) => KAHEERA_TYPES.has(s))) { + return { + ok: false, + reason: `${c.name} is a creature outside the allowed types`, + }; + } + } + return OK; + }, + }, + "Keruga, the Macrosage": { + summary: "Each nonland card has mana value 3 or greater.", + check: (cards) => { + const bad = cards.find((c) => !isLand(c.typeLine) && manaValue(c) < 3); + return bad + ? { ok: false, reason: `${bad.name} has mana value less than 3` } + : OK; + }, + }, + "Lurrus of the Dream-Den": { + summary: "Each permanent card has mana value 2 or less.", + check: (cards) => { + const bad = cards.find( + (c) => isPermanent(c.typeLine) && manaValue(c) > 2, + ); + return bad + ? { + ok: false, + reason: `${bad.name} is a permanent with mana value greater than 2`, + } + : OK; + }, + }, + "Lutri, the Spellchaser": { + summary: "No two cards in your deck have the same name (singleton).", + check: (cards) => { + const counts = new Map(); + for (const c of cards) { + if (isBasicLandCard(c.typeLine, c.name)) continue; + counts.set(c.name, (counts.get(c.name) ?? 0) + c.quantity); + } + for (const [name, n] of counts) { + if (n > 1) { + return { ok: false, reason: `${name} appears more than once` }; + } + } + return OK; + }, + }, + "Obosh, the Preypiercer": { + summary: "Each card in your deck has an odd mana value.", + check: (cards) => { + const bad = cards.find( + (c) => !isLand(c.typeLine) && manaValue(c) % 2 === 0, + ); + return bad + ? { ok: false, reason: `${bad.name} has an even mana value` } + : OK; + }, + }, + "Umori, the Collector": { + summary: "Each nonland card shares a card type.", + check: (cards) => { + const nonland = cards.filter((c) => !isLand(c.typeLine)); + if (nonland.length === 0) return OK; + const shared = new Set( + cardTypesOf(nonland[0]!.typeLine).filter((t) => t !== "Land"), + ); + for (const c of nonland.slice(1)) { + const types = new Set(cardTypesOf(c.typeLine)); + for (const t of [...shared]) { + if (!types.has(t)) shared.delete(t); + } + if (shared.size === 0) break; + } + return shared.size === 0 + ? { ok: false, reason: "nonland cards do not all share a card type" } + : OK; + }, + }, + "Yorion, Sky Nomad": { + summary: "Your deck has at least 20 cards above the format minimum.", + check: (cards, ctx) => { + const total = cards.reduce((s, c) => s + c.quantity, 0); + const needed = formatMinimum(ctx.format) + 20; + return total < needed + ? { + ok: false, + reason: `deck has ${total} cards, needs at least ${needed}`, + } + : OK; + }, + }, + "Zirda, the Dawnwaker": { + summary: "Each permanent card has an activated ability.", + check: (cards) => { + const bad = cards.find( + (c) => isPermanent(c.typeLine) && !hasActivatedAbility(c), + ); + return bad + ? { + ok: false, + reason: `${bad.name} is a permanent without an activated ability`, + } + : OK; + }, + }, +}; + +/** Names of every card maindeck recognises as a companion. */ +export const COMPANION_NAMES: ReadonlySet = new Set( + Object.keys(companionRestrictions), +); + +function toJudged(c: SnapshotCard): JudgedCard { + return { + name: c.cardName, + typeLine: c.typeLine, + cmc: c.cmc ?? null, + manaCost: c.manaCost ?? null, + oracleText: c.oracleText ?? null, + quantity: c.quantity, + }; +} + +/** + * Universal rule: for each card in the COMPANION zone, validate its + * deckbuilding restriction against the deck's mainboard + commander cards. + * Cards in the companion zone that aren't recognised companions are flagged. + */ +export function companionRule(snap: DeckSnapshot): LegalityIssue[] { + const companions = snap.cards.filter((c) => c.zone === Zone.COMPANION); + if (companions.length === 0) return []; + + const judged = snap.cards + .filter((c) => c.zone === Zone.MAINBOARD || c.zone === Zone.COMMANDER) + .map(toJudged); + + const issues: LegalityIssue[] = []; + if (companions.length > 1) { + issues.push({ + kind: "companion_violation", + cardName: companions[companions.length - 1]!.cardName, + reason: "a deck may have only one companion", + }); + } + for (const comp of companions) { + const restriction = companionRestrictions[comp.cardName]; + if (!restriction) { + issues.push({ + kind: "companion_violation", + cardName: comp.cardName, + reason: "not a companion", + }); + continue; + } + const result = restriction.check(judged, { format: snap.format }); + if (!result.ok) { + issues.push({ + kind: "companion_violation", + cardName: comp.cardName, + reason: result.reason, + }); + } + } + return issues; +} diff --git a/lib/deck/legality/index.ts b/lib/deck/legality/index.ts index 17c3188..c3c6cf6 100644 --- a/lib/deck/legality/index.ts +++ b/lib/deck/legality/index.ts @@ -6,6 +6,7 @@ import type { DeckSnapshot, LegalityIssue, } from "@/lib/deck/mutation/types"; +import { companionRule } from "./companions"; import { formatRules, isColorIdentityFormat, isSingletonFormat } from "./format-rules"; import { formatLegalityIssue, @@ -43,7 +44,7 @@ function checkPerCardLegality(snap: DeckSnapshot): LegalityIssue[] { */ export function fullLegality(snap: DeckSnapshot): LegalityIssue[] { const perFormat = formatRules[snap.format].flatMap((rule) => rule(snap)); - return [...checkPerCardLegality(snap), ...perFormat]; + return [...checkPerCardLegality(snap), ...perFormat, ...companionRule(snap)]; } export function validateDeck(deck: Deck): DeckLegality { diff --git a/lib/deck/legality/shared.ts b/lib/deck/legality/shared.ts index d92f79b..76cde38 100644 --- a/lib/deck/legality/shared.ts +++ b/lib/deck/legality/shared.ts @@ -47,6 +47,8 @@ export function formatLegalityIssue(issue: LegalityIssue): string { return `${issue.cardName}: Singleton format — ${issue.quantity} copies in deck`; case "color_identity_violation": return `${issue.cardName}: Outside commander color identity (${issue.offending.map((c) => `{${c}}`).join("")})`; + case "companion_violation": + return `${issue.cardName}: Companion restriction not met — ${issue.reason}`; case "category_zone_mismatch": return "Subcategories only apply to MAINBOARD cards"; } @@ -114,7 +116,12 @@ export function colorIdentityRule(snap: DeckSnapshot): LegalityIssue[] { const allowed = [...commanderIdentity]; const issues: LegalityIssue[] = []; for (const dc of snap.cards) { - if (dc.zone !== Zone.MAINBOARD && dc.zone !== Zone.COMMANDER) continue; + if ( + dc.zone !== Zone.MAINBOARD && + dc.zone !== Zone.COMMANDER && + dc.zone !== Zone.COMPANION + ) + continue; const off = offIdentityColors(dc.colorIdentity, allowed); if (off.length > 0) { issues.push({ diff --git a/lib/deck/mutation/snapshot-pure.ts b/lib/deck/mutation/snapshot-pure.ts index 198ac1c..a917d40 100644 --- a/lib/deck/mutation/snapshot-pure.ts +++ b/lib/deck/mutation/snapshot-pure.ts @@ -55,6 +55,9 @@ export function snapshotFromDeck(deck: Deck): DeckSnapshot { legalities: (dc.card.legalities as Legalities) ?? {}, printingId: dc.printingId ?? null, isFoil: dc.isFoil, + cmc: dc.card.cmc ?? null, + manaCost: dc.card.manaCost ?? null, + oracleText: dc.card.oracleText ?? null, })); return { deckId: deck.id, diff --git a/lib/deck/mutation/types.ts b/lib/deck/mutation/types.ts index 2d7ec4a..723bb2f 100644 --- a/lib/deck/mutation/types.ts +++ b/lib/deck/mutation/types.ts @@ -10,6 +10,7 @@ export type LegalityIssue = | { kind: "card_not_legal"; cardName: string } | { kind: "singleton_violation"; cardName: string; quantity: number } | { kind: "color_identity_violation"; cardName: string; offending: string[] } + | { kind: "companion_violation"; cardName: string; reason: string } | { kind: "category_zone_mismatch" }; export type PlannedChange = @@ -44,6 +45,10 @@ export type SnapshotCard = { printingId: number | null; isFoil: boolean; isNew?: boolean; + /** Companion-restriction inputs; optional so non-deck snapshots can omit them. */ + cmc?: number | null; + manaCost?: string | null; + oracleText?: string | null; }; export type DeckSnapshot = { diff --git a/prisma/migrations/20260628000000_zone_companion/migration.sql b/prisma/migrations/20260628000000_zone_companion/migration.sql new file mode 100644 index 0000000..f5883a9 --- /dev/null +++ b/prisma/migrations/20260628000000_zone_companion/migration.sql @@ -0,0 +1,3 @@ +-- Add the COMPANION zone so a deck can pin a companion card alongside the +-- mainboard, sideboard, considering, and commander zones. +ALTER TYPE "zone" ADD VALUE 'COMPANION'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 252d380..26dcf4c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -59,6 +59,7 @@ enum Zone { SIDEBOARD CONSIDERING COMMANDER + COMPANION @@map("zone") }