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:
@@ -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).
|
||||
|
||||
@@ -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".
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user