import { rankExactCooldownCombinations as rankExpectedCooldownCombinations } from './exact-optimizer-expected.ts'; import { effectiveActiveProbability } from './active-probability.ts'; import { calculateExactScore, finalNoteScore, specialScoreSupportForNote } from './score.ts'; import { passiveScoreSupportByCard } from './passive-support.ts'; import { effectiveCooldownSeconds } from './timeline.ts'; import { COOLDOWN_REDUCTIONS } from './types.ts'; import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard, SkillWindow } from './types.ts'; type CandidateProfile = { boost: Float64Array; probability: Float64Array; }; type NoteGroup = { note: ExactSongChart['notes'][number]; comboBonus: number; count: number }; /** Keep exact timing, note semantics and the original combo tier. */ export function groupExactOptimizationNotes(chart: ExactSongChart): NoteGroup[] { const groups = new Map(); chart.notes.forEach((note, index) => { const bonus = comboBonus(index + 1); const key = `${note.timeSeconds}|${note.type === 'mid'}|${Boolean(note.isFlick)}|${Boolean(note.isHoldEnd)}|${bonus}`; const group = groups.get(key); if (group) group.count += 1; else groups.set(key, { note, comboBonus: bonus, count: 1 }); }); return [...groups.values()]; } function optimizationNotes(cards: readonly OptimizerCard[], chart: ExactSongChart, options: ExactOptimizeOptions): NoteGroup[] { // Grouped integer additions are exact only within Number's integer precision. // For unusually large/invalid input, retain every note in its original order. const supportMagnitude = cards.reduce((sum, card) => sum + Math.abs(card.special.scoreUpPercent) + Math.abs(card.scoreSupportPercent ?? 0) + (card.passiveScoreSupportEffects ?? []).reduce((n, effect) => n + Math.abs(effect.percent), 0), 0); const boostMagnitude = Math.max(...cards.map((card) => Math.abs(card.active.scoreUpPercent))) / 100 * (1 + supportMagnitude / 100); const totalMagnitude = ((Math.abs(options.perfectNoteScore) + 1) * 1.155 * (1 + boostMagnitude) * Math.abs(1 + (options.directSongScoreBonusPercent ?? 0) / 100) + 1) * chart.notes.length; return Number.isFinite(totalMagnitude) && totalMagnitude < Number.MAX_SAFE_INTEGER / 2 ? groupExactOptimizationNotes(chart) : chart.notes.map((note, index) => ({ note, comboBonus: comboBonus(index + 1), count: 1 })); } 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 assignments(cards: readonly OptimizerCard[]): Array> { let result: Array> = [{}]; for (const card of cards) { const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS; result = result.flatMap((current) => allowed.map((reduction) => ({ ...current, [card.id]: reduction }))); } return result; } function specialActive(card: OptimizerCard, start: number | null | undefined, time: number): boolean { return start !== null && start !== undefined && start <= time && time < start + card.special.durationSeconds; } function specialRate( order: readonly OptimizerCard[], chart: ExactSongChart, time: number, field: 'scoreUpPercent' | 'activationRateUpPercent' ): number { let total = 0; for (let slot = 0; slot < order.length; slot += 1) { const card = order[slot]; if (specialActive(card, chart.specialStartSeconds[slot], time)) total += card.special[field] ?? 0; } return total; } function comboBonus(combo: number): number { return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0; } function baseScores(groups: readonly NoteGroup[], perfectNoteScore: number): Float64Array { return Float64Array.from(groups, ({ note, comboBonus }) => { const base = note.type === 'mid' ? Math.ceil(perfectNoteScore * 0.1) : perfectNoteScore * (note.isFlick ? 1.05 : 1); return base * (1 + comboBonus); }); } function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart, groups: readonly NoteGroup[]): Map { const profiles = new Map(); const noteSupport = Float64Array.from(groups, ({ note }) => specialScoreSupportForNote(order, chart, note)); const passiveSupport = passiveScoreSupportByCard(order); order.forEach((card, cardIndex) => { const reductions = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS; reductions.forEach((reduction) => { const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction); const boost = new Float64Array(groups.length); const probability = new Float64Array(groups.length); const probabilityByAttempt = new Map(); groups.forEach(({ note }, noteIndex) => { const attempt = Math.floor((note.timeSeconds + 1e-9) / interval); if (attempt <= 0) return; const activationTime = attempt * interval; if (note.timeSeconds >= activationTime + card.active.durationSeconds) return; let effectiveProbability = probabilityByAttempt.get(attempt); if (effectiveProbability === undefined) { const activationBonus = specialRate(order, chart, activationTime, 'activationRateUpPercent'); effectiveProbability = effectiveActiveProbability(card, activationBonus); probabilityByAttempt.set(attempt, effectiveProbability); } boost[noteIndex] = card.active.scoreUpPercent / 100 * (1 + (noteSupport[noteIndex] + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100); probability[noteIndex] = effectiveProbability; }); profiles.set(`${card.id}:${reduction}`, { boost, probability }); }); }); return profiles; } export function buildExactSkillWindows( order: readonly OptimizerCard[], reductions: Readonly>, chart: ExactSongChart, excludedActiveWindows: readonly Pick[] = [] ): { activeWindows: SkillWindow[]; effectiveActiveWindows: EffectiveSkillWindow[]; specialWindows: SkillWindow[] } { const activeWindows = order.flatMap((card) => { const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reductions[card.id] ?? 0); const windows: SkillWindow[] = []; for (let activation = 1; activation * interval < chart.durationSeconds - 1e-9; activation += 1) { const startSeconds = activation * interval; windows.push({ cardId: card.id, startSeconds, endSeconds: Math.min(chart.durationSeconds, startSeconds + card.active.durationSeconds), scoreUpPercent: card.active.scoreUpPercent }); } return windows; }); const specialWindows = order.flatMap((card, slot) => { const startSeconds = chart.specialStartSeconds[slot]; if (startSeconds === null || startSeconds === undefined) return []; return [{ cardId: card.id, startSeconds, endSeconds: Math.min(chart.durationSeconds, startSeconds + card.special.durationSeconds), scoreUpPercent: card.special.scoreUpPercent }]; }); const passiveSupport = passiveScoreSupportByCard(order); const orderIndex = new Map(order.map((card, index) => [card.id, index])); const boundaries = Array.from(new Set([ ...activeWindows.flatMap((window) => [window.startSeconds, window.endSeconds]), ...specialWindows.flatMap((window) => [window.startSeconds, window.endSeconds]) ])).sort((left, right) => left - right); const effectiveActiveWindows: EffectiveSkillWindow[] = []; for (let index = 0; index < boundaries.length - 1; index += 1) { const startSeconds = boundaries[index]; const endSeconds = boundaries[index + 1]; if (endSeconds - startSeconds < 0.01) continue; const midpoint = (startSeconds + endSeconds) / 2; const specialSupport = specialRate(order, chart, midpoint, 'scoreUpPercent'); const active = activeWindows .filter((window) => window.startSeconds <= midpoint && midpoint < window.endSeconds && !excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && Math.abs(excluded.startSeconds - window.startSeconds) < 1e-9)) .map((window) => { const cardIndex = orderIndex.get(window.cardId) ?? 0; const card = order[cardIndex]; return { window, effectivePercent: window.scoreUpPercent * (1 + (specialSupport + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100) }; }) .sort((left, right) => right.effectivePercent - left.effectivePercent || (orderIndex.get(left.window.cardId) ?? 99) - (orderIndex.get(right.window.cardId) ?? 99)); const winner = active[0]; if (!winner) continue; const previous = effectiveActiveWindows.at(-1); if (previous && previous.cardId === winner.window.cardId && Math.abs(previous.scoreUpPercent - winner.effectivePercent) < 1e-9 && Math.abs(previous.endSeconds - startSeconds) < 0.01) { previous.endSeconds = endSeconds; previous.overlapCount = Math.max(previous.overlapCount, active.length); } else { effectiveActiveWindows.push({ ...winner.window, startSeconds, endSeconds, baseScoreUpPercent: winner.window.scoreUpPercent, scoreUpPercent: winner.effectivePercent, overlapCount: active.length }); } } return { activeWindows, effectiveActiveWindows, specialWindows }; } /** Share prefix maxima across all cooldown assignments; round each note exactly as before. */ function scoreMaximumAssignments( cards: readonly OptimizerCard[], profiles: ReadonlyMap, base: Float64Array, directBonus: number, counts: Float64Array ): Float64Array | null { if (!Number.isFinite(directBonus) || directBonus < -100 || base.some(value => !Number.isFinite(value) || value < 0)) return null; const choices = cards.map(card => (card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS) .map(reduction => profiles.get(card.id + ':' + reduction)!)); if (choices.some(row => row.some(profile => !profile || profile.boost.some(value => !Number.isFinite(value))))) return null; const widths = choices.map(row => row.length); const sizes = [1]; for (const width of widths) sizes.push(sizes.at(-1)! * width); const totals = new Float64Array(sizes.at(-1)!); const prefix = sizes.slice(0, -1).map(size => new Float64Array(size)); const offsets = [1]; for (const width of widths) offsets.push(offsets.at(-1)! + width); // Notes with the same rounded scores for every selectable profile make identical contributions. const states = new Map(); let magnitude = 0; for (let noteIndex = 0; noteIndex < base.length; noteIndex++) { const baseline = finalNoteScore(base[noteIndex], directBonus); const scores = [baseline]; for (const row of choices) for (const profile of row) { scores.push(finalNoteScore(base[noteIndex] * (1 + Math.max(0, profile.boost[noteIndex])), directBonus)); } const largest = Math.max(...scores); magnitude += largest * counts[noteIndex]; if (!Number.isSafeInteger(largest) || magnitude >= Number.MAX_SAFE_INTEGER / 2) return null; const key = scores.join(','); const previous = states.get(key); if (previous) previous.count += counts[noteIndex]; else states.set(key, { scores, count: counts[noteIndex] }); } for (const { scores, count } of states.values()) { prefix[0][0] = scores[0]; for (let member = 0; member < cards.length - 1; member++) { const previous = prefix[member], next = prefix[member + 1], width = widths[member], offset = offsets[member]; let index = 0; for (let p = 0; p < previous.length; p++) for (let choice = 0; choice < width; choice++) { next[index++] = Math.max(previous[p], scores[offset + choice]); } } const member = cards.length - 1, previous = prefix[member], width = widths[member], offset = offsets[member]; let index = 0; for (let p = 0; p < previous.length; p++) for (let choice = 0; choice < width; choice++) { totals[index++] += Math.max(previous[p], scores[offset + choice]) * count; } } return totals; } function scoreMaximumAssignment( cards: readonly OptimizerCard[], reductions: Readonly>, profiles: ReadonlyMap, base: Float64Array, directBonus: number, counts: Float64Array, selected: CandidateProfile[] ): number { cards.forEach((card, index) => { const profile = profiles.get(`${card.id}:${reductions[card.id] ?? 0}`); if (!profile) throw new Error(`${card.name}의 빈도 조합 프로파일이 없습니다.`); selected[index] = profile; }); let total = 0; for (let noteIndex = 0; noteIndex < base.length; noteIndex += 1) { let largest = 0; for (let memberIndex = 0; memberIndex < selected.length; memberIndex += 1) { const boost = selected[memberIndex].boost[noteIndex]; if (boost <= largest) continue; // Invalid numerical inputs retain the original combined evaluator. if (!Number.isFinite(boost)) return NaN; largest = boost; } total += finalNoteScore(base[noteIndex] * (1 + largest), directBonus) * counts[noteIndex]; } return total; } function scoreAssignment( cards: readonly OptimizerCard[], reductions: Readonly>, profiles: ReadonlyMap, base: Float64Array, directBonus: number, counts: Float64Array, selected: CandidateProfile[], boosts: Float64Array, probabilities: Float64Array ): { expectedScore: number; theoreticalMaxScore: number } { cards.forEach((card, index) => { const reduction = reductions[card.id] ?? 0; const profile = profiles.get(`${card.id}:${reduction}`); if (!profile) throw new Error(`${card.name}의 ${reduction}% 감소 프로필이 없습니다.`); selected[index] = profile; }); let expectedScore = 0; let theoreticalMaxScore = 0; for (let noteIndex = 0; noteIndex < base.length; noteIndex += 1) { let count = 0; for (let memberIndex = 0; memberIndex < selected.length; memberIndex += 1) { const boost = selected[memberIndex].boost[noteIndex]; if (boost <= 0) continue; let position = count; while (position > 0 && boosts[position - 1] < boost) { boosts[position] = boosts[position - 1]; probabilities[position] = probabilities[position - 1]; position -= 1; } boosts[position] = boost; probabilities[position] = selected[memberIndex].probability[noteIndex]; count += 1; } let expectedBoost = 0; let previousFailed = 1; for (let position = 0; position < count; position += 1) { expectedBoost += previousFailed * probabilities[position] * boosts[position]; previousFailed *= 1 - probabilities[position]; } // Round each original note before applying the group's multiplicity. expectedScore += finalNoteScore(base[noteIndex] * (1 + expectedBoost), directBonus) * counts[noteIndex]; theoreticalMaxScore += finalNoteScore(base[noteIndex] * (1 + (count > 0 ? boosts[0] : 0)), directBonus) * counts[noteIndex]; } return { expectedScore, theoreticalMaxScore }; } export function optimizeExactOrderAndCooldowns( cards: readonly OptimizerCard[], chart: ExactSongChart, options: ExactOptimizeOptions ): ExactOptimizationResult { return rankExactCooldownCombinations(cards, chart, options, 1)[0]; } /** Keep the best special order for each distinct per-card cooldown assignment. */ export function rankExactCooldownCombinations( cards: readonly OptimizerCard[], chart: ExactSongChart, options: ExactOptimizeOptions, limit = 5 ) : ExactOptimizationResult[] { if (options.objective === 'expected') return rankExpectedCooldownCombinations(cards, chart, options, limit); if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) { throw new Error('서로 다른 카드 5장이 필요합니다.'); } const reductionAssignments = assignments(cards); const reductionTotals = new Map(reductionAssignments.map((reductions) => [reductions, Object.values(reductions).reduce((sum, value) => sum + value, 0)])); const groups = optimizationNotes(cards, chart, options); const base = baseScores(groups, options.perfectNoteScore); const counts = Float64Array.from(groups, (group) => group.count); // Local to this call: callbacks may recursively invoke the optimizer. const selected: CandidateProfile[] = []; const boosts = new Float64Array(cards.length); const probabilities = new Float64Array(cards.length); const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1); const totalCandidates = orderCount * reductionAssignments.length; let evaluatedCandidates = 0; type Candidate = { order: OptimizerCard[]; reductions: Record; theoreticalMaxScore: number; expectedScore: number | undefined; reductionTotal: number; ties?: Candidate[]; }; const bestByAssignment = new Map, Candidate>(); const maximumObjective = true; // Keep a bounded number of order profiles for deferred expected-score ties. const profileCache = new Map>(); let activeOrder: readonly OptimizerCard[] | null = null; let activeProfiles: Map | null = null; const profilesFor = (order: readonly OptimizerCard[]): Map => { if (!maximumObjective) return makeProfiles(order, chart, groups); let profiles = profileCache.get(order); if (profiles) profileCache.delete(order); else profiles = makeProfiles(order, chart, groups); profileCache.set(order, profiles); if (profileCache.size > 8) profileCache.delete(profileCache.keys().next().value!); return profiles; }; const expectedFor = (candidate: Candidate): number => { if (candidate.expectedScore === undefined) { const profiles = candidate.order === activeOrder && activeProfiles ? activeProfiles : profilesFor(candidate.order); candidate.expectedScore = scoreAssignment(candidate.order, candidate.reductions, profiles, base, options.directSongScoreBonusPercent ?? 0, counts, selected, boosts, probabilities).expectedScore; } return candidate.expectedScore; }; const compare = (left: Candidate, right: Candidate): number => (options.objective === 'expected' ? expectedFor(right) - expectedFor(left) || right.theoreticalMaxScore - left.theoreticalMaxScore : right.theoreticalMaxScore - left.theoreticalMaxScore || expectedFor(right) - expectedFor(left)) || left.reductionTotal - right.reductionTotal; const maximumCache = new Map(); for (const order of permutations(cards)) { const passive = passiveScoreSupportByCard(order); const supportById = new Map(order.map((card, index) => [card.id, passive[index]])); const inputs = [ ...order.flatMap((card) => [card.special.durationSeconds, card.special.scoreUpPercent]), ...cards.flatMap((card) => [card.active.cooldownSeconds, card.active.durationSeconds, card.active.scoreUpPercent, card.scoreSupportPercent ?? 0, supportById.get(card.id)!]), options.directSongScoreBonusPercent ?? 0 ]; const cacheable = inputs.every(Number.isFinite) && cards.every((card) => typeof card.id === 'string') && chart.specialStartSeconds.every((start) => start === null || start === undefined || Number.isFinite(start)); const signature = cacheable ? JSON.stringify([inputs, chart.specialStartSeconds]) : null; let maxima = signature === null ? undefined : maximumCache.get(signature); if (signature !== null && !maxima) { maxima = new Float64Array(reductionAssignments.length).fill(NaN); maximumCache.set(signature, maxima); } const profiles = profilesFor(order); activeOrder = order; activeProfiles = profiles; if (!maxima || Number.isNaN(maxima[0])) { const shared = scoreMaximumAssignments(cards, profiles, base, options.directSongScoreBonusPercent ?? 0, counts); if (shared) { maxima = shared; if (signature !== null) maximumCache.set(signature, shared); } } for (let assignmentIndex = 0; assignmentIndex < reductionAssignments.length; assignmentIndex += 1) { const reductions = reductionAssignments[assignmentIndex]; let maximum = maxima?.[assignmentIndex]; if (maximum === undefined || Number.isNaN(maximum)) { maximum = scoreMaximumAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0, counts, selected); if (maxima && Number.isFinite(maximum)) maxima[assignmentIndex] = maximum; } let score: { theoreticalMaxScore: number; expectedScore: number | undefined } = maximumObjective ? { theoreticalMaxScore: maximum, expectedScore: undefined } : scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0, counts, selected, boosts, probabilities); if (!Number.isFinite(score.theoreticalMaxScore)) score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0, counts, selected, boosts, probabilities); const reductionTotal = reductionTotals.get(reductions)!; const candidate = { ...score, order, reductions, reductionTotal }; const previous = bestByAssignment.get(reductions); if (previous && (!Number.isFinite(candidate.theoreticalMaxScore) || !Number.isFinite(previous.theoreticalMaxScore))) { if (compare(candidate, previous) < 0) bestByAssignment.set(reductions, candidate); } else if (!previous || candidate.theoreticalMaxScore > previous.theoreticalMaxScore) { bestByAssignment.set(reductions, candidate); } else if (candidate.theoreticalMaxScore === previous.theoreticalMaxScore) { // Resolve expected-score ties only if this assignment can reach the requested ranks. (previous.ties ??= []).push(candidate); } evaluatedCandidates += 1; } options.onProgress?.({ evaluatedCandidates, totalCandidates }); } const resultCount = Math.max(1, Math.floor(limit)); let ranked = [...bestByAssignment.values()]; if (Number.isFinite(resultCount) && ranked.every(candidate => Number.isFinite(candidate.theoreticalMaxScore))) { const maxima = ranked.map(candidate => candidate.theoreticalMaxScore).sort((left, right) => right - left); const cutoff = maxima[Math.min(maxima.length, resultCount) - 1]; ranked = ranked.filter(candidate => candidate.theoreticalMaxScore >= cutoff); } ranked = ranked.map(candidate => { let best = candidate; for (const tied of candidate.ties ?? []) if (compare(tied, best) < 0) best = tied; return best; }); return ranked.sort(compare).slice(0, resultCount).map((candidate) => ({ ...calculateExactScore(candidate.order, candidate.reductions, chart, options), order: candidate.order.map((card) => card.id), formationOrder: cards.map((card) => card.id), cooldownReductions: { ...candidate.reductions }, ...buildExactSkillWindows(candidate.order, candidate.reductions, chart), evaluatedCandidates })); }