feat: streamline search results and open captured analyses
Publish GitHub Pages output / publish (push) Successful in 54s

This commit is contained in:
2026-09-09 19:15:25 +09:00
parent 28035110f9
commit d26b9ca884
8 changed files with 365 additions and 53 deletions
+27 -8
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { difficultyLabel } from '$lib/data/difficulty.ts';
import { ANALYSIS_TRANSFER_KEY, type AnalysisTransfer } from '$lib/models/analysis-transfer.ts';
import type { AnalysisCapture } from '$lib/models/analysis-history.ts';
import { onDestroy, onMount } from 'svelte';
import type catalog from '$lib/data/exact-chart-catalog.json';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
@@ -9,9 +11,9 @@
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
type Chart = (typeof catalog.charts)[number];
type Context = { cards: OptimizerCard[]; overallPower: number; directSongScoreBonusPercent: number };
type Context = { cards: OptimizerCard[]; overallPower: number; directSongScoreBonusPercent: number; capture: AnalysisCapture };
type Evaluation = { result: ExactOptimizationResult; achievementProbabilities: ScoreAchievementProbabilities };
type Row = Chart & Evaluation & { overallPower: number; theoreticalMaxScore: number; expectedScore: number };
type Row = Chart & Evaluation & { transfer: AnalysisTransfer; overallPower: number; theoreticalMaxScore: number; expectedScore: number };
export let charts: Chart[];
export let leaderId: string;
export let leaderName: string;
@@ -19,7 +21,6 @@
export let inputKey: string;
export let disabled = false;
export let getContext: (chart: Chart) => Context;
export let onSelect: (chart: Chart, result: ExactOptimizationResult, overallPower: number, probabilities: ScoreAchievementProbabilities) => void;
const difficulty = 'all';
let running = false;
@@ -30,6 +31,7 @@
let reportedCores = 1;
let activeJobs: Record<number, { title: string; fraction: number }> = {};
let message = '';
let transferError = '';
let failures: string[] = [];
let snapshotKey = '';
let snapshotLeader = '';
@@ -128,6 +130,8 @@
});
if (id !== runId) return;
rows = [...rows, { ...summary, result, achievementProbabilities, overallPower: context.overallPower,
transfer: { schemaVersion: 1, source: 'rating', capture: context.capture, cards: context.cards, chart,
result, achievementProbabilities, directSongScoreBonusPercent: context.directSongScoreBonusPercent },
theoreticalMaxScore: result.theoreticalMaxScore, expectedScore: result.expectedScore }];
} catch (error) {
if (id !== runId) return;
@@ -159,6 +163,20 @@
}
const format = (value: number) => Math.round(value).toLocaleString('ko-KR');
function openInAnalysis(event: MouseEvent, transfer: AnalysisTransfer): void {
event.preventDefault();
transferError = '';
try {
sessionStorage.setItem(ANALYSIS_TRANSFER_KEY, JSON.stringify(transfer));
const opened = window.open((event.currentTarget as HTMLAnchorElement).href, '_blank');
if (!opened) throw new Error('새 탭이 차단되었습니다. 팝업을 허용한 뒤 다시 열어 주세요.');
opened.opener = null;
} catch (error) {
transferError = error instanceof Error ? error.message : '배치 분석을 열지 못했습니다.';
} finally {
try { sessionStorage.removeItem(ANALYSIS_TRANSFER_KEY); } catch { /* Storage may be blocked. */ }
}
}
function probabilityLabel(value: number): string {
if (value <= 0) return '0%';
if (value < 0.000001) return '<0.0001%';
@@ -173,6 +191,7 @@
}
</script>
<section class="ranking" aria-labelledby="leader-ranking-title">
{#if transferError}<p role="alert">{transferError}</p>{/if}
<div class="heading">
<div class="section-heading"><span class="section-number">02</span><div><h2 id="leader-ranking-title">리더 레이팅 곡 추천</h2><p>지금 편성으로 도전할 곡을 찾아보세요.</p></div></div>
</div>
@@ -242,7 +261,7 @@
<div title={`${format(Math.ceil(row.theoreticalMaxScore * .99))}점 이상 달성 확률`}><span>99% 이상</span><strong>{probabilityLabel(row.achievementProbabilities.atLeast99Percent)}</strong></div>
<div title={`${format(Math.ceil(row.theoreticalMaxScore * .95))}점 이상 달성 확률`}><span>95% 이상</span><strong>{probabilityLabel(row.achievementProbabilities.atLeast95Percent)}</strong></div>
</div></td>
<td class="row-action"><button type="button" disabled={stale || disabled || running} on:click={() => onSelect(row, row.result, row.overallPower, row.achievementProbabilities)}>배치 보기 <span aria-hidden="true">→</span></button></td>
<td class="row-action"><a href="/analysis" on:click={(event) => openInAnalysis(event, row.transfer)}>배치 분석에서 보기 <span aria-hidden="true">↗</span></a></td>
</tr>{/each}</tbody>
</table>
</div>
@@ -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; }
+50
View File
@@ -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' } })));
});
@@ -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));
}
});
+63
View File
@@ -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<AnalysisSettings, 'songId' | 'difficulty' | 'leaderCharacterId' | 'leaderOutfitCardId' | 'songScoreBonusOverride'> & {
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;
}
+3 -1
View File
@@ -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<OptimizeRequest | CombinationRequest | Ach
}
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
rankings.sort(compareRankings);
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, 10) });
const resultLimit = [10, 20, 30, 50].includes(event.data.resultLimit ?? 10) ? event.data.resultLimit ?? 10 : 10;
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, resultLimit) });
} catch (error) {
self.postMessage({
type: 'error',