feat: Implement set-based pack generation with booster box creation, including new UI for selecting MTG sets and fetching their card pools.

This commit is contained in:
2025-12-14 21:12:57 +01:00
parent d687c6b77f
commit d468055f6b
5 changed files with 307 additions and 75 deletions

View File

@@ -6,6 +6,7 @@ The project has successfully migrated from a .NET backend to a Node.js Modular M
## Recent Updates
- **[2025-12-14] Core Implementation**: Refactored `gemini-generated.js` into modular services and components. Implemented Cube Manager and Tournament Manager. [Link](./devlog/2025-12-14-194558_core_implementation.md)
- **[2025-12-14] Parser Robustness**: Improving `CardParserService` to handle formats without Scryfall IDs (e.g., Arena exports). [Link](./devlog/2025-12-14-210000_fix_parser_robustness.md)
- **[2025-12-14] Set Generation**: Implemented full set fetching and booster box generation (Completed). [Link](./devlog/2025-12-14-211000_set_based_generation.md)
## Active Modules
1. **Cube Manager**: Fully functional (Parsing, Fetching, Pack Generation).

View File

@@ -0,0 +1,26 @@
# Enhancement: Set-Based Pack Generation
## Status: Completed
## Summary
Implemented the ability to fetch entire sets from Scryfall and generate booster boxes.
## Changes
1. **ScryfallService**:
* Added `fetchSets()` to retrieve expansion sets.
* Added `fetchSetCards(setCode)` to retrieve all cards from a set.
2. **PackGeneratorService**:
* Added `generateBoosterBox()` to generate packs without depleting the pool.
* Added `buildTokenizedPack()` for probabilistic generation (R/M + 3U + 10C).
3. **CubeManager UI**:
* Added Toggle for "Custom List" vs "From Expansion".
* Added Set Selection Dropdown.
* Added "Number of Boxes" input.
* Integrated new service methods.
## Usage
1. Select "From Expansion" tab.
2. Choose a set (e.g., "Vintage Masters").
3. Choose number of boxes (default 3).
4. Click "Fetch Set".
5. Click "Generate Packs".

View File

@@ -1,7 +1,7 @@
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 { ScryfallService, ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
import { PackCard } from '../../components/PackCard';
import { TournamentPackView } from '../../components/TournamentPackView';
@@ -40,6 +40,11 @@ export const CubeManager: React.FC = () => {
rarityMode: 'peasant'
});
const [sourceMode, setSourceMode] = useState<'upload' | 'set'>('upload');
const [availableSets, setAvailableSets] = useState<ScryfallSet[]>([]);
const [selectedSet, setSelectedSet] = useState<string>('');
const [numBoxes, setNumBoxes] = useState<number>(3);
const fileInputRef = useRef<HTMLInputElement>(null);
// --- Effects ---
@@ -50,6 +55,12 @@ export const CubeManager: React.FC = () => {
}
}, [filters, rawScryfallData]);
useEffect(() => {
scryfallService.fetchSets().then(sets => {
setAvailableSets(sets.sort((a, b) => new Date(b.released_at).getTime() - new Date(a.released_at).getTime()));
});
}, [scryfallService]);
// --- Handlers ---
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -94,35 +105,35 @@ export const CubeManager: React.FC = () => {
const fetchAndParse = async () => {
setLoading(true);
setPacks([]);
setProgress('Parsing text...');
setProgress(sourceMode === 'set' ? 'Fetching set data...' : 'Parsing text...');
setTournamentMode(false);
try {
const identifiers = parserService.parse(inputText);
let expandedCards: ScryfallCard[] = [];
// Expand quantity for fetching logic (though service handles it, let's just pass uniques to service)
// The service `fetchCollection` deduplicates automatically.
if (sourceMode === 'set') {
if (!selectedSet) throw new Error("Please select a set.");
const cards = await scryfallService.fetchSetCards(selectedSet, (count) => {
setProgress(`Fetching set cards... (${count})`);
});
expandedCards = cards;
} else {
const identifiers = parserService.parse(inputText);
const fetchList = identifiers.map(id => id.type === 'id' ? { id: id.value } : { name: id.value });
// Map identifier interface
const fetchList = identifiers.map(id => id.type === 'id' ? { id: id.value } : { name: id.value });
await scryfallService.fetchCollection(fetchList, (current, total) => {
setProgress(`Fetching Scryfall data... (${current}/${total})`);
});
// 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);
}
});
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('');
@@ -136,20 +147,21 @@ export const CubeManager: React.FC = () => {
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);
let newPacks: Pack[] = [];
if (sourceMode === 'set') {
const totalPacks = numBoxes * 36;
newPacks = generatorService.generateBoosterBox(processedData.pools, totalPacks, genSettings);
} else {
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).`);
alert(`Not enough cards to generate valid packs.`);
} else {
setPacks(newPacks);
setTournamentMode(false);
@@ -173,55 +185,118 @@ export const CubeManager: React.FC = () => {
{/* --- 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
{/* Source Toggle */}
<div className="flex p-1 bg-slate-900 rounded-lg mb-4 border border-slate-700">
<button
onClick={() => setSourceMode('upload')}
className={`flex-1 py-1.5 text-xs font-bold rounded-md transition-all ${sourceMode === 'upload' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-slate-300'}`}
>
Custom List
</button>
<button
onClick={() => setSourceMode('set')}
className={`flex-1 py-1.5 text-xs font-bold rounded-md transition-all ${sourceMode === 'set' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-slate-300'}`}
>
From Expansion
</button>
</div>
{sourceMode === 'upload' ? (
<>
<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>
<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>
</>
) : (
<>
<div className="mb-4">
<label className="block text-sm font-semibold text-slate-300 mb-2">Select Expansion</label>
<select
value={selectedSet}
onChange={(e) => setSelectedSet(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-slate-300 focus:ring-2 focus:ring-purple-500 outline-none"
disabled={loading}
>
<option value="">-- Choose Set --</option>
{availableSets.map(s => (
<option key={s.code} value={s.code}>
{s.name} ({s.code.toUpperCase()}) - {s.set_type}
</option>
))}
</select>
</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>
<div className="mb-4">
<label className="block text-sm font-semibold text-slate-300 mb-2">Quantity</label>
<div className="flex items-center gap-2 bg-slate-900 p-2 rounded-lg border border-slate-700">
<input
type="number"
min={1}
max={20}
value={numBoxes}
onChange={(e) => setNumBoxes(parseInt(e.target.value))}
className="w-16 bg-slate-800 border-none rounded p-1 text-center text-white font-mono"
disabled={loading}
/>
<span className="text-slate-400 text-sm">Booster Boxes ({numBoxes * 36} Packs)</span>
</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>
<button
onClick={fetchAndParse}
disabled={loading || !selectedSet}
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-blue-600 hover:bg-blue-500 text-white'}`}
>
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> {progress}</> : <><Check className="w-4 h-4" /> 1. Fetch Set</>}
</button>
</>
)}
{/* Generation Settings */}
{processedData && Object.keys(processedData.sets).length > 0 && (

View File

@@ -217,6 +217,77 @@ export class PackGeneratorService {
return { selected, remainingPool, success: selected.length === count };
}
generateBoosterBox(setCards: ProcessedPools, numberOfPacks: number, settings: PackGenerationSettings): Pack[] {
const packs: Pack[] = [];
for (let i = 1; i <= numberOfPacks; i++) {
const newPack = this.buildTokenizedPack(setCards, i, 'Booster', settings.rarityMode);
if (newPack) packs.push(newPack);
}
return packs;
}
private buildTokenizedPack(pools: ProcessedPools, packId: number, setName: string, rarityMode: 'peasant' | 'standard'): Pack | null {
const packCards: DraftCard[] = [];
const namesInThisPack = new Set<string>();
const COMMONS_COUNT = 10;
const UNCOMMONS_COUNT = 3;
if (rarityMode === 'standard') {
// Rare/Mythic logic
const isMythic = Math.random() < 0.125;
let rPool = isMythic ? pools.mythics : pools.rares;
if (rPool.length === 0) rPool = pools.rares; // Fallback
if (rPool.length === 0) rPool = pools.mythics; // Fallback
if (rPool.length > 0) {
const pick = this.sampleUnique(rPool, 1, namesInThisPack);
if (pick.length) {
packCards.push(...pick);
namesInThisPack.add(pick[0].name);
}
}
}
// Uncommons
const uPicks = this.sampleUnique(pools.uncommons, UNCOMMONS_COUNT, namesInThisPack);
packCards.push(...uPicks);
uPicks.forEach(p => namesInThisPack.add(p.name));
// Commons
const cPicks = this.sampleUnique(pools.commons, COMMONS_COUNT, namesInThisPack);
packCards.push(...cPicks);
if (packCards.length < (rarityMode === 'standard' ? 14 : 13)) return null;
// Sort
const rarityWeight: { [key: string]: number } = { 'mythic': 4, 'rare': 3, 'uncommon': 2, 'common': 1 };
packCards.sort((a, b) => (rarityWeight[b.rarity] || 0) - (rarityWeight[a.rarity] || 0));
return { id: packId, setName: setName, cards: packCards };
}
private sampleUnique(pool: DraftCard[], count: number, excludeNames: Set<string>): DraftCard[] {
// Filter out excluded names
const candidates = pool.filter(c => !excludeNames.has(c.name));
if (candidates.length < count) {
// Not enough unique cards, just take what we have
return candidates;
}
const picked: DraftCard[] = [];
const indices = new Set<number>();
while (picked.length < count) {
const idx = Math.floor(Math.random() * candidates.length);
if (!indices.has(idx)) {
indices.add(idx);
picked.push(candidates[idx]);
}
}
return picked;
}
private shuffle(array: any[]) {
let currentIndex = array.length, randomIndex;
const newArray = [...array];

View File

@@ -85,4 +85,63 @@ export class ScryfallService {
if (identifier.name) return this.cacheByName.get(identifier.name.toLowerCase());
return undefined;
}
async fetchSets(): Promise<ScryfallSet[]> {
try {
const response = await fetch('https://api.scryfall.com/sets');
const data = await response.json();
if (data.data) {
return data.data.filter((s: any) =>
['core', 'expansion', 'masters', 'draft_innovation'].includes(s.set_type)
).map((s: any) => ({
code: s.code,
name: s.name,
set_type: s.set_type,
released_at: s.released_at,
icon_svg_uri: s.icon_svg_uri
}));
}
} catch (e) {
console.error("Error fetching sets", e);
}
return [];
}
async fetchSetCards(setCode: string, onProgress?: (current: number) => void): Promise<ScryfallCard[]> {
let cards: ScryfallCard[] = [];
let url = `https://api.scryfall.com/cards/search?q=set:${setCode}&unique=cards`;
while (url) {
try {
const res = await fetch(url);
const data = await res.json();
if (data.data) {
// Should we filter here strictly? The API query 'set:code' + 'unique=cards' is usually correct.
// We might want to filter out Basics if we don't want them in booster generation, but standard boosters contain basics.
// However, user setting for "Ignore Basic Lands" is handled in PackGeneratorService.processCards.
// So here we should fetch everything.
cards.push(...data.data);
if (onProgress) onProgress(cards.length);
}
if (data.has_more && data.next_page) {
url = data.next_page;
await new Promise(r => setTimeout(r, 100)); // Respect API limits
} else {
url = '';
}
} catch (e) {
console.error(e);
break;
}
}
return cards;
}
}
export interface ScryfallSet {
code: string;
name: string;
set_type: string;
released_at: string;
icon_svg_uri: string;
}