Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions client/src/adapter/draft-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface DraftCardInstance {
colors: string[];
cmc: number;
type_line: string;
is_land: boolean;
}

// @sync-with: crates/draft-core/src/view.rs
Expand Down
75 changes: 70 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 @@ -219,6 +219,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 +233,71 @@ export function LimitedDeckBuilder({
[pool, mainDeck],
);

const totalLands = useMemo(
const landNameSet = useMemo(
() =>
new Set(
pool
.filter((c) => c.is_land)
.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);
}
for (const [name, count] of Object.entries(extra)) {
if (count > 0) {
countMap.set(name, (countMap.get(name) ?? 0) + count);
}
}
const lines: string[] = [];
for (const [name, count] of countMap) {
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 +308,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 +398,7 @@ export function LimitedDeckBuilder({

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

{/* Actions */}
Expand All @@ -358,6 +412,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
102 changes: 100 additions & 2 deletions client/src/components/draft/__tests__/LimitedDeckBuilder.test.tsx
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 All @@ -38,6 +42,7 @@ const TEST_VIEW: BuilderView = {
colors: ["U"],
cmc: 3,
type_line: "Creature - Drake",
is_land: false,
},
],
seats: [],
Expand All @@ -53,6 +58,24 @@ 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",
is_land: false,
},
],
};

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

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

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

it("uses domain land classification for deck accounting", () => {
const view: BuilderView = {
...TEST_VIEW,
pool: [
{
instance_id: "domain-land",
name: "Domain Land",
set_code: "tst",
collector_number: "100",
rarity: "rare",
colors: [],
cmc: 0,
type_line: "Creature",
is_land: true,
},
],
};

render(
<LimitedDeckBuilder
view={view}
mainDeck={["Domain Land"]}
landCounts={{}}
onAddToDeck={() => {}}
onRemoveFromDeck={() => {}}
onSetLandCount={() => {}}
onSubmitDeck={() => {}}
showSuggestions={false}
/>,
);

expect(screen.getByRole("meter", { name: "Mana value 0" })).toHaveAttribute(
"aria-valuenow",
"0",
);
});

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", "Island"]}
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",
"3 Island",
"1 Forest",
"",
"Sideboard",
"1 Eager Cadet",
].join("\n"),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument(),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const view: DraftPlayerView = {
colors: ["R"],
cmc: 1,
type_line: "Instant",
is_land: false,
},
],
pool: [],
Expand Down
2 changes: 2 additions & 0 deletions client/src/i18n/locales/de/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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
2 changes: 2 additions & 0 deletions client/src/i18n/locales/en/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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
2 changes: 2 additions & 0 deletions client/src/i18n/locales/es/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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
2 changes: 2 additions & 0 deletions client/src/i18n/locales/fr/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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
2 changes: 2 additions & 0 deletions client/src/i18n/locales/it/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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
2 changes: 2 additions & 0 deletions client/src/i18n/locales/pl/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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
2 changes: 2 additions & 0 deletions client/src/i18n/locales/pt/draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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
1 change: 1 addition & 0 deletions client/src/network/__tests__/draftProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe("draftProtocol", () => {
colors: ["W", "U"],
cmc: i % 7,
type_line: "Creature - Human Wizard",
is_land: false,
})),
pool: [],
seats: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ describe("multiplayerDraftStore", () => {
colors: ["R"],
cmc: 1,
type_line: "Instant",
is_land: false,
},
],
},
Expand Down
2 changes: 2 additions & 0 deletions crates/draft-core/src/cube.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ fn card_instance_from_face(face: &CardFace, index: usize, copy: u32) -> DraftCar
colors: face.color_identity.iter().map(mana_color_letter).collect(),
cmc: face.mana_cost.mana_value().min(u32::from(u8::MAX)) as u8,
type_line: type_line(face),
is_land: face.card_type.core_types.contains(&CoreType::Land),
}
}

Expand Down Expand Up @@ -301,6 +302,7 @@ mod tests {
colors: Vec::new(),
cmc: 0,
type_line: String::new(),
is_land: false,
})
.collect();
let source = CubePackSource::new(cards);
Expand Down
3 changes: 2 additions & 1 deletion crates/draft-core/src/pack_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::pack_source::PackSource;
use crate::set_pool::{
LimitedSetPool, PackVariant, SheetCard, SheetDefinition, WeightedSheetChoice,
};
use crate::types::{DraftCardInstance, DraftPack};
use crate::types::{type_line_is_land, DraftCardInstance, DraftPack};

/// Generates draft packs from a `LimitedSetPool` using weighted random selection.
/// Set-specific exceptions (bonus sheets, Mystical Archive, etc.) are expressed
Expand Down Expand Up @@ -128,6 +128,7 @@ impl PackSource for PackGenerator {
colors: card.colors.clone(),
cmc: card.cmc,
type_line: card.type_line.clone(),
is_land: type_line_is_land(&card.type_line),
})
.collect();

Expand Down
1 change: 1 addition & 0 deletions crates/draft-core/src/pack_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ impl PackSource for FixturePackSource {
colors: Vec::new(),
cmc: 0,
type_line: String::new(),
is_land: false,
})
.collect();
DraftPack(cards)
Expand Down
Loading
Loading