feat: apply captured API configuration for Blue Splash event
Publish GitHub Pages output / publish (push) Successful in 37s
Publish GitHub Pages output / publish (push) Successful in 37s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`에서 관리합니다.
|
||||
|
||||
|
||||
@@ -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
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"file": "2026-09-29.json",
|
||||
"sha256": "dbb57a74aa28f1c5684c5dc6f2d45788e8e608011e72aef65db238b148788407",
|
||||
"eventIds": ["marathon_event-normal-005"]
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)}`);
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user