import { COOLDOWN_REDUCTIONS } from './types.ts'; import type { CooldownReduction, OptimizeOrderOptions, OptimizerCard, SimulationResult, SongChartDensity } from './types.ts'; import { buildActiveWindows, buildSpecialWindows, simulateOrder } from './timeline.ts'; function permutations(items: readonly T[]): T[][] { if (items.length <= 1) return [Array.from(items)]; const result: T[][] = []; for (let index = 0; index < items.length; index += 1) { const rest = [...items.slice(0, index), ...items.slice(index + 1)]; for (const permutation of permutations(rest)) result.push([items[index], ...permutation]); } return result; } function reductionAssignments(cards: readonly OptimizerCard[]): Array> { let assignments: Array> = [{}]; for (const card of cards) { const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS; assignments = assignments.flatMap((assignment) => allowed.map((reduction) => ({ ...assignment, [card.id]: reduction })) ); } return assignments; } function reductionTotal(reductions: Readonly>): number { return Object.values(reductions).reduce((sum, reduction) => sum + reduction, 0); } function fillWindowProfile( profile: Float64Array, windows: ReturnType, stepSeconds: number ): void { for (const window of windows) { const startTick = Math.max(0, Math.ceil(window.startSeconds / stepSeconds - 1e-9)); const endTick = Math.min(profile.length, Math.ceil(window.endSeconds / stepSeconds - 1e-9)); for (let tick = startTick; tick < endTick; tick += 1) { profile[tick] = Math.max(profile[tick], window.scoreUpPercent); } } } function makeNoteWeights(chart: SongChartDensity, stepSeconds: number, tickCount: number): Float64Array { const weights = new Float64Array(tickCount); for (let tick = 0; tick < tickCount; tick += 1) { const timeSeconds = tick * stepSeconds; const second = Math.floor(timeSeconds); weights[tick] = (chart.notesPerSecond[second] ?? 0) * Math.min(stepSeconds, chart.durationSeconds - timeSeconds); } return weights; } export function optimizeOrderAndCooldowns( cards: readonly OptimizerCard[], chart: SongChartDensity, options: OptimizeOrderOptions = {} ): SimulationResult { if (cards.length !== 5) throw new Error('최고점 순서 분석에는 정확히 5장의 카드가 필요합니다.'); if (new Set(cards.map((card) => card.id)).size !== cards.length) throw new Error('카드 ID는 서로 달라야 합니다.'); const stepSeconds = options.stepSeconds ?? 0.1; if (!Number.isFinite(stepSeconds) || stepSeconds <= 0) throw new Error('계산 간격은 0보다 커야 합니다.'); const orders = permutations(cards); const assignments = reductionAssignments(cards); const totalCandidates = orders.length * assignments.length; const tickCount = Math.ceil(chart.durationSeconds / stepSeconds); const noteWeights = makeNoteWeights(chart, stepSeconds, tickCount); const activeProfiles = new Map(); for (const card of cards) { const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS; for (const reduction of allowed) { const profile = new Float64Array(tickCount); fillWindowProfile( profile, buildActiveWindows(card, reduction, chart.durationSeconds, stepSeconds), stepSeconds ); activeProfiles.set(`${card.id}:${reduction}`, profile); } } const specialMultipliers = orders.map((order) => { const profile = new Float64Array(tickCount); fillWindowProfile(profile, buildSpecialWindows(order, chart), stepSeconds); for (let tick = 0; tick < tickCount; tick += 1) profile[tick] = 1 + profile[tick] / 100; return profile; }); let evaluatedCandidates = 0; let bestScoreWeight = Number.NEGATIVE_INFINITY; let bestOrder: OptimizerCard[] | null = null; let bestReductions: Record | null = null; let bestReductionTotal = Number.POSITIVE_INFINITY; for (const reductions of assignments) { const activeScore = new Float64Array(tickCount); for (const card of cards) { const reduction = reductions[card.id] ?? 0; const profile = activeProfiles.get(`${card.id}:${reduction}`); if (!profile) throw new Error(`${card.name}의 쿨타임 감소 ${reduction}% 계산값이 없습니다.`); for (let tick = 0; tick < tickCount; tick += 1) { activeScore[tick] = Math.max(activeScore[tick], profile[tick]); } } const currentReductionTotal = reductionTotal(reductions); for (let orderIndex = 0; orderIndex < orders.length; orderIndex += 1) { const specialMultiplier = specialMultipliers[orderIndex]; let scoreWeight = 0; for (let tick = 0; tick < tickCount; tick += 1) { scoreWeight += noteWeights[tick] * activeScore[tick] * specialMultiplier[tick]; } const isBetterScore = scoreWeight > bestScoreWeight + 1e-9; const isCheaperTie = Math.abs(scoreWeight - bestScoreWeight) <= 1e-9 && currentReductionTotal < bestReductionTotal; if (isBetterScore || isCheaperTie) { bestScoreWeight = scoreWeight; bestOrder = orders[orderIndex]; bestReductions = reductions; bestReductionTotal = currentReductionTotal; } evaluatedCandidates += 1; } options.onProgress?.({ evaluatedCandidates, totalCandidates }); } if (!bestOrder || !bestReductions) throw new Error('분석할 수 있는 후보가 없습니다.'); return simulateOrder(bestOrder, bestReductions, chart, stepSeconds); } export function countOptimizationCandidates(cards: readonly OptimizerCard[]): number { const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1); const reductionCount = cards.reduce( (value, card) => value * (card.allowedCooldownReductions?.length || COOLDOWN_REDUCTIONS.length), 1 ); return orderCount * reductionCount; }