feat: release version 1.0.9
This commit is contained in:
@@ -4,16 +4,20 @@
|
|||||||
import CardsPage from '../src/routes/cards/+page.svelte';
|
import CardsPage from '../src/routes/cards/+page.svelte';
|
||||||
import AnalysisPage from '../src/routes/analysis/+page.svelte';
|
import AnalysisPage from '../src/routes/analysis/+page.svelte';
|
||||||
import CombinationPage from '../src/routes/combination/+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 DataPage from '../src/routes/data/+page.svelte';
|
||||||
import DataCardsPage from '../src/routes/data/cards/+page.svelte';
|
import DataCardsPage from '../src/routes/data/cards/+page.svelte';
|
||||||
import DataBoardsPage from '../src/routes/data/boards/+page.svelte';
|
import DataBoardsPage from '../src/routes/data/boards/+page.svelte';
|
||||||
import { setOfflinePage } from './page-store';
|
import { setOfflinePage } from './page-store';
|
||||||
|
import { events } from '../src/lib/data/events';
|
||||||
|
import { activeEventAt } from '../src/lib/engine/event-optimization';
|
||||||
|
|
||||||
const routes = new Map([
|
const routes = new Map([
|
||||||
['/', HomePage],
|
['/', HomePage],
|
||||||
['/cards', CardsPage],
|
['/cards', CardsPage],
|
||||||
['/analysis', AnalysisPage],
|
['/analysis', AnalysisPage],
|
||||||
['/combination', CombinationPage],
|
['/combination', CombinationPage],
|
||||||
|
['/event', EventPage],
|
||||||
['/data', DataPage],
|
['/data', DataPage],
|
||||||
['/data/cards', DataCardsPage],
|
['/data/cards', DataCardsPage],
|
||||||
['/data/boards', DataBoardsPage]
|
['/data/boards', DataBoardsPage]
|
||||||
@@ -26,6 +30,8 @@
|
|||||||
|
|
||||||
let currentPath = pathFromHash();
|
let currentPath = pathFromHash();
|
||||||
let CurrentPage = routes.get(currentPath) ?? HomePage;
|
let CurrentPage = routes.get(currentPath) ?? HomePage;
|
||||||
|
let eventNow = Date.now();
|
||||||
|
$: showEventTab = Boolean(activeEventAt(events, eventNow));
|
||||||
setOfflinePage(currentPath);
|
setOfflinePage(currentPath);
|
||||||
|
|
||||||
function syncRoute() {
|
function syncRoute() {
|
||||||
@@ -36,6 +42,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const eventTimer = window.setInterval(() => eventNow = Date.now(), 1_000);
|
||||||
const rewriteInternalLinks = () => {
|
const rewriteInternalLinks = () => {
|
||||||
for (const anchor of document.querySelectorAll<HTMLAnchorElement>('a[href^="/"]')) {
|
for (const anchor of document.querySelectorAll<HTMLAnchorElement>('a[href^="/"]')) {
|
||||||
anchor.href = `#${anchor.getAttribute('href')}`;
|
anchor.href = `#${anchor.getAttribute('href')}`;
|
||||||
@@ -57,6 +64,7 @@
|
|||||||
window.removeEventListener('hashchange', syncRoute);
|
window.removeEventListener('hashchange', syncRoute);
|
||||||
document.removeEventListener('click', handleClick);
|
document.removeEventListener('click', handleClick);
|
||||||
linkObserver.disconnect();
|
linkObserver.disconnect();
|
||||||
|
window.clearInterval(eventTimer);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -68,6 +76,7 @@
|
|||||||
<a href="#/cards" aria-current={currentPath.startsWith('/cards') ? 'page' : undefined}>카드 목록</a>
|
<a href="#/cards" aria-current={currentPath.startsWith('/cards') ? 'page' : undefined}>카드 목록</a>
|
||||||
<a href="#/analysis" aria-current={currentPath.startsWith('/analysis') ? '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>
|
<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 href="#/data" aria-current={currentPath.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
|
||||||
<a
|
<a
|
||||||
class="report-link"
|
class="report-link"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "holodream-tools",
|
"name": "holodream-tools",
|
||||||
"version": "1.0.8",
|
"version": "1.0.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "holodream-tools",
|
"name": "holodream-tools",
|
||||||
"version": "1.0.8",
|
"version": "1.0.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "holodream-tools",
|
"name": "holodream-tools",
|
||||||
"version": "1.0.8",
|
"version": "1.0.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"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.theoreticalMaxScore, 400);
|
||||||
assert.equal(result.expectedScore, 130);
|
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) {
|
for (const reductions of reductionAssignments) {
|
||||||
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
|
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
|
||||||
const reductionTotal = Object.values(reductions).reduce<number>((sum, value) => sum + value, 0);
|
const reductionTotal = Object.values(reductions).reduce<number>((sum, value) => sum + value, 0);
|
||||||
const isHigherMaximum = score.theoreticalMaxScore > bestTheoreticalMaxScore;
|
const expectedFirst = options.objective === 'expected';
|
||||||
const isHigherExpectedTie = score.theoreticalMaxScore === bestTheoreticalMaxScore &&
|
const isBetterPrimary = expectedFirst
|
||||||
score.expectedScore > bestExpectedScore;
|
? 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 &&
|
const isCheaperTie = score.theoreticalMaxScore === bestTheoreticalMaxScore &&
|
||||||
score.expectedScore === bestExpectedScore && reductionTotal < bestReductionTotal;
|
score.expectedScore === bestExpectedScore && reductionTotal < bestReductionTotal;
|
||||||
if (isHigherMaximum || isHigherExpectedTie || isCheaperTie) {
|
if (isBetterPrimary || isBetterSecondary || isCheaperTie) {
|
||||||
bestTheoreticalMaxScore = score.theoreticalMaxScore;
|
bestTheoreticalMaxScore = score.theoreticalMaxScore;
|
||||||
bestExpectedScore = score.expectedScore;
|
bestExpectedScore = score.expectedScore;
|
||||||
bestOrder = order;
|
bestOrder = order;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './awakening-requirements.ts';
|
|||||||
export * from './board-effects.ts';
|
export * from './board-effects.ts';
|
||||||
export * from './board-connection-optimizer.ts';
|
export * from './board-connection-optimizer.ts';
|
||||||
export * from './exact-optimizer.ts';
|
export * from './exact-optimizer.ts';
|
||||||
|
export * from './event-optimization.ts';
|
||||||
export * from './memory.ts';
|
export * from './memory.ts';
|
||||||
export * from './maximum-weight-matching.ts';
|
export * from './maximum-weight-matching.ts';
|
||||||
export * from './optimizer.ts';
|
export * from './optimizer.ts';
|
||||||
|
|||||||
@@ -140,5 +140,6 @@ export type ExactOptimizationResult = ExactScoreResult & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ExactOptimizeOptions = ExactScoreOptions & {
|
export type ExactOptimizeOptions = ExactScoreOptions & {
|
||||||
|
objective?: 'maximum' | 'expected';
|
||||||
onProgress?: (progress: OptimizationProgress) => void;
|
onProgress?: (progress: OptimizationProgress) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ type CombinationCandidate = {
|
|||||||
overallPower: number;
|
overallPower: number;
|
||||||
perfectNoteScore: number;
|
perfectNoteScore: number;
|
||||||
directSongScoreBonusPercent?: number;
|
directSongScoreBonusPercent?: number;
|
||||||
|
eventBonusPercent?: number;
|
||||||
|
rankingMultiplier?: number;
|
||||||
|
optimizationObjective?: 'maximum' | 'expected';
|
||||||
};
|
};
|
||||||
|
|
||||||
type CombinationRequest = {
|
type CombinationRequest = {
|
||||||
@@ -30,9 +33,24 @@ type RankedCombination = {
|
|||||||
overallPower: number;
|
overallPower: number;
|
||||||
cards?: OptimizerCard[];
|
cards?: OptimizerCard[];
|
||||||
directSongScoreBonusPercent?: number;
|
directSongScoreBonusPercent?: number;
|
||||||
|
eventBonusPercent?: number;
|
||||||
|
rankingMultiplier?: number;
|
||||||
achievementProbabilities?: ScoreAchievementProbabilities;
|
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 AchievementRequest = {
|
||||||
type: 'estimate-achievement';
|
type: 'estimate-achievement';
|
||||||
rankings: RankedCombination[];
|
rankings: RankedCombination[];
|
||||||
@@ -106,31 +124,26 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
|
|||||||
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
|
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
|
||||||
perfectNoteScore: candidate.perfectNoteScore,
|
perfectNoteScore: candidate.perfectNoteScore,
|
||||||
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
|
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
|
||||||
|
objective: candidate.optimizationObjective,
|
||||||
onProgress: (progress) => self.postMessage({
|
onProgress: (progress) => self.postMessage({
|
||||||
type: 'progress',
|
type: 'progress',
|
||||||
progress: { evaluatedCandidates: completedCandidates + progress.evaluatedCandidates, totalCandidates }
|
progress: { evaluatedCandidates: completedCandidates + progress.evaluatedCandidates, totalCandidates }
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
completedCandidates += countOptimizationCandidates(candidate.cards);
|
completedCandidates += countOptimizationCandidates(candidate.cards);
|
||||||
if (!best || result.theoreticalMaxScore > best.result.theoreticalMaxScore ||
|
const ranking: RankedCombination = {
|
||||||
(result.theoreticalMaxScore === best.result.theoreticalMaxScore && result.expectedScore > best.result.expectedScore)) {
|
|
||||||
best = {
|
|
||||||
result,
|
result,
|
||||||
overallPower: candidate.overallPower,
|
overallPower: candidate.overallPower,
|
||||||
cards: candidate.cards,
|
cards: candidate.cards,
|
||||||
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent
|
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
|
||||||
|
eventBonusPercent: candidate.eventBonusPercent,
|
||||||
|
rankingMultiplier: candidate.rankingMultiplier
|
||||||
};
|
};
|
||||||
}
|
if (!best || compareRankings(ranking, best) < 0) best = ranking;
|
||||||
rankings.push({
|
rankings.push(ranking);
|
||||||
result,
|
|
||||||
overallPower: candidate.overallPower,
|
|
||||||
cards: candidate.cards,
|
|
||||||
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
|
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
|
||||||
rankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
|
rankings.sort(compareRankings);
|
||||||
right.result.expectedScore - left.result.expectedScore);
|
|
||||||
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, 10) });
|
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, 10) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
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';
|
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>
|
</script>
|
||||||
|
|
||||||
<header class="site-nav">
|
<header class="site-nav">
|
||||||
@@ -10,6 +32,7 @@
|
|||||||
<a href="/cards" aria-current={$page.url.pathname.startsWith('/cards') ? 'page' : undefined}>카드 목록</a>
|
<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="/analysis" aria-current={$page.url.pathname.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
|
||||||
<a href="/combination" aria-current={$page.url.pathname.startsWith('/combination') ? '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 href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
|
||||||
<a
|
<a
|
||||||
class="report-link"
|
class="report-link"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import boardSummary from '$lib/data/boards-summary.json';
|
import boardSummary from '$lib/data/boards-summary.json';
|
||||||
import memorySummary from '$lib/data/memory-summary.json';
|
import memorySummary from '$lib/data/memory-summary.json';
|
||||||
import exactChartCatalog from '$lib/data/exact-chart-catalog.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 { loadExactSongChart } from '$lib/data/chart-loader.ts';
|
||||||
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
|
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
|
||||||
import { releasedSongCharts } from '$lib/data/song-release.ts';
|
import { releasedSongCharts } from '$lib/data/song-release.ts';
|
||||||
@@ -18,6 +19,9 @@
|
|||||||
combinationSearchLimits,
|
combinationSearchLimits,
|
||||||
countOptimizationCandidates,
|
countOptimizationCandidates,
|
||||||
effectiveActiveProbability,
|
effectiveActiveProbability,
|
||||||
|
eventBonusBreakdown,
|
||||||
|
eventChallengeSongScoreBonusPercent,
|
||||||
|
eventPointIndex,
|
||||||
generateAnchoredCombinationCandidates,
|
generateAnchoredCombinationCandidates,
|
||||||
generateCombinationCandidates,
|
generateCombinationCandidates,
|
||||||
memoryParameterPermil,
|
memoryParameterPermil,
|
||||||
@@ -30,6 +34,8 @@
|
|||||||
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||||
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
|
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
|
||||||
|
|
||||||
|
export let eventMode = false;
|
||||||
|
|
||||||
type CardRow = (typeof cardSummary.cards)[number];
|
type CardRow = (typeof cardSummary.cards)[number];
|
||||||
type DifficultyKey = 'hard' | 'expert';
|
type DifficultyKey = 'hard' | 'expert';
|
||||||
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
|
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
|
||||||
@@ -42,12 +48,17 @@
|
|||||||
directSongScoreBonusPercent: number;
|
directSongScoreBonusPercent: number;
|
||||||
heuristic: number;
|
heuristic: number;
|
||||||
skillPotential: number;
|
skillPotential: number;
|
||||||
|
eventBonusPercent?: number;
|
||||||
|
rankingMultiplier?: number;
|
||||||
|
optimizationObjective?: 'maximum' | 'expected';
|
||||||
};
|
};
|
||||||
type RankedResult = {
|
type RankedResult = {
|
||||||
result: ExactOptimizationResult;
|
result: ExactOptimizationResult;
|
||||||
overallPower: number;
|
overallPower: number;
|
||||||
cards?: OptimizerCard[];
|
cards?: OptimizerCard[];
|
||||||
directSongScoreBonusPercent?: number;
|
directSongScoreBonusPercent?: number;
|
||||||
|
eventBonusPercent?: number;
|
||||||
|
rankingMultiplier?: number;
|
||||||
achievementProbabilities?: ScoreAchievementProbabilities;
|
achievementProbabilities?: ScoreAchievementProbabilities;
|
||||||
};
|
};
|
||||||
type DisplayRankedResult = RankedResult & { isBaseline: boolean };
|
type DisplayRankedResult = RankedResult & { isBaseline: boolean };
|
||||||
@@ -83,12 +94,17 @@
|
|||||||
leaderOutfitCardId: string;
|
leaderOutfitCardId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = 'holodori.combination.v1';
|
const STORAGE_KEY = eventMode ? 'holodori.event-combination.v1' : 'holodori.combination.v1';
|
||||||
const PRESET_STORAGE_KEY = 'holodori.combination.presets.v1';
|
const PRESET_STORAGE_KEY = eventMode ? 'holodori.event-combination.presets.v1' : 'holodori.combination.presets.v1';
|
||||||
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
|
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
|
||||||
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
|
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
|
||||||
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
|
const allCards = cardSummary.cards;
|
||||||
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
|
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 allCharts = exactChartCatalog.charts;
|
||||||
const initialReleaseClock = createInitialReleaseClock($page.data?.serverNow);
|
const initialReleaseClock = createInitialReleaseClock($page.data?.serverNow);
|
||||||
let currentReleaseClock = initialReleaseClock;
|
let currentReleaseClock = initialReleaseClock;
|
||||||
@@ -111,9 +127,9 @@
|
|||||||
let selectedSongId = songs[0]?.id ?? '';
|
let selectedSongId = songs[0]?.id ?? '';
|
||||||
let difficulty: DifficultyKey = 'expert';
|
let difficulty: DifficultyKey = 'expert';
|
||||||
let fixedCardIds = ['', '', '', ''];
|
let fixedCardIds = ['', '', '', ''];
|
||||||
let candidateAwakening = 5;
|
let candidateAwakening = eventMode ? 0 : 5;
|
||||||
let candidateGenerationBonus: number | null = null;
|
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 fixedCooldownReductions: (CooldownReduction | null)[] = [null, null, null, null];
|
||||||
let fixedAllBlueNodes = [false, false, false, false];
|
let fixedAllBlueNodes = [false, false, false, false];
|
||||||
let fixedGenerationBonusOverrides: (number | null)[] = [null, null, null, null];
|
let fixedGenerationBonusOverrides: (number | null)[] = [null, null, null, null];
|
||||||
@@ -148,6 +164,9 @@
|
|||||||
let ready = false;
|
let ready = false;
|
||||||
|
|
||||||
$: charts = releasedSongCharts(allCharts, releaseNow);
|
$: 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());
|
$: 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 ?? '';
|
$: if (!songs.some((song) => song.id === selectedSongId)) selectedSongId = songs[0]?.id ?? '';
|
||||||
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
|
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
|
||||||
@@ -155,7 +174,7 @@
|
|||||||
$: baselineChartMetadata = baselineState
|
$: baselineChartMetadata = baselineState
|
||||||
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
|
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
|
||||||
: undefined;
|
: undefined;
|
||||||
$: baselineMatchesConditions = Boolean(baselineState?.songId === selectedSongId && baselineState?.difficulty === difficulty);
|
$: baselineMatchesConditions = !eventMode && Boolean(baselineState?.songId === selectedSongId && baselineState?.difficulty === difficulty);
|
||||||
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
|
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
|
||||||
$: displayedRankings = buildDisplayedRankings(
|
$: displayedRankings = buildDisplayedRankings(
|
||||||
rankedResults,
|
rankedResults,
|
||||||
@@ -170,8 +189,8 @@
|
|||||||
$: if (!result && rankedResults.length > 0) rankedResults = [];
|
$: if (!result && rankedResults.length > 0) rankedResults = [];
|
||||||
$: fixedCards = fixedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
|
$: fixedCards = fixedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
|
||||||
$: candidateCards = ownedCardsOnly
|
$: candidateCards = ownedCardsOnly
|
||||||
? fiveStarCards.filter((card) => collection[card.id]?.owned === true)
|
? eligibleCards.filter((card) => collection[card.id]?.owned === true)
|
||||||
: fiveStarCards;
|
: eligibleCards;
|
||||||
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
|
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
|
||||||
$: leaderOutfitOptions = leaderMember
|
$: leaderOutfitOptions = leaderMember
|
||||||
? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume)
|
? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume)
|
||||||
@@ -502,7 +521,10 @@
|
|||||||
function roughCardScore(card: CardRow): number {
|
function roughCardScore(card: CardRow): number {
|
||||||
const statTotal = Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0);
|
const statTotal = Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0);
|
||||||
const { activeValue, specialValue } = roughSkillValues(card);
|
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 {
|
function roughStatScore(card: CardRow): number {
|
||||||
@@ -534,7 +556,30 @@
|
|||||||
effectSum + effect.percent * (effect.target === 'all' ? cards.length : effect.targetCount ?? 1), 0);
|
effectSum + effect.percent * (effect.target === 'all' ? cards.length : effect.targetCount ?? 1), 0);
|
||||||
return sum + activeValue + specialValue + activationValue + passiveValue;
|
return sum + activeValue + specialValue + activationValue + passiveValue;
|
||||||
}, 0);
|
}, 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 {
|
function combinationKey(cards: readonly CardRow[] | readonly OptimizerCard[]): string {
|
||||||
@@ -553,19 +598,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function screeningCombinations(): CardRow[][] {
|
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) {
|
if (fixedCards.length === 4) {
|
||||||
return generateCombinationCandidates(fixedCards, candidateCards, 5, roughCardScore, screeningLimit);
|
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 anchoredBudget = screeningLimit - globalBudget;
|
||||||
const perStrategy = Math.ceil(globalBudget / 4);
|
const strategies = eventMode
|
||||||
const strategies = [
|
? [
|
||||||
|
{ scoreCard: roughCardScore },
|
||||||
|
{ scoreCard: roughStatScore, scoreCombination: roughCombinationScore },
|
||||||
|
{ scoreCard: roughCardScore, scoreCombination: roughEventBonusPriority }
|
||||||
|
]
|
||||||
|
: [
|
||||||
{ scoreCard: roughCardScore },
|
{ scoreCard: roughCardScore },
|
||||||
{ scoreCard: roughSkillScore },
|
{ scoreCard: roughSkillScore },
|
||||||
{ scoreCard: roughStatScore },
|
{ scoreCard: roughStatScore },
|
||||||
{ scoreCard: roughStatScore, scoreCombination: roughCombinationScore }
|
{ scoreCard: roughStatScore, scoreCombination: roughCombinationScore }
|
||||||
];
|
];
|
||||||
|
const perStrategy = Math.ceil(globalBudget / strategies.length);
|
||||||
const combined = new Map<string, CardRow[]>();
|
const combined = new Map<string, CardRow[]>();
|
||||||
for (const strategy of strategies) {
|
for (const strategy of strategies) {
|
||||||
for (const cards of generateCombinationCandidates(
|
for (const cards of generateCombinationCandidates(
|
||||||
@@ -579,6 +631,7 @@
|
|||||||
combined.set(combinationKey(cards), cards);
|
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 fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
|
||||||
const anchorCount = candidateCards.filter((card) => !fixedCharacters.has(card.characterId)).length;
|
const anchorCount = candidateCards.filter((card) => !fixedCharacters.has(card.characterId)).length;
|
||||||
const perAnchorLimit = Math.max(1, Math.ceil(anchoredBudget / Math.max(1, anchorCount)));
|
const perAnchorLimit = Math.max(1, Math.ceil(anchoredBudget / Math.max(1, anchorCount)));
|
||||||
@@ -597,6 +650,7 @@
|
|||||||
function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] {
|
function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] {
|
||||||
if (fixedCards.length === 4) return prepared;
|
if (fixedCards.length === 4) return prepared;
|
||||||
const { baseExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
|
const { baseExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
|
||||||
|
const finalistLimit = eventMode ? Math.min(baseExactFinalistLimit, 96) : baseExactFinalistLimit;
|
||||||
const selected = new Map<string, PreparedCandidate>();
|
const selected = new Map<string, PreparedCandidate>();
|
||||||
const add = (candidates: PreparedCandidate[], count: number) => {
|
const add = (candidates: PreparedCandidate[], count: number) => {
|
||||||
for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate);
|
for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate);
|
||||||
@@ -606,23 +660,23 @@
|
|||||||
(candidate) => candidate.cards,
|
(candidate) => candidate.cards,
|
||||||
(candidate) => candidate.heuristic,
|
(candidate) => candidate.heuristic,
|
||||||
fixedCardIds,
|
fixedCardIds,
|
||||||
Math.ceil(baseExactFinalistLimit / 2)
|
Math.ceil(finalistLimit / 2)
|
||||||
);
|
);
|
||||||
add(diverse, diverse.length);
|
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 heuristicLimit = Math.ceil(remainingLimit / 2);
|
||||||
const skillLimit = Math.ceil(remainingLimit / 4);
|
const skillLimit = Math.ceil(remainingLimit / 4);
|
||||||
const powerLimit = remainingLimit - heuristicLimit - skillLimit;
|
const powerLimit = remainingLimit - heuristicLimit - skillLimit;
|
||||||
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), heuristicLimit);
|
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.skillPotential - left.skillPotential), skillLimit);
|
||||||
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), powerLimit);
|
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)) {
|
for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) {
|
||||||
selected.set(combinationKey(candidate.cards), candidate);
|
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[] {
|
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) =>
|
const rankedAlternatives = Array.from(alternatives.values()).sort((left, right) =>
|
||||||
right.roughScore - left.roughScore || combinationKey(left.cards).localeCompare(combinationKey(right.cards)));
|
right.roughScore - left.roughScore || combinationKey(left.cards).localeCompare(combinationKey(right.cards)));
|
||||||
const chosenAlternatives = new Map<string, { cards: CardRow[]; roughScore: number }>();
|
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);
|
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
|
||||||
|
if (chosenAlternatives.size >= refinementLimit) break;
|
||||||
}
|
}
|
||||||
for (const candidate of secondaryPriority.values()) {
|
for (const candidate of secondaryPriority.values()) {
|
||||||
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
|
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
|
||||||
@@ -721,13 +779,26 @@
|
|||||||
card.special.scoreUpPercent * card.special.durationSeconds / 100 +
|
card.special.scoreUpPercent * card.special.durationSeconds / 100 +
|
||||||
(card.scoreSupportPercent ?? 0) +
|
(card.scoreSupportPercent ?? 0) +
|
||||||
(card.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => effectSum + effect.percent * (effect.target === 'all' ? 5 : effect.targetCount ?? 1), 0), 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 {
|
return {
|
||||||
cards: optimizerCards,
|
cards: optimizerCards,
|
||||||
overallPower: power.overallPower,
|
overallPower: power.overallPower,
|
||||||
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, selectedChart?.scoreRatio ?? null),
|
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, selectedChart?.scoreRatio ?? null),
|
||||||
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
|
directSongScoreBonusPercent,
|
||||||
heuristic: power.overallPower * (1 + skillPotential / 500),
|
heuristic: power.overallPower * (1 + skillPotential / 500) * (rankingMultiplier ?? 1) * (1 + eventSongScoreBonus / 100),
|
||||||
skillPotential
|
skillPotential,
|
||||||
|
...(eventBonus ? {
|
||||||
|
eventBonusPercent: eventBonus.totalPercent,
|
||||||
|
rankingMultiplier,
|
||||||
|
optimizationObjective: 'expected' as const
|
||||||
|
} : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,13 +898,11 @@
|
|||||||
for (const ranking of rankings) {
|
for (const ranking of rankings) {
|
||||||
const key = [...ranking.result.order].sort().join('|');
|
const key = [...ranking.result.order].sort().join('|');
|
||||||
const previous = bestByCombination.get(key);
|
const previous = bestByCombination.get(key);
|
||||||
if (!previous || ranking.result.theoreticalMaxScore > previous.result.theoreticalMaxScore ||
|
if (!previous || compareRankedResults(ranking, previous) < 0) {
|
||||||
(ranking.result.theoreticalMaxScore === previous.result.theoreticalMaxScore && ranking.result.expectedScore > previous.result.expectedScore)) {
|
|
||||||
bestByCombination.set(key, ranking);
|
bestByCombination.set(key, ranking);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rankedResults = Array.from(bestByCombination.values()).sort((left, right) =>
|
rankedResults = Array.from(bestByCombination.values()).sort(compareRankedResults).slice(0, 10);
|
||||||
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore || right.result.expectedScore - left.result.expectedScore).slice(0, 10);
|
|
||||||
result = rankedResults[0]?.result ?? null;
|
result = rankedResults[0]?.result ?? null;
|
||||||
if (rankedResults.length > 0 && activeSearchChart && worker && rankedResults.every((ranking) => ranking.cards)) {
|
if (rankedResults.length > 0 && activeSearchChart && worker && rankedResults.every((ranking) => ranking.cards)) {
|
||||||
searchPhase = 'probability';
|
searchPhase = 'probability';
|
||||||
@@ -908,8 +977,7 @@
|
|||||||
completedWorkers += 1;
|
completedWorkers += 1;
|
||||||
parallelWorker.terminate();
|
parallelWorker.terminate();
|
||||||
if (completedWorkers !== workerCount) return;
|
if (completedWorkers !== workerCount) return;
|
||||||
completedRankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
|
completedRankings.sort(compareRankedResults);
|
||||||
right.result.expectedScore - left.result.expectedScore);
|
|
||||||
parallelWorkers = [];
|
parallelWorkers = [];
|
||||||
completeExactPass(completedRankings.slice(0, 10));
|
completeExactPass(completedRankings.slice(0, 10));
|
||||||
}
|
}
|
||||||
@@ -1022,22 +1090,27 @@
|
|||||||
unique.set(displayedCompositionKey(baseline), baseline);
|
unique.set(displayedCompositionKey(baseline), baseline);
|
||||||
}
|
}
|
||||||
return Array.from(unique.values())
|
return Array.from(unique.values())
|
||||||
.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
|
.sort((left, right) => compareRankedResults(left, right) || Number(right.isBaseline) - Number(left.isBaseline))
|
||||||
right.result.expectedScore - left.result.expectedScore || Number(right.isBaseline) - Number(left.isBaseline))
|
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>조합 탐색 · 홀로도리 계산기</title></svelte:head>
|
<svelte:head><title>{eventMode ? '이벤트 Pt 최적화' : '조합 탐색'} · 홀로도리 계산기</title></svelte:head>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<header>
|
<header>
|
||||||
<h1>조합 탐색</h1>
|
<h1>{eventMode ? '이벤트 Pt 최적화' : '조합 탐색'}</h1>
|
||||||
<p class="page-description">악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.</p>
|
<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'}
|
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured'}
|
||||||
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
|
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p>
|
{#if !eventMode}<p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p>{/if}
|
||||||
</header>
|
</header>
|
||||||
{#if baselineState && baselineMatchesConditions}
|
{#if baselineState && baselineMatchesConditions}
|
||||||
<section class="panel baseline-panel" aria-labelledby="baseline-title">
|
<section class="panel baseline-panel" aria-labelledby="baseline-title">
|
||||||
@@ -1066,7 +1139,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
<section class="panel" aria-labelledby="conditions-title">
|
<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-fields">
|
||||||
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
|
<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>
|
<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>
|
<strong>내가 보유한 카드만 사용하기</strong>
|
||||||
</label>
|
</label>
|
||||||
<p class="candidate-guide">
|
<p class="candidate-guide">
|
||||||
‘내가 보유한 카드만 사용하기’를 켜면 고정 카드와 자동 후보 모두 보유 5성 카드로 제한됩니다.<br>
|
‘내가 보유한 카드만 사용하기’를 켜면 고정 카드와 자동 후보 모두 {eventMode ? '보유한 이벤트 후보 카드' : '보유 5성 카드'}로 제한됩니다.<br>
|
||||||
보유 카드의 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.<br>
|
보유 카드의 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.<br>
|
||||||
기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.
|
기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.
|
||||||
</p>
|
</p>
|
||||||
@@ -1177,8 +1250,9 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
<span class="rank-metric">
|
<span class="rank-metric">
|
||||||
<small>최고 점수</small>
|
<small>{eventMode ? '예상 이벤트 Pt 지수' : '최고 점수'}</small>
|
||||||
<strong>{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>
|
<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}
|
{#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}
|
||||||
<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
|
<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
|
||||||
</span>
|
</span>
|
||||||
@@ -1203,6 +1277,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<p class="awakening-guide">추가 개화는 현재 카드 데이터와 조합의 목표 개화를 비교한 값입니다. 미보유 카드를 처음 얻는 과정도 1회로 계산합니다.</p>
|
<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>
|
<p class="probability-guide">달성 확률은 모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
|
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
|
||||||
@@ -1219,6 +1294,9 @@
|
|||||||
.calibration-warning strong { margin-right: 5px; color: #f1a3c4; }
|
.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 { 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; }
|
.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; }
|
.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, .section-title > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
.section-title > div { justify-content: flex-start; }
|
.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 strong { color: #dce1e8; font-size: 14px; line-height: 1.2; }
|
||||||
.rank-probabilities i { color: #7f8998; font-size: 10px; font-style: normal; font-weight: 700; }
|
.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; }
|
.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 { color: #aa8797; }
|
||||||
.awakening-guide + .probability-guide { margin-top: 5px; }
|
.awakening-guide + .probability-guide { margin-top: 5px; }
|
||||||
.ranking-detail { padding: 0 16px 18px; border-top: 1px solid #2b313c; }
|
.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