Files
HolodoriCalc/src/lib/engine/optimizer.ts
T

149 lines
6.0 KiB
TypeScript

import { COOLDOWN_REDUCTIONS } from './types.ts';
import type {
CooldownReduction,
OptimizeOrderOptions,
OptimizerCard,
SimulationResult,
SongChartDensity
} from './types.ts';
import { buildActiveWindows, buildSpecialWindows, simulateOrder } from './timeline.ts';
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 reductionAssignments(cards: readonly OptimizerCard[]): Array<Record<string, CooldownReduction>> {
let assignments: Array<Record<string, CooldownReduction>> = [{}];
for (const card of cards) {
const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
assignments = assignments.flatMap((assignment) =>
allowed.map((reduction) => ({ ...assignment, [card.id]: reduction }))
);
}
return assignments;
}
function reductionTotal(reductions: Readonly<Record<string, CooldownReduction>>): number {
return Object.values(reductions).reduce<number>((sum, reduction) => sum + reduction, 0);
}
function fillWindowProfile(
profile: Float64Array,
windows: ReturnType<typeof buildActiveWindows>,
stepSeconds: number
): void {
for (const window of windows) {
const startTick = Math.max(0, Math.ceil(window.startSeconds / stepSeconds - 1e-9));
const endTick = Math.min(profile.length, Math.ceil(window.endSeconds / stepSeconds - 1e-9));
for (let tick = startTick; tick < endTick; tick += 1) {
profile[tick] = Math.max(profile[tick], window.scoreUpPercent);
}
}
}
function makeNoteWeights(chart: SongChartDensity, stepSeconds: number, tickCount: number): Float64Array {
const weights = new Float64Array(tickCount);
for (let tick = 0; tick < tickCount; tick += 1) {
const timeSeconds = tick * stepSeconds;
const second = Math.floor(timeSeconds);
weights[tick] =
(chart.notesPerSecond[second] ?? 0) * Math.min(stepSeconds, chart.durationSeconds - timeSeconds);
}
return weights;
}
export function optimizeOrderAndCooldowns(
cards: readonly OptimizerCard[],
chart: SongChartDensity,
options: OptimizeOrderOptions = {}
): SimulationResult {
if (cards.length !== 5) throw new Error('최고점 순서 분석에는 정확히 5장의 카드가 필요합니다.');
if (new Set(cards.map((card) => card.id)).size !== cards.length) throw new Error('카드 ID는 서로 달라야 합니다.');
const stepSeconds = options.stepSeconds ?? 0.1;
if (!Number.isFinite(stepSeconds) || stepSeconds <= 0) throw new Error('계산 간격은 0보다 커야 합니다.');
const orders = permutations(cards);
const assignments = reductionAssignments(cards);
const totalCandidates = orders.length * assignments.length;
const tickCount = Math.ceil(chart.durationSeconds / stepSeconds);
const noteWeights = makeNoteWeights(chart, stepSeconds, tickCount);
const activeProfiles = new Map<string, Float64Array>();
for (const card of cards) {
const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
for (const reduction of allowed) {
const profile = new Float64Array(tickCount);
fillWindowProfile(
profile,
buildActiveWindows(card, reduction, chart.durationSeconds, stepSeconds),
stepSeconds
);
activeProfiles.set(`${card.id}:${reduction}`, profile);
}
}
const specialMultipliers = orders.map((order) => {
const profile = new Float64Array(tickCount);
fillWindowProfile(profile, buildSpecialWindows(order, chart), stepSeconds);
for (let tick = 0; tick < tickCount; tick += 1) profile[tick] = 1 + profile[tick] / 100;
return profile;
});
let evaluatedCandidates = 0;
let bestScoreWeight = Number.NEGATIVE_INFINITY;
let bestOrder: OptimizerCard[] | null = null;
let bestReductions: Record<string, CooldownReduction> | null = null;
let bestReductionTotal = Number.POSITIVE_INFINITY;
for (const reductions of assignments) {
const activeScore = new Float64Array(tickCount);
for (const card of cards) {
const reduction = reductions[card.id] ?? 0;
const profile = activeProfiles.get(`${card.id}:${reduction}`);
if (!profile) throw new Error(`${card.name}의 쿨타임 감소 ${reduction}% 계산값이 없습니다.`);
for (let tick = 0; tick < tickCount; tick += 1) {
activeScore[tick] = Math.max(activeScore[tick], profile[tick]);
}
}
const currentReductionTotal = reductionTotal(reductions);
for (let orderIndex = 0; orderIndex < orders.length; orderIndex += 1) {
const specialMultiplier = specialMultipliers[orderIndex];
let scoreWeight = 0;
for (let tick = 0; tick < tickCount; tick += 1) {
scoreWeight += noteWeights[tick] * activeScore[tick] * specialMultiplier[tick];
}
const isBetterScore = scoreWeight > bestScoreWeight + 1e-9;
const isCheaperTie =
Math.abs(scoreWeight - bestScoreWeight) <= 1e-9 && currentReductionTotal < bestReductionTotal;
if (isBetterScore || isCheaperTie) {
bestScoreWeight = scoreWeight;
bestOrder = orders[orderIndex];
bestReductions = reductions;
bestReductionTotal = currentReductionTotal;
}
evaluatedCandidates += 1;
}
options.onProgress?.({ evaluatedCandidates, totalCandidates });
}
if (!bestOrder || !bestReductions) throw new Error('분석할 수 있는 후보가 없습니다.');
return simulateOrder(bestOrder, bestReductions, chart, stepSeconds);
}
export function countOptimizationCandidates(cards: readonly OptimizerCard[]): number {
const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1);
const reductionCount = cards.reduce(
(value, card) => value * (card.allowedCooldownReductions?.length || COOLDOWN_REDUCTIONS.length),
1
);
return orderCount * reductionCount;
}