feat: integrate AI-powered deck building and card picking using Google Gemini.

This commit is contained in:
2025-12-20 16:18:11 +01:00
parent 664d0e838d
commit 2794ce71aa
13 changed files with 735 additions and 15 deletions

View File

@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.5drsp6r8gnc"
"revision": "0.njidsnjs7o4"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@@ -7,6 +7,8 @@ import { DraftCard } from '../../services/PackGeneratorService';
import { useCardTouch } from '../../utils/interaction';
import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import { AutoDeckBuilder } from '../../utils/AutoDeckBuilder';
import { Wand2 } from 'lucide-react'; // Import Wand icon
interface DeckBuilderViewProps {
roomId: string;
@@ -492,6 +494,37 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
socketService.socket.emit('player_ready', { deck: preparedDeck });
};
const handleAutoBuild = async () => {
if (confirm("This will replace your current deck with an auto-generated one. Continue?")) {
console.log("Auto-Build: Started");
// 1. Merge current deck back into pool (excluding basic lands generated)
const currentDeckSpells = deck.filter(c => !c.isLandSource && !(c.typeLine || c.type_line || '').includes('Basic'));
const fullPool = [...pool, ...currentDeckSpells];
console.log("Auto-Build: Full Pool Size:", fullPool.length);
// 2. Run Auto Builder
// We need real basic land objects if available, or generic ones
const landSource = availableBasicLands && availableBasicLands.length > 0 ? availableBasicLands : landSourceCards;
console.log("Auto-Build: Land Source Size:", landSource?.length);
try {
const newDeck = await AutoDeckBuilder.buildDeckAsync(fullPool, landSource);
console.log("Auto-Build: New Deck Generated:", newDeck.length);
// 3. Update State
// Remove deck cards from pool
const newDeckIds = new Set(newDeck.map((c: any) => c.id));
const remainingPool = fullPool.filter(c => !newDeckIds.has(c.id));
console.log("Auto-Build: Remaining Pool Size:", remainingPool.length);
setDeck(newDeck);
setPool(remainingPool);
} catch (e) {
console.error("Auto-Build Error:", e);
}
}
};
// --- DnD Handlers ---
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
@@ -816,6 +849,14 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
</div>
<div className="flex items-center gap-4">
<button
onClick={handleAutoBuild}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded-lg border border-indigo-400/50 shadow-lg font-bold text-xs transition-transform hover:scale-105"
title="Auto-Build Deck"
>
<Wand2 className="w-4 h-4" /> <span className="hidden sm:inline">Auto-Build</span>
</button>
<div className="hidden sm:flex items-center gap-2 text-amber-400 font-mono text-sm font-bold bg-slate-900 px-3 py-1.5 rounded border border-amber-500/30">
<Clock className="w-4 h-4" /> {formatTime(timer)}
</div>

View File

@@ -7,6 +7,8 @@ import { FoilOverlay, FloatingPreview } from '../../components/CardPreview';
import { useCardTouch } from '../../utils/interaction';
import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import { AutoPicker } from '../../utils/AutoPicker';
import { Wand2 } from 'lucide-react';
// Helper to normalize card data for visuals
// Helper to normalize card data for visuals
@@ -141,6 +143,9 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
localStorage.setItem('draft_cardScale', cardScale.toString());
}, [cardScale]);
const handleResizeStart = (type: 'sidebar' | 'pool', e: React.MouseEvent | React.TouchEvent) => {
// Prevent default to avoid scrolling/selection
if (e.cancelable) e.preventDefault();
@@ -217,9 +222,42 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
const pickedCards = draftState.players[currentPlayerId]?.pool || [];
const handlePick = (cardId: string) => {
const card = activePack?.cards.find((c: any) => c.id === cardId);
console.log(`[DraftView] 👆 Manual/Submit Pick: ${card?.name || 'Unknown'} (${cardId})`);
socketService.socket.emit('pick_card', { cardId });
};
const handleAutoPick = async () => {
if (activePack && activePack.cards.length > 0) {
console.log('[DraftView] Starting Auto-Pick Process...');
const bestCard = await AutoPicker.pickBestCardAsync(activePack.cards, pickedCards);
if (bestCard) {
console.log(`[DraftView] Auto-Pick submitting: ${bestCard.name}`);
handlePick(bestCard.id);
}
}
};
const toggleAutoPick = () => {
setIsAutoPickEnabled(!isAutoPickEnabled);
};
// --- Auto-Pick / AFK Mode ---
const [isAutoPickEnabled, setIsAutoPickEnabled] = useState(false);
useEffect(() => {
let timeout: NodeJS.Timeout;
if (isAutoPickEnabled && activePack && activePack.cards.length > 0) {
// Small delay for visual feedback and to avoid race conditions
timeout = setTimeout(() => {
handleAutoPick();
}, 1500);
}
return () => clearTimeout(timeout);
}, [isAutoPickEnabled, activePack, draftState.packNumber, pickedCards.length]);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
useSensor(TouchSensor, {
@@ -445,7 +483,20 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
</div>
) : (
<div className="flex flex-col items-center justify-center min-h-full pb-10">
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold mb-8">Select a Card</h3>
<div className="flex items-center gap-4 mb-4">
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold">Select a Card</h3>
<button
onClick={toggleAutoPick}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border shadow-lg font-bold text-xs transition-all hover:scale-105 ${isAutoPickEnabled
? 'bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-400/50 animate-pulse'
: 'bg-indigo-600 hover:bg-indigo-500 text-white border-indigo-400/50'
}`}
title={isAutoPickEnabled ? "Disable Auto-Pick" : "Enable Auto-Pick (AFK Mode)"}
>
<Wand2 className={`w-3 h-3 ${isAutoPickEnabled ? 'animate-spin' : ''}`} />
{isAutoPickEnabled ? 'Auto-Pick ON' : 'Auto-Pick'}
</button>
</div>
<div className="flex flex-wrap justify-center gap-6">
{activePack.cards.map((rawCard: any) => (
<DraftCardItem
@@ -496,7 +547,20 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
</div>
) : (
<div className="flex flex-col items-center justify-center min-h-full pb-10">
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold mb-8">Select a Card</h3>
<div className="flex items-center gap-4 mb-4">
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold">Select a Card</h3>
<button
onClick={toggleAutoPick}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border shadow-lg font-bold text-xs transition-all hover:scale-105 ${isAutoPickEnabled
? 'bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-400/50 animate-pulse'
: 'bg-indigo-600 hover:bg-indigo-500 text-white border-indigo-400/50'
}`}
title={isAutoPickEnabled ? "Disable Auto-Pick" : "Enable Auto-Pick (AFK Mode)"}
>
<Wand2 className={`w-3 h-3 ${isAutoPickEnabled ? 'animate-spin' : ''}`} />
{isAutoPickEnabled ? 'Auto-Pick ON' : 'Auto-Pick'}
</button>
</div>
<div className="flex flex-wrap justify-center gap-6">
{activePack.cards.map((rawCard: any) => (
<DraftCardItem

View File

@@ -218,13 +218,18 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
// Reconnection logic (Initial Mount)
React.useEffect(() => {
const savedRoomId = localStorage.getItem('active_room_id');
if (savedRoomId && !activeRoom && playerId) {
console.log(`[LobbyManager] Found saved session ${savedRoomId}. Attempting to reconnect...`);
setLoading(true);
connect();
socketService.emitPromise('rejoin_room', { roomId: savedRoomId, playerId })
.then((response: any) => {
const handleRejoin = async () => {
try {
console.log(`[LobbyManager] Emitting rejoin_room...`);
const response = await socketService.emitPromise('rejoin_room', { roomId: savedRoomId, playerId });
if (response.success) {
console.log("Rejoined session successfully");
console.log("[LobbyManager] Rejoined session successfully");
setActiveRoom(response.room);
if (response.draftState) {
setInitialDraftState(response.draftState);
@@ -233,18 +238,33 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
setInitialGameState(response.gameState);
}
} else {
console.warn("Rejoin failed by server: ", response.message);
localStorage.removeItem('active_room_id');
console.warn("[LobbyManager] Rejoin failed by server: ", response.message);
// Only clear if explicitly rejected (e.g. Room closed), not connection error
if (response.message !== 'Connection error') {
localStorage.removeItem('active_room_id');
}
setLoading(false);
}
})
.catch(err => {
console.warn("Reconnection failed", err);
localStorage.removeItem('active_room_id'); // Clear invalid session
} catch (err: any) {
console.warn("[LobbyManager] Reconnection failed", err);
// Do not clear ID immediately on network error, allow retry
setLoading(false);
});
}
};
if (!socketService.socket.connected) {
console.log(`[LobbyManager] Socket not connected. Connecting...`);
connect();
socketService.socket.once('connect', handleRejoin);
} else {
handleRejoin();
}
return () => {
socketService.socket.off('connect', handleRejoin);
};
}
}, []);
}, []); // Run once on mount
// Auto-Rejoin on Socket Reconnect (e.g. Server Restart)
React.useEffect(() => {

View File

@@ -0,0 +1,218 @@
interface Card {
id: string;
name: string;
manaCost?: string;
typeLine?: string; // or type_line
type_line?: string;
colors?: string[]; // e.g. ['W', 'U']
colorIdentity?: string[];
rarity?: string;
cmc?: number;
[key: string]: any;
}
export class AutoDeckBuilder {
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.`);
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;
}
// 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 };
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);
const colors = card.colors || [];
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++;
});
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 {
switch (rarity) {
case 'mythic': return 5;
case 'rare': return 4;
case 'uncommon': return 2;
default: return 1;
}
}
private static getBasicLandName(color: string): string {
switch (color) {
case 'W': return 'Plains';
case 'U': return 'Island';
case 'B': return 'Swamp';
case 'R': return 'Mountain';
case 'G': return 'Forest';
default: return 'Wastes';
}
}
}

View File

@@ -0,0 +1,102 @@
interface Card {
id: string;
name: string;
manaCost?: string;
typeLine?: string;
type_line?: string;
colors?: string[];
colorIdentity?: string[];
rarity?: string;
cmc?: number;
[key: string]: any;
}
export class AutoPicker {
static async pickBestCardAsync(pack: Card[], pool: Card[]): Promise<Card | null> {
if (!pack || pack.length === 0) return null;
console.log('[AutoPicker] 🧠 Calculating Heuristic Pick...');
// 1. Calculate Heuristic (Local)
console.log(`[AutoPicker] 🏁 Starting Best Card Calculation for pack of ${pack.length} cards...`);
// 1. Analyze Pool to find top 2 colors
const colorCounts = { W: 0, U: 0, B: 0, R: 0, G: 0 };
pool.forEach(card => {
const weight = this.getRarityWeight(card.rarity);
const colors = card.colors || [];
colors.forEach(c => {
if (colorCounts[c as keyof typeof colorCounts] !== undefined) {
colorCounts[c as keyof typeof colorCounts] += weight;
}
});
});
const sortedColors = Object.entries(colorCounts)
.sort(([, a], [, b]) => b - a)
.map(([color]) => color);
const mainColors = sortedColors.slice(0, 2);
let bestCard: Card | null = null;
let maxScore = -1;
pack.forEach(card => {
let score = 0;
score += this.getRarityWeight(card.rarity);
const colors = card.colors || [];
if (colors.length === 0) {
score += 2;
} else {
const matches = colors.filter(c => mainColors.includes(c)).length;
if (matches === colors.length) score += 4;
else if (matches > 0) score += 1;
else score -= 10;
}
if ((card.typeLine || card.type_line || '').includes('Basic Land')) score -= 20;
if (score > maxScore) {
maxScore = score;
bestCard = card;
}
});
const heuristicPick = bestCard || pack[0];
console.log(`[AutoPicker] 🤖 Heuristic Suggestion: ${heuristicPick.name} (Score: ${maxScore})`);
// 2. Call Server AI (Async)
try {
console.log('[AutoPicker] 📡 Sending context to Server AI...');
const response = await fetch('/api/ai/pick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pack,
pool,
suggestion: heuristicPick.id
})
});
if (response.ok) {
const data = await response.json();
console.log(`[AutoPicker] ✅ Server AI Response: Pick ID ${data.pick}`);
const pickedCard = pack.find(c => c.id === data.pick);
return pickedCard || heuristicPick;
} else {
console.warn('[AutoPicker] ⚠️ Server AI Request failed, using heuristic.');
return heuristicPick;
}
} catch (err) {
console.error('[AutoPicker] ❌ Error contacting AI Server:', err);
return heuristicPick;
}
}
private static getRarityWeight(rarity?: string): number {
switch (rarity) {
case 'mythic': return 5;
case 'rare': return 4;
case 'uncommon': return 2;
default: return 1;
}
}
}