feat: Implement new peasant and standard pack generation algorithms, including special guest support and subset merging, and add relevant documentation.

This commit is contained in:
2025-12-20 19:53:48 +01:00
parent 418e9e4507
commit 139aca6f4f
10 changed files with 284 additions and 95 deletions

View File

@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.rc445urejpk"
"revision": "0.ucm0m4ajm9g"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@@ -144,7 +144,12 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
useEffect(() => {
if (rawScryfallData) {
// Use local images: true
const result = generatorService.processCards(rawScryfallData, filters, true);
const setsMetadata = availableSets.reduce((acc, set) => {
acc[set.code] = { parent_set_code: set.parent_set_code };
return acc;
}, {} as { [code: string]: { parent_set_code?: string } });
const result = generatorService.processCards(rawScryfallData, filters, true, setsMetadata);
setProcessedData(result);
}
}, [filters, rawScryfallData]);
@@ -217,12 +222,70 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
if (sourceMode === 'set') {
// Fetch set by set
for (const [index, setCode] of selectedSets.entries()) {
setProgress(`Fetching set ${setCode.toUpperCase()} (${index + 1}/${selectedSets.length})...`);
const response = await fetch(`/api/sets/${setCode}/cards`);
if (!response.ok) throw new Error(`Failed to fetch set ${setCode}`);
// Fetch sets (Grouping Main + Subsets)
// We iterate through selectedSets. If a set has children also in selectedSets (or auto-detected), we fetch them together.
// We need to avoid fetching the child set again if it was covered by the parent.
const processedSets = new Set<string>();
// We already have `effectiveSelectedSets` which includes auto-added ones.
// Let's re-derive effective logic locally for fetching.
const allSetsToProcess = [...selectedSets];
const linkedSubsets = availableSets.filter(s =>
s.parent_set_code &&
selectedSets.includes(s.parent_set_code) &&
s.code.length === 3 && // 3-letter code filter
!selectedSets.includes(s.code)
).map(s => s.code);
allSetsToProcess.push(...linkedSubsets);
let totalCards = 0;
let setIndex = 0;
for (const setCode of allSetsToProcess) {
if (processedSets.has(setCode)) continue;
// Check if this is a Main Set that has children in our list
// OR if it's a child that should be fetched with its parent?
// Actually, we should look for Main Sets first.
let currentMain = setCode;
let currentRelated: string[] = [];
// Find children of this set in our list
const children = allSetsToProcess.filter(s => {
const meta = availableSets.find(as => as.code === s);
return meta && meta.parent_set_code === currentMain;
});
// Also check if this set IS a child, and its parent is NOT in the list?
// If parent IS in the list, we skip this iteration and let the parent handle it?
const meta = availableSets.find(as => as.code === currentMain);
if (meta && meta.parent_set_code && allSetsToProcess.includes(meta.parent_set_code)) {
// This is a child, and we are processing the parent elsewhere. Skip.
// But wait, the loop order is undefined.
// Safest: always fetch by Main Set if possible.
// If we encounter a Child whose parent is in the list, we skip.
continue;
}
if (children.length > 0) {
currentRelated = children;
currentRelated.forEach(c => processedSets.add(c));
}
processedSets.add(currentMain);
setIndex++;
setProgress(`Fetching set ${currentMain.toUpperCase()} ${currentRelated.length > 0 ? `(+ ${currentRelated.join(', ').toUpperCase()})` : ''}...`);
const queryParams = currentRelated.length > 0 ? `?related=${currentRelated.join(',')}` : '';
const response = await fetch(`/api/sets/${currentMain}/cards${queryParams}`);
if (!response.ok) throw new Error(`Failed to fetch set ${currentMain}`);
const cards: ScryfallCard[] = await response.json();
currentCards.push(...cards);
setRawScryfallData(prev => [...(prev || []), ...cards]);
totalCards += cards.length;
}
} else {
@@ -249,10 +312,20 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
// --- Step 2: Generate ---
setProgress('Generating packs on server...');
// Re-calculation of effective sets for Payload is safe to match.
const payloadSetCodes = [...selectedSets];
const linkedPayload = availableSets.filter(s =>
s.parent_set_code &&
selectedSets.includes(s.parent_set_code) &&
s.code.length === 3 && // 3-letter code filter
!selectedSets.includes(s.code)
).map(s => s.code);
payloadSetCodes.push(...linkedPayload);
const payload = {
cards: sourceMode === 'upload' ? currentCards : [], // For set mode, we let server refetch or handle it
sourceMode,
selectedSets,
selectedSets: payloadSetCodes,
settings: {
...genSettings,
withReplacement: sourceMode === 'set'

View File

@@ -59,6 +59,7 @@ export interface ProcessedPools {
mythics: DraftCard[];
lands: DraftCard[];
tokens: DraftCard[];
specialGuests: DraftCard[];
}
export interface SetsMap {
@@ -71,6 +72,7 @@ export interface SetsMap {
mythics: DraftCard[];
lands: DraftCard[];
tokens: DraftCard[];
specialGuests: DraftCard[];
}
}
@@ -82,10 +84,11 @@ export interface PackGenerationSettings {
export class PackGeneratorService {
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }, useLocalImages: boolean = false): { pools: ProcessedPools, sets: SetsMap } {
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] };
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }, useLocalImages: boolean = false, setsMetadata: { [code: string]: { parent_set_code?: string } } = {}): { pools: ProcessedPools, sets: SetsMap } {
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [], specialGuests: [] };
const setsMap: SetsMap = {};
// 1. First Pass: Organize into SetsMap
cards.forEach(cardData => {
const rarity = cardData.rarity;
const typeLine = cardData.type_line || '';
@@ -159,10 +162,11 @@ export class PackGeneratorService {
else if (rarity === 'uncommon') pools.uncommons.push(cardObj);
else if (rarity === 'rare') pools.rares.push(cardObj);
else if (rarity === 'mythic') pools.mythics.push(cardObj);
else pools.specialGuests.push(cardObj); // Catch-all for special/bonus
// Add to Sets Map
if (!setsMap[cardData.set]) {
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] };
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [], specialGuests: [] };
}
const setEntry = setsMap[cardData.set];
@@ -182,6 +186,43 @@ export class PackGeneratorService {
else if (rarity === 'uncommon') { pools.uncommons.push(cardObj); setEntry.uncommons.push(cardObj); }
else if (rarity === 'rare') { pools.rares.push(cardObj); setEntry.rares.push(cardObj); }
else if (rarity === 'mythic') { pools.mythics.push(cardObj); setEntry.mythics.push(cardObj); }
else { pools.specialGuests.push(cardObj); setEntry.specialGuests.push(cardObj); } // Catch-all for special/bonus
}
});
// 2. Second Pass: Merge Subsets (Masterpieces) into Parents
Object.keys(setsMap).forEach(setCode => {
const meta = setsMetadata[setCode];
if (meta && meta.parent_set_code) {
const parentCode = meta.parent_set_code;
if (setsMap[parentCode]) {
const parentSet = setsMap[parentCode];
const childSet = setsMap[setCode];
// Move ALL cards from child set to parent's 'specialGuests' pool
// We iterate all pools of the child set
const allChildCards = [
...childSet.commons,
...childSet.uncommons,
...childSet.rares,
...childSet.mythics,
...childSet.specialGuests, // Include explicit specials
// ...childSet.lands, // usually keeps land separate? or special lands?
// Let's treat everything non-token as special guest candidate
];
parentSet.specialGuests.push(...allChildCards);
pools.specialGuests.push(...allChildCards);
// IMPORTANT: If we are in 'by_set' mode, we might NOT want to generate packs for the child set anymore?
// Or we leave them there but they are ALSO in the parent's special pool?
// The request implies "merged".
// If we leave them in setsMap under their own code, they will generate their own packs in 'by_set' mode.
// If the user selected BOTH, they probably want the "Special Guest" experience AND maybe separate packs?
// Usually "Drafting WOT" separately is possible.
// But "Drafting WOE" should include "WOT".
// So copying is correct.
}
}
});
@@ -198,7 +239,8 @@ export class PackGeneratorService {
rares: this.shuffle(pools.rares),
mythics: this.shuffle(pools.mythics),
lands: this.shuffle(pools.lands),
tokens: this.shuffle(pools.tokens)
tokens: this.shuffle(pools.tokens),
specialGuests: this.shuffle(pools.specialGuests)
};
let packId = 1;
@@ -224,7 +266,8 @@ export class PackGeneratorService {
rares: this.shuffle(setData.rares),
mythics: this.shuffle(setData.mythics),
lands: this.shuffle(setData.lands),
tokens: this.shuffle(setData.tokens)
tokens: this.shuffle(setData.tokens),
specialGuests: this.shuffle(setData.specialGuests)
};
while (true) {
@@ -453,17 +496,22 @@ export class PackGeneratorService {
}
} else {
// 98-100: Rare/Mythic/Special Guest
// Pick Rare or Mythic
// 98-99 (2%) vs 100 (1%) -> 2:1 ratio
// 1/100 (1%) chance for Special Guest if available
const isGuest = roll7 === 100;
const useMythic = isGuest || Math.random() < 0.2;
if (useMythic && currentPools.mythics.length > 0) {
const res = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
if (res.success) { slot7Card = res.selected[0]; currentPools.mythics = res.remainingPool; }
if (isGuest && currentPools.specialGuests.length > 0) {
const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
} else {
const res = this.drawUniqueCards(currentPools.rares, 1, namesInThisPack);
if (res.success) { slot7Card = res.selected[0]; currentPools.rares = res.remainingPool; }
// Fallback to Rare/Mythic
const useMythic = Math.random() < 0.125; // 1/8
if (useMythic && currentPools.mythics.length > 0) {
const res = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
if (res.success) { slot7Card = res.selected[0]; currentPools.mythics = res.remainingPool; }
} else {
const res = this.drawUniqueCards(currentPools.rares, 1, namesInThisPack);
if (res.success) { slot7Card = res.selected[0]; currentPools.rares = res.remainingPool; }
}
}
}

View File

@@ -162,14 +162,16 @@ export class ScryfallService {
const data = await response.json();
if (data.data) {
return data.data.filter((s: any) =>
['core', 'expansion', 'masters', 'draft_innovation'].includes(s.set_type)
['core', 'expansion', 'masters', 'draft_innovation', 'commander', 'funny', 'masterpiece', 'eternal'].includes(s.set_type)
).map((s: any) => ({
code: s.code,
name: s.name,
set_type: s.set_type,
released_at: s.released_at,
icon_svg_uri: s.icon_svg_uri,
digital: s.digital
digital: s.digital,
parent_set_code: s.parent_set_code,
card_count: s.card_count
}));
}
} catch (e) {
@@ -178,7 +180,7 @@ export class ScryfallService {
return [];
}
async fetchSetCards(setCode: string, onProgress?: (current: number) => void): Promise<ScryfallCard[]> {
async fetchSetCards(setCode: string, relatedSets: string[] = [], onProgress?: (current: number) => void): Promise<ScryfallCard[]> {
if (this.initPromise) await this.initPromise;
// Check if we already have a significant number of cards from this set in cache?
@@ -186,7 +188,9 @@ export class ScryfallService {
// But for now, we just fetch and merge.
let cards: ScryfallCard[] = [];
let url = `https://api.scryfall.com/cards/search?q=set:${setCode}&unique=cards`;
const setClause = `e:${setCode}` + relatedSets.map(s => ` OR e:${s}`).join('');
// User requested pattern: (e:main or e:sub) and is:booster unique=prints
let url = `https://api.scryfall.com/cards/search?q=(${setClause}) unique=prints is:booster`;
while (url) {
try {
@@ -228,4 +232,6 @@ export interface ScryfallSet {
released_at: string;
icon_svg_uri: string;
digital: boolean;
parent_set_code?: string;
card_count: number;
}