Compare commits

..

2 Commits

7 changed files with 322 additions and 33 deletions

View File

@@ -29,3 +29,6 @@
- [Bulk Parse Feedback](./devlog/2025-12-16-231500_bulk_parse_feedback.md): Completed. Updated `CubeManager` to handle metadata generation properly and provide feedback on missing cards.
- [Full Metadata Passthrough](./devlog/2025-12-16-234500_full_metadata_passthrough.md): Completed. `DraftCard` now includes a `definition` property containing the complete, raw Scryfall object for future-proofing and advanced algorithm usage.
- [Server-Side Caching](./devlog/2025-12-16-235900_server_side_caching.md): Completed. Implemented logic to cache images and metadata on the server upon bulk parsing, and updated client to use local assets.
- [Peasant Algorithm Implementation](./devlog/2025-12-16-225700_peasant_algorithm.md): Completed. Implemented Peasant-specific pack generation rules including slot logic for commons, uncommons, lands, and wildcards.
- [Multi-Expansion Selection](./devlog/2025-12-16-230500_multi_expansion_selection.md): Completed. Implemented searchable multi-select interface for "From Expansion" pack generation, allowing mixed-set drafts.
- [Game Type Filter](./devlog/2025-12-16-231000_game_type_filter.md): Completed. Added Paper/Digital filter to the expansion selection list.

View File

@@ -0,0 +1,20 @@
# Peasant Algorithm Implementation
## Overview
Implemented the detailed "Peasant" pack generation algorithm in `PackGeneratorService.ts`.
## Changes
- Updated `buildSinglePack` in `PackGeneratorService.ts` to include specific logic for Peasant rarity mode.
- Implemented slot-based generation:
- Slots 1-6: Commons (Color Balanced)
- Slot 7: Common or "The List" (Simulated)
- Slots 8-11: Uncommons
- Slot 12: Land (20% Foil)
- Slot 13: Non-Foil Wildcard (Weighted by rarity)
- Slot 14: Foil Wildcard (Weighted by rarity)
- Slot 15: Marketing Token
## Notes
- Used existing helper methods `drawColorBalanced` and `drawUniqueCards`.
- Simulated "The List" logic using available Common/Uncommon pools as exact "The List" metadata might not be available in standard pools provided to the generator.
- Wildcard weights follow the specification (~49% C, ~24% U, ~13% R, ~13% M).

View File

@@ -0,0 +1,14 @@
# Multi-Expansion Selection
## Objective
Enhanced the "From Expansion" pack generation feature in the Cube Manager to allow users to select multiple expansions and use a searchable interface.
## Implementation Details
1. **Searchable Interface**: Replaced the simple set dropdown with a dedicated set selection UI featuring a search input for fuzzy filtering by set name or code.
2. **Multi-Select Capability**: Users can now check multiple sets from the filtered list.
3. **Frontend State Refactor**: Migrated `selectedSet` (string) to `selectedSets` (string array) in `CubeManager.tsx`.
4. **Fetch Logic Update**: Updated `fetchAndParse` to iterate through all selected sets, fetching card data for each sequentially and combining the results into the parse pool.
5. **Generation Logic**: The existing `generateBoosterBox` logic now naturally consumes the combined pool of cards from multiple sets, effectively allowing for "Chaos Drafts" or custom mixed-set environments based on the user's selection.
## Status
Completed. The Cube Manager UI now supports advanced set selection scenarios.

View File

@@ -0,0 +1,15 @@
# Game Type Filter for Expansion Selection
## Objective
Add a filter to the "From Expansion" set selection to easily distinguish between Paper and Digital (MTGA/MTGO) sets.
## Implementation Details
1. **ScryfallService Update**: Updated `ScryfallSet` interface to include the `digital` boolean property and mapped it in `fetchSets`.
2. **CubeManager UI**: Added a toggle filter bar above the set list with three options:
* **All**: Shows all sets.
* **Paper**: Shows only sets where `digital` is false.
* **Digital**: Shows only sets where `digital` is true.
3. **Filter Logic**: Integrated the game type filter into the existing search filter logic in `CubeManager`.
## Status
Completed. Users can now filter the expansion list by game type.

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,13 @@ 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 [gameTypeFilter, setGameTypeFilter] = useState<'all' | 'paper' | 'digital'>('all'); // Filter state
const [numBoxes, setNumBoxes] = useState<number>(() => {
const saved = localStorage.getItem('cube_numBoxes');
return saved ? parseInt(saved) : 3;
@@ -103,7 +109,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 +150,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})`);
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 = cards;
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 +327,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 +421,118 @@ 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"
<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>
{/* Game Type Filter */}
<div className="flex border-b border-slate-700 bg-slate-900">
<button
onClick={() => setGameTypeFilter('all')}
className={`flex-1 py-1.5 text-[10px] font-bold uppercase tracking-wider transition-colors ${gameTypeFilter === 'all' ? 'bg-slate-700 text-white' : 'text-slate-500 hover:text-slate-300 hover:bg-slate-800'}`}
>
All
</button>
<button
onClick={() => setGameTypeFilter('paper')}
className={`flex-1 py-1.5 text-[10px] font-bold uppercase tracking-wider transition-colors ${gameTypeFilter === 'paper' ? 'bg-emerald-900/40 text-emerald-400' : 'text-slate-500 hover:text-emerald-400 hover:bg-slate-800'}`}
title="Show only Paper sets"
>
Paper
</button>
<button
onClick={() => setGameTypeFilter('digital')}
className={`flex-1 py-1.5 text-[10px] font-bold uppercase tracking-wider transition-colors ${gameTypeFilter === 'digital' ? 'bg-blue-900/40 text-blue-400' : 'text-slate-500 hover:text-blue-400 hover:bg-slate-800'}`}
title="Show only Digital sets (Arena/MTGO)"
>
Digital
</button>
</div>
{/* List */}
<div className="max-h-60 overflow-y-auto custom-scrollbar p-1 space-y-0.5">
{availableSets
.filter(s => {
// Search Filter
const matchesSearch = !searchTerm ||
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.code.toLowerCase().includes(searchTerm.toLowerCase());
// Game Type Filter
const matchesType =
gameTypeFilter === 'all' ? true :
gameTypeFilter === 'paper' ? !s.digital :
gameTypeFilter === 'digital' ? s.digital : true;
return matchesSearch && matchesType;
})
.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}
>
<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>
Clear Selection
</button>
)}
</div>
</div>
</div>
<div className="mb-4">
@@ -439,10 +553,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) ---

View File

@@ -168,7 +168,8 @@ export class ScryfallService {
name: s.name,
set_type: s.set_type,
released_at: s.released_at,
icon_svg_uri: s.icon_svg_uri
icon_svg_uri: s.icon_svg_uri,
digital: s.digital
}));
}
} catch (e) {
@@ -226,4 +227,5 @@ export interface ScryfallSet {
set_type: string;
released_at: string;
icon_svg_uri: string;
digital: boolean;
}