Compare commits
2
Commits
cda25fdcb1
...
397e3c00ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
397e3c00ac | ||
|
|
e1925f4f0f |
+13
-1
@@ -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일
|
||||
|
||||
@@ -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 & {
|
||||
@@ -168,6 +169,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SpecialNoteSummary {result} {cardName} />
|
||||
|
||||
<style>
|
||||
.order-sections { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 18px; }
|
||||
.order-section { min-width: 0; }
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import cardSummary from '$lib/data/cards-summary.json';
|
||||
import SpecialNoteSummary from '$lib/components/SpecialNoteSummary.svelte';
|
||||
import { leaderOutfitCards } from '$lib/engine/leader-outfits.ts';
|
||||
import boardSummary from '$lib/data/boards-summary.json';
|
||||
import memorySummary from '$lib/data/memory-summary.json';
|
||||
@@ -1018,6 +1019,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<SpecialNoteSummary {result} cardName={resultCardName} />
|
||||
{:else}
|
||||
<div class="empty">조건을 선택하고 분석을 시작하면 최적 배치와 각 멤버의 쿨타임 감소량이 여기에 표시됩니다.</div>
|
||||
{/if}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
calculateBoardEffects,
|
||||
calculateUnitPower,
|
||||
combinationSearchLimits,
|
||||
compareEventRankings,
|
||||
countOptimizationCandidates,
|
||||
effectiveActiveProbability,
|
||||
eventBonusBreakdown,
|
||||
@@ -586,6 +587,7 @@
|
||||
}
|
||||
|
||||
function compareRankedResults(left: RankedResult, right: RankedResult): number {
|
||||
if (eventMode) return compareEventRankings(left, right);
|
||||
return rankingValue(right) - rankingValue(left) ||
|
||||
right.result.expectedScore - left.result.expectedScore ||
|
||||
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore;
|
||||
@@ -810,10 +812,10 @@
|
||||
directSongScoreBonusPercent,
|
||||
heuristic: power.overallPower * (1 + skillPotential / 500) * (rankingMultiplier ?? 1) * (1 + eventSongScoreBonus / 100),
|
||||
skillPotential,
|
||||
optimizationObjective: eventMode ? 'expected' : 'maximum',
|
||||
...(eventBonus ? {
|
||||
eventBonusPercent: eventBonus.totalPercent,
|
||||
rankingMultiplier,
|
||||
optimizationObjective: 'expected' as const
|
||||
rankingMultiplier
|
||||
} : {})
|
||||
};
|
||||
}
|
||||
@@ -1120,7 +1122,7 @@
|
||||
<main>
|
||||
<header>
|
||||
<h1>{eventMode ? '이벤트 Pt 최적화' : '조합 탐색'}</h1>
|
||||
<p class="page-description">{eventMode ? '진행 중인 이벤트의 편성·신규 카드·개화 보너스를 반영해 예상 이벤트 Pt가 높은 조합과 스페셜 순서를 찾습니다.' : '악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.'}</p>
|
||||
<p class="page-description">{eventMode ? '평균 점수에 편성·신규 카드·개화 보너스를 반영한 평균 이벤트 Pt 지수가 높은 순서로 조합을 추천합니다. 스페셜 순서도 평균 점수를 기준으로 최적화합니다.' : '악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.'}</p>
|
||||
{#if eventMode && currentEvent}
|
||||
<div class="event-summary" role="status">
|
||||
<strong>{currentEvent.name}</strong>
|
||||
@@ -1279,9 +1281,9 @@
|
||||
{/each}
|
||||
</span>
|
||||
<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>
|
||||
{#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}
|
||||
<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
|
||||
</span>
|
||||
@@ -1306,7 +1308,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
<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>
|
||||
{:else}
|
||||
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
|
||||
|
||||
Reference in New Issue
Block a user