feat: simulate individual active skill failures in analysis
Publish GitHub Pages output / publish (push) Successful in 45s
Publish GitHub Pages output / publish (push) Successful in 45s
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ export type ExactScoreResult = {
|
||||
};
|
||||
|
||||
export type ExactScoreOptions = {
|
||||
/** 지정한 카드의 해당 발동만 실패한 것으로 계산한다. */
|
||||
excludedActiveWindows?: readonly Pick<SkillWindow, 'cardId' | 'startSeconds'>[];
|
||||
perfectNoteScore: number;
|
||||
directSongScoreBonusPercent?: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user