From 384c4001349b357e96d0e8e8bd6d8c91be716e96 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Thu, 1 Oct 2026 13:03:44 +0900 Subject: [PATCH] feat: save per-note scores in analysis history --- UPDATES.md | 4 ++++ src/lib/components/AnalysisHistory.svelte | 3 +++ src/lib/engine/analysis-history.test.ts | 6 ++++++ src/lib/models/analysis-history.ts | 1 + src/lib/storage/analysis-history.ts | 1 + src/routes/analysis/+page.svelte | 3 ++- 6 files changed, 17 insertions(+), 1 deletion(-) diff --git a/UPDATES.md b/UPDATES.md index ead1198..b2652ba 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -18,6 +18,10 @@ updatedAt: 2026-09-30T15:11:08.161Z `FIX` 모바일 카드 목록에서 명함·5 개화 전환 버튼이 보이지 않던 문제를 수정하고, 터치하기 편하도록 버튼 크기를 키웠습니다. +### 배치 분석 결과 저장 + +`IMPROVED` 저장 기록에 기본 1노트 점수와 분석 당시 가창 보너스를 반영한 인게임 1노트 점수를 표시합니다. 새 기록은 인게임 1노트 점수도 함께 보관하며, 기존 기록은 해당 값을 미기록으로 표시합니다. + ### 두 편성 점수 비교 `IMPROVED` 각 편성의 최적 배치에서 최고점에 도달할 확률과 두 편성의 확률 차이(%p)를 표시합니다. diff --git a/src/lib/components/AnalysisHistory.svelte b/src/lib/components/AnalysisHistory.svelte index dd43394..58579c6 100644 --- a/src/lib/components/AnalysisHistory.svelte +++ b/src/lib/components/AnalysisHistory.svelte @@ -140,6 +140,7 @@ {record.name}{record.title} {#each record.settings.cardIds as id}{cardName(id)}{/each} 평균 점수{record.result.expectedScore.toLocaleString('ko-KR')}이론상 고점{record.result.theoreticalMaxScore.toLocaleString('ko-KR')} + 기본 1노트 {Math.ceil(record.result.perfectNoteScore).toLocaleString('ko-KR')}점 · 인게임 1노트 {record.inGamePerfectNoteScore === undefined ? '미기록' : `${record.inGamePerfectNoteScore.toLocaleString('ko-KR')}점`} {selected?.id === record.id ? '확인 중' : '기록 자세히 보기'} @@ -150,6 +151,8 @@
SAVED RESULT

{selected.name}

저장 당시 결과 · {new Date(selected.analyzedAt).toLocaleString('ko-KR')}

+

기본 1노트 {Math.ceil(selected.result.perfectNoteScore).toLocaleString('ko-KR')}점 · 인게임 1노트 {selected.inGamePerfectNoteScore === undefined ? '미기록' : `${selected.inGamePerfectNoteScore.toLocaleString('ko-KR')}점`}

+

인게임 1노트는 분석 당시 가창 보너스를 반영한 일반 PERFECT 점수이며, 스킬·콤보 보너스는 제외합니다. 기존 기록에는 인게임 1노트 점수가 없을 수 있습니다.

종합력{selected.overallPower.toLocaleString('ko-KR')}
평균 점수{selected.result.expectedScore.toLocaleString('ko-KR')}
이론상 고점{selected.result.theoreticalMaxScore.toLocaleString('ko-KR')}

추천 스페셜 순서

    {#each selected.result.order as id, i}
  1. {i + 1}{cardName(id)}쿨타임 −{selected.result.cooldownReductions[id]}%
  2. {/each}
diff --git a/src/lib/engine/analysis-history.test.ts b/src/lib/engine/analysis-history.test.ts index f827368..ab93a88 100644 --- a/src/lib/engine/analysis-history.test.ts +++ b/src/lib/engine/analysis-history.test.ts @@ -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); diff --git a/src/lib/models/analysis-history.ts b/src/lib/models/analysis-history.ts index 7d1eed5..6a3c271 100644 --- a/src/lib/models/analysis-history.ts +++ b/src/lib/models/analysis-history.ts @@ -17,6 +17,7 @@ export type StoredResult = Omit & { id: string; name: string; snapshotId: string; result: StoredResult; diff --git a/src/lib/storage/analysis-history.ts b/src/lib/storage/analysis-history.ts index dcf93c1..3899efe 100644 --- a/src/lib/storage/analysis-history.ts +++ b/src/lib/storage/analysis-history.ts @@ -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; diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index 4c24dc9..6f4d16b 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -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;