feat: Integrate EDHREC rank into card scoring and refactor auto deck builder for local, more sophisticated bot deck generation.

This commit is contained in:
2025-12-20 16:49:20 +01:00
parent 2794ce71aa
commit eb453fd906
7 changed files with 361 additions and 186 deletions

View File

@@ -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"), {

View File

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

View File

@@ -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<Card[]> {
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}`);
return this.calculateHeuristicDeck(pool, basicLands);
}
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[].
// --- Core Heuristic Logic ---
// 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;
}
// Extracted internal method for synchronous heuristic (Bot 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;
}
});
}
});
// 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++;
if (colors.length === 0) return true; // Artifacts
return colors.every(col => mainColors.includes(col)); // On-color
});
const totalPips = whitePips + bluePips + blackPips + redPips + greenPips || 1;
// 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.
// 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),
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<number, number> = { 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;
};
// 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<keyof typeof landAllocation>;
for (let i = 0; i < diff; i++) {
for (const k of keys) {
if (landAllocation[k] > 0) {
landAllocation[k]--;
break;
}
}
// Soft caps for each bucket to ensure distribution
const curveLimits: Record<number, number> = { 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]++;
}
}
// Add actual land objects
Object.entries(landAllocation).forEach(([color, count]) => {
// 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 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++) {
// 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({
...landCard,
id: `land-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
isLandSource: false // Ensure it's treated as a deck card
...source!,
name: landName, // Ensure correct name
typeLine: `Basic Land — ${landName}`,
id: `land-${color}-${Date.now()}-${Math.random().toString(36).substring(7)}`,
isLandSource: false
});
}
}
});
return deckLands;
}
return [...deckSpells, ...deckNonBasicLands, ...deckLands];
// --- Utilities ---
private static isLand(c: Card): boolean {
const t = c.typeLine || c.type_line || '';
return t.includes('Land');
}
private static getRarityWeight(rarity?: string): number {
switch (rarity) {
case 'mythic': return 5;
case 'rare': return 4;
case 'uncommon': return 2;
default: return 1;
}
private static isBasicLand(c: Card): boolean {
const t = c.typeLine || c.type_line || '';
return t.includes('Basic Land');
}
private static getBasicLandName(color: string): string {

View File

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

View File

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

View File

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

View File

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