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
+4
View File
@@ -27,3 +27,7 @@ npm-debug.log*
!data/game-db/snapshots/**
data/game-db/derived/
data/game-db/review/
!data/game-events/
data/game-events/*
!data/game-events/*.json
+1 -1
View File
@@ -187,7 +187,7 @@ npm run data:apply
검토 결과는 `data/game-db/review/latest.json`과 `latest.md`에 저장합니다. 원본은 `data/game-db/snapshots/`, 계산기용 중간 결과는 `data/game-db/derived/`에 보관합니다. 해시가 다른 스냅샷이나 누락된 테이블을 외부 자료로 대체하지 않습니다.
채보 본문은 별도 게임 SUS 파일이며, 과거 이벤트 일정·배율은 DB에 없는 수동 기록입니다. DB 등록과 채보 갱신 방법 및 남은 예외는 [데이터 원본 안내](docs/game-data-source.md)를 참고하세요.
채보 본문은 별도 게임 SUS 파일입니다. 신규 이벤트 일정·배율은 검증한 게임 API 응답에서 생성하며, 과거 수동 이벤트 기록은 기존 출처를 유지합니다. DB 등록과 채보 갱신 방법 및 남은 예외는 [데이터 원본 안내](docs/game-data-source.md)를 참고하세요.
특별 버전명이나 번역 이름은 `data/card-overrides.csv`에서 관리합니다.
+6
View File
@@ -4,6 +4,12 @@ updatedAt: 2026-09-29T00:00:00Z
## 2026년 9월 29일
### 두근두근 블루 스플래시! 이벤트
`NEW` 9월 29일 12시부터 10월 5일 19시 59분 59초까지 진행되는 이벤트의 PT·스코어 계산을 지원합니다. 마린·코요리·코보·벨즈의 멤버·신규 카드·개화 보너스와 Kyapi, BAKU LOVE CHEMISTRY, PROPOSE, Play Dice!의 대응 카드 스코어 보너스를 반영했습니다.
`FIX` 이벤트 탐색의 저등급 후보 카드를 현재 이벤트의 보너스 멤버 기준으로 선택합니다.
### 채보에서 분석 점수 확인
`NEW` 배치 분석 결과에 레인·노트 너비·플릭·롱노트 연결을 표시하는 채보를 추가했습니다. 채보를 가리키거나 터치하면 해당 시각의 누적 평균 점수·최고 점수·최고점 유지 확률을 확인할 수 있습니다. 액티브 미발동 설정을 반영하며, 시간 확대와 스페셜 구간 이동을 지원합니다.
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
{
"file": "2026-09-29.json",
"sha256": "dbb57a74aa28f1c5684c5dc6f2d45788e8e608011e72aef65db238b148788407",
"eventIds": ["marathon_event-normal-005"]
}
+9 -1
View File
@@ -25,7 +25,7 @@
| --- | --- |
| 카드·스킬·보드·메모리·업적·한국어 문자열·악곡 메타데이터 | 현재 게임 DB. 영어·일본어 외부 번역을 가져오지 않음 |
| 채보 본문·레인·노트 시각·점수 분모 | 게임 SUS 파일. DB의 난이도·노트 수·계수와 교차 검증. 외부 계산기 채보나 점수 참고표를 생성 입력으로 사용하지 않음 |
| 과거 이벤트 기간·배율 | `src/lib/data/events.ts`의 기존 수동 기록. 당시 외부 출처 주석을 유지. 현재 DB에서 이벤트별 설정을 찾지 못함. 앱의 EventListEventInfoResponse에 일정·특효 필드가 존재하나 실제 응답은 아직 미수집 |
| 과거 이벤트 기간·배율 | `src/lib/data/events.ts`의 기존 수동 기록. 당시 외부 출처 주석을 유지. 현재 DB에서 이벤트별 설정을 찾지 못함. 기존 기록의 기간·배율은 그대로 보존 |
| 카드 별칭·표시명 | `data/card-overrides.csv`의 사용자 편집 설정 |
이번 전환에서 기존 200곡/800채보를 로컬 게임 파일로 다시 생성하고, 공식 게임 CDN의 Octo revision 93에서 신규·누락 9곡의 SUS 36개를 확보했습니다. 현재 공개된 209곡/836채보를 생성했습니다. 아직 공개되지 않은 2곡은 DB 스냅샷에만 보관합니다.
@@ -36,3 +36,11 @@
기존 외부 점수 참고표와 달랐던 m0337(Pretty Guardian Matsulimit Zero)의 분모는 공식 게임 CDN revision 93에서 재확인했습니다. Hard는 544300 → 539500, Expert는 874750 → 884700이며, 같은 노트 수라도 비가시·제어점 가중치가 포함되는 분모는 별도로 검증해야 합니다. 검증용 게임 SUS는 scripts/fixtures/game-sus/에 보관합니다.
이벤트 설정의 DB·CDN 조사 범위와 서버 API 스키마 확인 결과는 [이벤트 설정 원본 조사](research/event-config-source-2026-09-29.md)를 참고합니다. CDN 목록은 조사했지만 모든 번들 내부를 검사한 것은 아닙니다.
## 게임 API 이벤트 반영
신규 이벤트의 직접 원본은 `data/game-events/2026-09-29.json`이다. 실제 앱이 받은 이벤트 응답을 메모리에서 추출·검증하고 계정별 필드를 제외했다. 마스터 DB 및 과거 수동 이벤트 기록과 출처를 구분한다.
`data/game-events/current.json`에서 적용할 이벤트 ID와 원본 파일의 SHA-256을 지정한 뒤 `npm run data:collect-events`를 실행한다. 검증한 일정·라이브 Pt·과제곡 스코어 보너스를 `src/lib/data/events-api.json`으로 생성하며 이벤트명은 DB의 포스터 이름으로 확인한다. 기존 PT·스코어 화면은 이 파일을 읽는다. 지원하지 않는 배율 조건이나 여러 챕터가 있으면 변환을 중단한다.
현재 적용 대상은 `marathon_event-normal-005`이다. 직전 릴레이 이벤트는 수동 기록을 유지하고, 미니게임 환산 계수와 보너스는 API 원본에 보관한다. 이 명령은 네트워크 수집이나 마스터 DB 갱신을 실행하지 않는다.
@@ -0,0 +1,91 @@
# 게임 API 이벤트 일정·배율 확보 (2026-09-29)
앱 1.2.0이 받은 `rpc.api.Event/ListEventInfo` 응답의 디코딩된 객체를 LDPlayer의 실행 중인 테스트 계정 프로세스에서 읽었다. 네트워크 패킷 원본이 아니라, 실제 응답을 담은 protobuf 객체를 필드별로 재구성한 자료다. DB·CDN 파일이나 외부 위키에서 추정한 값이 아니다.
보관한 설정: [data/game-events/2026-09-29.json](../../data/game-events/2026-09-29.json). 신규 스코어 챌린지의 일정·라이브 Pt·과제곡 스코어 보너스를 계산기에 적용했다. 미니게임 설정은 원본에 보관하며 기존 라이브 계산 화면의 범위를 확장하지 않는다.
## 확보 범위와 검증
- 이벤트 설정 5개: 신규 스코어 챌린지, 직전 스포트라이트, 신규 지역 미션, 복귀 미션, 초보자 미션.
- 마라톤 2개·챕터 5개의 일정, 라이브 Pt 보너스, 악곡 스코어 보너스, 미니게임 Pt 보너스·환산 계수 및 랭킹 보상 설정을 확보했다.
- 보너스 행 131개, 미니게임 계수 행 26개를 보관했다. 보너스의 카드·멤버·악곡 참조 64개를 해시 검증된 게임 DB와 대조해 모두 확인했다.
- 앱의 protobuf 정의 12종과 모든 추출 필드의 이름·타입·구조를 대조했다. 누락·추가 필드는 없고 직렬화/역직렬화 왕복 검증을 통과했다.
- 별도의 메모리 재읽기에서 이벤트 5개의 전체 내용이 동일했다. 표시용 캐시에 있던 4개의 기본 정보도 원래 응답 객체와 모두 일치했다. 복귀 미션은 표시 조건 `cd-comeback_user`가 있으며 이 계정의 표시용 목록에는 없었다.
- 이벤트 탭의 신규 이벤트 종료 표시 `2026/10/05 19:59`도 추출값과 일치한다. 이 계정에서는 메인 퀘스트 1장 4화 클리어 전 이벤트 상세가 잠겨 있었지만 서버 응답의 설정 객체는 이미 수신되어 있었다.
- 계정별 `isNew`, `isNoti`, 챕터의 현재 `score`를 공개 설정에서 제외했다. 토큰·인증키·계정 식별자·메모리 주소는 포함하지 않는다. 랭킹 보상은 플레이어 목록이 아닌 공통 보상 규칙이다.
- 공개 JSON SHA-256: `dbb57a74aa28f1c5684c5dc6f2d45788e8e608011e72aef65db238b148788407`.
원본 메모리 추출, 재읽기 및 검증 스크립트는 Git에서 제외된 `data/inspection/android-event-2026-09-29/`에 보관했다. 원본 응답 타입·앱 바이너리·descriptor 해시는 공개 JSON의 `source`에 기록했다. 이 목록은 수신 시점의 설정이며, 과거 모든 이벤트나 향후 서버 변경을 포함한다는 뜻은 아니다.
## 신규 스코어 챌린지
ID: `marathon_event-normal-005`. 아래 시각은 모두 한국 시간(UTC+9)이다.
| 구분 | 서버 값 |
|---|---|
| 시작 | 2026-09-29 12:00:00 |
| 종료 | 2026-10-05 19:59:59 |
| 교환소 종료 | 2026-10-11 19:59:59 |
| 집계 시작 | 2026-10-06 00:00:00 |
| 집계 랭킹 공개 | 2026-10-06 12:00:00 |
챕터는 1개다. `isMarathonScoreRankingDisable`은 `true`이며, 악곡별 최고점과 합산 최고점 랭킹 보상은 별도로 존재한다.
### 라이브 Pt
원본 단위는 퍼밀(‰)이다. `300`은 `+30%`, `100`은 `+10%`다.
- 마린·코요리·코보·벨즈의 카드: 멤버 보너스 각 +30%.
- 이번 신규 수영복 카드 4종: 신규 카드 보너스 각 +30% 추가.
- 따라서 해당 신규 카드 한 장은 미개화 기준 +60%, 5회 개화 기준 +90%다.
- 속성만을 조건으로 하는 추가 보너스는 이번 응답에 없다.
| 등급 | 개화 0 | 개화 1 | 개화 2 | 개화 3 | 개화 4 | 개화 5 |
|---|---:|---:|---:|---:|---:|---:|
| ★3 | 0% | 1% | 1% | 2% | 2% | 3% |
| ★4 | 0% | 6% | 7% | 8% | 9% | 10% |
| ★5 | 0% | 15% | 18% | 22% | 26% | 30% |
개화 보너스는 해당 단계의 값 하나만 적용한다. 앱의 `CardMarathonLiveEventBonusRatioCache.IsBonusApplicable`에서 희귀도와 양수인 개화 횟수를 동등 비교하고, `CalculateBonusRatioPermil`에서 적용되는 규칙을 합산하는 것을 확인했다. 예를 들어 5회 개화에 1~5단계 값을 누적하지 않는다.
### 과제곡 스코어
아래 대응 카드에 각 곡의 스코어 +10% 규칙이 있다. 곡명은 확보된 DB의 `Music.titleLangId → LangMusic_Kor`로 확인했다.
| 악곡 | ID | 대응 신규 카드 |
|---|---|---|
| Kyapi | `m0548` | 마린 `card-00023-5-uniq-0085-00` |
| BAKU LOVE CHEMISTRY | `m0413` | 코요리 `card-00037-5-uniq-0086-00` |
| PROPOSE | `m0358` | 코보 `card-03009-5-uniq-0088-00` |
| Play Dice! | `m0357` | 벨즈 `card-04012-5-uniq-0087-00` |
`PARADISE!`(`m0354`)에 대한 스코어 보너스 규칙은 이 응답에 없다.
### 미니게임
신규 카드 4종 각각에 미니게임 Pt +20% 규칙이 있다. 개화·등급에 따른 미니게임 보너스 행은 없다.
| 미니게임 | `marathonScorePermilMultiply` 원본 | 계수 ÷ 1000 | `bonusPermilUp` |
|---|---:|---:|---:|
| 줄넘기 | 1300 | 1.3 | 0 |
| 메가 서킷 | 600 | 0.6 | 0 |
| 모아 쿠킹 | 44 | 0.044 | 0 |
| 포카장! | 600 | 0.6 | 0 |
| 어둠 속 추격 | 300 | 0.3 | 0 |
| 스플래시 볼 | 2700 | 2.7 | 500 (+50%) |
이 표는 서버가 제공한 환산 계수와 보너스다. 미니게임별 점수/시간의 변환·반올림까지 모두 재현한 최종 Pt 공식이라는 의미는 아니다.
## 직전 스포트라이트 및 기타 일정
`marathon_event-relay-002`는 2026-09-19 20:00:00~09-27 19:59:59, 교환소 종료는 10-03 19:59:59다. 챕터별 대상 멤버는 아키로제·시오리·라플라스·아냐 순서이고 종료 시각은 각각 09-21·23·25·27의 19:59:59다.
챕터 대상 멤버 +50%, 해당 이벤트 신규 카드 4종 각 +50%, 대응 과제곡 +10%, 개화 보너스는 위 표와 동일하다. 미니게임 신규 카드 보너스는 각 +20%이고, 미니게임 추가 +50% 대상은 줄넘기다. 이 이벤트는 스플래시 볼 계수 행이 없다.
| 미션 | 시작 (한국 시간) | 종료 (한국 시간) |
|---|---|---|
| 신규 지역 미션 `area03-release-260929` | 2026-09-29 06:00:00 | 2026-10-26 04:59:59 |
| 복귀 미션 `comeback-260929` | 2026-09-29 11:00:00 | 2026-10-19 04:59:59 |
| 초보자 미션 | 원본의 기본 시작값 2000-01-01 00:00:00 | 미설정 (`0`) |
원본은 Unix 밀리초를 사용한다. 종료값은 초 단위 `...59.000`이며 기존 수동 기록의 `...59.999`로 보정하지 않았다. 표시·개방 조건이 있는 미션은 기간에 들어왔다는 사실만으로 모든 계정에서 사용할 수 있다고 판단하면 안 된다.
@@ -8,6 +8,6 @@
- MarathonInfo.MarathonChapter: end_time, marathon_music_score_bonuses, marathon_mini_game_marathon_score_bonuses, marathon_live_marathon_score_bonuses
- MarathonMusicScoreBonus: music_id, character_id, card_id, card_attribute_type, card_rarity, card_potential_upgrade_count, score_up_permil_up
- MarathonLiveMarathonScoreBonus: character_id, card_id, card_attribute_type, card_rarity, card_potential_upgrade_count, marathon_score_quantity_up_permil_up
- 따라서 앱이 이벤트별 일정과 보너스를 서버 API 응답으로 전달받을 수 있는 구조는 확인했다. 실제 응답의 현재 값은 아직 수집하지 않았다. CDN/DB 부재를 이유로 외부 위키가 필수라고 볼 근거는 없다.
- 이후 실행 중인 앱의 실제 응답 객체에서 이벤트 설정 5개와 마라톤 챕터 5개의 일정·배율을 확보했다. protobuf 필드 대조 및 별도 메모리 재읽기로 검증했다. [확보 결과와 배율](event-api-capture-2026-09-29.md), [공개 설정 JSON](../../data/game-events/2026-09-29.json)을 참조한다. 이벤트 일정·배율의 직접 원본은 게임 API 응답이며, 마스터 DB와 구분한다.
로컬 검증 기록: data/inspection/android-master-2026-09-29/cdn-event-catalog.json 및 event-proto-schema.json. 계정 인증 정보는 사용하지 않았다.
DB/CDN 조사 기록: data/inspection/android-master-2026-09-29/cdn-event-catalog.json 및 event-proto-schema.json. API 메모리 추출·검증 기록: data/inspection/android-event-2026-09-29/. 사용자가 허용한 테스트 계정의 실행 상태에서 읽었으며 인증 정보를 공개 파일에 포함하지 않았다.
+2 -1
View File
@@ -31,7 +31,8 @@
"data:validate-song-score-formula": "node scripts/validate-song-score-formula.mjs",
"test:sus-score": "node --test scripts/sus-score-weight.test.mjs scripts/sus-timeline.test.mjs",
"data:download-charts": "node scripts/download-sus-charts.mjs --all",
"test:game-db": "node --test scripts/game-db-source.test.mjs"
"test:game-db": "node --test scripts/game-db-source.test.mjs",
"data:collect-events": "node scripts/collect-game-events.mjs"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.7",
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { prepareGameDbSource, readGameDbText, sha256 } from './game-db-source.mjs';
// Explicitly select the event to apply; older manual records retain their provenance.
const root = new URL('../data/game-events/', import.meta.url);
const pointer = JSON.parse(readFileSync(new URL('current.json', root), 'utf8'));
assert.match(pointer.file, /^\d{4}-\d{2}-\d{2}\.json$/);
const bytes = readFileSync(new URL(pointer.file, root));
assert.equal(sha256(bytes), pointer.sha256, 'Event API snapshot hash mismatch');
const snapshot = JSON.parse(bytes);
assert.equal(snapshot.schemaVersion, 1);
assert.equal(snapshot.source.kind, 'android-event-api-memory');
assert.equal(snapshot.source.locale, 'kor');
assert(pointer.eventIds.length > 0);
assert.equal(new Set(pointer.eventIds).size, pointer.eventIds.length);
const db = prepareGameDbSource('kor', { review: true });
const table = name => JSON.parse(readGameDbText(db, name)).map(row => row.data);
const posters = table('Poster.json');
const posterNames = new Map(table('LangPoster_Kor.json').map(row => [row.id, row.text]));
const cards = new Set(table('Card.json').map(row => row.id));
const characters = new Set(table('Character.json').map(row => row.id));
const songs = new Set(table('Music.json').map(row => row.id));
const iso = value => {
assert(Number.isSafeInteger(value) && value > 0, 'Invalid event timestamp');
return new Date(value).toISOString();
};
function uniform(rows, field) {
const values = new Set(rows.map(row => row[field]));
assert(values.size <= 1, `Calculator cannot represent mixed ${field}`);
const value = values.size ? [...values][0] : 0;
assert(Number.isInteger(value) && value >= 0);
return value / 10;
}
function project(event) {
assert(event && event.marathonInfo, 'Selected event is missing marathon configuration');
// Relay chapters remain historical records until their exact transition semantics are applied.
assert.equal(event.marathonInfo.marathonChapters.length, 1, 'Only single-chapter events are supported by this projection');
const chapter = event.marathonInfo.marathonChapters[0];
assert(event.startTime < event.endTime && event.endTime < event.exchangeEndTime);
assert.equal(chapter.endTime, event.endTime);
const formation = [], featured = [], awakening = {};
for (const row of chapter.marathonLiveMarathonScoreBonuses) {
assert.equal(row.cardAttributeType, 0, 'Attribute bonus needs an explicit calculator implementation');
const { characterId, cardId, cardRarity: rarity, cardPotentialUpgradeCount: count } = row;
if (characterId && !cardId && !rarity && !count) {
assert(characters.has(characterId)); formation.push(row);
} else if (cardId && !characterId && !rarity && !count) {
assert(cards.has(cardId)); featured.push(row);
} else {
assert(!characterId && !cardId && Number.isInteger(rarity) && rarity > 0);
assert(Number.isInteger(count) && count > 0 && count <= 5);
awakening[rarity] ??= [0];
assert.equal(awakening[rarity][count], undefined, 'Duplicate awakening rule');
awakening[rarity][count] = row.marathonScoreQuantityUpPermilUp / 10;
}
}
for (const values of Object.values(awakening)) {
assert.equal(values.length, 6);
assert(Array.from(values).every(value => Number.isFinite(value) && value >= 0), 'Incomplete awakening rules');
}
const musicRules = chapter.marathonMusicScoreBonuses;
const challengeSongBonusCardIds = {};
for (const row of musicRules) {
assert(songs.has(row.musicId) && cards.has(row.cardId));
assert(!row.characterId && !row.cardAttributeType && !row.cardRarity && !row.cardPotentialUpgradeCount);
(challengeSongBonusCardIds[row.musicId] ??= []).push(row.cardId);
}
assert.equal(new Set(formation.map(row => row.characterId)).size, formation.length);
assert.equal(new Set(featured.map(row => row.cardId)).size, featured.length);
const poster = posters.find(row => row.id === `poster-${chapter.id}`);
return {
id: event.eventId,
name: posterNames.get(poster?.nameLangId) ?? event.name,
startsAt: iso(event.startTime),
endsAt: iso(event.endTime),
exchangeEndsAt: iso(event.exchangeEndTime),
bonusCharacterIds: formation.map(row => row.characterId),
featuredCardIds: featured.map(row => row.cardId),
challengeSongBonusCardIds,
formationBonusPercent: uniform(formation, 'marathonScoreQuantityUpPermilUp'),
featuredCardBonusPercent: uniform(featured, 'marathonScoreQuantityUpPermilUp'),
challengeSongScoreBonusPercent: uniform(musicRules, 'scoreUpPermilUp'),
awakeningBonusPercentByRarity: awakening
};
}
const output = {
source: { file: `data/game-events/${pointer.file}`, sha256: pointer.sha256,
capturedAt: snapshot.source.capturedAt, masterDataVersion: db.masterDataVersion },
events: pointer.eventIds.map(id => project(snapshot.events.find(event => event.eventId === id)))
};
const destination = new URL('../src/lib/data/events-api.json', import.meta.url);
writeFileSync(destination, JSON.stringify(output, null, 2) + '\n');
console.log(`Generated ${output.events.length} event(s): ${fileURLToPath(destination)}`);
+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>