export type CombinationSearchCard = { id: string; characterId: string; }; type BeamState = { cards: T[]; score: number; }; export type CombinationSearchLimits = { openSlots: number; screeningLimit: number; baseExactFinalistLimit: number; alternativeExactFinalistLimit: number; }; export type CombinationScoringStrategy = { scoreCard: (card: T) => number; scoreCombination?: (cards: readonly T[]) => number; }; const SCREENING_LIMIT_PER_OPEN_SLOT = 5_000; const EXACT_FINALIST_LIMIT_PER_OPEN_SLOT = 56; export function withinMediumProbabilityCardLimit( cards: readonly { details: { active: readonly { probabilityPercent?: number | null }[] } }[], maximum: number | null ): boolean { return maximum === null || cards.filter((card) => card.details.active.some((level) => level.probabilityPercent === 46) ).length <= maximum; } export function combinationSearchLimits(fixedCardCount: number, targetSize = 5): CombinationSearchLimits { const normalizedTargetSize = Math.max(1, Math.floor(targetSize)); const normalizedFixedCardCount = Math.min(normalizedTargetSize, Math.max(0, Math.floor(fixedCardCount))); const openSlots = Math.max(1, normalizedTargetSize - normalizedFixedCardCount); return { openSlots, screeningLimit: SCREENING_LIMIT_PER_OPEN_SLOT * openSlots, baseExactFinalistLimit: EXACT_FINALIST_LIMIT_PER_OPEN_SLOT * openSlots, alternativeExactFinalistLimit: EXACT_FINALIST_LIMIT_PER_OPEN_SLOT * openSlots }; } export function resolveCandidateAwakening( configuredAwakening: number, owned: boolean, ownedAwakening: number ): number { return owned ? Math.max(configuredAwakening, ownedAwakening) : configuredAwakening; } export function generateCombinationCandidates( fixedCards: readonly T[], candidateCards: readonly T[], targetSize: number, scoreCard: (card: T) => number, beamWidth = 800, scoreCombination?: (cards: readonly T[]) => number, // Must accept valid partial combinations, as it is checked at every expansion. acceptsCombination?: (cards: readonly T[]) => boolean ): T[][] { if (targetSize < fixedCards.length) throw new Error('목표 카드 수가 고정 카드 수보다 작습니다.'); if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) { throw new Error('같은 멤버를 두 번 고정할 수 없습니다.'); } if (acceptsCombination && !acceptsCombination(fixedCards)) return []; const fixedIds = new Set(fixedCards.map((card) => card.id)); const fixedCharacters = new Set(fixedCards.map((card) => card.characterId)); const pool = candidateCards.filter((card) => !fixedIds.has(card.id) && !fixedCharacters.has(card.characterId)); let beam: BeamState[] = [{ cards: Array.from(fixedCards), score: scoreCombination?.(fixedCards) ?? fixedCards.reduce((sum, card) => sum + scoreCard(card), 0) }]; for (let size = fixedCards.length; size < targetSize; size += 1) { const expanded = new Map>(); for (const state of beam) { const usedCharacters = new Set(state.cards.map((card) => card.characterId)); const usedIds = new Set(state.cards.map((card) => card.id)); for (const card of pool) { if (usedIds.has(card.id)) continue; if (usedCharacters.has(card.characterId)) continue; const cards = [...state.cards, card]; if (acceptsCombination && !acceptsCombination(cards)) continue; const nextState = { cards, score: scoreCombination?.(cards) ?? state.score + scoreCard(card) }; const key = nextState.cards.map((entry) => entry.id).sort().join('|'); if (!expanded.has(key)) expanded.set(key, nextState); } } beam = Array.from(expanded.values()) .sort((left, right) => right.score - left.score || left.cards.map((card) => card.id).join().localeCompare(right.cards.map((card) => card.id).join())) .slice(0, Math.max(1, beamWidth)); if (beam.length === 0) break; } return beam .filter((state) => state.cards.length === targetSize) .sort((left, right) => right.score - left.score) .map((state) => state.cards); } export function generateAnchoredCombinationCandidates( fixedCards: readonly T[], candidateCards: readonly T[], targetSize: number, strategies: readonly CombinationScoringStrategy[], perAnchorLimit: number, acceptsCombination?: (cards: readonly T[]) => boolean ): T[][] { if (strategies.length === 0 || perAnchorLimit <= 0 || fixedCards.length >= targetSize) return []; const fixedCharacters = new Set(fixedCards.map((card) => card.characterId)); const anchors = candidateCards.filter((card) => !fixedCharacters.has(card.characterId)); const perStrategyLimit = Math.max(1, Math.ceil(perAnchorLimit / strategies.length)); const combined = new Map(); for (const anchor of anchors) { const anchored = new Map(); for (const strategy of strategies) { for (const cards of generateCombinationCandidates( [...fixedCards, anchor], candidateCards, targetSize, strategy.scoreCard, perStrategyLimit, strategy.scoreCombination, acceptsCombination )) { anchored.set(cards.map((card) => card.id).sort().join('|'), cards); } } for (const [key, cards] of Array.from(anchored.entries()).slice(0, perAnchorLimit)) { combined.set(key, cards); } } return Array.from(combined.values()); } export function selectMemberDiverseCandidates( candidates: readonly T[], cardsOf: (candidate: T) => readonly { id: string }[], scoreOf: (candidate: T) => number, fixedCardIds: readonly string[], limit: number ): T[] { if (limit <= 0) return []; const fixedIds = new Set(fixedCardIds); const ranked = [...candidates].sort((left, right) => scoreOf(right) - scoreOf(left)); const bestByCardId = new Map(); for (const candidate of ranked) { for (const card of cardsOf(candidate)) { if (!fixedIds.has(card.id) && !bestByCardId.has(card.id)) bestByCardId.set(card.id, candidate); } } const selected = new Map(); for (const candidate of bestByCardId.values()) { const key = cardsOf(candidate).map((card) => card.id).sort().join('|'); selected.set(key, candidate); if (selected.size >= limit) break; } return Array.from(selected.values()); }