feat: emphasize active skill windows by score impact
Publish GitHub Pages output / publish (push) Successful in 50s
Publish GitHub Pages output / publish (push) Successful in 50s
This commit is contained in:
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user