From 476a25caef5bd9ffe82c90a1ab3715c4fa9f59fa Mon Sep 17 00:00:00 2001 From: dal4segno Date: Sun, 6 Sep 2026 10:39:06 +0900 Subject: [PATCH] feat: save analysis results and compare growth snapshots --- AGENTS.md | 6 + UPDATES.md | 14 +- src/lib/components/AnalysisHistory.svelte | 215 ++++++++++++++++++ src/lib/engine/analysis-history.test.ts | 46 ++++ src/lib/engine/card-filter-reactivity.test.ts | 14 ++ src/lib/models/analysis-history.ts | 37 +++ src/lib/storage/analysis-history.ts | 108 +++++++++ src/routes/analysis/+page.svelte | 70 +++++- src/routes/cards/+page.svelte | 8 +- 9 files changed, 510 insertions(+), 8 deletions(-) create mode 100644 src/lib/components/AnalysisHistory.svelte create mode 100644 src/lib/engine/analysis-history.test.ts create mode 100644 src/lib/engine/card-filter-reactivity.test.ts create mode 100644 src/lib/models/analysis-history.ts create mode 100644 src/lib/storage/analysis-history.ts diff --git a/AGENTS.md b/AGENTS.md index a1cf33f..2388274 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,11 @@ # Repository instructions +## Commit and push shorthand + +- When the user says "마무리 해" (or "마무리해"), treat it as authorization to verify the completed task, commit its changes, and push to the configured upstream. +- Otherwise, push only with the user's explicit authorization. Preserve unrelated pending changes and report what was committed and pushed. +- This shorthand does not authorize offline artifacts, version bumps, tags, or separate manual deployments. + ## Online-only release policy - The supported product and release target is the online app, including the GitHub Pages deployment. diff --git a/UPDATES.md b/UPDATES.md index 81a27bd..3eafd32 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,5 +1,17 @@ --- -updatedAt: 2026-09-05T10:20:00+09:00 +updatedAt: 2026-09-06T10:13:00+09:00 +--- + +## 2026년 9월 6일 + +### 배치 분석 결과 저장과 성장 비교 + +`NEW` 배치 분석 결과와 당시 성장 상태를 저장하고 다시 열어볼 수 있습니다. 같은 편성을 현재 성장 상태와 비교해 종합력·평균 점수·고점의 변화를 확인할 수 있으며, 기록과 성장 데이터의 백업·가져오기를 지원합니다. + +### 카드 목록 액티브 확률 필터 수정 + +`FIXED` 카드 목록에서 액티브 확률 선택을 바꾸면 표시되는 카드가 즉시 갱신되도록 수정했습니다. + --- ## 2026년 9월 5일 diff --git a/src/lib/components/AnalysisHistory.svelte b/src/lib/components/AnalysisHistory.svelte new file mode 100644 index 0000000..bf569d6 --- /dev/null +++ b/src/lib/components/AnalysisHistory.svelte @@ -0,0 +1,215 @@ + + +
+
MY RECORDS

저장된 분석 결과

{records.length}개 기록
+

오늘의 기록을 남기고, 다음 성장과 비교해 보세요.

+
+ + + + +
+

분석을 실행했던 시점의 설정과 성장 상태를 저장합니다. 기록 백업에는 성장 스냅샷도 포함됩니다. 일반 사용자 데이터 백업과 별도로 보관해 주세요.

+ {#if error}

{error}

{/if} + {#if message}

{message}

{/if} + {#if phase}

{phase}…

{/if} + {#if !records.length}

첫 분석을 기록해 보세요

분석을 마친 뒤 이름을 붙여 저장하면
당시의 점수와 성장 상태를 다시 꺼내볼 수 있습니다.

{/if} + {#if records.length}

기록 {records.length}개 · 공유 성장 상태 {new Set(records.map((record) => record.snapshotId)).size}개

{/if} + + {#if selected && savedGrowth} +
+ SAVED RESULT

{selected.name}

+

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

+
종합력{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}
+
당시 성장 상태·분석 설정 +

{selected.settings.cardIds.map((id, i) => `${cardName(id)} ${selected!.settings.awakenings[i]}개화`).join(' / ')}

+

리더 의상 카드: {cardName(selected.settings.leaderOutfitCardId)}

+

메모리 {savedGrowth.memoryCount}개 · 멤버 강화 {savedGrowth.memberPowerUpPercent}% · 활성 보드 {Object.values(savedGrowth.memberBoards).reduce((n, ids) => n + ids.length, 0)}개

+

파란 노드 전체: {selected.settings.allBlueNodes?.map((v, i) => v ? `${i + 1}번` : '').filter(Boolean).join(', ') || '미사용'} · 기수 보너스 덮어쓰기: {selected.settings.generationBonusOverrides?.map((v) => v ?? '실제').join(' / ')} · 가창 보너스: {selected.settings.songScoreBonusOverride ?? '실제 보드'}

+
+
+
+

곡·편성·의상·덮어쓰기·쿨타임 고정 조건을 유지합니다. 현재 보유 카드의 개화와 보드·연결 카드·메모리·멤버 강화를 적용합니다. 덮어쓴 항목의 실제 성장은 비교에 반영되지 않습니다.

+ {#if comparison} +
GROWTH REPORT

성장이 만든 점수 변화

+

양쪽 모두 현재 계산식과 악곡 데이터로 재최적화한 비교입니다. 위에 보관된 원본 점수와 다를 수 있습니다.

+
+ {#each [ ['종합력', comparison.before.overallPower, comparison.after.overallPower], ['평균 점수', comparison.before.result.expectedScore, comparison.after.result.expectedScore], ['이론상 고점', comparison.before.result.theoreticalMaxScore, comparison.after.result.theoreticalMaxScore] ] as row} + + {/each} +
항목당시 성장현재 성장변화
{row[0]}{Number(row[1]).toLocaleString('ko-KR')}{Number(row[2]).toLocaleString('ko-KR')} Number(row[1])} class:negative={Number(row[2]) < Number(row[1])}>{delta(Number(row[1]), Number(row[2]))}
+

성장 데이터 변화

+
{#each comparison.changes as change}

{change}

{:else}

비교 대상의 성장 데이터 변화가 없습니다.

{/each}
+

현재 성장의 추천 배치

+
    {#each comparison.after.result.order as id, i}
  1. {i + 1}{cardName(id)}쿨타임 −{comparison.after.result.cooldownReductions[id]}%
  2. {/each}
+ {/if} + {#if detail} +

당시 성장으로 상세 재분석한 결과

+

노트별 그래프는 저장 용량을 줄이기 위해 보관하지 않습니다. 아래는 현재 계산식·악곡 데이터로 재계산한 결과이며 저장 원본 점수와 다를 수 있습니다.

+ + {/if} +
+ {/if} +
+ + diff --git a/src/lib/engine/analysis-history.test.ts b/src/lib/engine/analysis-history.test.ts new file mode 100644 index 0000000..417a30f --- /dev/null +++ b/src/lib/engine/analysis-history.test.ts @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { compactResult, currentGrowthSettings, snapshotKey, type AnalysisSettings, type GrowthSnapshot } from '../models/analysis-history.ts'; +import { parseAnalysisArchive } from '../storage/analysis-history.ts'; +import type { ExactOptimizationResult } from './types.ts'; + +const growth: GrowthSnapshot = { collection: { a: { owned: true, awakening: 2 } }, memberBoards: { a: ['B1', 'G1'] }, boardCards: {}, memoryCount: 10, memberPowerUpPercent: 1 }; +test('동일한 성장 상태는 입력 순서와 무관하게 공유하고 성장 차이는 구분한다', () => { + const reordered = { ...growth, memberBoards: { a: ['G1', 'B1'] } }; + assert.equal(snapshotKey(growth), snapshotKey(reordered)); + assert.notEqual(snapshotKey(growth), snapshotKey({ ...growth, memoryCount: 11 })); +}); +test('현재 성장 비교는 저장 조건을 유지하고 보유 개화만 교체한다', () => { + const settings: AnalysisSettings = { songId: 'song', difficulty: 'expert', cardIds: ['a'], awakenings: [0], allBlueNodes: [true], generationBonusOverrides: [900], leaderCharacterId: 'a', leaderOutfitCardId: 'a' }; + const current = currentGrowthSettings(settings, growth); + assert.deepEqual(current.awakenings, [2]); assert.deepEqual(settings.awakenings, [0]); + assert.deepEqual(current.generationBonusOverrides, [900]); assert.deepEqual(current.allBlueNodes, [true]); + assert.throws(() => currentGrowthSettings(settings, { ...growth, collection: {} }), /보유/); +}); +test('기록은 노트별 배열을 제외하고 원본과 독립적으로 보존한다', () => { + const source = { notes: [{ timeSeconds: 1 }], scoreBySecond: [2], order: ['a'], expectedScore: 123 } as unknown as ExactOptimizationResult; + const saved = compactResult(source); + source.order = ['b']; + assert.deepEqual(saved.order, ['a']); assert.equal(saved.expectedScore, 123); + assert.equal('notes' in saved, false); assert.equal('scoreBySecond' in saved, false); +}); +test('기록 백업은 잘못된 구조와 누락된 성장 참조를 거부한다', () => { + assert.throws(() => parseAnalysisArchive('{}')); + assert.throws(() => parseAnalysisArchive(JSON.stringify({ schemaVersion: 1, snapshots: [], records: [{ settings: {}, result: {}, snapshotId: 'missing' }] }))); + assert.deepEqual(parseAnalysisArchive('{"schemaVersion":1,"snapshots":[],"records":[]}').records, []); +}); + +test('성장 상태를 공유하는 기록 백업을 손실 없이 읽고 잘못된 개화를 거부한다', () => { + const ids = ['a', 'b', 'c', 'd', 'e']; + const record = { + id: 'record', name: '기록', title: '곡', analyzedAt: '2026-09-06T00:00:00Z', snapshotId: 'snapshot', overallPower: 1000, durationSeconds: 120, + settings: { songId: 'song', difficulty: 'expert', cardIds: ids, awakenings: [0, 1, 2, 3, 4], leaderCharacterId: 'a', leaderOutfitCardId: 'a' }, + result: { expectedScore: 100, theoreticalMaxScore: 120, baselineScore: 50, perfectNoteScore: 10, order: ids, cooldownReductions: Object.fromEntries(ids.map((id) => [id, 0])) } + }; + const archive = { schemaVersion: 1, snapshots: [{ id: 'snapshot', growth }], records: [record, { ...record, id: 'second' }] }; + 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); + record.settings.awakenings[0] = 8; + assert.throws(() => parseAnalysisArchive(JSON.stringify(archive))); +}); diff --git a/src/lib/engine/card-filter-reactivity.test.ts b/src/lib/engine/card-filter-reactivity.test.ts new file mode 100644 index 0000000..a73a929 --- /dev/null +++ b/src/lib/engine/card-filter-reactivity.test.ts @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { compile } from 'svelte/compiler'; + +test('액티브 확률 선택 변경이 카드 목록 재계산을 유발한다', () => { + const filename = new URL('../../routes/cards/+page.svelte', import.meta.url); + const source = readFileSync(filename, 'utf8'); + const code = compile(source, { filename: filename.pathname, generate: 'client' }).js.code; + const effect = code.split('$.legacy_pre_effect(').find((part) => part.includes('$.set(filteredCards,')); + assert.ok(effect, '카드 목록 계산 효과가 있어야 한다'); + const dependencies = effect.split('() => {')[0]; + assert.ok(dependencies.includes('$.get(selectedActiveProbabilities)'), '확률 선택값을 반응형 의존성으로 추적해야 한다'); +}); diff --git a/src/lib/models/analysis-history.ts b/src/lib/models/analysis-history.ts new file mode 100644 index 0000000..273f23b --- /dev/null +++ b/src/lib/models/analysis-history.ts @@ -0,0 +1,37 @@ +import type { CooldownReduction, ExactOptimizationResult } from '../engine/types.ts'; + +export type AnalysisSettings = { + songId: string; difficulty: 'hard' | 'expert'; cardIds: string[]; awakenings: number[]; + fixedCooldownReductions?: (CooldownReduction | null)[]; + allBlueNodes?: boolean[]; generationBonusOverrides?: (number | null)[]; + songScoreBonusOverride?: number | null; leaderCharacterId: string; leaderOutfitCardId: string; +}; +export type GrowthSnapshot = { + collection: Record; + memberBoards: Record; + boardCards: Record>; + memoryCount: number; memberPowerUpPercent: number; +}; +export type StoredResult = Omit; +export type AnalysisCapture = { + settings: AnalysisSettings; growth: GrowthSnapshot; title: string; durationSeconds: number; + overallPower: number; analyzedAt: string; +}; +export type AnalysisRecord = Omit & { + id: string; name: string; snapshotId: string; result: StoredResult; +}; +export function compactResult(result: ExactOptimizationResult): StoredResult { + const { notes, scoreBySecond, ...stored } = result; + return structuredClone(stored); +} +// Object insertion order and board selection order must not create duplicate snapshots. +export function snapshotKey(value: unknown): string { + if (Array.isArray(value)) return JSON.stringify(value.map((item) => snapshotKey(item)).sort()); + if (value && typeof value === 'object') return JSON.stringify(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, snapshotKey(item)])); + return JSON.stringify(value) ?? 'null'; +} +export function currentGrowthSettings(settings: AnalysisSettings, growth: GrowthSnapshot): AnalysisSettings { + const missing = settings.cardIds.filter((id) => !growth.collection[id]?.owned); + if (missing.length) throw new Error('현재 보유 데이터에 없는 편성 카드가 있습니다. 보유 카드와 개화를 등록한 후 비교해 주세요.'); + return { ...structuredClone(settings), awakenings: settings.cardIds.map((id) => growth.collection[id]?.awakening ?? 0) }; +} diff --git a/src/lib/storage/analysis-history.ts b/src/lib/storage/analysis-history.ts new file mode 100644 index 0000000..34e0924 --- /dev/null +++ b/src/lib/storage/analysis-history.ts @@ -0,0 +1,108 @@ +import { compactResult, snapshotKey, type AnalysisCapture, type AnalysisRecord, type GrowthSnapshot } from '../models/analysis-history.ts'; +import type { ExactOptimizationResult } from '../engine/types.ts'; + +type SnapshotRow = { id: string; growth: GrowthSnapshot }; +type Archive = { schemaVersion: 1; records: AnalysisRecord[]; snapshots: SnapshotRow[] }; +export function parseAnalysisArchive(text: string): Archive { + const archive = JSON.parse(text) as Archive; + const object = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); + const finite = (v: unknown) => typeof v === 'number' && Number.isFinite(v) && v >= 0; + const strings = (v: unknown): v is string[] => Array.isArray(v) && v.every((s) => typeof s === 'string'); + const invalid = () => { throw new Error('지원하지 않거나 손상된 분석 기록 백업입니다.'); }; + if (!object(archive) || archive.schemaVersion !== 1 || !Array.isArray(archive.records) || !Array.isArray(archive.snapshots)) return invalid(); + for (const row of archive.snapshots) { + if (!object(row) || typeof row.id !== 'string' || !object(row.growth)) return invalid(); + const g = row.growth; + if (!object(g.collection) || !object(g.memberBoards) || !object(g.boardCards) || !finite(g.memoryCount) || !finite(g.memberPowerUpPercent)) return invalid(); + if (!Object.values(g.collection).every((v) => object(v) && (v.owned === undefined || typeof v.owned === 'boolean') && (v.awakening === undefined || (Number.isInteger(v.awakening) && Number(v.awakening) >= 0 && Number(v.awakening) <= 5)))) return invalid(); + if (!Object.values(g.memberBoards).every(strings) || !Object.values(g.boardCards).every((v) => object(v) && Object.values(v).every((s) => typeof s === 'string'))) return invalid(); + } + const snapshots = new Set(archive.snapshots.map((s) => s.id)); + if (snapshots.size !== archive.snapshots.length) return invalid(); + for (const record of archive.records) { + if (!object(record) || !object(record.settings) || !object(record.result) || !snapshots.has(record.snapshotId)) return invalid(); + if (![record.id, record.name, record.title, record.analyzedAt].every((s) => typeof s === 'string') || !Number.isFinite(Date.parse(record.analyzedAt))) return invalid(); + const s = record.settings, r = record.result; + if (!strings(s.cardIds) || s.cardIds.length !== 5 || new Set(s.cardIds).size !== 5 || !Array.isArray(s.awakenings) || s.awakenings.length !== 5 || !s.awakenings.every((n) => Number.isInteger(n) && n >= 0 && n <= 5)) return invalid(); + if (typeof s.songId !== 'string' || !['hard', 'expert'].includes(s.difficulty) || typeof s.leaderCharacterId !== 'string' || typeof s.leaderOutfitCardId !== 'string') return invalid(); + if (s.allBlueNodes && (s.allBlueNodes.length !== 5 || !s.allBlueNodes.every((v) => typeof v === 'boolean'))) return invalid(); + if (s.fixedCooldownReductions && (s.fixedCooldownReductions.length !== 5 || !s.fixedCooldownReductions.every((v) => v === null || [0, 4, 8, 12].includes(v)))) 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 (![record.overallPower, record.durationSeconds, r.expectedScore, r.theoreticalMaxScore, r.baselineScore, r.perfectNoteScore].every(finite)) 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; +} +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open('holodori-analysis-history', 1); + request.onupgradeneeded = () => { + request.result.createObjectStore('records', { keyPath: 'id' }); + request.result.createObjectStore('snapshots', { keyPath: 'id' }); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error('다른 탭을 닫은 후 저장 기록을 다시 열어 주세요.')); + }); +} +function requestValue(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); +} +async function transaction(mode: IDBTransactionMode, action: (tx: IDBTransaction) => Promise): Promise { + const db = await openDatabase(); + try { + const tx = db.transaction(['records', 'snapshots'], mode); + const done = new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onabort = () => reject(tx.error ?? new Error('저장 작업이 취소되었습니다.')); tx.onerror = () => reject(tx.error); }); + const [result] = await Promise.all([action(tx), done]); + return result; + } finally { db.close(); } +} +export async function saveAnalysis(capture: AnalysisCapture, result: ExactOptimizationResult, name: string): Promise { + const snapshotId = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(snapshotKey(capture.growth)))), (b) => b.toString(16).padStart(2, '0')).join(''); + const { growth, ...metadata } = structuredClone(capture); + const record: AnalysisRecord = { ...metadata, id: crypto.randomUUID(), snapshotId, name: name.trim().slice(0, 120) || capture.title, result: compactResult(result) }; + await transaction('readwrite', async (tx) => { + await Promise.all([requestValue(tx.objectStore('snapshots').put({ id: snapshotId, growth })), requestValue(tx.objectStore('records').add(record))]); + }); +} +export async function listAnalyses(): Promise { + return (await transaction('readonly', (tx) => requestValue(tx.objectStore('records').getAll()))).sort((a, b) => b.analyzedAt.localeCompare(a.analyzedAt)); +} +export async function readGrowth(id: string): Promise { + const row = await transaction('readonly', (tx) => requestValue(tx.objectStore('snapshots').get(id))); + if (!row) throw new Error('저장된 성장 데이터를 찾을 수 없습니다.'); + return row.growth; +} +export async function deleteAnalysis(id: string): Promise { + await transaction('readwrite', async (tx) => { + const records = tx.objectStore('records'); + await requestValue(records.delete(id)); + const remaining = await requestValue(records.getAll()); + const used = new Set(remaining.map((record) => record.snapshotId)); + const snapshots = tx.objectStore('snapshots'); + const keys = await requestValue(snapshots.getAllKeys()); + await Promise.all(keys.filter((key) => !used.has(String(key))).map((key) => requestValue(snapshots.delete(key)))); + }); +} +export async function exportAnalyses(): Promise { + return transaction('readonly', async (tx) => { + const [records, snapshots] = await Promise.all([requestValue(tx.objectStore('records').getAll()), requestValue(tx.objectStore('snapshots').getAll())]); + return JSON.stringify({ schemaVersion: 1, records, snapshots } satisfies Archive); + }); +} +export async function importAnalyses(text: string): Promise { + const archive = parseAnalysisArchive(text); + const referenced = new Set(archive.records.map((r) => r.snapshotId)); + const snapshots = await Promise.all(archive.snapshots.filter((s) => referenced.has(s.id)).map(async (s) => ({ + oldId: s.id, growth: s.growth, + id: Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(snapshotKey(s.growth)))), (b) => b.toString(16).padStart(2, '0')).join('') + }))); + const ids = new Map(snapshots.map((s) => [s.oldId, s.id])); + await transaction('readwrite', async (tx) => { + await Promise.all([ + ...snapshots.map(({ id, growth }) => requestValue(tx.objectStore('snapshots').put({ id, growth }))), + ...archive.records.map((r) => requestValue(tx.objectStore('records').add({ ...r, id: crypto.randomUUID(), snapshotId: ids.get(r.snapshotId) }))) + ]); + }); +} diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index 8e73900..aeb0477 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -1,5 +1,7 @@