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:
2025-12-16 23:05:47 +01:00
parent ea24b5a206
commit faa79906a8
5 changed files with 268 additions and 32 deletions

View File

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

View File

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