feat: release version 1.0.9
This commit is contained in:
@@ -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"
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "holodream-tools",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -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 압축파일은 실행용 배포본이 아닙니다.
|
||||
@@ -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[];
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -140,5 +140,6 @@ export type ExactOptimizationResult = ExactScoreResult & {
|
||||
};
|
||||
|
||||
export type ExactOptimizeOptions = ExactScoreOptions & {
|
||||
objective?: 'maximum' | 'expected';
|
||||
onProgress?: (progress: OptimizationProgress) => void;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user