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
+36
View File
@@ -7,6 +7,17 @@ const gamersFeaturedCards = [
'card-00017-5-uniq-0077-00'
] 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 = [
{
// 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],
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[];
+34
View File
@@ -5,6 +5,40 @@ import { activeEventAt, compareEventRankings, eventBonusBreakdown, eventChalleng
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('이벤트 기간 안에서만 진행 중 이벤트를 반환한다', () => {
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);
+13 -1
View File
@@ -16,6 +16,14 @@ export type EventDefinition = {
featuredCardBonusPercent: number;
challengeSongScoreBonusPercent: 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 = {
@@ -30,7 +38,11 @@ export function eventOptimizationCardEligible(card: EventCard, bonusCharacterIds
}
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(
+1 -1
View File
@@ -34,7 +34,7 @@
<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="/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="/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>
+28 -19
View File
@@ -6,6 +6,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 { activeEventAt } from '$lib/engine/event-optimization.ts';
import { events } from '$lib/data/events.ts';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
@@ -41,6 +42,8 @@
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
export let eventMode = false;
export let eventScoreMode = false;
const eventPtMode = eventMode && !eventScoreMode;
type CardRow = (typeof cardSummary.cards)[number];
import { difficulties, difficultyLabel, isDifficulty, type DifficultyKey } from '$lib/data/difficulty.ts';
@@ -102,13 +105,13 @@
leaderOutfitCardId: string;
};
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 STORAGE_KEY = eventScoreMode ? 'holodori.event-score.v1' : eventMode ? 'holodori.event-combination.v1' : 'holodori.combination.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 REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const allCards = cardSummary.cards;
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
? allCards.filter((card) => eventOptimizationCardEligible(card, eventBonusCharacters))
: fiveStarCards;
@@ -132,7 +135,7 @@
let boardCards: BoardCards = {};
let memoryCount = 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 fixedCardIds = ['', '', '', ''];
let candidateAwakening = eventMode ? 0 : 5;
@@ -251,7 +254,7 @@
$: charts = releasedSongCharts(allCharts, releaseNow);
$: currentEvent = eventMode
? events.find((event) => Date.parse(event.startsAt) <= releaseNow && releaseNow <= Date.parse(event.endsAt)) ?? null
? activeEventAt(events, releaseNow)
: 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 ?? '';
@@ -607,7 +610,7 @@
const statTotal = Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0);
const { activeValue, specialValue } = roughSkillValues(card);
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));
return score * (1 + bonus.totalPercent / 100);
}
@@ -645,24 +648,24 @@
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);
return score * (eventPtMode ? 1 + bonus.totalPercent / 100 : 1) * (1 + songBonus / 100);
}
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));
// 최대 보너스 편성이 순수 스코어 후보에 밀려 선별 단계에서 사라지지 않도록 별도 층을 보존한다.
return bonus.totalPercent * 1_000_000_000_000 + roughCombinationScore(cards);
}
function rankingValue(ranking: RankedResult): number {
return eventMode
return eventPtMode
? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0)
: ranking.result.theoreticalMaxScore;
}
function compareRankedResults(left: RankedResult, right: RankedResult): number {
if (eventMode) return compareEventRankings(left, right);
if (eventPtMode) return compareEventRankings(left, right);
return rankingValue(right) - rankingValue(left) ||
right.result.expectedScore - left.result.expectedScore ||
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore;
@@ -879,7 +882,7 @@
? eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id))
: 0;
const directSongScoreBonusPercent = boardEffects.directSongScoreBonusPercent + eventSongScoreBonus;
const rankingMultiplier = eventBonus ? 1 + eventBonus.totalPercent / 100 : undefined;
const rankingMultiplier = eventPtMode && eventBonus ? 1 + eventBonus.totalPercent / 100 : undefined;
return {
cards: optimizerCards,
overallPower: power.overallPower,
@@ -887,7 +890,7 @@
directSongScoreBonusPercent,
heuristic: power.overallPower * (1 + skillPotential / 500) * (rankingMultiplier ?? 1) * (1 + eventSongScoreBonus / 100),
skillPotential,
optimizationObjective: eventMode ? 'expected' : 'maximum',
optimizationObjective: eventPtMode ? 'expected' : 'maximum',
...(eventBonus ? {
eventBonusPercent: eventBonus.totalPercent,
rankingMultiplier
@@ -1219,18 +1222,22 @@
}
</script>
<svelte:head><title>{eventMode ? '이벤트 Pt 최적화' : '조합 탐색'} · 홀로도리 계산기</title></svelte:head>
<svelte:head><title>{eventScoreMode ? '이벤트 점수 탐색' : eventMode ? '이벤트 PT 비교' : '조합 탐색'} · 홀로도리 계산기</title></svelte:head>
<main>
<header>
<h1>{eventMode ? '이벤트 Pt 최적화' : '조합 탐색'}</h1>
<p class="page-description">{eventMode ? '평균 점수에 편성·신규 카드·개화 보너스를 반영한 평균 이벤트 Pt 지수가 높은 순서로 조합을 추천합니다. 스페셜 순서도 평균 점수를 기준으로 최적화합니다.' : '악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.'}</p>
<h1>{eventScoreMode ? '이벤트 점수 탐색' : eventMode ? '이벤트 PT 비교' : '조합 탐색'}</h1>
<p class="page-description">{eventScoreMode ? '현재 챕터의 과제곡 스코어 보너스를 반영해 최고 점수 조합과 스페셜 순서를 찾습니다.' : eventMode ? '평균 점수에 편성·신규 카드·개화 보너스를 반영한 평균 이벤트 Pt 지수가 높은 순서로 조합을 추천합니다. 스페셜 순서도 평균 점수를 기준으로 최적화합니다.' : '악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.'}</p>
{#if eventMode && currentEvent}
<div class="event-summary" role="status">
<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>
</div>
{/if}
{#if eventPtMode}<p class="event-point-guide">PT 비교 지수는 평균 점수 × 획득 보너스 배율입니다. 실제 획득 PT 공식의 기본점·곡 길이·부스트·패스 배율은 포함하지 않으며, 같은 악곡과 난이도에서만 비교하세요.</p>{/if}
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
{/if}
@@ -1402,9 +1409,11 @@
</div>
{/if}
<span class="rank-metric">
<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}
<small>{eventPtMode ? 'PT 비교 지수' : eventScoreMode ? '이벤트 최고 점수' : '최고 점수'}</small>
<strong>{eventPtMode ? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0).toLocaleString('ko-KR') : ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>
{#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 eventMode}<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
{:else}<small class="expected-score">예상 {ranking.result.expectedScore.toLocaleString('ko-KR')}</small>{/if}
@@ -1433,7 +1442,7 @@
{/each}
</div>
<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>
{:else}
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
+13 -2
View File
@@ -8,6 +8,7 @@
let clock = createInitialReleaseClock($page.data?.serverNow);
let now = clock.now();
let eventScoreMode = false;
$: activeEvent = activeEventAt(events, now);
onMount(() => {
@@ -25,10 +26,16 @@
});
</script>
<svelte:head><title>이벤트 Pt 최적화 · 홀로도리 계산기</title></svelte:head>
<svelte:head><title>이벤트 PT·점수 · 홀로도리 계산기</title></svelte:head>
{#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}
<main class="inactive-event">
<h1>진행 중인 이벤트가 없습니다</h1>
@@ -37,6 +44,10 @@
{/if}
<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; }
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; }