feat: save per-note scores in analysis history
Publish GitHub Pages output / publish (push) Successful in 48s

This commit is contained in:
2026-10-01 13:03:44 +09:00
parent 469d4e7df5
commit 384c400134
6 changed files with 17 additions and 1 deletions
+4
View File
@@ -18,6 +18,10 @@ updatedAt: 2026-09-30T15:11:08.161Z
`FIX` 모바일 카드 목록에서 명함·5 개화 전환 버튼이 보이지 않던 문제를 수정하고, 터치하기 편하도록 버튼 크기를 키웠습니다.
### 배치 분석 결과 저장
`IMPROVED` 저장 기록에 기본 1노트 점수와 분석 당시 가창 보너스를 반영한 인게임 1노트 점수를 표시합니다. 새 기록은 인게임 1노트 점수도 함께 보관하며, 기존 기록은 해당 값을 미기록으로 표시합니다.
### 두 편성 점수 비교
`IMPROVED` 각 편성의 최적 배치에서 최고점에 도달할 확률과 두 편성의 확률 차이(%p)를 표시합니다.
@@ -140,6 +140,7 @@
<strong class="record-name">{record.name}</strong><span class="song-name">{record.title}</span>
<span class="members">{#each record.settings.cardIds as id}<span>{cardName(id)}</span>{/each}</span>
<span class="record-scores"><span><small>평균 점수</small><b>{record.result.expectedScore.toLocaleString('ko-KR')}</b></span><span><small>이론상 고점</small><b>{record.result.theoreticalMaxScore.toLocaleString('ko-KR')}</b></span></span>
<span class="song-name">기본 1노트 {Math.ceil(record.result.perfectNoteScore).toLocaleString('ko-KR')}점 · 인게임 1노트 {record.inGamePerfectNoteScore === undefined ? '미기록' : `${record.inGamePerfectNoteScore.toLocaleString('ko-KR')}점`}</span>
<span class="open-label">{selected?.id === record.id ? '확인 중' : '기록 자세히 보기'} <span aria-hidden="true">↗</span></span>
</button>
<button class="delete-button" disabled={busy} on:click={() => remove(record)} aria-label={`${record.name} 삭제`}>삭제</button>
@@ -150,6 +151,8 @@
<article>
<span class="eyebrow">SAVED RESULT</span><h3 class="detail-title">{selected.name}</h3>
<p class="hint">저장 당시 결과 · {new Date(selected.analyzedAt).toLocaleString('ko-KR')}</p>
<p>기본 1노트 {Math.ceil(selected.result.perfectNoteScore).toLocaleString('ko-KR')}점 · 인게임 1노트 {selected.inGamePerfectNoteScore === undefined ? '미기록' : `${selected.inGamePerfectNoteScore.toLocaleString('ko-KR')}점`}</p>
<p class="hint">인게임 1노트는 분석 당시 가창 보너스를 반영한 일반 PERFECT 점수이며, 스킬·콤보 보너스는 제외합니다. 기존 기록에는 인게임 1노트 점수가 없을 수 있습니다.</p>
<div class="score-cards"><div><span>종합력</span><strong>{selected.overallPower.toLocaleString('ko-KR')}</strong></div><div><span>평균 점수</span><strong>{selected.result.expectedScore.toLocaleString('ko-KR')}</strong></div><div><span>이론상 고점</span><strong>{selected.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div></div>
<h4>추천 스페셜 순서</h4>
<ol class="order-cards">{#each selected.result.order as id, i}<li><span class="slot">{i + 1}</span><strong>{cardName(id)}</strong><span class="cooldown">쿨타임 −{selected.result.cooldownReductions[id]}%</span></li>{/each}</ol>
+6
View File
@@ -54,6 +54,12 @@ test('성장 상태를 공유하는 기록 백업을 손실 없이 읽고 잘못
const parsed = parseAnalysisArchive(JSON.stringify(archive));
assert.equal(parsed.records.length, 2); assert.equal(parsed.snapshots.length, 1);
assert.deepEqual(parsed.snapshots[0].growth, growth);
assert.equal(parsed.records[0].inGamePerfectNoteScore, undefined);
const withNoteScore = { ...archive, records: [{ ...record, inGamePerfectNoteScore: 11 }] };
assert.equal(parseAnalysisArchive(JSON.stringify(withNoteScore)).records[0].inGamePerfectNoteScore, 11);
for (const invalidScore of [-1, '11', null]) {
assert.throws(() => parseAnalysisArchive(JSON.stringify({ ...archive, records: [{ ...record, inGamePerfectNoteScore: invalidScore }] })));
}
for (const difficulty of ['easy', 'normal', 'hard', 'expert']) {
record.settings.difficulty = difficulty;
assert.equal(parseAnalysisArchive(JSON.stringify(archive)).records[0].settings.difficulty, difficulty);
+1
View File
@@ -17,6 +17,7 @@ export type StoredResult = Omit<ExactOptimizationResult, 'notes' | 'scoreBySecon
export type AnalysisCapture = {
settings: AnalysisSettings; growth: GrowthSnapshot; title: string; durationSeconds: number;
overallPower: number; analyzedAt: string;
inGamePerfectNoteScore?: number;
};
export type AnalysisRecord = Omit<AnalysisCapture, 'growth'> & {
id: string; name: string; snapshotId: string; result: StoredResult;
+1
View File
@@ -31,6 +31,7 @@ export function parseAnalysisArchive(text: string): Archive {
if (s.generationBonusOverrides && (s.generationBonusOverrides.length !== 5 || !s.generationBonusOverrides.every((v) => v === null || (finite(v) && v <= 900)))) return invalid();
if (s.songScoreBonusOverride != null && (!finite(s.songScoreBonusOverride) || s.songScoreBonusOverride > 10)) return invalid();
if (![record.overallPower, record.durationSeconds, r.expectedScore, r.theoreticalMaxScore, r.baselineScore, r.perfectNoteScore].every(finite)) return invalid();
if (record.inGamePerfectNoteScore !== undefined && !finite(record.inGamePerfectNoteScore)) return invalid();
if (!strings(r.order) || r.order.length !== 5 || new Set(r.order).size !== 5 || !r.order.every((id) => s.cardIds.includes(id)) || !object(r.cooldownReductions) || !r.order.every((id) => [0, 4, 8, 12].includes(r.cooldownReductions[id]))) return invalid();
}
return archive;
+2 -1
View File
@@ -382,7 +382,7 @@
void tick().then(async () => {
if (!mounted) return;
result = transfer.result;
resultCapture = transfer.capture;
resultCapture = { ...transfer.capture, inGamePerfectNoteScore: finalNoteScore(transfer.result.perfectNoteScore, transfer.directSongScoreBonusPercent) };
resultInputKey = transferredAnalysisKey(transfer.capture);
achievementProbabilities = transfer.achievementProbabilities;
simulationContext = { cards: transfer.cards, chart: transfer.chart, options: {
@@ -861,6 +861,7 @@
title: selectedChart.title,
durationSeconds: selectedChart.durationSeconds,
overallPower,
inGamePerfectNoteScore,
analyzedAt: new Date().toISOString()
});
const noteScore = perfectNoteScore;