feat: apply captured API configuration for Blue Splash event
Publish GitHub Pages output / publish (push) Successful in 37s

This commit is contained in:
2026-09-29 23:43:01 +09:00
parent 049e37bcff
commit 37102923ac
15 changed files with 3287 additions and 16 deletions
+72
View File
@@ -0,0 +1,72 @@
{
"source": {
"file": "data/game-events/2026-09-29.json",
"sha256": "dbb57a74aa28f1c5684c5dc6f2d45788e8e608011e72aef65db238b148788407",
"capturedAt": "2026-09-29T14:25:20.484782+00:00",
"masterDataVersion": "546b8e2e37db368c6731e148b88611cef105bbaa66ff567f5c1584711ef979a4"
},
"events": [
{
"id": "marathon_event-normal-005",
"name": "두근두근 블루 스플래시!",
"startsAt": "2026-09-29T03:00:00.000Z",
"endsAt": "2026-10-05T10:59:59.000Z",
"exchangeEndsAt": "2026-10-11T10:59:59.000Z",
"bonusCharacterIds": [
"chr-00023",
"chr-00037",
"chr-03009",
"chr-04012"
],
"featuredCardIds": [
"card-00023-5-uniq-0085-00",
"card-00037-5-uniq-0086-00",
"card-03009-5-uniq-0088-00",
"card-04012-5-uniq-0087-00"
],
"challengeSongBonusCardIds": {
"m0548": [
"card-00023-5-uniq-0085-00"
],
"m0413": [
"card-00037-5-uniq-0086-00"
],
"m0358": [
"card-03009-5-uniq-0088-00"
],
"m0357": [
"card-04012-5-uniq-0087-00"
]
},
"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
]
}
}
]
}
+7 -5
View File
@@ -1,4 +1,5 @@
import type { EventDefinition } from '$lib/engine/event-optimization.ts';
import apiEvents from './events-api.json' with { type: 'json' };
const gamersFeaturedCards = [
'card-00006-5-uniq-0074-00',
@@ -18,10 +19,10 @@ const summerChapters = [
['유능 감독, 성지순례에 나서다', 'chr-03005', 'm0352', 25, 27]
] as const;
export const events = [
export const events: readonly EventDefinition[] = [
{
// 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.
// Historical manual record, preserved with its original provenance.
// New event schedules and rates are projected from captured game API responses below.
id: 'marathon_event-normal-003',
name: '무인도 잭! 한여름의 서바이벌 데이즈',
startsAt: '2026-08-29T03:00:00.000Z',
@@ -68,5 +69,6 @@ export const events = [
bonusCharacterIds: [characterId],
challengeSongBonusCardIds: { [songId]: [summerFeaturedCards[index]] }
}))
}
] as const satisfies readonly EventDefinition[];
},
...apiEvents.events
];
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { events } from '../data/events.ts';
import { activeEventAt, eventBonusBreakdown, eventChallengeSongScoreBonusPercent, eventOptimizationCardCandidates } from './event-optimization.ts';
const readJson = (relative: string) => JSON.parse(readFileSync(new URL(relative, import.meta.url), 'utf8'));
const pointer = readJson('../../../data/game-events/current.json');
const rawBytes = readFileSync(new URL(`../../../data/game-events/${pointer.file}`, import.meta.url));
const snapshot = JSON.parse(rawBytes.toString('utf8'));
const raw = snapshot.events.find((event: { eventId: string }) => event.eventId === 'marathon_event-normal-005');
const chapter = raw.marathonInfo.marathonChapters[0];
const current = events.find(event => event.id === raw.eventId)!;
const cards = readJson('../data/cards-summary.json').cards as { id: string; characterId: string; rarity: number }[];
test('이벤트 원본 해시와 생성된 일정이 확보한 API 설정과 일치한다', () => {
const generated = readJson('../data/events-api.json');
assert.equal(createHash('sha256').update(rawBytes).digest('hex'), pointer.sha256);
assert.equal(generated.source.sha256, pointer.sha256);
assert.equal(current.name, '두근두근 블루 스플래시!');
assert.equal(Date.parse(current.startsAt), raw.startTime);
assert.equal(Date.parse(current.endsAt), raw.endTime);
assert.equal(Date.parse(current.exchangeEndsAt!), raw.exchangeEndTime);
assert.equal(activeEventAt(events, raw.startTime - 1), null);
assert.equal(activeEventAt(events, raw.startTime)?.id, raw.eventId);
assert.equal(activeEventAt(events, raw.endTime)?.id, raw.eventId);
assert.equal(activeEventAt(events, raw.endTime + 1), null);
assert.equal(activeEventAt(events, raw.exchangeEndTime), null);
});
test('전체 카드와 개화 0~5의 라이브 보너스가 API 조건별 합계와 일치한다', () => {
for (const card of cards) {
for (let awakening = 0; awakening <= 5; awakening++) {
const expected = chapter.marathonLiveMarathonScoreBonuses
.filter((rule: { characterId: string; cardId: string; cardRarity: number; cardPotentialUpgradeCount: number; cardAttributeType: number }) => {
assert.equal(rule.cardAttributeType, 0);
return (!rule.characterId || rule.characterId === card.characterId)
&& (!rule.cardId || rule.cardId === card.id)
&& (!rule.cardRarity || rule.cardRarity === card.rarity)
&& (!rule.cardPotentialUpgradeCount || rule.cardPotentialUpgradeCount === awakening);
})
.reduce((sum: number, rule: { marathonScoreQuantityUpPermilUp: number }) => sum + rule.marathonScoreQuantityUpPermilUp, 0) / 10;
assert.equal(eventBonusBreakdown(current, [card], () => awakening).totalPercent, expected, `${card.id}, awakening ${awakening}`);
}
}
const featured = cards.filter(card => current.featuredCardIds.includes(card.id));
assert.equal(featured.length, 4);
assert.equal(eventBonusBreakdown(current, featured, () => 0).totalPercent, 240);
assert.equal(eventBonusBreakdown(current, featured, () => 5).totalPercent, 360);
});
test('네 과제곡의 대응 카드만 스코어 보너스를 받고 PARADISE에는 적용하지 않는다', () => {
assert.deepEqual(Object.keys(current.challengeSongBonusCardIds).sort(), ['m0357', 'm0358', 'm0413', 'm0548']);
for (const songId of [...Object.keys(current.challengeSongBonusCardIds), 'm0354', 'm0349']) {
for (const card of cards) {
const expected = chapter.marathonMusicScoreBonuses
.filter((rule: { musicId: string; cardId: string }) => rule.musicId === songId && rule.cardId === card.id)
.reduce((sum: number, rule: { scoreUpPermilUp: number }) => sum + rule.scoreUpPermilUp, 0) / 10;
assert.equal(eventChallengeSongScoreBonusPercent(current, songId, [card.id]), expected, `${songId}, ${card.id}`);
}
}
});
test('현재 이벤트 후보는 신규 대상 멤버로 바뀌고 과거 이벤트의 저등급 카드는 제외한다', () => {
const sample = [
{ id: 'marine-three', characterId: 'chr-00023', rarity: 3 },
{ id: 'aki-three', characterId: 'chr-00004', rarity: 3 },
{ id: 'other-four', characterId: 'other', rarity: 4 },
{ id: 'other-five', characterId: 'other', rarity: 5 }
];
assert.deepEqual(eventOptimizationCardCandidates(sample, current).map(card => card.id), ['marine-three', 'other-four', 'other-five']);
const previous = activeEventAt(events, Date.parse('2026-09-19T11:00:00Z'))!;
assert.deepEqual(eventOptimizationCardCandidates(sample, previous).map(card => card.id), ['aki-three', 'other-four', 'other-five']);
});
+10
View File
@@ -9,6 +9,7 @@ export type EventDefinition = {
name: string;
startsAt: string;
endsAt: string;
exchangeEndsAt?: string;
bonusCharacterIds: readonly string[];
featuredCardIds: readonly string[];
challengeSongBonusCardIds: Readonly<Record<string, readonly string[]>>;
@@ -37,6 +38,15 @@ export function eventOptimizationCardEligible(card: EventCard, bonusCharacterIds
return card.rarity === 5 || card.rarity === 4 || bonusCharacterIds.has(card.characterId);
}
/** Returns the event-search pool for the active event or relay chapter. */
export function eventOptimizationCardCandidates<T extends EventCard>(
cards: readonly T[],
event: Pick<EventDefinition, 'bonusCharacterIds'> | null
): T[] {
const bonusCharacterIds = new Set(event?.bonusCharacterIds ?? []);
return cards.filter((card) => eventOptimizationCardEligible(card, bonusCharacterIds));
}
export function activeEventAt(events: readonly EventDefinition[], now: number): EventDefinition | null {
const event = events.find((event) => Date.parse(event.startsAt) <= now && now <= Date.parse(event.endsAt));
if (!event) return null;
+7 -6
View File
@@ -26,7 +26,7 @@
effectiveActiveProbability,
eventBonusBreakdown,
eventChallengeSongScoreBonusPercent,
eventOptimizationCardEligible,
eventOptimizationCardCandidates,
eventPointIndex,
generateAnchoredCombinationCandidates,
generateCombinationCandidates,
@@ -111,10 +111,7 @@
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, ...('chapters' in event ? event.chapters.flatMap((chapter) => chapter.bonusCharacterIds) : [])]));
const eligibleCards = eventMode
? allCards.filter((card) => eventOptimizationCardEligible(card, eventBonusCharacters))
: fiveStarCards;
let eligibleCards = fiveStarCards;
const cardById = new Map(allCards.map((card) => [card.id, card]));
const allCharts = exactChartCatalog.charts;
const initialReleaseClock = createInitialReleaseClock($page.data?.serverNow);
@@ -256,6 +253,9 @@
$: currentEvent = eventMode
? activeEventAt(events, releaseNow)
: null;
$: eligibleCards = eventMode
? eventOptimizationCardCandidates(allCards, currentEvent)
: fiveStarCards;
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
$: if (!songs.some((song) => song.id === selectedSongId)) selectedSongId = songs[0]?.id ?? '';
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
@@ -1237,6 +1237,7 @@
<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>
{#if currentEvent.exchangeEndsAt}<span>교환소 종료: {new Date(currentEvent.exchangeEndsAt).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })} (한국 시간)</span>{/if}
<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>
@@ -1276,7 +1277,7 @@
{/if}
<section class="panel" aria-labelledby="conditions-title">
<fieldset class="search-inputs" disabled={running}>
<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="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? (eventMode ? '보유 이벤트 후보 대상' : '보유 5성 대상') : (eventMode ? '4·5성 + 대상 멤버 전 레어리티' : '5성 전체 대상')}</strong></div>
<div class="song-fields">
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
<label>난이도<select bind:value={difficulty} on:change={() => result = null}>{#each difficulties as value}<option {value}>{difficultyLabel(value)}</option>{/each}</select></label>