feat: release version 1.0.9

This commit is contained in:
2026-08-31 12:33:52 +09:00
parent 2998278353
commit 89169c5c0d
15 changed files with 421 additions and 67 deletions
+125 -46
View File
@@ -5,6 +5,7 @@
import boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { events } from '$lib/data/events.ts';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts';
@@ -18,6 +19,9 @@
combinationSearchLimits,
countOptimizationCandidates,
effectiveActiveProbability,
eventBonusBreakdown,
eventChallengeSongScoreBonusPercent,
eventPointIndex,
generateAnchoredCombinationCandidates,
generateCombinationCandidates,
memoryParameterPermil,
@@ -30,6 +34,8 @@
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
export let eventMode = false;
type CardRow = (typeof cardSummary.cards)[number];
type DifficultyKey = 'hard' | 'expert';
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
@@ -42,12 +48,17 @@
directSongScoreBonusPercent: number;
heuristic: number;
skillPotential: number;
eventBonusPercent?: number;
rankingMultiplier?: number;
optimizationObjective?: 'maximum' | 'expected';
};
type RankedResult = {
result: ExactOptimizationResult;
overallPower: number;
cards?: OptimizerCard[];
directSongScoreBonusPercent?: number;
eventBonusPercent?: number;
rankingMultiplier?: number;
achievementProbabilities?: ScoreAchievementProbabilities;
};
type DisplayRankedResult = RankedResult & { isBaseline: boolean };
@@ -83,12 +94,17 @@
leaderOutfitCardId: string;
};
const STORAGE_KEY = 'holodori.combination.v1';
const PRESET_STORAGE_KEY = 'holodori.combination.presets.v1';
const STORAGE_KEY = eventMode ? 'holodori.event-combination.v1' : 'holodori.combination.v1';
const PRESET_STORAGE_KEY = eventMode ? 'holodori.event-combination.presets.v1' : 'holodori.combination.presets.v1';
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
const allCards = cardSummary.cards;
const fiveStarCards = allCards.filter((card) => card.rarity === 5);
const eventBonusCharacters = new Set<string>(events.flatMap((event) => [...event.bonusCharacterIds]));
const eligibleCards = eventMode
? allCards.filter((card) => card.rarity === 5 || eventBonusCharacters.has(card.characterId))
: fiveStarCards;
const cardById = new Map(allCards.map((card) => [card.id, card]));
const allCharts = exactChartCatalog.charts;
const initialReleaseClock = createInitialReleaseClock($page.data?.serverNow);
let currentReleaseClock = initialReleaseClock;
@@ -111,9 +127,9 @@
let selectedSongId = songs[0]?.id ?? '';
let difficulty: DifficultyKey = 'expert';
let fixedCardIds = ['', '', '', ''];
let candidateAwakening = 5;
let candidateAwakening = eventMode ? 0 : 5;
let candidateGenerationBonus: number | null = null;
let fixedAwakenings = [5, 5, 5, 5];
let fixedAwakenings = eventMode ? [0, 0, 0, 0] : [5, 5, 5, 5];
let fixedCooldownReductions: (CooldownReduction | null)[] = [null, null, null, null];
let fixedAllBlueNodes = [false, false, false, false];
let fixedGenerationBonusOverrides: (number | null)[] = [null, null, null, null];
@@ -148,6 +164,9 @@
let ready = false;
$: charts = releasedSongCharts(allCharts, releaseNow);
$: currentEvent = eventMode
? events.find((event) => Date.parse(event.startsAt) <= releaseNow && releaseNow <= Date.parse(event.endsAt)) ?? null
: null;
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
$: if (!songs.some((song) => song.id === selectedSongId)) selectedSongId = songs[0]?.id ?? '';
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
@@ -155,7 +174,7 @@
$: baselineChartMetadata = baselineState
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
: undefined;
$: baselineMatchesConditions = Boolean(baselineState?.songId === selectedSongId && baselineState?.difficulty === difficulty);
$: baselineMatchesConditions = !eventMode && Boolean(baselineState?.songId === selectedSongId && baselineState?.difficulty === difficulty);
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
$: displayedRankings = buildDisplayedRankings(
rankedResults,
@@ -170,8 +189,8 @@
$: if (!result && rankedResults.length > 0) rankedResults = [];
$: fixedCards = fixedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
$: candidateCards = ownedCardsOnly
? fiveStarCards.filter((card) => collection[card.id]?.owned === true)
: fiveStarCards;
? eligibleCards.filter((card) => collection[card.id]?.owned === true)
: eligibleCards;
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
$: leaderOutfitOptions = leaderMember
? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume)
@@ -502,7 +521,10 @@
function roughCardScore(card: CardRow): number {
const statTotal = Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0);
const { activeValue, specialValue } = roughSkillValues(card);
return statTotal * (1 + activeValue / 100 + specialValue / 500);
const score = statTotal * (1 + activeValue / 100 + specialValue / 500);
if (!currentEvent) return score;
const bonus = eventBonusBreakdown(currentEvent, [card], (candidate) => awakeningForCard(candidate as CardRow));
return score * (1 + bonus.totalPercent / 100);
}
function roughStatScore(card: CardRow): number {
@@ -534,7 +556,30 @@
effectSum + effect.percent * (effect.target === 'all' ? cards.length : effect.targetCount ?? 1), 0);
return sum + activeValue + specialValue + activationValue + passiveValue;
}, 0);
return statTotal * (1 + skillPotential / 500);
const score = statTotal * (1 + skillPotential / 500);
if (!currentEvent) return score;
const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow));
const songBonus = eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id));
return score * (1 + bonus.totalPercent / 100) * (1 + songBonus / 100);
}
function roughEventBonusPriority(cards: readonly CardRow[]): number {
if (!currentEvent) return roughCombinationScore(cards);
const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow));
// 최대 보너스 편성이 순수 스코어 후보에 밀려 선별 단계에서 사라지지 않도록 별도 층을 보존한다.
return bonus.totalPercent * 1_000_000_000_000 + roughCombinationScore(cards);
}
function rankingValue(ranking: RankedResult): number {
return eventMode
? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0)
: ranking.result.theoreticalMaxScore;
}
function compareRankedResults(left: RankedResult, right: RankedResult): number {
return rankingValue(right) - rankingValue(left) ||
right.result.expectedScore - left.result.expectedScore ||
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore;
}
function combinationKey(cards: readonly CardRow[] | readonly OptimizerCard[]): string {
@@ -553,19 +598,26 @@
}
function screeningCombinations(): CardRow[][] {
const { screeningLimit } = combinationSearchLimits(fixedCards.length);
const limits = combinationSearchLimits(fixedCards.length);
const screeningLimit = eventMode ? Math.min(limits.screeningLimit, 9_000) : limits.screeningLimit;
if (fixedCards.length === 4) {
return generateCombinationCandidates(fixedCards, candidateCards, 5, roughCardScore, screeningLimit);
}
const globalBudget = Math.ceil(screeningLimit * 0.8);
const globalBudget = eventMode ? screeningLimit : Math.ceil(screeningLimit * 0.8);
const anchoredBudget = screeningLimit - globalBudget;
const perStrategy = Math.ceil(globalBudget / 4);
const strategies = [
{ scoreCard: roughCardScore },
{ scoreCard: roughSkillScore },
{ scoreCard: roughStatScore },
{ scoreCard: roughStatScore, scoreCombination: roughCombinationScore }
];
const strategies = eventMode
? [
{ scoreCard: roughCardScore },
{ scoreCard: roughStatScore, scoreCombination: roughCombinationScore },
{ scoreCard: roughCardScore, scoreCombination: roughEventBonusPriority }
]
: [
{ scoreCard: roughCardScore },
{ scoreCard: roughSkillScore },
{ scoreCard: roughStatScore },
{ scoreCard: roughStatScore, scoreCombination: roughCombinationScore }
];
const perStrategy = Math.ceil(globalBudget / strategies.length);
const combined = new Map<string, CardRow[]>();
for (const strategy of strategies) {
for (const cards of generateCombinationCandidates(
@@ -579,6 +631,7 @@
combined.set(combinationKey(cards), cards);
}
}
if (eventMode) return Array.from(combined.values()).slice(0, screeningLimit);
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
const anchorCount = candidateCards.filter((card) => !fixedCharacters.has(card.characterId)).length;
const perAnchorLimit = Math.max(1, Math.ceil(anchoredBudget / Math.max(1, anchorCount)));
@@ -597,6 +650,7 @@
function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] {
if (fixedCards.length === 4) return prepared;
const { baseExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
const finalistLimit = eventMode ? Math.min(baseExactFinalistLimit, 96) : baseExactFinalistLimit;
const selected = new Map<string, PreparedCandidate>();
const add = (candidates: PreparedCandidate[], count: number) => {
for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate);
@@ -606,23 +660,23 @@
(candidate) => candidate.cards,
(candidate) => candidate.heuristic,
fixedCardIds,
Math.ceil(baseExactFinalistLimit / 2)
Math.ceil(finalistLimit / 2)
);
add(diverse, diverse.length);
const remainingLimit = Math.max(0, baseExactFinalistLimit - selected.size);
const remainingLimit = Math.max(0, finalistLimit - selected.size);
const heuristicLimit = Math.ceil(remainingLimit / 2);
const skillLimit = Math.ceil(remainingLimit / 4);
const powerLimit = remainingLimit - heuristicLimit - skillLimit;
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), heuristicLimit);
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), skillLimit);
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), powerLimit);
if (selected.size < baseExactFinalistLimit) {
if (selected.size < finalistLimit) {
for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) {
selected.set(combinationKey(candidate.cards), candidate);
if (selected.size >= baseExactFinalistLimit) break;
if (selected.size >= finalistLimit) break;
}
}
return Array.from(selected.values()).slice(0, baseExactFinalistLimit);
return Array.from(selected.values()).slice(0, finalistLimit);
}
function buildRefinementFinalists(rankings: RankedResult[]): PreparedCandidate[] {
@@ -658,12 +712,16 @@
}
}
const refinementLimit = Math.max(alternativeExactFinalistLimit, topResultNeighbors.size);
const refinementLimit = eventMode
? Math.min(alternativeExactFinalistLimit, 48)
: Math.max(alternativeExactFinalistLimit, topResultNeighbors.size);
const rankedAlternatives = Array.from(alternatives.values()).sort((left, right) =>
right.roughScore - left.roughScore || combinationKey(left.cards).localeCompare(combinationKey(right.cards)));
const chosenAlternatives = new Map<string, { cards: CardRow[]; roughScore: number }>();
for (const candidate of topResultNeighbors.values()) {
const rankedTopNeighbors = [...topResultNeighbors.values()].sort((left, right) => right.roughScore - left.roughScore);
for (const candidate of rankedTopNeighbors) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
if (chosenAlternatives.size >= refinementLimit) break;
}
for (const candidate of secondaryPriority.values()) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
@@ -721,13 +779,26 @@
card.special.scoreUpPercent * card.special.durationSeconds / 100 +
(card.scoreSupportPercent ?? 0) +
(card.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => effectSum + effect.percent * (effect.target === 'all' ? 5 : effect.targetCount ?? 1), 0), 0);
const eventBonus = currentEvent
? eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow))
: null;
const eventSongScoreBonus = currentEvent
? eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id))
: 0;
const directSongScoreBonusPercent = boardEffects.directSongScoreBonusPercent + eventSongScoreBonus;
const rankingMultiplier = eventBonus ? 1 + eventBonus.totalPercent / 100 : undefined;
return {
cards: optimizerCards,
overallPower: power.overallPower,
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, selectedChart?.scoreRatio ?? null),
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
heuristic: power.overallPower * (1 + skillPotential / 500),
skillPotential
directSongScoreBonusPercent,
heuristic: power.overallPower * (1 + skillPotential / 500) * (rankingMultiplier ?? 1) * (1 + eventSongScoreBonus / 100),
skillPotential,
...(eventBonus ? {
eventBonusPercent: eventBonus.totalPercent,
rankingMultiplier,
optimizationObjective: 'expected' as const
} : {})
};
}
@@ -827,13 +898,11 @@
for (const ranking of rankings) {
const key = [...ranking.result.order].sort().join('|');
const previous = bestByCombination.get(key);
if (!previous || ranking.result.theoreticalMaxScore > previous.result.theoreticalMaxScore ||
(ranking.result.theoreticalMaxScore === previous.result.theoreticalMaxScore && ranking.result.expectedScore > previous.result.expectedScore)) {
if (!previous || compareRankedResults(ranking, previous) < 0) {
bestByCombination.set(key, ranking);
}
}
rankedResults = Array.from(bestByCombination.values()).sort((left, right) =>
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore || right.result.expectedScore - left.result.expectedScore).slice(0, 10);
rankedResults = Array.from(bestByCombination.values()).sort(compareRankedResults).slice(0, 10);
result = rankedResults[0]?.result ?? null;
if (rankedResults.length > 0 && activeSearchChart && worker && rankedResults.every((ranking) => ranking.cards)) {
searchPhase = 'probability';
@@ -908,8 +977,7 @@
completedWorkers += 1;
parallelWorker.terminate();
if (completedWorkers !== workerCount) return;
completedRankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
right.result.expectedScore - left.result.expectedScore);
completedRankings.sort(compareRankedResults);
parallelWorkers = [];
completeExactPass(completedRankings.slice(0, 10));
}
@@ -1022,22 +1090,27 @@
unique.set(displayedCompositionKey(baseline), baseline);
}
return Array.from(unique.values())
.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
right.result.expectedScore - left.result.expectedScore || Number(right.isBaseline) - Number(left.isBaseline))
.sort((left, right) => compareRankedResults(left, right) || Number(right.isBaseline) - Number(left.isBaseline))
.slice(0, 10);
}
</script>
<svelte:head><title>조합 탐색 · 홀로도리 계산기</title></svelte:head>
<svelte:head><title>{eventMode ? '이벤트 Pt 최적화' : '조합 탐색'} · 홀로도리 계산기</title></svelte:head>
<main>
<header>
<h1>조합 탐색</h1>
<p class="page-description">악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.</p>
<h1>{eventMode ? '이벤트 Pt 최적화' : '조합 탐색'}</h1>
<p class="page-description">{eventMode ? '진행 중인 이벤트의 편성·신규 카드·개화 보너스를 반영해 예상 이벤트 Pt가 높은 조합과 스페셜 순서를 찾습니다.' : '악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.'}</p>
{#if eventMode && currentEvent}
<div class="event-summary" role="status">
<strong>{currentEvent.name}</strong>
<span>대상 멤버 1명당 +{currentEvent.formationBonusPercent}% · 신규 카드 추가 +{currentEvent.featuredCardBonusPercent}% · 과제곡 대응 카드 스코어 +{currentEvent.challengeSongScoreBonusPercent}%</span>
</div>
{/if}
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured'}
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
{/if}
<p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p>
{#if !eventMode}<p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p>{/if}
</header>
{#if baselineState && baselineMatchesConditions}
<section class="panel baseline-panel" aria-labelledby="baseline-title">
@@ -1066,7 +1139,7 @@
</section>
{/if}
<section class="panel" aria-labelledby="conditions-title">
<div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? '보유 5성 대상' : '5성 전체 대상'}</strong></div>
<div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? (eventMode ? '보유 이벤트 후보 대상' : '보유 5성 대상') : (eventMode ? '5성 + 대상 멤버 전 레어리티' : '5성 전체 대상')}</strong></div>
<div class="song-fields">
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
<label>난이도<select bind:value={difficulty} on:change={() => result = null}><option value="expert">Expert</option><option value="hard">Hard</option></select></label>
@@ -1090,7 +1163,7 @@
<strong>내가 보유한 카드만 사용하기</strong>
</label>
<p class="candidate-guide">
‘내가 보유한 카드만 사용하기’를 켜면 고정 카드와 자동 후보 모두 보유 5성 카드로 제한됩니다.<br>
‘내가 보유한 카드만 사용하기’를 켜면 고정 카드와 자동 후보 모두 {eventMode ? '보유한 이벤트 후보 카드' : '보유 5성 카드'}로 제한됩니다.<br>
보유 카드의 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.<br>
기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.
</p>
@@ -1177,8 +1250,9 @@
{/each}
</span>
<span class="rank-metric">
<small>최고 점수</small>
<strong>{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>
<small>{eventMode ? '예상 이벤트 Pt 지수' : '최고 점수'}</small>
<strong>{eventMode ? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0).toLocaleString('ko-KR') : ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>
{#if eventMode}<i>획득량 +{ranking.eventBonusPercent ?? 0}% · 예상 점수 {ranking.result.expectedScore.toLocaleString('ko-KR')}</i>{/if}
{#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}
<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
</span>
@@ -1203,6 +1277,7 @@
{/each}
</div>
<p class="awakening-guide">추가 개화는 현재 카드 데이터와 조합의 목표 개화를 비교한 값입니다. 미보유 카드를 처음 얻는 과정도 1회로 계산합니다.</p>
{#if eventMode}<p class="event-point-guide">이벤트 Pt 지수는 예상 점수에 편성·신규 카드·개화 획득 배율을 적용한 비교값입니다. 게임 서버의 최종 반올림과 멀티 라이브 보정에 따라 실제 획득 Pt는 달라질 수 있습니다.</p>{/if}
<p class="probability-guide">달성 확률은 모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
{:else}
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
@@ -1219,6 +1294,9 @@
.calibration-warning strong { margin-right: 5px; color: #f1a3c4; }
.baseline-guide { width: fit-content; margin: 10px 0 0; padding: 8px 11px; border-radius: 8px; color: #8d96a5; background: #1b2029; font-size: 13px; line-height: 1.55; }
.baseline-guide strong { color: #dba0b9; }
.event-summary { display: grid; gap: 5px; width: fit-content; margin-top: 12px; padding: 11px 13px; border: 1px solid #604653; border-radius: 10px; background: #261d23; }
.event-summary strong { color: #f2a6c6; font-size: 14px; }
.event-summary span { color: #c9b4bd; font-size: 12px; line-height: 1.5; }
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
.section-title, .section-title > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.section-title > div { justify-content: flex-start; }
@@ -1324,6 +1402,7 @@
.rank-probabilities strong { color: #dce1e8; font-size: 14px; line-height: 1.2; }
.rank-probabilities i { color: #7f8998; font-size: 10px; font-style: normal; font-weight: 700; }
.awakening-guide, .probability-guide { margin: 11px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; }
.event-point-guide { margin: 5px 2px 0; color: #d1a5b8; font-size: 12px; line-height: 1.55; }
.awakening-guide { color: #aa8797; }
.awakening-guide + .probability-guide { margin-top: 5px; }
.ranking-detail { padding: 0 16px 18px; border-top: 1px solid #2b313c; }