import { effectiveActiveProbability } from './active-probability.ts'; import { passiveScoreSupportByCard } from './passive-support.ts'; import { effectiveCooldownSeconds } from './timeline.ts'; import type { CooldownReduction, ExactScoreOptions, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from './types.ts'; type Attempt = { key: string; probability: number; }; type NoteCandidate = { attemptIndex: number; boost: number; }; type ScoreGroup = { candidates: readonly NoteCandidate[]; scores: readonly number[]; }; type PreparedProbabilityModel = { attempts: readonly Attempt[]; groups: readonly ScoreGroup[]; maximumScore: number; maximumClauses: readonly (readonly number[])[]; }; 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( cards: readonly OptimizerCard[], chart: ExactSongChart, time: number, field: 'scoreUpPercent' | 'activationRateUpPercent' ): number { return cards.reduce((sum, card, slot) => sum + (specialActive(card, chart.specialStartSeconds[slot], time) ? card.special[field] ?? 0 : 0), 0); } function comboBonus(combo: number): number { return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0; } function finalNoteScore(score: number, percent: number): number { const scaled = score * (1 + percent / 100); return Math.ceil(scaled - Number.EPSILON * Math.max(1, scaled) * 4); } function scoreNotes(chart: ExactSongChart): ExactSongChart['notes'] { return chart.notes; } function prepareModel( cards: readonly OptimizerCard[], reductions: Readonly>, chart: ExactSongChart, options: ExactScoreOptions, onNote?: (probability: number) => void ): PreparedProbabilityModel { const attempts: Attempt[] = []; const attemptIndexes = new Map(); const passiveSupport = passiveScoreSupportByCard(cards); const grouped = new Map(); const clauses: number[][] = []; let maximumScore = 0; let prefixProbability = 1; let prefixClauses: number[][] = []; scoreNotes(chart).forEach((note, noteIndex) => { const candidates: NoteCandidate[] = []; const specialSupport = specialRate(cards, chart, note.timeSeconds, 'scoreUpPercent'); cards.forEach((card, cardIndex) => { const reduction = reductions[card.id] ?? 0; const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction); const attemptNumber = Math.floor((note.timeSeconds + 1e-9) / interval); if (attemptNumber <= 0) return; const activationTime = attemptNumber * interval; if (options.excludedActiveWindows?.some((window) => window.cardId === card.id && Math.abs(window.startSeconds - activationTime) < 1e-9)) return; if (note.timeSeconds >= activationTime + card.active.durationSeconds) return; const key = `${card.id}:${attemptNumber}`; let attemptIndex = attemptIndexes.get(key); if (attemptIndex === undefined) { const activationBonus = specialRate(cards, chart, activationTime, 'activationRateUpPercent'); const probability = effectiveActiveProbability(card, activationBonus); attemptIndex = attempts.length; attemptIndexes.set(key, attemptIndex); attempts.push({ key, probability }); } candidates.push({ attemptIndex, boost: card.active.scoreUpPercent / 100 * (1 + (specialSupport + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100) }); }); candidates.sort((left, right) => right.boost - left.boost || left.attemptIndex - right.attemptIndex); const combo = noteIndex + 1; const baseNoteScore = note.type === 'mid' ? Math.ceil(options.perfectNoteScore * 0.1) : options.perfectNoteScore * (note.isFlick ? 1.05 : 1); const rawBaseline = baseNoteScore * (1 + comboBonus(combo)); const directBonus = options.directSongScoreBonusPercent ?? 0; const scores = [ ...candidates.map((candidate) => finalNoteScore(rawBaseline * (1 + candidate.boost), directBonus)), finalNoteScore(rawBaseline, directBonus) ]; maximumScore += scores[0]; // No activation is required when rounding gives the baseline the same score. if (scores[0] > scores[scores.length - 1]) { const clause = candidates .filter((_, index) => scores[index] === scores[0]) .map((candidate) => candidate.attemptIndex); clauses.push(clause); if (onNote && !prefixClauses.some((existing) => existing.every((variable) => clause.includes(variable)))) { prefixClauses = simplifyClauses([...prefixClauses, clause]); prefixProbability = exactClauseProbability(prefixClauses, attempts); } } onNote?.(prefixProbability); const signature = candidates.map((candidate) => `${candidate.attemptIndex}:${candidate.boost.toPrecision(15)}`).join('|'); const existing = grouped.get(signature); if (existing) scores.forEach((score, index) => { existing.scores[index] += score; }); else grouped.set(signature, { candidates, scores }); }); return { attempts, groups: Array.from(grouped.values()), maximumScore, maximumClauses: simplifyClauses(clauses) }; } /** Exact cumulative maximum-score probability after each chart note, in note order. */ export function calculateMaximumScoreProbabilityTimeline( cards: readonly OptimizerCard[], reductions: Readonly>, chart: ExactSongChart, options: ExactScoreOptions ): number[] { const probabilities: number[] = []; prepareModel(cards, reductions, chart, options, (probability) => probabilities.push(probability)); return probabilities; } function simplifyClauses(clauses: readonly (readonly number[])[]): number[][] { const unique = new Map(); for (const clause of clauses) { const normalized = Array.from(new Set(clause)).sort((left, right) => left - right); unique.set(normalized.join(','), normalized); } const sorted = Array.from(unique.values()).sort((left, right) => left.length - right.length); return sorted.filter((clause, index) => !sorted.slice(0, index).some((candidate) => candidate.length <= clause.length && candidate.every((variable) => clause.includes(variable)))); } function exactClauseProbability(clauses: readonly (readonly number[])[], attempts: readonly Attempt[]): number { const memo = new Map(); const solve = (current: readonly (readonly number[])[]): number => { if (current.length === 0) return 1; if (current.some((clause) => clause.length === 0)) return 0; const simplified = simplifyClauses(current); const key = simplified.map((clause) => clause.join(',')).join(';'); const cached = memo.get(key); if (cached !== undefined) return cached; const required = new Set(simplified.filter((clause) => clause.length === 1).map((clause) => clause[0])); if (required.size > 0) { let requiredProbability = 1; for (const variable of required) requiredProbability *= attempts[variable]?.probability ?? 0; const remaining = simplified.filter((clause) => !clause.some((variable) => required.has(variable))); const result = requiredProbability * solve(remaining); memo.set(key, result); return result; } const occurrences = new Map(); simplified.forEach((clause) => clause.forEach((variable) => occurrences.set(variable, (occurrences.get(variable) ?? 0) + 1))); const variable = Array.from(occurrences).sort((left, right) => right[1] - left[1])[0][0]; const probability = attempts[variable]?.probability ?? 0; const whenTrue = simplified.filter((clause) => !clause.includes(variable)); const whenFalse = simplified.map((clause) => clause.filter((candidate) => candidate !== variable)); const result = probability * solve(whenTrue) + (1 - probability) * solve(whenFalse); memo.set(key, result); return result; }; return solve(clauses); } function hashSeed(cards: readonly OptimizerCard[], reductions: Readonly>): number { const value = cards.map((card) => `${card.id}:${reductions[card.id] ?? 0}`).join('|'); let hash = 2166136261; for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16777619); } return hash >>> 0; } function randomGenerator(seed: number): () => number { let state = seed || 0x9e3779b9; return () => { state += 0x6d2b79f5; let value = state; value = Math.imul(value ^ value >>> 15, value | 1); value ^= value + Math.imul(value ^ value >>> 7, value | 61); return ((value ^ value >>> 14) >>> 0) / 4294967296; }; } export function estimateScoreAchievementProbabilities( cards: readonly OptimizerCard[], reductions: Readonly>, chart: ExactSongChart, options: ExactScoreOptions & { sampleCount?: number; seed?: number } ): ScoreAchievementProbabilities { const model = prepareModel(cards, reductions, chart, options); const samples = Math.max(1, Math.round(options.sampleCount ?? 25_000)); const random = randomGenerator(options.seed ?? hashSeed(cards, reductions)); const active = new Uint8Array(model.attempts.length); let atLeast99 = 0; let atLeast95 = 0; const threshold99 = model.maximumScore * 0.99; const threshold95 = model.maximumScore * 0.95; for (let sample = 0; sample < samples; sample += 1) { model.attempts.forEach((attempt, index) => { active[index] = random() < attempt.probability ? 1 : 0; }); let score = 0; for (const group of model.groups) { let scoreIndex = group.candidates.length; for (let index = 0; index < group.candidates.length; index += 1) { if (active[group.candidates[index].attemptIndex]) { scoreIndex = index; break; } } score += group.scores[scoreIndex]; } if (score >= threshold99) atLeast99 += 1; if (score >= threshold95) atLeast95 += 1; } return { maximum: exactClauseProbability(model.maximumClauses, model.attempts), atLeast99Percent: atLeast99 / samples, atLeast95Percent: atLeast95 / samples, samples }; }