Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
80 changes: 75 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,18 @@ export function LimitedDeckBuilder({
{t("limitedDeck.suggestDeck")}
</button>
)}
<button
type="button"
onClick={copyDeckList}
className={menuButtonClass({
tone: "neutral",
size: "sm",
disabled: mainDeck.length === 0,
className: "w-full",
})}
>
{copied ? t("limitedDeck.copied") : t("limitedDeck.copyList")}
Comment thread
klyusba marked this conversation as resolved.
</button>

<button
type="button"
Expand Down
100 changes: 68 additions & 32 deletions client/src/components/draft/ManaCurve.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,57 +20,93 @@ const MAX_BAR_HEIGHT = 100;
export function ManaCurve({ pool, cards }: ManaCurveProps) {
const { t } = useTranslation("draft");

const counts = useMemo(() => {
const { allCounts, creatureCounts } = useMemo(() => {
const cmcByName = new Map<string, number>();
const isCreatureByName = new Map<string, boolean>();
for (const card of pool) {
cmcByName.set(card.name, card.cmc);
isCreatureByName.set(card.name, card.type_line.toLowerCase().includes("creature"));
}

const buckets = new Map<string, number>();
for (const bucket of CMC_BUCKETS) buckets.set(bucket, 0);
const allBuckets = new Map<string, number>();
const creatureBuckets = new Map<string, number>();
for (const bucket of CMC_BUCKETS) {
allBuckets.set(bucket, 0);
creatureBuckets.set(bucket, 0);
}

for (const name of cards) {
const cmc = cmcByName.get(name) ?? 0;
const key = cmc >= 6 ? "6+" : String(cmc);
buckets.set(key, (buckets.get(key) ?? 0) + 1);
allBuckets.set(key, (allBuckets.get(key) ?? 0) + 1);
if (isCreatureByName.get(name)) {
creatureBuckets.set(key, (creatureBuckets.get(key) ?? 0) + 1);
}
}

return CMC_BUCKETS.map((key) => ({
label: key,
count: buckets.get(key) ?? 0,
}));
return {
allCounts: CMC_BUCKETS.map((key) => ({ label: key, count: allBuckets.get(key) ?? 0 })),
creatureCounts: CMC_BUCKETS.map((key) => ({
label: key,
count: creatureBuckets.get(key) ?? 0,
})),
};
}, [cards, pool]);

const maxCount = Math.max(1, ...counts.map((b) => b.count));
const maxCount = Math.max(1, ...allCounts.map((b) => b.count));

return (
<div className="flex flex-col gap-1">
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-slate-500">
{t("manaCurve.title")}
<div className="flex items-center justify-between">
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-slate-500">
{t("manaCurve.title")}
</div>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1 text-[0.6rem] text-slate-500">
<span className="inline-block h-2 w-2 rounded-sm bg-cyan-500/60" />
{t("manaCurve.allCards", "All")}
</span>
<span className="flex items-center gap-1 text-[0.6rem] text-slate-500">
<span className="inline-block h-2 w-2 rounded-sm bg-amber-400/70" />
{t("manaCurve.creatures", "Creatures")}
</span>
</div>
</div>
<div className="flex items-end gap-1.5" style={{ height: MAX_BAR_HEIGHT + 24 }}>
{counts.map(({ label, count }) => (
<div
key={label}
role="meter"
aria-label={t("manaCurve.bucketLabel", { bucket: label })}
aria-valuemin={0}
aria-valuemax={maxCount}
aria-valuenow={count}
className="flex flex-1 flex-col items-center gap-0.5"
>
<span className="h-4 text-[10px] leading-4 text-white/50">
{count > 0 ? count : ""}
</span>
{allCounts.map(({ label, count }, i) => {
const creatureCount = creatureCounts[i].count;
const allHeight = count > 0 ? Math.max(4, (count / maxCount) * MAX_BAR_HEIGHT) : 0;
const creatureHeight =
creatureCount > 0
? Math.max(4, (creatureCount / maxCount) * MAX_BAR_HEIGHT)
: 0;
return (
<div
className="w-full rounded-t bg-cyan-500/60 transition-all duration-200"
style={{
height: count > 0 ? Math.max(4, (count / maxCount) * MAX_BAR_HEIGHT) : 0,
}}
/>
<span className="text-[10px] text-white/30">{label}</span>
</div>
))}
key={label}
role="meter"
aria-label={t("manaCurve.bucketLabel", { bucket: label })}
aria-valuemin={0}
aria-valuemax={maxCount}
aria-valuenow={count}
className="flex flex-1 flex-col items-center gap-0.5"
>
<span className="h-4 text-[10px] leading-4 text-white/50">
{count > 0 ? count : ""}
</span>
<div className="relative w-full" style={{ height: MAX_BAR_HEIGHT }}>
<div
className="absolute bottom-0 w-full rounded-t bg-cyan-500/40 transition-all duration-200"
style={{ height: allHeight }}
/>
<div
className="absolute bottom-0 w-full rounded-t bg-amber-400/70 transition-all duration-200"
style={{ height: creatureHeight }}
/>
</div>
<span className="text-[10px] text-white/30">{label}</span>
</div>
);
})}
</div>
</div>
);
Expand Down
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
Loading
Loading