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;
|
||||
};
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
import PresetManager from '$lib/components/PresetManager.svelte';
|
||||
import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte';
|
||||
import SongPicker from '$lib/components/SongPicker.svelte';
|
||||
import { calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
|
||||
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
|
||||
import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
|
||||
import type { ExactScoreOptions, OptimizerCard, CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
|
||||
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
|
||||
|
||||
@@ -114,6 +114,44 @@
|
||||
let running = false;
|
||||
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
|
||||
let result: ExactOptimizationResult | null = null;
|
||||
let simulationContext: { cards: OptimizerCard[]; chart: ExactSongChart; options: ExactScoreOptions } | null = null;
|
||||
let simulationBase: ExactOptimizationResult | null = null;
|
||||
let simulatedResult: ExactOptimizationResult | null = null;
|
||||
let excludedActiveWindows: SkillWindow[] = [];
|
||||
$: if (result !== simulationBase) {
|
||||
simulationBase = result;
|
||||
simulatedResult = null;
|
||||
excludedActiveWindows = [];
|
||||
}
|
||||
$: displayedResult = simulatedResult ?? result;
|
||||
|
||||
function isExcluded(window: SkillWindow): boolean {
|
||||
return excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds);
|
||||
}
|
||||
|
||||
function toggleActiveWindow(window: SkillWindow): void {
|
||||
if (!result || !simulationContext) return;
|
||||
excludedActiveWindows = isExcluded(window)
|
||||
? excludedActiveWindows.filter((excluded) => excluded.cardId !== window.cardId || excluded.startSeconds !== window.startSeconds)
|
||||
: [...excludedActiveWindows, window];
|
||||
if (!excludedActiveWindows.length) {
|
||||
simulatedResult = null;
|
||||
return;
|
||||
}
|
||||
const { cards, chart, options } = simulationContext;
|
||||
const order = result.order.map((id) => cards.find((card) => card.id === id)!);
|
||||
simulatedResult = {
|
||||
...result,
|
||||
...calculateExactScore(order, result.cooldownReductions, chart, { ...options, excludedActiveWindows }),
|
||||
...buildExactSkillWindows(order, result.cooldownReductions, chart, excludedActiveWindows)
|
||||
};
|
||||
}
|
||||
|
||||
function resetActiveWindows(): void {
|
||||
excludedActiveWindows = [];
|
||||
simulatedResult = null;
|
||||
}
|
||||
|
||||
let ratingResultKey = '';
|
||||
let resultCapture: AnalysisCapture | null = null;
|
||||
let pendingCapture: AnalysisCapture | null = null;
|
||||
@@ -176,7 +214,7 @@
|
||||
$: inGamePerfectNoteScore = finalNoteScore(perfectNoteScore, boardEffects.directSongScoreBonusPercent);
|
||||
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'formula-v1' ? '곡별 공식' : selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
|
||||
$: timelineDuration = selectedChart?.durationSeconds ?? 0;
|
||||
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []);
|
||||
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(displayedResult?.effectiveActiveWindows ?? []);
|
||||
$: timelineTicks = buildTimelineTicks(timelineDuration);
|
||||
$: timelineWidth = Math.max(720, timelineDuration * 6);
|
||||
$: candidateCount = (() => {
|
||||
@@ -749,6 +787,7 @@
|
||||
}))
|
||||
});
|
||||
const chart = await loadExactSongChart(selectedChart.path);
|
||||
simulationContext = { cards, chart, options: { perfectNoteScore: noteScore, directSongScoreBonusPercent: songBonus } };
|
||||
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
|
||||
worker.postMessage({
|
||||
type: 'optimize-exact',
|
||||
@@ -1037,11 +1076,11 @@
|
||||
</section>
|
||||
</div>
|
||||
<div class="score-grid">
|
||||
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="score-summary"><span>예상 점수</span><strong>{displayedResult!.expectedScore.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="score-summary"><span>이론상 고점</span><strong>{displayedResult!.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
|
||||
</div>
|
||||
{#if mode === 'analysis' || achievementProbabilities}
|
||||
{#if !simulatedResult && (mode === 'analysis' || achievementProbabilities)}
|
||||
<div class="probability-grid">
|
||||
<div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div>
|
||||
<div><span>최고점의 99% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast99Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.99)}점 이상</small></div>
|
||||
@@ -1096,21 +1135,34 @@
|
||||
{#each result.order as cardId}<span><i style={`background: ${memberColor(cardId)};`}></i>{resultCardName(cardId)}</span>{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if mode === 'analysis'}
|
||||
<p class="simulation-guide" aria-live="polite">카드별 색칠된 구간을 클릭하면 해당 액티브만 미발동으로 계산합니다. 다시 클릭하면 복원됩니다. 배치와 쿨타임은 유지됩니다.
|
||||
{#if excludedActiveWindows.length}
|
||||
<strong>{excludedActiveWindows.length}회 미발동 적용 중</strong>
|
||||
<button type="button" on:click={resetActiveWindows}>모두 복원</button>
|
||||
<span>미발동 적용 중에는 달성 확률을 표시하지 않습니다. 분석 기록에는 원래 분석 결과가 저장됩니다.</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
|
||||
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
|
||||
<ScoreTimelineChart notes={result.notes} durationSeconds={timelineDuration} minimumWidth={timelineWidth} timeTicks={timelineTicks} />
|
||||
<ScoreTimelineChart notes={displayedResult!.notes} durationSeconds={timelineDuration} minimumWidth={timelineWidth} timeTicks={timelineTicks} />
|
||||
{#each result.order as cardId, slot}
|
||||
<div class="timeline-row member-row">
|
||||
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong>
|
||||
<div class="timeline-track skill-track" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
|
||||
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
class="skill-window active-window"
|
||||
class:excluded={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
|
||||
disabled={mode !== 'analysis' || !simulationContext || running}
|
||||
style={coloredWindowStyle(window, timelineDuration)}
|
||||
title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`}
|
||||
role="img"
|
||||
aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`}
|
||||
></span>
|
||||
title={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}% · 클릭하여 미발동/복원`}
|
||||
aria-label={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 미발동 처리`}
|
||||
aria-pressed={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
|
||||
on:click={() => toggleActiveWindow(window)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1290,7 +1342,13 @@
|
||||
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
|
||||
.skill-track { height: 36px; }
|
||||
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
|
||||
.active-window { top: 12px; height: 12px; opacity: .72; }
|
||||
.active-window { top: 8px; height: 20px; opacity: .72; padding: 0; border: 0; cursor: pointer; }
|
||||
.active-window:hover:not(:disabled), .active-window:focus-visible { opacity: 1; outline: 2px solid #fff; outline-offset: 2px; }
|
||||
.active-window.excluded { opacity: .28; border: 1px dashed var(--member-color); background: repeating-linear-gradient(135deg, transparent 0 3px, var(--member-color) 3px 4px); }
|
||||
.active-window:disabled { cursor: default; }
|
||||
.simulation-guide { color: #adb5c2; font-size: 13px; line-height: 1.7; }
|
||||
.simulation-guide strong, .simulation-guide span { display: block; }
|
||||
.simulation-guide button { margin: 4px 0; }
|
||||
.summary-row { background: rgb(255 255 255 / 1.5%); }
|
||||
.summary-label { color: #e1e5ec; font-weight: 800; }
|
||||
.effective-window { top: 9px; height: 18px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }
|
||||
|
||||
Reference in New Issue
Block a user