diff --git a/src/client/src/modules/cube/CubeManager.tsx b/src/client/src/modules/cube/CubeManager.tsx index 69f2a19..3d7ff93 100644 --- a/src/client/src/modules/cube/CubeManager.tsx +++ b/src/client/src/modules/cube/CubeManager.tsx @@ -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 = ({ 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 = ({ 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) => { @@ -146,87 +146,40 @@ export const CubeManager: React.FC = ({ 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 = ({ 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 = () => { diff --git a/src/client/src/services/PackGeneratorService.ts b/src/client/src/services/PackGeneratorService.ts index 52ec8a1..3c4e820 100644 --- a/src/client/src/services/PackGeneratorService.ts +++ b/src/client/src/services/PackGeneratorService.ts @@ -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, diff --git a/src/server/index.ts b/src/server/index.ts index 9462687..1bab0d8 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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(); diff --git a/src/server/services/CardParserService.ts b/src/server/services/CardParserService.ts new file mode 100644 index 0000000..0e5dcef --- /dev/null +++ b/src/server/services/CardParserService.ts @@ -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; + } +} diff --git a/src/server/services/CardService.ts b/src/server/services/CardService.ts index 1120a86..2cc2b87 100644 --- a/src/server/services/CardService.ts +++ b/src/server/services/CardService.ts @@ -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 { 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 { 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); diff --git a/src/server/services/PackGeneratorService.ts b/src/server/services/PackGeneratorService.ts new file mode 100644 index 0000000..e101222 --- /dev/null +++ b/src/server/services/PackGeneratorService.ts @@ -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(); + + // 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) { + 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; + } +} diff --git a/src/server/services/ScryfallService.ts b/src/server/services/ScryfallService.ts new file mode 100644 index 0000000..a08afa9 --- /dev/null +++ b/src/server/services/ScryfallService.ts @@ -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(); + // Map ID to Set Code to locate the file efficiently + private idToSet = new Map(); + + 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 { + 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 { + 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 { + 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; + } +}