feat: simulate individual active skill failures in analysis
Publish GitHub Pages output / publish (push) Successful in 45s

This commit is contained in:
2026-09-08 12:46:00 +09:00
parent dad8b2c53a
commit 795bf47e80
7 changed files with 126 additions and 24 deletions
+6 -4
View File
@@ -99,10 +99,11 @@ function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): M
return profiles;
}
function resultWindows(
export function buildExactSkillWindows(
order: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart
chart: ExactSongChart,
excludedActiveWindows: readonly Pick<SkillWindow, 'cardId' | 'startSeconds'>[] = []
): { activeWindows: SkillWindow[]; effectiveActiveWindows: EffectiveSkillWindow[]; specialWindows: SkillWindow[] } {
const activeWindows = order.flatMap((card) => {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reductions[card.id] ?? 0);
@@ -143,7 +144,8 @@ function resultWindows(
const midpoint = (startSeconds + endSeconds) / 2;
const specialSupport = specialRate(order, chart, midpoint, 'scoreUpPercent');
const active = activeWindows
.filter((window) => window.startSeconds <= midpoint && midpoint < window.endSeconds)
.filter((window) => window.startSeconds <= midpoint && midpoint < window.endSeconds &&
!excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && Math.abs(excluded.startSeconds - window.startSeconds) < 1e-9))
.map((window) => {
const cardIndex = orderIndex.get(window.cardId) ?? 0;
const card = order[cardIndex];
@@ -265,7 +267,7 @@ export function optimizeExactOrderAndCooldowns(
}
if (!bestOrder || !bestReductions) throw new Error('분석 결과를 만들지 못했습니다.');
const windows = resultWindows(bestOrder, bestReductions, chart);
const windows = buildExactSkillWindows(bestOrder, bestReductions, chart);
return {
...calculateExactScore(bestOrder, bestReductions, chart, options),
order: bestOrder.map((card) => card.id),
+33 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import scoreCalibrations from '../../../data/score-calibrations.json' with { type: 'json' };
import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts';
import { buildExactSkillWindows, calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
const inactiveCard: OptimizerCard = {
@@ -121,3 +121,35 @@ test('등록된 실측 ratio가 모든 1노트 측정값을 재현한다', () =>
}
}
});
test('지정한 액티브만 실패 처리하고 겹친 다른 카드와 다음 발동은 유지한다', () => {
const strong: OptimizerCard = {
...inactiveCard, id: 'strong',
active: { cooldownSeconds: 10, durationSeconds: 3, scoreUpPercent: 100, probabilityPercent: 50 }
};
const weak: OptimizerCard = {
...inactiveCard, id: 'weak',
active: { cooldownSeconds: 10, durationSeconds: 3, scoreUpPercent: 50, probabilityPercent: 100 }
};
const interval = 10 / 1.04;
const song = chart([interval - 0.01, interval, interval + 3, interval * 2].map((timeSeconds) => ({ timeSeconds, type: 'regular' as const, isFlick: false })));
const reductions = { strong: 4 as const, weak: 4 as const };
const cards = [strong, weak];
const original = calculateExactScore(cards, reductions, song, { perfectNoteScore: 100 });
const excludedActiveWindows = [{ cardId: 'strong', startSeconds: interval }];
const modified = calculateExactScore(cards, reductions, song, { perfectNoteScore: 100, excludedActiveWindows });
assert.deepEqual(modified.notes.map((note) => note.theoreticalMaxScore), [100, 150, 100, 200]);
assert.deepEqual(modified.notes.map((note) => note.expectedScore), [100, 150, 100, 175]);
assert.equal(modified.baselineScore, original.baselineScore);
assert.equal(modified.scoreBySecond[9], 250);
assert.deepEqual(calculateExactScore(cards, reductions, song, { perfectNoteScore: 100, excludedActiveWindows: [] }), original);
const windows = buildExactSkillWindows(cards, reductions, song, excludedActiveWindows);
assert.equal(windows.effectiveActiveWindows.find((window) => window.startSeconds === interval)?.cardId, 'weak');
assert.equal(windows.effectiveActiveWindows.find((window) => window.startSeconds === interval * 2)?.cardId, 'strong');
assert.ok(windows.activeWindows.some((window) => window.cardId === 'strong' && window.startSeconds === interval));
const bothExcluded = [...excludedActiveWindows, { cardId: 'weak', startSeconds: interval }];
const noActive = calculateExactScore(cards, reductions, song, { perfectNoteScore: 100, excludedActiveWindows: bothExcluded });
assert.equal(noActive.notes[1].theoreticalMaxScore, noActive.notes[1].baselineScore);
assert.ok(!buildExactSkillWindows(cards, reductions, song, bothExcluded).effectiveActiveWindows.some((window) => window.startSeconds === interval));
});
+4 -2
View File
@@ -95,7 +95,8 @@ function activeCandidates(
cardsInOrder: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
timeSeconds: number
timeSeconds: number,
excludedActiveWindows: ExactScoreOptions['excludedActiveWindows']
): ActiveCandidate[] {
const specialSupport = specialSupportAt(cardsInOrder, chart, timeSeconds);
const candidates = cardsInOrder.flatMap((card, memberIndex) => {
@@ -104,6 +105,7 @@ function activeCandidates(
if (attempt <= 0) return [];
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const activationTime = attempt * interval;
if (excludedActiveWindows?.some((window) => window.cardId === card.id && Math.abs(window.startSeconds - activationTime) < 1e-9)) return [];
if (timeSeconds >= activationTime + card.active.durationSeconds) return [];
const activationRateBonus = activationRateBonusAt(cardsInOrder, chart, activationTime);
const probability = effectiveActiveProbability(card, activationRateBonus);
@@ -153,7 +155,7 @@ export function calculateExactScore(
? 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 candidates = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds, options.excludedActiveWindows);
const baselineScore = finalNoteScore(rawBaselineScore, directBonus);
const expectedScore = finalNoteScore(rawBaselineScore * (1 + expectedStrongestBoost(candidates)), directBonus);
const theoreticalMaxScore = finalNoteScore(rawBaselineScore * (1 + (candidates[0]?.boost ?? 0)), directBonus);
+6 -4
View File
@@ -41,13 +41,15 @@ test('공식 적용 카탈로그와 실제 채보가 같은 분모 및 환산값
continue;
}
assert.equal(row.scoreCalibrationSource, 'formula-v1');
assert.equal(row.scoreRatio, scoreRatioFromFormula(ref));
assert.deepEqual(chart.scoreFormula, row.scoreFormula);
assert.deepEqual(chart.scoreFormula, {
assert.ok(typeof ref.liveScoreCoefficientPermil === 'number' && typeof ref.difficultyLevel === 'number');
const formula = {
perfectNoteWeight: ref.perfectNoteWeight,
liveScoreCoefficientPermil: ref.liveScoreCoefficientPermil,
difficultyLevel: ref.difficultyLevel
});
};
assert.equal(row.scoreRatio, scoreRatioFromFormula(formula));
assert.deepEqual(chart.scoreFormula, row.scoreFormula);
assert.deepEqual(chart.scoreFormula, formula);
assert.equal(chart.notes.length, ref.fullComboNoteCount);
formulaCount += 1;
}
+2
View File
@@ -118,6 +118,8 @@ export type ExactScoreResult = {
};
export type ExactScoreOptions = {
/** 지정한 카드의 해당 발동만 실패한 것으로 계산한다. */
excludedActiveWindows?: readonly Pick<SkillWindow, 'cardId' | 'startSeconds'>[];
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
};