Skip to content
Closed
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 @@ -12,6 +12,7 @@ export interface DraftCardInstance {
colors: string[];
cmc: number;
type_line: string;
is_land: boolean;
draft_effect?: "additional_pick";
}

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 { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion } from "framer-motion";

Expand Down Expand Up @@ -234,6 +234,7 @@ export function LimitedDeckBuilder({
const [hoveredCard, setHoveredCard] = useState<CardHoverInfo | null>(null);
const [addableQuery, setAddableQuery] = useState("");
const [localSubmissionError, setLocalSubmissionError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);

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

Expand All @@ -247,12 +248,29 @@ 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 ?? BASIC_LANDS.map((land) => land.name);
const filteredAddableCards = useMemo(() => {
Expand All @@ -278,6 +296,42 @@ export function LimitedDeckBuilder({
}
};

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 @@ -287,7 +341,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 @@ -385,7 +439,7 @@ export function LimitedDeckBuilder({

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

{/* Actions */}
Expand All @@ -399,6 +453,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 { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";

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

Expand Down Expand Up @@ -46,6 +46,7 @@ const TEST_VIEW: BuilderView = {
colors: ["U"],
cmc: 3,
type_line: "Creature - Drake",
is_land: false,
},
],
draft_effects: [],
Expand All @@ -69,6 +70,24 @@ const TEST_VIEW: BuilderView = {
match_config: { match_type: "Bo1" },
};

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 @@ -208,4 +227,79 @@ describe("LimitedDeckBuilder", () => {
"Deck needs attention: card 'Watery Grave' is not in the drafted pool",
);
});

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 @@ -37,6 +37,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 @@ -146,6 +146,8 @@
"searchAddableCards": "Hinzufügbare Karten durchsuchen...",
"autoLands": "Auto-Länder",
"suggestDeck": "Deck vorschlagen",
"copyList": "Deckliste kopieren",
"copied": "Kopiert!",
"submitDeck": "Deck einreichen",
"validationTitle": "Das Deck benötigt Aufmerksamkeit",
"submitFailed": "Deck konnte nicht eingereicht werden",
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 @@ -146,6 +146,8 @@
"searchAddableCards": "Search addable cards...",
"autoLands": "Auto Lands",
"suggestDeck": "Suggest Deck",
"copyList": "Copy Deck List",
"copied": "Copied!",
"submitDeck": "Submit Deck",
"validationTitle": "Deck needs attention",
"submitFailed": "Unable to submit deck",
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 @@ -146,6 +146,8 @@
"searchAddableCards": "Buscar cartas añadibles...",
"autoLands": "Tierras automáticas",
"suggestDeck": "Sugerir mazo",
"copyList": "Copiar lista del mazo",
"copied": "¡Copiado!",
"submitDeck": "Enviar mazo",
"validationTitle": "El mazo necesita atención",
"submitFailed": "No se pudo enviar el mazo",
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 @@ -146,6 +146,8 @@
"searchAddableCards": "Rechercher des cartes ajoutables...",
"autoLands": "Terrains auto",
"suggestDeck": "Suggérer un deck",
"copyList": "Copier la liste du deck",
"copied": "Copié !",
"submitDeck": "Soumettre le deck",
"validationTitle": "Le deck nécessite votre attention",
"submitFailed": "Impossible de soumettre le deck",
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 @@ -146,6 +146,8 @@
"searchAddableCards": "Cerca carte aggiungibili...",
"autoLands": "Terre automatiche",
"suggestDeck": "Suggerisci mazzo",
"copyList": "Copia lista mazzo",
"copied": "Copiato!",
"submitDeck": "Invia mazzo",
"validationTitle": "Il mazzo richiede attenzione",
"submitFailed": "Impossibile inviare il mazzo",
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 @@ -146,6 +146,8 @@
"searchAddableCards": "Szukaj kart do dodania...",
"autoLands": "Automatyczne ziemie",
"suggestDeck": "Zaproponuj talię",
"copyList": "Skopiuj listę talii",
"copied": "Skopiowano!",
"submitDeck": "Zatwierdź talię",
"validationTitle": "Talia wymaga uwagi",
"submitFailed": "Nie można zatwierdzić talii",
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 @@ -146,6 +146,8 @@
"searchAddableCards": "Pesquisar cartas adicionáveis...",
"autoLands": "Terrenos Automáticos",
"suggestDeck": "Sugerir Deck",
"copyList": "Copiar Lista do Deck",
"copied": "Copiado!",
"submitDeck": "Enviar Deck",
"validationTitle": "O deck precisa de atenção",
"submitFailed": "Não foi possível enviar o deck",
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 @@ -202,6 +202,7 @@ describe("draftProtocol", () => {
colors: ["W", "U"],
cmc: i % 7,
type_line: "Creature - Human Wizard",
is_land: false,
})),
pool: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,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 @@ -162,6 +162,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),
draft_effect: face
.oracle_text
.as_deref()
Expand Down Expand Up @@ -306,6 +307,7 @@ mod tests {
colors: Vec::new(),
cmc: 0,
type_line: String::new(),
is_land: false,
draft_effect: None,
})
.collect();
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),
draft_effect: card.draft_effect,
})
.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,
draft_effect: None,
})
.collect();
Expand Down
Loading
Loading