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
+9
View File
@@ -4,16 +4,20 @@
import CardsPage from '../src/routes/cards/+page.svelte';
import AnalysisPage from '../src/routes/analysis/+page.svelte';
import CombinationPage from '../src/routes/combination/+page.svelte';
import EventPage from '../src/routes/event/+page.svelte';
import DataPage from '../src/routes/data/+page.svelte';
import DataCardsPage from '../src/routes/data/cards/+page.svelte';
import DataBoardsPage from '../src/routes/data/boards/+page.svelte';
import { setOfflinePage } from './page-store';
import { events } from '../src/lib/data/events';
import { activeEventAt } from '../src/lib/engine/event-optimization';
const routes = new Map([
['/', HomePage],
['/cards', CardsPage],
['/analysis', AnalysisPage],
['/combination', CombinationPage],
['/event', EventPage],
['/data', DataPage],
['/data/cards', DataCardsPage],
['/data/boards', DataBoardsPage]
@@ -26,6 +30,8 @@
let currentPath = pathFromHash();
let CurrentPage = routes.get(currentPath) ?? HomePage;
let eventNow = Date.now();
$: showEventTab = Boolean(activeEventAt(events, eventNow));
setOfflinePage(currentPath);
function syncRoute() {
@@ -36,6 +42,7 @@
}
onMount(() => {
const eventTimer = window.setInterval(() => eventNow = Date.now(), 1_000);
const rewriteInternalLinks = () => {
for (const anchor of document.querySelectorAll<HTMLAnchorElement>('a[href^="/"]')) {
anchor.href = `#${anchor.getAttribute('href')}`;
@@ -57,6 +64,7 @@
window.removeEventListener('hashchange', syncRoute);
document.removeEventListener('click', handleClick);
linkObserver.disconnect();
window.clearInterval(eventTimer);
};
});
</script>
@@ -68,6 +76,7 @@
<a href="#/cards" aria-current={currentPath.startsWith('/cards') ? 'page' : undefined}>카드 목록</a>
<a href="#/analysis" aria-current={currentPath.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
<a href="#/combination" aria-current={currentPath.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
{#if showEventTab}<a href="#/event" aria-current={currentPath.startsWith('/event') ? 'page' : undefined}>이벤트 Pt</a>{/if}
<a href="#/data" aria-current={currentPath.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
<a
class="report-link"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "holodream-tools",
"version": "1.0.8",
"version": "1.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "holodream-tools",
"version": "1.0.8",
"version": "1.0.9",
"dependencies": {
"better-sqlite3": "^13.0.3",
"drizzle-orm": "^0.45.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "holodream-tools",
"version": "1.0.8",
"version": "1.0.9",
"private": true,
"type": "module",
"engines": {
+22
View File
@@ -0,0 +1,22 @@
# 홀로도리 계산기 v1.0.9
진행 중인 이벤트의 보너스를 반영한 이벤트 Pt 최적화를 추가하고, 점수 분석과 조합 탐색 결과를 개선한 업데이트입니다.
## 주요 변경 사항
- 진행 중인 이벤트에서 편성 대상 멤버, 신규 카드, 개화 단계에 따른 획득량 보너스를 반영해 예상 이벤트 Pt가 높은 조합을 탐색할 수 있습니다.
- 과제곡 대응 카드의 스코어 보너스를 반영하고, 이벤트 탐색에서는 최고점보다 예상 점수를 우선해 스페셜 순서를 선택하도록 개선했습니다.
- 이벤트 기간에만 상단 메뉴에 `이벤트 Pt` 항목을 표시하며 온라인과 오프라인 버전에서 동일하게 사용할 수 있습니다.
- 배치 분석 결과에 초당 노트 분포와 멤버별 액티브·스페셜 발동 구간을 보여주는 점수 타임라인을 추가했습니다.
- 배치 분석과 조합 탐색에서 카드별 가창 보너스를 직접 덮어쓸 수 있도록 개선했습니다.
- 일반 배치 분석은 예상 점수보다 이론상 최고점을 우선해 최적 순서를 선택하도록 기준을 명확히 했습니다.
## 참고 사항
- 이벤트 Pt 지수는 예상 점수에 편성·신규 카드·개화 보너스를 적용한 비교값입니다. 게임 서버의 최종 반올림과 멀티 라이브 보정에 따라 실제 획득 Pt와 차이가 날 수 있습니다.
- 이벤트 정보와 메뉴는 등록된 이벤트 기간을 기준으로 자동 활성화되고 종료 후 비활성화됩니다.
- 이벤트 계산 정확도는 사용자 카드 보유 여부, 개화 단계, 보드와 멤버 강화 데이터가 정확하다는 전제에 따릅니다.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.9.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
+36
View File
@@ -0,0 +1,36 @@
import type { EventDefinition } from '$lib/engine/event-optimization.ts';
const gamersFeaturedCards = [
'card-00006-5-uniq-0074-00',
'card-00014-5-uniq-0075-00',
'card-00016-5-uniq-0076-00',
'card-00017-5-uniq-0077-00'
] as const;
export const events = [
{
// HolodoriDB supplies the card/song IDs; the public master-data diff does not include
// the live-service schedule, so the official event period and bonus rules live here.
id: 'marathon_event-normal-003',
name: '무인도 잭! 한여름의 서바이벌 데이즈',
startsAt: '2026-08-29T03:00:00.000Z',
endsAt: '2026-09-06T10:59:59.999Z',
bonusCharacterIds: ['chr-00006', 'chr-00014', 'chr-00016', 'chr-00017'],
featuredCardIds: gamersFeaturedCards,
challengeSongBonusCardIds: {
m0338: [gamersFeaturedCards[0]],
m0339: [gamersFeaturedCards[1]],
m0340: [gamersFeaturedCards[2]],
m0341: [gamersFeaturedCards[3]],
m0342: gamersFeaturedCards
},
formationBonusPercent: 30,
featuredCardBonusPercent: 30,
challengeSongScoreBonusPercent: 10,
awakeningBonusPercentByRarity: {
3: [0, 1, 1, 2, 2, 3],
4: [0, 6, 7, 8, 9, 10],
5: [0, 15, 18, 22, 26, 30]
}
}
] as const satisfies readonly EventDefinition[];
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { events } from '../data/events.ts';
import { activeEventAt, eventBonusBreakdown, eventChallengeSongScoreBonusPercent, eventPointIndex } from './event-optimization.ts';
const currentEvent = events[0];
test('이벤트 기간 안에서만 진행 중 이벤트를 반환한다', () => {
assert.equal(activeEventAt(events, Date.parse('2026-08-29T02:59:59.999Z')), null);
assert.equal(activeEventAt(events, Date.parse('2026-08-31T00:00:00.000Z'))?.id, currentEvent.id);
assert.equal(activeEventAt(events, Date.parse('2026-09-06T11:00:00.000Z')), null);
});
test('편성·신규 카드·개화 보너스를 카드별로 합산한다', () => {
const cards = [
{ id: currentEvent.featuredCardIds[0], characterId: currentEvent.bonusCharacterIds[0], rarity: 5 },
{ id: 'regular-event-member', characterId: currentEvent.bonusCharacterIds[1], rarity: 4 },
{ id: 'other-member', characterId: 'chr-other', rarity: 3 }
];
const awakening = new Map([[cards[0].id, 5], [cards[1].id, 2], [cards[2].id, 1]]);
const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakening.get(card.id) ?? 0);
assert.deepEqual(bonus, {
formationPercent: 60,
featuredCardPercent: 30,
awakeningPercent: 38,
totalPercent: 128
});
});
test('과제곡에 대응하는 신규 카드가 있을 때만 스코어 보너스를 적용한다', () => {
assert.equal(eventChallengeSongScoreBonusPercent(currentEvent, 'm0338', [currentEvent.featuredCardIds[0]]), 10);
assert.equal(eventChallengeSongScoreBonusPercent(currentEvent, 'm0338', [currentEvent.featuredCardIds[1]]), 0);
assert.equal(eventChallengeSongScoreBonusPercent(currentEvent, 'm0342', [currentEvent.featuredCardIds[3]]), 10);
});
test('예상 점수에 이벤트 획득 배율을 적용해 비교 지수를 만든다', () => {
assert.equal(eventPointIndex(1_000_000, 128), 2_280_000);
});
+69
View File
@@ -0,0 +1,69 @@
export type EventCard = {
id: string;
characterId: string;
rarity: number;
};
export type EventDefinition = {
id: string;
name: string;
startsAt: string;
endsAt: string;
bonusCharacterIds: readonly string[];
featuredCardIds: readonly string[];
challengeSongBonusCardIds: Readonly<Record<string, readonly string[]>>;
formationBonusPercent: number;
featuredCardBonusPercent: number;
challengeSongScoreBonusPercent: number;
awakeningBonusPercentByRarity: Readonly<Record<number, readonly number[]>>;
};
export type EventBonusBreakdown = {
formationPercent: number;
featuredCardPercent: number;
awakeningPercent: number;
totalPercent: number;
};
export function activeEventAt(events: readonly EventDefinition[], now: number): EventDefinition | null {
return events.find((event) => Date.parse(event.startsAt) <= now && now <= Date.parse(event.endsAt)) ?? null;
}
export function eventBonusBreakdown(
event: EventDefinition,
cards: readonly EventCard[],
awakeningForCard: (card: EventCard) => number
): EventBonusBreakdown {
const bonusCharacters = new Set(event.bonusCharacterIds);
const featuredCards = new Set(event.featuredCardIds);
let formationPercent = 0;
let featuredCardPercent = 0;
let awakeningPercent = 0;
for (const card of cards) {
if (bonusCharacters.has(card.characterId)) formationPercent += event.formationBonusPercent;
if (featuredCards.has(card.id)) featuredCardPercent += event.featuredCardBonusPercent;
const awakening = Math.max(0, Math.min(5, Math.trunc(awakeningForCard(card))));
awakeningPercent += event.awakeningBonusPercentByRarity[card.rarity]?.[awakening] ?? 0;
}
return {
formationPercent,
featuredCardPercent,
awakeningPercent,
totalPercent: formationPercent + featuredCardPercent + awakeningPercent
};
}
export function eventChallengeSongScoreBonusPercent(
event: EventDefinition,
songId: string,
cardIds: readonly string[]
): number {
const bonusCards = new Set(event.challengeSongBonusCardIds[songId] ?? []);
return cardIds.some((cardId) => bonusCards.has(cardId)) ? event.challengeSongScoreBonusPercent : 0;
}
export function eventPointIndex(score: number, eventBonusPercent: number): number {
return Math.round(score * (1 + eventBonusPercent / 100));
}
+15
View File
@@ -130,3 +130,18 @@ test('예상 점수보다 이론상 고점을 우선해 스페셜 순서를 선
assert.equal(result.theoreticalMaxScore, 400);
assert.equal(result.expectedScore, 130);
});
test('이벤트 효율 탐색에서는 예상 점수를 우선해 스페셜 순서를 선택할 수 있다', () => {
const probabilistic = cards.map((card, index) => ({
...card,
active: {
...card.active,
probabilityPercent: index === 0 ? 5 : 100,
scoreUpPercent: index === 0 ? 500 : card.active.scoreUpPercent
}
}));
const maximum = optimizeExactOrderAndCooldowns(probabilistic, chart, { perfectNoteScore: 100 });
const expected = optimizeExactOrderAndCooldowns(probabilistic, chart, { perfectNoteScore: 100, objective: 'expected' });
assert.ok(expected.expectedScore >= maximum.expectedScore);
});
+8 -4
View File
@@ -243,12 +243,16 @@ export function optimizeExactOrderAndCooldowns(
for (const reductions of reductionAssignments) {
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
const reductionTotal = Object.values(reductions).reduce<number>((sum, value) => sum + value, 0);
const isHigherMaximum = score.theoreticalMaxScore > bestTheoreticalMaxScore;
const isHigherExpectedTie = score.theoreticalMaxScore === bestTheoreticalMaxScore &&
score.expectedScore > bestExpectedScore;
const expectedFirst = options.objective === 'expected';
const isBetterPrimary = expectedFirst
? score.expectedScore > bestExpectedScore
: score.theoreticalMaxScore > bestTheoreticalMaxScore;
const isBetterSecondary = expectedFirst
? score.expectedScore === bestExpectedScore && score.theoreticalMaxScore > bestTheoreticalMaxScore
: score.theoreticalMaxScore === bestTheoreticalMaxScore && score.expectedScore > bestExpectedScore;
const isCheaperTie = score.theoreticalMaxScore === bestTheoreticalMaxScore &&
score.expectedScore === bestExpectedScore && reductionTotal < bestReductionTotal;
if (isHigherMaximum || isHigherExpectedTie || isCheaperTie) {
if (isBetterPrimary || isBetterSecondary || isCheaperTie) {
bestTheoreticalMaxScore = score.theoreticalMaxScore;
bestExpectedScore = score.expectedScore;
bestOrder = order;
+1
View File
@@ -5,6 +5,7 @@ export * from './awakening-requirements.ts';
export * from './board-effects.ts';
export * from './board-connection-optimizer.ts';
export * from './exact-optimizer.ts';
export * from './event-optimization.ts';
export * from './memory.ts';
export * from './maximum-weight-matching.ts';
export * from './optimizer.ts';
+1
View File
@@ -140,5 +140,6 @@ export type ExactOptimizationResult = ExactScoreResult & {
};
export type ExactOptimizeOptions = ExactScoreOptions & {
objective?: 'maximum' | 'expected';
onProgress?: (progress: OptimizationProgress) => void;
};
+26 -13
View File
@@ -17,6 +17,9 @@ type CombinationCandidate = {
overallPower: number;
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
eventBonusPercent?: number;
rankingMultiplier?: number;
optimizationObjective?: 'maximum' | 'expected';
};
type CombinationRequest = {
@@ -30,9 +33,24 @@ type RankedCombination = {
overallPower: number;
cards?: OptimizerCard[];
directSongScoreBonusPercent?: number;
eventBonusPercent?: number;
rankingMultiplier?: number;
achievementProbabilities?: ScoreAchievementProbabilities;
};
function rankingScore(ranking: RankedCombination): number {
const score = ranking.rankingMultiplier === undefined
? ranking.result.theoreticalMaxScore
: ranking.result.expectedScore;
return score * (ranking.rankingMultiplier ?? 1);
}
function compareRankings(left: RankedCombination, right: RankedCombination): number {
return rankingScore(right) - rankingScore(left) ||
right.result.expectedScore - left.result.expectedScore ||
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore;
}
type AchievementRequest = {
type: 'estimate-achievement';
rankings: RankedCombination[];
@@ -106,31 +124,26 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
perfectNoteScore: candidate.perfectNoteScore,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
objective: candidate.optimizationObjective,
onProgress: (progress) => self.postMessage({
type: 'progress',
progress: { evaluatedCandidates: completedCandidates + progress.evaluatedCandidates, totalCandidates }
})
});
completedCandidates += countOptimizationCandidates(candidate.cards);
if (!best || result.theoreticalMaxScore > best.result.theoreticalMaxScore ||
(result.theoreticalMaxScore === best.result.theoreticalMaxScore && result.expectedScore > best.result.expectedScore)) {
best = {
const ranking: RankedCombination = {
result,
overallPower: candidate.overallPower,
cards: candidate.cards,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
eventBonusPercent: candidate.eventBonusPercent,
rankingMultiplier: candidate.rankingMultiplier
};
}
rankings.push({
result,
overallPower: candidate.overallPower,
cards: candidate.cards,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent
});
if (!best || compareRankings(ranking, best) < 0) best = ranking;
rankings.push(ranking);
}
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
rankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
right.result.expectedScore - left.result.expectedScore);
rankings.sort(compareRankings);
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, 10) });
} catch (error) {
self.postMessage({
+23
View File
@@ -1,6 +1,28 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { events } from '$lib/data/events.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { activeEventAt } from '$lib/engine/event-optimization.ts';
import '$lib/styles/layout-tokens.css';
let eventClock = createInitialReleaseClock($page.data?.serverNow);
let eventNow = eventClock.now();
$: showEventTab = Boolean(activeEventAt(events, eventNow));
onMount(() => {
let mounted = true;
void synchronizeReleaseClock().then((clock) => {
if (!mounted) return;
eventClock = clock;
eventNow = eventClock.now();
});
const timer = window.setInterval(() => eventNow = eventClock.now(), 1_000);
return () => {
mounted = false;
window.clearInterval(timer);
};
});
</script>
<header class="site-nav">
@@ -10,6 +32,7 @@
<a href="/cards" aria-current={$page.url.pathname.startsWith('/cards') ? 'page' : undefined}>카드 목록</a>
<a href="/analysis" aria-current={$page.url.pathname.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
<a href="/combination" aria-current={$page.url.pathname.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
{#if showEventTab}<a href="/event" aria-current={$page.url.pathname.startsWith('/event') ? 'page' : undefined}>이벤트 Pt</a>{/if}
<a href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
<a
class="report-link"
+120 -41
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 = [
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; }
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { events } from '$lib/data/events.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { activeEventAt } from '$lib/engine/event-optimization.ts';
import CombinationPage from '../combination/+page.svelte';
let clock = createInitialReleaseClock($page.data?.serverNow);
let now = clock.now();
$: activeEvent = activeEventAt(events, now);
onMount(() => {
let mounted = true;
void synchronizeReleaseClock().then((nextClock) => {
if (!mounted) return;
clock = nextClock;
now = clock.now();
});
const timer = window.setInterval(() => now = clock.now(), 1_000);
return () => {
mounted = false;
window.clearInterval(timer);
};
});
</script>
<svelte:head><title>이벤트 Pt 최적화 · 홀로도리 계산기</title></svelte:head>
{#if activeEvent}
<CombinationPage eventMode />
{:else}
<main class="inactive-event">
<h1>진행 중인 이벤트가 없습니다</h1>
<p>포인트 획득 이벤트가 시작되면 이 메뉴와 최적화 기능이 자동으로 활성화됩니다.</p>
</main>
{/if}
<style>
.inactive-event { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: var(--page-content-top) 0 80px; }
h1 { margin: 0; color: #f3f5f8; font-size: var(--page-title-size); letter-spacing: -.045em; }
p { margin: 12px 0 0; color: #929cab; line-height: 1.7; }
</style>