feat: Implement card pool depletion handling and wildcard rarity fallback for pack generation
All checks were successful
Build and Deploy / build (push) Successful in 1m17s

This commit is contained in:
2025-12-17 14:16:02 +01:00
parent 80de286777
commit 245ab6414a
4 changed files with 123 additions and 81 deletions

View File

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

View File

@@ -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'`.

View File

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

View File

@@ -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<string>();
// 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<string>) {
const selected: DraftCard[] = [];
const skipped: DraftCard[] = [];
let poolIndex = 0;
// Unified Draw Method
private drawCards(pool: DraftCard[], count: number, existingNames: Set<string>, 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[]) {