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

161 lines
5.9 KiB
TypeScript

import { effectiveCooldownSeconds } from './timeline.ts';
import { passiveScoreSupportAt } from './passive-support.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 {
let weight = 0;
for (const note of notes) {
if (note.type === 'mid') {
weight += 0.1;
continue;
}
weight += note.isFlick ? 1.05 : 1;
}
return weight * CHART_SCORE_WEIGHT_COEFFICIENT;
}
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 scoreNotes = chart.notes;
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
};
}