diff --git a/UPDATES.md b/UPDATES.md index 84a4092..83ed610 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,9 +1,23 @@ --- -updatedAt: 2026-09-09T17:59:00+09:00 +updatedAt: 2026-09-09T19:13:48+09:00 --- ## 2026년 9월 9일 +### 조합 탐색 결과 출력 수 선택 + +`NEW` 결과 출력 수를 10·20·30·50개 중 선택할 수 있습니다. 기본값은 10개이며, 변경한 설정은 다음 탐색부터 적용됩니다. 출력 수를 늘리면 달성 확률 계산 시간이 늘어납니다. + +### 리더 레이팅 결과를 배치 분석으로 연결 + +`IMPROVED` 리더 레이팅 결과의 ‘배치 분석에서 보기’를 누르면 새 탭의 분석 결과 영역으로 바로 이동합니다. 기존 레이팅 목록을 유지하면서 계산 당시 조건과 점수·달성 확률을 그대로 확인할 수 있습니다. + +### 조합 탐색 간소화와 배치 분석 연결 + +`IMPROVED` 조합 탐색 결과를 편성·점수·달성 확률 중심으로 간소화했습니다. 각 결과의 ‘배치 분석에서 보기’를 누르면 기존 탐색을 유지한 채 새 탭에서 상세 그래프와 액티브 미발동 비교를 확인할 수 있습니다. 새 탭은 분석 결과 영역으로 바로 이동하고 포커스를 맞춥니다. 탐색 당시 조건과 계산 결과를 함께 전달하며, 이후 성장 데이터가 바뀌었다면 이전 조건의 결과임을 표시합니다. + +카드 버전은 이름 아래 태그로 표시하며, 넓은 화면에서는 편성·추가 개화·점수·달성 확률·이동 버튼을 한 줄에 배치합니다. 종합력은 배치 분석에서 확인할 수 있습니다. + ### 배치 분석·조합 탐색 결과 유지 및 변경 안내 `IMPROVED` 입력 조건이나 사용자 데이터가 바뀌어도 배치 분석 결과를 지우지 않고 이전 조건의 결과임을 표시합니다. 기존 점수·확률·그래프는 새 분석이 완료될 때까지 유지하며, 결과에 분석 당시 악곡·난이도·종합력을 함께 표시합니다. diff --git a/src/lib/components/LeaderSongRanking.svelte b/src/lib/components/LeaderSongRanking.svelte index 175fe22..136b5b5 100644 --- a/src/lib/components/LeaderSongRanking.svelte +++ b/src/lib/components/LeaderSongRanking.svelte @@ -1,5 +1,7 @@
+ {#if transferError}

{transferError}

{/if}
02

리더 레이팅 곡 추천

지금 편성으로 도전할 곡을 찾아보세요.

@@ -242,7 +261,7 @@
99% 이상{probabilityLabel(row.achievementProbabilities.atLeast99Percent)}
95% 이상{probabilityLabel(row.achievementProbabilities.atLeast95Percent)}
- + openInAnalysis(event, row.transfer)}>배치 분석에서 보기 {/each} @@ -376,8 +395,8 @@ .achievement-grid span { color: #9690a6; font-size: 10px; } .achievement-grid strong { color: #cbb4db; font-size: 12px; font-weight: 650; } .achievement-grid > div:first-child strong { color: #f1b0ce; } - .row-action button { padding: 9px 11px; background: transparent; white-space: nowrap; font-size: 11px; } - .row-action button > span { margin-left: 8px; color: #bd8ea8; } + .row-action a { display: inline-block; padding: 9px 11px; color: inherit; background: transparent; white-space: nowrap; font-size: 11px; } + .row-action a > span { margin-left: 8px; color: #bd8ea8; } .mobile-label { display: none; } .list-footer { justify-content: space-between; gap: 12px; margin-top: 15px; } .range-label { color: #c4bdcd; font-size: 11px; } @@ -452,8 +471,8 @@ .average-score { grid-column: 2; grid-row: 3; text-align: left; font-size: 11px; } .mobile-label { display: inline-block; min-width: 48px; margin-right: 6px; color: #828e9f; font-size: 10px; } .row-action { grid-column: 3; grid-row: 2 / 4; align-self: end; } - .row-action button { padding: 7px 8px; font-size: 10px; } - .row-action button > span { display: none; } + .row-action a { padding: 7px 8px; font-size: 10px; } + .row-action a > span { display: none; } .probability-cell { grid-column: 1 / -1; grid-row: 4; padding-top: 11px; margin-top: 5px; border-top: 1px solid #3c3342; } .achievement-grid { gap: 12px; text-align: center; } .achievement-grid > div { gap: 5px; } diff --git a/src/lib/engine/analysis-transfer.test.ts b/src/lib/engine/analysis-transfer.test.ts new file mode 100644 index 0000000..afde0fd --- /dev/null +++ b/src/lib/engine/analysis-transfer.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { combinationAnalysisSettings, parseAnalysisTransfer, transferredAnalysisKey, type AnalysisTransfer } from '../models/analysis-transfer.ts'; +import { calculateExactScore } from './score.ts'; +import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts'; +import type { GrowthSnapshot } from '../models/analysis-history.ts'; + +const growth: GrowthSnapshot = { collection: { e: { owned: true, awakening: 5 } }, + memberBoards: { e: ['G1'] }, boardCards: {}, memoryCount: 30, memberPowerUpPercent: 4.37 }; +const state = { songId: 'song', difficulty: 'expert' as const, fixedCardIds: ['c', 'd', 'a', 'b'], + fixedAwakenings: [5, 3, 4, 4], fixedCooldownReductions: [null, 12, null, 4] as (null | 12 | 4)[], + fixedAllBlueNodes: [false, true, false, false], fixedGenerationBonusOverrides: [null, 0, 900, null], + awakening: 4, candidateGenerationBonus: 450, songScoreBonusOverride: 10, + leaderCharacterId: 'leader', leaderOutfitCardId: 'outfit' }; + +test('조합의 표시 순서로 바뀌어도 고정 카드 조건과 자동 후보 보드·보유 개화를 정확히 전달한다', () => { + const settings = combinationAnalysisSettings(state, ['a', 'b', 'c', 'd', 'e'], growth); + assert.deepEqual(settings.awakenings, [4, 4, 5, 3, 5]); + assert.deepEqual(settings.fixedCooldownReductions, [null, 4, null, 12, null]); + assert.deepEqual(settings.allBlueNodes, [false, false, false, true, true]); + assert.deepEqual(settings.generationBonusOverrides, [900, null, null, 0, 450]); + assert.equal(settings.leaderOutfitCardId, 'outfit'); + assert.equal(settings.songScoreBonusOverride, 10); + assert.deepEqual(growth.memberBoards, { e: ['G1'] }); +}); + +test('전달 결과는 재탐색 없이 점수·확률을 유지하고 원래 카드·채보로 시뮬레이션한다', () => { + const cards = ['a', 'b', 'c', 'd', 'e'].map((id, i) => ({ id, name: id, + active: { cooldownSeconds: 2 + i, durationSeconds: 1, scoreUpPercent: 100, probabilityPercent: 55 }, + special: { durationSeconds: 1, scoreUpPercent: 100 }, allowedCooldownReductions: [0] as const })); + const chart = { songId: 'song', difficulty: 'expert', durationSeconds: 8, scoreRatio: 1, + specialStartSeconds: [1, 2, 3, 4, 5], notes: Array.from({ length: 8 }, (_, timeSeconds) => ({timeSeconds, type: 'regular' as const, isFlick: false})) }; + const result = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100, directSongScoreBonusPercent: 10 }); + const capture = { settings: combinationAnalysisSettings(state, cards.map(c => c.id), growth), growth, + title: 'song', durationSeconds: 8, overallPower: 100, analyzedAt: '2026-09-09T09:00:00Z' }; + const transfer: AnalysisTransfer = { schemaVersion: 1, capture, result, cards, chart, directSongScoreBonusPercent: 10, + achievementProbabilities: { maximum: 0.1, atLeast99Percent: 0.2, atLeast95Percent: 0.4, samples: 25000 } }; + const loaded = parseAnalysisTransfer(JSON.stringify(transfer)); + assert.deepEqual(loaded, transfer); + const score = calculateExactScore(loaded.result.order.map(id => loaded.cards.find(c => c.id === id)!), loaded.result.cooldownReductions, loaded.chart, + { perfectNoteScore: loaded.result.perfectNoteScore, directSongScoreBonusPercent: loaded.directSongScoreBonusPercent }); + assert.equal(score.theoreticalMaxScore, result.theoreticalMaxScore); + assert.equal(score.expectedScore, result.expectedScore); + const originalKey = transferredAnalysisKey(loaded.capture); + loaded.capture.growth.memoryCount += 1; + assert.notEqual(transferredAnalysisKey(loaded.capture), originalKey); + assert.equal(growth.memoryCount, 30); + assert.throws(() => parseAnalysisTransfer(JSON.stringify({ ...transfer, cards: [] }))); + assert.throws(() => parseAnalysisTransfer(JSON.stringify({ ...transfer, chart: { ...chart, songId: 'another song' } }))); +}); diff --git a/src/lib/engine/combination-result-limit.test.ts b/src/lib/engine/combination-result-limit.test.ts new file mode 100644 index 0000000..6cea881 --- /dev/null +++ b/src/lib/engine/combination-result-limit.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { createContext, runInContext } from 'node:vm'; +import ts from 'typescript'; + +const source = readFileSync(new URL('../workers/score-optimizer.worker.ts', import.meta.url), 'utf8'); +const code = ts.transpileModule(source.replace(/^import .*;\r?\n/gm, '').replace('export {};', ''), { + compilerOptions: { target: ts.ScriptTarget.ES2022 } +}).outputText; + +test('조합 워커는 요청한 결과 수를 점수순으로 반환하고 미지정·잘못된 값은 10개로 처리한다', () => { + for (const [limit, expected] of [[undefined, 10], [10, 10], [20, 20], [30, 30], [50, 50], [0, 10]]) { + const messages: any[] = []; + const context = createContext({ + self: { postMessage: (message: unknown) => messages.push(message) }, + countOptimizationCandidates: () => 1, + optimizeExactOrderAndCooldowns: (_cards: unknown, _chart: unknown, options: { perfectNoteScore: number }) => ({ + theoreticalMaxScore: options.perfectNoteScore, expectedScore: options.perfectNoteScore + }) + }); + runInContext(code, context); + context.request = { type: 'optimize-combinations', resultLimit: limit, chart: {}, + candidates: Array.from({ length: 60 }, (_, i) => ({ cards: [], overallPower: i, perfectNoteScore: i })) }; + runInContext('self.onmessage({ data: request });', context); + const result = messages.at(-1); + assert.equal(result.type, 'combination-result'); + assert.equal(result.rankings.length, expected); + assert.deepEqual(Array.from(result.rankings, (row: any) => row.result.theoreticalMaxScore), + Array.from({ length: expected! }, (_, i) => 59 - i)); + } +}); diff --git a/src/lib/models/analysis-transfer.ts b/src/lib/models/analysis-transfer.ts new file mode 100644 index 0000000..bc0aa41 --- /dev/null +++ b/src/lib/models/analysis-transfer.ts @@ -0,0 +1,63 @@ +import type { AnalysisCapture, AnalysisSettings, GrowthSnapshot } from './analysis-history.ts'; +import type { ExactOptimizationResult, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from '../engine/types.ts'; +import { resolveCandidateAwakening } from '../engine/combination-search.ts'; + +export const ANALYSIS_TRANSFER_KEY = 'holodori.analysis-transfer.v1'; +export type AnalysisTransfer = { + schemaVersion: 1; + source?: 'combination' | 'rating'; + capture: AnalysisCapture; + result: ExactOptimizationResult; + cards: OptimizerCard[]; + chart: ExactSongChart; + directSongScoreBonusPercent: number; + achievementProbabilities?: ScoreAchievementProbabilities; +}; + +export type CombinationConditions = Pick & { + fixedCardIds: string[]; fixedAwakenings?: number[]; + fixedCooldownReductions?: AnalysisSettings['fixedCooldownReductions']; + fixedAllBlueNodes?: boolean[]; fixedGenerationBonusOverrides?: (number | null)[]; + awakening: number; candidateGenerationBonus?: number | null; +}; + +export function combinationAnalysisSettings(state: CombinationConditions, cardIds: readonly string[], growth: GrowthSnapshot): AnalysisSettings { + const slots = cardIds.map(id => state.fixedCardIds.indexOf(id)); + return { + songId: state.songId, difficulty: state.difficulty, cardIds: [...cardIds], + awakenings: cardIds.map((id, i) => slots[i] >= 0 ? state.fixedAwakenings?.[slots[i]] ?? 0 + : resolveCandidateAwakening(state.awakening, Boolean(growth.collection[id]?.owned), growth.collection[id]?.awakening ?? 5)), + fixedCooldownReductions: slots.map(slot => slot < 0 ? null : state.fixedCooldownReductions?.[slot] ?? null), + allBlueNodes: slots.map(slot => slot < 0 || state.fixedAllBlueNodes?.[slot] === true), + generationBonusOverrides: slots.map(slot => slot < 0 ? state.candidateGenerationBonus ?? null : state.fixedGenerationBonusOverrides?.[slot] ?? null), + songScoreBonusOverride: state.songScoreBonusOverride ?? null, + leaderCharacterId: state.leaderCharacterId, leaderOutfitCardId: state.leaderOutfitCardId + }; +} + +// Match the analysis page's key so a transferred result can flag newer growth data. +export function transferredAnalysisKey({ settings: s, growth: g }: AnalysisCapture): string { + return JSON.stringify({ songId: s.songId, difficulty: s.difficulty, input: JSON.stringify({ + selectedCardIds: s.cardIds, selectedAwakenings: s.awakenings, + fixedCooldownReductions: s.fixedCooldownReductions, allBlueNodes: s.allBlueNodes, + generationBonusOverrides: s.generationBonusOverrides, songScoreBonusOverride: s.songScoreBonusOverride, + leaderCharacterId: s.leaderCharacterId, leaderOutfitCardId: s.leaderOutfitCardId, + collection: g.collection, memberBoards: g.memberBoards, boardCards: g.boardCards, + memoryCount: g.memoryCount, memberPowerUpPercent: g.memberPowerUpPercent + }) }); +} + +export function parseAnalysisTransfer(raw: string): AnalysisTransfer { + const value = JSON.parse(raw) as AnalysisTransfer; + const ids = value?.capture?.settings?.cardIds; + if (value?.schemaVersion !== 1 || !Array.isArray(ids) || ids.length !== 5 || new Set(ids).size !== 5 || + !value.capture.growth || !Array.isArray(value.cards) || value.cards.length !== 5 || + !ids.every(id => value.cards.some(card => card.id === id)) || + !Array.isArray(value.result?.order) || value.result.order.length !== 5 || new Set(value.result.order).size !== 5 || + !value.result.order.every(id => ids.includes(id)) || !Array.isArray(value.result.notes) || + !Array.isArray(value.chart?.notes) || value.chart.songId !== value.capture.settings.songId || + value.chart.difficulty !== value.capture.settings.difficulty || !Number.isFinite(value.directSongScoreBonusPercent)) { + throw new Error('전달된 분석 결과를 읽지 못했습니다. 원래 결과 목록에서 다시 열어 주세요.'); + } + return value; +} diff --git a/src/lib/workers/score-optimizer.worker.ts b/src/lib/workers/score-optimizer.worker.ts index d846e93..d94de5d 100644 --- a/src/lib/workers/score-optimizer.worker.ts +++ b/src/lib/workers/score-optimizer.worker.ts @@ -24,6 +24,7 @@ type CombinationCandidate = { type CombinationRequest = { type: 'optimize-combinations'; + resultLimit?: number; candidates: CombinationCandidate[]; chart: ExactSongChart; }; @@ -145,7 +146,8 @@ self.onmessage = (event: MessageEvent + import { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts'; import { onMount, tick } from 'svelte'; import LeaderSongRanking from '$lib/components/LeaderSongRanking.svelte'; import { analytics } from '$lib/analytics.ts'; @@ -119,6 +120,8 @@ let pendingSimulationContext: SimulationContext | null = null; let resultInputKey = ''; let pendingInputKey = ''; + let transferredResult = false; + let transferredSource = '조합 탐색'; let simulationBase: ExactOptimizationResult | null = null; let simulatedResult: ExactOptimizationResult | null = null; let excludedActiveWindows: SkillWindow[] = []; @@ -334,11 +337,46 @@ analysisReady = true; } + if (mode === 'analysis') { + try { + const raw = sessionStorage.getItem(ANALYSIS_TRANSFER_KEY); + if (raw) { + const transfer = parseAnalysisTransfer(raw); + restoreAnalysisState(JSON.stringify({ ...transfer.capture.settings, schemaVersion: 2 })); + // Let hydration finish before mounting the result's reactive timeline. + void tick().then(async () => { + if (!mounted) return; + result = transfer.result; + resultCapture = transfer.capture; + resultInputKey = transferredAnalysisKey(transfer.capture); + achievementProbabilities = transfer.achievementProbabilities; + simulationContext = { cards: transfer.cards, chart: transfer.chart, options: { + perfectNoteScore: transfer.result.perfectNoteScore, + directSongScoreBonusPercent: transfer.directSongScoreBonusPercent + } }; + transferredResult = true; + transferredSource = transfer.source === 'rating' ? '리더 레이팅' : '조합 탐색'; + await tick(); + requestAnimationFrame(() => { + if (!mounted) return; + const heading = document.getElementById('result-title'); + heading?.focus({ preventScroll: true }); + heading?.scrollIntoView({ behavior: 'instant', block: 'start' }); + }); + }); + sessionStorage.removeItem(ANALYSIS_TRANSFER_KEY); + } + } catch (error) { + errorMessage = error instanceof Error ? error.message : '조합 탐색 결과를 읽지 못했습니다.'; + } + } + worker = createScoreOptimizerWorker(); worker.onmessage = (event) => { if (event.data.type === 'progress') progress = event.data.progress; if (event.data.type === 'result') { result = event.data.result; + transferredResult = false; resultCapture = pendingCapture; resultInputKey = pendingInputKey; simulationContext = pendingSimulationContext; @@ -568,24 +606,13 @@ throw new Error('서로 다른 멤버 5명과 성장 설정을 확인해 주세요.'); } return { cards: context.cards, overallPower: context.unitPower.overallPower, - directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent }; - } - - async function showRatingResult(chart: NonNullable, rankingResult: ExactOptimizationResult, power: number, probabilities: ScoreAchievementProbabilities) { - ratingResultKey = ratingInputKey; - selectedSongId = chart.songId; - difficulty = chart.difficulty as DifficultyKey; - result = rankingResult; - achievementProbabilities = probabilities; - clearConnectionOptimizationProposal(); - await tick(); - resultCapture = structuredClone({ - settings: presetSnapshot, - growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent }, - title: chart.title, durationSeconds: chart.durationSeconds, overallPower: power, - analyzedAt: new Date().toISOString() - }); - document.getElementById('result-title')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent, + capture: structuredClone({ + settings: { ...presetSnapshot, songId: chart.songId, difficulty: chart.difficulty as DifficultyKey, songScoreBonusOverride: 10 }, + growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent }, + title: chart.title, durationSeconds: chart.durationSeconds, overallPower: context.unitPower.overallPower, + analyzedAt: new Date().toISOString() + }) }; } function connectionSocketName(memberId: string, socketId: string): string { @@ -1068,12 +1095,13 @@ {#if mode === 'rating'} + getContext={ratingContext} /> {/if} {#if mode === 'analysis' || result}
-
{mode === 'rating' ? '03' : '02'}

분석 결과

+
{mode === 'rating' ? '03' : '02'}

분석 결과

+ {#if transferredResult}

{transferredSource} 당시의 계산 결과입니다. 다시 분석하면 현재 사용자 데이터를 적용합니다.

{/if} {#if resultStale || pendingStale}

입력 조건 또는 사용자 데이터가 변경되었습니다. {resultStale ? '아래는 이전 조건의 결과입니다. ' : ''}{pendingStale ? '진행 중인 분석은 시작 당시 조건으로 계속 계산합니다. ' : ''}최신 조건을 적용하려면 다시 분석해 주세요.

{/if} {#if mode === 'analysis' && resultCapture}

{resultCapture.title} · {difficultyLabel(resultCapture.settings.difficulty)} · 분석 당시 종합력 {resultCapture.overallPower.toLocaleString('ko-KR')}

{/if} {#if mode === 'rating' && result}

{selectedChart?.title} · {difficultyLabel(difficulty)} · 종합력 {overallPower.toLocaleString('ko-KR')} · 가창 보너스 10%

{/if} @@ -1247,6 +1275,7 @@