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 { 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 { ScryfallService, ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
|
||||
import { ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
|
||||
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
|
||||
import { PackCard } from '../../components/PackCard';
|
||||
|
||||
@@ -15,8 +14,6 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
// --- Services ---
|
||||
// --- Services ---
|
||||
// Memoize services to persist cache across renders
|
||||
const parserService = React.useMemo(() => new CardParserService(), []);
|
||||
const scryfallService = React.useMemo(() => new ScryfallService(), []);
|
||||
const generatorService = React.useMemo(() => new PackGeneratorService(), []);
|
||||
|
||||
// --- State ---
|
||||
@@ -124,10 +121,13 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
}, [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]);
|
||||
fetch('/api/sets')
|
||||
.then(res => res.json())
|
||||
.then((sets: ScryfallSet[]) => {
|
||||
setAvailableSets(sets.sort((a, b) => new Date(b.released_at).getTime() - new Date(a.released_at).getTime()));
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// --- Handlers ---
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -146,87 +146,40 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
setPacks([]);
|
||||
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 {
|
||||
let expandedCards: ScryfallCard[] = [];
|
||||
|
||||
if (sourceMode === '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()) {
|
||||
// Update progress for set
|
||||
// const setInfo = availableSets.find(s => s.code === setCode);
|
||||
setProgress(`Fetching set ${setCode.toUpperCase()} (${index + 1}/${selectedSets.length})...`);
|
||||
|
||||
setProgress(`Loading sets... (${index + 1}/${selectedSets.length})`);
|
||||
|
||||
const cards = await scryfallService.fetchSetCards(setCode, (_count) => {
|
||||
// Progress handled by outer loop mostly
|
||||
});
|
||||
|
||||
// Incrementally cache this set to server
|
||||
await cacheCardsToServer(cards);
|
||||
const response = await fetch(`/api/sets/${setCode}/cards`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch set ${setCode}`);
|
||||
|
||||
const cards: ScryfallCard[] = await response.json();
|
||||
expandedCards.push(...cards);
|
||||
}
|
||||
} else {
|
||||
const identifiers = parserService.parse(inputText);
|
||||
const fetchList = identifiers.map(id => id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||
|
||||
// Identify how many are already cached for feedback
|
||||
let cachedCount = 0;
|
||||
fetchList.forEach(req => {
|
||||
if (scryfallService.getCachedCard(req)) cachedCount++;
|
||||
// Parse Text
|
||||
setProgress('Parsing and fetching from server...');
|
||||
const response = await fetch('/api/cards/parse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: inputText })
|
||||
});
|
||||
|
||||
await scryfallService.fetchCollection(fetchList, (current, total) => {
|
||||
setProgress(`Fetching Scryfall data... (${current}/${total})`);
|
||||
});
|
||||
|
||||
// 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.`);
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error || "Failed to parse cards");
|
||||
}
|
||||
|
||||
// Cache custom list to server
|
||||
if (expandedCards.length > 0) {
|
||||
setProgress('Caching to server...');
|
||||
await cacheCardsToServer(expandedCards);
|
||||
}
|
||||
expandedCards = await response.json();
|
||||
}
|
||||
|
||||
setRawScryfallData(expandedCards);
|
||||
|
||||
setLoading(false);
|
||||
setProgress('');
|
||||
|
||||
@@ -237,34 +190,60 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
}
|
||||
};
|
||||
|
||||
const generatePacks = () => {
|
||||
if (!processedData) return;
|
||||
const generatePacks = async () => {
|
||||
// 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);
|
||||
setProgress('Generating packs on server...');
|
||||
|
||||
// Use setTimeout to allow UI to show loading spinner before sync calculation blocks
|
||||
setTimeout(() => {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
try {
|
||||
const payload = {
|
||||
cards: sourceMode === 'upload' ? rawScryfallData : [],
|
||||
sourceMode,
|
||||
selectedSets,
|
||||
settings: genSettings,
|
||||
numBoxes,
|
||||
numPacks: sourceMode === 'set' ? (numBoxes * 36) : undefined,
|
||||
filters
|
||||
};
|
||||
|
||||
if (newPacks.length === 0) {
|
||||
alert(`Not enough cards to generate valid packs.`);
|
||||
} else {
|
||||
setPacks(newPacks);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Generation failed", e);
|
||||
alert("Error generating packs: " + e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// Use fetch from server logic
|
||||
if (sourceMode === 'set') {
|
||||
payload.cards = [];
|
||||
}
|
||||
}, 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 = () => {
|
||||
|
||||
@@ -105,7 +105,7 @@ export class PackGeneratorService {
|
||||
layout: layout,
|
||||
colors: cardData.colors || [],
|
||||
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 || ''),
|
||||
set: cardData.set_name,
|
||||
setCode: cardData.set,
|
||||
|
||||
@@ -7,6 +7,9 @@ import { RoomManager } from './managers/RoomManager';
|
||||
import { GameManager } from './managers/GameManager';
|
||||
import { DraftManager } from './managers/DraftManager';
|
||||
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 __dirname = path.dirname(__filename);
|
||||
@@ -24,6 +27,9 @@ const roomManager = new RoomManager();
|
||||
const gameManager = new GameManager();
|
||||
const draftManager = new DraftManager();
|
||||
const cardService = new CardService();
|
||||
const scryfallService = new ScryfallService();
|
||||
const packGeneratorService = new PackGeneratorService();
|
||||
const cardParserService = new CardParserService();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
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
|
||||
setInterval(() => {
|
||||
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.metadataDir = path.join(CARDS_DIR, 'metadata');
|
||||
|
||||
this.ensureDirs();
|
||||
this.migrateExistingImages();
|
||||
}
|
||||
|
||||
private ensureDirs() {
|
||||
if (!fs.existsSync(this.imagesDir)) {
|
||||
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> {
|
||||
let downloadedCount = 0;
|
||||
|
||||
@@ -35,9 +88,11 @@ export class CardService {
|
||||
const card = queue.shift();
|
||||
if (!card) break;
|
||||
|
||||
// Determine UUID and URL
|
||||
const uuid = card.id || card.oracle_id; // Prefer ID
|
||||
if (!uuid) continue;
|
||||
// Determine UUID
|
||||
const uuid = card.id || card.oracle_id;
|
||||
const setCode = card.set;
|
||||
|
||||
if (!uuid || !setCode) continue;
|
||||
|
||||
// Check for normal image
|
||||
let imageUrl = card.image_uris?.normal;
|
||||
@@ -47,13 +102,30 @@ export class CardService {
|
||||
|
||||
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)) {
|
||||
// Already cached
|
||||
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 {
|
||||
// Download
|
||||
const response = await fetch(imageUrl);
|
||||
@@ -61,7 +133,7 @@ export class CardService {
|
||||
const buffer = await response.arrayBuffer();
|
||||
fs.writeFileSync(filePath, Buffer.from(buffer));
|
||||
downloadedCount++;
|
||||
console.log(`Cached image: ${uuid}.jpg`);
|
||||
console.log(`Cached image: ${setCode}/${uuid}.jpg`);
|
||||
} else {
|
||||
console.error(`Failed to download ${imageUrl}: ${response.statusText}`);
|
||||
}
|
||||
@@ -80,11 +152,21 @@ export class CardService {
|
||||
async cacheMetadata(cards: any[]): Promise<number> {
|
||||
let cachedCount = 0;
|
||||
for (const card of cards) {
|
||||
if (!card.id) continue;
|
||||
const filePath = path.join(this.metadataDir, `${card.id}.json`);
|
||||
if (!card.id || !card.set) continue;
|
||||
|
||||
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)) {
|
||||
try {
|
||||
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++;
|
||||
} catch (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