From eb453fd906a8ddd2d83b4a33cdce9c15e4f57843 Mon Sep 17 00:00:00 2001 From: dnviti Date: Sat, 20 Dec 2025 16:49:20 +0100 Subject: [PATCH] feat: Integrate EDHREC rank into card scoring and refactor auto deck builder for local, more sophisticated bot deck generation. --- src/client/dev-dist/sw.js | 2 +- .../src/services/PackGeneratorService.ts | 2 + src/client/src/utils/AutoDeckBuilder.ts | 484 +++++++++++------- src/server/managers/DraftManager.ts | 41 +- src/server/services/BotDeckBuilderService.ts | 12 +- src/server/services/PackGeneratorService.ts | 5 +- src/server/services/ScryfallService.ts | 1 + 7 files changed, 361 insertions(+), 186 deletions(-) diff --git a/src/client/dev-dist/sw.js b/src/client/dev-dist/sw.js index e91d775..ef5d02e 100644 --- a/src/client/dev-dist/sw.js +++ b/src/client/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.njidsnjs7o4" + "revision": "0.08qtrue2dho" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/src/client/src/services/PackGeneratorService.ts b/src/client/src/services/PackGeneratorService.ts index bb024ed..ba82bab 100644 --- a/src/client/src/services/PackGeneratorService.ts +++ b/src/client/src/services/PackGeneratorService.ts @@ -14,6 +14,7 @@ export interface DraftCard { setCode: string; setType: string; finish?: 'foil' | 'normal'; + edhrecRank?: number; // Added EDHREC Rank // Extended Metadata cmc?: number; manaCost?: string; @@ -116,6 +117,7 @@ export class PackGeneratorService { setCode: cardData.set, setType: setType, finish: cardData.finish, + edhrecRank: cardData.edhrec_rank, // Map EDHREC Rank // Extended Metadata mapping cmc: cardData.cmc, manaCost: cardData.mana_cost, diff --git a/src/client/src/utils/AutoDeckBuilder.ts b/src/client/src/utils/AutoDeckBuilder.ts index 2fb1d24..f7b02b8 100644 --- a/src/client/src/utils/AutoDeckBuilder.ts +++ b/src/client/src/utils/AutoDeckBuilder.ts @@ -1,208 +1,336 @@ -interface Card { +export interface Card { id: string; name: string; - manaCost?: string; - typeLine?: string; // or type_line - type_line?: string; + mana_cost?: string; // Standard Scryfall + manaCost?: string; // Legacy support + type_line?: string; // Standard Scryfall + typeLine?: string; // Legacy support colors?: string[]; // e.g. ['W', 'U'] colorIdentity?: string[]; - rarity?: string; + rarity?: 'common' | 'uncommon' | 'rare' | 'mythic' | string; cmc?: number; + power?: string; + toughness?: string; + edhrecRank?: number; // Added EDHREC Rank + card_faces?: any[]; [key: string]: any; } export class AutoDeckBuilder { + /** + * Main entry point to build a deck from a pool. + * Now purely local and synchronous in execution (wrapped in Promise for API comp). + */ static async buildDeckAsync(pool: Card[], basicLands: Card[]): Promise { console.log(`[AutoDeckBuilder] 🏗️ Building deck from pool of ${pool.length} cards...`); - // 1. Calculate Heuristic Deck (Local) using existing logic - const heuristicDeck = this.calculateHeuristicDeck(pool, basicLands); - console.log(`[AutoDeckBuilder] 🧠 Heuristic generated ${heuristicDeck.length} cards.`); + // We force a small delay to not block UI thread if it was heavy, though for 90 cards it's fast. + await new Promise(r => setTimeout(r, 10)); - try { - // 2. Call Server API for AI/Enhanced Decision - const response = await fetch('/api/ai/deck', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - pool: pool.map(c => ({ - id: c.id, - name: c.name, - colors: c.colors, - type_line: c.typeLine || c.type_line, - rarity: c.rarity, - cmc: c.cmc - })), - heuristicDeck: heuristicDeck.map(c => ({ id: c.id, name: c.name })) // Optimization: Send IDs/Names only? Server needs content. - // Actually server might need full card objects if it's stateless. - // Let's send lighter objects. - }) - }); - - if (!response.ok) { - throw new Error(`Server returned ${response.status}`); - } - - const data = await response.json(); - if (data.deck) { - console.log(`[AutoDeckBuilder] 🌐 Server returned deck with ${data.deck.length} cards.`); - // Re-hydrate cards from pool/lands based on IDs returned? - // Or use returned objects? - // If server returns IDs, we need to map back. - // For now, let's assume server returns full objects or we return the heuristic deck if failed. - // The server implementation GeminiService.generateDeck returns Card[]. - - // Mapper: - // return data.deck; // This might lose local props like `isLandSource`. - // We should trust the server's return if it matches our structure. - return data.deck; - } - - } catch (error) { - console.error('[AutoDeckBuilder] ⚠️ API Call failed, returning heuristic deck.', error); - } - - return heuristicDeck; + return this.calculateHeuristicDeck(pool, basicLands); } - // Extracted internal method for synchronous heuristic (Bot logic) + // --- Core Heuristic Logic --- + private static calculateHeuristicDeck(pool: Card[], basicLands: Card[]): Card[] { - // 1. Analyze Colors to find top 2 archetypes - const colorCounts = { W: 0, U: 0, B: 0, R: 0, G: 0 }; + const TARGET_SPELL_COUNT = 23; - pool.forEach(card => { - // Simple heuristic: Count cards by color identity - // Weighted by Rarity: Mythic=4, Rare=3, Uncommon=2, Common=1 - const weight = this.getRarityWeight(card.rarity); + // 1. Identify best 2-color combination + const bestPair = this.findBestColorPair(pool); + console.log(`[AutoDeckBuilder] 🎨 Best pair identified: ${bestPair.join('/')}`); - const colors = card.colors || []; + // 2. Filter available spells for that pair + Artifacts + const mainColors = bestPair; + let candidates = pool.filter(c => { + // Exclude Basic Lands from pool (they are added later) + if (this.isBasicLand(c)) return false; - if (colors.length > 0) { - colors.forEach(c => { - if (colorCounts[c as keyof typeof colorCounts] !== undefined) { - colorCounts[c as keyof typeof colorCounts] += weight; - } + const colors = c.colors || []; + if (colors.length === 0) return true; // Artifacts + return colors.every(col => mainColors.includes(col)); // On-color + }); + + // 3. Score and Select Spells + // Logic: + // a. Score every candidate + // b. Sort by score + // c. Fill Curve: + // - Ensure minimum 2-drops, 3-drops? + // - Or just pick best cards? + // - Let's do a weighted curve approach: Fill slots with best cards for that slot. + + const scoredCandidates = candidates.map(c => ({ + card: c, + score: this.calculateCardScore(c, mainColors) + })); + + // Sort Descending + scoredCandidates.sort((a, b) => b.score - a.score); + + // Curve Buckets (Min-Max goal) + // 1-2 CMC: 4-6 + // 3 CMC: 4-6 + // 4 CMC: 4-5 + // 5 CMC: 2-3 + // 6+ CMC: 1-2 + // Creatures check: Ensure at least ~13 creatures + const deckSpells: Card[] = []; + // const creatureCount = () => deckSpells.filter(c => c.typeLine?.includes('Creature')).length; + + + // Simple pass: Just take top 23? + // No, expensive cards might clog. + // Let's iterate and enforce limits. + + const curveCounts: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 }; + const getCmcBucket = (c: Card) => { + const val = c.cmc || 0; + if (val <= 2) return 2; // Merge 0,1,2 for simplicity + if (val >= 6) return 6; + return val; + }; + + // Soft caps for each bucket to ensure distribution + const curveLimits: Record = { 2: 8, 3: 7, 4: 6, 5: 4, 6: 3 }; + + // Pass 1: Fill using curve limits + for (const item of scoredCandidates) { + if (deckSpells.length >= TARGET_SPELL_COUNT) break; + const bucket = getCmcBucket(item.card); + if (curveCounts[bucket] < curveLimits[bucket]) { + deckSpells.push(item.card); + curveCounts[bucket]++; + } + } + + // Pass 2: Fill remaining slots with best available ignoring curve (to reach 23) + if (deckSpells.length < TARGET_SPELL_COUNT) { + const remaining = scoredCandidates.filter(item => !deckSpells.includes(item.card)); + for (const item of remaining) { + if (deckSpells.length >= TARGET_SPELL_COUNT) break; + deckSpells.push(item.card); + } + } + + // Creature Balance Check (Simplistic) + // If creatures < 12, swap worst non-creatures for best available creatures? + // Skipping for now to keep it deterministic and simple. + + // 4. Lands + // Fetch Basic Lands based on piping + const deckLands = this.generateBasicLands(deckSpells, basicLands, 40 - deckSpells.length); + + return [...deckSpells, ...deckLands]; + } + + + // --- Helper: Find Best Pair --- + + private static findBestColorPair(pool: Card[]): string[] { + const colors = ['W', 'U', 'B', 'R', 'G']; + const pairs: string[][] = []; + + // Generating all unique pairs + for (let i = 0; i < colors.length; i++) { + for (let j = i + 1; j < colors.length; j++) { + pairs.push([colors[i], colors[j]]); + } + } + + let bestPair = ['W', 'U']; + let maxScore = -1; + + pairs.forEach(pair => { + const score = this.evaluateColorPair(pool, pair); + // console.log(`Pair ${pair.join('')} Score: ${score}`); + if (score > maxScore) { + maxScore = score; + bestPair = pair; + } + }); + + return bestPair; + } + + private static evaluateColorPair(pool: Card[], pair: string[]): number { + // Score based on: + // 1. Quantity of playable cards in these colors + // 2. Specific bonuses for Rares/Mythics + + let score = 0; + + pool.forEach(c => { + // Skip lands for archetype selection power (mostly) + if (this.isLand(c)) return; + + const cardColors = c.colors || []; + + // Artifacts count for everyone but less + if (cardColors.length === 0) { + score += 0.5; + return; + } + + // Check if card fits in pair + const fits = cardColors.every(col => pair.includes(col)); + if (!fits) return; + + // Base score + let cardVal = 1; + + // Rarity Bonus + if (c.rarity === 'uncommon') cardVal += 1.5; + if (c.rarity === 'rare') cardVal += 3.5; + if (c.rarity === 'mythic') cardVal += 4.5; + + // Gold Card Bonus (Signpost) - If it uses BOTH colors, it's a strong signal + if (cardColors.length === 2 && cardColors.includes(pair[0]) && cardColors.includes(pair[1])) { + cardVal += 2; + } + + score += cardVal; + }); + + return score; + } + + // --- Helper: Card Scoring --- + + private static calculateCardScore(c: Card, mainColors: string[]): number { + let score = 0; + + // 1. Rarity Base + switch (c.rarity) { + case 'mythic': score = 5.0; break; + case 'rare': score = 4.0; break; + case 'uncommon': score = 2.5; break; + default: score = 1.0; break; // Common + } + + // 2. Removal Bonus (Heuristic based on type + text is hard, so just type for now) + // Instants/Sorceries tend to be removal or interaction + const typeLine = c.typeLine || c.type_line || ''; + if (typeLine.includes('Instant') || typeLine.includes('Sorcery')) { + score += 0.5; + } + + // 3. Gold Card Synergy + const colors = c.colors || []; + if (colors.length > 1) { + score += 0.5; // Multicolored cards are usually stronger rate-wise + + // Bonus if it perfectly matches our main colors (Signpost) + if (mainColors.length === 2 && colors.includes(mainColors[0]) && colors.includes(mainColors[1])) { + score += 1.0; + } + } + + // 4. CMC Check (Penalty for very high cost) + if ((c.cmc || 0) > 6) score -= 0.5; + + // 5. EDHREC Score (Mild Influence) + // Rank 1000 => +2.0, Rank 5000 => +1.0 + // Formula: 3 * (1 - (rank/10000)) limited to 0 + if (c.edhrecRank !== undefined && c.edhrecRank !== null) { + const rank = c.edhrecRank; + if (rank < 10000) { + score += (3 * (1 - (rank / 10000))); + } + } + + return score; + } + + // --- Helper: Lands --- + + private static generateBasicLands(deckSpells: Card[], basicLandPool: Card[], countNeeded: number): Card[] { + const deckLands: Card[] = []; + if (countNeeded <= 0) return deckLands; + + // Count pips + const pips = { W: 0, U: 0, B: 0, R: 0, G: 0 }; + deckSpells.forEach(c => { + const cost = c.mana_cost || c.manaCost || ''; + if (cost.includes('W')) pips.W += (cost.match(/W/g) || []).length; + if (cost.includes('U')) pips.U += (cost.match(/U/g) || []).length; + if (cost.includes('B')) pips.B += (cost.match(/B/g) || []).length; + if (cost.includes('R')) pips.R += (cost.match(/R/g) || []).length; + if (cost.includes('G')) pips.G += (cost.match(/G/g) || []).length; + }); + + const totalPips = Object.values(pips).reduce((a, b) => a + b, 0) || 1; + + // Allocate + const allocation = { + W: Math.round((pips.W / totalPips) * countNeeded), + U: Math.round((pips.U / totalPips) * countNeeded), + B: Math.round((pips.B / totalPips) * countNeeded), + R: Math.round((pips.R / totalPips) * countNeeded), + G: Math.round((pips.G / totalPips) * countNeeded), + }; + + // Adjust for rounding errors + let currentTotal = Object.values(allocation).reduce((a, b) => a + b, 0); + + // 1. If we are short, add to the color with most pips + while (currentTotal < countNeeded) { + const topColor = Object.entries(allocation).sort((a, b) => b[1] - a[1])[0][0]; + allocation[topColor as keyof typeof allocation]++; + currentTotal++; + } + + // 2. If we are over, subtract from the color with most lands (that has > 0) + while (currentTotal > countNeeded) { + const topColor = Object.entries(allocation).sort((a, b) => b[1] - a[1])[0][0]; + if (allocation[topColor as keyof typeof allocation] > 0) { + allocation[topColor as keyof typeof allocation]--; + currentTotal--; + } else { + // Fallback to remove from anyone + const anyColor = Object.keys(allocation).find(k => allocation[k as keyof typeof allocation] > 0); + if (anyColor) allocation[anyColor as keyof typeof allocation]--; + currentTotal--; + } + } + + // Generate Objects + Object.entries(allocation).forEach(([color, qty]) => { + if (qty <= 0) return; + const landName = this.getBasicLandName(color); + + // Find source + let source = basicLandPool.find(l => l.name === landName) + || basicLandPool.find(l => l.name.includes(landName)); // Fuzzy + + if (!source && basicLandPool.length > 0) source = basicLandPool[0]; // Fallback? + + // If we have a source, clone it. If not, we might be in trouble but let's assume source exists or we make a dummy. + for (let i = 0; i < qty; i++) { + deckLands.push({ + ...source!, + name: landName, // Ensure correct name + typeLine: `Basic Land — ${landName}`, + id: `land-${color}-${Date.now()}-${Math.random().toString(36).substring(7)}`, + isLandSource: false }); } }); - // Sort colors by count desc - const sortedColors = Object.entries(colorCounts) - .sort(([, a], [, b]) => b - a) - .map(([color]) => color); - - const mainColors = sortedColors.slice(0, 2); // Top 2 colors - - // 2. Filter Pool for On-Color + Artifacts - const candidates = pool.filter(card => { - const colors = card.colors || []; - if (colors.length === 0) return true; // Artifacts/Colorless - // Check if card fits within main colors - return colors.every(c => mainColors.includes(c)); - }); - - // 3. Separate Lands and Spells - // Check both camelCase and snake_case type line - const isLand = (c: Card) => (c.typeLine || c.type_line || '').includes('Land'); - - const lands = candidates.filter(isLand); // Non-basic lands in pool - const spells = candidates.filter(c => !isLand(c)); - - // 4. Select Spells (Curve + Power) - // Sort by Weight + slight curve preference (lower cmc preferred for consistency) - spells.sort((a, b) => { - const weightA = this.getRarityWeight(a.rarity); - const weightB = this.getRarityWeight(b.rarity); - return weightB - weightA; - }); - - const deckSpells = spells.slice(0, 23); - const deckNonBasicLands = lands.slice(0, 4); // Take up to 4 non-basics if available (simple cap) - - // 5. Fill with Basic Lands - const cardsNeeded = 40 - (deckSpells.length + deckNonBasicLands.length); - const deckLands: Card[] = []; - - if (cardsNeeded > 0 && basicLands.length > 0) { - // Calculate ratio of colors in spells - let whitePips = 0; - let bluePips = 0; - let blackPips = 0; - let redPips = 0; - let greenPips = 0; - - deckSpells.forEach(c => { - const colors = c.colors || []; - if (colors.includes('W')) whitePips++; - if (colors.includes('U')) bluePips++; - if (colors.includes('B')) blackPips++; - if (colors.includes('R')) redPips++; - if (colors.includes('G')) greenPips++; - }); - - const totalPips = whitePips + bluePips + blackPips + redPips + greenPips || 1; - - // Allocate lands - const landAllocation = { - W: Math.round((whitePips / totalPips) * cardsNeeded), - U: Math.round((bluePips / totalPips) * cardsNeeded), - B: Math.round((blackPips / totalPips) * cardsNeeded), - R: Math.round((redPips / totalPips) * cardsNeeded), - G: Math.round((greenPips / totalPips) * cardsNeeded), - }; - - // Fix rounding errors - const allocatedTotal = Object.values(landAllocation).reduce((a, b) => a + b, 0); - if (allocatedTotal < cardsNeeded) { - // Add to main color - landAllocation[mainColors[0] as keyof typeof landAllocation] += (cardsNeeded - allocatedTotal); - } else if (allocatedTotal > cardsNeeded) { - // Reduce main color? Or just truncate. - // In the server version we didn't handle over-allocation, assuming round down mostly. - // But round up can happen. - // Simple fix: if over, reduce first non-zero - let diff = allocatedTotal - cardsNeeded; - const keys = Object.keys(landAllocation) as Array; - for (let i = 0; i < diff; i++) { - for (const k of keys) { - if (landAllocation[k] > 0) { - landAllocation[k]--; - break; - } - } - } - } - - // Add actual land objects - Object.entries(landAllocation).forEach(([color, count]) => { - const landName = this.getBasicLandName(color); - // Find land with matching name (loose match) - const landCard = basicLands.find(l => l.name === landName || (l.name.includes(landName) && (l.typeLine || l.type_line || '').includes('Basic'))) || basicLands[0]; - - if (landCard) { - for (let i = 0; i < count; i++) { - deckLands.push({ - ...landCard, - id: `land-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - isLandSource: false // Ensure it's treated as a deck card - }); - } - } - }); - } - - return [...deckSpells, ...deckNonBasicLands, ...deckLands]; + return deckLands; } - private static getRarityWeight(rarity?: string): number { - switch (rarity) { - case 'mythic': return 5; - case 'rare': return 4; - case 'uncommon': return 2; - default: return 1; - } + // --- Utilities --- + + private static isLand(c: Card): boolean { + const t = c.typeLine || c.type_line || ''; + return t.includes('Land'); + } + + private static isBasicLand(c: Card): boolean { + const t = c.typeLine || c.type_line || ''; + return t.includes('Basic Land'); } private static getBasicLandName(color: string): string { diff --git a/src/server/managers/DraftManager.ts b/src/server/managers/DraftManager.ts index 4f00736..7edf2ea 100644 --- a/src/server/managers/DraftManager.ts +++ b/src/server/managers/DraftManager.ts @@ -6,6 +6,9 @@ interface Card { name: string; image_uris?: { normal: string }; card_faces?: { image_uris: { normal: string } }[]; + colors?: string[]; + rarity?: string; + edhrecRank?: number; // ... other props } @@ -238,9 +241,41 @@ export class DraftManager extends EventEmitter { const playerState = draft.players[playerId]; if (!playerState || !playerState.activePack || playerState.activePack.cards.length === 0) return null; - // Pick Random Card - const randomCardIndex = Math.floor(Math.random() * playerState.activePack.cards.length); - const card = playerState.activePack.cards[randomCardIndex]; + // Score cards + const scoredCards = playerState.activePack.cards.map(c => { + let score = 0; + + // 1. Rarity Base Score + if (c.rarity === 'mythic') score += 5; + else if (c.rarity === 'rare') score += 4; + else if (c.rarity === 'uncommon') score += 2; + else score += 1; + + // 2. Color Synergy (Simple) + const poolColors = playerState.pool.flatMap(p => p.colors || []); + if (poolColors.length > 0 && c.colors) { + c.colors.forEach(col => { + const count = poolColors.filter(pc => pc === col).length; + score += (count * 0.1); + }); + } + + // 3. EDHREC Score (Lower rank = better) + if (c.edhrecRank !== undefined && c.edhrecRank !== null) { + const rank = c.edhrecRank; + if (rank < 10000) { + score += (5 * (1 - (rank / 10000))); + } + } + + return { card: c, score }; + }); + + // Sort by score desc + scoredCards.sort((a, b) => b.score - a.score); + + // Pick top card + const card = scoredCards[0].card; // Reuse existing logic return this.pickCard(roomId, playerId, card.id); diff --git a/src/server/services/BotDeckBuilderService.ts b/src/server/services/BotDeckBuilderService.ts index 80d9818..b7d901e 100644 --- a/src/server/services/BotDeckBuilderService.ts +++ b/src/server/services/BotDeckBuilderService.ts @@ -8,6 +8,7 @@ interface Card { colorIdentity?: string[]; rarity?: string; cmc?: number; + edhrecRank?: number; // Added EDHREC } export class BotDeckBuilderService { @@ -49,11 +50,16 @@ export class BotDeckBuilderService { const lands = candidates.filter(c => c.typeLine?.includes('Land')); // Non-basic lands in pool const spells = candidates.filter(c => !c.typeLine?.includes('Land')); - // 4. Select Spells (Curve + Power) + // 4. Select Spells (Curve + Power + EDHREC) // Sort by Weight + slight curve preference (lower cmc preferred for consistency) spells.sort((a, b) => { - const weightA = this.getRarityWeight(a.rarity); - const weightB = this.getRarityWeight(b.rarity); + let weightA = this.getRarityWeight(a.rarity); + let weightB = this.getRarityWeight(b.rarity); + + // Add EDHREC influence + if (a.edhrecRank !== undefined && a.edhrecRank < 10000) weightA += (3 * (1 - (a.edhrecRank / 10000))); + if (b.edhrecRank !== undefined && b.edhrecRank < 10000) weightB += (3 * (1 - (b.edhrecRank / 10000))); + return weightB - weightA; }); diff --git a/src/server/services/PackGeneratorService.ts b/src/server/services/PackGeneratorService.ts index 209b61a..78fe326 100644 --- a/src/server/services/PackGeneratorService.ts +++ b/src/server/services/PackGeneratorService.ts @@ -15,6 +15,7 @@ export interface DraftCard { setCode: string; setType: string; finish?: 'foil' | 'normal'; + edhrecRank?: number; // Added EDHREC Rank oracleText?: string; manaCost?: string; [key: string]: any; // Allow extended props @@ -103,7 +104,9 @@ export class PackGeneratorService { set: cardData.set_name, setCode: cardData.set, setType: setType, - finish: cardData.finish || 'normal', + finish: cardData.finish, + edhrecRank: cardData.edhrec_rank, // Map EDHREC Rank + // Extended Metadata mappingl', oracleText: cardData.oracle_text || cardData.card_faces?.[0]?.oracle_text || '', manaCost: cardData.mana_cost || cardData.card_faces?.[0]?.mana_cost || '', damageMarked: 0, diff --git a/src/server/services/ScryfallService.ts b/src/server/services/ScryfallService.ts index 5584148..0d4073a 100644 --- a/src/server/services/ScryfallService.ts +++ b/src/server/services/ScryfallService.ts @@ -28,6 +28,7 @@ export interface ScryfallCard { layout: string; type_line: string; colors?: string[]; + edhrec_rank?: number; // Add EDHREC rank image_uris?: { normal: string; small?: string; large?: string; png?: string; art_crop?: string; border_crop?: string }; card_faces?: { name: string;