149 lines
6.0 KiB
TypeScript
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;
|
|
}
|