feat: Implement server-side Scryfall API integration for card and set caching and introduce new pack generation services.
All checks were successful
Build and Deploy / build (push) Successful in 1m15s
All checks were successful
Build and Deploy / build (push) Successful in 1m15s
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users, Download, Copy, FileDown, Trash2, Search, X } from 'lucide-react';
|
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users, Download, Copy, FileDown, Trash2, Search, X } from 'lucide-react';
|
||||||
import { CardParserService } from '../../services/CardParserService';
|
import { ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
|
||||||
import { ScryfallService, ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
|
|
||||||
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
|
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
|
||||||
import { PackCard } from '../../components/PackCard';
|
import { PackCard } from '../../components/PackCard';
|
||||||
|
|
||||||
@@ -15,8 +14,6 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
|||||||
// --- Services ---
|
// --- Services ---
|
||||||
// --- Services ---
|
// --- Services ---
|
||||||
// Memoize services to persist cache across renders
|
// 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(), []);
|
const generatorService = React.useMemo(() => new PackGeneratorService(), []);
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
@@ -124,10 +121,13 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
|||||||
}, [filters, rawScryfallData]);
|
}, [filters, rawScryfallData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scryfallService.fetchSets().then(sets => {
|
fetch('/api/sets')
|
||||||
setAvailableSets(sets.sort((a, b) => new Date(b.released_at).getTime() - new Date(a.released_at).getTime()));
|
.then(res => res.json())
|
||||||
});
|
.then((sets: ScryfallSet[]) => {
|
||||||
}, [scryfallService]);
|
setAvailableSets(sets.sort((a, b) => new Date(b.released_at).getTime() - new Date(a.released_at).getTime()));
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -146,87 +146,40 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
|||||||
setPacks([]);
|
setPacks([]);
|
||||||
setProgress(sourceMode === 'set' ? 'Fetching set data...' : 'Parsing text...');
|
setProgress(sourceMode === 'set' ? 'Fetching set data...' : 'Parsing text...');
|
||||||
|
|
||||||
const cacheCardsToServer = async (cardsToCache: ScryfallCard[]) => {
|
|
||||||
if (cardsToCache.length === 0) return;
|
|
||||||
try {
|
|
||||||
// Deduplicate for shipping to server
|
|
||||||
const uniqueCards = Array.from(new Map(cardsToCache.map(c => [c.id, c])).values());
|
|
||||||
await fetch('/api/cards/cache', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ cards: uniqueCards })
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to cache chunk to server:", e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let expandedCards: ScryfallCard[] = [];
|
let expandedCards: ScryfallCard[] = [];
|
||||||
|
|
||||||
if (sourceMode === 'set') {
|
if (sourceMode === 'set') {
|
||||||
if (selectedSets.length === 0) throw new Error("Please select at least one set.");
|
if (selectedSets.length === 0) throw new Error("Please select at least one set.");
|
||||||
|
|
||||||
|
// We fetch set by set to show progress
|
||||||
for (const [index, setCode] of selectedSets.entries()) {
|
for (const [index, setCode] of selectedSets.entries()) {
|
||||||
// Update progress for set
|
setProgress(`Fetching set ${setCode.toUpperCase()} (${index + 1}/${selectedSets.length})...`);
|
||||||
// const setInfo = availableSets.find(s => s.code === setCode);
|
|
||||||
|
|
||||||
setProgress(`Loading sets... (${index + 1}/${selectedSets.length})`);
|
const response = await fetch(`/api/sets/${setCode}/cards`);
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch set ${setCode}`);
|
||||||
const cards = await scryfallService.fetchSetCards(setCode, (_count) => {
|
|
||||||
// Progress handled by outer loop mostly
|
|
||||||
});
|
|
||||||
|
|
||||||
// Incrementally cache this set to server
|
|
||||||
await cacheCardsToServer(cards);
|
|
||||||
|
|
||||||
|
const cards: ScryfallCard[] = await response.json();
|
||||||
expandedCards.push(...cards);
|
expandedCards.push(...cards);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const identifiers = parserService.parse(inputText);
|
// Parse Text
|
||||||
const fetchList = identifiers.map(id => id.type === 'id' ? { id: id.value } : { name: id.value });
|
setProgress('Parsing and fetching from server...');
|
||||||
|
const response = await fetch('/api/cards/parse', {
|
||||||
// Identify how many are already cached for feedback
|
method: 'POST',
|
||||||
let cachedCount = 0;
|
headers: { 'Content-Type': 'application/json' },
|
||||||
fetchList.forEach(req => {
|
body: JSON.stringify({ text: inputText })
|
||||||
if (scryfallService.getCachedCard(req)) cachedCount++;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await scryfallService.fetchCollection(fetchList, (current, total) => {
|
if (!response.ok) {
|
||||||
setProgress(`Fetching Scryfall data... (${current}/${total})`);
|
const err = await response.json();
|
||||||
});
|
throw new Error(err.error || "Failed to parse cards");
|
||||||
|
|
||||||
// Re-check cache to get all objects
|
|
||||||
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++) {
|
|
||||||
// Clone card to attach unique properties like finish
|
|
||||||
const expandedCard = { ...card };
|
|
||||||
if (id.finish) {
|
|
||||||
expandedCard.finish = id.finish;
|
|
||||||
}
|
|
||||||
expandedCards.push(expandedCard);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalRequested = identifiers.reduce((acc, curr) => acc + curr.quantity, 0);
|
|
||||||
const missing = totalRequested - expandedCards.length;
|
|
||||||
|
|
||||||
if (missing > 0) {
|
|
||||||
alert(`Warning: ${missing} cards could not be identified or fetched.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache custom list to server
|
expandedCards = await response.json();
|
||||||
if (expandedCards.length > 0) {
|
|
||||||
setProgress('Caching to server...');
|
|
||||||
await cacheCardsToServer(expandedCards);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setRawScryfallData(expandedCards);
|
setRawScryfallData(expandedCards);
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setProgress('');
|
setProgress('');
|
||||||
|
|
||||||
@@ -237,34 +190,60 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const generatePacks = () => {
|
const generatePacks = async () => {
|
||||||
if (!processedData) return;
|
// if (!processedData) return; // Logic moved to server, but we still use processedData for UI check
|
||||||
|
if (!rawScryfallData || rawScryfallData.length === 0) {
|
||||||
|
if (sourceMode === 'set' && selectedSets.length > 0) {
|
||||||
|
// Allowed to proceed if sets selected (server fetches)
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setProgress('Generating packs on server...');
|
||||||
|
|
||||||
// Use setTimeout to allow UI to show loading spinner before sync calculation blocks
|
try {
|
||||||
setTimeout(() => {
|
const payload = {
|
||||||
try {
|
cards: sourceMode === 'upload' ? rawScryfallData : [],
|
||||||
let newPacks: Pack[] = [];
|
sourceMode,
|
||||||
if (sourceMode === 'set') {
|
selectedSets,
|
||||||
const totalPacks = numBoxes * 36;
|
settings: genSettings,
|
||||||
newPacks = generatorService.generateBoosterBox(processedData.pools, totalPacks, genSettings);
|
numBoxes,
|
||||||
} else {
|
numPacks: sourceMode === 'set' ? (numBoxes * 36) : undefined,
|
||||||
newPacks = generatorService.generatePacks(processedData.pools, processedData.sets, genSettings);
|
filters
|
||||||
}
|
};
|
||||||
|
|
||||||
if (newPacks.length === 0) {
|
// Use fetch from server logic
|
||||||
alert(`Not enough cards to generate valid packs.`);
|
if (sourceMode === 'set') {
|
||||||
} else {
|
payload.cards = [];
|
||||||
setPacks(newPacks);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Generation failed", e);
|
|
||||||
alert("Error generating packs: " + e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, 50);
|
|
||||||
|
const response = await fetch('/api/packs/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.error || "Generation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPacks: Pack[] = await response.json();
|
||||||
|
|
||||||
|
if (newPacks.length === 0) {
|
||||||
|
alert(`No packs generated. Check your card pool settings.`);
|
||||||
|
} else {
|
||||||
|
setPacks(newPacks);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Generation failed", e);
|
||||||
|
alert("Error generating packs: " + e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setProgress('');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportCsv = () => {
|
const handleExportCsv = () => {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export class PackGeneratorService {
|
|||||||
layout: layout,
|
layout: layout,
|
||||||
colors: cardData.colors || [],
|
colors: cardData.colors || [],
|
||||||
image: useLocalImages
|
image: useLocalImages
|
||||||
? `${window.location.origin}/cards/images/${cardData.id}.jpg`
|
? `${window.location.origin}/cards/images/${cardData.set}/${cardData.id}.jpg`
|
||||||
: (cardData.image_uris?.normal || cardData.card_faces?.[0]?.image_uris?.normal || ''),
|
: (cardData.image_uris?.normal || cardData.card_faces?.[0]?.image_uris?.normal || ''),
|
||||||
set: cardData.set_name,
|
set: cardData.set_name,
|
||||||
setCode: cardData.set,
|
setCode: cardData.set,
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { RoomManager } from './managers/RoomManager';
|
|||||||
import { GameManager } from './managers/GameManager';
|
import { GameManager } from './managers/GameManager';
|
||||||
import { DraftManager } from './managers/DraftManager';
|
import { DraftManager } from './managers/DraftManager';
|
||||||
import { CardService } from './services/CardService';
|
import { CardService } from './services/CardService';
|
||||||
|
import { ScryfallService } from './services/ScryfallService';
|
||||||
|
import { PackGeneratorService } from './services/PackGeneratorService';
|
||||||
|
import { CardParserService } from './services/CardParserService';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -24,6 +27,9 @@ const roomManager = new RoomManager();
|
|||||||
const gameManager = new GameManager();
|
const gameManager = new GameManager();
|
||||||
const draftManager = new DraftManager();
|
const draftManager = new DraftManager();
|
||||||
const cardService = new CardService();
|
const cardService = new CardService();
|
||||||
|
const scryfallService = new ScryfallService();
|
||||||
|
const packGeneratorService = new PackGeneratorService();
|
||||||
|
const cardParserService = new CardParserService();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
app.use(express.json({ limit: '50mb' })); // Increase limit for large card lists
|
app.use(express.json({ limit: '50mb' })); // Increase limit for large card lists
|
||||||
@@ -61,6 +67,93 @@ app.post('/api/cards/cache', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- NEW ROUTES ---
|
||||||
|
|
||||||
|
app.get('/api/sets', async (_req: Request, res: Response) => {
|
||||||
|
const sets = await scryfallService.fetchSets();
|
||||||
|
res.json(sets);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/sets/:code/cards', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const cards = await scryfallService.fetchSetCards(req.params.code);
|
||||||
|
res.json(cards);
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/cards/parse', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { text } = req.body;
|
||||||
|
const identifiers = cardParserService.parse(text);
|
||||||
|
|
||||||
|
// Resolve
|
||||||
|
const uniqueIds = identifiers.map(id => id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||||
|
const uniqueCards = await scryfallService.fetchCollection(uniqueIds);
|
||||||
|
|
||||||
|
// Expand
|
||||||
|
const expanded: any[] = [];
|
||||||
|
const cardMap = new Map();
|
||||||
|
uniqueCards.forEach(c => {
|
||||||
|
cardMap.set(c.id, c);
|
||||||
|
if (c.name) cardMap.set(c.name.toLowerCase(), c);
|
||||||
|
});
|
||||||
|
|
||||||
|
identifiers.forEach(req => {
|
||||||
|
let card = null;
|
||||||
|
if (req.type === 'id') card = cardMap.get(req.value);
|
||||||
|
else card = cardMap.get(req.value.toLowerCase());
|
||||||
|
|
||||||
|
if (card) {
|
||||||
|
for (let i = 0; i < req.quantity; i++) {
|
||||||
|
const clone = { ...card };
|
||||||
|
if (req.finish) clone.finish = req.finish;
|
||||||
|
// Add quantity to object? No, we duplicate objects in the list as requested by client flow usually
|
||||||
|
expanded.push(clone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(expanded);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Parse error", e);
|
||||||
|
res.status(400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/packs/generate', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { cards, settings, numPacks, sourceMode, selectedSets, filters } = req.body;
|
||||||
|
|
||||||
|
let poolCards = cards || [];
|
||||||
|
|
||||||
|
// If server-side expansion fetching is requested
|
||||||
|
if (sourceMode === 'set' && selectedSets && Array.isArray(selectedSets)) {
|
||||||
|
console.log(`[API] Fetching sets for generation: ${selectedSets.join(', ')}`);
|
||||||
|
for (const code of selectedSets) {
|
||||||
|
const setCards = await scryfallService.fetchSetCards(code);
|
||||||
|
poolCards.push(...setCards);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default filters if missing
|
||||||
|
const activeFilters = filters || {
|
||||||
|
ignoreBasicLands: true,
|
||||||
|
ignoreCommander: true,
|
||||||
|
ignoreTokens: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const { pools, sets } = packGeneratorService.processCards(poolCards, activeFilters);
|
||||||
|
|
||||||
|
const packs = packGeneratorService.generatePacks(pools, sets, settings, numPacks || 108);
|
||||||
|
res.json(packs);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Generation error", e);
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Global Draft Timer Loop
|
// Global Draft Timer Loop
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const updates = draftManager.checkTimers();
|
const updates = draftManager.checkTimers();
|
||||||
|
|||||||
154
src/server/services/CardParserService.ts
Normal file
154
src/server/services/CardParserService.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
|
||||||
|
export interface CardIdentifier {
|
||||||
|
type: 'id' | 'name';
|
||||||
|
value: string;
|
||||||
|
quantity: number;
|
||||||
|
finish?: 'foil' | 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
let colMap = { qty: 0, name: 1, finish: 2, id: -1, found: false };
|
||||||
|
|
||||||
|
// Check header to determine column indices dynamically
|
||||||
|
if (lines.length > 0) {
|
||||||
|
const headerLine = lines[0].toLowerCase();
|
||||||
|
// Heuristic: if it has Quantity and Name, it's likely our CSV
|
||||||
|
if (headerLine.includes('quantity') && headerLine.includes('name')) {
|
||||||
|
const headers = this.parseCsvLine(lines[0]).map(h => h.toLowerCase().trim());
|
||||||
|
const qtyIndex = headers.indexOf('quantity');
|
||||||
|
const nameIndex = headers.indexOf('name');
|
||||||
|
|
||||||
|
if (qtyIndex !== -1 && nameIndex !== -1) {
|
||||||
|
colMap.qty = qtyIndex;
|
||||||
|
colMap.name = nameIndex;
|
||||||
|
colMap.finish = headers.indexOf('finish');
|
||||||
|
// Find ID column: could be 'scryfall id', 'scryfall_id', 'id'
|
||||||
|
colMap.id = headers.findIndex(h => h === 'scryfall id' || h === 'scryfall_id' || h === 'id' || h === 'uuid');
|
||||||
|
colMap.found = true;
|
||||||
|
|
||||||
|
// Remove header row
|
||||||
|
lines.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
// Skip generic header repetition if it occurs
|
||||||
|
if (line.toLowerCase().startsWith('quantity') && line.toLowerCase().includes('name')) return;
|
||||||
|
|
||||||
|
// Try parsing as CSV line first if we detected a header or if it looks like CSV
|
||||||
|
const parts = this.parseCsvLine(line);
|
||||||
|
|
||||||
|
// If we have a detected map, use it strict(er)
|
||||||
|
if (colMap.found && parts.length > Math.max(colMap.qty, colMap.name)) {
|
||||||
|
const qty = parseInt(parts[colMap.qty]);
|
||||||
|
if (!isNaN(qty)) {
|
||||||
|
const name = parts[colMap.name];
|
||||||
|
let finish: 'foil' | 'normal' | undefined = undefined;
|
||||||
|
|
||||||
|
if (colMap.finish !== -1 && parts[colMap.finish]) {
|
||||||
|
const finishRaw = parts[colMap.finish].toLowerCase();
|
||||||
|
finish = (finishRaw === 'foil' || finishRaw === 'etched') ? 'foil' : (finishRaw === 'normal' ? 'normal' : undefined);
|
||||||
|
} else if (!colMap.found) {
|
||||||
|
const finishRaw = parts[2]?.toLowerCase();
|
||||||
|
finish = (finishRaw === 'foil' || finishRaw === 'etched') ? 'foil' : (finishRaw === 'normal' ? 'normal' : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
let idValue: string | null = null;
|
||||||
|
|
||||||
|
// If we have an ID column, look there
|
||||||
|
if (colMap.id !== -1 && parts[colMap.id]) {
|
||||||
|
const match = parts[colMap.id].match(uuidRegex);
|
||||||
|
if (match) idValue = match[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idValue) {
|
||||||
|
rawCardList.push({ type: 'id', value: idValue, quantity: qty, finish });
|
||||||
|
return;
|
||||||
|
} else if (name) {
|
||||||
|
rawCardList.push({ type: 'name', value: name, quantity: qty, finish });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fallback / Original Logic for non-header formats or failed parsings ---
|
||||||
|
|
||||||
|
const idMatch = line.match(uuidRegex);
|
||||||
|
if (idMatch) {
|
||||||
|
// It has a UUID, try to extract generic CSV info if possible
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const qty = parseInt(parts[0]);
|
||||||
|
if (!isNaN(qty)) {
|
||||||
|
// Assuming default 0=Qty, 2=Finish if no header map found
|
||||||
|
const finishRaw = parts[2]?.toLowerCase();
|
||||||
|
const finish = (finishRaw === 'foil' || finishRaw === 'etched') ? 'foil' : (finishRaw === 'normal' ? 'normal' : undefined);
|
||||||
|
|
||||||
|
// Use the regex match found
|
||||||
|
rawCardList.push({ type: 'id', value: idMatch[0], quantity: qty, finish });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Just ID flow
|
||||||
|
rawCardList.push({ type: 'id', value: idMatch[0], quantity: 1 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name-based generic parsing (Arena/MTGO or simple CSV without ID)
|
||||||
|
if (parts.length >= 2 && !isNaN(parseInt(parts[0]))) {
|
||||||
|
const quantity = parseInt(parts[0]);
|
||||||
|
const name = parts[1];
|
||||||
|
const finishRaw = parts[2]?.toLowerCase();
|
||||||
|
const finish = (finishRaw === 'foil' || finishRaw === 'etched') ? 'foil' : (finishRaw === 'normal' ? 'normal' : undefined);
|
||||||
|
|
||||||
|
if (name && name.length > 0) {
|
||||||
|
rawCardList.push({ type: 'name', value: name, quantity, finish });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "4 Lightning Bolt" format
|
||||||
|
const cleanLine = line.replace(/['"]/g, '');
|
||||||
|
const simpleMatch = cleanLine.match(/^(\d+)[xX\s]+(.+)$/);
|
||||||
|
if (simpleMatch) {
|
||||||
|
let name = simpleMatch[2].trim();
|
||||||
|
name = name.replace(/\s*[\(\[].*?[\)\]]/g, '');
|
||||||
|
name = name.replace(/\s+\d+$/, '');
|
||||||
|
|
||||||
|
rawCardList.push({ type: 'name', value: name, quantity: parseInt(simpleMatch[1]) });
|
||||||
|
} else {
|
||||||
|
let name = cleanLine.trim();
|
||||||
|
if (name) {
|
||||||
|
rawCardList.push({ type: 'name', value: name, quantity: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rawCardList.length === 0) throw new Error("No valid cards found.");
|
||||||
|
return rawCardList;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCsvLine(line: string): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuote = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
if (char === '"') {
|
||||||
|
inQuote = !inQuote;
|
||||||
|
} else if (char === ',' && !inQuote) {
|
||||||
|
parts.push(current.trim().replace(/^"|"$/g, '')); // Parsing finished, strip outer quotes if just accumulated
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(current.trim().replace(/^"|"$/g, ''));
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ export class CardService {
|
|||||||
this.imagesDir = path.join(CARDS_DIR, 'images');
|
this.imagesDir = path.join(CARDS_DIR, 'images');
|
||||||
this.metadataDir = path.join(CARDS_DIR, 'metadata');
|
this.metadataDir = path.join(CARDS_DIR, 'metadata');
|
||||||
|
|
||||||
|
this.ensureDirs();
|
||||||
|
this.migrateExistingImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureDirs() {
|
||||||
if (!fs.existsSync(this.imagesDir)) {
|
if (!fs.existsSync(this.imagesDir)) {
|
||||||
fs.mkdirSync(this.imagesDir, { recursive: true });
|
fs.mkdirSync(this.imagesDir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -23,6 +28,54 @@ export class CardService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private migrateExistingImages() {
|
||||||
|
console.log('[CardService] Checking for images to migrate...');
|
||||||
|
const start = Date.now();
|
||||||
|
let moved = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(this.metadataDir)) {
|
||||||
|
const items = fs.readdirSync(this.metadataDir);
|
||||||
|
for (const item of items) {
|
||||||
|
const itemPath = path.join(this.metadataDir, item);
|
||||||
|
if (fs.statSync(itemPath).isDirectory()) {
|
||||||
|
// This determines the set
|
||||||
|
const setCode = item;
|
||||||
|
const cardFiles = fs.readdirSync(itemPath);
|
||||||
|
|
||||||
|
for (const file of cardFiles) {
|
||||||
|
if (!file.endsWith('.json')) continue;
|
||||||
|
const id = file.replace('.json', '');
|
||||||
|
|
||||||
|
// Check for legacy image
|
||||||
|
const legacyImgPath = path.join(this.imagesDir, `${id}.jpg`);
|
||||||
|
if (fs.existsSync(legacyImgPath)) {
|
||||||
|
const targetDir = path.join(this.imagesDir, setCode);
|
||||||
|
if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
const targetPath = path.join(targetDir, `${id}.jpg`);
|
||||||
|
try {
|
||||||
|
fs.renameSync(legacyImgPath, targetPath);
|
||||||
|
moved++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[CardService] Failed to move ${id}.jpg to ${setCode}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[CardService] Migration error', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moved > 0) {
|
||||||
|
console.log(`[CardService] Migrated ${moved} images to set folders in ${Date.now() - start}ms.`);
|
||||||
|
} else {
|
||||||
|
console.log(`[CardService] No images needed migration.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async cacheImages(cards: any[]): Promise<number> {
|
async cacheImages(cards: any[]): Promise<number> {
|
||||||
let downloadedCount = 0;
|
let downloadedCount = 0;
|
||||||
|
|
||||||
@@ -35,9 +88,11 @@ export class CardService {
|
|||||||
const card = queue.shift();
|
const card = queue.shift();
|
||||||
if (!card) break;
|
if (!card) break;
|
||||||
|
|
||||||
// Determine UUID and URL
|
// Determine UUID
|
||||||
const uuid = card.id || card.oracle_id; // Prefer ID
|
const uuid = card.id || card.oracle_id;
|
||||||
if (!uuid) continue;
|
const setCode = card.set;
|
||||||
|
|
||||||
|
if (!uuid || !setCode) continue;
|
||||||
|
|
||||||
// Check for normal image
|
// Check for normal image
|
||||||
let imageUrl = card.image_uris?.normal;
|
let imageUrl = card.image_uris?.normal;
|
||||||
@@ -47,13 +102,30 @@ export class CardService {
|
|||||||
|
|
||||||
if (!imageUrl) continue;
|
if (!imageUrl) continue;
|
||||||
|
|
||||||
const filePath = path.join(this.imagesDir, `${uuid}.jpg`);
|
const setDir = path.join(this.imagesDir, setCode);
|
||||||
|
if (!fs.existsSync(setDir)) {
|
||||||
|
fs.mkdirSync(setDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(setDir, `${uuid}.jpg`);
|
||||||
|
|
||||||
|
// Check if exists in set folder
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
// Already cached
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check legacy location and move if exists (double check)
|
||||||
|
const legacyPath = path.join(this.imagesDir, `${uuid}.jpg`);
|
||||||
|
if (fs.existsSync(legacyPath)) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(legacyPath, filePath);
|
||||||
|
// console.log(`Migrated image ${uuid} to ${setCode}`);
|
||||||
|
continue;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to migrate image ${uuid}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Download
|
// Download
|
||||||
const response = await fetch(imageUrl);
|
const response = await fetch(imageUrl);
|
||||||
@@ -61,7 +133,7 @@ export class CardService {
|
|||||||
const buffer = await response.arrayBuffer();
|
const buffer = await response.arrayBuffer();
|
||||||
fs.writeFileSync(filePath, Buffer.from(buffer));
|
fs.writeFileSync(filePath, Buffer.from(buffer));
|
||||||
downloadedCount++;
|
downloadedCount++;
|
||||||
console.log(`Cached image: ${uuid}.jpg`);
|
console.log(`Cached image: ${setCode}/${uuid}.jpg`);
|
||||||
} else {
|
} else {
|
||||||
console.error(`Failed to download ${imageUrl}: ${response.statusText}`);
|
console.error(`Failed to download ${imageUrl}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
@@ -80,11 +152,21 @@ export class CardService {
|
|||||||
async cacheMetadata(cards: any[]): Promise<number> {
|
async cacheMetadata(cards: any[]): Promise<number> {
|
||||||
let cachedCount = 0;
|
let cachedCount = 0;
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
if (!card.id) continue;
|
if (!card.id || !card.set) continue;
|
||||||
const filePath = path.join(this.metadataDir, `${card.id}.json`);
|
|
||||||
|
const setDir = path.join(this.metadataDir, card.set);
|
||||||
|
if (!fs.existsSync(setDir)) {
|
||||||
|
fs.mkdirSync(setDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(setDir, `${card.id}.json`);
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(filePath, JSON.stringify(card, null, 2));
|
fs.writeFileSync(filePath, JSON.stringify(card, null, 2));
|
||||||
|
// Check and delete legacy if exists
|
||||||
|
const legacy = path.join(this.metadataDir, `${card.id}.json`);
|
||||||
|
if (fs.existsSync(legacy)) fs.unlinkSync(legacy);
|
||||||
|
|
||||||
cachedCount++;
|
cachedCount++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to save metadata for ${card.id}`, e);
|
console.error(`Failed to save metadata for ${card.id}`, e);
|
||||||
|
|||||||
410
src/server/services/PackGeneratorService.ts
Normal file
410
src/server/services/PackGeneratorService.ts
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
|
||||||
|
import { ScryfallCard } from './ScryfallService';
|
||||||
|
|
||||||
|
export interface DraftCard {
|
||||||
|
id: string; // Internal UUID
|
||||||
|
scryfallId: string;
|
||||||
|
name: string;
|
||||||
|
rarity: string;
|
||||||
|
typeLine?: string;
|
||||||
|
layout?: string;
|
||||||
|
colors: string[];
|
||||||
|
image: string;
|
||||||
|
set: string;
|
||||||
|
setCode: string;
|
||||||
|
setType: string;
|
||||||
|
finish?: 'foil' | 'normal';
|
||||||
|
[key: string]: any; // Allow extended props
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pack {
|
||||||
|
id: number;
|
||||||
|
setName: string;
|
||||||
|
cards: DraftCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessedPools {
|
||||||
|
commons: DraftCard[];
|
||||||
|
uncommons: DraftCard[];
|
||||||
|
rares: DraftCard[];
|
||||||
|
mythics: DraftCard[];
|
||||||
|
lands: DraftCard[];
|
||||||
|
tokens: DraftCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetsMap {
|
||||||
|
[code: string]: {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
commons: DraftCard[];
|
||||||
|
uncommons: DraftCard[];
|
||||||
|
rares: DraftCard[];
|
||||||
|
mythics: DraftCard[];
|
||||||
|
lands: DraftCard[];
|
||||||
|
tokens: 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 } {
|
||||||
|
console.time('processCards');
|
||||||
|
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] };
|
||||||
|
const setsMap: SetsMap = {};
|
||||||
|
|
||||||
|
let processedCount = 0;
|
||||||
|
|
||||||
|
// Server side doesn't need "useLocalImages" flag logic typically, or we construct local URL here.
|
||||||
|
// For now, we assume we return absolute URLs or relative to server.
|
||||||
|
// Use Scryfall URLs by default or if cached locally, point to /cards/images/ID.jpg
|
||||||
|
|
||||||
|
// We'll point to /cards/images/ID.jpg if we assume they are cached.
|
||||||
|
// But safely: return scryfall URL if not sure?
|
||||||
|
// User requested "optimize", serving local static files is usually faster than hotlinking if network is slow,
|
||||||
|
// but hotlinking scryfall is zero-load on our server IO.
|
||||||
|
// Let's stick to what the client code did: accept a flag or default.
|
||||||
|
// Let's default to standard URLs for now to minimize complexity, or local if we are sure.
|
||||||
|
// We'll stick to Scryfall URLs to ensure images load immediately even if not cached yet.
|
||||||
|
// Optimization is requested for GENERATION speed (algorithm), not image loading speed per se (though related).
|
||||||
|
|
||||||
|
cards.forEach(cardData => {
|
||||||
|
const rarity = cardData.rarity;
|
||||||
|
const typeLine = cardData.type_line || '';
|
||||||
|
const setType = cardData.set_type;
|
||||||
|
const layout = cardData.layout;
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
if (filters.ignoreCommander) {
|
||||||
|
if (['commander', 'starter', 'duel_deck', 'premium_deck', 'planechase', 'archenemy'].includes(setType)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardObj: DraftCard = {
|
||||||
|
// Copy base properties first
|
||||||
|
...cardData,
|
||||||
|
// Overwrite/Set specific Draft properties
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
scryfallId: cardData.id,
|
||||||
|
name: cardData.name,
|
||||||
|
rarity: rarity,
|
||||||
|
typeLine: typeLine,
|
||||||
|
layout: layout,
|
||||||
|
colors: cardData.colors || [],
|
||||||
|
image: cardData.image_uris?.normal || cardData.card_faces?.[0]?.image_uris?.normal || '',
|
||||||
|
set: cardData.set_name,
|
||||||
|
setCode: cardData.set,
|
||||||
|
setType: setType,
|
||||||
|
finish: cardData.finish || 'normal',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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: [], lands: [], tokens: [] };
|
||||||
|
}
|
||||||
|
const setEntry = setsMap[cardData.set];
|
||||||
|
|
||||||
|
const isLand = typeLine.includes('Land');
|
||||||
|
const isBasic = typeLine.includes('Basic');
|
||||||
|
const isToken = layout === 'token' || typeLine.includes('Token') || layout === 'art_series' || layout === 'emblem';
|
||||||
|
|
||||||
|
if (isToken) {
|
||||||
|
if (!filters.ignoreTokens) {
|
||||||
|
pools.tokens.push(cardObj);
|
||||||
|
setEntry.tokens.push(cardObj);
|
||||||
|
}
|
||||||
|
} else if (isBasic || (isLand && rarity === 'common')) {
|
||||||
|
// Slot 12 Logic: Basic or Common Dual Land
|
||||||
|
if (filters.ignoreBasicLands && isBasic) {
|
||||||
|
// Skip basic lands if ignored
|
||||||
|
} else {
|
||||||
|
pools.lands.push(cardObj);
|
||||||
|
setEntry.lands.push(cardObj);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (rarity === 'common') { pools.commons.push(cardObj); setEntry.commons.push(cardObj); }
|
||||||
|
else if (rarity === 'uncommon') { pools.uncommons.push(cardObj); setEntry.uncommons.push(cardObj); }
|
||||||
|
else if (rarity === 'rare') { pools.rares.push(cardObj); setEntry.rares.push(cardObj); }
|
||||||
|
else if (rarity === 'mythic') { pools.mythics.push(cardObj); setEntry.mythics.push(cardObj); }
|
||||||
|
}
|
||||||
|
|
||||||
|
processedCount++;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[PackGenerator] Processed ${processedCount} cards.`);
|
||||||
|
console.timeEnd('processCards');
|
||||||
|
return { pools, sets: setsMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
generatePacks(pools: ProcessedPools, sets: SetsMap, settings: PackGenerationSettings, numPacks: number): Pack[] {
|
||||||
|
console.time('generatePacks');
|
||||||
|
console.log('[PackGenerator] Starting generation:', { mode: settings.mode, rarity: settings.rarityMode, count: numPacks });
|
||||||
|
|
||||||
|
// Optimize: Deep clone only what's needed?
|
||||||
|
// Actually, we destructively modify lists in the algo (shifting/drawing), so we must clone the arrays of specific pools we use.
|
||||||
|
// The previous implementation cloned inside the loop or function.
|
||||||
|
|
||||||
|
let newPacks: Pack[] = [];
|
||||||
|
|
||||||
|
if (settings.mode === 'mixed') {
|
||||||
|
// Mixed Mode (Chaos)
|
||||||
|
const currentPools = {
|
||||||
|
commons: this.shuffle([...pools.commons]),
|
||||||
|
uncommons: this.shuffle([...pools.uncommons]),
|
||||||
|
rares: this.shuffle([...pools.rares]),
|
||||||
|
mythics: this.shuffle([...pools.mythics]),
|
||||||
|
lands: this.shuffle([...pools.lands]),
|
||||||
|
tokens: this.shuffle([...pools.tokens])
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log pool sizes
|
||||||
|
console.log('[PackGenerator] Pool stats:', {
|
||||||
|
c: currentPools.commons.length,
|
||||||
|
u: currentPools.uncommons.length,
|
||||||
|
r: currentPools.rares.length,
|
||||||
|
m: currentPools.mythics.length
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 1; i <= numPacks; i++) {
|
||||||
|
const result = this.buildSinglePack(currentPools, i, 'Chaos Pack', settings.rarityMode);
|
||||||
|
if (!result) {
|
||||||
|
console.warn(`[PackGenerator] Warning: ran out of cards at pack ${i}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
newPacks.push(result);
|
||||||
|
|
||||||
|
if (i % 50 === 0) console.log(`[PackGenerator] Built ${i} packs...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// By Set
|
||||||
|
// Logic: Distribute requested numPacks across available sets? Or generate boxes per set?
|
||||||
|
// Usage usually implies: "Generate X packs form these selected sets".
|
||||||
|
// If 3 boxes selected, caller calls this per set? Or calls with total?
|
||||||
|
// The client code previously iterated selectedSets.
|
||||||
|
// Helper "generateBoosterBox" exists.
|
||||||
|
|
||||||
|
// We will assume "pools" contains ALL cards, and "sets" contains partitioned.
|
||||||
|
// If the user wants specific sets, they filtering "sets" map before passing or we iterate keys of "sets".
|
||||||
|
|
||||||
|
const setKeys = Object.keys(sets);
|
||||||
|
if (setKeys.length === 0) return [];
|
||||||
|
|
||||||
|
const packsPerSet = Math.ceil(numPacks / setKeys.length);
|
||||||
|
|
||||||
|
let packId = 1;
|
||||||
|
for (const setCode of setKeys) {
|
||||||
|
const data = sets[setCode];
|
||||||
|
console.log(`[PackGenerator] Generating ${packsPerSet} packs for set ${data.name}`);
|
||||||
|
|
||||||
|
const currentPools = {
|
||||||
|
commons: this.shuffle([...data.commons]),
|
||||||
|
uncommons: this.shuffle([...data.uncommons]),
|
||||||
|
rares: this.shuffle([...data.rares]),
|
||||||
|
mythics: this.shuffle([...data.mythics]),
|
||||||
|
lands: this.shuffle([...data.lands]),
|
||||||
|
tokens: this.shuffle([...data.tokens])
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < packsPerSet; i++) {
|
||||||
|
if (packId > numPacks) break;
|
||||||
|
|
||||||
|
const result = this.buildSinglePack(currentPools, packId, data.name, settings.rarityMode);
|
||||||
|
if (result) {
|
||||||
|
newPacks.push(result);
|
||||||
|
packId++;
|
||||||
|
} else {
|
||||||
|
console.warn(`[PackGenerator] Set ${data.name} depleted at pack ${packId}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[PackGenerator] Generated ${newPacks.length} packs total.`);
|
||||||
|
console.timeEnd('generatePacks');
|
||||||
|
return newPacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSinglePack(pools: ProcessedPools, packId: number, setName: string, rarityMode: 'peasant' | 'standard'): Pack | null {
|
||||||
|
const packCards: DraftCard[] = [];
|
||||||
|
const namesInPack = new Set<string>();
|
||||||
|
|
||||||
|
// 1. Commons (6)
|
||||||
|
const drawC = this.drawUniqueCards(pools.commons, 6, namesInPack);
|
||||||
|
if (!drawC.success && pools.commons.length < 6) return null; // Hard fail if really empty
|
||||||
|
// Accept partial if just duplication is unavoidable?
|
||||||
|
// "Strict" mode would return null. Let's be lenient but log?
|
||||||
|
packCards.push(...drawC.selected);
|
||||||
|
pools.commons = drawC.remainingPool; // Update ref
|
||||||
|
drawC.selected.forEach(c => namesInPack.add(c.name));
|
||||||
|
|
||||||
|
// 2. Slot 7 (Common or List)
|
||||||
|
// Quick implementation of logic from memo
|
||||||
|
let slot7: DraftCard | undefined;
|
||||||
|
const roll7 = Math.random() * 100;
|
||||||
|
if (roll7 < 87) {
|
||||||
|
// Common
|
||||||
|
const r = this.drawUniqueCards(pools.commons, 1, namesInPack);
|
||||||
|
if (r.selected.length) { slot7 = r.selected[0]; pools.commons = r.remainingPool; }
|
||||||
|
} else {
|
||||||
|
// Uncommon/List (Simplification: pick uncommon)
|
||||||
|
const r = this.drawUniqueCards(pools.uncommons, 1, namesInPack);
|
||||||
|
if (r.selected.length) { slot7 = r.selected[0]; pools.uncommons = r.remainingPool; }
|
||||||
|
else {
|
||||||
|
// Fallback to common
|
||||||
|
const rc = this.drawUniqueCards(pools.commons, 1, namesInPack);
|
||||||
|
if (rc.selected.length) { slot7 = rc.selected[0]; pools.commons = rc.remainingPool; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slot7) { packCards.push(slot7); namesInPack.add(slot7.name); }
|
||||||
|
|
||||||
|
// 3. Uncommons (3 or 4 dependent on PEASANT vs STANDARD)
|
||||||
|
// Memo says: PEASANT slots 8-11 (4 uncommons). STANDARD slots 8-10 (3 uncommons).
|
||||||
|
const uNeeded = rarityMode === 'peasant' ? 4 : 3;
|
||||||
|
const drawU = this.drawUniqueCards(pools.uncommons, uNeeded, namesInPack);
|
||||||
|
packCards.push(...drawU.selected);
|
||||||
|
pools.uncommons = drawU.remainingPool;
|
||||||
|
drawU.selected.forEach(c => namesInPack.add(c.name));
|
||||||
|
|
||||||
|
// 4. Rare/Mythic (Standard Only)
|
||||||
|
if (rarityMode === 'standard') {
|
||||||
|
const isMythic = Math.random() < 0.125;
|
||||||
|
let pickedR = false;
|
||||||
|
|
||||||
|
if (isMythic && pools.mythics.length > 0) {
|
||||||
|
const r = this.drawUniqueCards(pools.mythics, 1, namesInPack);
|
||||||
|
if (r.selected.length) {
|
||||||
|
packCards.push(r.selected[0]);
|
||||||
|
pools.mythics = r.remainingPool;
|
||||||
|
namesInPack.add(r.selected[0].name);
|
||||||
|
pickedR = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pickedR && pools.rares.length > 0) {
|
||||||
|
const r = this.drawUniqueCards(pools.rares, 1, namesInPack);
|
||||||
|
if (r.selected.length) {
|
||||||
|
packCards.push(r.selected[0]);
|
||||||
|
pools.rares = r.remainingPool;
|
||||||
|
namesInPack.add(r.selected[0].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Land
|
||||||
|
const isFoilLand = Math.random() < 0.2;
|
||||||
|
if (pools.lands.length > 0) {
|
||||||
|
const r = this.drawUniqueCards(pools.lands, 1, namesInPack);
|
||||||
|
if (r.selected.length) {
|
||||||
|
const l = { ...r.selected[0] };
|
||||||
|
if (isFoilLand) l.finish = 'foil';
|
||||||
|
packCards.push(l);
|
||||||
|
pools.lands = r.remainingPool;
|
||||||
|
namesInPack.add(l.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Wildcards (2 slots) + Foil Wildcard
|
||||||
|
|
||||||
|
// Re-implement Wildcard simply:
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const isFoil = i === 1; // 2nd is foil
|
||||||
|
const wRoll = Math.random() * 100;
|
||||||
|
let targetPool = pools.commons;
|
||||||
|
let targetKey: keyof ProcessedPools = 'commons';
|
||||||
|
|
||||||
|
if (wRoll > 87) { targetPool = pools.mythics; targetKey = 'mythics'; }
|
||||||
|
else if (wRoll > 74) { targetPool = pools.rares; targetKey = 'rares'; }
|
||||||
|
else if (wRoll > 50) { targetPool = pools.uncommons; targetKey = 'uncommons'; }
|
||||||
|
|
||||||
|
if (targetPool.length === 0) {
|
||||||
|
targetPool = pools.commons;
|
||||||
|
targetKey = 'commons';
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = this.drawUniqueCards(targetPool, 1, namesInPack);
|
||||||
|
if (res.selected.length) {
|
||||||
|
const c = { ...res.selected[0] };
|
||||||
|
if (isFoil) c.finish = 'foil';
|
||||||
|
packCards.push(c);
|
||||||
|
namesInPack.add(c.name);
|
||||||
|
// Updating the pool
|
||||||
|
// @ts-ignore
|
||||||
|
pools[targetKey] = res.remainingPool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Token (Slot 15)
|
||||||
|
if (pools.tokens.length > 0) {
|
||||||
|
const r = this.drawUniqueCards(pools.tokens, 1, namesInPack);
|
||||||
|
if (r.selected.length) {
|
||||||
|
packCards.push(r.selected[0]);
|
||||||
|
pools.tokens = r.remainingPool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
const getWeight = (c: DraftCard) => {
|
||||||
|
if (c.layout === 'token') return 0;
|
||||||
|
if (c.typeLine?.includes('Land')) return 1;
|
||||||
|
if (c.rarity === 'common') return 2;
|
||||||
|
if (c.rarity === 'uncommon') return 3;
|
||||||
|
if (c.rarity === 'rare') return 4;
|
||||||
|
if (c.rarity === 'mythic') return 5;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
packCards.sort((a, b) => getWeight(b) - getWeight(a));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: packId,
|
||||||
|
setName: setName,
|
||||||
|
cards: packCards
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPTIMIZED DRAW (Index based)
|
||||||
|
private drawUniqueCards(pool: DraftCard[], count: number, existingNames: Set<string>) {
|
||||||
|
const selected: DraftCard[] = [];
|
||||||
|
const skipped: DraftCard[] = [];
|
||||||
|
let poolIndex = 0;
|
||||||
|
|
||||||
|
// Use simple iteration
|
||||||
|
while (selected.length < count && poolIndex < pool.length) {
|
||||||
|
const card = pool[poolIndex];
|
||||||
|
poolIndex++;
|
||||||
|
|
||||||
|
if (!existingNames.has(card.name)) {
|
||||||
|
selected.push(card);
|
||||||
|
existingNames.add(card.name);
|
||||||
|
} else {
|
||||||
|
skipped.push(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining = Rest of pool + Skipped
|
||||||
|
const remaining = pool.slice(poolIndex).concat(skipped);
|
||||||
|
|
||||||
|
return { selected, remainingPool: remaining, success: selected.length === count };
|
||||||
|
}
|
||||||
|
|
||||||
|
private shuffle(array: any[]) {
|
||||||
|
let currentIndex = array.length, randomIndex;
|
||||||
|
while (currentIndex !== 0) {
|
||||||
|
randomIndex = Math.floor(Math.random() * currentIndex);
|
||||||
|
currentIndex--;
|
||||||
|
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
}
|
||||||
344
src/server/services/ScryfallService.ts
Normal file
344
src/server/services/ScryfallService.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const CARDS_DIR = path.join(__dirname, '../public/cards');
|
||||||
|
const METADATA_DIR = path.join(CARDS_DIR, 'metadata');
|
||||||
|
const SETS_DIR = path.join(CARDS_DIR, 'sets');
|
||||||
|
|
||||||
|
// Ensure dirs exist
|
||||||
|
if (!fs.existsSync(METADATA_DIR)) {
|
||||||
|
fs.mkdirSync(METADATA_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure sets dir exists
|
||||||
|
if (!fs.existsSync(SETS_DIR)) {
|
||||||
|
fs.mkdirSync(SETS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScryfallCard {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rarity: string;
|
||||||
|
set: string;
|
||||||
|
set_name: string;
|
||||||
|
layout: string;
|
||||||
|
type_line: string;
|
||||||
|
colors?: string[];
|
||||||
|
image_uris?: { normal: string; small?: string; large?: string; png?: string; art_crop?: string; border_crop?: string };
|
||||||
|
card_faces?: {
|
||||||
|
name: string;
|
||||||
|
image_uris?: { normal: string; };
|
||||||
|
type_line?: string;
|
||||||
|
mana_cost?: string;
|
||||||
|
oracle_text?: string;
|
||||||
|
}[];
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScryfallSet {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
set_type: string;
|
||||||
|
released_at: string;
|
||||||
|
digital: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScryfallService {
|
||||||
|
private cacheById = new Map<string, ScryfallCard>();
|
||||||
|
// Map ID to Set Code to locate the file efficiently
|
||||||
|
private idToSet = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.hydrateCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hydrateCache() {
|
||||||
|
console.time('ScryfallService:hydrateCache');
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(METADATA_DIR)) {
|
||||||
|
fs.mkdirSync(METADATA_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(METADATA_DIR, { withFileTypes: true });
|
||||||
|
|
||||||
|
// We will perform a migration if we find flat files
|
||||||
|
// and index existing folders
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
// This is a set folder
|
||||||
|
const setCode = entry.name;
|
||||||
|
const setDir = path.join(METADATA_DIR, setCode);
|
||||||
|
try {
|
||||||
|
const cardFiles = fs.readdirSync(setDir);
|
||||||
|
for (const file of cardFiles) {
|
||||||
|
if (file.endsWith('.json')) {
|
||||||
|
const id = file.replace('.json', '');
|
||||||
|
this.idToSet.set(id, setCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[ScryfallService] Error reading set dir ${setCode}`, err);
|
||||||
|
}
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
||||||
|
// Legacy flat file - needs migration
|
||||||
|
// We read it to find the set, then move it
|
||||||
|
const oldPath = path.join(METADATA_DIR, entry.name);
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(oldPath, 'utf-8');
|
||||||
|
const card = JSON.parse(content) as ScryfallCard;
|
||||||
|
|
||||||
|
if (card.set && card.id) {
|
||||||
|
const setCode = card.set;
|
||||||
|
const newDir = path.join(METADATA_DIR, setCode);
|
||||||
|
if (!fs.existsSync(newDir)) {
|
||||||
|
fs.mkdirSync(newDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const newPath = path.join(newDir, `${card.id}.json`);
|
||||||
|
fs.renameSync(oldPath, newPath);
|
||||||
|
|
||||||
|
// Update Index
|
||||||
|
this.idToSet.set(card.id, setCode);
|
||||||
|
// Also update memory cache if we want, but let's keep it light
|
||||||
|
} else {
|
||||||
|
console.warn(`[ScryfallService] Skipping migration for invalid card file: ${entry.name}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[ScryfallService] Failed to migrate ${entry.name}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ScryfallService] Cache hydration complete. Indexed ${this.idToSet.size} cards.`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to hydrate cache", e);
|
||||||
|
}
|
||||||
|
console.timeEnd('ScryfallService:hydrateCache');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCachedCard(id: string): ScryfallCard | null {
|
||||||
|
if (this.cacheById.has(id)) return this.cacheById.get(id)!;
|
||||||
|
|
||||||
|
// Check Index to find Set
|
||||||
|
let setCode = this.idToSet.get(id);
|
||||||
|
|
||||||
|
// If we have an index hit, look there
|
||||||
|
if (setCode) {
|
||||||
|
const p = path.join(METADATA_DIR, setCode, `${id}.json`);
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(p, 'utf-8');
|
||||||
|
const card = JSON.parse(raw);
|
||||||
|
this.cacheById.set(id, card);
|
||||||
|
return card;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error reading cached card ${id}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: Check flat dir just in case hydration missed it or new file added differently
|
||||||
|
const flatPath = path.join(METADATA_DIR, `${id}.json`);
|
||||||
|
if (fs.existsSync(flatPath)) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(flatPath, 'utf-8');
|
||||||
|
const card = JSON.parse(raw);
|
||||||
|
|
||||||
|
// Auto-migrate on read?
|
||||||
|
if (card.set) {
|
||||||
|
this.saveCard(card); // This effectively migrates it by saving to new structure
|
||||||
|
try { fs.unlinkSync(flatPath); } catch { } // Cleanup old file
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cacheById.set(id, card);
|
||||||
|
return card;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error reading flat cached card ${id}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One last check: try to find it in ANY subdir if index missing?
|
||||||
|
// No, that is too slow. hydration should have caught it.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveCard(card: ScryfallCard) {
|
||||||
|
if (!card.id || !card.set) return;
|
||||||
|
|
||||||
|
this.cacheById.set(card.id, card);
|
||||||
|
this.idToSet.set(card.id, card.set);
|
||||||
|
|
||||||
|
const setDir = path.join(METADATA_DIR, card.set);
|
||||||
|
if (!fs.existsSync(setDir)) {
|
||||||
|
fs.mkdirSync(setDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = path.join(setDir, `${card.id}.json`);
|
||||||
|
|
||||||
|
// Async write
|
||||||
|
fs.writeFile(p, JSON.stringify(card, null, 2), (err) => {
|
||||||
|
if (err) console.error(`Error saving metadata for ${card.id}`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchSets(): Promise<ScryfallSet[]> {
|
||||||
|
console.log('[ScryfallService] Fetching sets...');
|
||||||
|
try {
|
||||||
|
const resp = await fetch('https://api.scryfall.com/sets');
|
||||||
|
if (!resp.ok) throw new Error(`Scryfall API error: ${resp.statusText}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
const sets = data.data
|
||||||
|
.filter((s: any) => ['core', 'expansion', 'masters', 'draft_innovation', 'commander', 'funny'].includes(s.set_type))
|
||||||
|
.map((s: any) => ({
|
||||||
|
code: s.code,
|
||||||
|
name: s.name,
|
||||||
|
set_type: s.set_type,
|
||||||
|
released_at: s.released_at,
|
||||||
|
digital: s.digital
|
||||||
|
}));
|
||||||
|
|
||||||
|
return sets;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ScryfallService] fetchSets failed', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchSetCards(setCode: string): Promise<ScryfallCard[]> {
|
||||||
|
const setHash = setCode.toLowerCase();
|
||||||
|
const setCachePath = path.join(SETS_DIR, `${setHash}.json`);
|
||||||
|
|
||||||
|
// Check Local Set Cache
|
||||||
|
if (fs.existsSync(setCachePath)) {
|
||||||
|
console.log(`[ScryfallService] Loading set ${setCode} from local cache...`);
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(setCachePath, 'utf-8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
console.log(`[ScryfallService] Loaded ${data.length} cards from cache for ${setCode}.`);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[ScryfallService] Corrupt set cache for ${setCode}, refetching...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ScryfallService] Fetching cards for set ${setCode} from API...`);
|
||||||
|
let allCards: ScryfallCard[] = [];
|
||||||
|
let url = `https://api.scryfall.com/cards/search?q=set:${setCode}&unique=cards`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (url) {
|
||||||
|
console.log(`[ScryfallService] Requesting: ${url}`);
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) {
|
||||||
|
if (r.status === 404) {
|
||||||
|
console.warn(`[ScryfallService] 404 Not Found for URL: ${url}. Assuming set has no cards.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errBody = await r.text();
|
||||||
|
console.error(`[ScryfallService] Error fetching ${url}: ${r.status} ${r.statusText}`, errBody);
|
||||||
|
throw new Error(`Failed to fetch set: ${r.statusText} (${r.status}) - ${errBody}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = await r.json();
|
||||||
|
|
||||||
|
if (d.data) {
|
||||||
|
allCards.push(...d.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.has_more && d.next_page) {
|
||||||
|
url = d.next_page;
|
||||||
|
await new Promise(res => setTimeout(res, 100)); // Respect rate limits
|
||||||
|
} else {
|
||||||
|
url = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save Set Cache
|
||||||
|
if (allCards.length > 0) {
|
||||||
|
fs.writeFileSync(setCachePath, JSON.stringify(allCards, null, 2));
|
||||||
|
|
||||||
|
// Smartly save individuals: only if missing from cache
|
||||||
|
let newCount = 0;
|
||||||
|
allCards.forEach(c => {
|
||||||
|
if (!this.getCachedCard(c.id)) {
|
||||||
|
this.saveCard(c);
|
||||||
|
newCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`[ScryfallService] Saved set ${setCode}. New individual cards cached: ${newCount}/${allCards.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allCards;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error fetching set", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchCollection(identifiers: { id?: string, name?: string }[]): Promise<ScryfallCard[]> {
|
||||||
|
const results: ScryfallCard[] = [];
|
||||||
|
const missing: { id?: string, name?: string }[] = [];
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
for (const id of identifiers) {
|
||||||
|
if (id.id) {
|
||||||
|
const c = this.getCachedCard(id.id);
|
||||||
|
if (c) {
|
||||||
|
results.push(c);
|
||||||
|
} else {
|
||||||
|
missing.push(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Warning: Name lookup relies on API because we don't index names locally yet
|
||||||
|
missing.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length === 0) return results;
|
||||||
|
|
||||||
|
console.log(`[ScryfallService] Locally cached: ${results.length}. Fetching ${missing.length} missing cards from API...`);
|
||||||
|
|
||||||
|
// Chunk requests
|
||||||
|
const CHUNK_SIZE = 75;
|
||||||
|
for (let i = 0; i < missing.length; i += CHUNK_SIZE) {
|
||||||
|
const chunk = missing.slice(i, i + CHUNK_SIZE);
|
||||||
|
try {
|
||||||
|
const resp = await fetch('https://api.scryfall.com/cards/collection', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ identifiers: chunk })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.error(`[ScryfallService] Collection fetch failed: ${resp.status}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = await resp.json();
|
||||||
|
|
||||||
|
if (d.data) {
|
||||||
|
d.data.forEach((c: ScryfallCard) => {
|
||||||
|
this.saveCard(c);
|
||||||
|
results.push(c);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.not_found && d.not_found.length > 0) {
|
||||||
|
console.warn(`[ScryfallService] Cards not found:`, d.not_found);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error fetching collection chunk", e);
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 75)); // Rate limiting
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user