feat: save per-note scores in analysis history
Publish GitHub Pages output / publish (push) Successful in 48s
Publish GitHub Pages output / publish (push) Successful in 48s
This commit is contained in:
@@ -18,6 +18,10 @@ updatedAt: 2026-09-30T15:11:08.161Z
|
|||||||
|
|
||||||
`FIX` 모바일 카드 목록에서 명함·5 개화 전환 버튼이 보이지 않던 문제를 수정하고, 터치하기 편하도록 버튼 크기를 키웠습니다.
|
`FIX` 모바일 카드 목록에서 명함·5 개화 전환 버튼이 보이지 않던 문제를 수정하고, 터치하기 편하도록 버튼 크기를 키웠습니다.
|
||||||
|
|
||||||
|
### 배치 분석 결과 저장
|
||||||
|
|
||||||
|
`IMPROVED` 저장 기록에 기본 1노트 점수와 분석 당시 가창 보너스를 반영한 인게임 1노트 점수를 표시합니다. 새 기록은 인게임 1노트 점수도 함께 보관하며, 기존 기록은 해당 값을 미기록으로 표시합니다.
|
||||||
|
|
||||||
### 두 편성 점수 비교
|
### 두 편성 점수 비교
|
||||||
|
|
||||||
`IMPROVED` 각 편성의 최적 배치에서 최고점에 도달할 확률과 두 편성의 확률 차이(%p)를 표시합니다.
|
`IMPROVED` 각 편성의 최적 배치에서 최고점에 도달할 확률과 두 편성의 확률 차이(%p)를 표시합니다.
|
||||||
|
|||||||
@@ -140,6 +140,7 @@
|
|||||||
<strong class="record-name">{record.name}</strong><span class="song-name">{record.title}</span>
|
<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="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="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>
|
<span class="open-label">{selected?.id === record.id ? '확인 중' : '기록 자세히 보기'} <span aria-hidden="true">↗</span></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="delete-button" disabled={busy} on:click={() => remove(record)} aria-label={`${record.name} 삭제`}>삭제</button>
|
<button class="delete-button" disabled={busy} on:click={() => remove(record)} aria-label={`${record.name} 삭제`}>삭제</button>
|
||||||
@@ -150,6 +151,8 @@
|
|||||||
<article>
|
<article>
|
||||||
<span class="eyebrow">SAVED RESULT</span><h3 class="detail-title">{selected.name}</h3>
|
<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 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>
|
<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>
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ test('성장 상태를 공유하는 기록 백업을 손실 없이 읽고 잘못
|
|||||||
const parsed = parseAnalysisArchive(JSON.stringify(archive));
|
const parsed = parseAnalysisArchive(JSON.stringify(archive));
|
||||||
assert.equal(parsed.records.length, 2); assert.equal(parsed.snapshots.length, 1);
|
assert.equal(parsed.records.length, 2); assert.equal(parsed.snapshots.length, 1);
|
||||||
assert.deepEqual(parsed.snapshots[0].growth, growth);
|
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']) {
|
for (const difficulty of ['easy', 'normal', 'hard', 'expert']) {
|
||||||
record.settings.difficulty = difficulty;
|
record.settings.difficulty = difficulty;
|
||||||
assert.equal(parseAnalysisArchive(JSON.stringify(archive)).records[0].settings.difficulty, difficulty);
|
assert.equal(parseAnalysisArchive(JSON.stringify(archive)).records[0].settings.difficulty, difficulty);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type StoredResult = Omit<ExactOptimizationResult, 'notes' | 'scoreBySecon
|
|||||||
export type AnalysisCapture = {
|
export type AnalysisCapture = {
|
||||||
settings: AnalysisSettings; growth: GrowthSnapshot; title: string; durationSeconds: number;
|
settings: AnalysisSettings; growth: GrowthSnapshot; title: string; durationSeconds: number;
|
||||||
overallPower: number; analyzedAt: string;
|
overallPower: number; analyzedAt: string;
|
||||||
|
inGamePerfectNoteScore?: number;
|
||||||
};
|
};
|
||||||
export type AnalysisRecord = Omit<AnalysisCapture, 'growth'> & {
|
export type AnalysisRecord = Omit<AnalysisCapture, 'growth'> & {
|
||||||
id: string; name: string; snapshotId: string; result: StoredResult;
|
id: string; name: string; snapshotId: string; result: StoredResult;
|
||||||
|
|||||||
@@ -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.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 (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.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();
|
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;
|
return archive;
|
||||||
|
|||||||
@@ -382,7 +382,7 @@
|
|||||||
void tick().then(async () => {
|
void tick().then(async () => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
result = transfer.result;
|
result = transfer.result;
|
||||||
resultCapture = transfer.capture;
|
resultCapture = { ...transfer.capture, inGamePerfectNoteScore: finalNoteScore(transfer.result.perfectNoteScore, transfer.directSongScoreBonusPercent) };
|
||||||
resultInputKey = transferredAnalysisKey(transfer.capture);
|
resultInputKey = transferredAnalysisKey(transfer.capture);
|
||||||
achievementProbabilities = transfer.achievementProbabilities;
|
achievementProbabilities = transfer.achievementProbabilities;
|
||||||
simulationContext = { cards: transfer.cards, chart: transfer.chart, options: {
|
simulationContext = { cards: transfer.cards, chart: transfer.chart, options: {
|
||||||
@@ -861,6 +861,7 @@
|
|||||||
title: selectedChart.title,
|
title: selectedChart.title,
|
||||||
durationSeconds: selectedChart.durationSeconds,
|
durationSeconds: selectedChart.durationSeconds,
|
||||||
overallPower,
|
overallPower,
|
||||||
|
inGamePerfectNoteScore,
|
||||||
analyzedAt: new Date().toISOString()
|
analyzedAt: new Date().toISOString()
|
||||||
});
|
});
|
||||||
const noteScore = perfectNoteScore;
|
const noteScore = perfectNoteScore;
|
||||||
|
|||||||
Reference in New Issue
Block a user