feat: Implement peasant pack generation algorithm in PackGeneratorService including slot logic for commons, uncommons, lands, and wildcards, and add related documentation.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users, Download, Copy, FileDown, Trash2 } from 'lucide-react';
|
||||
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users, Download, Copy, FileDown, Trash2, Search, X } from 'lucide-react';
|
||||
import { CardParserService } from '../../services/CardParserService';
|
||||
import { ScryfallService, ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
|
||||
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
|
||||
@@ -92,7 +92,11 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
(localStorage.getItem('cube_sourceMode') as 'upload' | 'set') || 'upload'
|
||||
);
|
||||
const [availableSets, setAvailableSets] = useState<ScryfallSet[]>([]);
|
||||
const [selectedSet, setSelectedSet] = useState(() => localStorage.getItem('cube_selectedSet') || '');
|
||||
const [selectedSets, setSelectedSets] = useState<string[]>(() => {
|
||||
const saved = localStorage.getItem('cube_selectedSets');
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [numBoxes, setNumBoxes] = useState<number>(() => {
|
||||
const saved = localStorage.getItem('cube_numBoxes');
|
||||
return saved ? parseInt(saved) : 3;
|
||||
@@ -103,7 +107,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
useEffect(() => localStorage.setItem('cube_filters', JSON.stringify(filters)), [filters]);
|
||||
useEffect(() => localStorage.setItem('cube_genSettings', JSON.stringify(genSettings)), [genSettings]);
|
||||
useEffect(() => localStorage.setItem('cube_sourceMode', sourceMode), [sourceMode]);
|
||||
useEffect(() => localStorage.setItem('cube_selectedSet', selectedSet), [selectedSet]);
|
||||
useEffect(() => localStorage.setItem('cube_selectedSets', JSON.stringify(selectedSets)), [selectedSets]);
|
||||
useEffect(() => localStorage.setItem('cube_numBoxes', numBoxes.toString()), [numBoxes]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -144,11 +148,20 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
let expandedCards: ScryfallCard[] = [];
|
||||
|
||||
if (sourceMode === 'set') {
|
||||
if (!selectedSet) throw new Error("Please select a set.");
|
||||
const cards = await scryfallService.fetchSetCards(selectedSet, (count) => {
|
||||
setProgress(`Fetching set cards... (${count})`);
|
||||
});
|
||||
expandedCards = cards;
|
||||
if (selectedSets.length === 0) throw new Error("Please select at least one set.");
|
||||
|
||||
for (const [index, setCode] of selectedSets.entries()) {
|
||||
// Update progress for set
|
||||
const setInfo = availableSets.find(s => s.code === setCode);
|
||||
const setName = setInfo ? setInfo.name : setCode;
|
||||
|
||||
setProgress(`Fetching ${setName}... (${index + 1}/${selectedSets.length})`);
|
||||
|
||||
const cards = await scryfallService.fetchSetCards(setCode, (_count) => {
|
||||
// Progress handled by outer loop mostly, but we could update strictly if needed.
|
||||
});
|
||||
expandedCards.push(...cards);
|
||||
}
|
||||
} else {
|
||||
const identifiers = parserService.parse(inputText);
|
||||
const fetchList = identifiers.map(id => id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||
@@ -312,10 +325,11 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
setInputText('');
|
||||
setRawScryfallData(null);
|
||||
setProcessedData(null);
|
||||
setSelectedSet('');
|
||||
setProcessedData(null);
|
||||
setSelectedSets([]);
|
||||
localStorage.removeItem('cube_inputText');
|
||||
localStorage.removeItem('cube_rawScryfallData');
|
||||
localStorage.removeItem('cube_selectedSet');
|
||||
localStorage.removeItem('cube_selectedSets');
|
||||
// We keep filters and settings as they are user preferences
|
||||
}
|
||||
};
|
||||
@@ -405,20 +419,85 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-semibold text-slate-300 mb-2">Select Expansion</label>
|
||||
<select
|
||||
value={selectedSet}
|
||||
onChange={(e) => setSelectedSet(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-slate-300 focus:ring-2 focus:ring-purple-500 outline-none"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">-- Choose Set --</option>
|
||||
{availableSets.map(s => (
|
||||
<option key={s.code} value={s.code}>
|
||||
{s.name} ({s.code.toUpperCase()}) - {s.set_type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="block text-sm font-semibold text-slate-300 mb-2">Select Expansions</label>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-lg overflow-hidden">
|
||||
{/* Search Header */}
|
||||
<div className="flex items-center gap-2 p-2 border-b border-slate-700 bg-slate-800/50">
|
||||
<Search className="w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search sets..."
|
||||
className="bg-transparent text-xs w-full outline-none text-white placeholder-slate-500 font-medium"
|
||||
disabled={loading}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button onClick={() => setSearchTerm('')}>
|
||||
<X className="w-3 h-3 text-slate-500 hover:text-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar p-1 space-y-0.5">
|
||||
{availableSets
|
||||
.filter(s =>
|
||||
!searchTerm ||
|
||||
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
s.code.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
.map(s => {
|
||||
const isSelected = selectedSets.includes(s.code);
|
||||
return (
|
||||
<label
|
||||
key={s.code}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${isSelected ? 'bg-purple-900/30 text-purple-200' : 'hover:bg-slate-800 text-slate-400 hover:text-slate-200'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {
|
||||
setSelectedSets(prev =>
|
||||
prev.includes(s.code)
|
||||
? prev.filter(c => c !== s.code)
|
||||
: [...prev, s.code]
|
||||
)
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-slate-600 bg-slate-800 text-purple-500 focus:ring-purple-500 focus:ring-offset-0"
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium leading-none">{s.name}</span>
|
||||
<span className="text-[10px] opacity-60 font-mono">{s.code.toUpperCase()} • {s.set_type} • {s.released_at?.slice(0, 4)}</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{availableSets.filter(s => !searchTerm || s.name.toLowerCase().includes(searchTerm.toLowerCase()) || s.code.toLowerCase().includes(searchTerm.toLowerCase())).length === 0 && (
|
||||
<div className="p-3 text-center text-xs text-slate-600 italic">
|
||||
No sets found matching "{searchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Stats */}
|
||||
<div className="bg-slate-950 p-2 border-t border-slate-800 flex justify-between items-center">
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{selectedSets.length} selected
|
||||
</span>
|
||||
{selectedSets.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedSets([])}
|
||||
className="text-[10px] text-red-400 hover:text-red-300 hover:underline"
|
||||
disabled={loading}
|
||||
>
|
||||
Clear Selection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
@@ -439,10 +518,10 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
|
||||
<button
|
||||
onClick={fetchAndParse}
|
||||
disabled={loading || !selectedSet}
|
||||
disabled={loading || selectedSets.length === 0}
|
||||
className={`w-full py-2 mb-4 rounded-lg font-bold flex justify-center items-center gap-2 transition-all ${loading ? 'bg-slate-700 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-500 text-white'}`}
|
||||
>
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> {progress}</> : <><Check className="w-4 h-4" /> 1. Fetch Set</>}
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> {progress}</> : <><Check className="w-4 h-4" /> 1. Fetch {selectedSets.length > 1 ? 'Sets' : 'Set'}</>}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -239,17 +239,138 @@ export class PackGeneratorService {
|
||||
const namesInThisPack = new Set<string>();
|
||||
|
||||
if (rarityMode === 'peasant') {
|
||||
const COMMONS_COUNT = 10;
|
||||
const UNCOMMONS_COUNT = 5; // Boosted uncommons for peasant
|
||||
// 1. Slots 1-6: Commons (Color Balanced)
|
||||
const commonsNeeded = 6;
|
||||
const drawC = this.drawColorBalanced(currentPools.commons, commonsNeeded, namesInThisPack);
|
||||
|
||||
const drawU = this.drawUniqueCards(currentPools.uncommons, UNCOMMONS_COUNT, namesInThisPack);
|
||||
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;
|
||||
} else if (currentPools.commons.length < commonsNeeded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
packCards.push(...drawC.selected);
|
||||
currentPools.commons = drawC.remainingPool;
|
||||
drawC.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
// 2. Slot 7: Common / The List
|
||||
// 1-87: Common from Main Set
|
||||
// 88-97: Card from "The List" (Common/Uncommon)
|
||||
// 98-100: Uncommon from "The List"
|
||||
const roll7 = Math.floor(Math.random() * 100) + 1;
|
||||
let slot7Card: DraftCard | undefined;
|
||||
|
||||
if (roll7 <= 87) {
|
||||
// Common
|
||||
const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; }
|
||||
} else if (roll7 <= 97) {
|
||||
// List (Common/Uncommon). Simulating by picking 50/50 C/U if actual List not available
|
||||
const useUncommon = Math.random() < 0.5;
|
||||
const pool = useUncommon ? currentPools.uncommons : currentPools.commons;
|
||||
// Fallback if one pool is empty
|
||||
const effectivePool = pool.length > 0 ? pool : (useUncommon ? currentPools.commons : currentPools.uncommons);
|
||||
|
||||
if (effectivePool.length > 0) {
|
||||
const res = this.drawUniqueCards(effectivePool, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
slot7Card = res.selected[0];
|
||||
// Identify which pool to update
|
||||
if (effectivePool === currentPools.uncommons) currentPools.uncommons = res.remainingPool;
|
||||
else currentPools.commons = res.remainingPool;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 98-100: Uncommon (from List or pool)
|
||||
const res = this.drawUniqueCards(currentPools.uncommons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.uncommons = res.remainingPool; }
|
||||
}
|
||||
|
||||
if (slot7Card) {
|
||||
packCards.push(slot7Card);
|
||||
namesInThisPack.add(slot7Card.name);
|
||||
}
|
||||
|
||||
// 3. Slots 8-11: Uncommons (4 cards)
|
||||
const uncommonsNeeded = 4;
|
||||
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);
|
||||
currentPools.uncommons = drawU.remainingPool;
|
||||
drawU.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
const drawC = this.drawUniqueCards(currentPools.commons, COMMONS_COUNT, namesInThisPack);
|
||||
packCards.push(...drawC.selected);
|
||||
currentPools.commons = drawC.remainingPool;
|
||||
// 4. Slot 12: Land (Basic or Common Dual)
|
||||
const foilLandRoll = Math.random();
|
||||
const isFoilLand = foilLandRoll < 0.20;
|
||||
let landCard: DraftCard | undefined;
|
||||
|
||||
if (currentPools.lands.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.lands, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
landCard = { ...res.selected[0] };
|
||||
currentPools.lands = res.remainingPool;
|
||||
}
|
||||
}
|
||||
|
||||
if (landCard) {
|
||||
if (isFoilLand) landCard.finish = 'foil';
|
||||
packCards.push(landCard);
|
||||
namesInThisPack.add(landCard.name);
|
||||
}
|
||||
|
||||
// Helper for Wildcards
|
||||
const drawWildcard = (foil: boolean) => {
|
||||
const wRoll = Math.random() * 100;
|
||||
let wRarity = 'common';
|
||||
// ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic
|
||||
if (wRoll > 87) wRarity = 'mythic';
|
||||
else if (wRoll > 74) wRarity = 'rare';
|
||||
else if (wRoll > 50) wRarity = 'uncommon';
|
||||
else wRarity = 'common';
|
||||
|
||||
let poolToUse: DraftCard[] = [];
|
||||
let updatePool = (_newPool: DraftCard[]) => { };
|
||||
|
||||
if (wRarity === 'mythic') { poolToUse = currentPools.mythics; updatePool = (p) => currentPools.mythics = 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; }
|
||||
|
||||
// Fallback
|
||||
if (poolToUse.length === 0) {
|
||||
if (currentPools.commons.length > 0) { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
|
||||
}
|
||||
|
||||
if (poolToUse.length > 0) {
|
||||
const res = this.drawUniqueCards(poolToUse, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
const card = { ...res.selected[0] };
|
||||
if (foil) card.finish = 'foil';
|
||||
packCards.push(card);
|
||||
updatePool(res.remainingPool);
|
||||
namesInThisPack.add(card.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 5. Slot 13: Non-Foil Wildcard
|
||||
drawWildcard(false);
|
||||
|
||||
// 6. Slot 14: Foil Wildcard
|
||||
drawWildcard(true);
|
||||
|
||||
// 7. Slot 15: Marketing / Token
|
||||
if (currentPools.tokens.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.tokens, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
packCards.push(res.selected[0]);
|
||||
currentPools.tokens = res.remainingPool;
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// --- NEW ALGORITHM (Play Booster) ---
|
||||
|
||||
Reference in New Issue
Block a user