262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
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<Record<string, CooldownReduction>>,
|
|
chart: ExactSongChart,
|
|
options: ExactScoreOptions,
|
|
onNote?: (probability: number) => void
|
|
): PreparedProbabilityModel {
|
|
const attempts: Attempt[] = [];
|
|
const attemptIndexes = new Map<string, number>();
|
|
const passiveSupport = passiveScoreSupportByCard(cards);
|
|
const grouped = new Map<string, { candidates: NoteCandidate[]; scores: number[] }>();
|
|
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<Record<string, CooldownReduction>>,
|
|
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<string, number[]>();
|
|
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<string, number>();
|
|
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<number, number>();
|
|
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<Record<string, CooldownReduction>>): 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<Record<string, CooldownReduction>>,
|
|
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
|
|
};
|
|
}
|