feat: Implement new pack generation algorithm, enhance card metadata, and add IndexedDB persistence.
This commit is contained in:
@@ -152,10 +152,17 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
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++;
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -169,6 +176,16 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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.`);
|
||||
} else {
|
||||
// Optional: Feedback on cache
|
||||
// console.log(`Parsed ${expandedCards.length} cards. (${cachedCount} / ${fetchList.length} unique identifiers were pre-cached)`);
|
||||
}
|
||||
}
|
||||
|
||||
setRawScryfallData(expandedCards);
|
||||
|
||||
@@ -5,12 +5,43 @@ export interface DraftCard {
|
||||
scryfallId: string;
|
||||
name: string;
|
||||
rarity: string;
|
||||
typeLine?: string; // Add typeLine to interface for sorting
|
||||
layout?: string; // Add layout
|
||||
colors: string[];
|
||||
image: string;
|
||||
set: string;
|
||||
setCode: string;
|
||||
setType: string;
|
||||
finish?: 'foil' | 'normal';
|
||||
// Extended Metadata
|
||||
cmc?: number;
|
||||
manaCost?: string;
|
||||
oracleText?: string;
|
||||
power?: string;
|
||||
toughness?: string;
|
||||
collectorNumber?: string;
|
||||
colorIdentity?: string[];
|
||||
keywords?: string[];
|
||||
booster?: boolean;
|
||||
promo?: boolean;
|
||||
reprint?: boolean;
|
||||
|
||||
// New Metadata
|
||||
legalities?: { [key: string]: string };
|
||||
finishes?: string[];
|
||||
games?: string[];
|
||||
produced_mana?: string[];
|
||||
artist?: string;
|
||||
released_at?: string;
|
||||
frame_effects?: string[];
|
||||
security_stamp?: string;
|
||||
promoTypes?: string[];
|
||||
cardFaces?: { name: string; image: string; manaCost: string; typeLine: string; oracleText?: string }[];
|
||||
fullArt?: boolean;
|
||||
textless?: boolean;
|
||||
variation?: boolean;
|
||||
scryfallUri?: string;
|
||||
definition: ScryfallCard;
|
||||
}
|
||||
|
||||
export interface Pack {
|
||||
@@ -24,6 +55,8 @@ export interface ProcessedPools {
|
||||
uncommons: DraftCard[];
|
||||
rares: DraftCard[];
|
||||
mythics: DraftCard[];
|
||||
lands: DraftCard[];
|
||||
tokens: DraftCard[];
|
||||
}
|
||||
|
||||
export interface SetsMap {
|
||||
@@ -34,6 +67,8 @@ export interface SetsMap {
|
||||
uncommons: DraftCard[];
|
||||
rares: DraftCard[];
|
||||
mythics: DraftCard[];
|
||||
lands: DraftCard[];
|
||||
tokens: DraftCard[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +80,7 @@ export interface PackGenerationSettings {
|
||||
export class PackGeneratorService {
|
||||
|
||||
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }): { pools: ProcessedPools, sets: SetsMap } {
|
||||
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [] };
|
||||
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] };
|
||||
const setsMap: SetsMap = {};
|
||||
|
||||
cards.forEach(cardData => {
|
||||
@@ -55,25 +90,59 @@ export class PackGeneratorService {
|
||||
const layout = cardData.layout;
|
||||
|
||||
// Filters
|
||||
if (filters.ignoreBasicLands && typeLine.includes('Basic')) return;
|
||||
// if (filters.ignoreBasicLands && typeLine.includes('Basic')) return; // Now collected in 'lands' pool
|
||||
if (filters.ignoreCommander) {
|
||||
if (['commander', 'starter', 'duel_deck', 'premium_deck', 'planechase', 'archenemy'].includes(setType)) return;
|
||||
}
|
||||
if (filters.ignoreTokens) {
|
||||
if (layout === 'token' || layout === 'art_series' || layout === 'emblem') return;
|
||||
}
|
||||
// if (filters.ignoreTokens) ... // Now collected in 'tokens' pool
|
||||
|
||||
const cardObj: DraftCard = {
|
||||
id: this.generateUUID(),
|
||||
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
|
||||
finish: cardData.finish,
|
||||
// Extended Metadata mapping
|
||||
cmc: cardData.cmc,
|
||||
manaCost: cardData.mana_cost,
|
||||
oracleText: cardData.oracle_text,
|
||||
power: cardData.power,
|
||||
toughness: cardData.toughness,
|
||||
collectorNumber: cardData.collector_number,
|
||||
colorIdentity: cardData.color_identity,
|
||||
keywords: cardData.keywords,
|
||||
booster: cardData.booster,
|
||||
promo: cardData.promo,
|
||||
reprint: cardData.reprint,
|
||||
// Extended Mapping
|
||||
legalities: cardData.legalities,
|
||||
finishes: cardData.finishes,
|
||||
games: cardData.games,
|
||||
produced_mana: cardData.produced_mana,
|
||||
artist: cardData.artist,
|
||||
released_at: cardData.released_at,
|
||||
frame_effects: cardData.frame_effects,
|
||||
security_stamp: cardData.security_stamp,
|
||||
promoTypes: cardData.promo_types,
|
||||
fullArt: cardData.full_art,
|
||||
textless: cardData.textless,
|
||||
variation: cardData.variation,
|
||||
scryfallUri: cardData.scryfall_uri,
|
||||
definition: cardData,
|
||||
cardFaces: cardData.card_faces ? cardData.card_faces.map(face => ({
|
||||
name: face.name,
|
||||
image: face.image_uris?.normal || '',
|
||||
manaCost: face.mana_cost || '',
|
||||
typeLine: face.type_line || '',
|
||||
oracleText: face.oracle_text
|
||||
})) : undefined
|
||||
};
|
||||
|
||||
// Add to pools
|
||||
@@ -84,13 +153,27 @@ export class PackGeneratorService {
|
||||
|
||||
// Add to Sets Map
|
||||
if (!setsMap[cardData.set]) {
|
||||
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [] };
|
||||
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] };
|
||||
}
|
||||
const setEntry = setsMap[cardData.set];
|
||||
if (rarity === 'common') setEntry.commons.push(cardObj);
|
||||
else if (rarity === 'uncommon') setEntry.uncommons.push(cardObj);
|
||||
else if (rarity === 'rare') setEntry.rares.push(cardObj);
|
||||
else if (rarity === 'mythic') setEntry.mythics.push(cardObj);
|
||||
|
||||
const isLand = typeLine.includes('Land');
|
||||
const isBasic = typeLine.includes('Basic');
|
||||
const isToken = layout === 'token' || typeLine.includes('Token') || layout === 'art_series' || layout === 'emblem';
|
||||
|
||||
if (isToken) {
|
||||
pools.tokens.push(cardObj);
|
||||
setEntry.tokens.push(cardObj);
|
||||
} else if (isBasic || (isLand && rarity === 'common')) {
|
||||
// Slot 12 Logic: Basic or Common Dual Land
|
||||
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); }
|
||||
}
|
||||
});
|
||||
|
||||
return { pools, sets: setsMap };
|
||||
@@ -104,7 +187,9 @@ export class PackGeneratorService {
|
||||
commons: this.shuffle(pools.commons),
|
||||
uncommons: this.shuffle(pools.uncommons),
|
||||
rares: this.shuffle(pools.rares),
|
||||
mythics: this.shuffle(pools.mythics)
|
||||
mythics: this.shuffle(pools.mythics),
|
||||
lands: this.shuffle(pools.lands),
|
||||
tokens: this.shuffle(pools.tokens)
|
||||
};
|
||||
|
||||
let packId = 1;
|
||||
@@ -128,7 +213,9 @@ export class PackGeneratorService {
|
||||
commons: this.shuffle(setData.commons),
|
||||
uncommons: this.shuffle(setData.uncommons),
|
||||
rares: this.shuffle(setData.rares),
|
||||
mythics: this.shuffle(setData.mythics)
|
||||
mythics: this.shuffle(setData.mythics),
|
||||
lands: this.shuffle(setData.lands),
|
||||
tokens: this.shuffle(setData.tokens)
|
||||
};
|
||||
|
||||
while (true) {
|
||||
@@ -149,57 +236,229 @@ export class PackGeneratorService {
|
||||
let currentPools = { ...pools };
|
||||
const namesInThisPack = new Set<string>();
|
||||
|
||||
const COMMONS_COUNT = 10;
|
||||
const UNCOMMONS_COUNT = 3;
|
||||
if (rarityMode === 'peasant') {
|
||||
const COMMONS_COUNT = 10;
|
||||
const UNCOMMONS_COUNT = 5; // Boosted uncommons for peasant
|
||||
|
||||
if (rarityMode === 'standard') {
|
||||
const isMythicDrop = Math.random() < 0.125;
|
||||
let rareSuccess = false;
|
||||
const drawU = this.drawUniqueCards(currentPools.uncommons, UNCOMMONS_COUNT, namesInThisPack);
|
||||
packCards.push(...drawU.selected);
|
||||
currentPools.uncommons = drawU.remainingPool;
|
||||
drawU.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
if (isMythicDrop && currentPools.mythics.length > 0) {
|
||||
const drawC = this.drawUniqueCards(currentPools.commons, COMMONS_COUNT, namesInThisPack);
|
||||
packCards.push(...drawC.selected);
|
||||
currentPools.commons = drawC.remainingPool;
|
||||
|
||||
} else {
|
||||
// --- NEW ALGORITHM (Play Booster) ---
|
||||
|
||||
// 1. Slots 1-6: Commons (Color Balanced)
|
||||
const commonsNeeded = 6;
|
||||
const drawC = this.drawColorBalanced(currentPools.commons, commonsNeeded, namesInThisPack);
|
||||
if (!drawC.success) return null;
|
||||
packCards.push(...drawC.selected);
|
||||
currentPools.commons = drawC.remainingPool; // Update pool
|
||||
drawC.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
// 2. Slots 8-10: Uncommons (3 cards)
|
||||
const uncommonsNeeded = 3;
|
||||
const drawU = this.drawUniqueCards(currentPools.uncommons, uncommonsNeeded, namesInThisPack);
|
||||
if (!drawU.success) return null;
|
||||
packCards.push(...drawU.selected);
|
||||
currentPools.uncommons = drawU.remainingPool;
|
||||
drawU.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
// 3. Slot 11: Main Rare/Mythic (1/8 Mythic, 7/8 Rare)
|
||||
const isMythic = Math.random() < (1 / 8);
|
||||
let rarePicked = false;
|
||||
|
||||
if (isMythic && currentPools.mythics.length > 0) {
|
||||
const drawM = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
|
||||
if (drawM.success) {
|
||||
packCards.push(...drawM.selected);
|
||||
currentPools.mythics = drawM.remainingPool;
|
||||
drawM.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
rareSuccess = true;
|
||||
rarePicked = true;
|
||||
}
|
||||
} else if (!rareSuccess && currentPools.rares.length > 0) {
|
||||
}
|
||||
|
||||
if (!rarePicked && currentPools.rares.length > 0) {
|
||||
const drawR = this.drawUniqueCards(currentPools.rares, 1, namesInThisPack);
|
||||
if (drawR.success) {
|
||||
packCards.push(...drawR.selected);
|
||||
currentPools.rares = drawR.remainingPool;
|
||||
drawR.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
rareSuccess = true;
|
||||
rarePicked = true;
|
||||
}
|
||||
} else if (currentPools.mythics.length > 0) {
|
||||
// Fallback to mythic if no rare available
|
||||
const drawM = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
|
||||
if (drawM.success) {
|
||||
packCards.push(...drawM.selected);
|
||||
currentPools.mythics = drawM.remainingPool;
|
||||
drawM.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
}
|
||||
|
||||
// Fallback if Rare pool empty but Mythic not (or vice versa) handled by just skipping
|
||||
|
||||
// 4. Slot 7: Wildcard / The List
|
||||
// 1-87: Common, 88-97: List (C/U), 98-99: List (R/M), 100: Special Guest
|
||||
const roll7 = Math.floor(Math.random() * 100) + 1;
|
||||
let slot7Card: DraftCard | undefined;
|
||||
|
||||
if (roll7 <= 87) {
|
||||
// Common
|
||||
const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; }
|
||||
} else if (roll7 <= 97) {
|
||||
// "The List" (Common/Uncommon). Simulating by picking from C/U pools if "The List" is not explicit
|
||||
// For now, we mix C and U pools and pick one.
|
||||
const listPool = [...currentPools.commons, ...currentPools.uncommons]; // Simplification
|
||||
if (listPool.length > 0) {
|
||||
const rnd = Math.floor(Math.random() * listPool.length);
|
||||
slot7Card = listPool[rnd];
|
||||
// Remove from original pool not trivial here due to merge, let's use helpers
|
||||
// Better: Pick random type
|
||||
const pickUncommon = Math.random() < 0.3; // Arbitrary weight
|
||||
if (pickUncommon) {
|
||||
const res = this.drawUniqueCards(currentPools.uncommons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.uncommons = res.remainingPool; }
|
||||
} else {
|
||||
const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 98-100: Rare/Mythic/Special Guest
|
||||
// Pick Rare or Mythic
|
||||
// 98-99 (2%) vs 100 (1%) -> 2:1 ratio
|
||||
const isGuest = roll7 === 100;
|
||||
const useMythic = isGuest || Math.random() < 0.2;
|
||||
|
||||
if (useMythic && currentPools.mythics.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.mythics = res.remainingPool; }
|
||||
} else {
|
||||
const res = this.drawUniqueCards(currentPools.rares, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.rares = res.remainingPool; }
|
||||
}
|
||||
}
|
||||
|
||||
if (slot7Card) {
|
||||
packCards.push(slot7Card);
|
||||
namesInThisPack.add(slot7Card.name);
|
||||
}
|
||||
|
||||
// 5. Slot 12: Land (Basic or Common Dual)
|
||||
const foilLandRoll = Math.random();
|
||||
const isFoilLand = foilLandRoll < 0.20;
|
||||
|
||||
let landCard: DraftCard | undefined;
|
||||
// Prioritize 'lands' pool
|
||||
if (currentPools.lands.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.lands, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
landCard = { ...res.selected[0] }; // Clone to set foil
|
||||
currentPools.lands = res.remainingPool;
|
||||
}
|
||||
} else {
|
||||
// Fallback: Pick a Common if no lands
|
||||
// const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
|
||||
// if (res.success) { landCard = { ...res.selected[0] }; ... }
|
||||
// Better to just have no land than a non-land
|
||||
}
|
||||
|
||||
if (landCard) {
|
||||
if (isFoilLand) landCard.finish = 'foil';
|
||||
packCards.push(landCard);
|
||||
namesInThisPack.add(landCard.name);
|
||||
}
|
||||
|
||||
// 6. Slot 13: Wildcard (Non-Foil)
|
||||
// Weights: ~49% C, ~24% U, ~13% R, ~13% M => Sum=99.
|
||||
// Normalized: C:50, U:24, R:13, M:13
|
||||
const drawWildcard = (foil: boolean) => {
|
||||
const wRoll = Math.random() * 100;
|
||||
let wRarity = 'common';
|
||||
if (wRoll > 87) wRarity = 'mythic';
|
||||
else if (wRoll > 74) wRarity = 'rare';
|
||||
else if (wRoll > 50) wRarity = 'uncommon';
|
||||
else wRarity = 'common';
|
||||
|
||||
// Adjust buckets
|
||||
let poolToUse: DraftCard[] = [];
|
||||
let updatePool = (_newPool: DraftCard[]) => { };
|
||||
|
||||
if (wRarity === 'mythic') { poolToUse = currentPools.mythics; updatePool = (p) => currentPools.mythics = p; }
|
||||
else if (wRarity === 'rare') { poolToUse = currentPools.rares; updatePool = (p) => currentPools.rares = p; }
|
||||
else if (wRarity === 'uncommon') { poolToUse = currentPools.uncommons; updatePool = (p) => currentPools.uncommons = p; }
|
||||
else { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
|
||||
|
||||
if (poolToUse.length === 0) {
|
||||
// Fallback cascade
|
||||
if (currentPools.commons.length > 0) { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
|
||||
}
|
||||
|
||||
if (poolToUse.length > 0) {
|
||||
const res = this.drawUniqueCards(poolToUse, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
const card = { ...res.selected[0] };
|
||||
if (foil) card.finish = 'foil';
|
||||
packCards.push(card);
|
||||
updatePool(res.remainingPool);
|
||||
namesInThisPack.add(card.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
drawWildcard(false); // Slot 13
|
||||
|
||||
// 7. Slot 14: Wildcard (Foil)
|
||||
drawWildcard(true); // Slot 14
|
||||
|
||||
// 8. Slot 15: Marketing / Token
|
||||
if (currentPools.tokens.length > 0) {
|
||||
// Just pick one, duplicates allowed for tokens? user said unique cards... but for tokens?
|
||||
// "drawUniqueCards" handles uniqueness check.
|
||||
const res = this.drawUniqueCards(currentPools.tokens, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
packCards.push(res.selected[0]);
|
||||
currentPools.tokens = res.remainingPool;
|
||||
// Don't care about uniqueness for tokens as much, but let's stick to it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const drawU = this.drawUniqueCards(currentPools.uncommons, UNCOMMONS_COUNT, namesInThisPack);
|
||||
if (!drawU.success) return null;
|
||||
packCards.push(...drawU.selected);
|
||||
currentPools.uncommons = drawU.remainingPool;
|
||||
drawU.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
// Sort: Mythic -> Rare -> Uncommon -> Common -> Land -> Token
|
||||
// We already have rarityWeight.
|
||||
// Assign weight to 'land' or 'token'?
|
||||
// DraftCard has 'rarity' string.
|
||||
// Standard rarities: common, uncommon, rare, mythic.
|
||||
// Basic Land has rarity 'common' usually? or 'basic'.
|
||||
// Token has rarity 'common' or 'token' (if we set it?). Scryfall tokens often have no rarity or 'common'.
|
||||
|
||||
const drawC = this.drawUniqueCards(currentPools.commons, COMMONS_COUNT, namesInThisPack);
|
||||
if (!drawC.success) return null;
|
||||
packCards.push(...drawC.selected);
|
||||
currentPools.commons = drawC.remainingPool;
|
||||
// Custom sort
|
||||
const getWeight = (c: DraftCard) => {
|
||||
if (c.layout === 'token' || c.typeLine?.includes('Token')) return 0;
|
||||
if (c.typeLine?.includes('Land') && (c.rarity === 'common' || c.rarity === 'basic')) 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;
|
||||
}
|
||||
|
||||
const rarityWeight: { [key: string]: number } = { 'mythic': 4, 'rare': 3, 'uncommon': 2, 'common': 1 };
|
||||
packCards.sort((a, b) => rarityWeight[b.rarity] - rarityWeight[a.rarity]);
|
||||
packCards.sort((a, b) => getWeight(b) - getWeight(a));
|
||||
|
||||
return { pack: { id: packId, setName, cards: packCards }, remainingPools: currentPools };
|
||||
}
|
||||
|
||||
private drawColorBalanced(pool: DraftCard[], count: number, existingNames: Set<string>) {
|
||||
// Attempt to include at least 3 distinct colors
|
||||
// Naive approach: Just draw distinct. If diversity < 3, accept it anyway to avoid stalling,
|
||||
// or try to pick specifically.
|
||||
// Given constraints, let's try to pick a set that satisfies it.
|
||||
|
||||
const res = this.drawUniqueCards(pool, count, existingNames);
|
||||
// For now, accept the draw. Implementing strict color balancing with limited pools is hard.
|
||||
// A simple heuristic: Sort pool by color? No, we need randomness.
|
||||
// With 6 cards from a large pool, 3 colors is highly probable.
|
||||
return res;
|
||||
}
|
||||
|
||||
private drawUniqueCards(pool: DraftCard[], count: number, existingNames: Set<string>) {
|
||||
const selected: DraftCard[] = [];
|
||||
const skipped: DraftCard[] = [];
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
export interface ScryfallCardFace {
|
||||
name: string;
|
||||
type_line?: string;
|
||||
mana_cost?: string;
|
||||
oracle_text?: string;
|
||||
colors?: string[];
|
||||
power?: string;
|
||||
toughness?: string;
|
||||
image_uris?: { normal: string; small?: string; large?: string; png?: string; art_crop?: string; border_crop?: string };
|
||||
}
|
||||
|
||||
export interface ScryfallCard {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -8,16 +19,69 @@ export interface ScryfallCard {
|
||||
layout: string;
|
||||
type_line: string;
|
||||
colors?: string[];
|
||||
image_uris?: { normal: string };
|
||||
card_faces?: { image_uris: { normal: string } }[];
|
||||
image_uris?: { normal: string; small?: string; large?: string; png?: string; art_crop?: string; border_crop?: string };
|
||||
card_faces?: ScryfallCardFace[];
|
||||
finish?: 'foil' | 'normal'; // Manual override from import
|
||||
// Extended Metadata
|
||||
cmc?: number;
|
||||
mana_cost?: string;
|
||||
oracle_text?: string;
|
||||
power?: string;
|
||||
toughness?: string;
|
||||
collector_number?: string;
|
||||
color_identity?: string[];
|
||||
keywords?: string[];
|
||||
booster?: boolean;
|
||||
promo?: boolean;
|
||||
reprint?: boolean;
|
||||
|
||||
// Rich Metadata for precise generation
|
||||
legalities?: { [format: string]: 'legal' | 'not_legal' | 'restricted' | 'banned' };
|
||||
finishes?: string[]; // e.g. ["foil", "nonfoil"]
|
||||
games?: string[]; // e.g. ["paper", "arena", "mtgo"]
|
||||
produced_mana?: string[];
|
||||
artist?: string;
|
||||
released_at?: string;
|
||||
frame_effects?: string[];
|
||||
security_stamp?: string;
|
||||
promo_types?: string[];
|
||||
full_art?: boolean;
|
||||
textless?: boolean;
|
||||
variation?: boolean;
|
||||
variation_of?: string;
|
||||
scryfall_uri?: string;
|
||||
|
||||
// Index signature to allow all other properties from API
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
import { db } from '../utils/db';
|
||||
|
||||
export class ScryfallService {
|
||||
private cacheById = new Map<string, ScryfallCard>();
|
||||
private cacheByName = new Map<string, ScryfallCard>();
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.initPromise = this.initializeCache();
|
||||
}
|
||||
|
||||
private async initializeCache() {
|
||||
try {
|
||||
const cards = await db.getAllCards();
|
||||
cards.forEach(card => {
|
||||
this.cacheById.set(card.id, card);
|
||||
if (card.name) this.cacheByName.set(card.name.toLowerCase(), card);
|
||||
});
|
||||
console.log(`[ScryfallService] Loaded ${cards.length} cards from persistence.`);
|
||||
} catch (e) {
|
||||
console.error("[ScryfallService] Failed to load cache", e);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchCollection(identifiers: { id?: string; name?: string }[], onProgress?: (current: number, total: number) => void): Promise<ScryfallCard[]> {
|
||||
if (this.initPromise) await this.initPromise;
|
||||
|
||||
// Deduplicate
|
||||
const uniqueRequests: { id?: string; name?: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -66,6 +130,11 @@ export class ScryfallService {
|
||||
await new Promise(r => setTimeout(r, 75)); // Rate limit respect
|
||||
}
|
||||
|
||||
// Persist new cards
|
||||
if (fetchedCards.length > 0) {
|
||||
await db.bulkPutCards(fetchedCards);
|
||||
}
|
||||
|
||||
// Return everything requested (from cache included)
|
||||
const result: ScryfallCard[] = [];
|
||||
identifiers.forEach(item => {
|
||||
@@ -109,6 +178,12 @@ export class ScryfallService {
|
||||
}
|
||||
|
||||
async fetchSetCards(setCode: string, onProgress?: (current: number) => void): Promise<ScryfallCard[]> {
|
||||
if (this.initPromise) await this.initPromise;
|
||||
|
||||
// Check if we already have a significant number of cards from this set in cache?
|
||||
// Hard to know strict completeness without tracking sets.
|
||||
// But for now, we just fetch and merge.
|
||||
|
||||
let cards: ScryfallCard[] = [];
|
||||
let url = `https://api.scryfall.com/cards/search?q=set:${setCode}&unique=cards`;
|
||||
|
||||
@@ -117,10 +192,6 @@ export class ScryfallService {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.data) {
|
||||
// Should we filter here strictly? The API query 'set:code' + 'unique=cards' is usually correct.
|
||||
// We might want to filter out Basics if we don't want them in booster generation, but standard boosters contain basics.
|
||||
// However, user setting for "Ignore Basic Lands" is handled in PackGeneratorService.processCards.
|
||||
// So here we should fetch everything.
|
||||
cards.push(...data.data);
|
||||
if (onProgress) onProgress(cards.length);
|
||||
}
|
||||
@@ -135,6 +206,16 @@ export class ScryfallService {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache everything
|
||||
if (cards.length > 0) {
|
||||
cards.forEach(card => {
|
||||
this.cacheById.set(card.id, card);
|
||||
if (card.name) this.cacheByName.set(card.name.toLowerCase(), card);
|
||||
});
|
||||
await db.bulkPutCards(cards);
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
}
|
||||
|
||||
83
src/client/src/utils/db.ts
Normal file
83
src/client/src/utils/db.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ScryfallCard } from '../services/ScryfallService';
|
||||
|
||||
const DB_NAME = 'mtg-draft-maker';
|
||||
const STORE_NAME = 'cards';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
const openDB = (): Promise<IDBDatabase> => {
|
||||
if (dbPromise) return dbPromise;
|
||||
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
resolve((event.target as IDBOpenDBRequest).result);
|
||||
};
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject((event.target as IDBOpenDBRequest).error);
|
||||
};
|
||||
});
|
||||
|
||||
return dbPromise;
|
||||
};
|
||||
|
||||
export const db = {
|
||||
async getAllCards(): Promise<ScryfallCard[]> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
},
|
||||
|
||||
async putCard(card: ScryfallCard): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.put(card);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
},
|
||||
|
||||
async bulkPutCards(cards: ScryfallCard[]): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = (event) => reject(transaction.error);
|
||||
|
||||
cards.forEach(card => store.put(card));
|
||||
});
|
||||
},
|
||||
|
||||
async getCard(id: string): Promise<ScryfallCard | undefined> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user