From 139aca6f4f208b3644a6cd1fbca9212d26f18830 Mon Sep 17 00:00:00 2001 From: dnviti Date: Sat, 20 Dec 2025 19:53:48 +0100 Subject: [PATCH] feat: Implement new peasant and standard pack generation algorithms, including special guest support and subset merging, and add relevant documentation. --- .agent/rules/pack-generation-algorithm.md | 45 ---------- .../peasant-pack-generation-algorithm.md | 15 ++++ .../standard-pack-generation-algorithm.md | 20 +++++ src/client/dev-dist/sw.js | 2 +- src/client/src/modules/cube/CubeManager.tsx | 87 +++++++++++++++++-- .../src/services/PackGeneratorService.ts | 74 +++++++++++++--- src/client/src/services/ScryfallService.ts | 14 ++- src/server/index.ts | 16 +++- src/server/services/PackGeneratorService.ts | 72 +++++++++++++-- src/server/services/ScryfallService.ts | 34 +++++--- 10 files changed, 284 insertions(+), 95 deletions(-) delete mode 100644 .agent/rules/pack-generation-algorithm.md create mode 100644 docs/development/mtg-rulebook/peasant-pack-generation-algorithm.md create mode 100644 docs/development/mtg-rulebook/standard-pack-generation-algorithm.md diff --git a/.agent/rules/pack-generation-algorithm.md b/.agent/rules/pack-generation-algorithm.md deleted file mode 100644 index 2c2aeb6..0000000 --- a/.agent/rules/pack-generation-algorithm.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -trigger: always_on ---- - -Valid for all generations: - - If foils are not available in the pool, ignore the foil generation - -STANDARD GENERATION: - -Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors). -Slot 7 (Common/List Slot): - - Roll a d100. - - 1-87: 1 Common from Main Set. - - 88-97: 1 Card from "The List" (Common/Uncommon reprint). - - 98-99: 1 Rare/Mythic from "The List". - - 100: 1 Special Guest (High Value). -Slots 8-10 (Uncommons): 3 Uncommon cards. -Slot 11 (Main Rare Slot): - - Roll 1d8. - - If 1-7: Rare. - - If 8: Mythic Rare. -Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil). -Slot 13 (Non-Foil Wildcard): - - Can be any rarity (Common, Uncommon, Rare, Mythic). - - Use weighted probability: ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic (simplified for simulation). -Slot 14 (Foil Wildcard): - - Same rarity weights as Slot 13, but the card must be Foil. -Slot 15 (Marketing): Token or Art Card. - -PEASANT GENERATION: - -Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors). -Slot 7 (Common/List Slot): - - Roll a d100. - - 1-87: 1 Common from Main Set. - - 88-97: 1 Card from "The List" (Common/Uncommon reprint). - - 98-100: 1 Uncommon from "The List". -Slots 8-11 (Uncommons): 4 Uncommon cards. -Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil). -Slot 13 (Non-Foil Wildcard): - - Can be any rarity (Common, Uncommon, Rare, Mythic). - - Use weighted probability: ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic (simplified for simulation). -Slot 14 (Foil Wildcard): - - Same rarity weights as Slot 13, but the card must be Foil. -Slot 15 (Marketing): Token or Art Card. diff --git a/docs/development/mtg-rulebook/peasant-pack-generation-algorithm.md b/docs/development/mtg-rulebook/peasant-pack-generation-algorithm.md new file mode 100644 index 0000000..3d7512d --- /dev/null +++ b/docs/development/mtg-rulebook/peasant-pack-generation-algorithm.md @@ -0,0 +1,15 @@ +Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors). +Slot 7 (Common/List Slot): + - Roll a d100. + - 1-87: 1 Common from Main Set. + - 88-97: 1 Card from "The List" (Common/Uncommon reprint). + - 98-100: 1 Uncommon from "The List". +Slots 8-11 (Uncommons): 4 Uncommon cards. +Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil). +Slot 13 (Non-Foil Wildcard): + - Can be any rarity (Common, Uncommon, Rare, Mythic). + - Use weighted probability: ~62% Common, ~37% Uncommon. + - Can be a card from the child sets. +Slot 14 (Foil Wildcard): + - Same rarity weights as Slot 13, but the card must be Foil. +Slot 15 (Marketing): Token or Art Card. diff --git a/docs/development/mtg-rulebook/standard-pack-generation-algorithm.md b/docs/development/mtg-rulebook/standard-pack-generation-algorithm.md new file mode 100644 index 0000000..4812cbe --- /dev/null +++ b/docs/development/mtg-rulebook/standard-pack-generation-algorithm.md @@ -0,0 +1,20 @@ +Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors). +Slot 7 (Common/List Slot): + - Roll a d100. + - 1-87: 1 Common from Main Set. + - 88-97: 1 Card from "The List" (Common/Uncommon reprint). + - 98-99: 1 Rare/Mythic from "The List". + - 100: 1 Special Guest (High Value). +Slots 8-10 (Uncommons): 3 Uncommon cards. +Slot 11 (Main Rare Slot): + - Roll 1d8. + - If 1-7: Rare. + - If 8: Mythic Rare. +Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil). +Slot 13 (Non-Foil Wildcard): + - Can be any rarity (Common, Uncommon, Rare, Mythic). + - Use weighted probability: ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic. + - Can be a card from the child sets. +Slot 14 (Foil Wildcard): + - Same rarity weights as Slot 13, but the card must be Foil. +Slot 15 (Marketing): Token or Art Card. \ No newline at end of file diff --git a/src/client/dev-dist/sw.js b/src/client/dev-dist/sw.js index 5036f75..301187d 100644 --- a/src/client/dev-dist/sw.js +++ b/src/client/dev-dist/sw.js @@ -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"), { diff --git a/src/client/src/modules/cube/CubeManager.tsx b/src/client/src/modules/cube/CubeManager.tsx index 167075a..2f8d2af 100644 --- a/src/client/src/modules/cube/CubeManager.tsx +++ b/src/client/src/modules/cube/CubeManager.tsx @@ -144,7 +144,12 @@ export const CubeManager: React.FC = ({ 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 = ({ 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(); + + // 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 = ({ 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' diff --git a/src/client/src/services/PackGeneratorService.ts b/src/client/src/services/PackGeneratorService.ts index ba82bab..2f47b19 100644 --- a/src/client/src/services/PackGeneratorService.ts +++ b/src/client/src/services/PackGeneratorService.ts @@ -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; } + } } } diff --git a/src/client/src/services/ScryfallService.ts b/src/client/src/services/ScryfallService.ts index 4a2bcb0..dc2dda1 100644 --- a/src/client/src/services/ScryfallService.ts +++ b/src/client/src/services/ScryfallService.ts @@ -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 { + async fetchSetCards(setCode: string, relatedSets: string[] = [], onProgress?: (current: number) => void): Promise { 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; } diff --git a/src/server/index.ts b/src/server/index.ts index 2c7feff..14b4e76 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -130,7 +130,8 @@ app.get('/api/sets', async (_req: Request, res: Response) => { app.get('/api/sets/:code/cards', async (req: Request, res: Response) => { try { - const cards = await scryfallService.fetchSetCards(req.params.code); + const related = req.query.related ? (req.query.related as string).split(',') : []; + const cards = await scryfallService.fetchSetCards(req.params.code, related); // Implicitly cache images for these cards so local URLs work if (cards.length > 0) { @@ -218,7 +219,18 @@ app.post('/api/packs/generate', async (req: Request, res: Response) => { ignoreTokens: false }; - const { pools, sets } = packGeneratorService.processCards(poolCards, activeFilters); + // Fetch metadata for merging subsets + const allSets = await scryfallService.fetchSets(); + const setsMetadata: { [code: string]: { parent_set_code?: string } } = {}; + if (allSets && Array.isArray(allSets)) { + allSets.forEach((s: any) => { + if (selectedSets && selectedSets.includes(s.code)) { + setsMetadata[s.code] = { parent_set_code: s.parent_set_code }; + } + }); + } + + const { pools, sets } = packGeneratorService.processCards(poolCards, activeFilters, setsMetadata); // Extract available basic lands for deck building const basicLands = pools.lands.filter(c => c.typeLine?.includes('Basic')); diff --git a/src/server/services/PackGeneratorService.ts b/src/server/services/PackGeneratorService.ts index 78fe326..bc424c3 100644 --- a/src/server/services/PackGeneratorService.ts +++ b/src/server/services/PackGeneratorService.ts @@ -34,6 +34,7 @@ export interface ProcessedPools { mythics: DraftCard[]; lands: DraftCard[]; tokens: DraftCard[]; + specialGuests: DraftCard[]; } export interface SetsMap { @@ -46,6 +47,7 @@ export interface SetsMap { mythics: DraftCard[]; lands: DraftCard[]; tokens: DraftCard[]; + specialGuests: DraftCard[]; } } @@ -57,9 +59,9 @@ export interface PackGenerationSettings { export class PackGeneratorService { - processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }): { pools: ProcessedPools, sets: SetsMap } { + processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }, setsMetadata: { [code: string]: { parent_set_code?: string } } = {}): { pools: ProcessedPools, sets: SetsMap } { console.time('processCards'); - const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] }; + const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [], specialGuests: [] }; const setsMap: SetsMap = {}; let processedCount = 0; @@ -118,10 +120,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); // 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]; @@ -147,11 +150,38 @@ 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); } } processedCount++; }); + // 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]; + + const allChildCards = [ + ...childSet.commons, + ...childSet.uncommons, + ...childSet.rares, + ...childSet.mythics, + ...childSet.specialGuests + ]; + + parentSet.specialGuests.push(...allChildCards); + pools.specialGuests.push(...allChildCards); + + // Remove child set from map so we don't generate separate packs for it + delete setsMap[setCode]; + } + } + }); + console.log(`[PackGenerator] Processed ${processedCount} cards.`); console.timeEnd('processCards'); return { pools, sets: setsMap }; @@ -176,7 +206,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]) }; // Log pool sizes @@ -197,7 +228,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]) }; } @@ -256,7 +288,8 @@ export class PackGeneratorService { rares: this.shuffle([...data.rares]), mythics: this.shuffle([...data.mythics]), lands: this.shuffle([...data.lands]), - tokens: this.shuffle([...data.tokens]) + tokens: this.shuffle([...data.tokens]), + specialGuests: this.shuffle([...data.specialGuests]) }; let packsGeneratedForSet = 0; @@ -276,7 +309,8 @@ export class PackGeneratorService { rares: this.shuffle([...data.rares]), mythics: this.shuffle([...data.mythics]), lands: this.shuffle([...data.lands]), - tokens: this.shuffle([...data.tokens]) + tokens: this.shuffle([...data.tokens]), + specialGuests: this.shuffle([...data.specialGuests]) }; } @@ -331,9 +365,29 @@ export class PackGeneratorService { // Common draw(pools.commons, 1, 'commons'); } else { - // Uncommon/List // If pool empty, try fallback if standard? No, strict as per previous instruction. - draw(pools.uncommons, 1, 'uncommons'); + // draw(pools.uncommons, 1, 'uncommons'); + + // Slot 7: Common/List Slot (Strict adherence to standard-pack-generation-algorithm.md) + // 1-87: Common from Main Set + // 88-97: Card from The List (Common/Uncommon) -> Mapped to specialGuests + // 98-99: Rare/Mythic from The List -> Mapped to specialGuests + // 100: Special Guest -> Mapped to specialGuests + + // We simplify by drawing from specialGuests for 88-100 (13% chance) + // if specialGuests is empty, fallback to Common or Uncommon? + // Rulebook says "1-87 Common". + + const hasGuests = pools.specialGuests.length > 0; + + if (roll7 > 87 && hasGuests) { + draw(pools.specialGuests, 1, 'specialGuests'); + } else { + // 1-87 or no guests available + draw(pools.commons, 1, 'commons'); + // Note: If original rule for 88-97 was "Common from List", + // and we fall back to "Common from Set", it's acceptable if List/Guests missing. + } } // 3. Uncommons (3 or 4 dependent on PEASANT vs STANDARD) diff --git a/src/server/services/ScryfallService.ts b/src/server/services/ScryfallService.ts index 0d4073a..7067963 100644 --- a/src/server/services/ScryfallService.ts +++ b/src/server/services/ScryfallService.ts @@ -193,13 +193,15 @@ export class ScryfallService { const data = await resp.json(); const sets = data.data - .filter((s: any) => ['core', 'expansion', 'masters', 'draft_innovation', 'commander', 'funny'].includes(s.set_type)) + .filter((s: any) => ['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, - digital: s.digital + digital: s.digital, + parent_set_code: s.parent_set_code, + card_count: s.card_count })); return sets; @@ -209,7 +211,7 @@ export class ScryfallService { } } - async fetchSetCards(setCode: string): Promise { + async fetchSetCards(setCode: string, relatedSets: string[] = []): Promise { const setHash = setCode.toLowerCase(); const setCachePath = path.join(SETS_DIR, `${setHash}.json`); @@ -226,26 +228,30 @@ export class ScryfallService { } } - console.log(`[ScryfallService] Fetching cards for set ${setCode} from API...`); + console.log(`[ScryfallService] Fetching cards for set ${setCode} (related: ${relatedSets.join(',')}) from API...`); let allCards: ScryfallCard[] = []; - let url = `https://api.scryfall.com/cards/search?q=set:${setCode}&unique=cards`; + + // Construct Composite Query: (e:main OR e:sub1 OR e:sub2) is:booster unique=prints + const setClause = `e:${setCode}` + relatedSets.map(s => ` OR e:${s}`).join(''); + let url = `https://api.scryfall.com/cards/search?q=(${setClause}) unique=prints is:booster`; try { while (url) { - console.log(`[ScryfallService] Requesting: ${url}`); - const r = await fetch(url); - if (!r.ok) { - if (r.status === 404) { + console.log(`[ScryfallService] [API CALL] Requesting: ${url}`); + const resp = await fetch(url); + console.log(`[ScryfallService] [API RESPONSE] Status: ${resp.status}`); + + if (!resp.ok) { + if (resp.status === 404) { console.warn(`[ScryfallService] 404 Not Found for URL: ${url}. Assuming set has no cards.`); break; } - - const errBody = await r.text(); - console.error(`[ScryfallService] Error fetching ${url}: ${r.status} ${r.statusText}`, errBody); - throw new Error(`Failed to fetch set: ${r.statusText} (${r.status}) - ${errBody}`); + const errBody = await resp.text(); + console.error(`[ScryfallService] Error fetching ${url}: ${resp.status} ${resp.statusText}`, errBody); + throw new Error(`Failed to fetch set: ${resp.statusText} (${resp.status}) - ${errBody}`); } - const d = await r.json(); + const d = await resp.json(); if (d.data) { allCards.push(...d.data);