feat: show special note shares and clarify average event rankings
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import SpecialNoteSummary from './SpecialNoteSummary.svelte';
|
||||
import type { EffectiveSkillWindow, ExactOptimizationResult, SkillWindow } from '$lib/engine/index.ts';
|
||||
|
||||
type DisplayEffectiveWindow = SkillWindow & {
|
||||
@@ -115,6 +116,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SpecialNoteSummary {result} {cardName} />
|
||||
|
||||
<section class="skill-timeline" aria-labelledby={`timeline-title-${instanceId}`}>
|
||||
<div class="timeline-heading">
|
||||
<div><h3 id={`timeline-title-${instanceId}`}>스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import type { ExactOptimizationResult } from '$lib/engine/types.ts';
|
||||
import { summarizeSpecialNotes } from '$lib/engine/special-note-summary.ts';
|
||||
export let result: ExactOptimizationResult;
|
||||
export let cardName: (cardId: string) => string;
|
||||
$: summaries = summarizeSpecialNotes(result.notes, result.specialWindows);
|
||||
</script>
|
||||
|
||||
<section class="special-note-summary" aria-label="멤버별 스페셜 구간 노트 비중">
|
||||
<h3>스페셜 구간 노트 비중</h3>
|
||||
<p>노트 수는 롱노트 중간 판정을 포함합니다. 기본점수 비중은 스킬 미발동 기준으로 노트 종류·콤보·가창 보너스를 반영한 점수 합계의 비율이며, 실제 스페셜 점수 기여도는 아닙니다. 겹친 구간은 각 멤버에 포함합니다.</p>
|
||||
<div class="summary-grid">
|
||||
{#each summaries as summary}
|
||||
<div class="summary-card">
|
||||
<strong>{cardName(summary.cardId)}</strong>
|
||||
<small>{summary.startSeconds.toFixed(1)}~{summary.endSeconds.toFixed(1)}초</small>
|
||||
<span>노트 {summary.noteCount.toLocaleString('ko-KR')}개 · 전체의 {summary.notePercent.toFixed(1)}%</span>
|
||||
<span>기본점수 합계 {summary.baselineScore.toLocaleString('ko-KR')} · {summary.baselinePercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p>스페셜 발동 구간이 없습니다.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.special-note-summary { margin: 16px 0; padding: 14px; border: 1px solid #303a47; border-radius: 12px; background: #131922; }
|
||||
h3 { margin: 0 0 8px; color: #dce3eb; font-size: 15px; }
|
||||
p { margin: 0 0 12px; color: #9ba8bb; font-size: 12px; line-height: 1.6; }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 8px; }
|
||||
.summary-card { display: grid; gap: 5px; padding: 10px; border-radius: 8px; background: #1c2430; overflow-wrap: anywhere; }
|
||||
strong { color: #e0e7f1; font-size: 13px; }
|
||||
small { color: #9ba8bb; }
|
||||
span { color: #c0ccdc; font-size: 12px; }
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { events } from '../data/events.ts';
|
||||
import { activeEventAt, eventBonusBreakdown, eventChallengeSongScoreBonusPercent, eventOptimizationCardEligible, eventPointIndex } from './event-optimization.ts';
|
||||
import { activeEventAt, compareEventRankings, eventBonusBreakdown, eventChallengeSongScoreBonusPercent, eventOptimizationCardEligible, eventPointIndex } from './event-optimization.ts';
|
||||
|
||||
const currentEvent = events[0];
|
||||
|
||||
@@ -46,3 +46,18 @@ test('과제곡에 대응하는 신규 카드가 있을 때만 스코어 보너
|
||||
test('예상 점수에 이벤트 획득 배율을 적용해 비교 지수를 만든다', () => {
|
||||
assert.equal(eventPointIndex(1_000_000, 128), 2_280_000);
|
||||
});
|
||||
|
||||
test('이벤트 순위는 최고점보다 평균 점수를 우선한다', () => {
|
||||
const highMaximum = { result: { expectedScore: 100, theoreticalMaxScore: 300 } };
|
||||
const highAverage = { result: { expectedScore: 120, theoreticalMaxScore: 200 } };
|
||||
assert.deepEqual([highMaximum, highAverage].sort(compareEventRankings), [highAverage, highMaximum]);
|
||||
});
|
||||
|
||||
test('평균 이벤트 Pt에는 획득 보너스를 반영하고 동률은 평균 점수로 정렬한다', () => {
|
||||
const bonus = { result: { expectedScore: 100 }, eventBonusPercent: 100 };
|
||||
const score = { result: { expectedScore: 150 }, eventBonusPercent: 0 };
|
||||
assert.ok(compareEventRankings(bonus, score) < 0);
|
||||
assert.ok(compareEventRankings(bonus, { result: { expectedScore: 200 } }) > 0);
|
||||
const sameAverage = { ...bonus, result: { expectedScore: 100, theoreticalMaxScore: 999 } };
|
||||
assert.equal(compareEventRankings(bonus, sameAverage), 0);
|
||||
});
|
||||
|
||||
@@ -71,3 +71,12 @@ export function eventChallengeSongScoreBonusPercent(
|
||||
export function eventPointIndex(score: number, eventBonusPercent: number): number {
|
||||
return Math.round(score * (1 + eventBonusPercent / 100));
|
||||
}
|
||||
|
||||
export function compareEventRankings(
|
||||
left: { result: { expectedScore: number }; eventBonusPercent?: number },
|
||||
right: { result: { expectedScore: number }; eventBonusPercent?: number }
|
||||
): number {
|
||||
return eventPointIndex(right.result.expectedScore, right.eventBonusPercent ?? 0)
|
||||
- eventPointIndex(left.result.expectedScore, left.eventBonusPercent ?? 0)
|
||||
|| right.result.expectedScore - left.result.expectedScore;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { summarizeSpecialNotes } from './special-note-summary.ts';
|
||||
|
||||
const note = (timeSeconds: number, baselineScore: number) => ({ timeSeconds, baselineScore, combo: 0, expectedScore: baselineScore * 2, theoreticalMaxScore: baselineScore * 3 });
|
||||
const window = (cardId: string, startSeconds: number, endSeconds: number) => ({ cardId, startSeconds, endSeconds, scoreUpPercent: 100 });
|
||||
|
||||
test('스페셜 시작 노트는 포함하고 종료 노트는 제외하며 동시 노트를 각각 합산한다', () => {
|
||||
const [summary] = summarizeSpecialNotes([note(0, 100), note(1, 100), note(1, 10), note(2, 100)], [window('a', 1, 2)]);
|
||||
assert.equal(summary.noteCount, 2);
|
||||
assert.equal(summary.notePercent, 50);
|
||||
assert.equal(summary.baselineScore, 110);
|
||||
assert.equal(summary.baselinePercent, 110 / 310 * 100);
|
||||
});
|
||||
|
||||
test('겹친 스페셜 구간은 각 멤버 기준으로 계산하고 빈 채보는 0으로 표시한다', () => {
|
||||
const summaries = summarizeSpecialNotes([note(1, 10)], [window('a', 0, 2), window('b', 1, 3)]);
|
||||
assert.deepEqual(summaries.map((summary) => summary.noteCount), [1, 1]);
|
||||
const [empty] = summarizeSpecialNotes([], [window('a', 0, 2)]);
|
||||
assert.equal(empty.notePercent, 0);
|
||||
assert.equal(empty.baselinePercent, 0);
|
||||
assert.deepEqual(summarizeSpecialNotes([note(1, 10)], []), []);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { NoteScorePoint, SkillWindow } from './types.ts';
|
||||
|
||||
export function summarizeSpecialNotes(notes: readonly NoteScorePoint[], windows: readonly SkillWindow[]) {
|
||||
const totalScore = notes.reduce((sum, note) => sum + note.baselineScore, 0);
|
||||
return windows.map((window) => {
|
||||
const selected = notes.filter((note) => window.startSeconds <= note.timeSeconds && note.timeSeconds < window.endSeconds);
|
||||
const baselineScore = selected.reduce((sum, note) => sum + note.baselineScore, 0);
|
||||
return {
|
||||
...window,
|
||||
noteCount: selected.length,
|
||||
notePercent: notes.length > 0 ? selected.length / notes.length * 100 : 0,
|
||||
baselineScore,
|
||||
baselinePercent: totalScore > 0 ? baselineScore / totalScore * 100 : 0
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user