From 245ab6414ade7ac1c2919427d79e21dd59010838 Mon Sep 17 00:00:00 2001 From: dnviti Date: Wed, 17 Dec 2025 14:16:02 +0100 Subject: [PATCH] feat: Implement card pool depletion handling and wildcard rarity fallback for pack generation --- docs/development/CENTRAL.md | 1 + ...5-12-17-140000_fix_expansion_generation.md | 22 +++ src/server/index.ts | 4 + src/server/services/PackGeneratorService.ts | 177 ++++++++++-------- 4 files changed, 123 insertions(+), 81 deletions(-) create mode 100644 docs/development/devlog/2025-12-17-140000_fix_expansion_generation.md diff --git a/docs/development/CENTRAL.md b/docs/development/CENTRAL.md index d1972a0..0d056eb 100644 --- a/docs/development/CENTRAL.md +++ b/docs/development/CENTRAL.md @@ -73,3 +73,4 @@ - [Animated Copy Button](./devlog/2025-12-17-024000_animated_copy_button.md): Completed. Replaced copy toast with an in-place animated tick button for immediate feedback. - [Play Online Logic](./devlog/2025-12-17-031500_play_online_logic.md): Completed. Implemented strict pack limits (min 12 for 4 players) and visual feedback for the online lobby button. - [Lobby Rules Tooltip](./devlog/2025-12-17-032000_lobby_rules_tooltip.md): Completed. Added dynamic rules explanation and supported player indicators to the lobby creation screen. +- [Fix Expansion Pack Generation](./devlog/2025-12-17-140000_fix_expansion_generation.md): Completed. Enforced infinite card pool for expansion drafts to ensure correct pack counts and prevent depletion. diff --git a/docs/development/devlog/2025-12-17-140000_fix_expansion_generation.md b/docs/development/devlog/2025-12-17-140000_fix_expansion_generation.md new file mode 100644 index 0000000..650473d --- /dev/null +++ b/docs/development/devlog/2025-12-17-140000_fix_expansion_generation.md @@ -0,0 +1,22 @@ +# Fix Expansion Pack Generation (Infinite Cards) + +## Problem +The user reported two issues with "From Expansion" pack generation: +1. Incorrect amount of packs generated (e.g., 10 instead of 36). +2. The generator was using a finite pool of cards (like a custom cube) instead of an infinite supply (like opening fresh packs). + +## Root Cause +The `PackGeneratorService` defaults to generating packs without replacement (`withReplacement: false`). This means once a card is used, it is removed from the pool. +For a standard set (Expansion), the pool contains only one copy of each card (from Scryfall fetch). +When generating a large number of packs (e.g., 36 for a box), the rare/mythic/uncommon pools would deplete quickly, causing the generator to stop early and produce fewer packs than requested. + +## Solution +Modified `src/server/index.ts` to enforce `settings.withReplacement = true` when `sourceMode === 'set'`. +This ensures that: +- The pack generator refreshes the card pools for every new pack. +- Generating 36 packs (or any number) is possible even from a single set of source cards. +- Duplicates are allowed across packs (simulating a print run), while maintaining uniqueness within a single pack (handled by `buildSinglePack`). + +## Changes +- **File**: `src/server/index.ts` +- **Logic**: Added a check in the `/api/packs/generate` route to set `settings.withReplacement = true` if `sourceMode === 'set'`. diff --git a/src/server/index.ts b/src/server/index.ts index 62f98af..ae6b63b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -135,6 +135,10 @@ app.post('/api/packs/generate', async (req: Request, res: Response) => { const setCards = await scryfallService.fetchSetCards(code); poolCards.push(...setCards); } + // Force infinite card pool for Expansion mode + if (settings) { + settings.withReplacement = true; + } } // Default filters if missing diff --git a/src/server/services/PackGeneratorService.ts b/src/server/services/PackGeneratorService.ts index 8ec8f11..ccba35e 100644 --- a/src/server/services/PackGeneratorService.ts +++ b/src/server/services/PackGeneratorService.ts @@ -192,7 +192,7 @@ export class PackGeneratorService { }; } - const result = this.buildSinglePack(packPools, i, 'Chaos Pack', settings.rarityMode); + const result = this.buildSinglePack(packPools, i, 'Chaos Pack', settings.rarityMode, settings.withReplacement); if (result) { newPacks.push(result); @@ -250,8 +250,13 @@ export class PackGeneratorService { tokens: this.shuffle([...data.tokens]) }; - for (let i = 0; i < packsPerSet; i++) { + let packsGeneratedForSet = 0; + let attempts = 0; + const maxAttempts = packsPerSet * 5; // Prevent infinite loop + + while (packsGeneratedForSet < packsPerSet && attempts < maxAttempts) { if (packId > numPacks) break; + attempts++; let packPools = currentPools; if (settings.withReplacement) { @@ -266,13 +271,17 @@ export class PackGeneratorService { }; } - const result = this.buildSinglePack(packPools, packId, data.name, settings.rarityMode); + const result = this.buildSinglePack(packPools, packId, data.name, settings.rarityMode, settings.withReplacement); if (result) { newPacks.push(result); packId++; + packsGeneratedForSet++; } else { - console.warn(`[PackGenerator] Set ${data.name} depleted at pack ${packId}`); - if (!settings.withReplacement) break; + // only warn occasionally or if persistent + if (!settings.withReplacement) { + console.warn(`[PackGenerator] Set ${data.name} depleted at pack ${packId}`); + break; // Cannot generate more from this set + } } } } @@ -283,45 +292,44 @@ export class PackGeneratorService { return newPacks; } - private buildSinglePack(pools: ProcessedPools, packId: number, setName: string, rarityMode: 'peasant' | 'standard'): Pack | null { + private buildSinglePack(pools: ProcessedPools, packId: number, setName: string, rarityMode: 'peasant' | 'standard', withReplacement: boolean = false): Pack | null { const packCards: DraftCard[] = []; const namesInPack = new Set(); // Standard: 14 cards exactly. Peasant: 13 cards exactly. const targetSize = rarityMode === 'peasant' ? 13 : 14; + // Helper to abstract draw logic + const draw = (pool: DraftCard[], count: number, poolKey: keyof ProcessedPools) => { + const result = this.drawCards(pool, count, namesInPack, withReplacement); + if (result.selected.length > 0) { + packCards.push(...result.selected); + if (!withReplacement) { + // @ts-ignore + pools[poolKey] = result.remainingPool; // Update ref only if not infinite + result.selected.forEach(c => namesInPack.add(c.name)); + } + } + return result.selected; + }; + // 1. Commons (6) - const drawC = this.drawUniqueCards(pools.commons, 6, namesInPack); - if (drawC.selected.length > 0) { - packCards.push(...drawC.selected); - pools.commons = drawC.remainingPool; // Update ref - drawC.selected.forEach(c => namesInPack.add(c.name)); - } + draw(pools.commons, 6, 'commons'); // 2. Slot 7 (Common or List) - let slot7: DraftCard | undefined; const roll7 = Math.random() * 100; if (roll7 < 87) { // Common - const r = this.drawUniqueCards(pools.commons, 1, namesInPack); - if (r.selected.length) { slot7 = r.selected[0]; pools.commons = r.remainingPool; } + draw(pools.commons, 1, 'commons'); } else { // Uncommon/List - // Strict Mode: If List/Uncommon unavailable, DO NOT fallback to Common. - const r = this.drawUniqueCards(pools.uncommons, 1, namesInPack); - if (r.selected.length) { slot7 = r.selected[0]; pools.uncommons = r.remainingPool; } + // If pool empty, try fallback if standard? No, strict as per previous instruction. + draw(pools.uncommons, 1, 'uncommons'); } - if (slot7) { packCards.push(slot7); namesInPack.add(slot7.name); } // 3. Uncommons (3 or 4 dependent on PEASANT vs STANDARD) - // Memo says: PEASANT slots 8-11 (4 uncommons). STANDARD slots 8-10 (3 uncommons). const uNeeded = rarityMode === 'peasant' ? 4 : 3; - const drawU = this.drawUniqueCards(pools.uncommons, uNeeded, namesInPack); - if (drawU.selected.length > 0) { - packCards.push(...drawU.selected); - pools.uncommons = drawU.remainingPool; - drawU.selected.forEach(c => namesInPack.add(c.name)); - } + draw(pools.uncommons, uNeeded, 'uncommons'); // 4. Rare/Mythic (Standard Only) if (rarityMode === 'standard') { @@ -329,35 +337,29 @@ export class PackGeneratorService { let pickedR = false; if (isMythic && pools.mythics.length > 0) { - const r = this.drawUniqueCards(pools.mythics, 1, namesInPack); - if (r.selected.length) { - packCards.push(r.selected[0]); - pools.mythics = r.remainingPool; - namesInPack.add(r.selected[0].name); - pickedR = true; - } + const sel = draw(pools.mythics, 1, 'mythics'); + if (sel.length) pickedR = true; } if (!pickedR && pools.rares.length > 0) { - const r = this.drawUniqueCards(pools.rares, 1, namesInPack); - if (r.selected.length) { - packCards.push(r.selected[0]); - pools.rares = r.remainingPool; - namesInPack.add(r.selected[0].name); - } + draw(pools.rares, 1, 'rares'); } } // 5. Land const isFoilLand = Math.random() < 0.2; if (pools.lands.length > 0) { - const r = this.drawUniqueCards(pools.lands, 1, namesInPack); - if (r.selected.length) { - const l = { ...r.selected[0] }; + // For lands, we generally want random basic lands anyway even in finite cubes if possible? + // But adhering to 'withReplacement' logic strictly. + const res = this.drawCards(pools.lands, 1, namesInPack, withReplacement); + if (res.selected.length) { + const l = { ...res.selected[0] }; if (isFoilLand) l.finish = 'foil'; packCards.push(l); - pools.lands = r.remainingPool; - namesInPack.add(l.name); + if (!withReplacement) { + pools.lands = res.remainingPool; + namesInPack.add(l.name); + } } } @@ -369,40 +371,39 @@ export class PackGeneratorService { let targetKey: keyof ProcessedPools = 'commons'; if (rarityMode === 'peasant') { - // Peasant Wildcard: Strictly Common or Uncommon. No Rare/Mythic. - // Adjusted probability: 40% Uncommon, 60% Common (arbitrary but peasant-friendly) if (wRoll > 60) { targetPool = pools.uncommons; targetKey = 'uncommons'; } else { targetPool = pools.commons; targetKey = 'commons'; } } else { - // Standard Wildcard: Can be anything. if (wRoll > 87) { targetPool = pools.mythics; targetKey = 'mythics'; } else if (wRoll > 74) { targetPool = pools.rares; targetKey = 'rares'; } else if (wRoll > 50) { targetPool = pools.uncommons; targetKey = 'uncommons'; } } - // Strict Mode: NO Fallback if target pool empty. - // If targetPool is empty, we simply cannot fill this wildcard slot with the selected rarity. - // The slot remains empty, potentially causing valid pack failure. + let res = this.drawCards(targetPool, 1, namesInPack, withReplacement); + + // FALLBACK LOGIC for Wildcards (Standard Only mostly) + // If we failed to get a card from target pool (e.g. rolled Mythic but set has none), try lower rarity + if (!res.success && rarityMode === 'standard') { + 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'; } + } - const res = this.drawUniqueCards(targetPool, 1, namesInPack); if (res.selected.length) { const c = { ...res.selected[0] }; if (isFoil) c.finish = 'foil'; packCards.push(c); - namesInPack.add(c.name); - // Updating the pool - // @ts-ignore - pools[targetKey] = res.remainingPool; + if (!withReplacement) { + // @ts-ignore + pools[targetKey] = res.remainingPool; + namesInPack.add(c.name); + } } } // 7. Token (Slot 15) if (pools.tokens.length > 0) { - const r = this.drawUniqueCards(pools.tokens, 1, namesInPack); - if (r.selected.length) { - packCards.push(r.selected[0]); - pools.tokens = r.remainingPool; - } + draw(pools.tokens, 1, 'tokens'); } // Sort @@ -419,10 +420,9 @@ export class PackGeneratorService { packCards.sort((a, b) => getWeight(b) - getWeight(a)); // ENFORCE SIZE STRICTLY - // Truncate to target size (ignoring exceeding tokens/extra) const finalCards = packCards.slice(0, targetSize); - // Strict Validation: If we don't have enough cards, FAIL. + // Strict Validation if (finalCards.length < targetSize) { return null; } @@ -434,29 +434,44 @@ export class PackGeneratorService { }; } - // OPTIMIZED DRAW (Index based) - private drawUniqueCards(pool: DraftCard[], count: number, existingNames: Set) { - const selected: DraftCard[] = []; - const skipped: DraftCard[] = []; - let poolIndex = 0; + // Unified Draw Method + private drawCards(pool: DraftCard[], count: number, existingNames: Set, withReplacement: boolean) { + if (pool.length === 0) return { selected: [], remainingPool: pool, success: false }; - // Use simple iteration - while (selected.length < count && poolIndex < pool.length) { - const card = pool[poolIndex]; - poolIndex++; - - if (!existingNames.has(card.name)) { + if (withReplacement) { + // Infinite Mode: Pick random cards, allow duplicates, do not modify pool + const selected: DraftCard[] = []; + for (let i = 0; i < count; i++) { + const randomIndex = Math.floor(Math.random() * pool.length); + // Deep clone to ensure unique IDs if picking same card twice? + // Service assigns unique ID during processCards, but if we pick same object ref twice... + // We should clone to be safe, especially if we mutate it later (foil). + const card = { ...pool[randomIndex] }; + card.id = crypto.randomUUID(); // Ensure unique ID for this instance in pack selected.push(card); - existingNames.add(card.name); - } else { - skipped.push(card); } + return { selected, remainingPool: pool, success: true }; + } else { + // Finite Mode: Unique, remove from pool + const selected: DraftCard[] = []; + const skipped: DraftCard[] = []; + let poolIndex = 0; + + while (selected.length < count && poolIndex < pool.length) { + const card = pool[poolIndex]; + poolIndex++; + + if (!existingNames.has(card.name)) { + selected.push(card); + existingNames.add(card.name); + } else { + skipped.push(card); + } + } + + const remaining = pool.slice(poolIndex).concat(skipped); + return { selected, remainingPool: remaining, success: selected.length === count }; } - - // Remaining = Rest of pool + Skipped - const remaining = pool.slice(poolIndex).concat(skipped); - - return { selected, remainingPool: remaining, success: selected.length === count }; } private shuffle(array: any[]) {