Skip to content
Merged
7 changes: 5 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

Expand All @@ -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**:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion app/_components/builder/__tests__/move-card-menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ describe("orderZoneOptions", () => {
const order = orderZoneOptions(false).map((o) => o.value);
expect(order).toEqual([
"COMMANDER",
"COMPANION",
"MAINBOARD",
"SIDEBOARD",
"CONSIDERING",
Expand All @@ -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",
Expand All @@ -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);
});
});
1 change: 1 addition & 0 deletions app/_components/builder/card-row-sortable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const ROW_ZONE_BY_KEY: Record<string, Zone> = {
"2": "MAINBOARD",
"3": "SIDEBOARD",
"4": "CONSIDERING",
"5": "COMPANION",
};

function shouldIgnoreRowKeyEvent(e: React.KeyboardEvent<HTMLLIElement>): boolean {
Expand Down
1 change: 1 addition & 0 deletions app/_components/builder/move-card-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
25 changes: 24 additions & 1 deletion app/_components/builder/sideboard-considering-dnd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -176,7 +181,24 @@ export function SideboardConsideringDnd({
const commanderSet = cards.some((c) => c.zone === Zone.COMMANDER);

return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="flex flex-col gap-6">
{companion.length > 0 && (
<ZoneBlockDnd
title="Companion"
emptyHint="A card whose deckbuilding restriction your deck must satisfy."
zone={Zone.COMPANION}
cards={companion}
deckId={deck.id}
format={deck.format}
subcategories={subcategoryNames}
commanderSet={commanderSet}
dispatch={dispatch}
viewerId={viewerId}
viewerHoldings={viewerHoldings}
viewOptions={viewOptions}
/>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<ZoneBlockDnd
title="Sideboard"
emptyHint="Cards you swap in between games or fetch from outside the game."
Expand Down Expand Up @@ -205,6 +227,7 @@ export function SideboardConsideringDnd({
viewerHoldings={viewerHoldings}
viewOptions={viewOptions}
/>
</div>
</div>
);
}
23 changes: 22 additions & 1 deletion app/_components/builder/sideboard-considering.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="flex flex-col gap-6">
{companion.length > 0 && (
<ZoneBlock
title="Companion"
emptyHint="A card whose deckbuilding restriction your deck must satisfy."
cards={companion}
deckId={deck.id}
format={deck.format}
subcategories={subcategoryNames}
dispatch={dispatch}
viewerId={viewerId}
viewerHoldings={viewerHoldings}
viewOptions={viewOptions}
/>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<ZoneBlock
title="Sideboard"
emptyHint="Cards you swap in between games or fetch from outside the game."
Expand All @@ -151,6 +171,7 @@ export function SideboardConsidering({
viewerHoldings={viewerHoldings}
viewOptions={viewOptions}
/>
</div>
</div>
);
}
9 changes: 8 additions & 1 deletion app/_components/deck/deck-history-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const ZONE_LABEL: Record<Zone, string> = {
SIDEBOARD: "Sideboard",
CONSIDERING: "Considering",
COMMANDER: "Commander",
COMPANION: "Companion",
};

export function DeckHistoryList({
Expand Down Expand Up @@ -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) => ({
Expand Down
1 change: 1 addition & 0 deletions app/_components/header-search/deck-mode-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {

const ZONE_LABEL: Record<Zone, string> = {
COMMANDER: "Commander",
COMPANION: "Companion",
MAINBOARD: "Mainboard",
SIDEBOARD: "Sideboard",
CONSIDERING: "Considering",
Expand Down
2 changes: 2 additions & 0 deletions app/_components/hotkeys/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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" },
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0002-deck-illegality-is-a-ui-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 8 additions & 1 deletion lib/deck/__tests__/add-intent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand All @@ -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<typeof i, { kind: "dest-zone" }> =>
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", () => {
Expand Down
1 change: 1 addition & 0 deletions lib/deck/add-intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions lib/deck/io/__tests__/serialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
4 changes: 4 additions & 0 deletions lib/deck/io/adapters/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/;
Expand Down Expand Up @@ -65,6 +67,7 @@ export function parseLineBased(

export const ZONE_ORDER: Zone[] = [
"COMMANDER",
"COMPANION",
"MAINBOARD",
"SIDEBOARD",
"CONSIDERING",
Expand All @@ -75,6 +78,7 @@ export const ZONE_LABEL: Record<Zone, string> = {
SIDEBOARD: "Sideboard",
CONSIDERING: "Considering",
COMMANDER: "Commander",
COMPANION: "Companion",
};

export function groupByZone(
Expand Down
6 changes: 6 additions & 0 deletions lib/deck/io/adapters/arena.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (mainLines.length > 0) {
if (lines.length > 0) lines.push("");
lines.push("Deck");
lines.push(...mainLines);
}
Expand Down
4 changes: 1 addition & 3 deletions lib/deck/io/serialize.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down
Loading
Loading