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;