feat: complete placement analysis workflow
This commit is contained in:
1 parent
dd3ea911f6
commit
7b713bffe3
452 files changed
+97115
-24
No files matched your search
@@ -0,0 +1,280 @@
|
||||
import { calculateExactScore } from './score.ts';
|
||||
import { skillTargetBelongsTo } from './skill-condition.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;
|
||||
};
|
||||
|
||||
function passiveScoreSupportByCard(order: readonly OptimizerCard[]): number[] {
|
||||
const totals = order.map(() => 0);
|
||||
order.forEach((source, sourceIndex) => {
|
||||
for (const effect of source.passiveScoreSupportEffects ?? []) {
|
||||
let indexes: number[];
|
||||
if (effect.target === 'all') indexes = order.map((_, index) => index);
|
||||
else if (effect.target === 'self') indexes = [sourceIndex];
|
||||
else indexes = order
|
||||
.map((card, index) => ({ card, index }))
|
||||
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
|
||||
.slice(0, effect.targetCount ?? order.length)
|
||||
.map(({ index }) => index);
|
||||
for (const index of indexes) totals[index] += effect.percent;
|
||||
}
|
||||
});
|
||||
return totals;
|
||||
}
|
||||
|
||||
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(chart: ExactSongChart, perfectNoteScore: number): Float64Array {
|
||||
return Float64Array.from(chart.notes, (note, index) => {
|
||||
const base = note.type === 'mid' ? Math.ceil(perfectNoteScore * 0.1) : perfectNoteScore * (note.isFlick ? 1.05 : 1);
|
||||
return base * (1 + comboBonus(index + 1));
|
||||
});
|
||||
}
|
||||
|
||||
function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): Map<string, CandidateProfile> {
|
||||
const profiles = new Map<string, CandidateProfile>();
|
||||
const noteSupport = Float64Array.from(chart.notes, (note) => specialRate(order, chart, note.timeSeconds, 'scoreUpPercent'));
|
||||
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(chart.notes.length);
|
||||
const probability = new Float64Array(chart.notes.length);
|
||||
const probabilityByAttempt = new Map<number, number>();
|
||||
chart.notes.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 = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationBonus / 100));
|
||||
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;
|
||||
}
|
||||
|
||||
function resultWindows(
|
||||
order: readonly OptimizerCard[],
|
||||
reductions: Readonly<Record<string, CooldownReduction>>,
|
||||
chart: ExactSongChart
|
||||
): { 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)
|
||||
.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 };
|
||||
}
|
||||
|
||||
function scoreAssignment(
|
||||
cards: readonly OptimizerCard[],
|
||||
reductions: Readonly<Record<string, CooldownReduction>>,
|
||||
profiles: ReadonlyMap<string, CandidateProfile>,
|
||||
base: Float64Array,
|
||||
directBonus: number
|
||||
): number {
|
||||
const selected = cards.map((card) => {
|
||||
const reduction = reductions[card.id] ?? 0;
|
||||
const profile = profiles.get(`${card.id}:${reduction}`);
|
||||
if (!profile) throw new Error(`${card.name}의 ${reduction}% 감소 프로필이 없습니다.`);
|
||||
return profile;
|
||||
});
|
||||
const boosts = new Float64Array(cards.length);
|
||||
const probabilities = new Float64Array(cards.length);
|
||||
let rawScore = 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];
|
||||
}
|
||||
rawScore += base[noteIndex] * (1 + expectedBoost);
|
||||
}
|
||||
return Math.floor(Math.floor(rawScore) * (1 + directBonus / 100) + Number.EPSILON * Math.max(1, rawScore) * 4);
|
||||
}
|
||||
|
||||
export function optimizeExactOrderAndCooldowns(
|
||||
cards: readonly OptimizerCard[],
|
||||
chart: ExactSongChart,
|
||||
options: ExactOptimizeOptions
|
||||
): ExactOptimizationResult {
|
||||
if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) {
|
||||
throw new Error('서로 다른 카드 5장이 필요합니다.');
|
||||
}
|
||||
const orders = permutations(cards);
|
||||
const reductionAssignments = assignments(cards);
|
||||
const base = baseScores(chart, options.perfectNoteScore);
|
||||
const totalCandidates = orders.length * reductionAssignments.length;
|
||||
let evaluatedCandidates = 0;
|
||||
let bestScore = Number.NEGATIVE_INFINITY;
|
||||
let bestOrder: OptimizerCard[] | null = null;
|
||||
let bestReductions: Record<string, CooldownReduction> | null = null;
|
||||
let bestReductionTotal = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const order of orders) {
|
||||
const profiles = makeProfiles(order, chart);
|
||||
for (const reductions of reductionAssignments) {
|
||||
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
|
||||
const reductionTotal = Object.values(reductions).reduce<number>((sum, value) => sum + value, 0);
|
||||
if (score > bestScore || (score === bestScore && reductionTotal < bestReductionTotal)) {
|
||||
bestScore = score;
|
||||
bestOrder = order;
|
||||
bestReductions = reductions;
|
||||
bestReductionTotal = reductionTotal;
|
||||
}
|
||||
evaluatedCandidates += 1;
|
||||
}
|
||||
options.onProgress?.({ evaluatedCandidates, totalCandidates });
|
||||
}
|
||||
|
||||
if (!bestOrder || !bestReductions) throw new Error('분석 결과를 만들지 못했습니다.');
|
||||
const windows = resultWindows(bestOrder, bestReductions, chart);
|
||||
return {
|
||||
...calculateExactScore(bestOrder, bestReductions, chart, options),
|
||||
order: bestOrder.map((card) => card.id),
|
||||
cooldownReductions: { ...bestReductions },
|
||||
...windows,
|
||||
evaluatedCandidates
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user