From faa79906a88a8151a96b6e228f9d487f12800b97 Mon Sep 17 00:00:00 2001 From: dnviti Date: Tue, 16 Dec 2025 23:05:47 +0100 Subject: [PATCH] feat: Implement peasant pack generation algorithm in `PackGeneratorService` including slot logic for commons, uncommons, lands, and wildcards, and add related documentation. --- docs/development/CENTRAL.md | 2 + .../2025-12-16-225700_peasant_algorithm.md | 20 +++ ...-12-16-230500_multi_expansion_selection.md | 14 ++ src/client/src/modules/cube/CubeManager.tsx | 131 +++++++++++++---- .../src/services/PackGeneratorService.ts | 133 +++++++++++++++++- 5 files changed, 268 insertions(+), 32 deletions(-) create mode 100644 docs/development/devlog/2025-12-16-225700_peasant_algorithm.md create mode 100644 docs/development/devlog/2025-12-16-230500_multi_expansion_selection.md diff --git a/docs/development/CENTRAL.md b/docs/development/CENTRAL.md index 7968fa6..51c9cc9 100644 --- a/docs/development/CENTRAL.md +++ b/docs/development/CENTRAL.md @@ -29,3 +29,5 @@ - [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. diff --git a/docs/development/devlog/2025-12-16-225700_peasant_algorithm.md b/docs/development/devlog/2025-12-16-225700_peasant_algorithm.md new file mode 100644 index 0000000..ffdc579 --- /dev/null +++ b/docs/development/devlog/2025-12-16-225700_peasant_algorithm.md @@ -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). diff --git a/docs/development/devlog/2025-12-16-230500_multi_expansion_selection.md b/docs/development/devlog/2025-12-16-230500_multi_expansion_selection.md new file mode 100644 index 0000000..4502e35 --- /dev/null +++ b/docs/development/devlog/2025-12-16-230500_multi_expansion_selection.md @@ -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. diff --git a/src/client/src/modules/cube/CubeManager.tsx b/src/client/src/modules/cube/CubeManager.tsx index 6fcfa14..f672259 100644 --- a/src/client/src/modules/cube/CubeManager.tsx +++ b/src/client/src/modules/cube/CubeManager.tsx @@ -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 = ({ packs, setPacks, onGoT (localStorage.getItem('cube_sourceMode') as 'upload' | 'set') || 'upload' ); const [availableSets, setAvailableSets] = useState([]); - const [selectedSet, setSelectedSet] = useState(() => localStorage.getItem('cube_selectedSet') || ''); + const [selectedSets, setSelectedSets] = useState(() => { + const saved = localStorage.getItem('cube_selectedSets'); + return saved ? JSON.parse(saved) : []; + }); + const [searchTerm, setSearchTerm] = useState(''); const [numBoxes, setNumBoxes] = useState(() => { const saved = localStorage.getItem('cube_numBoxes'); return saved ? parseInt(saved) : 3; @@ -103,7 +107,7 @@ export const CubeManager: React.FC = ({ 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(null); @@ -144,11 +148,20 @@ export const CubeManager: React.FC = ({ 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 = ({ 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 = ({ packs, setPacks, onGoT ) : ( <>
- - + + +
+ {/* Search Header */} +
+ + 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 && ( + + )} +
+ + {/* List */} +
+ {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 ( + + ); + })} + {availableSets.filter(s => !searchTerm || s.name.toLowerCase().includes(searchTerm.toLowerCase()) || s.code.toLowerCase().includes(searchTerm.toLowerCase())).length === 0 && ( +
+ No sets found matching "{searchTerm}" +
+ )} +
+ + {/* Footer Stats */} +
+ + {selectedSets.length} selected + + {selectedSets.length > 0 && ( + + )} +
+
@@ -439,10 +518,10 @@ export const CubeManager: React.FC = ({ packs, setPacks, onGoT )} diff --git a/src/client/src/services/PackGeneratorService.ts b/src/client/src/services/PackGeneratorService.ts index 426135a..52ec8a1 100644 --- a/src/client/src/services/PackGeneratorService.ts +++ b/src/client/src/services/PackGeneratorService.ts @@ -239,17 +239,138 @@ export class PackGeneratorService { const namesInThisPack = new Set(); 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) ---