Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 74 additions & 5 deletions client/src/components/draft/LimitedDeckBuilder.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion } from "framer-motion";

Expand Down Expand Up @@ -176,6 +176,12 @@ function computeRemainingPool(
return remaining;
}

function primaryTypeLineIsLand(typeLine: string): boolean {
const primary = typeLine.split(" // ")[0] ?? typeLine;
const coreTypes = primary.split("—")[0] ?? primary;
return coreTypes.split(/\s+/).some((word) => word.toLowerCase() === "land");
}

// ── Main component ──────────────────────────────────────────────────────

interface LimitedDeckBuilderProps {
Expand Down Expand Up @@ -219,6 +225,7 @@ export function LimitedDeckBuilder({
const submitDeck = onSubmitDeck ?? quickSubmitDeck;

const [hoveredCard, setHoveredCard] = useState<CardHoverInfo | null>(null);
const [copied, setCopied] = useState(false);

const pool = useMemo(() => view?.pool ?? [], [view?.pool]);

Expand All @@ -232,18 +239,69 @@ export function LimitedDeckBuilder({
[pool, mainDeck],
);

const totalLands = useMemo(
const landNameSet = useMemo(
() =>
new Set(
pool
.filter((c) => primaryTypeLineIsLand(c.type_line))
.map((c) => c.name),
),
[pool],
);

const mainDeckSpells = useMemo(
() => mainDeck.filter((name) => !landNameSet.has(name)),
[mainDeck, landNameSet],
);

const deckLandCount = mainDeck.length - mainDeckSpells.length;

const basicLands = useMemo(
() => Object.values(landCounts).reduce((sum, n) => sum + n, 0),
[landCounts],
);

const totalCards = mainDeck.length + totalLands;
const totalCards = mainDeck.length + basicLands;
const minDeckSize = view?.min_deck_size ?? 40;
const addableCards = view?.addable_cards?.length
? view.addable_cards
: BASIC_LANDS.map((land) => land.name);
const deckValid = totalCards >= minDeckSize;

const copyDeckList = useCallback(() => {
const toLines = (names: string[], extra: Record<string, number> = {}): string[] => {
const countMap = new Map<string, number>();
for (const name of names) {
countMap.set(name, (countMap.get(name) ?? 0) + 1);
}
const lines: string[] = [];
for (const [name, count] of countMap) {
lines.push(`${count} ${name}`);
}
for (const [name, count] of Object.entries(extra)) {
if (count > 0) lines.push(`${count} ${name}`);
}
return lines;
};

const sideboardNames = remainingPool.map((c) => c.name);
const deckLines = toLines(mainDeck, landCounts);
const sideboardLines = toLines(sideboardNames);
Comment thread
klyusba marked this conversation as resolved.

const text = [
"Deck",
...deckLines,
"",
"Sideboard",
...sideboardLines,
].join("\n");

void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1800);
});
}, [mainDeck, landCounts, remainingPool]);

if (!view) return null;

return (
Expand All @@ -254,7 +312,7 @@ export function LimitedDeckBuilder({
mobileLayout="compact"
onDismiss={() => setHoveredCard(null)}
/>
<DeckStatus spells={mainDeck.length} lands={totalLands} min={minDeckSize} />
<DeckStatus spells={mainDeckSpells.length} lands={basicLands + deckLandCount} min={minDeckSize} />

<div className="flex min-h-0 flex-1 gap-6">
{/* Left column: Pool + Main Deck */}
Expand Down Expand Up @@ -344,7 +402,7 @@ export function LimitedDeckBuilder({

{/* Mana curve */}
<section>
<ManaCurve pool={pool} cards={mainDeck} />
<ManaCurve pool={pool} cards={mainDeckSpells} />
</section>

{/* Actions */}
Expand All @@ -358,6 +416,17 @@ export function LimitedDeckBuilder({
{t("limitedDeck.suggestDeck")}
</button>
)}
<button
type="button"
onClick={copyDeckList}
className={menuButtonClass({
tone: "neutral",
size: "sm",
className: "w-full",
})}
>
{copied ? t("limitedDeck.copied") : t("limitedDeck.copyList")}
Comment thread
klyusba marked this conversation as resolved.
</button>

<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";

import { LimitedDeckBuilder } from "../LimitedDeckBuilder";

Expand All @@ -19,6 +19,10 @@ vi.mock("../../../stores/draftStore", () => ({
}),
}));

afterEach(() => {
cleanup();
});

type BuilderView = NonNullable<NonNullable<Parameters<typeof LimitedDeckBuilder>[0]>["view"]>;

const TEST_VIEW: BuilderView = {
Expand Down Expand Up @@ -53,6 +57,23 @@ const TEST_VIEW: BuilderView = {
pairings: [],
};

const COPY_VIEW: BuilderView = {
...TEST_VIEW,
pool: [
...TEST_VIEW.pool,
{
instance_id: "card-2",
name: "Eager Cadet",
set_code: "dmu",
collector_number: "1",
rarity: "common",
colors: ["W"],
cmc: 1,
type_line: "Creature - Human Soldier",
},
],
};

function Harness() {
const [mainDeck, setMainDeck] = useState<string[]>([]);

Expand Down Expand Up @@ -89,4 +110,42 @@ describe("LimitedDeckBuilder", () => {

expect(threeDropBucket).toHaveAttribute("aria-valuenow", "1");
});

it("copies the current deck list to the clipboard", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});

render(
<LimitedDeckBuilder
view={COPY_VIEW}
mainDeck={["Wind Drake"]}
landCounts={{ Island: 2, Plains: 0, Forest: 1 }}
onAddToDeck={() => {}}
onRemoveFromDeck={() => {}}
onSetLandCount={() => {}}
onSubmitDeck={() => {}}
showSuggestions={false}
/>,
);

fireEvent.click(screen.getByRole("button", { name: "Copy Deck List" }));

expect(writeText).toHaveBeenCalledWith(
[
"Deck",
"1 Wind Drake",
"2 Island",
"1 Forest",
"",
"Sideboard",
"1 Eager Cadet",
].join("\n"),
Comment thread
klyusba marked this conversation as resolved.
Outdated
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument(),
);
});
});
4 changes: 4 additions & 0 deletions client/src/i18n/locales/de/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
},
"manaCurve": {
"title": "Manakurve",
"allCards": "Alle",
"creatures": "Kreaturen",
"bucketLabel": "Manawert {{bucket}}"
},
"pickTimer": {
Expand Down Expand Up @@ -106,6 +108,8 @@
"addableCards": "Hinzufügbare Karten",
"autoLands": "Auto-Länder",
"suggestDeck": "Deck vorschlagen",
"copyList": "Deckliste kopieren",
"copied": "Kopiert!",
"submitDeck": "Deck einreichen",
"removeCard": "{{name}} entfernen",
"addCard": "{{name}} hinzufügen",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/en/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
},
"manaCurve": {
"title": "Mana Curve",
"allCards": "All",
"creatures": "Creatures",
"bucketLabel": "Mana value {{bucket}}"
},
"pickTimer": {
Expand Down Expand Up @@ -106,6 +108,8 @@
"addableCards": "Addable Cards",
"autoLands": "Auto Lands",
"suggestDeck": "Suggest Deck",
"copyList": "Copy Deck List",
"copied": "Copied!",
"submitDeck": "Submit Deck",
"removeCard": "Remove {{name}}",
"addCard": "Add {{name}}",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/es/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
},
"manaCurve": {
"title": "Curva de maná",
"allCards": "Todo",
"creatures": "Criaturas",
"bucketLabel": "Valor de maná {{bucket}}"
},
"pickTimer": {
Expand Down Expand Up @@ -106,6 +108,8 @@
"addableCards": "Cartas añadibles",
"autoLands": "Tierras automáticas",
"suggestDeck": "Sugerir mazo",
"copyList": "Copiar lista del mazo",
"copied": "¡Copiado!",
"submitDeck": "Enviar mazo",
"removeCard": "Quitar {{name}}",
"addCard": "Añadir {{name}}",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/fr/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
},
"manaCurve": {
"title": "Courbe de mana",
"allCards": "Toutes",
"creatures": "Créatures",
"bucketLabel": "Valeur de mana {{bucket}}"
},
"pickTimer": {
Expand Down Expand Up @@ -106,6 +108,8 @@
"addableCards": "Cartes ajoutables",
"autoLands": "Terrains auto",
"suggestDeck": "Suggérer un deck",
"copyList": "Copier la liste du deck",
"copied": "Copié !",
"submitDeck": "Soumettre le deck",
"removeCard": "Retirer {{name}}",
"addCard": "Ajouter {{name}}",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/it/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
},
"manaCurve": {
"title": "Curva del mana",
"allCards": "Tutte",
"creatures": "Creature",
"bucketLabel": "Valore di mana {{bucket}}"
},
"pickTimer": {
Expand Down Expand Up @@ -106,6 +108,8 @@
"addableCards": "Carte aggiungibili",
"autoLands": "Terre automatiche",
"suggestDeck": "Suggerisci mazzo",
"copyList": "Copia lista mazzo",
"copied": "Copiato!",
"submitDeck": "Invia mazzo",
"removeCard": "Rimuovi {{name}}",
"addCard": "Aggiungi {{name}}",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/pl/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
},
"manaCurve": {
"title": "Krzywa many",
"allCards": "Wszystkie",
"creatures": "Stwory",
"bucketLabel": "Wartość many {{bucket}}"
},
"pickTimer": {
Expand Down Expand Up @@ -106,6 +108,8 @@
"addableCards": "Karty do dodania",
"autoLands": "Automatyczne ziemie",
"suggestDeck": "Zaproponuj talię",
"copyList": "Skopiuj listę talii",
"copied": "Skopiowano!",
"submitDeck": "Zatwierdź talię",
"removeCard": "Usuń {{name}}",
"addCard": "Dodaj {{name}}",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/pt/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
},
"manaCurve": {
"title": "Curva de Mana",
"allCards": "Todas",
"creatures": "Criaturas",
"bucketLabel": "Valor de mana {{bucket}}"
},
"pickTimer": {
Expand Down Expand Up @@ -106,6 +108,8 @@
"addableCards": "Cartas Adicionáveis",
"autoLands": "Terrenos Automáticos",
"suggestDeck": "Sugerir Deck",
"copyList": "Copiar Lista do Deck",
"copied": "Copiado!",
"submitDeck": "Enviar Deck",
"removeCard": "Remover {{name}}",
"addCard": "Adicionar {{name}}",
Expand Down
28 changes: 10 additions & 18 deletions crates/draft-core/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,10 @@ use serde::{Deserialize, Serialize};
use crate::types::DeckAddableCards;

/// Standard basic land names that are always available in unlimited quantity.
/// CR 100.2a: basic lands are exempt from copy limits. All cards with the
/// Basic supertype are listed here (five originals, Wastes, and all
/// Snow-Covered variants).
pub const STANDARD_BASIC_LANDS: &[&str] = &[
"Plains",
"Island",
"Swamp",
"Mountain",
"Forest",
"Wastes",
"Snow-Covered Plains",
"Snow-Covered Island",
"Snow-Covered Swamp",
"Snow-Covered Mountain",
"Snow-Covered Forest",
];
/// MTR 7.2: Players may add an unlimited number of cards named Plains, Island, Swamp, Mountain,
/// or Forest. They may not add additional snow basic land cards (e.g., Snow-Covered Forest, etc)
/// or Wastes basic land cards, even in formats in which they are legal.
pub const STANDARD_BASIC_LANDS: &[&str] = &["Plains", "Island", "Swamp", "Mountain", "Forest"];

#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
pub enum LimitedDeckError {
Expand Down Expand Up @@ -184,12 +172,16 @@ mod tests {
}

#[test]
fn wastes_count_as_basic() {
fn wastes_not_count_as_basic() {
let pool: Vec<String> = (0..23).map(|i| format!("Card {i}")).collect();
let mut deck: Vec<String> = (0..23).map(|i| format!("Card {i}")).collect();
deck.extend(std::iter::repeat_n(s("Wastes"), 17));
assert_eq!(deck.len(), 40);
assert!(validate_limited_deck(&deck, &pool, &addable(), 40).is_ok());
let errors = validate_limited_deck(&deck, &pool, &addable(), 40).unwrap_err();
assert!(errors.iter().any(|e| matches!(
e,
LimitedDeckError::NotInPool { name } if name == "Wastes"
)));
}

#[test]
Expand Down
Loading