feat: show special note shares and clarify average event rankings

This commit is contained in:
2026-09-04 10:41:52 +09:00
parent cda25fdcb1
commit e1925f4f0f
9 changed files with 126 additions and 8 deletions
+13 -1
View File
@@ -1,5 +1,17 @@
--- ---
updatedAt: 2026-09-03T10:37:43+09:00 updatedAt: 2026-09-04T10:39:24+09:00
---
## 2026년 9월 4일
### 스페셜 구간 노트 비중 표시
`NEW` 배치 분석과 조합 탐색 결과에서 멤버별 스페셜 구간의 노트 수와 비중, 스킬 미발동 기준 점수 비중을 확인할 수 있습니다.
### 이벤트 Pt 평균 기준 안내 개선
`IMPROVED` 이벤트 Pt 최적화의 순위 기준을 평균 점수와 이벤트 보너스를 반영한 평균 이벤트 Pt 지수로 명확히 표시합니다.
--- ---
## 2026년 9월 3일 ## 2026년 9월 3일
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import SpecialNoteSummary from './SpecialNoteSummary.svelte';
import type { EffectiveSkillWindow, ExactOptimizationResult, SkillWindow } from '$lib/engine/index.ts'; import type { EffectiveSkillWindow, ExactOptimizationResult, SkillWindow } from '$lib/engine/index.ts';
type DisplayEffectiveWindow = SkillWindow & { type DisplayEffectiveWindow = SkillWindow & {
@@ -115,6 +116,8 @@
</div> </div>
{/if} {/if}
<SpecialNoteSummary {result} {cardName} />
<section class="skill-timeline" aria-labelledby={`timeline-title-${instanceId}`}> <section class="skill-timeline" aria-labelledby={`timeline-title-${instanceId}`}>
<div class="timeline-heading"> <div class="timeline-heading">
<div><h3 id={`timeline-title-${instanceId}`}>스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div> <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>
+16 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { events } from '../data/events.ts'; 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]; const currentEvent = events[0];
@@ -46,3 +46,18 @@ test('과제곡에 대응하는 신규 카드가 있을 때만 스코어 보너
test('예상 점수에 이벤트 획득 배율을 적용해 비교 지수를 만든다', () => { test('예상 점수에 이벤트 획득 배율을 적용해 비교 지수를 만든다', () => {
assert.equal(eventPointIndex(1_000_000, 128), 2_280_000); 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);
});
+9
View File
@@ -71,3 +71,12 @@ export function eventChallengeSongScoreBonusPercent(
export function eventPointIndex(score: number, eventBonusPercent: number): number { export function eventPointIndex(score: number, eventBonusPercent: number): number {
return Math.round(score * (1 + eventBonusPercent / 100)); 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)], []), []);
});
+16
View File
@@ -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
};
});
}
+3
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import cardSummary from '$lib/data/cards-summary.json'; import cardSummary from '$lib/data/cards-summary.json';
import SpecialNoteSummary from '$lib/components/SpecialNoteSummary.svelte';
import { leaderOutfitCards } from '$lib/engine/leader-outfits.ts'; import { leaderOutfitCards } from '$lib/engine/leader-outfits.ts';
import boardSummary from '$lib/data/boards-summary.json'; import boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json'; import memorySummary from '$lib/data/memory-summary.json';
@@ -955,6 +956,8 @@
{/if} {/if}
</section> </section>
<SpecialNoteSummary {result} cardName={resultCardName} />
<section class="skill-timeline" aria-labelledby="timeline-title"> <section class="skill-timeline" aria-labelledby="timeline-title">
<div class="timeline-heading"> <div class="timeline-heading">
<div><h3 id="timeline-title">스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div> <div><h3 id="timeline-title">스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div>
+8 -6
View File
@@ -18,6 +18,7 @@
calculateBoardEffects, calculateBoardEffects,
calculateUnitPower, calculateUnitPower,
combinationSearchLimits, combinationSearchLimits,
compareEventRankings,
countOptimizationCandidates, countOptimizationCandidates,
effectiveActiveProbability, effectiveActiveProbability,
eventBonusBreakdown, eventBonusBreakdown,
@@ -586,6 +587,7 @@
} }
function compareRankedResults(left: RankedResult, right: RankedResult): number { function compareRankedResults(left: RankedResult, right: RankedResult): number {
if (eventMode) return compareEventRankings(left, right);
return rankingValue(right) - rankingValue(left) || return rankingValue(right) - rankingValue(left) ||
right.result.expectedScore - left.result.expectedScore || right.result.expectedScore - left.result.expectedScore ||
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore; right.result.theoreticalMaxScore - left.result.theoreticalMaxScore;
@@ -810,10 +812,10 @@
directSongScoreBonusPercent, directSongScoreBonusPercent,
heuristic: power.overallPower * (1 + skillPotential / 500) * (rankingMultiplier ?? 1) * (1 + eventSongScoreBonus / 100), heuristic: power.overallPower * (1 + skillPotential / 500) * (rankingMultiplier ?? 1) * (1 + eventSongScoreBonus / 100),
skillPotential, skillPotential,
optimizationObjective: eventMode ? 'expected' : 'maximum',
...(eventBonus ? { ...(eventBonus ? {
eventBonusPercent: eventBonus.totalPercent, eventBonusPercent: eventBonus.totalPercent,
rankingMultiplier, rankingMultiplier
optimizationObjective: 'expected' as const
} : {}) } : {})
}; };
} }
@@ -1120,7 +1122,7 @@
<main> <main>
<header> <header>
<h1>{eventMode ? '이벤트 Pt 최적화' : '조합 탐색'}</h1> <h1>{eventMode ? '이벤트 Pt 최적화' : '조합 탐색'}</h1>
<p class="page-description">{eventMode ? '진행 중인 이벤트의 편성·신규 카드·개화 보너스를 반영해 예상 이벤트 Pt가 높은 조합과 스페셜 순서를 찾습니다.' : '악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.'}</p> <p class="page-description">{eventMode ? '평균 점수에 편성·신규 카드·개화 보너스를 반영한 평균 이벤트 Pt 지수가 높은 순서로 조합을 추천합니다. 스페셜 순서도 평균 점수를 기준으로 최적화합니다.' : '악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.'}</p>
{#if eventMode && currentEvent} {#if eventMode && currentEvent}
<div class="event-summary" role="status"> <div class="event-summary" role="status">
<strong>{currentEvent.name}</strong> <strong>{currentEvent.name}</strong>
@@ -1279,9 +1281,9 @@
{/each} {/each}
</span> </span>
<span class="rank-metric"> <span class="rank-metric">
<small>{eventMode ? '예상 이벤트 Pt 지수' : '최고 점수'}</small> <small>{eventMode ? '평균 이벤트 Pt 지수' : '최고 점수'}</small>
<strong>{eventMode ? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0).toLocaleString('ko-KR') : ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong> <strong>{eventMode ? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0).toLocaleString('ko-KR') : ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>
{#if eventMode}<i>획득량 +{ranking.eventBonusPercent ?? 0}% · 예상 점수 {ranking.result.expectedScore.toLocaleString('ko-KR')}</i>{/if} {#if eventMode}<i>획득량 +{ranking.eventBonusPercent ?? 0}% · 평균 점수 {ranking.result.expectedScore.toLocaleString('ko-KR')}</i>{/if}
{#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if} {#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}
<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b> <b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
</span> </span>
@@ -1306,7 +1308,7 @@
{/each} {/each}
</div> </div>
<p class="awakening-guide">추가 개화는 현재 카드 데이터와 조합의 목표 개화를 비교한 값입니다. 미보유 카드를 처음 얻는 과정도 1회로 계산합니다.</p> <p class="awakening-guide">추가 개화는 현재 카드 데이터와 조합의 목표 개화를 비교한 값입니다. 미보유 카드를 처음 얻는 과정도 1회로 계산합니다.</p>
{#if eventMode}<p class="event-point-guide">이벤트 Pt 지수는 예상 점수에 편성·신규 카드·개화 획득 배율을 적용한 비교값입니다. 게임 서버의 최종 반올림과 멀티 라이브 보정에 따라 실제 획득 Pt는 달라질 수 있습니다.</p>{/if} {#if eventMode}<p class="event-point-guide">순위는 최고점이 아닌 평균 이벤트 Pt 지수 기준입니다. 평균 점수(액티브 발동 확률을 반영한 기대값)에 편성·신규 카드·개화 획득 배율을 적용해 비교하며, 지수가 같으면 평균 점수가 높은 조합을 우선합니다. 게임 서버의 최종 반올림과 멀티 라이브 보정에 따라 실제 획득 Pt는 달라질 수 있습니다.</p>{/if}
<p class="probability-guide">달성 확률은 모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p> <p class="probability-guide">달성 확률은 모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
{:else} {:else}
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div> <div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>