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

This commit is contained in:
2025-12-17 00:09:21 +01:00
parent 2efb66cfc4
commit 0ac657847e
7 changed files with 1164 additions and 102 deletions

View File

@@ -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 => {
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()));
});
}, [scryfallService]);
})
.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;
setLoading(true);
// 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);
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 {
newPacks = generatorService.generatePacks(processedData.pools, processedData.sets, genSettings);
return;
}
}
setLoading(true);
setProgress('Generating packs on server...');
try {
const payload = {
cards: sourceMode === 'upload' ? rawScryfallData : [],
sourceMode,
selectedSets,
settings: genSettings,
numBoxes,
numPacks: sourceMode === 'set' ? (numBoxes * 36) : undefined,
filters
};
// Use fetch from server logic
if (sourceMode === 'set') {
payload.cards = [];
}
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(`Not enough cards to generate valid packs.`);
alert(`No packs generated. Check your card pool settings.`);
} else {
setPacks(newPacks);
}
} catch (e) {
} catch (e: any) {
console.error("Generation failed", e);
alert("Error generating packs: " + e);
alert("Error generating packs: " + e.message);
} finally {
setLoading(false);
setProgress('');
}
}, 50);
};
const handleExportCsv = () => {

View File

@@ -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,

View File

@@ -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();

View 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;
}
}

View File

@@ -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);

View 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;
}
}

View 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;
}
}