feat: complete placement analysis workflow
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user