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
All checks were successful
Build and Deploy / build (push) Successful in 1m17s
This commit is contained in:
@@ -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.
|
- [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.
|
- [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.
|
- [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.
|
||||||
|
|||||||
@@ -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'`.
|
||||||
@@ -135,6 +135,10 @@ app.post('/api/packs/generate', async (req: Request, res: Response) => {
|
|||||||
const setCards = await scryfallService.fetchSetCards(code);
|
const setCards = await scryfallService.fetchSetCards(code);
|
||||||
poolCards.push(...setCards);
|
poolCards.push(...setCards);
|
||||||
}
|
}
|
||||||
|
// Force infinite card pool for Expansion mode
|
||||||
|
if (settings) {
|
||||||
|
settings.withReplacement = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default filters if missing
|
// Default filters if missing
|
||||||
|
|||||||
@@ -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) {
|
if (result) {
|
||||||
newPacks.push(result);
|
newPacks.push(result);
|
||||||
@@ -250,8 +250,13 @@ export class PackGeneratorService {
|
|||||||
tokens: this.shuffle([...data.tokens])
|
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;
|
if (packId > numPacks) break;
|
||||||
|
attempts++;
|
||||||
|
|
||||||
let packPools = currentPools;
|
let packPools = currentPools;
|
||||||
if (settings.withReplacement) {
|
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) {
|
if (result) {
|
||||||
newPacks.push(result);
|
newPacks.push(result);
|
||||||
packId++;
|
packId++;
|
||||||
|
packsGeneratedForSet++;
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[PackGenerator] Set ${data.name} depleted at pack ${packId}`);
|
// only warn occasionally or if persistent
|
||||||
if (!settings.withReplacement) break;
|
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;
|
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 packCards: DraftCard[] = [];
|
||||||
const namesInPack = new Set<string>();
|
const namesInPack = new Set<string>();
|
||||||
|
|
||||||
// Standard: 14 cards exactly. Peasant: 13 cards exactly.
|
// Standard: 14 cards exactly. Peasant: 13 cards exactly.
|
||||||
const targetSize = rarityMode === 'peasant' ? 13 : 14;
|
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)
|
// 1. Commons (6)
|
||||||
const drawC = this.drawUniqueCards(pools.commons, 6, namesInPack);
|
draw(pools.commons, 6, 'commons');
|
||||||
if (drawC.selected.length > 0) {
|
|
||||||
packCards.push(...drawC.selected);
|
|
||||||
pools.commons = drawC.remainingPool; // Update ref
|
|
||||||
drawC.selected.forEach(c => namesInPack.add(c.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Slot 7 (Common or List)
|
// 2. Slot 7 (Common or List)
|
||||||
let slot7: DraftCard | undefined;
|
|
||||||
const roll7 = Math.random() * 100;
|
const roll7 = Math.random() * 100;
|
||||||
if (roll7 < 87) {
|
if (roll7 < 87) {
|
||||||
// Common
|
// Common
|
||||||
const r = this.drawUniqueCards(pools.commons, 1, namesInPack);
|
draw(pools.commons, 1, 'commons');
|
||||||
if (r.selected.length) { slot7 = r.selected[0]; pools.commons = r.remainingPool; }
|
|
||||||
} else {
|
} else {
|
||||||
// Uncommon/List
|
// Uncommon/List
|
||||||
// Strict Mode: If List/Uncommon unavailable, DO NOT fallback to Common.
|
// If pool empty, try fallback if standard? No, strict as per previous instruction.
|
||||||
const r = this.drawUniqueCards(pools.uncommons, 1, namesInPack);
|
draw(pools.uncommons, 1, 'uncommons');
|
||||||
if (r.selected.length) { slot7 = r.selected[0]; pools.uncommons = r.remainingPool; }
|
|
||||||
}
|
}
|
||||||
if (slot7) { packCards.push(slot7); namesInPack.add(slot7.name); }
|
|
||||||
|
|
||||||
// 3. Uncommons (3 or 4 dependent on PEASANT vs STANDARD)
|
// 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 uNeeded = rarityMode === 'peasant' ? 4 : 3;
|
||||||
const drawU = this.drawUniqueCards(pools.uncommons, uNeeded, namesInPack);
|
draw(pools.uncommons, uNeeded, 'uncommons');
|
||||||
if (drawU.selected.length > 0) {
|
|
||||||
packCards.push(...drawU.selected);
|
|
||||||
pools.uncommons = drawU.remainingPool;
|
|
||||||
drawU.selected.forEach(c => namesInPack.add(c.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Rare/Mythic (Standard Only)
|
// 4. Rare/Mythic (Standard Only)
|
||||||
if (rarityMode === 'standard') {
|
if (rarityMode === 'standard') {
|
||||||
@@ -329,35 +337,29 @@ export class PackGeneratorService {
|
|||||||
let pickedR = false;
|
let pickedR = false;
|
||||||
|
|
||||||
if (isMythic && pools.mythics.length > 0) {
|
if (isMythic && pools.mythics.length > 0) {
|
||||||
const r = this.drawUniqueCards(pools.mythics, 1, namesInPack);
|
const sel = draw(pools.mythics, 1, 'mythics');
|
||||||
if (r.selected.length) {
|
if (sel.length) pickedR = true;
|
||||||
packCards.push(r.selected[0]);
|
|
||||||
pools.mythics = r.remainingPool;
|
|
||||||
namesInPack.add(r.selected[0].name);
|
|
||||||
pickedR = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pickedR && pools.rares.length > 0) {
|
if (!pickedR && pools.rares.length > 0) {
|
||||||
const r = this.drawUniqueCards(pools.rares, 1, namesInPack);
|
draw(pools.rares, 1, 'rares');
|
||||||
if (r.selected.length) {
|
|
||||||
packCards.push(r.selected[0]);
|
|
||||||
pools.rares = r.remainingPool;
|
|
||||||
namesInPack.add(r.selected[0].name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Land
|
// 5. Land
|
||||||
const isFoilLand = Math.random() < 0.2;
|
const isFoilLand = Math.random() < 0.2;
|
||||||
if (pools.lands.length > 0) {
|
if (pools.lands.length > 0) {
|
||||||
const r = this.drawUniqueCards(pools.lands, 1, namesInPack);
|
// For lands, we generally want random basic lands anyway even in finite cubes if possible?
|
||||||
if (r.selected.length) {
|
// But adhering to 'withReplacement' logic strictly.
|
||||||
const l = { ...r.selected[0] };
|
const res = this.drawCards(pools.lands, 1, namesInPack, withReplacement);
|
||||||
|
if (res.selected.length) {
|
||||||
|
const l = { ...res.selected[0] };
|
||||||
if (isFoilLand) l.finish = 'foil';
|
if (isFoilLand) l.finish = 'foil';
|
||||||
packCards.push(l);
|
packCards.push(l);
|
||||||
pools.lands = r.remainingPool;
|
if (!withReplacement) {
|
||||||
namesInPack.add(l.name);
|
pools.lands = res.remainingPool;
|
||||||
|
namesInPack.add(l.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,40 +371,39 @@ export class PackGeneratorService {
|
|||||||
let targetKey: keyof ProcessedPools = 'commons';
|
let targetKey: keyof ProcessedPools = 'commons';
|
||||||
|
|
||||||
if (rarityMode === 'peasant') {
|
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'; }
|
if (wRoll > 60) { targetPool = pools.uncommons; targetKey = 'uncommons'; }
|
||||||
else { targetPool = pools.commons; targetKey = 'commons'; }
|
else { targetPool = pools.commons; targetKey = 'commons'; }
|
||||||
} else {
|
} else {
|
||||||
// Standard Wildcard: Can be anything.
|
|
||||||
if (wRoll > 87) { targetPool = pools.mythics; targetKey = 'mythics'; }
|
if (wRoll > 87) { targetPool = pools.mythics; targetKey = 'mythics'; }
|
||||||
else if (wRoll > 74) { targetPool = pools.rares; targetKey = 'rares'; }
|
else if (wRoll > 74) { targetPool = pools.rares; targetKey = 'rares'; }
|
||||||
else if (wRoll > 50) { targetPool = pools.uncommons; targetKey = 'uncommons'; }
|
else if (wRoll > 50) { targetPool = pools.uncommons; targetKey = 'uncommons'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strict Mode: NO Fallback if target pool empty.
|
let res = this.drawCards(targetPool, 1, namesInPack, withReplacement);
|
||||||
// If targetPool is empty, we simply cannot fill this wildcard slot with the selected rarity.
|
|
||||||
// The slot remains empty, potentially causing valid pack failure.
|
// 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) {
|
if (res.selected.length) {
|
||||||
const c = { ...res.selected[0] };
|
const c = { ...res.selected[0] };
|
||||||
if (isFoil) c.finish = 'foil';
|
if (isFoil) c.finish = 'foil';
|
||||||
packCards.push(c);
|
packCards.push(c);
|
||||||
namesInPack.add(c.name);
|
if (!withReplacement) {
|
||||||
// Updating the pool
|
// @ts-ignore
|
||||||
// @ts-ignore
|
pools[targetKey] = res.remainingPool;
|
||||||
pools[targetKey] = res.remainingPool;
|
namesInPack.add(c.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Token (Slot 15)
|
// 7. Token (Slot 15)
|
||||||
if (pools.tokens.length > 0) {
|
if (pools.tokens.length > 0) {
|
||||||
const r = this.drawUniqueCards(pools.tokens, 1, namesInPack);
|
draw(pools.tokens, 1, 'tokens');
|
||||||
if (r.selected.length) {
|
|
||||||
packCards.push(r.selected[0]);
|
|
||||||
pools.tokens = r.remainingPool;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
@@ -419,10 +420,9 @@ export class PackGeneratorService {
|
|||||||
packCards.sort((a, b) => getWeight(b) - getWeight(a));
|
packCards.sort((a, b) => getWeight(b) - getWeight(a));
|
||||||
|
|
||||||
// ENFORCE SIZE STRICTLY
|
// ENFORCE SIZE STRICTLY
|
||||||
// Truncate to target size (ignoring exceeding tokens/extra)
|
|
||||||
const finalCards = packCards.slice(0, targetSize);
|
const finalCards = packCards.slice(0, targetSize);
|
||||||
|
|
||||||
// Strict Validation: If we don't have enough cards, FAIL.
|
// Strict Validation
|
||||||
if (finalCards.length < targetSize) {
|
if (finalCards.length < targetSize) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -434,29 +434,44 @@ export class PackGeneratorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZED DRAW (Index based)
|
// Unified Draw Method
|
||||||
private drawUniqueCards(pool: DraftCard[], count: number, existingNames: Set<string>) {
|
private drawCards(pool: DraftCard[], count: number, existingNames: Set<string>, withReplacement: boolean) {
|
||||||
const selected: DraftCard[] = [];
|
if (pool.length === 0) return { selected: [], remainingPool: pool, success: false };
|
||||||
const skipped: DraftCard[] = [];
|
|
||||||
let poolIndex = 0;
|
|
||||||
|
|
||||||
// Use simple iteration
|
if (withReplacement) {
|
||||||
while (selected.length < count && poolIndex < pool.length) {
|
// Infinite Mode: Pick random cards, allow duplicates, do not modify pool
|
||||||
const card = pool[poolIndex];
|
const selected: DraftCard[] = [];
|
||||||
poolIndex++;
|
for (let i = 0; i < count; i++) {
|
||||||
|
const randomIndex = Math.floor(Math.random() * pool.length);
|
||||||
if (!existingNames.has(card.name)) {
|
// 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);
|
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[]) {
|
private shuffle(array: any[]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user