feat: complete placement analysis workflow
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import { effectiveCooldownSeconds } from './timeline.ts';
|
||||
import { skillTargetBelongsTo } from './skill-condition.ts';
|
||||
import type {
|
||||
CooldownReduction,
|
||||
ExactScoreOptions,
|
||||
ExactScoreResult,
|
||||
ExactSongChart,
|
||||
OptimizerCard
|
||||
} from './types.ts';
|
||||
|
||||
type ActiveCandidate = {
|
||||
memberIndex: number;
|
||||
probability: number;
|
||||
boost: number;
|
||||
};
|
||||
|
||||
// QUEEN 및 Our Promise Expert의 인게임 실측값으로 보정한 전역 환산 상수다.
|
||||
export const CHART_SCORE_WEIGHT_COEFFICIENT = 0.3713416563352296;
|
||||
|
||||
export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number {
|
||||
const countedMidTimes = new Set<number>();
|
||||
let weight = 0;
|
||||
|
||||
for (const note of notes) {
|
||||
if (note.type === 'mid') {
|
||||
if (countedMidTimes.has(note.timeSeconds)) continue;
|
||||
countedMidTimes.add(note.timeSeconds);
|
||||
weight += 0.1;
|
||||
continue;
|
||||
}
|
||||
weight += note.isFlick ? 1.05 : 1;
|
||||
}
|
||||
|
||||
return weight * CHART_SCORE_WEIGHT_COEFFICIENT;
|
||||
}
|
||||
|
||||
function passiveScoreSupportAt(cards: readonly OptimizerCard[], targetIndex: number): number {
|
||||
let total = 0;
|
||||
cards.forEach((source, sourceIndex) => {
|
||||
for (const effect of source.passiveScoreSupportEffects ?? []) {
|
||||
let indexes: number[];
|
||||
if (effect.target === 'all') indexes = cards.map((_, index) => index);
|
||||
else if (effect.target === 'self') indexes = [sourceIndex];
|
||||
else indexes = cards
|
||||
.map((card, index) => ({ card, index }))
|
||||
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
|
||||
.slice(0, effect.targetCount ?? cards.length)
|
||||
.map(({ index }) => index);
|
||||
if (indexes.includes(targetIndex)) total += effect.percent;
|
||||
}
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
function comboBonus(combo: number): number {
|
||||
if (combo >= 1_000) return 0.1;
|
||||
if (combo >= 100) return Math.floor(combo / 100) * 0.01;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function activeAttempt(card: OptimizerCard, reduction: CooldownReduction, timeSeconds: number): number {
|
||||
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
|
||||
return Math.floor((timeSeconds + 1e-9) / interval);
|
||||
}
|
||||
|
||||
function specialIsActive(
|
||||
card: OptimizerCard,
|
||||
startSeconds: number | null | undefined,
|
||||
timeSeconds: number
|
||||
): boolean {
|
||||
return startSeconds !== null && startSeconds !== undefined &&
|
||||
startSeconds <= timeSeconds && timeSeconds < startSeconds + card.special.durationSeconds;
|
||||
}
|
||||
|
||||
function specialSupportAt(
|
||||
cardsInOrder: readonly OptimizerCard[],
|
||||
chart: ExactSongChart,
|
||||
timeSeconds: number
|
||||
): number {
|
||||
return cardsInOrder.reduce((sum, card, slot) =>
|
||||
sum + (specialIsActive(card, chart.specialStartSeconds[slot], timeSeconds) ? card.special.scoreUpPercent : 0), 0);
|
||||
}
|
||||
|
||||
function activationRateBonusAt(
|
||||
cardsInOrder: readonly OptimizerCard[],
|
||||
chart: ExactSongChart,
|
||||
timeSeconds: number
|
||||
): number {
|
||||
return cardsInOrder.reduce((sum, card, slot) =>
|
||||
sum + (specialIsActive(card, chart.specialStartSeconds[slot], timeSeconds)
|
||||
? card.special.activationRateUpPercent ?? 0
|
||||
: 0), 0);
|
||||
}
|
||||
|
||||
function activeCandidates(
|
||||
cardsInOrder: readonly OptimizerCard[],
|
||||
reductions: Readonly<Record<string, CooldownReduction>>,
|
||||
chart: ExactSongChart,
|
||||
timeSeconds: number
|
||||
): ActiveCandidate[] {
|
||||
const specialSupport = specialSupportAt(cardsInOrder, chart, timeSeconds);
|
||||
const candidates = cardsInOrder.flatMap((card, memberIndex) => {
|
||||
const reduction = reductions[card.id] ?? 0;
|
||||
const attempt = activeAttempt(card, reduction, timeSeconds);
|
||||
if (attempt <= 0) return [];
|
||||
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
|
||||
const activationTime = attempt * interval;
|
||||
if (timeSeconds >= activationTime + card.active.durationSeconds) return [];
|
||||
const activationRateBonus = activationRateBonusAt(cardsInOrder, chart, activationTime);
|
||||
const probability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationRateBonus / 100));
|
||||
const support = specialSupport + (card.scoreSupportPercent ?? 0) + passiveScoreSupportAt(cardsInOrder, memberIndex);
|
||||
return [{
|
||||
memberIndex,
|
||||
probability,
|
||||
boost: card.active.scoreUpPercent / 100 * (1 + support / 100)
|
||||
}];
|
||||
});
|
||||
return candidates.sort((left, right) => right.boost - left.boost || left.memberIndex - right.memberIndex);
|
||||
}
|
||||
|
||||
function expectedStrongestBoost(candidates: readonly ActiveCandidate[]): number {
|
||||
let expectedBoost = 0;
|
||||
let previousFailed = 1;
|
||||
for (const candidate of candidates) {
|
||||
expectedBoost += previousFailed * candidate.probability * candidate.boost;
|
||||
previousFailed *= 1 - candidate.probability;
|
||||
}
|
||||
return expectedBoost;
|
||||
}
|
||||
|
||||
function finalNoteScore(score: number, percent: number): number {
|
||||
const scaled = score * (1 + percent / 100);
|
||||
return Math.ceil(scaled - Number.EPSILON * Math.max(1, scaled) * 4);
|
||||
}
|
||||
|
||||
export function perfectNoteScoreFromPower(overallPower: number, scoreRatio: number | null, fallback = 600): number {
|
||||
if (overallPower > 0 && scoreRatio !== null && scoreRatio > 0) {
|
||||
return Math.max(1, overallPower / scoreRatio);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function calculateExactScore(
|
||||
cardsInOrder: readonly OptimizerCard[],
|
||||
reductions: Readonly<Record<string, CooldownReduction>>,
|
||||
chart: ExactSongChart,
|
||||
options: ExactScoreOptions
|
||||
): ExactScoreResult {
|
||||
const directBonus = options.directSongScoreBonusPercent ?? 0;
|
||||
const countedMidTimes = new Set<number>();
|
||||
const scoreNotes = chart.notes.filter((note) => {
|
||||
if (note.type !== 'mid') return true;
|
||||
if (countedMidTimes.has(note.timeSeconds)) return false;
|
||||
countedMidTimes.add(note.timeSeconds);
|
||||
return true;
|
||||
});
|
||||
const notePoints = scoreNotes.map((note, index) => {
|
||||
const combo = index + 1;
|
||||
const baseNoteScore = note.type === 'mid'
|
||||
? Math.ceil(options.perfectNoteScore * 0.1)
|
||||
: options.perfectNoteScore * (note.isFlick ? 1.05 : 1);
|
||||
const rawBaselineScore = baseNoteScore * (1 + comboBonus(combo));
|
||||
const candidates = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds);
|
||||
const baselineScore = finalNoteScore(rawBaselineScore, directBonus);
|
||||
const expectedScore = finalNoteScore(rawBaselineScore * (1 + expectedStrongestBoost(candidates)), directBonus);
|
||||
const theoreticalMaxScore = finalNoteScore(rawBaselineScore * (1 + (candidates[0]?.boost ?? 0)), directBonus);
|
||||
return { timeSeconds: note.timeSeconds, combo, expectedScore, theoreticalMaxScore, baselineScore };
|
||||
});
|
||||
|
||||
const rawExpected = notePoints.reduce((sum, note) => sum + note.expectedScore, 0);
|
||||
const rawMaximum = notePoints.reduce((sum, note) => sum + note.theoreticalMaxScore, 0);
|
||||
const rawBaseline = notePoints.reduce((sum, note) => sum + note.baselineScore, 0);
|
||||
const scoreBySecond = Array.from({ length: Math.ceil(chart.durationSeconds) }, () => 0);
|
||||
notePoints.forEach((note) => {
|
||||
const second = Math.floor(note.timeSeconds);
|
||||
if (second >= 0 && second < scoreBySecond.length) scoreBySecond[second] += note.expectedScore;
|
||||
});
|
||||
|
||||
return {
|
||||
expectedScore: rawExpected,
|
||||
theoreticalMaxScore: rawMaximum,
|
||||
baselineScore: rawBaseline,
|
||||
perfectNoteScore: options.perfectNoteScore,
|
||||
scoreBySecond,
|
||||
notes: notePoints
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user