feat: Integrate EDHREC rank into card scoring and refactor auto deck builder for local, more sophisticated bot deck generation.
This commit is contained in:
@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.njidsnjs7o4"
|
"revision": "0.08qtrue2dho"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface DraftCard {
|
|||||||
setCode: string;
|
setCode: string;
|
||||||
setType: string;
|
setType: string;
|
||||||
finish?: 'foil' | 'normal';
|
finish?: 'foil' | 'normal';
|
||||||
|
edhrecRank?: number; // Added EDHREC Rank
|
||||||
// Extended Metadata
|
// Extended Metadata
|
||||||
cmc?: number;
|
cmc?: number;
|
||||||
manaCost?: string;
|
manaCost?: string;
|
||||||
@@ -116,6 +117,7 @@ export class PackGeneratorService {
|
|||||||
setCode: cardData.set,
|
setCode: cardData.set,
|
||||||
setType: setType,
|
setType: setType,
|
||||||
finish: cardData.finish,
|
finish: cardData.finish,
|
||||||
|
edhrecRank: cardData.edhrec_rank, // Map EDHREC Rank
|
||||||
// Extended Metadata mapping
|
// Extended Metadata mapping
|
||||||
cmc: cardData.cmc,
|
cmc: cardData.cmc,
|
||||||
manaCost: cardData.mana_cost,
|
manaCost: cardData.mana_cost,
|
||||||
|
|||||||
@@ -1,208 +1,336 @@
|
|||||||
|
|
||||||
interface Card {
|
export interface Card {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
manaCost?: string;
|
mana_cost?: string; // Standard Scryfall
|
||||||
typeLine?: string; // or type_line
|
manaCost?: string; // Legacy support
|
||||||
type_line?: string;
|
type_line?: string; // Standard Scryfall
|
||||||
|
typeLine?: string; // Legacy support
|
||||||
colors?: string[]; // e.g. ['W', 'U']
|
colors?: string[]; // e.g. ['W', 'U']
|
||||||
colorIdentity?: string[];
|
colorIdentity?: string[];
|
||||||
rarity?: string;
|
rarity?: 'common' | 'uncommon' | 'rare' | 'mythic' | string;
|
||||||
cmc?: number;
|
cmc?: number;
|
||||||
|
power?: string;
|
||||||
|
toughness?: string;
|
||||||
|
edhrecRank?: number; // Added EDHREC Rank
|
||||||
|
card_faces?: any[];
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AutoDeckBuilder {
|
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[]> {
|
static async buildDeckAsync(pool: Card[], basicLands: Card[]): Promise<Card[]> {
|
||||||
console.log(`[AutoDeckBuilder] 🏗️ Building deck from pool of ${pool.length} cards...`);
|
console.log(`[AutoDeckBuilder] 🏗️ Building deck from pool of ${pool.length} cards...`);
|
||||||
|
|
||||||
// 1. Calculate Heuristic Deck (Local) using existing logic
|
// We force a small delay to not block UI thread if it was heavy, though for 90 cards it's fast.
|
||||||
const heuristicDeck = this.calculateHeuristicDeck(pool, basicLands);
|
await new Promise(r => setTimeout(r, 10));
|
||||||
console.log(`[AutoDeckBuilder] 🧠 Heuristic generated ${heuristicDeck.length} cards.`);
|
|
||||||
|
|
||||||
try {
|
return this.calculateHeuristicDeck(pool, basicLands);
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extracted internal method for synchronous heuristic (Bot logic)
|
// --- Core Heuristic Logic ---
|
||||||
|
|
||||||
private static calculateHeuristicDeck(pool: Card[], basicLands: Card[]): Card[] {
|
private static calculateHeuristicDeck(pool: Card[], basicLands: Card[]): Card[] {
|
||||||
// 1. Analyze Colors to find top 2 archetypes
|
const TARGET_SPELL_COUNT = 23;
|
||||||
const colorCounts = { W: 0, U: 0, B: 0, R: 0, G: 0 };
|
|
||||||
|
|
||||||
pool.forEach(card => {
|
// 1. Identify best 2-color combination
|
||||||
// Simple heuristic: Count cards by color identity
|
const bestPair = this.findBestColorPair(pool);
|
||||||
// Weighted by Rarity: Mythic=4, Rare=3, Uncommon=2, Common=1
|
console.log(`[AutoDeckBuilder] 🎨 Best pair identified: ${bestPair.join('/')}`);
|
||||||
const weight = this.getRarityWeight(card.rarity);
|
|
||||||
|
|
||||||
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) {
|
const colors = c.colors || [];
|
||||||
colors.forEach(c => {
|
if (colors.length === 0) return true; // Artifacts
|
||||||
if (colorCounts[c as keyof typeof colorCounts] !== undefined) {
|
return colors.every(col => mainColors.includes(col)); // On-color
|
||||||
colorCounts[c as keyof typeof colorCounts] += weight;
|
});
|
||||||
}
|
|
||||||
|
// 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<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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
return deckLands;
|
||||||
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<keyof typeof landAllocation>;
|
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getRarityWeight(rarity?: string): number {
|
// --- Utilities ---
|
||||||
switch (rarity) {
|
|
||||||
case 'mythic': return 5;
|
private static isLand(c: Card): boolean {
|
||||||
case 'rare': return 4;
|
const t = c.typeLine || c.type_line || '';
|
||||||
case 'uncommon': return 2;
|
return t.includes('Land');
|
||||||
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 {
|
private static getBasicLandName(color: string): string {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ interface Card {
|
|||||||
name: string;
|
name: string;
|
||||||
image_uris?: { normal: string };
|
image_uris?: { normal: string };
|
||||||
card_faces?: { image_uris: { normal: string } }[];
|
card_faces?: { image_uris: { normal: string } }[];
|
||||||
|
colors?: string[];
|
||||||
|
rarity?: string;
|
||||||
|
edhrecRank?: number;
|
||||||
// ... other props
|
// ... other props
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,9 +241,41 @@ export class DraftManager extends EventEmitter {
|
|||||||
const playerState = draft.players[playerId];
|
const playerState = draft.players[playerId];
|
||||||
if (!playerState || !playerState.activePack || playerState.activePack.cards.length === 0) return null;
|
if (!playerState || !playerState.activePack || playerState.activePack.cards.length === 0) return null;
|
||||||
|
|
||||||
// Pick Random Card
|
// Score cards
|
||||||
const randomCardIndex = Math.floor(Math.random() * playerState.activePack.cards.length);
|
const scoredCards = playerState.activePack.cards.map(c => {
|
||||||
const card = playerState.activePack.cards[randomCardIndex];
|
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
|
// Reuse existing logic
|
||||||
return this.pickCard(roomId, playerId, card.id);
|
return this.pickCard(roomId, playerId, card.id);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface Card {
|
|||||||
colorIdentity?: string[];
|
colorIdentity?: string[];
|
||||||
rarity?: string;
|
rarity?: string;
|
||||||
cmc?: number;
|
cmc?: number;
|
||||||
|
edhrecRank?: number; // Added EDHREC
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BotDeckBuilderService {
|
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 lands = candidates.filter(c => c.typeLine?.includes('Land')); // Non-basic lands in pool
|
||||||
const spells = candidates.filter(c => !c.typeLine?.includes('Land'));
|
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)
|
// Sort by Weight + slight curve preference (lower cmc preferred for consistency)
|
||||||
spells.sort((a, b) => {
|
spells.sort((a, b) => {
|
||||||
const weightA = this.getRarityWeight(a.rarity);
|
let weightA = this.getRarityWeight(a.rarity);
|
||||||
const weightB = this.getRarityWeight(b.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;
|
return weightB - weightA;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface DraftCard {
|
|||||||
setCode: string;
|
setCode: string;
|
||||||
setType: string;
|
setType: string;
|
||||||
finish?: 'foil' | 'normal';
|
finish?: 'foil' | 'normal';
|
||||||
|
edhrecRank?: number; // Added EDHREC Rank
|
||||||
oracleText?: string;
|
oracleText?: string;
|
||||||
manaCost?: string;
|
manaCost?: string;
|
||||||
[key: string]: any; // Allow extended props
|
[key: string]: any; // Allow extended props
|
||||||
@@ -103,7 +104,9 @@ export class PackGeneratorService {
|
|||||||
set: cardData.set_name,
|
set: cardData.set_name,
|
||||||
setCode: cardData.set,
|
setCode: cardData.set,
|
||||||
setType: setType,
|
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 || '',
|
oracleText: cardData.oracle_text || cardData.card_faces?.[0]?.oracle_text || '',
|
||||||
manaCost: cardData.mana_cost || cardData.card_faces?.[0]?.mana_cost || '',
|
manaCost: cardData.mana_cost || cardData.card_faces?.[0]?.mana_cost || '',
|
||||||
damageMarked: 0,
|
damageMarked: 0,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface ScryfallCard {
|
|||||||
layout: string;
|
layout: string;
|
||||||
type_line: string;
|
type_line: string;
|
||||||
colors?: 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 };
|
image_uris?: { normal: string; small?: string; large?: string; png?: string; art_crop?: string; border_crop?: string };
|
||||||
card_faces?: {
|
card_faces?: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user