feat: emphasize active skill windows by score impact
Publish GitHub Pages output / publish (push) Successful in 50s

This commit is contained in:
2026-09-10 11:00:16 +09:00
parent 089bb69ffd
commit 3f21819b39
5 changed files with 150 additions and 7 deletions
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { activeWindowKey, calculateActiveImpacts } from './active-impact.ts';
import { buildExactSkillWindows } from './exact-optimizer.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
test('영향력은 겹친 액티브와 스페셜을 반영하며 제외 후 복원 효과로 갱신된다', () => {
const cards: OptimizerCard[] = [100, 50].map((scoreUpPercent, index) => ({
id: String(index), name: String(index),
active: { cooldownSeconds: 10, durationSeconds: 2, scoreUpPercent },
special: { durationSeconds: 2, scoreUpPercent: index === 0 ? 100 : 0 }
}));
const chart: ExactSongChart = {
songId: 'synthetic', difficulty: 'expert', durationSeconds: 22, scoreRatio: 1,
notes: [10, 20].map((timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false })),
specialStartSeconds: [10, null]
};
const windows = buildExactSkillWindows(cards, {}, chart).activeWindows;
const key = (cardId: string, startSeconds: number) => activeWindowKey({ cardId, startSeconds });
const impacts = calculateActiveImpacts(cards, {}, chart, { perfectNoteScore: 1000 }, windows);
assert.deepEqual(impacts.get(key('0', 10)), { scoreChange: -1000, strength: 1 });
assert.deepEqual(impacts.get(key('0', 20)), { scoreChange: -500, strength: 0.5 });
assert.deepEqual(impacts.get(key('1', 10)), { scoreChange: 0, strength: 0 });
const after = calculateActiveImpacts(cards, {}, chart, {
perfectNoteScore: 1000, excludedActiveWindows: [{ cardId: '0', startSeconds: 10 }]
}, windows);
assert.deepEqual(after.get(key('0', 10)), { scoreChange: 1000, strength: 1 });
assert.deepEqual(after.get(key('1', 10)), { scoreChange: -1000, strength: 1 });
const empty = calculateActiveImpacts(cards, {}, { ...chart, notes: [] }, { perfectNoteScore: 1000 }, windows);
assert.ok([...empty.values()].every((value) => value.scoreChange === 0 && value.strength === 0));
});
+31
View File
@@ -0,0 +1,31 @@
import { calculateExactScore } from './score.ts';
import type { CooldownReduction, ExactScoreOptions, ExactSongChart, OptimizerCard, SkillWindow } from './types.ts';
export function activeWindowKey(window: Pick<SkillWindow, 'cardId' | 'startSeconds'>): string {
return JSON.stringify([window.cardId, window.startSeconds]);
}
/** Current-setting marginal maximum-score change when each activation is toggled. */
export function calculateActiveImpacts(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
options: ExactScoreOptions,
windows: readonly SkillWindow[]
): Map<string, { scoreChange: number; strength: number }> {
const excluded = options.excludedActiveWindows ?? [];
const excludedKeys = new Set(excluded.map(activeWindowKey));
const current = calculateExactScore(cards, reductions, chart, options).theoreticalMaxScore;
const changes = windows.map((window) => {
const key = activeWindowKey(window);
const toggled = excludedKeys.has(key)
? excluded.filter((entry) => activeWindowKey(entry) !== key)
: [...excluded, window];
const score = calculateExactScore(cards, reductions, chart, { ...options, excludedActiveWindows: toggled });
return { key, scoreChange: score.theoreticalMaxScore - current };
});
const maximum = Math.max(0, ...changes.map((entry) => Math.abs(entry.scoreChange)));
return new Map(changes.map(({ key, scoreChange }) => [key, {
scoreChange, strength: maximum > 0 ? Math.abs(scoreChange) / maximum : 0
}]));
}
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { calculateExactScore } from './score.ts';
import { buildExactSkillWindows } from './exact-optimizer.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
const strong: OptimizerCard = {
id: 'strong', name: 'strong',
active: { cooldownSeconds: 10, durationSeconds: 4, scoreUpPercent: 100, probabilityPercent: 50 },
special: { durationSeconds: 2, scoreUpPercent: 145 }
};
const weak: OptimizerCard = {
id: 'weak', name: 'weak',
active: { cooldownSeconds: 10, durationSeconds: 4, scoreUpPercent: 50, probabilityPercent: 100 },
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
const chart: ExactSongChart = {
songId: 'synthetic-special-overlap', difficulty: 'expert', durationSeconds: 25, scoreRatio: 1,
specialStartSeconds: [11, null],
notes: [10, 11, 12, 13, 14, 20].map((timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false }))
};
const excludedActiveWindows = [{ cardId: 'strong', startSeconds: 10 }];
const options = { perfectNoteScore: 1000 };
test('스페셜 중첩 발동을 제외해도 같은 카드의 스페셜은 대체 액티브에 유지된다', () => {
const before = calculateExactScore([strong, weak], {}, chart, options);
const after = calculateExactScore([strong, weak], {}, chart, { ...options, excludedActiveWindows });
// 11~13초에는 액티브 100%/50%에 서포트 145%를 한 번만 적용한다.
assert.deepEqual(before.notes.map((note) => note.theoreticalMaxScore), [2000, 3450, 3450, 2000, 1000, 2000]);
assert.deepEqual(after.notes.map((note) => note.theoreticalMaxScore), [1500, 2225, 2225, 1500, 1000, 2000]);
assert.equal(before.theoreticalMaxScore - after.theoreticalMaxScore, 3450);
assert.deepEqual(before.notes.map((note, index) => note.theoreticalMaxScore - after.notes[index].theoreticalMaxScore), [500, 1225, 1225, 500, 0, 0]);
const windows = buildExactSkillWindows([strong, weak], {}, chart, excludedActiveWindows);
assert.deepEqual(windows.specialWindows, buildExactSkillWindows([strong, weak], {}, chart).specialWindows);
const winner = windows.effectiveActiveWindows.find((window) => window.startSeconds <= 11 && 11 < window.endSeconds);
assert.equal(winner?.cardId, 'weak');
assert.ok(Math.abs((winner?.scoreUpPercent ?? 0) - 122.5) < 1e-9);
assert.deepEqual(calculateExactScore([strong, weak], {}, chart, { ...options, excludedActiveWindows: [] }), before);
});
test('대체 액티브가 없으면 스페셜 중첩 부분을 포함한 발동 전체의 증가분만 사라진다', () => {
const before = calculateExactScore([strong], {}, chart, options);
const after = calculateExactScore([strong], {}, chart, { ...options, excludedActiveWindows });
assert.deepEqual(after.notes.map((note) => note.theoreticalMaxScore), [1000, 1000, 1000, 1000, 1000, 2000]);
assert.equal(before.theoreticalMaxScore - after.theoreticalMaxScore, 6900);
assert.equal(before.baselineScore, after.baselineScore);
// 최고점 변화는 성공을 가정한다. 발동률 50%를 적용한 기대값 변화와 다르다.
assert.equal(before.expectedScore - after.expectedScore, 3450);
});
test('스페셜이 중첩된 발동도 여러 토글의 증감 합과 원래 대비 차이가 일치한다', () => {
const cards = [strong, weak];
const before = calculateExactScore(cards, {}, chart, options);
const first = calculateExactScore(cards, {}, chart, { ...options, excludedActiveWindows });
const both = calculateExactScore(cards, {}, chart, {
...options, excludedActiveWindows: [...excludedActiveWindows, { cardId: 'weak', startSeconds: 10 }]
});
assert.equal(before.theoreticalMaxScore - first.theoreticalMaxScore, 3450);
assert.equal(first.theoreticalMaxScore - both.theoreticalMaxScore, 3450);
assert.equal(before.theoreticalMaxScore - both.theoreticalMaxScore, 6900);
const weakOnly = calculateExactScore(cards, {}, chart, {
...options, excludedActiveWindows: [{ cardId: 'weak', startSeconds: 10 }]
});
assert.equal(weakOnly.theoreticalMaxScore, before.theoreticalMaxScore);
assert.ok(weakOnly.expectedScore < before.expectedScore);
});