Files
HolodoriCalc/src/lib/engine/exact-optimizer.ts
T
dal4segno da40ec4238
Publish GitHub Pages output / publish (push) Successful in 34s
perf: 조합 탐색 정밀 계산과 Worker 처리 최적화
카드 조건과 빈도별 공통 계산을 재사용하고 준비·평가 Worker를 재사용합니다.
준비된 후보를 모두 정밀 평가하고 최종 결과를 한 번 표시합니다.
업데이트 문구를 한 항목으로 통합합니다.
2026-10-13 12:03:10 +09:00

478 lines
23 KiB
TypeScript

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<string, NoteGroup>();
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<T>(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<Record<string, CooldownReduction>> {
let result: Array<Record<string, CooldownReduction>> = [{}];
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<string, CandidateProfile> {
const profiles = new Map<string, CandidateProfile>();
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<number, number>();
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<Record<string, CooldownReduction>>,
chart: ExactSongChart,
excludedActiveWindows: readonly Pick<SkillWindow, 'cardId' | 'startSeconds'>[] = []
): { 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<string, CandidateProfile>,
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<string, { scores: number[]; count: number }>();
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<Record<string, CooldownReduction>>,
profiles: ReadonlyMap<string, CandidateProfile>,
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<Record<string, CooldownReduction>>,
profiles: ReadonlyMap<string, CandidateProfile>,
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<number>((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<string, CooldownReduction>;
theoreticalMaxScore: number;
expectedScore: number | undefined;
reductionTotal: number;
ties?: Candidate[];
};
const bestByAssignment = new Map<Record<string, CooldownReduction>, Candidate>();
const maximumObjective = true;
// Keep a bounded number of order profiles for deferred expected-score ties.
const profileCache = new Map<readonly OptimizerCard[], Map<string, CandidateProfile>>();
let activeOrder: readonly OptimizerCard[] | null = null;
let activeProfiles: Map<string, CandidateProfile> | null = null;
const profilesFor = (order: readonly OptimizerCard[]): Map<string, CandidateProfile> => {
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<string, Float64Array>();
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
}));
}