import { effectiveCooldownSeconds } from './timeline.ts'; import { passiveScoreSupportAt } from './passive-support.ts'; import type { CooldownReduction, ExactScoreOptions, ExactScoreResult, ExactSongChart, OptimizerCard } from './types.ts'; type ActiveCandidate = { memberIndex: number; probability: number; boost: number; }; // QUEEN 및 Our Promise Expert의 인게임 실측값으로 보정한 전역 환산 상수다. export const CHART_SCORE_WEIGHT_COEFFICIENT = 0.3713416563352296; export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number { let weight = 0; for (const note of notes) { if (note.type === 'mid') { weight += 0.1; continue; } weight += note.isFlick ? 1.05 : 1; } return weight * CHART_SCORE_WEIGHT_COEFFICIENT; } function comboBonus(combo: number): number { if (combo >= 1_000) return 0.1; if (combo >= 100) return Math.floor(combo / 100) * 0.01; return 0; } function activeAttempt(card: OptimizerCard, reduction: CooldownReduction, timeSeconds: number): number { const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction); return Math.floor((timeSeconds + 1e-9) / interval); } function specialIsActive( card: OptimizerCard, startSeconds: number | null | undefined, timeSeconds: number ): boolean { return startSeconds !== null && startSeconds !== undefined && startSeconds <= timeSeconds && timeSeconds < startSeconds + card.special.durationSeconds; } function specialSupportAt( cardsInOrder: readonly OptimizerCard[], chart: ExactSongChart, timeSeconds: number ): number { return cardsInOrder.reduce((sum, card, slot) => sum + (specialIsActive(card, chart.specialStartSeconds[slot], timeSeconds) ? card.special.scoreUpPercent : 0), 0); } function activationRateBonusAt( cardsInOrder: readonly OptimizerCard[], chart: ExactSongChart, timeSeconds: number ): number { return cardsInOrder.reduce((sum, card, slot) => sum + (specialIsActive(card, chart.specialStartSeconds[slot], timeSeconds) ? card.special.activationRateUpPercent ?? 0 : 0), 0); } function activeCandidates( cardsInOrder: readonly OptimizerCard[], reductions: Readonly>, chart: ExactSongChart, timeSeconds: number ): ActiveCandidate[] { const specialSupport = specialSupportAt(cardsInOrder, chart, timeSeconds); const candidates = cardsInOrder.flatMap((card, memberIndex) => { const reduction = reductions[card.id] ?? 0; const attempt = activeAttempt(card, reduction, timeSeconds); if (attempt <= 0) return []; const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction); const activationTime = attempt * interval; if (timeSeconds >= activationTime + card.active.durationSeconds) return []; const activationRateBonus = activationRateBonusAt(cardsInOrder, chart, activationTime); const probability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationRateBonus / 100)); const support = specialSupport + (card.scoreSupportPercent ?? 0) + passiveScoreSupportAt(cardsInOrder, memberIndex); return [{ memberIndex, probability, boost: card.active.scoreUpPercent / 100 * (1 + support / 100) }]; }); return candidates.sort((left, right) => right.boost - left.boost || left.memberIndex - right.memberIndex); } function expectedStrongestBoost(candidates: readonly ActiveCandidate[]): number { let expectedBoost = 0; let previousFailed = 1; for (const candidate of candidates) { expectedBoost += previousFailed * candidate.probability * candidate.boost; previousFailed *= 1 - candidate.probability; } return expectedBoost; } function finalNoteScore(score: number, percent: number): number { const scaled = score * (1 + percent / 100); return Math.ceil(scaled - Number.EPSILON * Math.max(1, scaled) * 4); } export function perfectNoteScoreFromPower(overallPower: number, scoreRatio: number | null, fallback = 600): number { if (overallPower > 0 && scoreRatio !== null && scoreRatio > 0) { return Math.max(1, overallPower / scoreRatio); } return fallback; } export function calculateExactScore( cardsInOrder: readonly OptimizerCard[], reductions: Readonly>, chart: ExactSongChart, options: ExactScoreOptions ): ExactScoreResult { const directBonus = options.directSongScoreBonusPercent ?? 0; const scoreNotes = chart.notes; const notePoints = scoreNotes.map((note, index) => { const combo = index + 1; const baseNoteScore = note.type === 'mid' ? Math.ceil(options.perfectNoteScore * 0.1) : options.perfectNoteScore * (note.isFlick ? 1.05 : 1); const rawBaselineScore = baseNoteScore * (1 + comboBonus(combo)); const candidates = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds); const baselineScore = finalNoteScore(rawBaselineScore, directBonus); const expectedScore = finalNoteScore(rawBaselineScore * (1 + expectedStrongestBoost(candidates)), directBonus); const theoreticalMaxScore = finalNoteScore(rawBaselineScore * (1 + (candidates[0]?.boost ?? 0)), directBonus); return { timeSeconds: note.timeSeconds, combo, expectedScore, theoreticalMaxScore, baselineScore }; }); const rawExpected = notePoints.reduce((sum, note) => sum + note.expectedScore, 0); const rawMaximum = notePoints.reduce((sum, note) => sum + note.theoreticalMaxScore, 0); const rawBaseline = notePoints.reduce((sum, note) => sum + note.baselineScore, 0); const scoreBySecond = Array.from({ length: Math.ceil(chart.durationSeconds) }, () => 0); notePoints.forEach((note) => { const second = Math.floor(note.timeSeconds); if (second >= 0 && second < scoreBySecond.length) scoreBySecond[second] += note.expectedScore; }); return { expectedScore: rawExpected, theoreticalMaxScore: rawMaximum, baselineScore: rawBaseline, perfectNoteScore: options.perfectNoteScore, scoreBySecond, notes: notePoints }; }