fix: restore summer event calculation through source deployment
Publish GitHub Pages output / publish (push) Successful in 39s

This commit is contained in:
2026-09-20 21:26:49 +09:00
parent 5d0361de26
commit 8ed658e804
8 changed files with 137 additions and 25 deletions
+11 -1
View File
@@ -1,9 +1,13 @@
--- ---
updatedAt: 2026-09-20T04:48:31Z updatedAt: 2026-09-20T12:25:36Z
--- ---
## 2026년 9월 20일 ## 2026년 9월 20일
### 이벤트 기능 복구
`FIX` 배포 갱신 후 사라진 「몽환의 서머 이스케이프?!」 이벤트 메뉴와 PT 비교·점수 탐색을 복구했습니다. 현재 챕터의 대상 멤버와 과제곡 보너스를 다시 적용합니다.
### 홀로워크용 연결 카드 유지 ### 홀로워크용 연결 카드 유지
`IMPROVED` 연결 카드 최적화에서 활성 노란 홀로워크 노드를 증폭 중인 카드는 이론상 고점을 높이는 데 필요한 경우에만 이동합니다. 점수가 같으면 발동률 증가보다 기존 배치를 우선하며, 다른 카드의 점수 개선 때문에 불필요하게 이동한 홀로워크 카드도 복원합니다. `IMPROVED` 연결 카드 최적화에서 활성 노란 홀로워크 노드를 증폭 중인 카드는 이론상 고점을 높이는 데 필요한 경우에만 이동합니다. 점수가 같으면 발동률 증가보다 기존 배치를 우선하며, 다른 카드의 점수 개선 때문에 불필요하게 이동한 홀로워크 카드도 복원합니다.
@@ -14,6 +18,12 @@ updatedAt: 2026-09-20T04:48:31Z
`DATA` 아키 로젠탈의 Grave of Halo를 추가했습니다. Easy·Normal·Hard·Expert의 정확한 채보와 곡별 점수 계산을 지원합니다. `DATA` 아키 로젠탈의 Grave of Halo를 추가했습니다. Easy·Normal·Hard·Expert의 정확한 채보와 곡별 점수 계산을 지원합니다.
### 몽환의 서머 이스케이프?! 이벤트 계산
`NEW` 이벤트 PT 비교와 이벤트 점수 탐색을 열었습니다. 챕터별 대상 멤버와 과제곡이 기간에 맞춰 전환되며, 신규 카드 공통 보너스와 개화 보너스를 반영합니다.
`IMPROVED` PT 비교는 평균 점수에 획득 배율을 적용한 비교 지수, 점수 탐색은 과제곡 보너스를 반영한 최고 점수를 기준으로 정렬합니다. PT 비교 지수는 실제 획득량이 아니며 동일 악곡·난이도에서 비교하는 용도입니다.
## 2026년 9월 18일 ## 2026년 9월 18일
### 홀로워크 조합 추천과 목록 개선 ### 홀로워크 조합 추천과 목록 개선
+1 -1
View File
@@ -88,7 +88,7 @@
<a href="#/analysis" aria-current={currentPath.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a> <a href="#/analysis" aria-current={currentPath.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
<a href="#/rating" aria-current={currentPath.startsWith('/rating') ? 'page' : undefined}>리더 레이팅</a> <a href="#/rating" aria-current={currentPath.startsWith('/rating') ? '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} {#if showEventTab}<a href="#/event" aria-current={currentPath.startsWith('/event') ? 'page' : undefined}>이벤트</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 href="#/updates" aria-current={currentPath.startsWith('/updates') ? 'page' : undefined}>업데이트 내역</a> <a href="#/updates" aria-current={currentPath.startsWith('/updates') ? 'page' : undefined}>업데이트 내역</a>
<a href="#/tools" aria-current={currentPath.startsWith('/tools') || currentPath === '/random' ? 'page' : undefined}>도구</a> <a href="#/tools" aria-current={currentPath.startsWith('/tools') || currentPath === '/random' ? 'page' : undefined}>도구</a>
+36
View File
@@ -7,6 +7,17 @@ const gamersFeaturedCards = [
'card-00017-5-uniq-0077-00' 'card-00017-5-uniq-0077-00'
] as const; ] as const;
const summerFeaturedCards = ['card-00004-5-uniq-0081-00', 'card-04013-5-uniq-0083-00', 'card-00035-5-uniq-0082-00', 'card-03005-5-uniq-0084-00'] as const;
// Schedule: https://dengekionline.com/article/202609/88492
// Bonuses: https://gamerch.com/hololive-dreams/1015338
// IDs/names: HolodoriDB kor 5517e40b931fec10253f5b850989346518e233c2.
const summerChapters = [
['아키로제식 여름 휴가', 'chr-00004', 'm0349', 19, 21],
['더위와 풀장과 독서가와', 'chr-04013', 'm0351', 21, 23],
['총수의 파라다이스 침략 작전!', 'chr-00035', 'm0350', 23, 25],
['유능 감독, 성지순례에 나서다', 'chr-03005', 'm0352', 25, 27]
] as const;
export const events = [ export const events = [
{ {
// HolodoriDB supplies the card/song IDs; the public master-data diff does not include // HolodoriDB supplies the card/song IDs; the public master-data diff does not include
@@ -32,5 +43,30 @@ export const events = [
4: [0, 6, 7, 8, 9, 10], 4: [0, 6, 7, 8, 9, 10],
5: [0, 15, 18, 22, 26, 30] 5: [0, 15, 18, 22, 26, 30]
} }
},
{
id: 'marathon_event-relay-002',
name: '몽환의 서머 이스케이프?!',
startsAt: '2026-09-19T11:00:00.000Z',
endsAt: '2026-09-27T10:59:59.999Z',
bonusCharacterIds: [],
featuredCardIds: summerFeaturedCards,
challengeSongBonusCardIds: {},
formationBonusPercent: 50,
featuredCardBonusPercent: 50,
challengeSongScoreBonusPercent: 10,
awakeningBonusPercentByRarity: {
3: [0, 1, 1, 2, 2, 3],
4: [0, 6, 7, 8, 9, 10],
5: [0, 15, 18, 22, 26, 30]
},
chapters: summerChapters.map(([name, characterId, songId, start, end], index) => ({
id: `marathon_event-relay-002-chapter-0${index + 1}`,
name,
startsAt: `2026-09-${start}T11:00:00.000Z`,
endsAt: `2026-09-${end}T10:59:59.999Z`,
bonusCharacterIds: [characterId],
challengeSongBonusCardIds: { [songId]: [summerFeaturedCards[index]] }
}))
} }
] as const satisfies readonly EventDefinition[]; ] as const satisfies readonly EventDefinition[];
+34
View File
@@ -5,6 +5,40 @@ import { activeEventAt, compareEventRankings, eventBonusBreakdown, eventChalleng
const currentEvent = events[0]; const currentEvent = events[0];
test('솔로 이벤트는 챕터 경계에서 대상 멤버와 과제곡을 함께 전환한다', () => {
assert.equal(activeEventAt(events, Date.parse('2026-09-19T10:59:59.999Z')), null);
const expected = [
[19, 'chr-00004', 'm0349'], [21, 'chr-04013', 'm0351'],
[23, 'chr-00035', 'm0350'], [25, 'chr-03005', 'm0352']
] as const;
for (const [day, characterId, songId] of expected) {
const start = Date.parse(`2026-09-${day}T11:00:00.000Z`);
const chapter = activeEventAt(events, start)!;
assert.deepEqual(chapter.bonusCharacterIds, [characterId]);
assert.deepEqual(Object.keys(chapter.challengeSongBonusCardIds), [songId]);
assert.equal(activeEventAt(events, start + 48 * 60 * 60 * 1000 - 1)?.id, chapter.id);
assert.notEqual(activeEventAt(events, start - 1)?.id, chapter.id);
}
assert.equal(activeEventAt(events, Date.parse('2026-09-27T11:00:00.000Z')), null);
});
test('신규 카드 공통 보너스와 현재 챕터 멤버 보너스를 따로 적용한다', () => {
const aki = activeEventAt(events, Date.parse('2026-09-19T11:00:00Z'))!;
const shiori = activeEventAt(events, Date.parse('2026-09-21T11:00:00Z'))!;
const cards = [
{ id: aki.featuredCardIds[0], characterId: 'chr-00004', rarity: 5 },
{ id: 'shiori-regular', characterId: 'chr-04013', rarity: 3 }
];
assert.deepEqual(eventBonusBreakdown(aki, cards, () => 5), {
formationPercent: 50, featuredCardPercent: 50, awakeningPercent: 33, totalPercent: 133
});
assert.equal(eventBonusBreakdown(shiori, [cards[0]], () => 0).totalPercent, 50);
assert.equal(eventBonusBreakdown(shiori, [cards[1]], () => 0).totalPercent, 50);
assert.equal(eventChallengeSongScoreBonusPercent(aki, 'm0349', [cards[0].id]), 10);
assert.equal(eventChallengeSongScoreBonusPercent(shiori, 'm0349', [cards[0].id]), 0);
assert.equal(eventChallengeSongScoreBonusPercent(aki, 'm0349', [aki.featuredCardIds[1]]), 0);
});
test('이벤트 기간 안에서만 진행 중 이벤트를 반환한다', () => { test('이벤트 기간 안에서만 진행 중 이벤트를 반환한다', () => {
assert.equal(activeEventAt(events, Date.parse('2026-08-29T02:59:59.999Z')), null); 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-08-31T00:00:00.000Z'))?.id, currentEvent.id);
+13 -1
View File
@@ -16,6 +16,14 @@ export type EventDefinition = {
featuredCardBonusPercent: number; featuredCardBonusPercent: number;
challengeSongScoreBonusPercent: number; challengeSongScoreBonusPercent: number;
awakeningBonusPercentByRarity: Readonly<Record<number, readonly number[]>>; awakeningBonusPercentByRarity: Readonly<Record<number, readonly number[]>>;
chapters?: readonly {
id: string;
name: string;
startsAt: string;
endsAt: string;
bonusCharacterIds: readonly string[];
challengeSongBonusCardIds: Readonly<Record<string, readonly string[]>>;
}[];
}; };
export type EventBonusBreakdown = { export type EventBonusBreakdown = {
@@ -30,7 +38,11 @@ export function eventOptimizationCardEligible(card: EventCard, bonusCharacterIds
} }
export function activeEventAt(events: readonly EventDefinition[], now: number): EventDefinition | null { 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; const event = events.find((event) => Date.parse(event.startsAt) <= now && now <= Date.parse(event.endsAt));
if (!event) return null;
if (!event.chapters) return event;
const chapter = event.chapters.find((entry) => Date.parse(entry.startsAt) <= now && now <= Date.parse(entry.endsAt));
return chapter ? { ...event, ...chapter, name: `${event.name} · ${chapter.name}` } : null;
} }
export function eventBonusBreakdown( export function eventBonusBreakdown(
+1 -1
View File
@@ -34,7 +34,7 @@
<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="/rating" aria-current={$page.url.pathname.startsWith('/rating') ? 'page' : undefined}>리더 레이팅</a> <a href="/rating" aria-current={$page.url.pathname.startsWith('/rating') ? '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} {#if showEventTab}<a href="/event" aria-current={$page.url.pathname.startsWith('/event') ? 'page' : undefined}>이벤트</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 href="/updates" aria-current={$page.url.pathname.startsWith('/updates') ? 'page' : undefined}>업데이트 내역</a> <a href="/updates" aria-current={$page.url.pathname.startsWith('/updates') ? 'page' : undefined}>업데이트 내역</a>
<a href="/tools" aria-current={$page.url.pathname.startsWith('/tools') || $page.url.pathname === '/random' ? 'page' : undefined}>도구</a> <a href="/tools" aria-current={$page.url.pathname.startsWith('/tools') || $page.url.pathname === '/random' ? 'page' : undefined}>도구</a>
+28 -19
View File
@@ -6,6 +6,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 { activeEventAt } from '$lib/engine/event-optimization.ts';
import { events } from '$lib/data/events.ts'; 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';
@@ -41,6 +42,8 @@
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts'; import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
export let eventMode = false; export let eventMode = false;
export let eventScoreMode = false;
const eventPtMode = eventMode && !eventScoreMode;
type CardRow = (typeof cardSummary.cards)[number]; type CardRow = (typeof cardSummary.cards)[number];
import { difficulties, difficultyLabel, isDifficulty, type DifficultyKey } from '$lib/data/difficulty.ts'; import { difficulties, difficultyLabel, isDifficulty, type DifficultyKey } from '$lib/data/difficulty.ts';
@@ -102,13 +105,13 @@
leaderOutfitCardId: string; leaderOutfitCardId: string;
}; };
const STORAGE_KEY = eventMode ? 'holodori.event-combination.v1' : 'holodori.combination.v1'; const STORAGE_KEY = eventScoreMode ? 'holodori.event-score.v1' : eventMode ? 'holodori.event-combination.v1' : 'holodori.combination.v1';
const PRESET_STORAGE_KEY = eventMode ? 'holodori.event-combination.presets.v1' : 'holodori.combination.presets.v1'; const PRESET_STORAGE_KEY = eventScoreMode ? 'holodori.event-score.presets.v1' : 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 allCards = cardSummary.cards; const allCards = cardSummary.cards;
const fiveStarCards = allCards.filter((card) => card.rarity === 5); const fiveStarCards = allCards.filter((card) => card.rarity === 5);
const eventBonusCharacters = new Set<string>(events.flatMap((event) => [...event.bonusCharacterIds])); const eventBonusCharacters = new Set<string>(events.flatMap((event) => [...event.bonusCharacterIds, ...('chapters' in event ? event.chapters.flatMap((chapter) => chapter.bonusCharacterIds) : [])]));
const eligibleCards = eventMode const eligibleCards = eventMode
? allCards.filter((card) => eventOptimizationCardEligible(card, eventBonusCharacters)) ? allCards.filter((card) => eventOptimizationCardEligible(card, eventBonusCharacters))
: fiveStarCards; : fiveStarCards;
@@ -132,7 +135,7 @@
let boardCards: BoardCards = {}; let boardCards: BoardCards = {};
let memoryCount = 0; let memoryCount = 0;
let memberPowerUpPercent = 0; let memberPowerUpPercent = 0;
let selectedSongId = songs[0]?.id ?? ''; let selectedSongId = (eventMode ? Object.keys(activeEventAt(events, releaseNow)?.challengeSongBonusCardIds ?? {}).find((id) => songs.some((song) => song.id === id)) : undefined) ?? songs[0]?.id ?? '';
let difficulty: DifficultyKey = 'expert'; let difficulty: DifficultyKey = 'expert';
let fixedCardIds = ['', '', '', '']; let fixedCardIds = ['', '', '', ''];
let candidateAwakening = eventMode ? 0 : 5; let candidateAwakening = eventMode ? 0 : 5;
@@ -251,7 +254,7 @@
$: charts = releasedSongCharts(allCharts, releaseNow); $: charts = releasedSongCharts(allCharts, releaseNow);
$: currentEvent = eventMode $: currentEvent = eventMode
? events.find((event) => Date.parse(event.startsAt) <= releaseNow && releaseNow <= Date.parse(event.endsAt)) ?? null ? activeEventAt(events, releaseNow)
: 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 ?? '';
@@ -607,7 +610,7 @@
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);
const score = statTotal * (1 + activeValue / 100 + specialValue / 500); const score = statTotal * (1 + activeValue / 100 + specialValue / 500);
if (!currentEvent) return score; if (!currentEvent || !eventPtMode) return score;
const bonus = eventBonusBreakdown(currentEvent, [card], (candidate) => awakeningForCard(candidate as CardRow)); const bonus = eventBonusBreakdown(currentEvent, [card], (candidate) => awakeningForCard(candidate as CardRow));
return score * (1 + bonus.totalPercent / 100); return score * (1 + bonus.totalPercent / 100);
} }
@@ -645,24 +648,24 @@
if (!currentEvent) return score; if (!currentEvent) return score;
const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow)); const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow));
const songBonus = eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id)); const songBonus = eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id));
return score * (1 + bonus.totalPercent / 100) * (1 + songBonus / 100); return score * (eventPtMode ? 1 + bonus.totalPercent / 100 : 1) * (1 + songBonus / 100);
} }
function roughEventBonusPriority(cards: readonly CardRow[]): number { function roughEventBonusPriority(cards: readonly CardRow[]): number {
if (!currentEvent) return roughCombinationScore(cards); if (!currentEvent || !eventPtMode) return roughCombinationScore(cards);
const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow)); const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow));
// 최대 보너스 편성이 순수 스코어 후보에 밀려 선별 단계에서 사라지지 않도록 별도 층을 보존한다. // 최대 보너스 편성이 순수 스코어 후보에 밀려 선별 단계에서 사라지지 않도록 별도 층을 보존한다.
return bonus.totalPercent * 1_000_000_000_000 + roughCombinationScore(cards); return bonus.totalPercent * 1_000_000_000_000 + roughCombinationScore(cards);
} }
function rankingValue(ranking: RankedResult): number { function rankingValue(ranking: RankedResult): number {
return eventMode return eventPtMode
? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0) ? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0)
: ranking.result.theoreticalMaxScore; : ranking.result.theoreticalMaxScore;
} }
function compareRankedResults(left: RankedResult, right: RankedResult): number { function compareRankedResults(left: RankedResult, right: RankedResult): number {
if (eventMode) return compareEventRankings(left, right); if (eventPtMode) return compareEventRankings(left, right);
return rankingValue(right) - rankingValue(left) || return rankingValue(right) - rankingValue(left) ||
right.result.expectedScore - left.result.expectedScore || right.result.expectedScore - left.result.expectedScore ||
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore; right.result.theoreticalMaxScore - left.result.theoreticalMaxScore;
@@ -879,7 +882,7 @@
? eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id)) ? eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id))
: 0; : 0;
const directSongScoreBonusPercent = boardEffects.directSongScoreBonusPercent + eventSongScoreBonus; const directSongScoreBonusPercent = boardEffects.directSongScoreBonusPercent + eventSongScoreBonus;
const rankingMultiplier = eventBonus ? 1 + eventBonus.totalPercent / 100 : undefined; const rankingMultiplier = eventPtMode && eventBonus ? 1 + eventBonus.totalPercent / 100 : undefined;
return { return {
cards: optimizerCards, cards: optimizerCards,
overallPower: power.overallPower, overallPower: power.overallPower,
@@ -887,7 +890,7 @@
directSongScoreBonusPercent, directSongScoreBonusPercent,
heuristic: power.overallPower * (1 + skillPotential / 500) * (rankingMultiplier ?? 1) * (1 + eventSongScoreBonus / 100), heuristic: power.overallPower * (1 + skillPotential / 500) * (rankingMultiplier ?? 1) * (1 + eventSongScoreBonus / 100),
skillPotential, skillPotential,
optimizationObjective: eventMode ? 'expected' : 'maximum', optimizationObjective: eventPtMode ? 'expected' : 'maximum',
...(eventBonus ? { ...(eventBonus ? {
eventBonusPercent: eventBonus.totalPercent, eventBonusPercent: eventBonus.totalPercent,
rankingMultiplier rankingMultiplier
@@ -1219,18 +1222,22 @@
} }
</script> </script>
<svelte:head><title>{eventMode ? '이벤트 Pt 최적화' : '조합 탐색'} · 홀로도리 계산기</title></svelte:head> <svelte:head><title>{eventScoreMode ? '이벤트 점수 탐색' : eventMode ? '이벤트 PT 비교' : '조합 탐색'} · 홀로도리 계산기</title></svelte:head>
<main> <main>
<header> <header>
<h1>{eventMode ? '이벤트 Pt 최적화' : '조합 탐색'}</h1> <h1>{eventScoreMode ? '이벤트 점수 탐색' : eventMode ? '이벤트 PT 비교' : '조합 탐색'}</h1>
<p class="page-description">{eventMode ? '평균 점수에 편성·신규 카드·개화 보너스를 반영한 평균 이벤트 Pt 지수가 높은 순서로 조합을 추천합니다. 스페셜 순서도 평균 점수를 기준으로 최적화합니다.' : '악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.'}</p> <p class="page-description">{eventScoreMode ? '현재 챕터의 과제곡 스코어 보너스를 반영해 최고 점수 조합과 스페셜 순서를 찾습니다.' : eventMode ? '평균 점수에 편성·신규 카드·개화 보너스를 반영한 평균 이벤트 Pt 지수가 높은 순서로 조합을 추천합니다. 스페셜 순서도 평균 점수를 기준으로 최적화합니다.' : '악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.'}</p>
{#if eventMode && currentEvent} {#if eventMode && currentEvent}
<div class="event-summary" role="status"> <div class="event-summary" role="status">
<strong>{currentEvent.name}</strong> <strong>{currentEvent.name}</strong>
<span>{new Date(currentEvent.startsAt).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })} ~ {new Date(currentEvent.endsAt).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })} (한국 시간)</span>
<span>현재 대상: {currentEvent.bonusCharacterIds.map((id) => leaderMembers.find((card) => card.characterId === id)?.name ?? id).join(', ')}</span>
<span>과제곡: {Object.keys(currentEvent.challengeSongBonusCardIds).map((id) => songs.find((song) => song.id === id)?.title ?? '채보 준비 중').join(', ')}</span>
<span>대상 멤버 1명당 +{currentEvent.formationBonusPercent}% · 신규 카드 추가 +{currentEvent.featuredCardBonusPercent}% · 과제곡 대응 카드 스코어 +{currentEvent.challengeSongScoreBonusPercent}%</span> <span>대상 멤버 1명당 +{currentEvent.formationBonusPercent}% · 신규 카드 추가 +{currentEvent.featuredCardBonusPercent}% · 과제곡 대응 카드 스코어 +{currentEvent.challengeSongScoreBonusPercent}%</span>
</div> </div>
{/if} {/if}
{#if eventPtMode}<p class="event-point-guide">PT 비교 지수는 평균 점수 × 획득 보너스 배율입니다. 실제 획득 PT 공식의 기본점·곡 길이·부스트·패스 배율은 포함하지 않으며, 같은 악곡과 난이도에서만 비교하세요.</p>{/if}
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'} {#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p> <p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
{/if} {/if}
@@ -1402,9 +1409,11 @@
</div> </div>
{/if} {/if}
<span class="rank-metric"> <span class="rank-metric">
<small>{eventMode ? '평균 이벤트 Pt 지수' : '최고 점수'}</small> <small>{eventPtMode ? 'PT 비교 지수' : eventScoreMode ? '이벤트 최고 점수' : '최고 점수'}</small>
<strong>{eventMode ? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0).toLocaleString('ko-KR') : ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong> <strong>{eventPtMode ? 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 eventMode}<i>PT 획득 보너스 +{ranking.eventBonusPercent ?? 0}% · 평균 점수 {ranking.result.expectedScore.toLocaleString('ko-KR')}</i>
{#if currentEvent}<i>과제곡 스코어 +{eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, ranking.result.order)}%</i>{/if}
{/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}
{#if eventMode}<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b> {#if eventMode}<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
{:else}<small class="expected-score">예상 {ranking.result.expectedScore.toLocaleString('ko-KR')}</small>{/if} {:else}<small class="expected-score">예상 {ranking.result.expectedScore.toLocaleString('ko-KR')}</small>{/if}
@@ -1433,7 +1442,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} {#if eventPtMode}<p class="event-point-guide">이 수치는 실제 획득 PT가 아닌 동일 악곡·난이도에서의 비교 지수입니다. 실제 PT 공식의 기본점, 곡 길이, 부스트, 패스 배율은 포함하지 않습니다. 순위는 최고점이 아닌 평균 이벤트 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>
+13 -2
View File
@@ -8,6 +8,7 @@
let clock = createInitialReleaseClock($page.data?.serverNow); let clock = createInitialReleaseClock($page.data?.serverNow);
let now = clock.now(); let now = clock.now();
let eventScoreMode = false;
$: activeEvent = activeEventAt(events, now); $: activeEvent = activeEventAt(events, now);
onMount(() => { onMount(() => {
@@ -25,10 +26,16 @@
}); });
</script> </script>
<svelte:head><title>이벤트 Pt 최적화 · 홀로도리 계산기</title></svelte:head> <svelte:head><title>이벤트 PT·점수 · 홀로도리 계산기</title></svelte:head>
{#if activeEvent} {#if activeEvent}
<CombinationPage eventMode /> <nav class="event-objective" aria-label="이벤트 계산 목적">
<button class:active={!eventScoreMode} aria-pressed={!eventScoreMode} on:click={() => eventScoreMode = false}>PT</button>
<button class:active={eventScoreMode} aria-pressed={eventScoreMode} on:click={() => eventScoreMode = true}>스코어</button>
</nav>
{#key activeEvent.id + ':' + eventScoreMode}
<CombinationPage eventMode {eventScoreMode} />
{/key}
{:else} {:else}
<main class="inactive-event"> <main class="inactive-event">
<h1>진행 중인 이벤트가 없습니다</h1> <h1>진행 중인 이벤트가 없습니다</h1>
@@ -37,6 +44,10 @@
{/if} {/if}
<style> <style>
.event-objective { display: flex; gap: 8px; width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 20px auto 0; }
button { padding: 10px 16px; border: 1px solid #604653; border-radius: 8px; background: #261d23; color: #c9b4bd; cursor: pointer; }
button.active { background: #573448; color: #fff; border-color: #f2a6c6; }
.inactive-event { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: var(--page-content-top) 0 80px; } .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; } 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; } p { margin: 12px 0 0; color: #929cab; line-height: 1.7; }