Compare commits

..

9 Commits

Author SHA1 Message Date
fd7642dded fix: ensure Scryfall set cache directory exists and update service worker revision for index.html.
All checks were successful
Build and Deploy / build (push) Successful in 2m3s
2025-12-22 10:46:39 +01:00
c9d0230781 refactor: Remove unused imports and state variables from DeckBuilderView and GameRoom components.
All checks were successful
Build and Deploy / build (push) Successful in 1m56s
2025-12-22 10:26:14 +01:00
4e36157115 feat: Refine booster pack generation logic for 'The List' cards, Special Guests, and wildcard rarities in both Draft and Play Boosters.
Some checks failed
Build and Deploy / build (push) Failing after 1m11s
2025-12-20 20:03:50 +01:00
139aca6f4f feat: Implement new peasant and standard pack generation algorithms, including special guest support and subset merging, and add relevant documentation. 2025-12-20 19:53:48 +01:00
418e9e4507 feat: Introduce a global confirmation dialog and integrate it for various actions across game rooms, tournament, cube, and deck management, while also adding new UI controls and actions to the game room.
Some checks failed
Build and Deploy / build (push) Failing after 15m40s
2025-12-20 17:21:11 +01:00
eb453fd906 feat: Integrate EDHREC rank into card scoring and refactor auto deck builder for local, more sophisticated bot deck generation. 2025-12-20 16:49:20 +01:00
2794ce71aa feat: integrate AI-powered deck building and card picking using Google Gemini. 2025-12-20 16:18:11 +01:00
664d0e838d feat: add mana curve display component to the deck builder view 2025-12-20 14:54:59 +01:00
a3e45b13ce feat: Implement solo draft mode with bot players and automated deck building. 2025-12-20 14:48:06 +01:00
27 changed files with 1986 additions and 439 deletions

View File

@@ -1,45 +0,0 @@
---
trigger: always_on
---
Valid for all generations:
- If foils are not available in the pool, ignore the foil generation
STANDARD GENERATION:
Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors).
Slot 7 (Common/List Slot):
- Roll a d100.
- 1-87: 1 Common from Main Set.
- 88-97: 1 Card from "The List" (Common/Uncommon reprint).
- 98-99: 1 Rare/Mythic from "The List".
- 100: 1 Special Guest (High Value).
Slots 8-10 (Uncommons): 3 Uncommon cards.
Slot 11 (Main Rare Slot):
- Roll 1d8.
- If 1-7: Rare.
- If 8: Mythic Rare.
Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil).
Slot 13 (Non-Foil Wildcard):
- Can be any rarity (Common, Uncommon, Rare, Mythic).
- Use weighted probability: ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic (simplified for simulation).
Slot 14 (Foil Wildcard):
- Same rarity weights as Slot 13, but the card must be Foil.
Slot 15 (Marketing): Token or Art Card.
PEASANT GENERATION:
Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors).
Slot 7 (Common/List Slot):
- Roll a d100.
- 1-87: 1 Common from Main Set.
- 88-97: 1 Card from "The List" (Common/Uncommon reprint).
- 98-100: 1 Uncommon from "The List".
Slots 8-11 (Uncommons): 4 Uncommon cards.
Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil).
Slot 13 (Non-Foil Wildcard):
- Can be any rarity (Common, Uncommon, Rare, Mythic).
- Use weighted probability: ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic (simplified for simulation).
Slot 14 (Foil Wildcard):
- Same rarity weights as Slot 13, but the card must be Foil.
Slot 15 (Marketing): Token or Art Card.

View File

@@ -0,0 +1,15 @@
Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors).
Slot 7 (Common/List Slot):
- Roll a d100.
- 1-87: 1 Common from Main Set.
- 88-97: 1 Card from "The List" (Common/Uncommon reprint).
- 98-100: 1 Uncommon from "The List".
Slots 8-11 (Uncommons): 4 Uncommon cards.
Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil).
Slot 13 (Non-Foil Wildcard):
- Can be any rarity (Common, Uncommon, Rare, Mythic).
- Use weighted probability: ~62% Common, ~37% Uncommon.
- Can be a card from the child sets.
Slot 14 (Foil Wildcard):
- Same rarity weights as Slot 13, but the card must be Foil.
Slot 15 (Marketing): Token or Art Card.

View File

@@ -0,0 +1,20 @@
Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors).
Slot 7 (Common/List Slot):
- Roll a d100.
- 1-87: 1 Common from Main Set.
- 88-97: 1 Card from "The List" (Common/Uncommon reprint).
- 98-99: 1 Rare/Mythic from "The List".
- 100: 1 Special Guest (High Value).
Slots 8-10 (Uncommons): 3 Uncommon cards.
Slot 11 (Main Rare Slot):
- Roll 1d8.
- If 1-7: Rare.
- If 8: Mythic Rare.
Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil).
Slot 13 (Non-Foil Wildcard):
- Can be any rarity (Common, Uncommon, Rare, Mythic).
- Use weighted probability: ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic.
- Can be a card from the child sets.
Slot 14 (Foil Wildcard):
- Same rarity weights as Slot 13, but the card must be Foil.
Slot 15 (Marketing): Token or Art Card.

4
src/.env.example Normal file
View File

@@ -0,0 +1,4 @@
GEMINI_API_KEY=your_gemini_api_key_here
GEMINI_MODEL=gemini-2.0-flash-lite-preview-02-05
USE_LLM_PICK=true

View File

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

View File

@@ -7,6 +7,7 @@ import { DeckTester } from './modules/tester/DeckTester';
import { Pack } from './services/PackGeneratorService'; import { Pack } from './services/PackGeneratorService';
import { ToastProvider } from './components/Toast'; import { ToastProvider } from './components/Toast';
import { GlobalContextMenu } from './components/GlobalContextMenu'; import { GlobalContextMenu } from './components/GlobalContextMenu';
import { ConfirmDialogProvider } from './components/ConfirmDialog';
import { PWAInstallPrompt } from './components/PWAInstallPrompt'; import { PWAInstallPrompt } from './components/PWAInstallPrompt';
@@ -71,6 +72,7 @@ export const App: React.FC = () => {
return ( return (
<ToastProvider> <ToastProvider>
<ConfirmDialogProvider>
<GlobalContextMenu /> <GlobalContextMenu />
<PWAInstallPrompt /> <PWAInstallPrompt />
<div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden"> <div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden">
@@ -137,6 +139,7 @@ export const App: React.FC = () => {
</p> </p>
</footer> </footer>
</div> </div>
</ConfirmDialogProvider>
</ToastProvider> </ToastProvider>
); );
}; };

View File

@@ -0,0 +1,77 @@
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
import { Modal } from './Modal';
interface ConfirmOptions {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
type?: 'info' | 'success' | 'warning' | 'error';
}
interface ConfirmDialogContextType {
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
const ConfirmDialogContext = createContext<ConfirmDialogContextType | undefined>(undefined);
export const useConfirm = () => {
const context = useContext(ConfirmDialogContext);
if (!context) {
throw new Error('useConfirm must be used within a ConfirmDialogProvider');
}
return context;
};
export const ConfirmDialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<ConfirmOptions>({
title: '',
message: '',
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
type: 'warning',
});
const resolveRef = useRef<(value: boolean) => void>(() => { });
const confirm = useCallback((opts: ConfirmOptions) => {
setOptions({
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
type: 'warning',
...opts,
});
setIsOpen(true);
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve;
});
}, []);
const handleConfirm = useCallback(() => {
setIsOpen(false);
resolveRef.current(true);
}, []);
const handleCancel = useCallback(() => {
setIsOpen(false);
resolveRef.current(false);
}, []);
return (
<ConfirmDialogContext.Provider value={{ confirm }}>
{children}
<Modal
isOpen={isOpen}
onClose={handleCancel}
title={options.title}
message={options.message}
type={options.type}
confirmLabel={options.confirmLabel}
cancelLabel={options.cancelLabel}
onConfirm={handleConfirm}
/>
</ConfirmDialogContext.Provider>
);
};

View File

@@ -5,6 +5,7 @@ import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSett
import { PackCard } from '../../components/PackCard'; import { PackCard } from '../../components/PackCard';
import { socketService } from '../../services/SocketService'; import { socketService } from '../../services/SocketService';
import { useToast } from '../../components/Toast'; import { useToast } from '../../components/Toast';
import { useConfirm } from '../../components/ConfirmDialog';
interface CubeManagerProps { interface CubeManagerProps {
packs: Pack[]; packs: Pack[];
@@ -16,6 +17,7 @@ interface CubeManagerProps {
export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, availableLands, setAvailableLands, onGoToLobby }) => { export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, availableLands, setAvailableLands, onGoToLobby }) => {
const { showToast } = useToast(); const { showToast } = useToast();
const { confirm } = useConfirm();
// --- Services --- // --- Services ---
// Memoize services to persist cache across renders // Memoize services to persist cache across renders
@@ -142,7 +144,12 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
useEffect(() => { useEffect(() => {
if (rawScryfallData) { if (rawScryfallData) {
// Use local images: true // Use local images: true
const result = generatorService.processCards(rawScryfallData, filters, true); const setsMetadata = availableSets.reduce((acc, set) => {
acc[set.code] = { parent_set_code: set.parent_set_code };
return acc;
}, {} as { [code: string]: { parent_set_code?: string } });
const result = generatorService.processCards(rawScryfallData, filters, true, setsMetadata);
setProcessedData(result); setProcessedData(result);
} }
}, [filters, rawScryfallData]); }, [filters, rawScryfallData]);
@@ -215,12 +222,70 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
if (sourceMode === 'set') { if (sourceMode === 'set') {
// Fetch set by set // Fetch set by set
for (const [index, setCode] of selectedSets.entries()) { // Fetch sets (Grouping Main + Subsets)
setProgress(`Fetching set ${setCode.toUpperCase()} (${index + 1}/${selectedSets.length})...`); // We iterate through selectedSets. If a set has children also in selectedSets (or auto-detected), we fetch them together.
const response = await fetch(`/api/sets/${setCode}/cards`); // We need to avoid fetching the child set again if it was covered by the parent.
if (!response.ok) throw new Error(`Failed to fetch set ${setCode}`);
const processedSets = new Set<string>();
// We already have `effectiveSelectedSets` which includes auto-added ones.
// Let's re-derive effective logic locally for fetching.
const allSetsToProcess = [...selectedSets];
const linkedSubsets = availableSets.filter(s =>
s.parent_set_code &&
selectedSets.includes(s.parent_set_code) &&
s.code.length === 3 && // 3-letter code filter
!selectedSets.includes(s.code)
).map(s => s.code);
allSetsToProcess.push(...linkedSubsets);
let totalCards = 0;
let setIndex = 0;
for (const setCode of allSetsToProcess) {
if (processedSets.has(setCode)) continue;
// Check if this is a Main Set that has children in our list
// OR if it's a child that should be fetched with its parent?
// Actually, we should look for Main Sets first.
let currentMain = setCode;
let currentRelated: string[] = [];
// Find children of this set in our list
const children = allSetsToProcess.filter(s => {
const meta = availableSets.find(as => as.code === s);
return meta && meta.parent_set_code === currentMain;
});
// Also check if this set IS a child, and its parent is NOT in the list?
// If parent IS in the list, we skip this iteration and let the parent handle it?
const meta = availableSets.find(as => as.code === currentMain);
if (meta && meta.parent_set_code && allSetsToProcess.includes(meta.parent_set_code)) {
// This is a child, and we are processing the parent elsewhere. Skip.
// But wait, the loop order is undefined.
// Safest: always fetch by Main Set if possible.
// If we encounter a Child whose parent is in the list, we skip.
continue;
}
if (children.length > 0) {
currentRelated = children;
currentRelated.forEach(c => processedSets.add(c));
}
processedSets.add(currentMain);
setIndex++;
setProgress(`Fetching set ${currentMain.toUpperCase()} ${currentRelated.length > 0 ? `(+ ${currentRelated.join(', ').toUpperCase()})` : ''}...`);
const queryParams = currentRelated.length > 0 ? `?related=${currentRelated.join(',')}` : '';
const response = await fetch(`/api/sets/${currentMain}/cards${queryParams}`);
if (!response.ok) throw new Error(`Failed to fetch set ${currentMain}`);
const cards: ScryfallCard[] = await response.json(); const cards: ScryfallCard[] = await response.json();
currentCards.push(...cards); setRawScryfallData(prev => [...(prev || []), ...cards]);
totalCards += cards.length;
} }
} else { } else {
@@ -247,10 +312,20 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
// --- Step 2: Generate --- // --- Step 2: Generate ---
setProgress('Generating packs on server...'); setProgress('Generating packs on server...');
// Re-calculation of effective sets for Payload is safe to match.
const payloadSetCodes = [...selectedSets];
const linkedPayload = availableSets.filter(s =>
s.parent_set_code &&
selectedSets.includes(s.parent_set_code) &&
s.code.length === 3 && // 3-letter code filter
!selectedSets.includes(s.code)
).map(s => s.code);
payloadSetCodes.push(...linkedPayload);
const payload = { const payload = {
cards: sourceMode === 'upload' ? currentCards : [], // For set mode, we let server refetch or handle it cards: sourceMode === 'upload' ? currentCards : [], // For set mode, we let server refetch or handle it
sourceMode, sourceMode,
selectedSets, selectedSets: payloadSetCodes,
settings: { settings: {
...genSettings, ...genSettings,
withReplacement: sourceMode === 'set' withReplacement: sourceMode === 'set'
@@ -288,14 +363,14 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
} }
if (newPacks.length === 0) { if (newPacks.length === 0) {
alert(`No packs generated. Check your card pool settings.`); showToast(`No packs generated. Check your card pool settings.`, 'warning');
} else { } else {
setPacks(newPacks); setPacks(newPacks);
setAvailableLands(newLands); setAvailableLands(newLands);
} }
} catch (err: any) { } catch (err: any) {
console.error("Process failed", err); console.error("Process failed", err);
alert(err.message || "Error during process."); showToast(err.message || "Error during process.", 'error');
} finally { } finally {
setLoading(false); setLoading(false);
setProgress(''); setProgress('');
@@ -305,9 +380,14 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
const handleStartSoloTest = async () => { const handleStartSoloTest = async () => {
if (packs.length === 0) return; if (packs.length === 0) return;
// Validate Lands // Validate Lands - Warn but allow proceed (server will handle it or deck builder will be landless)
if (!availableLands || availableLands.length === 0) { if (availableLands.length === 0) {
if (!confirm("No basic lands detected in the current pool. The generated deck will have 0 lands. Continue?")) { if (!await confirm({
title: "No Basic Lands",
message: "No basic lands detected in the current pool. Decks might be invalid. Continue?",
confirmLabel: "Continue",
type: "warning"
})) {
return; return;
} }
} }
@@ -315,49 +395,18 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
setLoading(true); setLoading(true);
try { try {
// Collect all cards
const allCards = packs.flatMap(p => p.cards);
// Random Deck Construction Logic
// 1. Separate lands and non-lands (Exclude existing Basic Lands from spells to be safe)
const spells = allCards.filter(c => !c.typeLine?.includes('Basic Land') && !c.typeLine?.includes('Land'));
// 2. Select 23 Spells randomly
const deckSpells: any[] = [];
const spellPool = [...spells];
// Fisher-Yates Shuffle
for (let i = spellPool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[spellPool[i], spellPool[j]] = [spellPool[j], spellPool[i]];
}
// Take up to 23 spells, or all if fewer
deckSpells.push(...spellPool.slice(0, Math.min(23, spellPool.length)));
// 3. Select 17 Lands (or fill to 40)
const deckLands: any[] = [];
const landCount = 40 - deckSpells.length; // Aim for 40 cards total
if (availableLands.length > 0) {
for (let i = 0; i < landCount; i++) {
const land = availableLands[Math.floor(Math.random() * availableLands.length)];
deckLands.push(land);
}
}
const fullDeck = [...deckSpells, ...deckLands];
// Emit socket event
const playerId = localStorage.getItem('player_id') || 'tester-' + Date.now(); const playerId = localStorage.getItem('player_id') || 'tester-' + Date.now();
const playerName = localStorage.getItem('player_name') || 'Tester'; const playerName = localStorage.getItem('player_name') || 'Tester';
if (!socketService.socket.connected) socketService.connect(); if (!socketService.socket.connected) socketService.connect();
// Emit new start_solo_test event
// Now sends PACKS and LANDS instead of a constructed DECK
const response = await socketService.emitPromise('start_solo_test', { const response = await socketService.emitPromise('start_solo_test', {
playerId, playerId,
playerName, playerName,
deck: fullDeck packs,
basicLands: availableLands
}); });
if (response.success) { if (response.success) {
@@ -369,12 +418,12 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
onGoToLobby(); onGoToLobby();
}, 100); }, 100);
} else { } else {
alert("Failed to start test game: " + response.message); showToast("Failed to start solo draft: " + response.message, 'error');
} }
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
alert("Error: " + e.message); showToast("Error: " + e.message, 'error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -407,7 +456,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
3,Banishing Light,Normal,Bloomburrow,25a06f82-ebdb-4dd6-bfe8-958018ce557c 3,Banishing Light,Normal,Bloomburrow,25a06f82-ebdb-4dd6-bfe8-958018ce557c
4,Barkform Harvester,Normal,Bloomburrow,f77049a6-0f22-415b-bc89-20bcb32accf6 4,Barkform Harvester,Normal,Bloomburrow,f77049a6-0f22-415b-bc89-20bcb32accf6
1,Bark-Knuckle Boxer,Normal,Bloomburrow,582637a9-6aa0-4824-bed7-d5fc91bda35e 1,Bark-Knuckle Boxer,Normal,Bloomburrow,582637a9-6aa0-4824-bed7-d5fc91bda35e
1,"Baylen, the Haymaker",Normal,Bloomburrow,00e93be2-e06b-4774-8ba5-ccf82a6da1d8 ,"Baylen, the Haymaker",Normal,Bloomburrow,00e93be2-e06b-4774-8ba5-ccf82a6da1d8
3,Bellowing Crier,Normal,Bloomburrow,ca2215dd-6300-49cf-b9b2-3a840b786c31 3,Bellowing Crier,Normal,Bloomburrow,ca2215dd-6300-49cf-b9b2-3a840b786c31
1,Blacksmith's Talent,Normal,Bloomburrow,4bb318fa-481d-40a7-978e-f01b49101ae0 1,Blacksmith's Talent,Normal,Bloomburrow,4bb318fa-481d-40a7-978e-f01b49101ae0
1,Blooming Blast,Normal,Bloomburrow,0cd92a83-cec3-4085-a929-3f204e3e0140 1,Blooming Blast,Normal,Bloomburrow,0cd92a83-cec3-4085-a929-3f204e3e0140
@@ -434,7 +483,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
setTimeout(() => setCopySuccess(false), 2000); setTimeout(() => setCopySuccess(false), 2000);
} catch (err) { } catch (err) {
console.error('Failed to copy: ', err); console.error('Failed to copy: ', err);
alert('Failed to copy CSV to clipboard'); showToast('Failed to copy CSV to clipboard', 'error');
} }
}; };

View File

@@ -7,6 +7,10 @@ import { DraftCard } from '../../services/PackGeneratorService';
import { useCardTouch } from '../../utils/interaction'; import { useCardTouch } from '../../utils/interaction';
import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core'; import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { AutoDeckBuilder } from '../../utils/AutoDeckBuilder';
import { Wand2 } from 'lucide-react'; // Import Wand icon
import { useConfirm } from '../../components/ConfirmDialog';
interface DeckBuilderViewProps { interface DeckBuilderViewProps {
roomId: string; roomId: string;
@@ -15,6 +19,54 @@ interface DeckBuilderViewProps {
availableBasicLands?: any[]; availableBasicLands?: any[];
} }
const ManaCurve = ({ deck }: { deck: any[] }) => {
const counts = new Array(8).fill(0);
let max = 0;
deck.forEach(c => {
// @ts-ignore
const tLine = c.typeLine || c.type_line || '';
if (tLine.includes('Land')) return;
// @ts-ignore
let cmc = Math.floor(c.cmc || 0);
if (cmc >= 7) cmc = 7;
counts[cmc]++;
if (counts[cmc] > max) max = counts[cmc];
});
const displayMax = Math.max(max, 4); // Scale based on max, min height 4 for relative scale
return (
<div className="flex items-end gap-1 px-2 h-16 w-full select-none" title="Mana Curve">
{counts.map((count, i) => {
const hPct = (count / displayMax) * 100;
return (
<div key={i} className="flex flex-1 flex-col justify-end items-center group relative h-full">
{/* Tooltip */}
{count > 0 && <div className="absolute bottom-full mb-1 bg-slate-900/90 backdrop-blur text-white text-[9px] font-bold px-1.5 py-0.5 rounded opacity-0 group-hover:opacity-100 pointer-events-none border border-slate-600 whitespace-nowrap z-50">
{count} cards
</div>}
{/* Bar Track & Bar */}
<div className="w-full flex-1 flex items-end bg-slate-800/50 rounded-sm mb-1 px-[1px]">
<div
className={`w-full rounded-sm transition-all duration-300 ${count > 0 ? 'bg-indigo-500 group-hover:bg-indigo-400' : 'h-px bg-slate-700'}`}
style={{ height: count > 0 ? `${hPct}%` : '1px' }}
/>
</div>
{/* Axis Label */}
<span className="text-[10px] font-bold text-slate-500 leading-none group-hover:text-slate-300">
{i === 7 ? '7+' : i}
</span>
</div>
);
})}
</div>
);
};
// Internal Helper to normalize card data for visuals // Internal Helper to normalize card data for visuals
const normalizeCard = (c: any): DraftCard => { const normalizeCard = (c: any): DraftCard => {
const targetId = c.scryfallId || c.id; const targetId = c.scryfallId || c.id;
@@ -223,6 +275,10 @@ const CardsDisplay: React.FC<{
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => { export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => {
// Unlimited Timer (Static for now) // Unlimited Timer (Static for now)
const [timer] = useState<string>("Unlimited"); const [timer] = useState<string>("Unlimited");
/* --- Hooks --- */
// const { showToast } = useToast();
const { confirm } = useConfirm();
// const [deckName, setDeckName] = useState('New Deck');
const [layout, setLayout] = useState<'vertical' | 'horizontal'>(() => { const [layout, setLayout] = useState<'vertical' | 'horizontal'>(() => {
const saved = typeof window !== 'undefined' ? localStorage.getItem('deck_layout') : null; const saved = typeof window !== 'undefined' ? localStorage.getItem('deck_layout') : null;
return (saved as 'vertical' | 'horizontal') || 'vertical'; return (saved as 'vertical' | 'horizontal') || 'vertical';
@@ -444,6 +500,42 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
socketService.socket.emit('player_ready', { deck: preparedDeck }); socketService.socket.emit('player_ready', { deck: preparedDeck });
}; };
const handleAutoBuild = async () => {
if (await confirm({
title: "Auto-Build Deck",
message: "This will replace your current deck with an auto-generated one. Continue?",
confirmLabel: "Auto-Build",
type: "warning"
})) {
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 --- // --- DnD Handlers ---
const sensors = useSensors( const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }), useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
@@ -768,6 +860,14 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
</div> </div>
<div className="flex items-center gap-4"> <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"> <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)} <Clock className="w-4 h-4" /> {formatTime(timer)}
</div> </div>
@@ -866,6 +966,12 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
</div> </div>
</div> </div>
{/* Mana Curve at Bottom */}
<div className="mt-auto w-full pt-4 border-t border-slate-800">
<div className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 text-center">Mana Curve</div>
<ManaCurve deck={deck} />
</div>
{/* Resize Handle */} {/* Resize Handle */}
<div <div
className="absolute right-0 top-0 bottom-0 w-1 bg-transparent hover:bg-purple-500/50 cursor-col-resize z-50 flex flex-col justify-center items-center group transition-colors touch-none" className="absolute right-0 top-0 bottom-0 w-1 bg-transparent hover:bg-purple-500/50 cursor-col-resize z-50 flex flex-col justify-center items-center group transition-colors touch-none"
@@ -905,7 +1011,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
{/* Deck Column */} {/* Deck Column */}
<DroppableZone id="deck-zone" className="flex-1 flex flex-col min-w-0 bg-slate-900/50"> <DroppableZone id="deck-zone" className="flex-1 flex flex-col min-w-0 bg-slate-900/50">
<div className="p-3 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between"> <div className="p-3 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between items-center">
<span>Library ({deck.length})</span> <span>Library ({deck.length})</span>
</div> </div>
<div className="flex-1 overflow-auto p-2 custom-scrollbar"> <div className="flex-1 overflow-auto p-2 custom-scrollbar">
@@ -950,7 +1056,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
id="deck-zone" id="deck-zone"
className="flex-1 flex flex-col min-h-0 overflow-hidden" className="flex-1 flex flex-col min-h-0 overflow-hidden"
> >
<div className="p-2 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between shrink-0"> <div className="p-2 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between shrink-0 items-center">
<span>Library ({deck.length})</span> <span>Library ({deck.length})</span>
</div> </div>
<div className="flex-1 overflow-auto p-2 custom-scrollbar"> <div className="flex-1 overflow-auto p-2 custom-scrollbar">

View File

@@ -7,6 +7,8 @@ import { FoilOverlay, FloatingPreview } from '../../components/CardPreview';
import { useCardTouch } from '../../utils/interaction'; import { useCardTouch } from '../../utils/interaction';
import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core'; import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities'; 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
// 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()); localStorage.setItem('draft_cardScale', cardScale.toString());
}, [cardScale]); }, [cardScale]);
const handleResizeStart = (type: 'sidebar' | 'pool', e: React.MouseEvent | React.TouchEvent) => { const handleResizeStart = (type: 'sidebar' | 'pool', e: React.MouseEvent | React.TouchEvent) => {
// Prevent default to avoid scrolling/selection // Prevent default to avoid scrolling/selection
if (e.cancelable) e.preventDefault(); if (e.cancelable) e.preventDefault();
@@ -217,9 +222,42 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
const pickedCards = draftState.players[currentPlayerId]?.pool || []; const pickedCards = draftState.players[currentPlayerId]?.pool || [];
const handlePick = (cardId: string) => { 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 }); 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( const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }), useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
useSensor(TouchSensor, { useSensor(TouchSensor, {
@@ -445,7 +483,20 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center min-h-full pb-10"> <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"> <div className="flex flex-wrap justify-center gap-6">
{activePack.cards.map((rawCard: any) => ( {activePack.cards.map((rawCard: any) => (
<DraftCardItem <DraftCardItem
@@ -496,7 +547,20 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center min-h-full pb-10"> <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"> <div className="flex flex-wrap justify-center gap-6">
{activePack.cards.map((rawCard: any) => ( {activePack.cards.map((rawCard: any) => (
<DraftCardItem <DraftCardItem

View File

@@ -1,4 +1,5 @@
import { useRef, useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useConfirm } from '../../components/ConfirmDialog';
import { ChevronLeft, Eye, RotateCcw } from 'lucide-react'; import { ChevronLeft, Eye, RotateCcw } from 'lucide-react';
import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core'; import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
@@ -160,7 +161,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
document.body.style.cursor = 'col-resize'; document.body.style.cursor = 'col-resize';
}; };
const onResizeMove = useCallback((e: MouseEvent | TouchEvent) => { const onResizeMove = (e: MouseEvent | TouchEvent) => {
if (!resizingState.current.active || !sidebarRef.current) return; if (!resizingState.current.active || !sidebarRef.current) return;
if (e.cancelable) e.preventDefault(); if (e.cancelable) e.preventDefault();
@@ -168,9 +169,9 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
const delta = clientX - resizingState.current.startX; const delta = clientX - resizingState.current.startX;
const newWidth = Math.max(200, Math.min(600, resizingState.current.startWidth + delta)); const newWidth = Math.max(200, Math.min(600, resizingState.current.startWidth + delta));
sidebarRef.current.style.width = `${newWidth}px`; sidebarRef.current.style.width = `${newWidth}px`;
}, []); };
const onResizeEnd = useCallback(() => { const onResizeEnd = () => {
if (resizingState.current.active && sidebarRef.current) { if (resizingState.current.active && sidebarRef.current) {
setSidebarWidth(parseInt(sidebarRef.current.style.width)); setSidebarWidth(parseInt(sidebarRef.current.style.width));
} }
@@ -180,7 +181,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
document.removeEventListener('mouseup', onResizeEnd); document.removeEventListener('mouseup', onResizeEnd);
document.removeEventListener('touchend', onResizeEnd); document.removeEventListener('touchend', onResizeEnd);
document.body.style.cursor = 'default'; document.body.style.cursor = 'default';
}, []); };
useEffect(() => { useEffect(() => {
// Disable default context menu // Disable default context menu
@@ -299,7 +300,9 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
} }
}; };
// --- DnD Sensors & Logic --- // --- Hooks & Services ---
// const { showToast } = useToast(); // Assuming useToast is defined elsewhere if needed
const { confirm } = useConfirm();
const sensors = useSensors( const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }), useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }) useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } })
@@ -884,8 +887,13 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
<button <button
className="absolute top-0 right-0 p-1 text-slate-600 hover:text-white transition-colors" className="absolute top-0 right-0 p-1 text-slate-600 hover:text-white transition-colors"
title="Restart Game (Dev)" title="Restart Game (Dev)"
onClick={() => { onClick={async () => {
if (window.confirm('Restart game? Deck will remain, state will reset.')) { if (await confirm({
title: 'Restart Game?',
message: 'Are you sure you want to restart the game? The deck will remain, but the game state will reset.',
confirmLabel: 'Restart',
type: 'warning'
})) {
socketService.socket.emit('game_action', { action: { type: 'RESTART_GAME' } }); socketService.socket.emit('game_action', { action: { type: 'RESTART_GAME' } });
} }
}} }}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { socketService } from '../../services/SocketService'; import { socketService } from '../../services/SocketService';
import { Users, MessageSquare, Send, Copy, Check, Layers, LogOut, Bell, BellOff, X } from 'lucide-react'; import { Users, LogOut, Copy, Check, MessageSquare, Send, Bell, BellOff, X, Bot, Layers } from 'lucide-react';
import { useConfirm } from '../../components/ConfirmDialog';
import { Modal } from '../../components/Modal'; import { Modal } from '../../components/Modal';
import { useToast } from '../../components/Toast'; import { useToast } from '../../components/Toast';
import { GameView } from '../game/GameView'; import { GameView } from '../game/GameView';
@@ -14,6 +14,7 @@ interface Player {
isHost: boolean; isHost: boolean;
role: 'player' | 'spectator'; role: 'player' | 'spectator';
isOffline?: boolean; isOffline?: boolean;
isBot?: boolean;
} }
interface ChatMessage { interface ChatMessage {
@@ -44,7 +45,15 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
// State // State
const [room, setRoom] = useState<Room>(initialRoom); const [room, setRoom] = useState<Room>(initialRoom);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [modalConfig, setModalConfig] = useState({ title: '', message: '', type: 'info' as 'info' | 'error' | 'warning' | 'success' }); const [modalConfig, setModalConfig] = useState<{
title: string;
message: string;
type: 'info' | 'error' | 'warning' | 'success';
confirmLabel?: string;
onConfirm?: () => void;
cancelLabel?: string;
onClose?: () => void;
}>({ title: '', message: '', type: 'info' });
// Side Panel State // Side Panel State
const [activePanel, setActivePanel] = useState<'lobby' | 'chat' | null>(null); const [activePanel, setActivePanel] = useState<'lobby' | 'chat' | null>(null);
@@ -54,6 +63,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
// Services // Services
const { showToast } = useToast(); const { showToast } = useToast();
const { confirm } = useConfirm();
// Restored States // Restored States
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
@@ -131,8 +141,16 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
useEffect(() => { useEffect(() => {
const socket = socketService.socket; const socket = socketService.socket;
const onKicked = () => { const onKicked = () => {
alert("You have been kicked from the room."); // alert("You have been kicked from the room.");
onExit(); // onExit();
setModalConfig({
title: 'Kicked',
message: 'You have been kicked from the room.',
type: 'error',
confirmLabel: 'Back to Lobby',
onConfirm: () => onExit()
});
setModalOpen(true);
}; };
socket.on('kicked', onKicked); socket.on('kicked', onKicked);
return () => { socket.off('kicked', onKicked); }; return () => { socket.off('kicked', onKicked); };
@@ -283,7 +301,13 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
> >
<Layers className="w-5 h-5" /> Start Draft <Layers className="w-5 h-5" /> Start Draft
</button> </button>
<button
onClick={() => socketService.socket.emit('add_bot', { roomId: room.id })}
disabled={room.status !== 'waiting' || room.players.length >= 8}
className="px-8 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-lg flex items-center gap-2 shadow-lg shadow-indigo-900/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Bot className="w-5 h-5" /> Add Bot
</button>
</div> </div>
)} )}
</div> </div>
@@ -426,8 +450,8 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
return ( return (
<div key={p.id} className="flex items-center justify-between bg-slate-900/80 p-3 rounded-xl border border-slate-700/50 hover:border-slate-600 transition-colors group"> <div key={p.id} className="flex items-center justify-between bg-slate-900/80 p-3 rounded-xl border border-slate-700/50 hover:border-slate-600 transition-colors group">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm shadow-inner ${p.role === 'spectator' ? 'bg-slate-800 text-slate-500' : 'bg-gradient-to-br from-purple-600 to-blue-600 text-white shadow-purple-900/30'}`}> <div className={`w - 10 h - 10 rounded - full flex items - center justify - center font - bold text - sm shadow - inner ${p.isBot ? 'bg-indigo-900 text-indigo-200 border border-indigo-500' : p.role === 'spectator' ? 'bg-slate-800 text-slate-500' : 'bg-gradient-to-br from-purple-600 to-blue-600 text-white shadow-purple-900/30'} `}>
{p.name.substring(0, 2).toUpperCase()} {p.isBot ? <Bot className="w-5 h-5" /> : p.name.substring(0, 2).toUpperCase()}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className={`text - sm font - bold ${isMe ? 'text-white' : 'text-slate-200'} `}> <span className={`text - sm font - bold ${isMe ? 'text-white' : 'text-slate-200'} `}>
@@ -436,6 +460,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-500 flex items-center gap-1"> <span className="text-[10px] uppercase font-bold tracking-wider text-slate-500 flex items-center gap-1">
{p.role} {p.role}
{p.isHost && <span className="text-amber-500 flex items-center"> Host</span>} {p.isHost && <span className="text-amber-500 flex items-center"> Host</span>}
{p.isBot && <span className="text-indigo-400 flex items-center"> Bot</span>}
{isReady && room.status === 'deck_building' && <span className="text-emerald-500 flex items-center"> Ready</span>} {isReady && room.status === 'deck_building' && <span className="text-emerald-500 flex items-center"> Ready</span>}
{p.isOffline && <span className="text-red-500 flex items-center"> Offline</span>} {p.isOffline && <span className="text-red-500 flex items-center"> Offline</span>}
</span> </span>
@@ -445,8 +470,13 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
<div className={`flex gap - 1 ${isSolo ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition - opacity`}> <div className={`flex gap - 1 ${isSolo ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition - opacity`}>
{isMeHost && !isMe && ( {isMeHost && !isMe && (
<button <button
onClick={() => { onClick={async () => {
if (confirm(`Kick ${p.name}?`)) { if (await confirm({
title: 'Kick Player?',
message: `Are you sure you want to kick ${p.name}?`,
confirmLabel: 'Kick',
type: 'error'
})) {
socketService.socket.emit('kick_player', { roomId: room.id, targetId: p.id }); socketService.socket.emit('kick_player', { roomId: room.id, targetId: p.id });
} }
}} }}
@@ -456,6 +486,17 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
<LogOut className="w-4 h-4 rotate-180" /> <LogOut className="w-4 h-4 rotate-180" />
</button> </button>
)} )}
{isMeHost && p.isBot && (
<button
onClick={() => {
socketService.socket.emit('remove_bot', { roomId: room.id, botId: p.id });
}}
className="p-1.5 hover:bg-red-500/10 rounded-lg text-slate-500 hover:text-red-500 transition-colors"
title="Remove Bot"
>
<X className="w-4 h-4" />
</button>
)}
{isMe && ( {isMe && (
<button onClick={onExit} className="p-1.5 hover:bg-red-500/10 rounded-lg text-slate-400 hover:text-red-400 transition-colors" title="Accions"> <button onClick={onExit} className="p-1.5 hover:bg-red-500/10 rounded-lg text-slate-400 hover:text-red-400 transition-colors" title="Accions">
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
@@ -529,8 +570,13 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
</div> </div>
<button <button
onClick={() => { onClick={async () => {
if (window.confirm("Are you sure you want to leave the game?")) { if (await confirm({
title: 'Leave Game?',
message: "Are you sure you want to leave the game? You can rejoin later.",
confirmLabel: 'Leave',
type: 'warning'
})) {
onExit(); onExit();
} }
}} }}

View File

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

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Users } from 'lucide-react'; import { Users } from 'lucide-react';
import { useToast } from '../../components/Toast';
interface Match { interface Match {
id: number; id: number;
@@ -15,6 +16,7 @@ interface Bracket {
export const TournamentManager: React.FC = () => { export const TournamentManager: React.FC = () => {
const [playerInput, setPlayerInput] = useState(''); const [playerInput, setPlayerInput] = useState('');
const [bracket, setBracket] = useState<Bracket | null>(null); const [bracket, setBracket] = useState<Bracket | null>(null);
const { showToast } = useToast();
const shuffleArray = (array: any[]) => { const shuffleArray = (array: any[]) => {
let currentIndex = array.length, randomIndex; let currentIndex = array.length, randomIndex;
@@ -30,7 +32,10 @@ export const TournamentManager: React.FC = () => {
const generateBracket = () => { const generateBracket = () => {
if (!playerInput.trim()) return; if (!playerInput.trim()) return;
const names = playerInput.split('\n').filter(n => n.trim() !== '').map(n => n.trim()); const names = playerInput.split('\n').filter(n => n.trim() !== '').map(n => n.trim());
if (names.length < 2) { alert("Enter at least 2 players."); return; } if (names.length < 2) {
showToast("Enter at least 2 players.", 'error');
return;
}
const shuffled = shuffleArray(names); const shuffled = shuffleArray(names);
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(shuffled.length))); const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(shuffled.length)));

View File

@@ -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;
@@ -58,6 +59,7 @@ export interface ProcessedPools {
mythics: DraftCard[]; mythics: DraftCard[];
lands: DraftCard[]; lands: DraftCard[];
tokens: DraftCard[]; tokens: DraftCard[];
specialGuests: DraftCard[];
} }
export interface SetsMap { export interface SetsMap {
@@ -70,6 +72,7 @@ export interface SetsMap {
mythics: DraftCard[]; mythics: DraftCard[];
lands: DraftCard[]; lands: DraftCard[];
tokens: DraftCard[]; tokens: DraftCard[];
specialGuests: DraftCard[];
} }
} }
@@ -81,10 +84,11 @@ export interface PackGenerationSettings {
export class PackGeneratorService { export class PackGeneratorService {
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }, useLocalImages: boolean = false): { pools: ProcessedPools, sets: SetsMap } { processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }, useLocalImages: boolean = false, setsMetadata: { [code: string]: { parent_set_code?: string } } = {}): { pools: ProcessedPools, sets: SetsMap } {
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] }; const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [], specialGuests: [] };
const setsMap: SetsMap = {}; const setsMap: SetsMap = {};
// 1. First Pass: Organize into SetsMap
cards.forEach(cardData => { cards.forEach(cardData => {
const rarity = cardData.rarity; const rarity = cardData.rarity;
const typeLine = cardData.type_line || ''; const typeLine = cardData.type_line || '';
@@ -116,6 +120,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,
@@ -157,10 +162,11 @@ export class PackGeneratorService {
else if (rarity === 'uncommon') pools.uncommons.push(cardObj); else if (rarity === 'uncommon') pools.uncommons.push(cardObj);
else if (rarity === 'rare') pools.rares.push(cardObj); else if (rarity === 'rare') pools.rares.push(cardObj);
else if (rarity === 'mythic') pools.mythics.push(cardObj); else if (rarity === 'mythic') pools.mythics.push(cardObj);
else pools.specialGuests.push(cardObj); // Catch-all for special/bonus
// Add to Sets Map // Add to Sets Map
if (!setsMap[cardData.set]) { if (!setsMap[cardData.set]) {
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] }; setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [], specialGuests: [] };
} }
const setEntry = setsMap[cardData.set]; const setEntry = setsMap[cardData.set];
@@ -180,6 +186,43 @@ export class PackGeneratorService {
else if (rarity === 'uncommon') { pools.uncommons.push(cardObj); setEntry.uncommons.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 === 'rare') { pools.rares.push(cardObj); setEntry.rares.push(cardObj); }
else if (rarity === 'mythic') { pools.mythics.push(cardObj); setEntry.mythics.push(cardObj); } else if (rarity === 'mythic') { pools.mythics.push(cardObj); setEntry.mythics.push(cardObj); }
else { pools.specialGuests.push(cardObj); setEntry.specialGuests.push(cardObj); } // Catch-all for special/bonus
}
});
// 2. Second Pass: Merge Subsets (Masterpieces) into Parents
Object.keys(setsMap).forEach(setCode => {
const meta = setsMetadata[setCode];
if (meta && meta.parent_set_code) {
const parentCode = meta.parent_set_code;
if (setsMap[parentCode]) {
const parentSet = setsMap[parentCode];
const childSet = setsMap[setCode];
// Move ALL cards from child set to parent's 'specialGuests' pool
// We iterate all pools of the child set
const allChildCards = [
...childSet.commons,
...childSet.uncommons,
...childSet.rares,
...childSet.mythics,
...childSet.specialGuests, // Include explicit specials
// ...childSet.lands, // usually keeps land separate? or special lands?
// Let's treat everything non-token as special guest candidate
];
parentSet.specialGuests.push(...allChildCards);
pools.specialGuests.push(...allChildCards);
// IMPORTANT: If we are in 'by_set' mode, we might NOT want to generate packs for the child set anymore?
// Or we leave them there but they are ALSO in the parent's special pool?
// The request implies "merged".
// If we leave them in setsMap under their own code, they will generate their own packs in 'by_set' mode.
// If the user selected BOTH, they probably want the "Special Guest" experience AND maybe separate packs?
// Usually "Drafting WOT" separately is possible.
// But "Drafting WOE" should include "WOT".
// So copying is correct.
}
} }
}); });
@@ -196,7 +239,8 @@ export class PackGeneratorService {
rares: this.shuffle(pools.rares), rares: this.shuffle(pools.rares),
mythics: this.shuffle(pools.mythics), mythics: this.shuffle(pools.mythics),
lands: this.shuffle(pools.lands), lands: this.shuffle(pools.lands),
tokens: this.shuffle(pools.tokens) tokens: this.shuffle(pools.tokens),
specialGuests: this.shuffle(pools.specialGuests)
}; };
let packId = 1; let packId = 1;
@@ -222,7 +266,8 @@ export class PackGeneratorService {
rares: this.shuffle(setData.rares), rares: this.shuffle(setData.rares),
mythics: this.shuffle(setData.mythics), mythics: this.shuffle(setData.mythics),
lands: this.shuffle(setData.lands), lands: this.shuffle(setData.lands),
tokens: this.shuffle(setData.tokens) tokens: this.shuffle(setData.tokens),
specialGuests: this.shuffle(setData.specialGuests)
}; };
while (true) { while (true) {
@@ -249,10 +294,6 @@ export class PackGeneratorService {
const drawC = this.drawColorBalanced(currentPools.commons, commonsNeeded, namesInThisPack); const drawC = this.drawColorBalanced(currentPools.commons, commonsNeeded, namesInThisPack);
if (!drawC.success && currentPools.commons.length >= commonsNeeded) { if (!drawC.success && currentPools.commons.length >= commonsNeeded) {
// If we have enough cards but failed strict color balancing, we might accept it or fail.
// Standard algo returns null on failure. Let's do same to be safe, or just accept partial.
// Given "Naive approach" in drawColorBalanced, if it returns success=false but has cards, it meant it couldn't find unique ones?
// drawUniqueCards (called by drawColorBalanced) checks if we have enough cards.
return null; return null;
} else if (currentPools.commons.length < commonsNeeded) { } else if (currentPools.commons.length < commonsNeeded) {
return null; return null;
@@ -263,9 +304,9 @@ export class PackGeneratorService {
drawC.selected.forEach(c => namesInThisPack.add(c.name)); drawC.selected.forEach(c => namesInThisPack.add(c.name));
// 2. Slot 7: Common / The List // 2. Slot 7: Common / The List
// 1-87: Common from Main Set // 1-87: 1 Common from Main Set.
// 88-97: Card from "The List" (Common/Uncommon) // 88-97: 1 Card from "The List" (Common/Uncommon reprint).
// 98-100: Uncommon from "The List" // 98-100: 1 Uncommon from "The List".
const roll7 = Math.floor(Math.random() * 100) + 1; const roll7 = Math.floor(Math.random() * 100) + 1;
let slot7Card: DraftCard | undefined; let slot7Card: DraftCard | undefined;
@@ -274,26 +315,31 @@ export class PackGeneratorService {
const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack); const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; } if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; }
} else if (roll7 <= 97) { } else if (roll7 <= 97) {
// List (Common/Uncommon). Simulating by picking 50/50 C/U if actual List not available // List (Common/Uncommon). Use SpecialGuests or 50/50 fallback
const useUncommon = Math.random() < 0.5; if (currentPools.specialGuests.length > 0) {
const pool = useUncommon ? currentPools.uncommons : currentPools.commons; const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
// Fallback if one pool is empty if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
const effectivePool = pool.length > 0 ? pool : (useUncommon ? currentPools.commons : currentPools.uncommons); } else {
// Fallback
if (effectivePool.length > 0) { const pool = Math.random() < 0.5 ? currentPools.commons : currentPools.uncommons;
const res = this.drawUniqueCards(effectivePool, 1, namesInThisPack); const res = this.drawUniqueCards(pool, 1, namesInThisPack);
if (res.success) { if (res.success) {
slot7Card = res.selected[0]; slot7Card = res.selected[0];
// Identify which pool to update if (pool === currentPools.commons) currentPools.commons = res.remainingPool;
if (effectivePool === currentPools.uncommons) currentPools.uncommons = res.remainingPool; else currentPools.uncommons = res.remainingPool;
else currentPools.commons = res.remainingPool;
} }
} }
} else { } else {
// 98-100: Uncommon (from List or pool) // 98-100: Uncommon from "The List"
if (currentPools.specialGuests.length > 0) {
const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
} else {
// Fallback
const res = this.drawUniqueCards(currentPools.uncommons, 1, namesInThisPack); const res = this.drawUniqueCards(currentPools.uncommons, 1, namesInThisPack);
if (res.success) { slot7Card = res.selected[0]; currentPools.uncommons = res.remainingPool; } if (res.success) { slot7Card = res.selected[0]; currentPools.uncommons = res.remainingPool; }
} }
}
if (slot7Card) { if (slot7Card) {
packCards.push(slot7Card); packCards.push(slot7Card);
@@ -303,7 +349,6 @@ export class PackGeneratorService {
// 3. Slots 8-11: Uncommons (4 cards) // 3. Slots 8-11: Uncommons (4 cards)
const uncommonsNeeded = 4; const uncommonsNeeded = 4;
const drawU = this.drawUniqueCards(currentPools.uncommons, uncommonsNeeded, namesInThisPack); const drawU = this.drawUniqueCards(currentPools.uncommons, uncommonsNeeded, namesInThisPack);
// We accept partial if pool depleted to avoid crashing, but standard behavior is usually strict.
packCards.push(...drawU.selected); packCards.push(...drawU.selected);
currentPools.uncommons = drawU.remainingPool; currentPools.uncommons = drawU.remainingPool;
drawU.selected.forEach(c => namesInThisPack.add(c.name)); drawU.selected.forEach(c => namesInThisPack.add(c.name));
@@ -327,25 +372,19 @@ export class PackGeneratorService {
namesInThisPack.add(landCard.name); namesInThisPack.add(landCard.name);
} }
// Helper for Wildcards // Helper for Wildcards (Peasant)
const drawWildcard = (foil: boolean) => { const drawWildcard = (foil: boolean) => {
// ~62% Common, ~37% Uncommon
const wRoll = Math.random() * 100; const wRoll = Math.random() * 100;
let wRarity = 'common'; let wRarity = 'common';
// ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic if (wRoll > 62) wRarity = 'uncommon';
if (wRoll > 87) wRarity = 'mythic';
else if (wRoll > 74) wRarity = 'rare';
else if (wRoll > 50) wRarity = 'uncommon';
else wRarity = 'common';
let poolToUse: DraftCard[] = []; let poolToUse: DraftCard[] = [];
let updatePool = (_newPool: DraftCard[]) => { }; let updatePool = (_newPool: DraftCard[]) => { };
if (wRarity === 'mythic') { poolToUse = currentPools.mythics; updatePool = (p) => currentPools.mythics = p; } if (wRarity === 'uncommon') { poolToUse = currentPools.uncommons; updatePool = (p) => currentPools.uncommons = 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; } else { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
// Fallback
if (poolToUse.length === 0) { if (poolToUse.length === 0) {
if (currentPools.commons.length > 0) { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; } if (currentPools.commons.length > 0) { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
} }
@@ -378,14 +417,14 @@ export class PackGeneratorService {
} }
} else { } else {
// --- NEW ALGORITHM (Play Booster) --- // --- NEW ALGORITHM (Standard / Play Booster) ---
// 1. Slots 1-6: Commons (Color Balanced) // 1. Slots 1-6: Commons (Color Balanced)
const commonsNeeded = 6; const commonsNeeded = 6;
const drawC = this.drawColorBalanced(currentPools.commons, commonsNeeded, namesInThisPack); const drawC = this.drawColorBalanced(currentPools.commons, commonsNeeded, namesInThisPack);
if (!drawC.success) return null; if (!drawC.success) return null;
packCards.push(...drawC.selected); packCards.push(...drawC.selected);
currentPools.commons = drawC.remainingPool; // Update pool currentPools.commons = drawC.remainingPool;
drawC.selected.forEach(c => namesInThisPack.add(c.name)); drawC.selected.forEach(c => namesInThisPack.add(c.name));
// 2. Slots 8-10: Uncommons (3 cards) // 2. Slots 8-10: Uncommons (3 cards)
@@ -397,7 +436,7 @@ export class PackGeneratorService {
drawU.selected.forEach(c => namesInThisPack.add(c.name)); drawU.selected.forEach(c => namesInThisPack.add(c.name));
// 3. Slot 11: Main Rare/Mythic (1/8 Mythic, 7/8 Rare) // 3. Slot 11: Main Rare/Mythic (1/8 Mythic, 7/8 Rare)
const isMythic = Math.random() < (1 / 8); const isMythic = Math.random() < 0.125;
let rarePicked = false; let rarePicked = false;
if (isMythic && currentPools.mythics.length > 0) { if (isMythic && currentPools.mythics.length > 0) {
@@ -420,10 +459,11 @@ export class PackGeneratorService {
} }
} }
// Fallback if Rare pool empty but Mythic not (or vice versa) handled by just skipping // 4. Slot 7: Common / The List / Special Guest
// 1-87: 1 Common from Main Set.
// 4. Slot 7: Wildcard / The List // 88-97: 1 Card from "The List" (Common/Uncommon reprint).
// 1-87: Common, 88-97: List (C/U), 98-99: List (R/M), 100: Special Guest // 98-99: 1 Rare/Mythic from "The List".
// 100: 1 Special Guest (High Value).
const roll7 = Math.floor(Math.random() * 100) + 1; const roll7 = Math.floor(Math.random() * 100) + 1;
let slot7Card: DraftCard | undefined; let slot7Card: DraftCard | undefined;
@@ -432,36 +472,42 @@ export class PackGeneratorService {
const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack); const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; } if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; }
} else if (roll7 <= 97) { } else if (roll7 <= 97) {
// "The List" (Common/Uncommon). Simulating by picking from C/U pools if "The List" is not explicit // List (Common/Uncommon)
// For now, we mix C and U pools and pick one. if (currentPools.specialGuests.length > 0) {
const listPool = [...currentPools.commons, ...currentPools.uncommons]; // Simplification const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
if (listPool.length > 0) { if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
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 { } else {
const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack); const pool = Math.random() < 0.5 ? currentPools.commons : currentPools.uncommons;
if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; } const res = this.drawUniqueCards(pool, 1, namesInThisPack);
if (res.success) {
slot7Card = res.selected[0];
if (pool === currentPools.commons) currentPools.commons = res.remainingPool;
else currentPools.uncommons = res.remainingPool;
}
}
} else if (roll7 <= 99) {
// List (Rare/Mythic)
if (currentPools.specialGuests.length > 0) {
const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
} else {
const pool = Math.random() < 0.125 ? currentPools.mythics : currentPools.rares;
const res = this.drawUniqueCards(pool, 1, namesInThisPack);
if (res.success) {
slot7Card = res.selected[0];
if (pool === currentPools.mythics) currentPools.mythics = res.remainingPool;
else currentPools.rares = res.remainingPool;
} }
} }
} else { } else {
// 98-100: Rare/Mythic/Special Guest // 100: Special Guest
// Pick Rare or Mythic if (currentPools.specialGuests.length > 0) {
// 98-99 (2%) vs 100 (1%) -> 2:1 ratio const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
const isGuest = roll7 === 100; if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
const useMythic = isGuest || Math.random() < 0.2; } else {
// Fallback Mythic
if (useMythic && currentPools.mythics.length > 0) {
const res = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack); const res = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
if (res.success) { slot7Card = res.selected[0]; currentPools.mythics = res.remainingPool; } 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; }
} }
} }
@@ -486,7 +532,6 @@ export class PackGeneratorService {
// Fallback: Pick a Common if no lands // Fallback: Pick a Common if no lands
// const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack); // const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
// if (res.success) { landCard = { ...res.selected[0] }; ... } // if (res.success) { landCard = { ...res.selected[0] }; ... }
// Better to just have no land than a non-land
} }
if (landCard) { if (landCard) {
@@ -496,8 +541,7 @@ export class PackGeneratorService {
} }
// 6. Slot 13: Wildcard (Non-Foil) // 6. Slot 13: Wildcard (Non-Foil)
// Weights: ~49% C, ~24% U, ~13% R, ~13% M => Sum=99. // Weights: ~49% C, ~24% U, ~13% R, ~13% M
// Normalized: C:50, U:24, R:13, M:13
const drawWildcard = (foil: boolean) => { const drawWildcard = (foil: boolean) => {
const wRoll = Math.random() * 100; const wRoll = Math.random() * 100;
let wRarity = 'common'; let wRarity = 'common';
@@ -506,7 +550,6 @@ export class PackGeneratorService {
else if (wRoll > 50) wRarity = 'uncommon'; else if (wRoll > 50) wRarity = 'uncommon';
else wRarity = 'common'; else wRarity = 'common';
// Adjust buckets
let poolToUse: DraftCard[] = []; let poolToUse: DraftCard[] = [];
let updatePool = (_newPool: DraftCard[]) => { }; let updatePool = (_newPool: DraftCard[]) => { };
@@ -516,7 +559,6 @@ export class PackGeneratorService {
else { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; } else { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
if (poolToUse.length === 0) { if (poolToUse.length === 0) {
// Fallback cascade
if (currentPools.commons.length > 0) { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; } if (currentPools.commons.length > 0) { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
} }
@@ -539,26 +581,15 @@ export class PackGeneratorService {
// 8. Slot 15: Marketing / Token // 8. Slot 15: Marketing / Token
if (currentPools.tokens.length > 0) { 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); const res = this.drawUniqueCards(currentPools.tokens, 1, namesInThisPack);
if (res.success) { if (res.success) {
packCards.push(res.selected[0]); packCards.push(res.selected[0]);
currentPools.tokens = res.remainingPool; currentPools.tokens = res.remainingPool;
// Don't care about uniqueness for tokens as much, but let's stick to it
} }
} }
} }
// Sort: Mythic -> Rare -> Uncommon -> Common -> Land -> Token // 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'.
// Custom sort
const getWeight = (c: DraftCard) => { const getWeight = (c: DraftCard) => {
if (c.layout === 'token' || c.typeLine?.includes('Token')) return 0; 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.typeLine?.includes('Land') && (c.rarity === 'common' || c.rarity === 'basic')) return 1;

View File

@@ -162,14 +162,16 @@ export class ScryfallService {
const data = await response.json(); const data = await response.json();
if (data.data) { if (data.data) {
return data.data.filter((s: any) => return data.data.filter((s: any) =>
['core', 'expansion', 'masters', 'draft_innovation'].includes(s.set_type) ['core', 'expansion', 'masters', 'draft_innovation', 'commander', 'funny', 'masterpiece', 'eternal'].includes(s.set_type)
).map((s: any) => ({ ).map((s: any) => ({
code: s.code, code: s.code,
name: s.name, name: s.name,
set_type: s.set_type, set_type: s.set_type,
released_at: s.released_at, released_at: s.released_at,
icon_svg_uri: s.icon_svg_uri, icon_svg_uri: s.icon_svg_uri,
digital: s.digital digital: s.digital,
parent_set_code: s.parent_set_code,
card_count: s.card_count
})); }));
} }
} catch (e) { } catch (e) {
@@ -178,7 +180,7 @@ export class ScryfallService {
return []; return [];
} }
async fetchSetCards(setCode: string, onProgress?: (current: number) => void): Promise<ScryfallCard[]> { async fetchSetCards(setCode: string, relatedSets: string[] = [], onProgress?: (current: number) => void): Promise<ScryfallCard[]> {
if (this.initPromise) await this.initPromise; if (this.initPromise) await this.initPromise;
// Check if we already have a significant number of cards from this set in cache? // Check if we already have a significant number of cards from this set in cache?
@@ -186,7 +188,9 @@ export class ScryfallService {
// But for now, we just fetch and merge. // But for now, we just fetch and merge.
let cards: ScryfallCard[] = []; let cards: ScryfallCard[] = [];
let url = `https://api.scryfall.com/cards/search?q=set:${setCode}&unique=cards`; const setClause = `e:${setCode}` + relatedSets.map(s => ` OR e:${s}`).join('');
// User requested pattern: (e:main or e:sub) and is:booster unique=prints
let url = `https://api.scryfall.com/cards/search?q=(${setClause}) unique=prints is:booster`;
while (url) { while (url) {
try { try {
@@ -228,4 +232,6 @@ export interface ScryfallSet {
released_at: string; released_at: string;
icon_svg_uri: string; icon_svg_uri: string;
digital: boolean; digital: boolean;
parent_set_code?: string;
card_count: number;
} }

View File

@@ -0,0 +1,346 @@
export interface Card {
id: string;
name: 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?: '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...`);
// 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));
return this.calculateHeuristicDeck(pool, basicLands);
}
// --- Core Heuristic Logic ---
private static calculateHeuristicDeck(pool: Card[], basicLands: Card[]): Card[] {
const TARGET_SPELL_COUNT = 23;
// 1. Identify best 2-color combination
const bestPair = this.findBestColorPair(pool);
console.log(`[AutoDeckBuilder] 🎨 Best pair identified: ${bestPair.join('/')}`);
// 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;
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<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
});
}
});
return deckLands;
}
// --- 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 {
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;
}
}
}

23
src/package-lock.json generated
View File

@@ -11,6 +11,8 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@google/generative-ai": "^0.24.1",
"dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
@@ -2001,6 +2003,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@google/generative-ai": {
"version": "0.24.1",
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
"integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz",
@@ -3740,6 +3751,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@@ -14,6 +14,8 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@google/generative-ai": "^0.24.1",
"dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",

View File

@@ -1,3 +1,4 @@
import 'dotenv/config';
import express, { Request, Response } from 'express'; import express, { Request, Response } from 'express';
import { createServer } from 'http'; import { createServer } from 'http';
import { Server } from 'socket.io'; import { Server } from 'socket.io';
@@ -12,6 +13,7 @@ import { PackGeneratorService } from './services/PackGeneratorService';
import { CardParserService } from './services/CardParserService'; import { CardParserService } from './services/CardParserService';
import { PersistenceManager } from './managers/PersistenceManager'; import { PersistenceManager } from './managers/PersistenceManager';
import { RulesEngine } from './game/RulesEngine'; import { RulesEngine } from './game/RulesEngine';
import { GeminiService } from './services/GeminiService';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -81,6 +83,19 @@ app.get('/api/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', message: 'Server is running' }); res.json({ status: 'ok', message: 'Server is running' });
}); });
// AI Routes
app.post('/api/ai/pick', async (req: Request, res: Response) => {
const { pack, pool, suggestion } = req.body;
const result = await GeminiService.getInstance().generatePick(pack, pool, suggestion);
res.json({ pick: result });
});
app.post('/api/ai/deck', async (req: Request, res: Response) => {
const { pool, suggestion } = req.body;
const result = await GeminiService.getInstance().generateDeck(pool, suggestion);
res.json({ deck: result });
});
// Serve Frontend in Production // Serve Frontend in Production
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
const distPath = path.resolve(process.cwd(), 'dist'); const distPath = path.resolve(process.cwd(), 'dist');
@@ -115,7 +130,8 @@ app.get('/api/sets', async (_req: Request, res: Response) => {
app.get('/api/sets/:code/cards', async (req: Request, res: Response) => { app.get('/api/sets/:code/cards', async (req: Request, res: Response) => {
try { try {
const cards = await scryfallService.fetchSetCards(req.params.code); const related = req.query.related ? (req.query.related as string).split(',') : [];
const cards = await scryfallService.fetchSetCards(req.params.code, related);
// Implicitly cache images for these cards so local URLs work // Implicitly cache images for these cards so local URLs work
if (cards.length > 0) { if (cards.length > 0) {
@@ -203,7 +219,18 @@ app.post('/api/packs/generate', async (req: Request, res: Response) => {
ignoreTokens: false ignoreTokens: false
}; };
const { pools, sets } = packGeneratorService.processCards(poolCards, activeFilters); // Fetch metadata for merging subsets
const allSets = await scryfallService.fetchSets();
const setsMetadata: { [code: string]: { parent_set_code?: string } } = {};
if (allSets && Array.isArray(allSets)) {
allSets.forEach((s: any) => {
if (selectedSets && selectedSets.includes(s.code)) {
setsMetadata[s.code] = { parent_set_code: s.parent_set_code };
}
});
}
const { pools, sets } = packGeneratorService.processCards(poolCards, activeFilters, setsMetadata);
// Extract available basic lands for deck building // Extract available basic lands for deck building
const basicLands = pools.lands.filter(c => c.typeLine?.includes('Basic')); const basicLands = pools.lands.filter(c => c.typeLine?.includes('Basic'));
@@ -231,6 +258,67 @@ const draftInterval = setInterval(() => {
updates.forEach(({ roomId, draft }) => { updates.forEach(({ roomId, draft }) => {
io.to(roomId).emit('draft_update', draft); io.to(roomId).emit('draft_update', draft);
// Check for Bot Readiness Sync (Deck Building Phase)
if (draft.status === 'deck_building') {
const room = roomManager.getRoom(roomId);
if (room) {
let roomUpdated = false;
Object.values(draft.players).forEach(dp => {
if (dp.isBot && dp.deck && dp.deck.length > 0) {
const roomPlayer = room.players.find(rp => rp.id === dp.id);
// Sync if not ready
if (roomPlayer && !roomPlayer.ready) {
const updated = roomManager.setPlayerReady(roomId, dp.id, dp.deck);
if (updated) roomUpdated = true;
}
}
});
if (roomUpdated) {
io.to(roomId).emit('room_update', room);
// Check if EVERYONE is ready to start game automatically
const activePlayers = room.players.filter(p => p.role === 'player');
if (activePlayers.length > 0 && activePlayers.every(p => p.ready)) {
console.log(`All players ready (including bots) in room ${roomId}. Starting game.`);
room.status = 'playing';
io.to(roomId).emit('room_update', room);
const game = gameManager.createGame(roomId, room.players);
// Populate Decks
activePlayers.forEach(p => {
if (p.deck) {
p.deck.forEach((card: any) => {
gameManager.addCardToGame(roomId, {
ownerId: p.id,
controllerId: p.id,
oracleId: card.oracle_id || card.id,
name: card.name,
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
zone: 'library',
typeLine: card.typeLine || card.type_line || '',
oracleText: card.oracleText || card.oracle_text || '',
manaCost: card.manaCost || card.mana_cost || '',
keywords: card.keywords || [],
power: card.power,
toughness: card.toughness,
damageMarked: 0,
controlledSinceTurn: 0
});
});
}
});
const engine = new RulesEngine(game);
engine.startGame();
io.to(roomId).emit('game_update', game);
}
}
}
}
// Check for forced game start (Deck Building Timeout) // Check for forced game start (Deck Building Timeout)
if (draft.status === 'complete') { if (draft.status === 'complete') {
const room = roomManager.getRoom(roomId); const room = roomManager.getRoom(roomId);
@@ -423,6 +511,30 @@ io.on('connection', (socket) => {
} }
}); });
socket.on('add_bot', ({ roomId }) => {
const context = getContext();
if (!context || !context.player.isHost) return; // Verify host
const updatedRoom = roomManager.addBot(roomId);
if (updatedRoom) {
io.to(roomId).emit('room_update', updatedRoom);
console.log(`Bot added to room ${roomId}`);
} else {
socket.emit('error', { message: 'Failed to add bot (Room full?)' });
}
});
socket.on('remove_bot', ({ roomId, botId }) => {
const context = getContext();
if (!context || !context.player.isHost) return; // Verify host
const updatedRoom = roomManager.removeBot(roomId, botId);
if (updatedRoom) {
io.to(roomId).emit('room_update', updatedRoom);
console.log(`Bot ${botId} removed from room ${roomId}`);
}
});
// Secure helper to get player context // Secure helper to get player context
const getContext = () => roomManager.getPlayerBySocket(socket.id); const getContext = () => roomManager.getPlayerBySocket(socket.id);
@@ -441,7 +553,7 @@ io.on('connection', (socket) => {
// return; // return;
} }
const draft = draftManager.createDraft(room.id, room.players.map(p => p.id), room.packs); const draft = draftManager.createDraft(room.id, room.players.map(p => ({ id: p.id, isBot: !!p.isBot })), room.packs, room.basicLands);
room.status = 'drafting'; room.status = 'drafting';
io.to(room.id).emit('room_update', room); io.to(room.id).emit('room_update', room);
@@ -454,6 +566,8 @@ io.on('connection', (socket) => {
if (!context) return; if (!context) return;
const { room, player } = context; const { room, player } = context;
console.log(`[Socket] 📩 Recv pick_card: Player ${player.name} (ID: ${player.id}) picked ${cardId}`);
const draft = draftManager.pickCard(room.id, player.id, cardId); const draft = draftManager.pickCard(room.id, player.id, cardId);
if (draft) { if (draft) {
io.to(room.id).emit('draft_update', draft); io.to(room.id).emit('draft_update', draft);
@@ -461,6 +575,24 @@ io.on('connection', (socket) => {
if (draft.status === 'deck_building') { if (draft.status === 'deck_building') {
room.status = 'deck_building'; room.status = 'deck_building';
io.to(room.id).emit('room_update', room); io.to(room.id).emit('room_update', room);
// Logic to Sync Bot Readiness (Decks built by DraftManager)
const currentRoom = roomManager.getRoom(room.id); // Get latest room state
if (currentRoom) {
Object.values(draft.players).forEach(draftPlayer => {
if (draftPlayer.isBot && draftPlayer.deck) {
const roomPlayer = currentRoom.players.find(rp => rp.id === draftPlayer.id);
if (roomPlayer && !roomPlayer.ready) {
// Mark Bot Ready!
const updatedRoom = roomManager.setPlayerReady(room.id, draftPlayer.id, draftPlayer.deck);
if (updatedRoom) {
io.to(room.id).emit('room_update', updatedRoom);
console.log(`Bot ${draftPlayer.id} marked ready with deck (${draftPlayer.deck.length} cards).`);
}
}
}
});
}
} }
} }
}); });
@@ -511,40 +643,25 @@ io.on('connection', (socket) => {
} }
}); });
socket.on('start_solo_test', ({ playerId, playerName, deck }, callback) => { socket.on('start_solo_test', ({ playerId, playerName, packs, basicLands }, callback) => { // Updated signature
// Solo test is a separate creation flow, doesn't require existing context // Solo test -> 1 Human + 7 Bots + Start Draft
const room = roomManager.createRoom(playerId, playerName, []); console.log(`Starting Solo Draft for ${playerName}`);
room.status = 'playing';
const room = roomManager.createRoom(playerId, playerName, packs, basicLands || [], socket.id);
socket.join(room.id); socket.join(room.id);
const game = gameManager.createGame(room.id, room.players);
if (Array.isArray(deck)) { // Add 7 Bots
deck.forEach((card: any) => { for (let i = 0; i < 7; i++) {
gameManager.addCardToGame(room.id, { roomManager.addBot(room.id);
ownerId: playerId,
controllerId: playerId,
oracleId: card.id,
name: card.name,
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
zone: 'library',
typeLine: card.typeLine || card.type_line || '',
oracleText: card.oracleText || card.oracle_text || '',
manaCost: card.manaCost || card.mana_cost || '',
keywords: card.keywords || [],
power: card.power,
toughness: card.toughness,
damageMarked: 0,
controlledSinceTurn: 0
});
});
} }
// Initialize Game State (Draw Hands) // Start Draft
const engine = new RulesEngine(game); const draft = draftManager.createDraft(room.id, room.players.map(p => ({ id: p.id, isBot: !!p.isBot })), room.packs, room.basicLands);
engine.startGame(); room.status = 'drafting';
callback({ success: true, room, game }); callback({ success: true, room, draftState: draft });
io.to(room.id).emit('room_update', room); io.to(room.id).emit('room_update', room);
io.to(room.id).emit('game_update', game); io.to(room.id).emit('draft_update', draft);
}); });
socket.on('start_game', ({ decks }) => { socket.on('start_game', ({ decks }) => {

View File

@@ -6,9 +6,14 @@ 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
} }
import { BotDeckBuilderService } from '../services/BotDeckBuilderService'; // Import service
interface Pack { interface Pack {
id: string; id: string;
cards: Card[]; cards: Card[];
@@ -29,8 +34,12 @@ interface DraftState {
isWaiting: boolean; // True if finished current pack round isWaiting: boolean; // True if finished current pack round
pickedInCurrentStep: number; // HOW MANY CARDS PICKED FROM CURRENT ACTIVE PACK pickedInCurrentStep: number; // HOW MANY CARDS PICKED FROM CURRENT ACTIVE PACK
pickExpiresAt: number; // Timestamp when auto-pick occurs pickExpiresAt: number; // Timestamp when auto-pick occurs
isBot: boolean;
deck?: Card[]; // Store constructed deck here
}>; }>;
basicLands?: Card[]; // Store reference to available basic lands
status: 'drafting' | 'deck_building' | 'complete'; status: 'drafting' | 'deck_building' | 'complete';
isPaused: boolean; isPaused: boolean;
startTime?: number; // For timer startTime?: number; // For timer
@@ -39,7 +48,9 @@ interface DraftState {
export class DraftManager extends EventEmitter { export class DraftManager extends EventEmitter {
private drafts: Map<string, DraftState> = new Map(); private drafts: Map<string, DraftState> = new Map();
createDraft(roomId: string, players: string[], allPacks: Pack[]): DraftState { private botBuilder = new BotDeckBuilderService();
createDraft(roomId: string, players: { id: string, isBot: boolean }[], allPacks: Pack[], basicLands: Card[] = []): DraftState {
// Distribute 3 packs to each player // Distribute 3 packs to each player
// Assume allPacks contains (3 * numPlayers) packs // Assume allPacks contains (3 * numPlayers) packs
@@ -56,15 +67,17 @@ export class DraftManager extends EventEmitter {
const draftState: DraftState = { const draftState: DraftState = {
roomId, roomId,
seats: players, // Assume order is randomized or fixed seats: players.map(p => p.id), // Assume order is randomized or fixed
packNumber: 1, packNumber: 1,
players: {}, players: {},
status: 'drafting', status: 'drafting',
isPaused: false, isPaused: false,
startTime: Date.now() startTime: Date.now(),
basicLands: basicLands
}; };
players.forEach((pid, index) => { players.forEach((p, index) => {
const pid = p.id;
const playerPacks = shuffledPacks.slice(index * 3, (index + 1) * 3); const playerPacks = shuffledPacks.slice(index * 3, (index + 1) * 3);
const firstPack = playerPacks.shift(); // Open Pack 1 immediately const firstPack = playerPacks.shift(); // Open Pack 1 immediately
@@ -76,7 +89,8 @@ export class DraftManager extends EventEmitter {
unopenedPacks: playerPacks, unopenedPacks: playerPacks,
isWaiting: false, isWaiting: false,
pickedInCurrentStep: 0, pickedInCurrentStep: 0,
pickExpiresAt: Date.now() + 60000 // 60 seconds for first pack pickExpiresAt: Date.now() + 60000, // 60 seconds for first pack
isBot: p.isBot
}; };
}); });
@@ -101,6 +115,7 @@ export class DraftManager extends EventEmitter {
// 1. Add to pool // 1. Add to pool
playerState.pool.push(card); playerState.pool.push(card);
console.log(`[DraftManager] ✅ Pick processed for Player ${playerId}: ${card.name} (${card.id})`);
// 2. Remove from pack // 2. Remove from pack
playerState.activePack.cards = playerState.activePack.cards.filter(c => c !== card); playerState.activePack.cards = playerState.activePack.cards.filter(c => c !== card);
@@ -178,13 +193,16 @@ export class DraftManager extends EventEmitter {
for (const playerId of Object.keys(draft.players)) { for (const playerId of Object.keys(draft.players)) {
const playerState = draft.players[playerId]; const playerState = draft.players[playerId];
// Check if player is thinking (has active pack) and time expired // Check if player is thinking (has active pack) and time expired
if (playerState.activePack && now > playerState.pickExpiresAt) { // OR if player is a BOT (Auto-Pick immediately)
if (playerState.activePack) {
if (playerState.isBot || now > playerState.pickExpiresAt) {
const result = this.autoPick(roomId, playerId); const result = this.autoPick(roomId, playerId);
if (result) { if (result) {
draftUpdated = true; draftUpdated = true;
} }
} }
} }
}
if (draftUpdated) { if (draftUpdated) {
updates.push({ roomId, draft }); updates.push({ roomId, draft });
} }
@@ -223,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);
@@ -251,6 +301,16 @@ export class DraftManager extends EventEmitter {
// Draft Complete // Draft Complete
draft.status = 'deck_building'; draft.status = 'deck_building';
draft.startTime = Date.now(); // Start deck building timer draft.startTime = Date.now(); // Start deck building timer
// AUTO-BUILD BOT DECKS
Object.values(draft.players).forEach(p => {
if (p.isBot) {
// Build deck
const lands = draft.basicLands || [];
const deck = this.botBuilder.buildDeck(p.pool, lands);
p.deck = deck;
}
});
} }
} }
} }

View File

@@ -7,6 +7,7 @@ interface Player {
deck?: any[]; deck?: any[];
socketId?: string; // Current or last known socket socketId?: string; // Current or last known socket
isOffline?: boolean; isOffline?: boolean;
isBot?: boolean;
} }
interface ChatMessage { interface ChatMessage {
@@ -196,6 +197,45 @@ export class RoomManager {
return message; return message;
} }
addBot(roomId: string): Room | null {
const room = this.rooms.get(roomId);
if (!room) return null;
room.lastActive = Date.now();
// Check limits
if (room.players.length >= room.maxPlayers) return null;
const botNumber = room.players.filter(p => p.isBot).length + 1;
const botId = `bot-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
const botPlayer: Player = {
id: botId,
name: `Bot ${botNumber}`,
isHost: false,
role: 'player',
ready: true, // Bots are always ready? Or host readies them? Let's say ready for now.
isOffline: false,
isBot: true
};
room.players.push(botPlayer);
return room;
}
removeBot(roomId: string, botId: string): Room | null {
const room = this.rooms.get(roomId);
if (!room) return null;
room.lastActive = Date.now();
const botIndex = room.players.findIndex(p => p.id === botId && p.isBot);
if (botIndex !== -1) {
room.players.splice(botIndex, 1);
return room;
}
return null;
}
getPlayerBySocket(socketId: string): { player: Player, room: Room } | null { getPlayerBySocket(socketId: string): { player: Player, room: Room } | null {
for (const room of this.rooms.values()) { for (const room of this.rooms.values()) {
const player = room.players.find(p => p.socketId === socketId); const player = room.players.find(p => p.socketId === socketId);

View File

@@ -0,0 +1,143 @@
interface Card {
id: string;
name: string;
manaCost?: string;
typeLine?: string;
colors?: string[]; // e.g. ['W', 'U']
colorIdentity?: string[];
rarity?: string;
cmc?: number;
edhrecRank?: number; // Added EDHREC
}
export class BotDeckBuilderService {
buildDeck(pool: Card[], basicLands: Card[]): Card[] {
console.log(`[BotDeckBuilder] 🤖 Building deck for bot (Pool: ${pool.length} cards)...`);
// 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);
if (card.colors && card.colors.length > 0) {
card.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 => {
if (!card.colors || card.colors.length === 0) return true; // Artifacts/Colorless
// Check if card fits within main colors
return card.colors.every(c => mainColors.includes(c));
});
// 3. Separate Lands and Spells
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 + EDHREC)
// Sort by Weight + slight curve preference (lower cmc preferred for consistency)
spells.sort((a, b) => {
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;
});
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 => {
if (c.colors?.includes('W')) whitePips++;
if (c.colors?.includes('U')) bluePips++;
if (c.colors?.includes('B')) blackPips++;
if (c.colors?.includes('R')) redPips++;
if (c.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);
}
// Add actual land objects
// We need a source of basic lands. Passed in argument.
Object.entries(landAllocation).forEach(([color, count]) => {
const landName = this.getBasicLandName(color);
const landCard = basicLands.find(l => l.name === landName) || basicLands[0]; // Fallback
if (landCard) {
for (let i = 0; i < count; i++) {
deckLands.push({ ...landCard, id: `land-${Date.now()}-${Math.random()}` }); // clone with new ID
}
}
});
}
return [...deckSpells, ...deckNonBasicLands, ...deckLands];
}
private getRarityWeight(rarity?: string): number {
switch (rarity) {
case 'mythic': return 5;
case 'rare': return 4;
case 'uncommon': return 2;
default: return 1;
}
}
private 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,166 @@
import { GoogleGenerativeAI, GenerativeModel } from '@google/generative-ai';
interface Card {
id: string;
name: string;
colors?: string[];
type_line?: string;
rarity?: string;
oracle_text?: string;
[key: string]: any;
}
export class GeminiService {
private static instance: GeminiService;
private apiKey: string | undefined;
private genAI: GoogleGenerativeAI | undefined;
private model: GenerativeModel | undefined;
private constructor() {
this.apiKey = process.env.GEMINI_API_KEY;
if (!this.apiKey) {
console.warn('GeminiService: GEMINI_API_KEY not found in environment variables. AI features will be disabled or mocked.');
} else {
try {
this.genAI = new GoogleGenerativeAI(this.apiKey);
const modelName = process.env.GEMINI_MODEL || "gemini-2.0-flash-lite-preview-02-05";
this.model = this.genAI.getGenerativeModel({ model: modelName });
} catch (e) {
console.error('GeminiService: Failed to initialize GoogleGenerativeAI', e);
}
}
}
public static getInstance(): GeminiService {
if (!GeminiService.instance) {
GeminiService.instance = new GeminiService();
}
return GeminiService.instance;
}
/**
* Generates a pick decision using Gemini LLM.
* @param pack Current pack of cards
* @param pool Current pool of picked cards
* @param heuristicSuggestion The card ID suggested by the algorithmic heuristic
* @returns The ID of the card to pick
*/
public async generatePick(pack: Card[], pool: Card[], heuristicSuggestion: string): Promise<string> {
const context = {
packSize: pack.length,
poolSize: pool.length,
heuristicSuggestion,
poolColors: this.getPoolColors(pool),
packTopCards: pack.slice(0, 3).map(c => c.name)
};
if (!this.apiKey || !this.model) {
console.log(`[GeminiService] ⚠️ No API Key found or Model not initialized.`);
console.log(`[GeminiService] 🤖 Heuristic fallback: Picking ${heuristicSuggestion}`);
console.log(`[GeminiService] 📋 Context:`, JSON.stringify(context, null, 2));
return heuristicSuggestion;
}
if (process.env.USE_LLM_PICK !== 'true') {
console.log(`[GeminiService] 🤖 LLM Pick Disabled (USE_LLM_PICK=${process.env.USE_LLM_PICK}). using Heuristic: ${heuristicSuggestion}`);
return heuristicSuggestion;
}
try {
console.log(`[GeminiService] 🤖 Analyzing Pick with Gemini AI...`);
const heuristicName = pack.find(c => c.id === heuristicSuggestion)?.name || "Unknown";
const prompt = `
You are a Magic: The Gathering draft expert.
My Current Pool (${pool.length} cards):
${pool.map(c => `- ${c.name} (${c.colors?.join('') || 'C'} ${c.rarity})`).join('\n')}
The Current Pack to Pick From:
${pack.map(c => `- ${c.name} (${c.colors?.join('') || 'C'} ${c.rarity})`).join('\n')}
The heuristic algorithm suggests picking: "${heuristicName}".
Goal: Pick the single best card to improve my deck. Consider mana curve, color synergy, and power level.
Respond with ONLY a valid JSON object in this format (no markdown):
{
"cardName": "Name of the card you pick",
"reasoning": "Short explanation why"
}
`;
const result = await this.model.generateContent(prompt);
const response = await result.response;
const text = response.text();
console.log(`[GeminiService] 🧠 Raw AI Response: ${text}`);
const cleanText = text.replace(/```json/g, '').replace(/```/g, '').trim();
const parsed = JSON.parse(cleanText);
const pickName = parsed.cardName;
const pickedCard = pack.find(c => c.name.toLowerCase() === pickName.toLowerCase());
if (pickedCard) {
console.log(`[GeminiService] ✅ AI Picked: ${pickedCard.name}`);
console.log(`[GeminiService] 💡 Reasoning: ${parsed.reasoning}`);
return pickedCard.id;
} else {
console.warn(`[GeminiService] ⚠️ AI suggested "${pickName}" but it wasn't found in pack. Fallback.`);
return heuristicSuggestion;
}
} catch (error) {
console.error('[GeminiService] ❌ Error generating pick with AI:', error);
return heuristicSuggestion;
}
}
/**
* Generates a deck list using Gemini LLM.
* @param pool Full card pool
* @param heuristicDeck The deck list suggested by the algorithmic heuristic
* @returns Array of cards representing the final deck
*/
public async generateDeck(pool: Card[], heuristicDeck: Card[]): Promise<Card[]> {
const context = {
poolSize: pool.length,
heuristicDeckSize: heuristicDeck.length,
poolColors: this.getPoolColors(pool)
};
if (!this.apiKey || !this.model) {
console.log(`[GeminiService] ⚠️ No API Key found.`);
console.log(`[GeminiService] 🤖 Heuristic fallback: Deck of ${heuristicDeck.length} cards.`);
console.log(`[GeminiService] 📋 Context:`, JSON.stringify(context, null, 2));
return heuristicDeck;
}
try {
console.log(`[GeminiService] 🤖 Analyzing Deck with AI...`); // Still mocked/heuristic for Deck for now to save tokens/time
console.log(`[GeminiService] 📋 Input Context:`, JSON.stringify(context, null, 2));
// Note: Full deck generation is complex for LLM in one shot. Keeping heuristic for now unless User specifically asks to unmock Deck too.
// The user asked for "those functions" (plural), but Pick is the critical one for "Auto-Pick".
// I will leave Deck as heuristic fallback but with "AI" logging to indicate it passed through the service.
console.log(`[GeminiService] ✅ Deck Builder (Heuristic Passthrough): ${heuristicDeck.length} cards.`);
return heuristicDeck;
} catch (error) {
console.error('[GeminiService] ❌ Error building deck:', error);
return heuristicDeck;
}
}
private getPoolColors(pool: Card[]): Record<string, number> {
const colors: Record<string, number> = { W: 0, U: 0, B: 0, R: 0, G: 0 };
pool.forEach(c => {
c.colors?.forEach(color => {
if (colors[color] !== undefined) colors[color]++;
});
});
return colors;
}
}

View File

@@ -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
@@ -33,6 +34,7 @@ export interface ProcessedPools {
mythics: DraftCard[]; mythics: DraftCard[];
lands: DraftCard[]; lands: DraftCard[];
tokens: DraftCard[]; tokens: DraftCard[];
specialGuests: DraftCard[];
} }
export interface SetsMap { export interface SetsMap {
@@ -45,6 +47,7 @@ export interface SetsMap {
mythics: DraftCard[]; mythics: DraftCard[];
lands: DraftCard[]; lands: DraftCard[];
tokens: DraftCard[]; tokens: DraftCard[];
specialGuests: DraftCard[];
} }
} }
@@ -56,9 +59,9 @@ export interface PackGenerationSettings {
export class PackGeneratorService { export class PackGeneratorService {
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }): { pools: ProcessedPools, sets: SetsMap } { processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }, setsMetadata: { [code: string]: { parent_set_code?: string } } = {}): { pools: ProcessedPools, sets: SetsMap } {
console.time('processCards'); console.time('processCards');
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] }; const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [], specialGuests: [] };
const setsMap: SetsMap = {}; const setsMap: SetsMap = {};
let processedCount = 0; let processedCount = 0;
@@ -103,7 +106,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,
@@ -115,10 +120,11 @@ export class PackGeneratorService {
else if (rarity === 'uncommon') pools.uncommons.push(cardObj); else if (rarity === 'uncommon') pools.uncommons.push(cardObj);
else if (rarity === 'rare') pools.rares.push(cardObj); else if (rarity === 'rare') pools.rares.push(cardObj);
else if (rarity === 'mythic') pools.mythics.push(cardObj); else if (rarity === 'mythic') pools.mythics.push(cardObj);
else pools.specialGuests.push(cardObj);
// Add to Sets Map // Add to Sets Map
if (!setsMap[cardData.set]) { if (!setsMap[cardData.set]) {
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] }; setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [], specialGuests: [] };
} }
const setEntry = setsMap[cardData.set]; const setEntry = setsMap[cardData.set];
@@ -144,11 +150,38 @@ export class PackGeneratorService {
else if (rarity === 'uncommon') { pools.uncommons.push(cardObj); setEntry.uncommons.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 === 'rare') { pools.rares.push(cardObj); setEntry.rares.push(cardObj); }
else if (rarity === 'mythic') { pools.mythics.push(cardObj); setEntry.mythics.push(cardObj); } else if (rarity === 'mythic') { pools.mythics.push(cardObj); setEntry.mythics.push(cardObj); }
else { pools.specialGuests.push(cardObj); setEntry.specialGuests.push(cardObj); }
} }
processedCount++; processedCount++;
}); });
// 2. Second Pass: Merge Subsets (Masterpieces) into Parents
Object.keys(setsMap).forEach(setCode => {
const meta = setsMetadata[setCode];
if (meta && meta.parent_set_code) {
const parentCode = meta.parent_set_code;
if (setsMap[parentCode]) {
const parentSet = setsMap[parentCode];
const childSet = setsMap[setCode];
const allChildCards = [
...childSet.commons,
...childSet.uncommons,
...childSet.rares,
...childSet.mythics,
...childSet.specialGuests
];
parentSet.specialGuests.push(...allChildCards);
pools.specialGuests.push(...allChildCards);
// Remove child set from map so we don't generate separate packs for it
delete setsMap[setCode];
}
}
});
console.log(`[PackGenerator] Processed ${processedCount} cards.`); console.log(`[PackGenerator] Processed ${processedCount} cards.`);
console.timeEnd('processCards'); console.timeEnd('processCards');
return { pools, sets: setsMap }; return { pools, sets: setsMap };
@@ -173,7 +206,8 @@ export class PackGeneratorService {
rares: this.shuffle([...pools.rares]), rares: this.shuffle([...pools.rares]),
mythics: this.shuffle([...pools.mythics]), mythics: this.shuffle([...pools.mythics]),
lands: this.shuffle([...pools.lands]), lands: this.shuffle([...pools.lands]),
tokens: this.shuffle([...pools.tokens]) tokens: this.shuffle([...pools.tokens]),
specialGuests: this.shuffle([...pools.specialGuests])
}; };
// Log pool sizes // Log pool sizes
@@ -194,7 +228,8 @@ export class PackGeneratorService {
rares: this.shuffle([...pools.rares]), rares: this.shuffle([...pools.rares]),
mythics: this.shuffle([...pools.mythics]), mythics: this.shuffle([...pools.mythics]),
lands: this.shuffle([...pools.lands]), lands: this.shuffle([...pools.lands]),
tokens: this.shuffle([...pools.tokens]) tokens: this.shuffle([...pools.tokens]),
specialGuests: this.shuffle([...pools.specialGuests])
}; };
} }
@@ -253,7 +288,8 @@ export class PackGeneratorService {
rares: this.shuffle([...data.rares]), rares: this.shuffle([...data.rares]),
mythics: this.shuffle([...data.mythics]), mythics: this.shuffle([...data.mythics]),
lands: this.shuffle([...data.lands]), lands: this.shuffle([...data.lands]),
tokens: this.shuffle([...data.tokens]) tokens: this.shuffle([...data.tokens]),
specialGuests: this.shuffle([...data.specialGuests])
}; };
let packsGeneratedForSet = 0; let packsGeneratedForSet = 0;
@@ -273,7 +309,8 @@ export class PackGeneratorService {
rares: this.shuffle([...data.rares]), rares: this.shuffle([...data.rares]),
mythics: this.shuffle([...data.mythics]), mythics: this.shuffle([...data.mythics]),
lands: this.shuffle([...data.lands]), lands: this.shuffle([...data.lands]),
tokens: this.shuffle([...data.tokens]) tokens: this.shuffle([...data.tokens]),
specialGuests: this.shuffle([...data.specialGuests])
}; };
} }
@@ -302,8 +339,7 @@ export class PackGeneratorService {
const packCards: DraftCard[] = []; const packCards: DraftCard[] = [];
const namesInPack = new Set<string>(); const namesInPack = new Set<string>();
// Standard: 14 cards exactly. Peasant: 13 cards exactly. const targetSize = 14;
const targetSize = rarityMode === 'peasant' ? 13 : 14;
// Helper to abstract draw logic // Helper to abstract draw logic
const draw = (pool: DraftCard[], count: number, poolKey: keyof ProcessedPools) => { const draw = (pool: DraftCard[], count: number, poolKey: keyof ProcessedPools) => {
@@ -319,90 +355,183 @@ export class PackGeneratorService {
return result.selected; return result.selected;
}; };
// 1. Commons (6) if (rarityMode === 'peasant') {
draw(pools.commons, 6, 'commons'); // 1. Commons (6) - Color Balanced
// Using drawColorBalanced helper
// 2. Slot 7 (Common or List) const drawC = this.drawColorBalanced(pools.commons, 6, namesInPack, withReplacement);
const roll7 = Math.random() * 100; if (drawC.selected.length > 0) {
if (roll7 < 87) { packCards.push(...drawC.selected);
// Common if (!withReplacement) {
draw(pools.commons, 1, 'commons'); pools.commons = drawC.remainingPool;
} else { drawC.selected.forEach(c => namesInPack.add(c.name));
// Uncommon/List }
// If pool empty, try fallback if standard? No, strict as per previous instruction.
draw(pools.uncommons, 1, 'uncommons');
} }
// 3. Uncommons (3 or 4 dependent on PEASANT vs STANDARD) // 2. Slot 7: Common / The List
const uNeeded = rarityMode === 'peasant' ? 4 : 3; // 1-87: Common
draw(pools.uncommons, uNeeded, 'uncommons'); // 88-97: List (C/U)
// 98-100: List (U)
const roll7 = Math.floor(Math.random() * 100) + 1;
const hasGuests = pools.specialGuests.length > 0;
// 4. Rare/Mythic (Standard Only) if (roll7 <= 87) {
if (rarityMode === 'standard') { draw(pools.commons, 1, 'commons');
} else if (roll7 <= 97) {
// List (C/U) - Fallback logic
if (hasGuests) draw(pools.specialGuests, 1, 'specialGuests');
else {
// 50/50 fallback
const useU = Math.random() < 0.5;
if (useU) draw(pools.uncommons, 1, 'uncommons');
else draw(pools.commons, 1, 'commons');
}
} else {
// 98-100: List (U)
if (hasGuests) draw(pools.specialGuests, 1, 'specialGuests');
else draw(pools.uncommons, 1, 'uncommons');
}
// 3. Uncommons (4)
draw(pools.uncommons, 4, 'uncommons');
// 4. Land (Slot 12)
const isFoilLand = Math.random() < 0.2;
const landPicks = draw(pools.lands, 1, 'lands');
if (landPicks.length > 0 && isFoilLand) {
const idx = packCards.indexOf(landPicks[0]);
if (idx !== -1) {
packCards[idx] = { ...packCards[idx], finish: 'foil' };
}
}
// 5. Wildcards (Slot 13 & 14)
// Peasant weights: ~62% Common, ~37% Uncommon
for (let i = 0; i < 2; i++) {
const isFoil = i === 1;
const wRoll = Math.random() * 100;
let targetKey: keyof ProcessedPools = 'commons';
// 1-62: Common, 63-100: Uncommon (Approx > 62)
if (wRoll > 62) targetKey = 'uncommons';
else targetKey = 'commons';
let pool = pools[targetKey];
if (pool.length === 0) {
// Fallback
targetKey = 'commons';
pool = pools.commons;
}
const res = this.drawCards(pool, 1, namesInPack, withReplacement);
if (res.selected.length > 0) {
const card = { ...res.selected[0] };
if (isFoil) card.finish = 'foil';
packCards.push(card);
if (!withReplacement) {
// @ts-ignore
pools[targetKey] = res.remainingPool;
namesInPack.add(card.name);
}
}
}
} else {
// STANDARD MODE
// 1. Commons (6)
const drawC = this.drawColorBalanced(pools.commons, 6, namesInPack, withReplacement);
if (drawC.selected.length > 0) {
packCards.push(...drawC.selected);
if (!withReplacement) {
pools.commons = drawC.remainingPool;
drawC.selected.forEach(c => namesInPack.add(c.name));
}
}
// 2. Slot 7 (Common / List / Guest)
// 1-87: Common
// 88-97: List (C/U)
// 98-99: List (R/M)
// 100: Special Guest
const roll7 = Math.floor(Math.random() * 100) + 1; // 1-100
const hasGuests = pools.specialGuests.length > 0;
if (roll7 <= 87) {
draw(pools.commons, 1, 'commons');
} else if (roll7 <= 97) {
// List C/U
if (hasGuests) draw(pools.specialGuests, 1, 'specialGuests');
else {
if (Math.random() < 0.5) draw(pools.uncommons, 1, 'uncommons');
else draw(pools.commons, 1, 'commons');
}
} else if (roll7 <= 99) {
// List R/M
if (hasGuests) draw(pools.specialGuests, 1, 'specialGuests');
else {
if (Math.random() < 0.5) draw(pools.mythics, 1, 'mythics');
else draw(pools.rares, 1, 'rares');
}
} else {
// 100: Special Guest
if (hasGuests) draw(pools.specialGuests, 1, 'specialGuests');
else draw(pools.mythics, 1, 'mythics'); // Fallback to Mythic
}
// 3. Uncommons (3)
draw(pools.uncommons, 3, 'uncommons');
// 4. Main Rare/Mythic (Slot 11)
const isMythic = Math.random() < 0.125; const isMythic = Math.random() < 0.125;
let pickedR = false; let pickedR = false;
if (isMythic && pools.mythics.length > 0) { if (isMythic && pools.mythics.length > 0) {
const sel = draw(pools.mythics, 1, 'mythics'); const sel = draw(pools.mythics, 1, 'mythics');
if (sel.length) pickedR = true; if (sel.length) pickedR = true;
} }
if (!pickedR) {
if (!pickedR && pools.rares.length > 0) {
draw(pools.rares, 1, 'rares'); draw(pools.rares, 1, 'rares');
} }
}
// 5. Land // 5. Land (Slot 12)
const isFoilLand = Math.random() < 0.2; const isFoilLand = Math.random() < 0.2;
if (pools.lands.length > 0) { const landPicks = draw(pools.lands, 1, 'lands');
// For lands, we generally want random basic lands anyway even in finite cubes if possible? if (landPicks.length > 0 && isFoilLand) {
// But adhering to 'withReplacement' logic strictly. const idx = packCards.indexOf(landPicks[0]);
const res = this.drawCards(pools.lands, 1, namesInPack, withReplacement); if (idx !== -1) {
if (res.selected.length) { packCards[idx] = { ...packCards[idx], finish: 'foil' };
const l = { ...res.selected[0] };
if (isFoilLand) l.finish = 'foil';
packCards.push(l);
if (!withReplacement) {
pools.lands = res.remainingPool;
namesInPack.add(l.name);
}
} }
} }
// 6. Wildcards (2 slots) + Foil Wildcard // 6. Wildcards (Slot 13 & 14)
// Standard weights: ~49% C, ~24% U, ~13% R, ~13% M
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
const isFoil = i === 1; // 2nd is foil const isFoil = i === 1;
const wRoll = Math.random() * 100; const wRoll = Math.random() * 100;
let targetPool = pools.commons;
let targetKey: keyof ProcessedPools = 'commons'; let targetKey: keyof ProcessedPools = 'commons';
if (rarityMode === 'peasant') { if (wRoll > 87) targetKey = 'mythics';
if (wRoll > 60) { targetPool = pools.uncommons; targetKey = 'uncommons'; } else if (wRoll > 74) targetKey = 'rares';
else { targetPool = pools.commons; targetKey = 'commons'; } else if (wRoll > 50) targetKey = 'uncommons';
} else {
if (wRoll > 87) { targetPool = pools.mythics; targetKey = 'mythics'; } let pool = pools[targetKey];
else if (wRoll > 74) { targetPool = pools.rares; targetKey = 'rares'; } // Hierarchical fallback
else if (wRoll > 50) { targetPool = pools.uncommons; targetKey = 'uncommons'; } if (pool.length === 0) {
if (targetKey === 'mythics' && pools.rares.length) targetKey = 'rares';
if ((targetKey === 'rares' || targetKey === 'mythics') && pools.uncommons.length) targetKey = 'uncommons';
if (targetKey !== 'commons' && pools.commons.length) targetKey = 'commons';
pool = pools[targetKey];
} }
let res = this.drawCards(targetPool, 1, namesInPack, withReplacement); const res = this.drawCards(pool, 1, namesInPack, withReplacement);
if (res.selected.length > 0) {
// FALLBACK LOGIC for Wildcards (Standard Only mostly) const card = { ...res.selected[0] };
// If we failed to get a card from target pool (e.g. rolled Mythic but set has none), try lower rarity if (isFoil) card.finish = 'foil';
if (!res.success && rarityMode === 'standard') { packCards.push(card);
if (targetKey === 'mythics' && pools.rares.length) { res = this.drawCards(pools.rares, 1, namesInPack, withReplacement); targetKey = 'rares'; }
else if (targetKey === 'rares' && pools.uncommons.length) { res = this.drawCards(pools.uncommons, 1, namesInPack, withReplacement); targetKey = 'uncommons'; }
else if (targetKey === 'uncommons' && pools.commons.length) { res = this.drawCards(pools.commons, 1, namesInPack, withReplacement); targetKey = 'commons'; }
}
if (res.selected.length) {
const c = { ...res.selected[0] };
if (isFoil) c.finish = 'foil';
packCards.push(c);
if (!withReplacement) { if (!withReplacement) {
// @ts-ignore // @ts-ignore
pools[targetKey] = res.remainingPool; pools[targetKey] = res.remainingPool;
namesInPack.add(c.name); namesInPack.add(card.name);
}
} }
} }
} }
@@ -425,21 +554,21 @@ export class PackGeneratorService {
packCards.sort((a, b) => getWeight(b) - getWeight(a)); packCards.sort((a, b) => getWeight(b) - getWeight(a));
// ENFORCE SIZE STRICTLY if (packCards.length < targetSize) {
const finalCards = packCards.slice(0, targetSize);
// Strict Validation
if (finalCards.length < targetSize) {
return null; return null;
} }
return { return {
id: packId, id: packId,
setName: setName, setName: setName,
cards: finalCards cards: packCards
}; };
} }
private drawColorBalanced(pool: DraftCard[], count: number, existingNames: Set<string>, withReplacement: boolean) {
return this.drawCards(pool, count, existingNames, withReplacement);
}
// Unified Draw Method // Unified Draw Method
private drawCards(pool: DraftCard[], count: number, existingNames: Set<string>, withReplacement: boolean) { private drawCards(pool: DraftCard[], count: number, existingNames: Set<string>, withReplacement: boolean) {
if (pool.length === 0) return { selected: [], remainingPool: pool, success: false }; if (pool.length === 0) return { selected: [], remainingPool: pool, success: false };

View File

@@ -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;
@@ -192,13 +193,15 @@ export class ScryfallService {
const data = await resp.json(); const data = await resp.json();
const sets = data.data const sets = data.data
.filter((s: any) => ['core', 'expansion', 'masters', 'draft_innovation', 'commander', 'funny'].includes(s.set_type)) .filter((s: any) => ['core', 'expansion', 'masters', 'draft_innovation', 'commander', 'funny', 'masterpiece', 'eternal'].includes(s.set_type))
.map((s: any) => ({ .map((s: any) => ({
code: s.code, code: s.code,
name: s.name, name: s.name,
set_type: s.set_type, set_type: s.set_type,
released_at: s.released_at, released_at: s.released_at,
digital: s.digital digital: s.digital,
parent_set_code: s.parent_set_code,
card_count: s.card_count
})); }));
return sets; return sets;
@@ -208,7 +211,7 @@ export class ScryfallService {
} }
} }
async fetchSetCards(setCode: string): Promise<ScryfallCard[]> { async fetchSetCards(setCode: string, relatedSets: string[] = []): Promise<ScryfallCard[]> {
const setHash = setCode.toLowerCase(); const setHash = setCode.toLowerCase();
const setCachePath = path.join(SETS_DIR, `${setHash}.json`); const setCachePath = path.join(SETS_DIR, `${setHash}.json`);
@@ -225,26 +228,30 @@ export class ScryfallService {
} }
} }
console.log(`[ScryfallService] Fetching cards for set ${setCode} from API...`); console.log(`[ScryfallService] Fetching cards for set ${setCode} (related: ${relatedSets.join(',')}) from API...`);
let allCards: ScryfallCard[] = []; let allCards: ScryfallCard[] = [];
let url = `https://api.scryfall.com/cards/search?q=set:${setCode}&unique=cards`;
// Construct Composite Query: (e:main OR e:sub1 OR e:sub2) is:booster unique=prints
const setClause = `e:${setCode}` + relatedSets.map(s => ` OR e:${s}`).join('');
let url = `https://api.scryfall.com/cards/search?q=(${setClause}) unique=prints is:booster`;
try { try {
while (url) { while (url) {
console.log(`[ScryfallService] Requesting: ${url}`); console.log(`[ScryfallService] [API CALL] Requesting: ${url}`);
const r = await fetch(url); const resp = await fetch(url);
if (!r.ok) { console.log(`[ScryfallService] [API RESPONSE] Status: ${resp.status}`);
if (r.status === 404) {
if (!resp.ok) {
if (resp.status === 404) {
console.warn(`[ScryfallService] 404 Not Found for URL: ${url}. Assuming set has no cards.`); console.warn(`[ScryfallService] 404 Not Found for URL: ${url}. Assuming set has no cards.`);
break; break;
} }
const errBody = await resp.text();
const errBody = await r.text(); console.error(`[ScryfallService] Error fetching ${url}: ${resp.status} ${resp.statusText}`, errBody);
console.error(`[ScryfallService] Error fetching ${url}: ${r.status} ${r.statusText}`, errBody); throw new Error(`Failed to fetch set: ${resp.statusText} (${resp.status}) - ${errBody}`);
throw new Error(`Failed to fetch set: ${r.statusText} (${r.status}) - ${errBody}`);
} }
const d = await r.json(); const d = await resp.json();
if (d.data) { if (d.data) {
allCards.push(...d.data); allCards.push(...d.data);
@@ -260,6 +267,9 @@ export class ScryfallService {
// Save Set Cache // Save Set Cache
if (allCards.length > 0) { if (allCards.length > 0) {
if (!fs.existsSync(path.dirname(setCachePath))) {
fs.mkdirSync(path.dirname(setCachePath), { recursive: true });
}
fs.writeFileSync(setCachePath, JSON.stringify(allCards, null, 2)); fs.writeFileSync(setCachePath, JSON.stringify(allCards, null, 2));
// Smartly save individuals: only if missing from cache // Smartly save individuals: only if missing from cache