Initial Commit
This commit is contained in:
12
src/client/index.html
Normal file
12
src/client/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MTG Draft Maker</title>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-50">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
44
src/client/src/App.tsx
Normal file
44
src/client/src/App.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layers, Box, Trophy } from 'lucide-react';
|
||||
import { CubeManager } from './modules/cube/CubeManager';
|
||||
import { TournamentManager } from './modules/tournament/TournamentManager';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'draft' | 'bracket'>('draft');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 text-slate-100 font-sans pb-20">
|
||||
<header className="bg-slate-800 border-b border-slate-700 p-4 sticky top-0 z-50 shadow-lg">
|
||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-purple-600 p-2 rounded-lg"><Layers className="w-6 h-6 text-white" /></div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">MTG Peasant Drafter</h1>
|
||||
<p className="text-slate-400 text-xs uppercase tracking-wider">Pack Generator & Tournament Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('draft')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'draft' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<Box className="w-4 h-4" /> Draft Management
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('bracket')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'bracket' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<Trophy className="w-4 h-4" /> Tournament / Bracket
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{activeTab === 'draft' && <CubeManager />}
|
||||
{activeTab === 'bracket' && <TournamentManager />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
120
src/client/src/components/PackCard.tsx
Normal file
120
src/client/src/components/PackCard.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { DraftCard, Pack } from '../services/PackGeneratorService';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { StackView } from './StackView';
|
||||
|
||||
interface PackCardProps {
|
||||
pack: Pack;
|
||||
viewMode: 'list' | 'grid' | 'stack';
|
||||
}
|
||||
|
||||
const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
|
||||
const getRarityColorClass = (rarity: string) => {
|
||||
switch (rarity) {
|
||||
case 'common': return 'bg-black text-white border-slate-600';
|
||||
case 'uncommon': return 'bg-slate-300 text-slate-900 border-white';
|
||||
case 'rare': return 'bg-yellow-500 text-yellow-950 border-yellow-200';
|
||||
case 'mythic': return 'bg-orange-600 text-white border-orange-300';
|
||||
default: return 'bg-slate-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="relative group">
|
||||
<div className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700/50 cursor-pointer">
|
||||
<span className={`font-medium ${card.rarity === 'mythic' ? 'text-orange-400' : card.rarity === 'rare' ? 'text-yellow-400' : card.rarity === 'uncommon' ? 'text-slate-200' : 'text-slate-400'}`}>
|
||||
{card.name}
|
||||
</span>
|
||||
<span className={`w-2 h-2 rounded-full border ${getRarityColorClass(card.rarity)} !p-0 !text-[0px]`}></span>
|
||||
</div>
|
||||
{card.image && (
|
||||
<div className="hidden group-hover:block absolute left-0 top-full z-50 mt-1 pointer-events-none">
|
||||
<div className="bg-black p-1 rounded-lg border border-slate-500 shadow-2xl w-48">
|
||||
<img src={card.image} alt={card.name} className="w-full rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode }) => {
|
||||
const mythics = pack.cards.filter(c => c.rarity === 'mythic');
|
||||
const rares = pack.cards.filter(c => c.rarity === 'rare');
|
||||
const uncommons = pack.cards.filter(c => c.rarity === 'uncommon');
|
||||
const commons = pack.cards.filter(c => c.rarity === 'common');
|
||||
|
||||
const copyPackToClipboard = () => {
|
||||
const text = pack.cards.map(c => c.name).join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
// Toast notification could go here
|
||||
alert(`Pack list ${pack.id} copied!`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-slate-800 rounded-xl border border-slate-700 shadow-lg flex flex-col ${viewMode === 'stack' ? 'bg-transparent border-none shadow-none' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className={`p-3 bg-slate-900 border-b border-slate-700 flex justify-between items-center rounded-t-xl ${viewMode === 'stack' ? 'bg-slate-800 border border-slate-700 mb-4 rounded-xl' : ''}`}>
|
||||
<div className="flex flex-col">
|
||||
<h3 className="font-bold text-purple-400 text-sm md:text-base">Pack #{pack.id}</h3>
|
||||
<span className="text-xs text-slate-500 font-mono">{pack.setName}</span>
|
||||
</div>
|
||||
<button onClick={copyPackToClipboard} className="text-slate-400 hover:text-white p-1 rounded hover:bg-slate-700 transition-colors flex items-center gap-2 text-xs">
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`${viewMode !== 'stack' ? 'p-4' : ''}`}>
|
||||
{viewMode === 'list' && (
|
||||
<div className="text-sm space-y-4">
|
||||
{(mythics.length > 0 || rares.length > 0) && (
|
||||
<div>
|
||||
<div className="text-xs font-bold text-yellow-500 uppercase mb-2 border-b border-slate-700 pb-1">Rare / Mythic ({mythics.length + rares.length})</div>
|
||||
<ul className="space-y-1">
|
||||
{mythics.map(card => <ListItem key={card.id} card={card} />)}
|
||||
{rares.map(card => <ListItem key={card.id} card={card} />)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-300 uppercase mb-2 border-b border-slate-700 pb-1">Uncommons ({uncommons.length})</div>
|
||||
<ul className="space-y-1">
|
||||
{uncommons.map(card => <ListItem key={card.id} card={card} />)}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-500 uppercase mb-2 border-b border-slate-700 pb-1">Commons ({commons.length})</div>
|
||||
<ul className="space-y-1">
|
||||
{commons.map(card => <ListItem key={card.id} card={card} />)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'grid' && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{pack.cards.map((card) => (
|
||||
<div key={card.id} className="relative aspect-[2.5/3.5] bg-slate-900 rounded-lg overflow-hidden group hover:scale-105 transition-transform duration-200 shadow-xl border border-slate-800">
|
||||
{card.image ? (
|
||||
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">
|
||||
{card.name}
|
||||
</div>
|
||||
)}
|
||||
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' :
|
||||
card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' :
|
||||
card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' :
|
||||
'bg-black'
|
||||
}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'stack' && <StackView cards={pack.cards} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
53
src/client/src/components/StackView.tsx
Normal file
53
src/client/src/components/StackView.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { DraftCard } from '../services/PackGeneratorService';
|
||||
|
||||
interface StackViewProps {
|
||||
cards: DraftCard[];
|
||||
}
|
||||
|
||||
export const StackView: React.FC<StackViewProps> = ({ cards }) => {
|
||||
const getRarityColorClass = (rarity: string) => {
|
||||
switch (rarity) {
|
||||
case 'common': return 'bg-black text-white border-slate-600';
|
||||
case 'uncommon': return 'bg-slate-300 text-slate-900 border-white';
|
||||
case 'rare': return 'bg-yellow-500 text-yellow-950 border-yellow-200';
|
||||
case 'mythic': return 'bg-orange-600 text-white border-orange-300';
|
||||
default: return 'bg-slate-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-sm mx-auto group perspective-1000 py-20">
|
||||
<div className="relative flex flex-col items-center transition-all duration-500 ease-in-out group-hover:space-y-4 space-y-[-16rem] py-10">
|
||||
{cards.map((card, index) => {
|
||||
const colorClass = getRarityColorClass(card.rarity);
|
||||
// Random slight rotation for "organic" look
|
||||
const rotation = (index % 2 === 0 ? 1 : -1) * (Math.random() * 2);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className="relative w-64 aspect-[2.5/3.5] rounded-xl shadow-2xl transition-transform duration-300 hover:scale-110 hover:z-50 hover:rotate-0 origin-center bg-slate-800 border-2 border-slate-900"
|
||||
style={{
|
||||
zIndex: index,
|
||||
transform: `rotate(${rotation}deg)`
|
||||
}}
|
||||
>
|
||||
{card.image ? (
|
||||
<img src={card.image} alt={card.name} className="w-full h-full object-cover rounded-lg" />
|
||||
) : (
|
||||
<div className="w-full h-full p-4 text-center flex items-center justify-center font-bold text-slate-500">
|
||||
{card.name}
|
||||
</div>
|
||||
)}
|
||||
<div className={`absolute top-2 right-2 w-3 h-3 rounded-full shadow-md z-10 border ${colorClass}`} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-center text-slate-500 text-xs mt-4 opacity-50 group-hover:opacity-0 transition-opacity">
|
||||
Hover to expand stack
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
75
src/client/src/components/TournamentPackView.tsx
Normal file
75
src/client/src/components/TournamentPackView.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Target, Package, Layers } from 'lucide-react';
|
||||
import { Pack } from '../services/PackGeneratorService';
|
||||
|
||||
interface TournamentPackViewProps {
|
||||
packs: Pack[];
|
||||
}
|
||||
|
||||
export const TournamentPackView: React.FC<TournamentPackViewProps> = ({ packs }) => {
|
||||
const packsBySet = packs.reduce((acc, pack) => {
|
||||
const key = pack.setName || 'Unknown Set';
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(pack);
|
||||
return acc;
|
||||
}, {} as { [key: string]: Pack[] });
|
||||
|
||||
const BOX_SIZE = 30;
|
||||
|
||||
return (
|
||||
<div className="space-y-12 animate-in fade-in duration-700">
|
||||
{Object.entries(packsBySet).map(([setName, setPacks]) => {
|
||||
const boxes = [];
|
||||
for (let i = 0; i < setPacks.length; i += BOX_SIZE) boxes.push(setPacks.slice(i, i + BOX_SIZE));
|
||||
|
||||
return (
|
||||
<div key={setName} className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-px bg-slate-700 flex-1"></div>
|
||||
<h3 className="text-2xl font-black text-slate-200 uppercase tracking-widest flex items-center gap-2">
|
||||
<Target className="w-6 h-6 text-purple-500" /> {setName}
|
||||
</h3>
|
||||
<div className="h-px bg-slate-700 flex-1"></div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{boxes.map((boxPacks, boxIndex) => (
|
||||
<div key={boxIndex} className="bg-slate-800/50 border border-slate-700 rounded-2xl p-6 relative">
|
||||
<div className="absolute -top-4 left-6 bg-amber-600 text-white px-4 py-1 rounded-full font-bold shadow-lg flex items-center gap-2 border-2 border-slate-900 z-10">
|
||||
<Package className="w-4 h-4" /> BOX {boxIndex + 1}
|
||||
<span className="text-amber-200 text-xs font-normal ml-1">({boxPacks.length} packs)</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 mt-4">
|
||||
{boxPacks.map((pack) => (
|
||||
<div key={pack.id} className="aspect-[2.5/3.5] bg-gradient-to-br from-slate-700 to-slate-800 rounded-xl border-2 border-slate-600 shadow-xl relative group overflow-hidden cursor-pointer hover:border-amber-500/50 transition-colors">
|
||||
<div className="absolute inset-2 border-2 border-dashed border-slate-600/30 rounded-lg flex flex-col items-center justify-center">
|
||||
<Layers className="w-8 h-8 text-slate-600 mb-2 opacity-50" />
|
||||
<span className="text-2xl font-black text-slate-500 opacity-20">MTG</span>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-0 right-0 text-center">
|
||||
<div className="bg-slate-900/90 text-white text-xs font-bold py-1 px-2 mx-2 rounded border border-slate-700 truncate">#{pack.id}</div>
|
||||
<div className="text-[10px] text-slate-400 mt-1 uppercase tracking-widest font-semibold truncate px-2">{pack.setName}</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-black/80 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div className="text-center p-2">
|
||||
<p className="text-amber-400 font-bold text-xs">Contains {pack.cards.length} cards:</p>
|
||||
{pack.cards.some(c => c.rarity === 'mythic' || c.rarity === 'rare') && (
|
||||
<p className="text-yellow-400 text-xs font-bold">★ Rare / Mythic</p>
|
||||
)}
|
||||
<p className="text-slate-300 text-[10px] italic mt-1">Click for full list</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
16
src/client/src/main.tsx
Normal file
16
src/client/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './styles/main.css';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
if (rootElement) {
|
||||
const root = createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
{/* <CubeManager /> is now part of App */}
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
336
src/client/src/modules/cube/CubeManager.tsx
Normal file
336
src/client/src/modules/cube/CubeManager.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Layers, RotateCcw, Box, Check, Loader2, Upload, Eye, EyeOff, LayoutGrid, List, Sliders, Settings } from 'lucide-react';
|
||||
import { CardParserService } from '../../services/CardParserService';
|
||||
import { ScryfallService, ScryfallCard } from '../../services/ScryfallService';
|
||||
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
|
||||
import { PackCard } from '../../components/PackCard';
|
||||
import { TournamentPackView } from '../../components/TournamentPackView';
|
||||
|
||||
export const CubeManager: React.FC = () => {
|
||||
// --- Services ---
|
||||
// --- Services ---
|
||||
// Memoize services to persist cache across renders
|
||||
const parserService = React.useMemo(() => new CardParserService(), []);
|
||||
const scryfallService = React.useMemo(() => new ScryfallService(), []);
|
||||
const generatorService = React.useMemo(() => new PackGeneratorService(), []);
|
||||
|
||||
// --- State ---
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState('');
|
||||
|
||||
const [rawScryfallData, setRawScryfallData] = useState<ScryfallCard[] | null>(null);
|
||||
const [processedData, setProcessedData] = useState<{ pools: ProcessedPools, sets: SetsMap } | null>(null);
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
ignoreBasicLands: true,
|
||||
ignoreCommander: true,
|
||||
ignoreTokens: true
|
||||
});
|
||||
|
||||
const [packs, setPacks] = useState<Pack[]>([]);
|
||||
|
||||
// UI State
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('list');
|
||||
const [tournamentMode, setTournamentMode] = useState(false);
|
||||
|
||||
// Generation Settings
|
||||
const [genSettings, setGenSettings] = useState<PackGenerationSettings>({
|
||||
mode: 'mixed',
|
||||
rarityMode: 'peasant'
|
||||
});
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// --- Effects ---
|
||||
useEffect(() => {
|
||||
if (rawScryfallData) {
|
||||
const result = generatorService.processCards(rawScryfallData, filters);
|
||||
setProcessedData(result);
|
||||
}
|
||||
}, [filters, rawScryfallData]);
|
||||
|
||||
// --- Handlers ---
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => setInputText(e.target?.result as string || '');
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const loadDemoData = () => {
|
||||
const demo = `20 Shock
|
||||
20 Llanowar Elves
|
||||
20 Giant Growth
|
||||
20 Counterspell
|
||||
20 Dark Ritual
|
||||
20 Lightning Bolt
|
||||
20 Opt
|
||||
20 Consider
|
||||
20 Ponder
|
||||
20 Preordain
|
||||
20 Brainstorm
|
||||
20 Duress
|
||||
20 Faithless Looting
|
||||
20 Thrill of Possibility
|
||||
20 Terror
|
||||
10 Serra Angel
|
||||
10 Vampire Nighthawk
|
||||
10 Eternal Witness
|
||||
10 Mulldrifter
|
||||
10 Flametongue Kavu
|
||||
5 Wrath of God
|
||||
5 Birds of Paradise
|
||||
2 Jace, the Mind Sculptor
|
||||
1 Sheoldred, the Apocalypse
|
||||
20 Island
|
||||
1 Sol Ring
|
||||
1 Command Tower`;
|
||||
setInputText(demo);
|
||||
};
|
||||
|
||||
const fetchAndParse = async () => {
|
||||
setLoading(true);
|
||||
setPacks([]);
|
||||
setProgress('Parsing text...');
|
||||
setTournamentMode(false);
|
||||
|
||||
try {
|
||||
const identifiers = parserService.parse(inputText);
|
||||
|
||||
// Expand quantity for fetching logic (though service handles it, let's just pass uniques to service)
|
||||
// The service `fetchCollection` deduplicates automatically.
|
||||
|
||||
// Map identifier interface
|
||||
const fetchList = identifiers.map(id => id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||
|
||||
// Fetch
|
||||
await scryfallService.fetchCollection(fetchList, (current, total) => {
|
||||
setProgress(`Fetching Scryfall data... (${current}/${total})`);
|
||||
});
|
||||
|
||||
// Expand based on original quantities
|
||||
const expandedCards: ScryfallCard[] = [];
|
||||
identifiers.forEach(id => {
|
||||
const card = scryfallService.getCachedCard(id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||
if (card) {
|
||||
for (let i = 0; i < id.quantity; i++) expandedCards.push(card);
|
||||
}
|
||||
});
|
||||
|
||||
setRawScryfallData(expandedCards);
|
||||
// Processing happens via useEffect
|
||||
|
||||
setLoading(false);
|
||||
setProgress('');
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert(err.message || "Error during process.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generatePacks = () => {
|
||||
if (!processedData) return;
|
||||
|
||||
if (processedData.pools.commons.length === 0 && processedData.pools.uncommons.length === 0 && processedData.pools.rares.length === 0) {
|
||||
alert(`Not enough valid cards.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Use setTimeout to allow UI to show loading spinner before sync calculation blocks
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const newPacks = generatorService.generatePacks(processedData.pools, processedData.sets, genSettings);
|
||||
|
||||
if (newPacks.length === 0) {
|
||||
alert(`Not enough cards to generate valid packs (minimum required for selected mode).`);
|
||||
} else {
|
||||
setPacks(newPacks);
|
||||
setTournamentMode(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Generation failed", e);
|
||||
alert("Error generating packs: " + e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const toggleFilter = (key: keyof typeof filters) => {
|
||||
setFilters(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-8 p-4 md:p-6">
|
||||
|
||||
{/* --- LEFT COLUMN: CONTROLS --- */}
|
||||
<div className="lg:col-span-4 flex flex-col gap-4">
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="text-sm font-semibold text-slate-300 flex items-center gap-2">
|
||||
<Box className="w-4 h-4" /> Input Bulk
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => fileInputRef.current?.click()} className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 hover:underline">
|
||||
<Upload className="w-3 h-3" /> Upload
|
||||
</button>
|
||||
<input type="file" ref={fileInputRef} className="hidden" accept=".csv,.txt" onChange={handleFileUpload} />
|
||||
<button onClick={loadDemoData} className="text-xs text-purple-400 hover:text-purple-300 hover:underline">Demo</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-4 bg-slate-900 p-3 rounded-lg border border-slate-700">
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase mb-2 flex items-center gap-2">
|
||||
<Sliders className="w-3 h-3" /> Import Options
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-white text-slate-300">
|
||||
<input type="checkbox" checked={filters.ignoreBasicLands} onChange={() => toggleFilter('ignoreBasicLands')} className="rounded border-slate-600 bg-slate-800 text-purple-500 focus:ring-purple-500" />
|
||||
<span>Ignore Basic Lands</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-white text-slate-300">
|
||||
<input type="checkbox" checked={filters.ignoreCommander} onChange={() => toggleFilter('ignoreCommander')} className="rounded border-slate-600 bg-slate-800 text-purple-500 focus:ring-purple-500" />
|
||||
<span>Ignore Commander Sets</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-white text-slate-300">
|
||||
<input type="checkbox" checked={filters.ignoreTokens} onChange={() => toggleFilter('ignoreTokens')} className="rounded border-slate-600 bg-slate-800 text-purple-500 focus:ring-purple-500" />
|
||||
<span>Ignore Tokens</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="w-full h-40 bg-slate-900 border border-slate-700 rounded-lg p-3 text-xs font-mono text-slate-300 focus:ring-2 focus:ring-purple-500 outline-none resize-none mb-4 whitespace-pre"
|
||||
placeholder="Paste list or upload file..."
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={fetchAndParse}
|
||||
disabled={loading || !inputText}
|
||||
className={`w-full py-2 mb-4 rounded-lg font-bold flex justify-center items-center gap-2 transition-all ${loading ? 'bg-slate-700 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-500 text-white'}`}
|
||||
>
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> {progress}</> : <><Check className="w-4 h-4" /> 1. Parse Bulk</>}
|
||||
</button>
|
||||
|
||||
{/* Generation Settings */}
|
||||
{processedData && Object.keys(processedData.sets).length > 0 && (
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-700 mb-4 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||
<h3 className="text-sm font-bold text-white mb-2 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4 text-emerald-400" /> Configuration
|
||||
</h3>
|
||||
|
||||
{/* Mode */}
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase mb-1 block">Card Source</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||
<input type="radio" name="genMode" value="mixed" checked={genSettings.mode === 'mixed'} onChange={() => setGenSettings({ ...genSettings, mode: 'mixed' })} className="accent-purple-500" />
|
||||
<span>Chaos Draft (Mix All)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||
<input type="radio" name="genMode" value="by_set" checked={genSettings.mode === 'by_set'} onChange={() => setGenSettings({ ...genSettings, mode: 'by_set' })} className="accent-purple-500" />
|
||||
<span>Split by Expansion</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rarity */}
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase mb-1 block">Format</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className={`flex items-center gap-2 text-sm cursor-pointer p-2 rounded border ${genSettings.rarityMode === 'peasant' ? 'bg-slate-700 border-purple-500' : 'border-transparent hover:bg-slate-800'}`}>
|
||||
<input type="radio" name="rarMode" value="peasant" checked={genSettings.rarityMode === 'peasant'} onChange={() => setGenSettings({ ...genSettings, rarityMode: 'peasant' })} className="accent-purple-500" />
|
||||
<div>
|
||||
<span className="block font-bold text-white">Peasant (13 Cards)</span>
|
||||
<span className="text-xs text-slate-400">10 Commons, 3 Uncommons</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 text-sm cursor-pointer p-2 rounded border ${genSettings.rarityMode === 'standard' ? 'bg-slate-700 border-amber-500' : 'border-transparent hover:bg-slate-800'}`}>
|
||||
<input type="radio" name="rarMode" value="standard" checked={genSettings.rarityMode === 'standard'} onChange={() => setGenSettings({ ...genSettings, rarityMode: 'standard' })} className="accent-amber-500" />
|
||||
<div>
|
||||
<span className="block font-bold text-white">Standard (14 Cards)</span>
|
||||
<span className="text-xs text-slate-400">10C, 3U, 1 Rare/Mythic</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sets Info */}
|
||||
<div className="max-h-40 overflow-y-auto text-xs space-y-1 pr-2 custom-scrollbar border-t border-slate-800 pt-2">
|
||||
{Object.values(processedData.sets).sort((a, b) => b.commons.length - a.commons.length).map(set => (
|
||||
<div key={set.code} className="flex justify-between items-center text-slate-400 border-b border-slate-800 pb-1">
|
||||
<span className="truncate w-32" title={set.name}>{set.name}</span>
|
||||
<span className="font-mono text-[10px]">{set.commons.length}C / {set.uncommons.length}U / {set.rares.length}R</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={generatePacks}
|
||||
disabled={!processedData || Object.keys(processedData.sets).length === 0 || loading}
|
||||
className={`w-full py-3 px-4 rounded-lg font-bold flex justify-center items-center gap-2 transition-all ${!processedData || Object.keys(processedData.sets).length === 0 || loading ? 'bg-slate-700 cursor-not-allowed text-slate-500' : 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-900/20'}`}
|
||||
>
|
||||
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <RotateCcw className="w-5 h-5" />}
|
||||
{loading ? 'Generating...' : '2. Generate Packs'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- RIGHT COLUMN: PACKS --- */}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center mb-6 gap-4 sticky top-4 z-40 bg-slate-900/90 backdrop-blur-sm p-3 rounded-xl border border-white/5 shadow-2xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<span className="bg-slate-700 text-purple-400 px-3 py-1 rounded-lg text-sm border border-slate-600">{packs.length}</span>
|
||||
Packs
|
||||
</h2>
|
||||
{packs.length > 0 && (
|
||||
<button onClick={() => setTournamentMode(!tournamentMode)} className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold border transition-all ${tournamentMode ? 'bg-amber-500/20 border-amber-500 text-amber-500 animate-pulse' : 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-400'}`}>
|
||||
{tournamentMode ? <><EyeOff className="w-4 h-4" /> Tournament Mode</> : <><Eye className="w-4 h-4" /> Editor Mode</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!tournamentMode && (
|
||||
<div className="flex bg-slate-800 rounded-lg p-1 border border-slate-700">
|
||||
<button onClick={() => setViewMode('list')} className={`p-2 rounded ${viewMode === 'list' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><List className="w-4 h-4" /></button>
|
||||
<button onClick={() => setViewMode('grid')} className={`p-2 rounded ${viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><LayoutGrid className="w-4 h-4" /></button>
|
||||
<button onClick={() => setViewMode('stack')} className={`p-2 rounded ${viewMode === 'stack' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><Layers className="w-4 h-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{packs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed border-slate-700 rounded-2xl bg-slate-800/30 text-slate-500">
|
||||
<Box className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>No packs generated.</p>
|
||||
</div>
|
||||
) : (
|
||||
tournamentMode ? (
|
||||
<TournamentPackView packs={packs} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 pb-20">
|
||||
{packs.map((pack) => (
|
||||
<PackCard key={pack.id} pack={pack} viewMode={viewMode} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div >
|
||||
);
|
||||
};
|
||||
92
src/client/src/modules/tournament/TournamentManager.tsx
Normal file
92
src/client/src/modules/tournament/TournamentManager.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
interface Match {
|
||||
id: number;
|
||||
p1: string;
|
||||
p2: string;
|
||||
}
|
||||
|
||||
interface Bracket {
|
||||
round1: Match[];
|
||||
totalPlayers: number;
|
||||
}
|
||||
|
||||
export const TournamentManager: React.FC = () => {
|
||||
const [playerInput, setPlayerInput] = useState('');
|
||||
const [bracket, setBracket] = useState<Bracket | null>(null);
|
||||
|
||||
const shuffleArray = (array: any[]) => {
|
||||
let currentIndex = array.length, randomIndex;
|
||||
const newArray = [...array];
|
||||
while (currentIndex !== 0) {
|
||||
randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex--;
|
||||
[newArray[currentIndex], newArray[randomIndex]] = [newArray[randomIndex], newArray[currentIndex]];
|
||||
}
|
||||
return newArray;
|
||||
};
|
||||
|
||||
const generateBracket = () => {
|
||||
if (!playerInput.trim()) return;
|
||||
const names = playerInput.split('\n').filter(n => n.trim() !== '').map(n => n.trim());
|
||||
if (names.length < 2) { alert("Enter at least 2 players."); return; }
|
||||
|
||||
const shuffled = shuffleArray(names);
|
||||
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(shuffled.length)));
|
||||
const byesNeeded = nextPowerOf2 - shuffled.length;
|
||||
|
||||
const fullRoster = [...shuffled];
|
||||
for (let i = 0; i < byesNeeded; i++) fullRoster.push("BYE");
|
||||
|
||||
const pairings: Match[] = [];
|
||||
for (let i = 0; i < fullRoster.length; i += 2) {
|
||||
pairings.push({ id: i, p1: fullRoster[i], p2: fullRoster[i + 1] });
|
||||
}
|
||||
|
||||
setBracket({ round1: pairings, totalPlayers: names.length });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-6">
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl mb-8">
|
||||
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-blue-400" /> Players
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mb-2">Enter one name per line</p>
|
||||
<textarea
|
||||
className="w-full h-32 bg-slate-900 border border-slate-700 rounded-lg p-3 text-sm text-slate-300 focus:ring-2 focus:ring-blue-500 outline-none resize-none mb-4"
|
||||
placeholder={`Player 1\nPlayer 2...`}
|
||||
value={playerInput}
|
||||
onChange={(e) => setPlayerInput(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={generateBracket}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded-lg font-bold w-full md:w-auto transition-colors"
|
||||
>
|
||||
Generate Bracket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bracket && (
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl overflow-x-auto">
|
||||
<h3 className="text-lg font-bold text-white mb-6 border-b border-slate-700 pb-2">Round 1 (Single Elimination)</h3>
|
||||
<div className="flex flex-col gap-4 min-w-[300px]">
|
||||
{bracket.round1.map((match, i) => (
|
||||
<div key={i} className="bg-slate-900 border border-slate-700 rounded-lg p-4 flex flex-col gap-2 relative">
|
||||
<div className="absolute -left-3 top-1/2 w-3 h-px bg-slate-600"></div>
|
||||
<div className="flex justify-between items-center bg-slate-800/50 p-2 rounded border border-slate-700/50">
|
||||
<span className={match.p1 === 'BYE' ? 'text-slate-500 italic' : 'font-bold text-white'}>{match.p1}</span>
|
||||
</div>
|
||||
<div className="text-xs text-center text-slate-500">VS</div>
|
||||
<div className="flex justify-between items-center bg-slate-800/50 p-2 rounded border border-slate-700/50">
|
||||
<span className={match.p2 === 'BYE' ? 'text-slate-500 italic' : 'font-bold text-white'}>{match.p2}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
64
src/client/src/services/CardParserService.ts
Normal file
64
src/client/src/services/CardParserService.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export interface CardIdentifier {
|
||||
type: 'id' | 'name';
|
||||
value: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export class CardParserService {
|
||||
parse(text: string): CardIdentifier[] {
|
||||
const lines = text.split('\n').filter(line => line.trim() !== '');
|
||||
const rawCardList: CardIdentifier[] = [];
|
||||
const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.toLowerCase().startsWith('quantity') || line.toLowerCase().startsWith('count,name')) return;
|
||||
|
||||
const idMatch = line.match(uuidRegex);
|
||||
const cleanLineForQty = line.replace(/['"]/g, '');
|
||||
const quantityMatch = cleanLineForQty.match(/^(\d+)[xX\s,;]/);
|
||||
const quantity = quantityMatch ? parseInt(quantityMatch[1], 10) : 1;
|
||||
|
||||
let identifier: { type: 'id' | 'name', value: string } | null = null;
|
||||
|
||||
if (idMatch) {
|
||||
identifier = { type: 'id', value: idMatch[0] };
|
||||
} else {
|
||||
const cleanLine = line.replace(/['"]/g, '');
|
||||
// Remove leading quantity
|
||||
let name = cleanLine.replace(/^(\d+)[xX\s,;]+/, '').trim();
|
||||
|
||||
// Remove set codes in parentheses/brackets e.g. (M20), [STA]
|
||||
// This regex looks for ( starts, anything inside, ) ends, or same for []
|
||||
name = name.replace(/\s*[\(\[].*?[\)\]]/g, '');
|
||||
|
||||
// Remove trailing collector numbers (digits at the very end)
|
||||
name = name.replace(/\s+\d+$/, '');
|
||||
|
||||
// Remove trailing punctuation
|
||||
name = name.replace(/^[,;]+|[,;]+$/g, '').trim();
|
||||
|
||||
// If CSV like "Name, SetCode", take first part
|
||||
if (name.includes(',')) name = name.split(',')[0].trim();
|
||||
|
||||
if (name && name.length > 1) identifier = { type: 'name', value: name };
|
||||
}
|
||||
|
||||
if (identifier) {
|
||||
// Return one entry per quantity? Or aggregated?
|
||||
// The original code pushed multiple entries to an array.
|
||||
// For a parser service, returning the count is better, but to match logic:
|
||||
// "for (let i = 0; i < quantity; i++) rawCardList.push(identifier);"
|
||||
// I will return one object with Quantity property to be efficient.
|
||||
|
||||
rawCardList.push({
|
||||
type: identifier.type,
|
||||
value: identifier.value,
|
||||
quantity: quantity
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (rawCardList.length === 0) throw new Error("No valid cards found.");
|
||||
return rawCardList;
|
||||
}
|
||||
}
|
||||
230
src/client/src/services/PackGeneratorService.ts
Normal file
230
src/client/src/services/PackGeneratorService.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { ScryfallCard } from './ScryfallService';
|
||||
|
||||
export interface DraftCard {
|
||||
id: string; // Internal UUID
|
||||
scryfallId: string;
|
||||
name: string;
|
||||
rarity: string;
|
||||
colors: string[];
|
||||
image: string;
|
||||
set: string;
|
||||
setCode: string;
|
||||
setType: string;
|
||||
}
|
||||
|
||||
export interface Pack {
|
||||
id: number;
|
||||
setName: string;
|
||||
cards: DraftCard[];
|
||||
}
|
||||
|
||||
export interface ProcessedPools {
|
||||
commons: DraftCard[];
|
||||
uncommons: DraftCard[];
|
||||
rares: DraftCard[];
|
||||
mythics: DraftCard[];
|
||||
}
|
||||
|
||||
export interface SetsMap {
|
||||
[code: string]: {
|
||||
name: string;
|
||||
code: string;
|
||||
commons: DraftCard[];
|
||||
uncommons: DraftCard[];
|
||||
rares: DraftCard[];
|
||||
mythics: DraftCard[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface PackGenerationSettings {
|
||||
mode: 'mixed' | 'by_set';
|
||||
rarityMode: 'peasant' | 'standard'; // Peasant: 10C/3U, Standard: 10C/3U/1R
|
||||
}
|
||||
|
||||
export class PackGeneratorService {
|
||||
|
||||
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }): { pools: ProcessedPools, sets: SetsMap } {
|
||||
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [] };
|
||||
const setsMap: SetsMap = {};
|
||||
|
||||
cards.forEach(cardData => {
|
||||
const rarity = cardData.rarity;
|
||||
const typeLine = cardData.type_line || '';
|
||||
const setType = cardData.set_type;
|
||||
const layout = cardData.layout;
|
||||
|
||||
// Filters
|
||||
if (filters.ignoreBasicLands && typeLine.includes('Basic')) return;
|
||||
if (filters.ignoreCommander) {
|
||||
if (['commander', 'starter', 'duel_deck', 'premium_deck', 'planechase', 'archenemy'].includes(setType)) return;
|
||||
}
|
||||
if (filters.ignoreTokens) {
|
||||
if (layout === 'token' || layout === 'art_series' || layout === 'emblem') return;
|
||||
}
|
||||
|
||||
const cardObj: DraftCard = {
|
||||
id: crypto.randomUUID(),
|
||||
scryfallId: cardData.id,
|
||||
name: cardData.name,
|
||||
rarity: rarity,
|
||||
colors: cardData.colors || [],
|
||||
image: cardData.image_uris?.normal || cardData.card_faces?.[0]?.image_uris?.normal || '',
|
||||
set: cardData.set_name,
|
||||
setCode: cardData.set,
|
||||
setType: setType
|
||||
};
|
||||
|
||||
// Add to pools
|
||||
if (rarity === 'common') pools.commons.push(cardObj);
|
||||
else if (rarity === 'uncommon') pools.uncommons.push(cardObj);
|
||||
else if (rarity === 'rare') pools.rares.push(cardObj);
|
||||
else if (rarity === 'mythic') pools.mythics.push(cardObj);
|
||||
|
||||
// Add to Sets Map
|
||||
if (!setsMap[cardData.set]) {
|
||||
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [] };
|
||||
}
|
||||
const setEntry = setsMap[cardData.set];
|
||||
if (rarity === 'common') setEntry.commons.push(cardObj);
|
||||
else if (rarity === 'uncommon') setEntry.uncommons.push(cardObj);
|
||||
else if (rarity === 'rare') setEntry.rares.push(cardObj);
|
||||
else if (rarity === 'mythic') setEntry.mythics.push(cardObj);
|
||||
});
|
||||
|
||||
return { pools, sets: setsMap };
|
||||
}
|
||||
|
||||
generatePacks(pools: ProcessedPools, sets: SetsMap, settings: PackGenerationSettings): Pack[] {
|
||||
let newPacks: Pack[] = [];
|
||||
|
||||
if (settings.mode === 'mixed') {
|
||||
let currentPools = {
|
||||
commons: this.shuffle(pools.commons),
|
||||
uncommons: this.shuffle(pools.uncommons),
|
||||
rares: this.shuffle(pools.rares),
|
||||
mythics: this.shuffle(pools.mythics)
|
||||
};
|
||||
|
||||
let packId = 1;
|
||||
while (true) {
|
||||
const result = this.buildSinglePack(currentPools, packId, 'Chaos / Mixed', settings.rarityMode);
|
||||
if (!result) {
|
||||
break;
|
||||
}
|
||||
newPacks.push(result.pack);
|
||||
currentPools = result.remainingPools;
|
||||
packId++;
|
||||
}
|
||||
} else {
|
||||
// By Set
|
||||
let packId = 1;
|
||||
const sortedSetKeys = Object.keys(sets).sort();
|
||||
|
||||
sortedSetKeys.forEach(setCode => {
|
||||
const setData = sets[setCode];
|
||||
let currentPools = {
|
||||
commons: this.shuffle(setData.commons),
|
||||
uncommons: this.shuffle(setData.uncommons),
|
||||
rares: this.shuffle(setData.rares),
|
||||
mythics: this.shuffle(setData.mythics)
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const result = this.buildSinglePack(currentPools, packId, setData.name, settings.rarityMode);
|
||||
if (!result) break;
|
||||
newPacks.push(result.pack);
|
||||
currentPools = result.remainingPools;
|
||||
packId++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return newPacks;
|
||||
}
|
||||
|
||||
private buildSinglePack(pools: ProcessedPools, packId: number, setName: string, rarityMode: 'peasant' | 'standard') {
|
||||
const packCards: DraftCard[] = [];
|
||||
let currentPools = { ...pools };
|
||||
const namesInThisPack = new Set<string>();
|
||||
|
||||
const COMMONS_COUNT = 10;
|
||||
const UNCOMMONS_COUNT = 3;
|
||||
|
||||
if (rarityMode === 'standard') {
|
||||
const isMythicDrop = Math.random() < 0.125;
|
||||
let rareSuccess = false;
|
||||
|
||||
if (isMythicDrop && currentPools.mythics.length > 0) {
|
||||
const drawM = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
|
||||
if (drawM.success) {
|
||||
packCards.push(...drawM.selected);
|
||||
currentPools.mythics = drawM.remainingPool;
|
||||
drawM.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
rareSuccess = true;
|
||||
}
|
||||
} else if (!rareSuccess && currentPools.rares.length > 0) {
|
||||
const drawR = this.drawUniqueCards(currentPools.rares, 1, namesInThisPack);
|
||||
if (drawR.success) {
|
||||
packCards.push(...drawR.selected);
|
||||
currentPools.rares = drawR.remainingPool;
|
||||
drawR.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
rareSuccess = true;
|
||||
}
|
||||
} else if (currentPools.mythics.length > 0) {
|
||||
// Fallback to mythic if no rare available
|
||||
const drawM = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
|
||||
if (drawM.success) {
|
||||
packCards.push(...drawM.selected);
|
||||
currentPools.mythics = drawM.remainingPool;
|
||||
drawM.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const drawU = this.drawUniqueCards(currentPools.uncommons, UNCOMMONS_COUNT, namesInThisPack);
|
||||
if (!drawU.success) return null;
|
||||
packCards.push(...drawU.selected);
|
||||
currentPools.uncommons = drawU.remainingPool;
|
||||
drawU.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
const drawC = this.drawUniqueCards(currentPools.commons, COMMONS_COUNT, namesInThisPack);
|
||||
if (!drawC.success) return null;
|
||||
packCards.push(...drawC.selected);
|
||||
currentPools.commons = drawC.remainingPool;
|
||||
|
||||
const rarityWeight: { [key: string]: number } = { 'mythic': 4, 'rare': 3, 'uncommon': 2, 'common': 1 };
|
||||
packCards.sort((a, b) => rarityWeight[b.rarity] - rarityWeight[a.rarity]);
|
||||
|
||||
return { pack: { id: packId, setName, cards: packCards }, remainingPools: currentPools };
|
||||
}
|
||||
|
||||
private drawUniqueCards(pool: DraftCard[], count: number, existingNames: Set<string>) {
|
||||
const selected: DraftCard[] = [];
|
||||
const skipped: DraftCard[] = [];
|
||||
const namesInPack = new Set(existingNames);
|
||||
const workingPool = [...pool];
|
||||
|
||||
while (selected.length < count && workingPool.length > 0) {
|
||||
const card = workingPool.shift()!;
|
||||
if (!namesInPack.has(card.name)) {
|
||||
selected.push(card);
|
||||
namesInPack.add(card.name);
|
||||
} else {
|
||||
skipped.push(card);
|
||||
}
|
||||
}
|
||||
const remainingPool = [...workingPool, ...skipped];
|
||||
return { selected, remainingPool, success: selected.length === count };
|
||||
}
|
||||
|
||||
private shuffle(array: any[]) {
|
||||
let currentIndex = array.length, randomIndex;
|
||||
const newArray = [...array];
|
||||
while (currentIndex !== 0) {
|
||||
randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex--;
|
||||
[newArray[currentIndex], newArray[randomIndex]] = [newArray[randomIndex], newArray[currentIndex]];
|
||||
}
|
||||
return newArray;
|
||||
}
|
||||
}
|
||||
88
src/client/src/services/ScryfallService.ts
Normal file
88
src/client/src/services/ScryfallService.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export interface ScryfallCard {
|
||||
id: string;
|
||||
name: string;
|
||||
rarity: string;
|
||||
set: string;
|
||||
set_name: string;
|
||||
set_type: string;
|
||||
layout: string;
|
||||
type_line: string;
|
||||
colors?: string[];
|
||||
image_uris?: { normal: string };
|
||||
card_faces?: { image_uris: { normal: string } }[];
|
||||
}
|
||||
|
||||
export class ScryfallService {
|
||||
private cacheById = new Map<string, ScryfallCard>();
|
||||
private cacheByName = new Map<string, ScryfallCard>();
|
||||
|
||||
async fetchCollection(identifiers: { id?: string; name?: string }[], onProgress?: (current: number, total: number) => void): Promise<ScryfallCard[]> {
|
||||
// Deduplicate
|
||||
const uniqueRequests: { id?: string; name?: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
identifiers.forEach(item => {
|
||||
const key = item.id ? `id:${item.id}` : `name:${item.name?.toLowerCase()}`;
|
||||
// Check internal cache or seen
|
||||
if (item.id && this.cacheById.has(item.id)) return;
|
||||
if (item.name && this.cacheByName.has(item.name.toLowerCase())) return;
|
||||
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueRequests.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const fetchedCards: ScryfallCard[] = [];
|
||||
const chunks = [];
|
||||
for (let i = 0; i < uniqueRequests.length; i += 75) chunks.push(uniqueRequests.slice(i, i + 75));
|
||||
|
||||
let totalFetched = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (onProgress) onProgress(totalFetched, uniqueRequests.length);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.scryfall.com/cards/collection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifiers: chunk })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.data) {
|
||||
data.data.forEach((card: ScryfallCard) => {
|
||||
this.cacheById.set(card.id, card);
|
||||
if (card.name) this.cacheByName.set(card.name.toLowerCase(), card);
|
||||
fetchedCards.push(card);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Scryfall fetch error:", error);
|
||||
}
|
||||
|
||||
totalFetched += chunk.length;
|
||||
await new Promise(r => setTimeout(r, 75)); // Rate limit respect
|
||||
}
|
||||
|
||||
// Return everything requested (from cache included)
|
||||
const result: ScryfallCard[] = [];
|
||||
identifiers.forEach(item => {
|
||||
if (item.id) {
|
||||
const c = this.cacheById.get(item.id);
|
||||
if (c) result.push(c);
|
||||
} else if (item.name) {
|
||||
const c = this.cacheByName.get(item.name.toLowerCase());
|
||||
if (c) result.push(c);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getCachedCard(identifier: { id?: string; name?: string }): ScryfallCard | undefined {
|
||||
if (identifier.id) return this.cacheById.get(identifier.id);
|
||||
if (identifier.name) return this.cacheByName.get(identifier.name.toLowerCase());
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
3
src/client/src/styles/main.css
Normal file
3
src/client/src/styles/main.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Reference in New Issue
Block a user