Compare commits

..
13 Commits
482 changed files with 15987 additions and 2437 deletions
+1
View File
@@ -9,6 +9,7 @@ release
data/* data/*
!data/.gitkeep !data/.gitkeep
!data/card-overrides.csv !data/card-overrides.csv
!data/score-calibrations.json
!data/holodoridb/ !data/holodoridb/
!data/holodoridb/** !data/holodoridb/**
data/holodoridb/*.tmp data/holodoridb/*.tmp
+8
View File
@@ -0,0 +1,8 @@
# Repository instructions
## Online and offline parity
- Every user-facing feature, fix, data update, and behavior change must be included in both the online app and the offline app.
- Keep shared behavior in `src/` whenever possible. If an online-only dependency is introduced, add or update the corresponding offline alias or adapter under `offline/`.
- Treat `npm run build:offline` as a required verification step for every repository change, in addition to the relevant tests, checks, and online build.
- Do not commit generated `offline-dist/` or `release/` artifacts unless the user explicitly requests packaged artifacts; these directories are ignored build outputs.
+163 -73
View File
@@ -1,53 +1,146 @@
# Holodream Tools # 홀로도리 계산기
## Card master data 홀로라이브 드림즈의 카드와 멤버 성장 상태를 관리하고, 악곡별 최적 배치와 카드 조합을 분석하는 비공식 도구입니다.
Collect the current card-related master data and Korean, English, and Japanese text from the public HolodoriDB diff repositories: [최신 버전 다운로드](https://github.com/DD4SN/HolodoriCalc/releases/latest) · 한국어 전용 · PC 환경 권장
```bash ![홀로도리 계산기 홈 화면](docs/images/home.png)
npm run data:sync
```
The generated manifest, raw relational bundle, and card-by-card bundle are written to `data/holodoridb`. Card artwork, movies, and voices are intentionally excluded. ## 주요 기능
### 카드 표시명 수동 수정 - 5성 카드의 타입, 주스탯, 기수와 스킬 정보 조회
- 보유 카드와 개화 단계, 메모리, 멤버 강화 수치 저장
- 멤버별 보드 노드와 연결 카드 설정
- 현재 편성에서 초록 노드의 자원 대비 효율 분석
- 특정 악곡과 5인 편성의 최적 배치 및 빈도 감소 조합 분석
- 고정 멤버를 기준으로 남은 자리에 들어갈 카드 조합 탐색
- 액티브·스페셜 발동 구간과 노트 분포 시각화
- 사용자 데이터 백업과 복원
- 인터넷 연결 없이 실행 가능한 오프라인 버전 제공
특별 버전명이나 번역 이름은 `data/card-overrides.csv`에서 관리합니다. ## 다운로드 및 실행
```csv 1. [최신 Release](https://github.com/DD4SN/HolodoriCalc/releases/latest)를 엽니다.
cardId,versionName,displayName 2. `Assets`에서 `HolodoriCalc-offline-v버전.zip`을 내려받습니다.
card-06002-5-uniq-0066-00,수영복, 3. ZIP 파일을 원하는 폴더에 완전히 압축 해제합니다.
``` 4. 압축을 해제한 폴더의 `index.html`을 실행합니다.
- `cardId`: 수정할 카드의 고유 ID > GitHub가 자동으로 제공하는 `Source code (zip)`은 실행용 배포 파일이 아닙니다. 반드시 `HolodoriCalc-offline-...zip`을 받아 주세요.
- `versionName`: 기본 이름 옆에 표시할 짧은 버전명
- `displayName`: 기본 멤버 이름을 완전히 바꿀 때만 입력하며, 평소에는 비워 둡니다.
수정 후 `npm run data:sync`를 실행하면 카드 목록에 반영됩니다. 이 파일은 원본 데이터를 다시 수집해도 덮어쓰지 않습니다. Chrome 또는 Edge의 최신 버전을 권장합니다. 일부 브라우저는 로컬 HTML의 저장소나 네트워크 접근을 제한할 수 있습니다.
## 브라우저 데이터 ## 처음 사용하는 순서
사용자 데이터는 서버로 전송하지 않고 현재 브라우저의 `localStorage`에 자동 저장합니다. 별도의 `사용자 데이터` 페이지에서 JSON 백업을 내보내거나 가져오고, 전체 데이터를 초기화할 수 있습니다. ### 1. 사용자 데이터를 설정합니다
- 현재 카드 목록의 출력 조건을 자동으로 복원합니다. 상단 메뉴에서 **사용자 데이터 → 카드**로 이동해 보유 카드와 개화 단계를 입력합니다.
- `백업 내보내기`로 버전이 포함된 JSON 파일을 내려받을 수 있습니다.
- `백업 가져오기`로 다른 브라우저에서 데이터를 복원할 수 있습니다.
- `초기화`는 현재 브라우저에 저장된 수집 내역, 저장 덱, 설정을 모두 기본값으로 되돌립니다.
향후 보유 카드와 저장 덱 기능도 `src/lib/models/user-data.ts`의 동일한 버전 데이터에 저장합니다. - 5성 카드는 `0~5` 개화 단계를 설정합니다.
- 4성 카드는 보유 여부와 5개화 여부를 설정합니다.
- 보유하지 않은 카드는 개화 단계를 변경할 수 없습니다.
- 행을 누르면 보유 여부를 빠르게 전환할 수 있습니다.
홀로드림스 덱 최적화와 스킬 로스 분석을 통합할 웹사이트의 개발 기반입니다. 현재 단계에는 게임 기능을 포함하지 않습니다. ![카드 보유 및 개화 설정](docs/images/user-cards.png)
## 구성 **사용자 데이터 → 일반**에서는 메모리 보유 수와 멤버 강화 수치를 설정하고, 전체 사용자 데이터를 백업하거나 복원할 수 있습니다.
### 2. 멤버 보드를 설정합니다
**사용자 데이터 → 보드**에서 멤버를 선택한 뒤 실제 게임에서 활성화한 노드를 같은 형태로 입력합니다.
- 빈 공간을 드래그해 보드를 이동합니다.
- 마우스 휠로 확대하거나 축소합니다.
- 인접하지 않은 노드를 누르면 중심에서 이어지는 최단 경로가 함께 활성화됩니다.
- 보드 슬롯에는 보유 카드를 한 장씩 배치할 수 있습니다.
- `모든 멤버 초록 3칸 활성화`와 `중앙 연결 노드 자동 배치`로 일반적인 설정을 빠르게 적용할 수 있습니다.
![멤버 보드 설정](docs/images/member-board.png)
같은 페이지의 **초록 노드 효율 분석**에서는 현재 편성을 기준으로 다음 항목을 비교할 수 있습니다.
- 아직 활성화하지 않은 노드 중 우선 활성화할 노드
- 이미 활성화한 노드 중 초기화를 검토할 노드
- 종합력 변화, 필요한 연결 노드 수, 자원 소모량과 그린 큐브 효율
목록의 항목을 누르면 해당 멤버와 노드로 바로 이동합니다.
### 3. 카드 정보를 확인합니다
**카드 목록**에서는 타입, 주스탯, 기수, 액티브 확률, 조건부 스킬과 이격 여부로 카드를 필터링할 수 있습니다. 카드 행을 누르면 패시브·액티브·스페셜·의상 효과가 펼쳐집니다.
![카드 목록과 상세 스킬](docs/images/cards.png)
### 4. 5인 편성의 최적 배치를 분석합니다
**배치 분석**은 선택한 악곡과 5인 조합을 유지하면서 가장 높은 점수를 내는 멤버 순서와 빈도 감소 조합을 찾습니다.
1. 악곡과 난이도를 선택합니다.
2. 5장의 카드와 개화 단계를 설정합니다.
3. 필요한 경우 카드별 빈도 감소를 특정 값으로 고정합니다.
4. 리더와 리더 의상을 선택합니다.
5. `최적 배치 분석`을 누릅니다.
![배치 분석 조건 설정](docs/images/analysis.png)
분석 결과에서는 최고·평균 점수, 최적 순서, 카드별 빈도 감소율과 함께 다음 시각화 자료를 확인할 수 있습니다.
- 초당 노트 분포
- 멤버별 액티브 발동 구간
- 중첩을 반영한 최종 액티브 적용 구간
- 멤버별 스페셜 발동 구간
분석 조건과 마지막 결과는 브라우저에 저장되며, 같은 악곡과 난이도의 조합 탐색에서 **기준 조합**으로 사용할 수 있습니다.
### 5. 더 좋은 카드 조합을 탐색합니다
**조합 탐색**은 악곡, 리더, 리더 의상과 최대 4명의 고정 카드를 기준으로 남은 자리에 들어갈 후보를 비교합니다.
- 고정하지 않은 자리에는 전체 5성 카드가 후보로 들어갑니다.
- 자동 후보의 개화 단계와 기수 보너스 덮어쓰기 값을 지정할 수 있습니다.
- 보유 카드의 실제 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.
- 고정 멤버별 개화, 빈도 감소, 파란 노드 전체 적용 여부와 기수 보너스를 따로 설정할 수 있습니다.
- 한 편성에는 같은 인물의 일반 카드와 이격 카드가 동시에 들어가지 않습니다.
- 결과는 최고 점수 기준으로 정렬되며 상위 조합의 상세 분석을 펼쳐볼 수 있습니다.
![조합 탐색 조건과 기준 조합](docs/images/combination.png)
`최대 컴퓨팅 자원 활용`을 켜면 탐색 속도가 빨라질 수 있지만, 분석 중 브라우저와 다른 프로그램의 반응이 느려질 수 있습니다.
## 데이터 저장과 백업
사용자 데이터와 분석 조건은 서버로 전송하지 않고 현재 브라우저의 `localStorage`에 자동 저장합니다.
- 브라우저나 사용자 프로필이 달라지면 별도의 데이터로 취급될 수 있습니다.
- 브라우저 데이터 삭제 시 저장 내용도 사라질 수 있습니다.
- 새 버전으로 이동하기 전 **사용자 데이터 → 일반 → 백업 내보내기**를 권장합니다.
- 백업 JSON은 다른 브라우저나 PC의 **백업 가져오기**에서 복원할 수 있습니다.
## 업데이트
홈 화면에서 현재 버전과 GitHub의 최신 Release를 비교합니다. 새 버전이 있으면 오프라인 ZIP 다운로드 버튼이 표시됩니다.
업데이트는 자동으로 파일을 교체하지 않습니다. 새 ZIP을 내려받아 별도 폴더에 압축을 해제한 뒤 `index.html`을 실행해 주세요. 업데이트 전에 사용자 데이터 백업을 권장합니다.
## 참고
- 이 프로젝트는 홀로라이브 드림즈의 비공식 팬 제작 도구입니다.
- 게임 업데이트와 확보된 데이터 범위에 따라 실제 게임 결과와 차이가 날 수 있습니다.
- 분석 결과는 입력한 카드·개화·보드·메모리·멤버 강화 데이터가 정확하다는 전제에서 계산됩니다.
- 카드와 악곡의 원본 게임 데이터는 프로젝트가 직접 소유하거나 공식 제공하는 데이터가 아닙니다.
<details>
<summary><strong>개발 환경과 실행 방법</strong></summary>
### 기술 구성
- SvelteKit + TypeScript + `@sveltejs/adapter-node` - SvelteKit + TypeScript + `@sveltejs/adapter-node`
- SQLite + Drizzle ORM - SQLite + Drizzle ORM
- Web Worker를 위한 독립 모듈 경계 - Web Worker 기반 클라이언트 계산
- Docker Compose 개발 환경(HMR) - Docker / Docker Compose
- 멀티 스테이지 production 이미지
- Docker named volume 기반 SQLite 영속 저장소
## Docker 개발 실행 계산 엔진은 UI와 분리하며 `UI → Engine → Models` 의존성 방향을 유지합니다.
### Docker 개발 실행
```bash ```bash
docker compose up --build docker compose up --build
@@ -56,32 +149,9 @@ docker compose up --build
- 웹: <http://localhost:5173> - 웹: <http://localhost:5173>
- 상태 확인: <http://localhost:5173/api/health> - 상태 확인: <http://localhost:5173/api/health>
소스 파일을 수정하면 Windows의 Docker Desktop에서도 polling 기반 HMR이 동작합니다. 종료할 때 `docker compose down`은 SQLite 볼륨을 보존합니다. `docker compose down --volumes`는 저장된 볼륨까지 제거하므로 주의하세요.
종료: ### 호스트에서 실행
```bash
docker compose down
```
`docker compose down`은 SQLite 볼륨을 보존합니다. 데이터까지 지우려는 경우에만 `docker compose down --volumes`를 사용하세요.
## production 실행
```bash
docker compose -f compose.prod.yaml up --build -d
```
- 웹: <http://localhost:3000>
- 상태 확인: <http://localhost:3000/api/health>
종료:
```bash
docker compose -f compose.prod.yaml down
```
## 호스트에서 실행
Node.js 22.12 이상이 필요합니다. Node.js 22.12 이상이 필요합니다.
@@ -91,33 +161,53 @@ npm ci
npm run dev npm run dev
``` ```
기본 로컬 DB 경로는 `./data/holodream.db`입니다. ### 검증과 빌드
## 검증 명령
```bash ```bash
npm run check npm run check
npm run build npm run build
npm run build:offline
``` ```
스키마를 변경한 뒤에는 migration을 생성합니다. 릴리즈용 오프라인 ZIP 생성:
```bash ```bash
npm run db:generate npm run package:offline
``` ```
생성된 migration은 서버가 시작될 때 자동 적용됩니다. 호스트에서 수동 적용하려면 `npm run db:migrate`를 사용합니다. ### Production 실행
## 디렉터리 경계 ```bash
docker compose -f compose.prod.yaml up --build -d
```text
src/
├─ lib/
│ ├─ engine/ # UI 프레임워크에 의존하지 않는 순수 계산 로직
│ ├─ models/ # UI·엔진·서버가 공유하는 도메인 타입
│ ├─ server/db/ # SQLite/Drizzle 서버 전용 모듈
│ └─ workers/ # 무거운 최적화 작업의 Web Worker 진입점
└─ routes/ # SvelteKit 화면과 API
``` ```
의존성 방향은 `UI → Engine → Models`를 유지합니다. 계산 엔진은 Svelte나 서버 모듈을 import하지 않습니다. - 웹: <http://localhost:3000>
- 상태 확인: <http://localhost:3000/api/health>
</details>
<details>
<summary><strong>게임 데이터 갱신과 카드 표시명 수정</strong></summary>
공개 HolodoriDB diff 저장소에서 카드 관련 마스터 데이터와 한국어·영어·일본어 텍스트를 수집합니다.
```bash
npm run data:sync
```
생성된 데이터는 `data/holodoridb`에 저장합니다. 카드 일러스트, 영상과 음성은 수집 대상에서 제외합니다.
특별 버전명이나 번역 이름은 `data/card-overrides.csv`에서 관리합니다.
```csv
cardId,versionName,displayName
card-06002-5-uniq-0066-00,수영복,
```
- `cardId`: 수정할 카드의 고유 ID
- `versionName`: 이름 옆에 표시할 짧은 버전명
- `displayName`: 기본 멤버 이름을 완전히 바꿀 때만 입력
수정 후 `npm run data:sync`를 실행하면 반영됩니다. 이 파일은 원본 데이터를 다시 수집해도 덮어쓰지 않습니다.
</details>
+4
View File
@@ -11,3 +11,7 @@ card-00028-5-uniq-0070-00,수영복,
card-03004-5-uniq-0073-00,수영복, card-03004-5-uniq-0073-00,수영복,
card-04001-5-uniq-0071-00,수영복, card-04001-5-uniq-0071-00,수영복,
card-04003-5-uniq-0072-00,수영복, card-04003-5-uniq-0072-00,수영복,
card-00006-5-uniq-0074-00,수영복,
card-00014-5-uniq-0075-00,수영복,
card-00016-5-uniq-0076-00,수영복,
card-00017-5-uniq-0077-00,수영복,
1 cardId versionName displayName
11 card-03004-5-uniq-0073-00 수영복
12 card-04001-5-uniq-0071-00 수영복
13 card-04003-5-uniq-0072-00 수영복
14 card-00006-5-uniq-0074-00 수영복
15 card-00014-5-uniq-0075-00 수영복
16 card-00016-5-uniq-0076-00 수영복
17 card-00017-5-uniq-0077-00 수영복
File diff suppressed because it is too large Load Diff
+5105 -10
View File
File diff suppressed because it is too large Load Diff
+102 -102
View File
@@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"generatedAt": "2026-08-18T01:01:34.166Z", "generatedAt": "2026-08-29T14:35:34.947Z",
"source": "HolodoriDB community master-data diff repositories", "source": "HolodoriDB community master-data diff repositories",
"sourceLicense": null, "sourceLicense": null,
"sourceLicenseNote": "No LICENSE file was present in the three source repositories at collection time.", "sourceLicenseNote": "No LICENSE file was present in the three source repositories at collection time.",
@@ -8,46 +8,46 @@
"sources": { "sources": {
"eng": { "eng": {
"repository": "https://github.com/HolodoriDB/holodori-db-eng-diff", "repository": "https://github.com/HolodoriDB/holodori-db-eng-diff",
"commit": "a15150a8b7413f035f28f8f85d63ab9df122c380", "commit": "95e08ebe8f5b0bec83af036230f10291726b7130",
"commitDate": "2026-08-16T12:45:55Z", "commitDate": "2026-08-28T15:11:43Z",
"masterDataVersion": "71e11fbd082eec83d10cff35da7179cbaf319097f0021aa5747fe5a5392b549c" "masterDataVersion": "fbca8c670e074558b24708bf163fff446a614f709afa9d1784395a15444121b0"
}, },
"kor": { "kor": {
"repository": "https://github.com/HolodoriDB/holodori-db-kor-diff", "repository": "https://github.com/HolodoriDB/holodori-db-kor-diff",
"commit": "f5e3c9513edbeec7007e02c0002786291fe1d119", "commit": "1d7c7850bf2df34239c440b5b0cc53bbd72e5d5d",
"commitDate": "2026-08-16T12:18:18Z", "commitDate": "2026-08-28T14:42:22Z",
"masterDataVersion": "71e11fbd082eec83d10cff35da7179cbaf319097f0021aa5747fe5a5392b549c" "masterDataVersion": "fbca8c670e074558b24708bf163fff446a614f709afa9d1784395a15444121b0"
}, },
"jpn": { "jpn": {
"repository": "https://github.com/HolodoriDB/holodori-db-jpn-diff", "repository": "https://github.com/HolodoriDB/holodori-db-jpn-diff",
"commit": "84cb500d8ebf19e306be20faba696123018e49a8", "commit": "09fd4e4abbee2a640a49e7f3f023ad23f43eaa89",
"commitDate": "2026-08-16T12:55:53Z", "commitDate": "2026-08-28T15:16:40Z",
"masterDataVersion": "71e11fbd082eec83d10cff35da7179cbaf319097f0021aa5747fe5a5392b549c" "masterDataVersion": "fbca8c670e074558b24708bf163fff446a614f709afa9d1784395a15444121b0"
} }
}, },
"counts": { "counts": {
"cards": 174, "cards": 178,
"rarities": { "rarities": {
"3": 54, "3": 54,
"4": 54, "4": 54,
"5": 66 "5": 70
}, },
"attributes": { "attributes": {
"1": 57, "1": 58,
"2": 57, "2": 59,
"3": 60 "3": 61
}, },
"localizedCardNames": { "localizedCardNames": {
"eng": 174, "eng": 178,
"kor": 174, "kor": 178,
"jpn": 174 "jpn": 178
} }
}, },
"tables": { "tables": {
"Card": { "Card": {
"filename": "Card.json", "filename": "Card.json",
"count": 174, "count": 178,
"sha256": "bde4e409ce4c184d7a2d4cd34a264e3356a1d0628c3cfdaee881b6f119f6a4a9" "sha256": "89412d36bc3c7875f056f81dcb2d23b8cfb871d2cd2a522dd79762b899f1343c"
}, },
"CardLevel": { "CardLevel": {
"filename": "CardLevel.json", "filename": "CardLevel.json",
@@ -86,43 +86,43 @@
}, },
"Costume": { "Costume": {
"filename": "Costume.json", "filename": "Costume.json",
"count": 182, "count": 186,
"sha256": "a45926292833e1927aa8cf2c4427d6937743ed044bc395331734cc6b40182539" "sha256": "a2f33481edaf769a99a13261d2d688885b62134b79c317cffd446ffaf2f1d9b6"
}, },
"SdCostume": { "SdCostume": {
"filename": "SdCostume.json", "filename": "SdCostume.json",
"count": 290, "count": 294,
"sha256": "2fcf6448638df807915f2bba60188657374cbeb230ec251e44cc84a7c56e65d0" "sha256": "a91f4fee5769620ace9ebe98a32894561048e3a386810499cde520cf08f0d18d"
}, },
"LiveLeaderSkill": { "LiveLeaderSkill": {
"filename": "LiveLeaderSkill.json", "filename": "LiveLeaderSkill.json",
"count": 174, "count": 178,
"sha256": "e58b9e6671ea10c3220422b0da9335ce23659793bf77ada2489b8c48be822a3a" "sha256": "9c6c2729e18f5d6c839dffe67c0b1e2a5e5182061c7447b804fb3650e50d8db1"
}, },
"LivePassiveSkill": { "LivePassiveSkill": {
"filename": "LivePassiveSkill.json", "filename": "LivePassiveSkill.json",
"count": 174, "count": 178,
"sha256": "d4b40eb960f6314b3ea1c02d5724520bf21bffe99d40dda27f592d903ce05345" "sha256": "2a3ed4d50a9145e6da5fc87d24b521dffd4c8ccb16bb3b380dbae11634747ca3"
}, },
"LivePassiveSkillLevel": { "LivePassiveSkillLevel": {
"filename": "LivePassiveSkillLevel.json", "filename": "LivePassiveSkillLevel.json",
"count": 348, "count": 356,
"sha256": "c1103ef24a22d62e0fe375460e1d247073e21b155f1f296b3d46728b4284235e" "sha256": "4e4244eeb4a86b857efe6f7f1957aaf3b480366c89b6db5a0960c5da37263a56"
}, },
"LivePassiveSkillEffect": { "LivePassiveSkillEffect": {
"filename": "LivePassiveSkillEffect.json", "filename": "LivePassiveSkillEffect.json",
"count": 178, "count": 182,
"sha256": "52f4f6bfae8a2f12494b553fe272920dcc0b1014787ca89d85f919b4a248ec6b" "sha256": "52e6529df13641eac635cc770d58411e4b7b52d64672a3d681bd6099048e7cc2"
}, },
"LiveActiveSkill": { "LiveActiveSkill": {
"filename": "LiveActiveSkill.json", "filename": "LiveActiveSkill.json",
"count": 176, "count": 180,
"sha256": "2828267edb9a2f9395070a2c12cbc3f0991937644dd606722a969c980a9585d3" "sha256": "ccb82158aae0c2dd6aa636f583ffb460837393591a17face70b631065b03cdc0"
}, },
"LiveActiveSkillLevel": { "LiveActiveSkillLevel": {
"filename": "LiveActiveSkillLevel.json", "filename": "LiveActiveSkillLevel.json",
"count": 351, "count": 359,
"sha256": "82970f85b34ac632eef08de873e6cbd826e1e6271efaf8f7ea48235f083dff32" "sha256": "e19ca76a52551bf6e971b33d6edaede4f503996b2548014dffd2813f1a9765a3"
}, },
"LiveActiveSkillEffect": { "LiveActiveSkillEffect": {
"filename": "LiveActiveSkillEffect.json", "filename": "LiveActiveSkillEffect.json",
@@ -131,13 +131,13 @@
}, },
"LiveSpecialSkill": { "LiveSpecialSkill": {
"filename": "LiveSpecialSkill.json", "filename": "LiveSpecialSkill.json",
"count": 174, "count": 178,
"sha256": "5eed11dfb765627028aaf143f53c3363385a35a3ee884f3bbe190f8d10b3992b" "sha256": "99d3bfe0efd3872c61d2f531ad505495ecaf9cfac7a07940b663022feb9defc0"
}, },
"LiveSpecialSkillLevel": { "LiveSpecialSkillLevel": {
"filename": "LiveSpecialSkillLevel.json", "filename": "LiveSpecialSkillLevel.json",
"count": 348, "count": 356,
"sha256": "25c7bebf7027097ac21505bd2aa625384ceaed6ff2ba077ca6d471b22fd09584" "sha256": "58444ef911e3cc0573edbfd439fc3f0de646cf0845f50bd62a808f3d2681ccd8"
}, },
"LiveSkillTrigger": { "LiveSkillTrigger": {
"filename": "LiveSkillTrigger.json", "filename": "LiveSkillTrigger.json",
@@ -156,8 +156,8 @@
}, },
"Poster": { "Poster": {
"filename": "Poster.json", "filename": "Poster.json",
"count": 28, "count": 29,
"sha256": "a75e14e4a75091c41d6d0790d30a96d743eedad50806fc7f85697ca86a56b664" "sha256": "ddd93ddeb83482e675fba2b6964e9df4ae729b5185b4773faa99e565b901d6b9"
}, },
"PosterCollectEffect": { "PosterCollectEffect": {
"filename": "PosterCollectEffect.json", "filename": "PosterCollectEffect.json",
@@ -176,8 +176,8 @@
}, },
"SkillTreeEffect": { "SkillTreeEffect": {
"filename": "SkillTreeEffect.json", "filename": "SkillTreeEffect.json",
"count": 120, "count": 121,
"sha256": "36277a6b971331c0c2ea430f384a425b8670516e3ddb4f95d3dde667bdc45011" "sha256": "b95f3b11a106f50a5edfe8ee875593f608dd2c55c9430e22d0b3476f2934bb3b"
}, },
"SkillTreeEffectPassiveTrigger": { "SkillTreeEffectPassiveTrigger": {
"filename": "SkillTreeEffectPassiveTrigger.json", "filename": "SkillTreeEffectPassiveTrigger.json",
@@ -196,8 +196,8 @@
}, },
"SkillTreeNode": { "SkillTreeNode": {
"filename": "SkillTreeNode.json", "filename": "SkillTreeNode.json",
"count": 323, "count": 324,
"sha256": "30d2479d6516c96c214caec98380454963733b1ef5f3ab75a171f4f461924605" "sha256": "c47e047d0ccfeaa9be961fd6ef7ca9b989ce9841fdfa85b016c939920931c25d"
}, },
"SkillTreeNodeAutoSelection": { "SkillTreeNodeAutoSelection": {
"filename": "SkillTreeNodeAutoSelection.json", "filename": "SkillTreeNodeAutoSelection.json",
@@ -206,8 +206,8 @@
}, },
"SkillTreeNodePosition": { "SkillTreeNodePosition": {
"filename": "SkillTreeNodePosition.json", "filename": "SkillTreeNodePosition.json",
"count": 608, "count": 612,
"sha256": "973e1efd5a35a291ddbc0c963b15068ba6df3e504ed18d4cf9246b831c94dffa" "sha256": "94fdbb6386ec5cd8b3fcf8125584cacb69ddb079d206aa58889dda5bd2aa722e"
}, },
"SkillTreePoint": { "SkillTreePoint": {
"filename": "SkillTreePoint.json", "filename": "SkillTreePoint.json",
@@ -219,8 +219,8 @@
"eng": { "eng": {
"Card": { "Card": {
"filename": "LangCard_Eng.json", "filename": "LangCard_Eng.json",
"count": 174, "count": 178,
"sha256": "f9722ef9d6fb3817989f83db9a5c332a17497e7f3952639677e2f9e1fa0abc06" "sha256": "7a273560a1da8f35abba04625b96c0aa323adab44f0472690687d227fde701b2"
}, },
"Character": { "Character": {
"filename": "LangCharacter_Eng.json", "filename": "LangCharacter_Eng.json",
@@ -239,13 +239,13 @@
}, },
"Costume": { "Costume": {
"filename": "LangCostume_Eng.json", "filename": "LangCostume_Eng.json",
"count": 182, "count": 186,
"sha256": "db86a28a6b929f0d9f1094f1b1ea39a5aa6dfd298dd050b48cf9147c50b34d67" "sha256": "de570bcdb456626bed7aa09ad1573646097e9f227c2b8c7511fd9b646e92e7eb"
}, },
"SdCostume": { "SdCostume": {
"filename": "LangSdCostume_Eng.json", "filename": "LangSdCostume_Eng.json",
"count": 290, "count": 294,
"sha256": "5dd66fa7fd0b819ad098b0b7909a4b7ecd1cd4d27ee2cc304b477638526ebabc" "sha256": "8b377f79331459ed29a928914294ad338541132eabe2e53387a1e1ef4765d99c"
}, },
"GeneratedLiveActiveSkillEffect": { "GeneratedLiveActiveSkillEffect": {
"filename": "LangGeneratedLiveActiveSkillEffect_Eng.json", "filename": "LangGeneratedLiveActiveSkillEffect_Eng.json",
@@ -254,23 +254,23 @@
}, },
"GeneratedLiveActiveSkillLevel": { "GeneratedLiveActiveSkillLevel": {
"filename": "LangGeneratedLiveActiveSkillLevel_Eng.json", "filename": "LangGeneratedLiveActiveSkillLevel_Eng.json",
"count": 351, "count": 359,
"sha256": "3a3d0d2d7e6b7f1f9e98a8d7f4e0dad406422b6cb4fbd720ccae68b4ded5c487" "sha256": "9d561634e3f1ac790c1f0834b69b53a44ab27086c350ebc866a0ed95dd43f3a3"
}, },
"GeneratedLiveLeaderSkill": { "GeneratedLiveLeaderSkill": {
"filename": "LangGeneratedLiveLeaderSkill_Eng.json", "filename": "LangGeneratedLiveLeaderSkill_Eng.json",
"count": 174, "count": 178,
"sha256": "1ae291b27f6d99eb2fb25c5ce27aa56ecea77a1e73ec57f06659ddcf51522009" "sha256": "5072999b71f3067aef56b17f6f9f0b4b115f026d007914086209dc8659a667ac"
}, },
"GeneratedLivePassiveSkillEffect": { "GeneratedLivePassiveSkillEffect": {
"filename": "LangGeneratedLivePassiveSkillEffect_Eng.json", "filename": "LangGeneratedLivePassiveSkillEffect_Eng.json",
"count": 178, "count": 182,
"sha256": "6583ea64ed64d6a2c24fd0b96731c11eb46b0ee2bd27f7febab84cec6b0e76e7" "sha256": "b56adac2cb5863d54c004525224cbd0c2dab2672813b158cfdfa3b6a1d42c22c"
}, },
"GeneratedLivePassiveSkillLevel": { "GeneratedLivePassiveSkillLevel": {
"filename": "LangGeneratedLivePassiveSkillLevel_Eng.json", "filename": "LangGeneratedLivePassiveSkillLevel_Eng.json",
"count": 348, "count": 356,
"sha256": "1da7ff4d2b0bd99281aeb0394e799b761088c0066d00bcfa7aaa092bbb361687" "sha256": "2d8132656058ae7ef63b06fd106e17eb9ed0aabc42c027548d93f88c8a22dd93"
}, },
"GeneratedLiveSkillEffectTarget": { "GeneratedLiveSkillEffectTarget": {
"filename": "LangGeneratedLiveSkillEffectTarget_Eng.json", "filename": "LangGeneratedLiveSkillEffectTarget_Eng.json",
@@ -284,8 +284,8 @@
}, },
"GeneratedLiveSpecialSkillLevel": { "GeneratedLiveSpecialSkillLevel": {
"filename": "LangGeneratedLiveSpecialSkillLevel_Eng.json", "filename": "LangGeneratedLiveSpecialSkillLevel_Eng.json",
"count": 348, "count": 356,
"sha256": "04dc888915eed7b5fc01572cee0dc480249f4dd54b508eb9e4e6a3bfe6a84e05" "sha256": "a8cfb9391c42a42116574893399fb7be6d02de05c4e73b75706ab902c569e2e4"
}, },
"GeneratedSkillTreeConnectEffect": { "GeneratedSkillTreeConnectEffect": {
"filename": "LangGeneratedSkillTreeConnectEffect_Eng.json", "filename": "LangGeneratedSkillTreeConnectEffect_Eng.json",
@@ -294,8 +294,8 @@
}, },
"GeneratedSkillTreeEffect": { "GeneratedSkillTreeEffect": {
"filename": "LangGeneratedSkillTreeEffect_Eng.json", "filename": "LangGeneratedSkillTreeEffect_Eng.json",
"count": 120, "count": 121,
"sha256": "590e393c867912d60fcd2f24b87f1f3b1cba80327019f38bdfa041acfe157433" "sha256": "6d45f0e0d493d1ec3a8ce26eb4d1f310468f3e5b4848df3933d3c6f2275bd697"
}, },
"GeneratedSkillTreeEffectPassiveTrigger": { "GeneratedSkillTreeEffectPassiveTrigger": {
"filename": "LangGeneratedSkillTreeEffectPassiveTrigger_Eng.json", "filename": "LangGeneratedSkillTreeEffectPassiveTrigger_Eng.json",
@@ -326,8 +326,8 @@
"kor": { "kor": {
"Card": { "Card": {
"filename": "LangCard_Kor.json", "filename": "LangCard_Kor.json",
"count": 174, "count": 178,
"sha256": "448ae14796cecd434666618aa5d63ff8348fe94780f9a7c975c2dcb7298a13fd" "sha256": "7de9f21e5e4eb602be499b32e20a3d82d2e4dc5ab2961fc0f604bc83c7245693"
}, },
"Character": { "Character": {
"filename": "LangCharacter_Kor.json", "filename": "LangCharacter_Kor.json",
@@ -346,13 +346,13 @@
}, },
"Costume": { "Costume": {
"filename": "LangCostume_Kor.json", "filename": "LangCostume_Kor.json",
"count": 182, "count": 186,
"sha256": "8eab70215043422bdb2d79e0ed04f831fcfc7b752010968ee559348119bfe3d3" "sha256": "e2942b92e1fcbf94fbed88cae4fa17864cfbc57ad6214f3ebb926c6bf14f71be"
}, },
"SdCostume": { "SdCostume": {
"filename": "LangSdCostume_Kor.json", "filename": "LangSdCostume_Kor.json",
"count": 290, "count": 294,
"sha256": "ebedae476122acb831a2c16ce5866c9baba24fc2826cc98a9116b18d2ff47712" "sha256": "465d369c6df4c969e904d8260c2477bb68207c0af70af3de36725cf51c4a1cea"
}, },
"GeneratedLiveActiveSkillEffect": { "GeneratedLiveActiveSkillEffect": {
"filename": "LangGeneratedLiveActiveSkillEffect_Kor.json", "filename": "LangGeneratedLiveActiveSkillEffect_Kor.json",
@@ -361,23 +361,23 @@
}, },
"GeneratedLiveActiveSkillLevel": { "GeneratedLiveActiveSkillLevel": {
"filename": "LangGeneratedLiveActiveSkillLevel_Kor.json", "filename": "LangGeneratedLiveActiveSkillLevel_Kor.json",
"count": 351, "count": 359,
"sha256": "8320427b5fbafc7e53f65c3a9d04737dba0af74b01b1757681a7c69babd6c596" "sha256": "907f43bade06ada7f5f3a00c24869526eb0612a034baa1bc7167d65ea084f1a2"
}, },
"GeneratedLiveLeaderSkill": { "GeneratedLiveLeaderSkill": {
"filename": "LangGeneratedLiveLeaderSkill_Kor.json", "filename": "LangGeneratedLiveLeaderSkill_Kor.json",
"count": 174, "count": 178,
"sha256": "b1b4f54738fb6e28a12ca11ebfcf3f74645a3134cbc893519df8ca67b5ee28d7" "sha256": "977d41dd08465944688bf322ff8e6d1b5d9f64c3b30a3e55198c46d02103430d"
}, },
"GeneratedLivePassiveSkillEffect": { "GeneratedLivePassiveSkillEffect": {
"filename": "LangGeneratedLivePassiveSkillEffect_Kor.json", "filename": "LangGeneratedLivePassiveSkillEffect_Kor.json",
"count": 178, "count": 182,
"sha256": "5d1ada4bef3a0c3cc3e890489b39d2bfaeda999081908c0b657f29a748ae915b" "sha256": "9eadbcb826569a40ed871e9894d75faf0ae4ab30c87d21fddc0791091e3ef7ec"
}, },
"GeneratedLivePassiveSkillLevel": { "GeneratedLivePassiveSkillLevel": {
"filename": "LangGeneratedLivePassiveSkillLevel_Kor.json", "filename": "LangGeneratedLivePassiveSkillLevel_Kor.json",
"count": 348, "count": 356,
"sha256": "2afe4179bafb998c2d6ddf1deec90302f66d60cd966a471d6b375c205e4eec0b" "sha256": "2794789db54429c7e415d9fb78759f0c553a1da540f5138504f4c1e41a551e12"
}, },
"GeneratedLiveSkillEffectTarget": { "GeneratedLiveSkillEffectTarget": {
"filename": "LangGeneratedLiveSkillEffectTarget_Kor.json", "filename": "LangGeneratedLiveSkillEffectTarget_Kor.json",
@@ -391,8 +391,8 @@
}, },
"GeneratedLiveSpecialSkillLevel": { "GeneratedLiveSpecialSkillLevel": {
"filename": "LangGeneratedLiveSpecialSkillLevel_Kor.json", "filename": "LangGeneratedLiveSpecialSkillLevel_Kor.json",
"count": 348, "count": 356,
"sha256": "e1d2972614f6d60b54924db9b440c3ce377a6344f537b8313058aa6d3322c72e" "sha256": "f2b1839e9a4c8c8c62a077e9620602f1a2bf8be485f0eeb75ebf360c6051f7d8"
}, },
"GeneratedSkillTreeConnectEffect": { "GeneratedSkillTreeConnectEffect": {
"filename": "LangGeneratedSkillTreeConnectEffect_Kor.json", "filename": "LangGeneratedSkillTreeConnectEffect_Kor.json",
@@ -401,8 +401,8 @@
}, },
"GeneratedSkillTreeEffect": { "GeneratedSkillTreeEffect": {
"filename": "LangGeneratedSkillTreeEffect_Kor.json", "filename": "LangGeneratedSkillTreeEffect_Kor.json",
"count": 120, "count": 121,
"sha256": "753b94f2df410cf8e259bcd29f3b9705c3441d976506f128cb1af8c7af8d2f6b" "sha256": "dbb2eba6c1def08b47338e0270c6de86b7e7ddc1c4cb2d81c91c0f76ecd8f1a7"
}, },
"GeneratedSkillTreeEffectPassiveTrigger": { "GeneratedSkillTreeEffectPassiveTrigger": {
"filename": "LangGeneratedSkillTreeEffectPassiveTrigger_Kor.json", "filename": "LangGeneratedSkillTreeEffectPassiveTrigger_Kor.json",
@@ -433,8 +433,8 @@
"jpn": { "jpn": {
"Card": { "Card": {
"filename": "LangCard_Jpn.json", "filename": "LangCard_Jpn.json",
"count": 174, "count": 178,
"sha256": "5334af207d35a342dcb2e24df7e0ae6c47f0e41fd1f1ac9b0a93b9238485b46d" "sha256": "c22b7cad065601082d44a30ec9ba6f8ac1109a3323f6315fe6db3bb292da29c1"
}, },
"Character": { "Character": {
"filename": "LangCharacter_Jpn.json", "filename": "LangCharacter_Jpn.json",
@@ -453,13 +453,13 @@
}, },
"Costume": { "Costume": {
"filename": "LangCostume_Jpn.json", "filename": "LangCostume_Jpn.json",
"count": 182, "count": 186,
"sha256": "dc376c668b8d299c05bd3f8ab302d57475a9ee1455991b462c3dd5168fc1768f" "sha256": "897b7140e9eb8d7371d87ea015e04c084e8bb2b69c8140e8079f5f2af57aa431"
}, },
"SdCostume": { "SdCostume": {
"filename": "LangSdCostume_Jpn.json", "filename": "LangSdCostume_Jpn.json",
"count": 290, "count": 294,
"sha256": "bce32f3326432c3426dee38abba709bf91aeb04207a9d058156a2e3afca0b83a" "sha256": "53599937d206a494ba80bbb354b808ec0498aa6342164756fa1db7da32e96aeb"
}, },
"GeneratedLiveActiveSkillEffect": { "GeneratedLiveActiveSkillEffect": {
"filename": "LangGeneratedLiveActiveSkillEffect_Jpn.json", "filename": "LangGeneratedLiveActiveSkillEffect_Jpn.json",
@@ -468,23 +468,23 @@
}, },
"GeneratedLiveActiveSkillLevel": { "GeneratedLiveActiveSkillLevel": {
"filename": "LangGeneratedLiveActiveSkillLevel_Jpn.json", "filename": "LangGeneratedLiveActiveSkillLevel_Jpn.json",
"count": 351, "count": 359,
"sha256": "ee55ac4522fc5b0f641b38203e4fcdcc4ea340f011553e9b1cf1be5a5c212293" "sha256": "085b4e28eb98f76eed8f418863ccffc2edc6cbdadadd4d1c778f29d449b335cd"
}, },
"GeneratedLiveLeaderSkill": { "GeneratedLiveLeaderSkill": {
"filename": "LangGeneratedLiveLeaderSkill_Jpn.json", "filename": "LangGeneratedLiveLeaderSkill_Jpn.json",
"count": 174, "count": 178,
"sha256": "c83e4436db04305094e527d6130390ff3c195fe495277b636f6a21c53986ab83" "sha256": "40ef8f1a03131f0753edcf96a4fbeb6004fd44c24f674b39713f3e0d95157c5a"
}, },
"GeneratedLivePassiveSkillEffect": { "GeneratedLivePassiveSkillEffect": {
"filename": "LangGeneratedLivePassiveSkillEffect_Jpn.json", "filename": "LangGeneratedLivePassiveSkillEffect_Jpn.json",
"count": 178, "count": 182,
"sha256": "a197afd9d1b752c2eec24c65558f1d398b44527a8d5f5f5a2f4220afcb381c3d" "sha256": "ed7224cd3edd4a46c5b2d0ab32c65d23f71a16435d5279db296b2b17e2b1c4e5"
}, },
"GeneratedLivePassiveSkillLevel": { "GeneratedLivePassiveSkillLevel": {
"filename": "LangGeneratedLivePassiveSkillLevel_Jpn.json", "filename": "LangGeneratedLivePassiveSkillLevel_Jpn.json",
"count": 348, "count": 356,
"sha256": "5e2b927e997aa438359e98b94f065d9650517ea81b173305fd706779c9a5ed9d" "sha256": "ddad142cc49f4cf53ce5bf5e66d046fc414022e43cbb150e1213409ab6029086"
}, },
"GeneratedLiveSkillEffectTarget": { "GeneratedLiveSkillEffectTarget": {
"filename": "LangGeneratedLiveSkillEffectTarget_Jpn.json", "filename": "LangGeneratedLiveSkillEffectTarget_Jpn.json",
@@ -498,8 +498,8 @@
}, },
"GeneratedLiveSpecialSkillLevel": { "GeneratedLiveSpecialSkillLevel": {
"filename": "LangGeneratedLiveSpecialSkillLevel_Jpn.json", "filename": "LangGeneratedLiveSpecialSkillLevel_Jpn.json",
"count": 348, "count": 356,
"sha256": "f20e52a776256504c2bf82f00ebd039b9afd94ae665ceaf79117b3756212cc6b" "sha256": "a19a04e6bac45766017916e815a730a52c6dd66dc01a7795e1b442e1413f4ae1"
}, },
"GeneratedSkillTreeConnectEffect": { "GeneratedSkillTreeConnectEffect": {
"filename": "LangGeneratedSkillTreeConnectEffect_Jpn.json", "filename": "LangGeneratedSkillTreeConnectEffect_Jpn.json",
@@ -508,8 +508,8 @@
}, },
"GeneratedSkillTreeEffect": { "GeneratedSkillTreeEffect": {
"filename": "LangGeneratedSkillTreeEffect_Jpn.json", "filename": "LangGeneratedSkillTreeEffect_Jpn.json",
"count": 120, "count": 121,
"sha256": "4f7fe5cd22633bea68c83f26ceb4cca1e2546d258a4bbd747f224997a5701067" "sha256": "980c5656e5ebc90546ef1cdda543158323b2acec7dc088eee8303296e073ca2b"
}, },
"GeneratedSkillTreeEffectPassiveTrigger": { "GeneratedSkillTreeEffectPassiveTrigger": {
"filename": "LangGeneratedSkillTreeEffectPassiveTrigger_Jpn.json", "filename": "LangGeneratedSkillTreeEffectPassiveTrigger_Jpn.json",
+86
View File
@@ -0,0 +1,86 @@
{
"schemaVersion": 1,
"charts": [
{
"songId": "m0157",
"difficulty": "easy",
"scoreRatio": 55.72941713590017,
"measurements": [
{ "overallPower": 348760, "directSongScoreBonusPercent": 5, "normalPerfectScore": 6571 }
]
},
{
"songId": "m0157",
"difficulty": "normal",
"scoreRatio": 89.51307748716695,
"measurements": [
{ "overallPower": 348760, "directSongScoreBonusPercent": 5, "normalPerfectScore": 4091 }
]
},
{
"songId": "m0157",
"difficulty": "hard",
"scoreRatio": 159.56339869281047,
"measurements": [
{ "overallPower": 348760, "directSongScoreBonusPercent": 5, "normalPerfectScore": 2295 }
]
},
{
"songId": "m0157",
"difficulty": "expert",
"scoreRatio": 251.68247422680412,
"measurements": [
{ "overallPower": 348760, "directSongScoreBonusPercent": 5, "normalPerfectScore": 1455 }
]
},
{
"songId": "m0121",
"difficulty": "hard",
"scoreRatio": 189.63717251606528,
"measurements": [
{ "overallPower": 348760, "directSongScoreBonusPercent": 10, "normalPerfectScore": 2023 }
]
},
{
"songId": "m0121",
"difficulty": "expert",
"scoreRatio": 318.8994181213633,
"measurements": [
{ "overallPower": 348760, "directSongScoreBonusPercent": 10, "normalPerfectScore": 1203 },
{ "overallPower": 348760, "directSongScoreBonusPercent": 8.26, "normalPerfectScore": 1184 }
]
},
{
"songId": "m0182",
"difficulty": "hard",
"scoreRatio": 234.92712798530317,
"measurements": [
{ "overallPower": 348760, "directSongScoreBonusPercent": 10, "normalPerfectScore": 1633 }
]
},
{
"songId": "m0182",
"difficulty": "expert",
"scoreRatio": 380.5912698412699,
"measurements": [
{ "overallPower": 348760, "directSongScoreBonusPercent": 10, "normalPerfectScore": 1008 }
]
},
{
"songId": "m0201",
"difficulty": "expert",
"scoreRatio": 208.21492537313435,
"measurements": [
{ "overallPower": 348760, "directSongScoreBonusPercent": 4, "normalPerfectScore": 1742 }
]
},
{
"songId": "m0319",
"difficulty": "expert",
"scoreRatio": 363.4372745490982,
"measurements": [
{ "overallPower": 348760, "directSongScoreBonusPercent": 4, "normalPerfectScore": 998 }
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+28 -3
View File
@@ -4,16 +4,20 @@
import CardsPage from '../src/routes/cards/+page.svelte'; import CardsPage from '../src/routes/cards/+page.svelte';
import AnalysisPage from '../src/routes/analysis/+page.svelte'; import AnalysisPage from '../src/routes/analysis/+page.svelte';
import CombinationPage from '../src/routes/combination/+page.svelte'; import CombinationPage from '../src/routes/combination/+page.svelte';
import EventPage from '../src/routes/event/+page.svelte';
import DataPage from '../src/routes/data/+page.svelte'; import DataPage from '../src/routes/data/+page.svelte';
import DataCardsPage from '../src/routes/data/cards/+page.svelte'; import DataCardsPage from '../src/routes/data/cards/+page.svelte';
import DataBoardsPage from '../src/routes/data/boards/+page.svelte'; import DataBoardsPage from '../src/routes/data/boards/+page.svelte';
import { setOfflinePage } from './page-store'; import { setOfflinePage } from './page-store';
import { events } from '../src/lib/data/events';
import { activeEventAt } from '../src/lib/engine/event-optimization';
const routes = new Map([ const routes = new Map([
['/', HomePage], ['/', HomePage],
['/cards', CardsPage], ['/cards', CardsPage],
['/analysis', AnalysisPage], ['/analysis', AnalysisPage],
['/combination', CombinationPage], ['/combination', CombinationPage],
['/event', EventPage],
['/data', DataPage], ['/data', DataPage],
['/data/cards', DataCardsPage], ['/data/cards', DataCardsPage],
['/data/boards', DataBoardsPage] ['/data/boards', DataBoardsPage]
@@ -26,6 +30,8 @@
let currentPath = pathFromHash(); let currentPath = pathFromHash();
let CurrentPage = routes.get(currentPath) ?? HomePage; let CurrentPage = routes.get(currentPath) ?? HomePage;
let eventNow = Date.now();
$: showEventTab = Boolean(activeEventAt(events, eventNow));
setOfflinePage(currentPath); setOfflinePage(currentPath);
function syncRoute() { function syncRoute() {
@@ -36,6 +42,7 @@
} }
onMount(() => { onMount(() => {
const eventTimer = window.setInterval(() => eventNow = Date.now(), 1_000);
const rewriteInternalLinks = () => { const rewriteInternalLinks = () => {
for (const anchor of document.querySelectorAll<HTMLAnchorElement>('a[href^="/"]')) { for (const anchor of document.querySelectorAll<HTMLAnchorElement>('a[href^="/"]')) {
anchor.href = `#${anchor.getAttribute('href')}`; anchor.href = `#${anchor.getAttribute('href')}`;
@@ -57,6 +64,7 @@
window.removeEventListener('hashchange', syncRoute); window.removeEventListener('hashchange', syncRoute);
document.removeEventListener('click', handleClick); document.removeEventListener('click', handleClick);
linkObserver.disconnect(); linkObserver.disconnect();
window.clearInterval(eventTimer);
}; };
}); });
</script> </script>
@@ -68,7 +76,15 @@
<a href="#/cards" aria-current={currentPath.startsWith('/cards') ? 'page' : undefined}>카드 목록</a> <a href="#/cards" aria-current={currentPath.startsWith('/cards') ? 'page' : undefined}>카드 목록</a>
<a href="#/analysis" aria-current={currentPath.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a> <a href="#/analysis" aria-current={currentPath.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
<a href="#/combination" aria-current={currentPath.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a> <a href="#/combination" aria-current={currentPath.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
{#if showEventTab}<a href="#/event" aria-current={currentPath.startsWith('/event') ? 'page' : undefined}>이벤트 Pt</a>{/if}
<a href="#/data" aria-current={currentPath.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a> <a href="#/data" aria-current={currentPath.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
<a
class="report-link"
href="https://docs.google.com/forms/d/1bShzsxNN-iTk0uU3RfMfmLkYXYmUTmIWMmCtQmfl_Ik"
target="_blank"
rel="noreferrer"
aria-label="실측값 제보 Google 폼을 새 탭에서 열기"
>실측값 제보 <span aria-hidden="true">↗</span></a>
</nav> </nav>
</div> </div>
</header> </header>
@@ -92,6 +108,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px;
width: min(var(--page-max-width), calc(100% - var(--page-gutter))); width: min(var(--page-max-width), calc(100% - var(--page-gutter)));
min-height: 62px; min-height: 62px;
margin: 0 auto; margin: 0 auto;
@@ -105,23 +122,31 @@
text-decoration: none; text-decoration: none;
} }
nav { display: flex; align-items: center; gap: 4px; } nav { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: flex-end; gap: 4px; overflow-x: auto; scrollbar-width: none; }
nav::-webkit-scrollbar { display: none; }
nav a { nav a {
flex: 0 0 auto;
padding: 8px 12px; padding: 8px 12px;
border-radius: 9px; border-radius: 9px;
color: #929cad; color: #929cad;
font-size: 13px; font-size: 13px;
font-weight: 750; font-weight: 750;
text-decoration: none; text-decoration: none;
white-space: nowrap;
} }
nav a:hover { color: #e2e6ed; background: rgb(255 255 255 / 5%); } nav a:hover { color: #e2e6ed; background: rgb(255 255 255 / 5%); }
nav a[aria-current='page'] { color: #f1a1c3; background: rgb(239 139 182 / 12%); } nav a[aria-current='page'] { color: #f1a1c3; background: rgb(239 139 182 / 12%); }
.report-link { flex: 0 0 auto; padding: 8px 12px; border: 1px solid #ef8bb6; border-radius: 9px; color: #21121a; background: linear-gradient(135deg, #f3a3c5, #e77ead); box-shadow: 0 0 18px rgb(239 139 182 / 18%); font-size: 13px; font-weight: 800; text-decoration: none; white-space: nowrap; }
.report-link:hover { color: #160d12; background: linear-gradient(135deg, #f8bad3, #ee94ba); box-shadow: 0 0 22px rgb(239 139 182 / 30%); }
.report-link span { margin-left: 2px; font-size: 11px; }
a:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 2px; } a:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 2px; }
@media (max-width: 560px) { @media (max-width: 560px) {
:global(:root) { --page-gutter: 24px; } :global(:root) { --page-gutter: 24px; }
.nav-inner { width: calc(100% - 24px); min-height: 54px; } .nav-inner { width: calc(100% - 24px); min-height: 54px; }
.brand { font-size: 15px; } .brand { font-size: 13px; }
nav a { padding: 7px 9px; font-size: 14px; } nav { gap: 2px; }
nav a { padding: 7px 7px; font-size: 12px; }
.report-link { padding: 7px 8px; font-size: 12px; }
} }
</style> </style>
+5
View File
@@ -0,0 +1,5 @@
// Minimal `$app/environment` adapter for the standalone offline bundle.
export const browser = true;
export const building = false;
export const dev = false;
export const version = '';
+1 -1
View File
@@ -1,7 +1,7 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
function pageValue(pathname: string) { function pageValue(pathname: string) {
return { url: new URL(pathname, 'https://offline.holodori.local') }; return { url: new URL(pathname, 'https://offline.holodori.local'), data: {} };
} }
export const page = writable(pageValue('/')); export const page = writable(pageValue('/'));
+19 -6
View File
@@ -1,18 +1,16 @@
{ {
"name": "holodream-tools", "name": "holodream-tools",
"version": "0.1.0", "version": "1.0.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "holodream-tools", "name": "holodream-tools",
"version": "0.1.0", "version": "1.0.9",
"engines": {
"node": ">=22.12.0"
},
"dependencies": { "dependencies": {
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"drizzle-orm": "^0.45.2" "drizzle-orm": "^0.45.2",
"marked": "^16.4.2"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
@@ -25,6 +23,9 @@
"svelte-check": "^4.7.4", "svelte-check": "^4.7.4",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^8.1.5" "vite": "^8.1.5"
},
"engines": {
"node": ">=22.12.0"
} }
}, },
"node_modules/@drizzle-team/brocli": { "node_modules/@drizzle-team/brocli": {
@@ -3166,6 +3167,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "holodream-tools", "name": "holodream-tools",
"version": "0.1.0", "version": "1.0.9",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -18,6 +18,7 @@
"data:sync": "node scripts/collect-holodoridb.mjs", "data:sync": "node scripts/collect-holodoridb.mjs",
"data:import-songs": "node scripts/import-legacy-song-data.mjs", "data:import-songs": "node scripts/import-legacy-song-data.mjs",
"data:collect-charts": "node scripts/collect-exact-charts.mjs", "data:collect-charts": "node scripts/collect-exact-charts.mjs",
"data:apply-score-calibrations": "node scripts/apply-score-calibrations.mjs",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio"
@@ -36,6 +37,7 @@
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"drizzle-orm": "^0.45.2" "drizzle-orm": "^0.45.2",
"marked": "^16.4.2"
} }
} }
+25
View File
@@ -0,0 +1,25 @@
# 홀로도리 계산기 1.0.2
조합 탐색의 후보 누락을 줄이고, 패시브 적용 대상과 분석 결과 표시를 개선한 업데이트입니다.
## 주요 변경 사항
- 빈 편성 슬롯이 많을수록 조합 탐색 후보와 정밀 분석 대상을 확대했습니다.
- 여러 선별 기준과 후보별 교체 조합 재검증을 추가해 강한 카드가 조기에 제외될 가능성을 줄였습니다.
- 보유 카드의 실제 개화가 자동 후보 설정값보다 높으면 실제 개화를 우선 적용합니다.
- 편성 카드 목록과 추천 스페셜 발동 순서를 구분해 표시합니다.
- 제한 인원 패시브와 스코어 서포트의 적용 대상을 파라미터 합계 기준으로 판정하도록 수정했습니다.
- 배치 분석과 조합 탐색에 최고점, 최고점의 99%, 95% 이상 달성 확률을 추가했습니다.
- 중앙 연결 노드 자동 배치 결과에서 어떤 카드가 어느 멤버 보드에 배치됐는지 확인할 수 있습니다.
- 초록 노드 효율 분석에 `코어 큐브 미소모만` 검색 옵션을 추가했습니다.
- 스크린샷을 포함한 사용자용 README를 추가했습니다.
## 알려진 문제
> 달성 확률과 이론상 최고점이 실제 인게임 결과보다 다소 높게 평가되는 현상이 확인되었습니다. 현재 결과는 절대적인 예상 점수보다 조합 간 상대 비교와 참고값으로 활용해 주세요.
달성 확률은 모든 노트를 PERFECT로 처리한다는 가정 아래 액티브 발동 확률만 반영합니다. 실제 플레이의 판정과 아직 확인되지 않은 게임 내부 계산 방식에 따라 차이가 발생할 수 있습니다.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.2.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
+19
View File
@@ -0,0 +1,19 @@
# 홀로도리 계산기 1.0.3
악곡 채보의 노트 종류와 점수 가중치를 실제 동작에 맞게 수정한 업데이트입니다.
## 주요 변경 사항
- 롱노트 내부의 모든 판정을 표시 형태와 관계없이 0.1배로 계산하도록 수정했습니다.
- 같은 시각에 존재하는 여러 롱노트 판정을 각각 점수에 반영하도록 수정했습니다.
- 롱노트 끝에 연결된 플릭은 1.0배, 독립 플릭은 1.05배로 구분하도록 수정했습니다.
- 전체 Hard/Expert 채보 데이터를 새로운 판정 규칙으로 다시 생성했습니다.
- Our Promise Expert의 시간대별 노트 가중치와 총 가중치가 비교 대상 계산기와 일치하는 것을 확인했습니다.
## 참고 사항
노트 종류별 가중치 오류가 수정되어 이전 버전보다 이론상 고점과 달성 확률이 낮아질 수 있습니다. 아직 확인되지 않은 게임 내부 계산 방식이나 패시브 적용 규칙에 따라 실제 플레이 결과와 차이가 남을 수 있으므로, 결과는 조합 간 비교와 참고값으로 함께 활용해 주세요.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.3.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
+25
View File
@@ -0,0 +1,25 @@
# 홀로도리 계산기 v1.0.4
카드 목록에서 액티브 정보를 더 쉽게 비교하고, 배치 분석과 조합 탐색의 입력 화면을 일관되게 개선한 업데이트입니다.
## 주요 변경 사항
- 카드 목록의 각 행에 액티브 주기, 확률, 지속 시간과 효과를 표시합니다.
- 액티브 주기, 확률, 지속 시간과 효과를 기준으로 오름차순·내림차순 정렬할 수 있습니다.
- 카드 목록 전체를 명함 또는 5개화 상태로 전환해 표시와 정렬 결과를 비교할 수 있습니다.
- 카드 목록 출력 조건에 `모두 취소` 기능을 추가했습니다.
- 액티브 또는 일부 값이 없는 카드는 `—`로 표시하고 정렬 시 항상 뒤에 배치합니다.
- 신규 이벤트 악곡 5곡의 정확 채보를 추가했습니다. 각 악곡은 게임 내 오픈 시간에 맞춰 계산기에서도 자동으로 사용할 수 있습니다.
- 악곡 오픈 판정은 온라인에서 서버 시각을 사용하며, 서버에 연결할 수 없는 오프라인 환경에서만 PC 시스템 시각을 사용합니다.
- 배치 분석의 카드 선택, 개화와 빈도 감소 입력을 열 헤더 중심의 표 형태로 정리했습니다.
- 조합 탐색의 탐색 조건 UI를 배치 분석과 같은 입력 구조와 스타일로 통일했습니다.
- 조합 탐색의 기수 보너스 입력을 숫자 `0~900` 범위로 제한했습니다.
- 보드 데이터의 활성 노드 수에서 항상 활성화된 가운데 연결 노드를 제외했습니다.
## 참고 사항
카드 목록의 정렬 기준, 정렬 방향과 명함·5개화 선택은 사용자 데이터에 저장됩니다. 이전 버전의 사용자 데이터와 백업 파일은 기본값을 자동으로 적용해 그대로 사용할 수 있습니다.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.4.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
+18
View File
@@ -0,0 +1,18 @@
# 홀로도리 계산기 v1.0.5
자주 사용하는 분석 조건을 빠르게 다시 불러오고, 보유 카드 중심의 조합 탐색과 업데이트 확인을 편리하게 만든 업데이트입니다.
## 주요 변경 사항
- 배치 분석과 조합 탐색에서 현재 조건을 이름별 프리셋으로 저장하고 불러오거나 삭제할 수 있습니다.
- 조합 탐색에 `내가 보유한 카드만 사용하기` 옵션을 추가해 고정 카드와 자동 후보를 보유 5성 카드로 제한할 수 있습니다.
- 멤버 보드 화면에서 `A`와 `D` 키로 이전·다음 멤버를 빠르게 이동할 수 있습니다.
- 업데이트 영역에서 현재 버전과 최신 여부를 확인하고, 새 버전이 있으면 GitHub 릴리즈 노트를 앱 안에서 바로 읽을 수 있습니다.
## 참고 사항
프리셋은 사용 중인 브라우저 또는 오프라인 앱의 로컬 저장 공간에 보관됩니다. 다른 브라우저나 PC로 자동 동기화되지는 않습니다.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.5.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
+20
View File
@@ -0,0 +1,20 @@
# 홀로도리 계산기 v1.0.6
배치 분석의 보드 조건을 세밀하게 조정하고, 조합을 완성하는 데 필요한 추가 개화 횟수를 바로 확인할 수 있도록 개선한 업데이트입니다.
## 주요 변경 사항
- 배치 분석에서 카드별로 파란 노드를 모두 활성화한 상태를 적용할 수 있습니다.
- 카드별 기수 보너스를 `0~900` 범위의 값으로 덮어쓸 수 있습니다. 값을 비워 두면 사용자 데이터에 저장된 실제 보드 수치를 사용합니다.
- 파란 노드와 기수 보너스 조건을 현재 분석 상태와 프리셋에 저장하고 다시 불러올 수 있습니다.
- 배치 분석에서 가져온 조건으로 조합 탐색의 기준 조합을 계산할 때도 같은 보드 설정을 적용합니다.
- 조합 탐색 결과에 카드별 부족한 개화 횟수와 조합 전체의 추가 개화 합계를 표시합니다.
- 미보유 카드를 처음 획득하는 과정은 추가 개화 1회로 계산합니다.
## 참고 사항
파란 노드 전체 활성화와 기수 보너스 덮어쓰기는 분석 조건에만 적용되며 사용자 데이터에 저장된 실제 보드 상태를 변경하지 않습니다.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.6.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
+25
View File
@@ -0,0 +1,25 @@
# 홀로도리 계산기 v1.0.7
점수 계산의 근거를 더 명확하게 표시하고, 악곡 선택과 연결 카드 배치를 편리하게 개선한 업데이트입니다.
## 주요 변경 사항
- 배치 분석에 보유 연결 카드 자동 최적화를 추가했습니다. 활성 노드는 유지하면서 액티브 발동률과 예상 점수를 기준으로 배치를 탐색하고, 변경 전후 결과를 비교한 뒤 적용할 수 있습니다.
- 블루 보드의 상시 액티브 발동률 증가와 스페셜의 시간제 발동률 증가를 합산해 계산하도록 수정했습니다.
- 복수 기수 소속 카드도 모든 기수 보너스의 합계를 카드별 최대 `900`까지만 적용하도록 수정했습니다.
- 등록된 실측 자료가 있는 Hard/Expert 채보 8개의 점수 계수를 보정하고, 모든 채보에 점수 계수가 `실측값`인지 `추정값`인지 표시합니다.
- 악곡을 영문 제목이나 곡 ID로 검색하고 알파벳순 또는 난이도순으로 정렬할 수 있습니다.
- 모든 Hard/Expert 채보에 난이도 레벨을 추가하고, 가능한 악곡은 공식 영문 제목으로 표시합니다.
- 카드 49장의 스페셜에서 발동률 증가, 판정 강화 또는 라이프 회복과 같은 추가 효과가 누락되지 않도록 데이터 요약을 수정했습니다.
- 보드 화면에서 연결 카드의 희귀도를 표시하고 중앙 연결 노드 자동 배치와 초록 노드 효율 목록을 개선했습니다.
## 참고 사항
- 점수 계수가 보정된 채보와 복수 기수 카드의 보드 보너스는 이전 버전과 계산 결과가 달라질 수 있습니다.
- 현재 실측 점수 계수가 등록된 채보는 일부입니다. 계산 정확도 향상을 위해 대부분의 악곡에서 실측값 제보가 필요합니다.
- 실측값 제보: [Google Form](https://docs.google.com/forms/d/1bShzsxNN-iTk0uU3RfMfmLkYXYmUTmIWMmCtQmfl_Ik)
- 연결 카드 최적화는 결과를 확인한 뒤 적용할 때만 사용자 데이터의 배치를 변경하며, 적용 직후에는 이전 배치로 복원할 수 있습니다.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.7.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
+23
View File
@@ -0,0 +1,23 @@
# 홀로도리 계산기 v1.0.8
최신 HolodoriDB 데이터와 신규 악곡 채보를 반영하고, 악곡 공개 시각에 맞춰 선택 목록을 갱신하도록 개선한 업데이트입니다.
## 주요 변경 사항
- 시라카미 후부키, 오오카미 미오, 네코마타 오카유, 이누가미 코로네의 수영복 5성 카드를 추가했습니다.
- `Say!Fanfare!`, `Yakou Tsuushinn`, `KAMISAMA・NEKOSAMA`, `SAIKYO TENSAI WANDERFUL WORLD of KORONE`, `We are GAMERS !!!!`, `Backseat`의 Hard/Expert 채보를 추가했습니다.
- HolodoriDB의 악곡 공개 시각을 채보 카탈로그에 포함하고, 공개 전인 악곡은 해당 시각이 지난 뒤 선택 목록에 표시하도록 개선했습니다.
- 최신 카드, 보드, 메모리 데이터를 반영해 카드 수를 178장으로, 메모리 최대 수를 29개로 갱신했습니다.
- 기존 채보를 최신 원본 기준으로 다시 수집하고 일부 악곡의 재생 시간과 원본 메타데이터를 바로잡았습니다.
- 보드 데이터 검증이 특정 노드 수에 의존하지 않고 모든 보드 모델의 구조적 일관성을 확인하도록 개선했습니다.
## 참고 사항
- `Backseat`은 게임 내 공개 시각인 2026년 8월 30일 00:00(KST)부터 선택할 수 있습니다.
- 현재 실측 점수 계수가 등록된 채보는 일부입니다. 계산 정확도 향상을 위해 대부분의 악곡에서 실측값 제보가 필요합니다.
- 실측값 제보: [제보 링크를 여기에 추가해 주세요]
- 신규 채보의 점수 계수는 실측 자료가 확보되기 전까지 추정값으로 표시됩니다.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.8.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
+22
View File
@@ -0,0 +1,22 @@
# 홀로도리 계산기 v1.0.9
진행 중인 이벤트의 보너스를 반영한 이벤트 Pt 최적화를 추가하고, 점수 분석과 조합 탐색 결과를 개선한 업데이트입니다.
## 주요 변경 사항
- 진행 중인 이벤트에서 편성 대상 멤버, 신규 카드, 개화 단계에 따른 획득량 보너스를 반영해 예상 이벤트 Pt가 높은 조합을 탐색할 수 있습니다.
- 과제곡 대응 카드의 스코어 보너스를 반영하고, 이벤트 탐색에서는 최고점보다 예상 점수를 우선해 스페셜 순서를 선택하도록 개선했습니다.
- 이벤트 기간에만 상단 메뉴에 `이벤트 Pt` 항목을 표시하며 온라인과 오프라인 버전에서 동일하게 사용할 수 있습니다.
- 배치 분석 결과에 초당 노트 분포와 멤버별 액티브·스페셜 발동 구간을 보여주는 점수 타임라인을 추가했습니다.
- 배치 분석과 조합 탐색에서 카드별 가창 보너스를 직접 덮어쓸 수 있도록 개선했습니다.
- 일반 배치 분석은 예상 점수보다 이론상 최고점을 우선해 최적 순서를 선택하도록 기준을 명확히 했습니다.
## 참고 사항
- 이벤트 Pt 지수는 예상 점수에 편성·신규 카드·개화 보너스를 적용한 비교값입니다. 게임 서버의 최종 반올림과 멀티 라이브 보정에 따라 실제 획득 Pt와 차이가 날 수 있습니다.
- 이벤트 정보와 메뉴는 등록된 이벤트 기간을 기준으로 자동 활성화되고 종료 후 비활성화됩니다.
- 이벤트 계산 정확도는 사용자 카드 보유 여부, 개화 단계, 보드와 멤버 강화 데이터가 정확하다는 전제에 따릅니다.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.9.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
+31
View File
@@ -0,0 +1,31 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
const calibrationPath = path.resolve('data/score-calibrations.json');
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
const chartDirectory = path.resolve('static/data/charts');
const calibrations = JSON.parse(await readFile(calibrationPath, 'utf8'));
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
const catalogRows = Array.isArray(catalog) ? catalog : catalog.charts;
for (const calibration of calibrations.charts) {
const difficulty = calibration.difficulty.toLowerCase();
const catalogRow = catalogRows.find((row) =>
row.songId === calibration.songId && row.difficulty.toLowerCase() === difficulty
);
// Easy/Normal 실측도 보관하되 현재 생성 대상인 Hard/Expert만 즉시 동기화한다.
if (!catalogRow) continue;
catalogRow.scoreRatio = calibration.scoreRatio;
catalogRow.scoreCalibrationSource = 'measured';
const chartPath = path.join(chartDirectory, `${calibration.songId}.${difficulty}.json`);
const chart = JSON.parse(await readFile(chartPath, 'utf8'));
chart.scoreRatio = calibration.scoreRatio;
chart.scoreCalibrationSource = 'measured';
await writeFile(chartPath, `${JSON.stringify(chart)}\n`);
}
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
+103 -12
View File
@@ -1,4 +1,5 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import path from 'node:path'; import path from 'node:path';
const sourceOrigin = process.argv[2] ?? 'https://dreams.wf-calc.net'; const sourceOrigin = process.argv[2] ?? 'https://dreams.wf-calc.net';
@@ -6,8 +7,29 @@ const outputDirectory = path.resolve('static/data/charts');
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json'); const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
const selectedDifficulties = new Set(['HARD', 'EXPERT']); const selectedDifficulties = new Set(['HARD', 'EXPERT']);
const chartScoreWeightCoefficient = 0.3713416563352296; const chartScoreWeightCoefficient = 0.3713416563352296;
const musicMaster = JSON.parse(await readFile(path.resolve('data/raw/holodoridb/kor/Music.json'), 'utf8')); const scoreCalibrations = JSON.parse(await readFile(path.resolve('data/score-calibrations.json'), 'utf8'));
const scoreCalibrationByKey = new Map(scoreCalibrations.charts.map((row) => [
`${row.songId}::${row.difficulty}`,
row
]));
function readHolodoriDbSource(locale, filename) {
const repositoryPath = path.resolve(`data/raw/holodoridb/${locale}`);
return JSON.parse(execFileSync('git', [
'-c', `safe.directory=${repositoryPath.replaceAll('\\', '/')}`,
'-C', repositoryPath,
'show', `origin/main:${filename}`
], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }));
}
const musicMaster = readHolodoriDbSource('kor', 'Music.json');
const musicById = new Map(musicMaster.map((row) => [row.data?.id ?? row.id, row.data ?? row])); const musicById = new Map(musicMaster.map((row) => [row.data?.id ?? row.id, row.data ?? row]));
const englishMusicText = readHolodoriDbSource('eng', 'LangMusic_Eng.json');
const englishTextById = new Map(englishMusicText.map((row) => [row.data?.id ?? row.id, row.data?.text ?? row.text]));
const musicDifficultyMaster = readHolodoriDbSource('kor', 'MusicDifficulty.json');
const difficultyLevelByKey = new Map(musicDifficultyMaster.map((row) => {
const data = row.data ?? row;
return [`${data.musicId}::${data.difficultyType.split('_').at(-1)?.toLowerCase()}`, data.difficultyLevel];
}));
const singerMetadataOverrides = { const singerMetadataOverrides = {
m0334: { singerType: 'solo', singerCharacterIds: ['chr-04001'] } m0334: { singerType: 'solo', singerCharacterIds: ['chr-04001'] }
}; };
@@ -79,12 +101,56 @@ function extractJsonParseArgument(moduleText) {
function flattenEvents(chartData) { function flattenEvents(chartData) {
const noteEvents = chartData.note_events ?? {}; const noteEvents = chartData.note_events ?? {};
const green = (noteEvents.green_blocks ?? noteEvents.green ?? []) const greenBlocks = (noteEvents.green_blocks ?? noteEvents.green ?? [])
.flatMap((block) => Array.isArray(block) ? block : [block]); .map((rawBlock) => Array.isArray(rawBlock) ? rawBlock : [rawBlock]);
const pink = noteEvents.pink ?? [];
const internalGreenKeys = new Set(greenBlocks.flatMap((block) => block.slice(1, -1)
.map((note) => `${note.time_seconds}|${note.lane}`)));
const beatGap = (end, flick) => flick.beat - end.beat;
const sameShape = (end, flick) =>
end.lane_center === flick.lane_center && end.lane_half_width === flick.lane_half_width;
const shapeOverlapRatio = (end, flick) => {
const left = Math.max(end.lane_center - end.lane_half_width, flick.lane_center - flick.lane_half_width);
const right = Math.min(end.lane_center + end.lane_half_width, flick.lane_center + flick.lane_half_width);
const unionLeft = Math.min(end.lane_center - end.lane_half_width, flick.lane_center - flick.lane_half_width);
const unionRight = Math.max(end.lane_center + end.lane_half_width, flick.lane_center + flick.lane_half_width);
return Math.max(0, right - left) / Math.max(Number.EPSILON, unionRight - unionLeft);
};
const isDirectContinuation = (end, flick) =>
beatGap(end, flick) > 0 && beatGap(end, flick) <= 0.25 + 1e-9 && sameShape(end, flick);
const continuesIntoFlick = (note) => pink.some((flick) => isDirectContinuation(note, flick));
// 롱노트 끝 플릭은 색상이 아니라 직전 green block과의 연결 관계로 구분한다.
// 끝이 mid인 블록은 다음 플릭이 끝 판정을 대신하며, 아주 짧은 동일 형상 연결도 같은 경우다.
const isHoldEndFlick = (flick) => greenBlocks.some((block) => {
const end = block.at(-1);
if (!end) return false;
const gap = beatGap(end, flick);
return isDirectContinuation(end, flick) ||
(end.note_type === 'mid' && gap > 0 && gap <= 0.5 + 1e-9 && shapeOverlapRatio(end, flick) >= 0.8);
});
const green = greenBlocks
.flatMap((rawBlock) => {
const block = Array.isArray(rawBlock) ? rawBlock : [rawBlock];
return block.map((note, index) => ({
...note,
// 롱노트 안쪽 판정은 점 모양이어도 0.1배다. 시작·끝의 일반 판정만 1배다.
type: (index === 0 || index === block.length - 1) &&
note.note_type !== 'mid' &&
!internalGreenKeys.has(`${note.time_seconds}|${note.lane}`) &&
!(index === block.length - 1 && continuesIntoFlick(note))
? 'regular'
: 'mid'
}));
});
return [ return [
...(noteEvents.blue ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular' })), ...(noteEvents.blue ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
...green.map((note) => ({ ...note, type: note.note_type ?? 'regular' })), ...green,
...(noteEvents.pink ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular', is_flick: true })) // 롱노트 끝에 붙은 플릭은 판정 모양과 달리 1.0배, 독립 플릭만 1.05배다.
...pink.map((note) => ({
...note,
type: note.note_type ?? 'regular',
is_flick: !isHoldEndFlick(note)
}))
] ]
.map((note) => ({ .map((note) => ({
timeSeconds: note.time_seconds, timeSeconds: note.time_seconds,
@@ -95,7 +161,23 @@ function flattenEvents(chartData) {
.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1)); .sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
} }
function scoreRatioFromNotes(notes) { function legacyCalibrationNotes(chartData) {
const noteEvents = chartData.note_events ?? {};
const green = (noteEvents.green_blocks ?? noteEvents.green ?? [])
.flatMap((block) => Array.isArray(block) ? block : [block]);
return [
...(noteEvents.blue ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
...green.map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
...(noteEvents.pink ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular', is_flick: true }))
].map((note) => ({
timeSeconds: note.time_seconds,
type: note.type === 'mid' ? 'mid' : 'regular',
isFlick: note.is_flick === true
}));
}
function calibratedScoreRatio(chartData) {
const notes = legacyCalibrationNotes(chartData);
const countedMidTimes = new Set(); const countedMidTimes = new Set();
let weight = 0; let weight = 0;
for (const note of notes) { for (const note of notes) {
@@ -130,8 +212,10 @@ const importPattern = /charts\/([^"/]+\.json)"[^\n]{0,180}?import\("\.\/([^"/]+\
for (const match of simulatorModule.matchAll(importPattern)) { for (const match of simulatorModule.matchAll(importPattern)) {
const [, chartFile, assetFile] = match; const [, chartFile, assetFile] = match;
const [songId, difficultyAndLevel] = chartFile.split('.', 2); const [songId, difficultyAndLevel] = chartFile.split('.', 2);
const difficulty = difficultyAndLevel.split('-')[0].toUpperCase(); const [difficultyName, difficultyLevelText] = difficultyAndLevel.split('-');
if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, chartFile, assetFile }); const difficulty = difficultyName.toUpperCase();
const difficultyLevel = Number.parseInt(difficultyLevelText, 10);
if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, difficultyLevel, chartFile, assetFile });
} }
if (imports.length === 0) throw new Error('수집할 Hard/Expert 채보 모듈을 찾지 못했습니다.'); if (imports.length === 0) throw new Error('수집할 Hard/Expert 채보 모듈을 찾지 못했습니다.');
@@ -150,14 +234,17 @@ async function worker() {
const sourceEntry = catalogByKey.get(`${item.songId}::${item.difficulty}`); const sourceEntry = catalogByKey.get(`${item.songId}::${item.difficulty}`);
const music = musicById.get(item.songId); const music = musicById.get(item.songId);
const singerOverride = singerMetadataOverrides[item.songId]; const singerOverride = singerMetadataOverrides[item.songId];
const releaseTime = Number(music?.startTime);
const notes = flattenEvents(chartData); const notes = flattenEvents(chartData);
const measuredCalibration = scoreCalibrationByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`);
const output = { const output = {
schemaVersion: 1, schemaVersion: 1,
songId: item.songId, songId: item.songId,
difficulty: item.difficulty.toLowerCase(), difficulty: item.difficulty.toLowerCase(),
durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0), durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0),
scoreRatio: scoreRatioFromNotes(notes), // 실측이 있으면 우선하고, 없을 때만 채보 가중치와 계산기 고유 상수로 추정한다.
scoreCalibrationSource: 'measured-formula-v1', scoreRatio: measuredCalibration?.scoreRatio ?? calibratedScoreRatio(chartData),
scoreCalibrationSource: measuredCalibration ? 'measured' : 'local-estimated-v1',
notes, notes,
specialStartSeconds: (chartData.sp_activation_events ?? []) specialStartSeconds: (chartData.sp_activation_events ?? [])
.sort((left, right) => left.slot - right.slot) .sort((left, right) => left.slot - right.slot)
@@ -174,13 +261,17 @@ async function worker() {
await writeFile(path.join(outputDirectory, fileName), JSON.stringify(output), 'utf8'); await writeFile(path.join(outputDirectory, fileName), JSON.stringify(output), 'utf8');
normalizedCharts.push({ normalizedCharts.push({
songId: item.songId, songId: item.songId,
title: sourceEntry?.title ?? item.songId, title: englishTextById.get(`la-music_title-${item.songId}`) ?? sourceEntry?.title ?? item.songId,
difficulty: item.difficulty.toLowerCase(), difficulty: item.difficulty.toLowerCase(),
difficultyLevel: difficultyLevelByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`) ??
(Number.isFinite(item.difficultyLevel) ? item.difficultyLevel : null),
durationSeconds: output.durationSeconds, durationSeconds: output.durationSeconds,
scoreRatio: output.scoreRatio, scoreRatio: output.scoreRatio,
scoreCalibrationSource: output.scoreCalibrationSource,
totalNotes: notes.length, totalNotes: notes.length,
singerType: output.singerType, singerType: output.singerType,
singerCharacterIds: output.singerCharacterIds, singerCharacterIds: output.singerCharacterIds,
releaseTime: Number.isFinite(releaseTime) ? releaseTime : null,
path: `/data/charts/${fileName}` path: `/data/charts/${fileName}`
}); });
completed += 1; completed += 1;
@@ -194,7 +285,7 @@ await Promise.all(Array.from({ length: 6 }, () => worker()));
normalizedCharts.sort((left, right) => left.songId.localeCompare(right.songId) || left.difficulty.localeCompare(right.difficulty)); normalizedCharts.sort((left, right) => left.songId.localeCompare(right.songId) || left.difficulty.localeCompare(right.difficulty));
await writeFile(catalogPath, `${JSON.stringify({ await writeFile(catalogPath, `${JSON.stringify({
schemaVersion: 1, schemaVersion: 1,
source: sourceOrigin, source: `${sourceOrigin}; hololive Dreams CDN via https://github.com/HolodoriDB/holodori-asset-tools`,
charts: normalizedCharts charts: normalizedCharts
}, null, 2)}\n`, 'utf8'); }, null, 2)}\n`, 'utf8');
console.log(`${normalizedCharts.length}개 정확 채보를 저장했습니다.`); console.log(`${normalizedCharts.length}개 정확 채보를 저장했습니다.`);
+4 -2
View File
@@ -581,7 +581,7 @@ function summarizeSpecialSkillLevels(levels) {
return { return {
...summary, ...summary,
condition, condition,
effect: condition ? [baseEffect, conditionalEffect].filter(Boolean).join(' ') : baseEffect effect: [baseEffect, conditionalEffect].filter(Boolean).join(' ')
}; };
}); });
} }
@@ -735,7 +735,9 @@ const memorySummary = {
assert(boardSummary.members.length > 0, 'no member boards were collected'); assert(boardSummary.members.length > 0, 'no member boards were collected');
assert(Object.keys(boardSummary.models).length > 0, 'no board position models were collected'); assert(Object.keys(boardSummary.models).length > 0, 'no board position models were collected');
assert(boardSummary.members.every((member) => boardSummary.models[member.positionGroupId]?.length === 152), 'one or more members have incomplete board positions'); const boardPositionCounts = Object.values(boardSummary.models).map((positions) => positions.length);
assert(boardPositionCounts.every((count) => count > 0), 'one or more board position models are empty');
assert(new Set(boardPositionCounts).size === 1, 'board position models have inconsistent position counts');
assert(boardSummary.members.every((member) => boardSummary.models[member.positionGroupId].every((position) => skillTreeNodeForCharacter(member.id, position.groupId))), 'one or more members have unresolved board nodes'); assert(boardSummary.members.every((member) => boardSummary.models[member.positionGroupId].every((position) => skillTreeNodeForCharacter(member.id, position.groupId))), 'one or more members have unresolved board nodes');
assert(memorySummary.maxCount > 0, 'no memory posters were collected'); assert(memorySummary.maxCount > 0, 'no memory posters were collected');
assert(memorySummary.effects.length > 0, 'no memory collection effects were collected'); assert(memorySummary.effects.length > 0, 'no memory collection effects were collected');
+15 -6
View File
@@ -97,11 +97,16 @@
} }
</script> </script>
<ol class="order-list"> <div class="order-sections">
{#each result.order as cardId, slot} <section class="order-section">
<li><span class="slot">{slot + 1}</span><strong>{cardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li> <h3>추천 스페셜 발동 순서</h3>
{/each} <ol class="order-list compact">
</ol> {#each result.order as cardId, slot}
<li><span class="slot">{slot + 1}</span><strong>{cardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each}
</ol>
</section>
</div>
{#if showScoreSummary} {#if showScoreSummary}
<div class="score-grid"> <div class="score-grid">
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.expectedScore, baseline.expectedScore)}</small>{/if}</div> <div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.expectedScore, baseline.expectedScore)}</small>{/if}</div>
@@ -164,7 +169,10 @@
</section> </section>
<style> <style>
.order-list { display: grid; gap: 8px; padding: 0; margin: 18px 0 0; list-style: none; } .order-sections { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 18px; }
.order-section { min-width: 0; }
.order-section h3 { margin: 0 0 9px; color: #aeb7c5; font-size: 14px; }
.order-list { display: grid; gap: 8px; padding: 0; margin: 0; list-style: none; }
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; } .order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; } .slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -206,6 +214,7 @@
.time-axis span:first-child { transform: none; } .time-axis span:first-child { transform: none; }
.time-axis span:last-child { transform: translateX(-100%); } .time-axis span:last-child { transform: translateX(-100%); }
@media (max-width: 700px) { @media (max-width: 700px) {
.order-sections { grid-template-columns: 1fr; }
.order-list li { grid-template-columns: 30px minmax(0, 1fr); } .order-list li { grid-template-columns: 30px minmax(0, 1fr); }
.reduction { grid-column: 2; justify-self: start; } .reduction { grid-column: 2; justify-self: start; }
.score-grid { grid-template-columns: 1fr; } .score-grid { grid-template-columns: 1fr; }
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
type Preset = {
id: string;
name: string;
updatedAt: string;
state: unknown;
};
type PresetDocument = {
schemaVersion: 1;
presets: Preset[];
};
export let storageKey: string;
export let snapshot: unknown;
export let disabled = false;
const dispatch = createEventDispatcher<{ apply: unknown }>();
let presets: Preset[] = [];
let selectedId = '';
let name = '';
let ready = false;
let message = '';
let messageIsError = false;
$: selectedPreset = presets.find((preset) => preset.id === selectedId);
onMount(() => {
try {
const parsed = JSON.parse(localStorage.getItem(storageKey) ?? 'null') as Partial<PresetDocument> | null;
if (parsed?.schemaVersion === 1 && Array.isArray(parsed.presets)) {
presets = parsed.presets.filter(isPreset).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
} catch {
showMessage('프리셋 목록을 읽지 못했습니다.', true);
} finally {
ready = true;
}
});
function isPreset(value: unknown): value is Preset {
if (!value || typeof value !== 'object') return false;
const preset = value as Partial<Preset>;
return typeof preset.id === 'string' && typeof preset.name === 'string' &&
typeof preset.updatedAt === 'string' && 'state' in preset;
}
function persist(next: Preset[]): boolean {
try {
localStorage.setItem(storageKey, JSON.stringify({ schemaVersion: 1, presets: next } satisfies PresetDocument));
presets = next;
return true;
} catch {
showMessage('프리셋을 저장할 수 없습니다. 브라우저 저장 공간을 확인해 주세요.', true);
return false;
}
}
function showMessage(text: string, isError = false): void {
message = text;
messageIsError = isError;
}
function save(): void {
const normalizedName = name.trim();
if (!normalizedName) {
showMessage('프리셋 이름을 입력해 주세요.', true);
return;
}
const now = new Date().toISOString();
const existing = presets.find((preset) => preset.name.toLocaleLowerCase('ko-KR') === normalizedName.toLocaleLowerCase('ko-KR'));
const saved: Preset = {
id: existing?.id ?? globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: normalizedName,
updatedAt: now,
state: JSON.parse(JSON.stringify(snapshot))
};
const next = [saved, ...presets.filter((preset) => preset.id !== saved.id)];
if (!persist(next)) return;
selectedId = saved.id;
name = saved.name;
showMessage(existing ? '같은 이름의 프리셋을 갱신했습니다.' : '현재 조건을 프리셋으로 저장했습니다.');
}
function apply(): void {
if (!selectedPreset) return;
dispatch('apply', JSON.parse(JSON.stringify(selectedPreset.state)));
name = selectedPreset.name;
showMessage(`‘${selectedPreset.name}’ 프리셋을 불러왔습니다.`);
}
function remove(): void {
if (!selectedPreset) return;
const removedName = selectedPreset.name;
if (!persist(presets.filter((preset) => preset.id !== selectedPreset!.id))) return;
selectedId = '';
name = '';
showMessage(`‘${removedName}’ 프리셋을 삭제했습니다.`);
}
function selectPreset(): void {
if (selectedPreset) name = selectedPreset.name;
message = '';
}
</script>
<div class="preset-manager" aria-label="프리셋 관리">
<div class="preset-heading">
<strong>프리셋</strong>
<span>현재 조건을 이름별로 저장하고 다시 불러옵니다.</span>
</div>
<div class="preset-controls">
<input bind:value={name} maxlength="40" placeholder="프리셋 이름" aria-label="프리셋 이름" disabled={disabled || !ready} on:keydown={(event) => event.key === 'Enter' && save()}>
<button class="save" type="button" disabled={disabled || !ready} on:click={save}>저장</button>
<select bind:value={selectedId} aria-label="저장된 프리셋" disabled={disabled || presets.length === 0} on:change={selectPreset}>
<option value="">{presets.length === 0 ? '저장된 프리셋 없음' : '불러올 프리셋 선택'}</option>
{#each presets as preset}<option value={preset.id}>{preset.name}</option>{/each}
</select>
<button type="button" disabled={disabled || !selectedPreset} on:click={apply}>불러오기</button>
<button class="delete" type="button" disabled={disabled || !selectedPreset} on:click={remove}>삭제</button>
</div>
{#if message}<p class:error={messageIsError} aria-live="polite">{message}</p>{/if}
</div>
<style>
.preset-manager { margin-top: 18px; padding: 14px; border: 1px solid #343b47; border-radius: 11px; background: #12161c; }
.preset-heading { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
.preset-heading strong { color: #e4e8ee; font-size: 14px; }
.preset-heading span { color: #7f8998; font-size: 12px; }
.preset-controls { display: grid; grid-template-columns: minmax(150px, .8fr) auto minmax(190px, 1fr) auto auto; gap: 7px; }
input, select, button { min-width: 0; height: 38px; border: 1px solid #3a4350; border-radius: 8px; font: inherit; }
input, select { padding: 0 10px; color: #e8ebf1; background: #0f1319; }
input:focus, select:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
button { padding: 0 13px; color: #c7ced8; background: #252c36; font-size: 13px; font-weight: 800; cursor: pointer; }
button:hover:not(:disabled) { color: #fff; background: #303844; }
button.save { border-color: #bd6e91; color: #21151b; background: #df83aa; }
button.save:hover:not(:disabled) { background: #eda0c0; }
button.delete { color: #e5a4a4; }
button:disabled, input:disabled, select:disabled { opacity: .45; cursor: default; }
p { margin: 9px 2px 0; color: #8fc9a6; font-size: 12px; }
p.error { color: #ff9f9f; }
@media (max-width: 700px) {
.preset-heading { align-items: flex-start; flex-direction: column; gap: 4px; }
.preset-controls { grid-template-columns: minmax(0, 1fr) auto auto; }
.preset-controls select { grid-column: 1 / -1; grid-row: 2; }
}
</style>
@@ -0,0 +1,210 @@
<script lang="ts">
import type { NoteScorePoint } from '$lib/engine/index.ts';
export let notes: readonly NoteScorePoint[];
export let durationSeconds: number;
export let minimumWidth = 720;
export let timeTicks: readonly number[] = [];
type ScorePoint = {
timeSeconds: number;
averageScore: number;
maximumScore: number;
};
const HEIGHT = 220;
const LEFT = 0;
const RIGHT = 0;
const TOP = 8;
const BOTTOM = 8;
const PLOT_HEIGHT = HEIGHT - TOP - BOTTOM;
let chartWidth = 1000;
let hoverTime: number | null = null;
let tooltipPercent = 0;
$: viewWidth = Math.max(320, chartWidth);
$: plotWidth = viewWidth - LEFT - RIGHT;
$: points = buildPoints(notes);
$: finalMaximumScore = Math.max(1, points.at(-1)?.maximumScore ?? 0);
$: chartMaximumScore = Math.max(500_000, Math.ceil(finalMaximumScore / 500_000) * 500_000);
$: averagePath = linePath(points, 'averageScore', durationSeconds, chartMaximumScore, viewWidth);
$: maximumPath = linePath(points, 'maximumScore', durationSeconds, chartMaximumScore, viewWidth);
$: yTicks = Array.from({ length: chartMaximumScore / 500_000 + 1 }, (_, index) => index * 500_000);
$: hoveredPoint = hoverTime === null ? null : pointAtTime(points, hoverTime);
function buildPoints(source: readonly NoteScorePoint[]): ScorePoint[] {
let averageScore = 0;
let maximumScore = 0;
const result: ScorePoint[] = [{ timeSeconds: 0, averageScore: 0, maximumScore: 0 }];
for (const note of source) {
averageScore += note.expectedScore;
maximumScore += note.theoreticalMaxScore;
result.push({ timeSeconds: note.timeSeconds, averageScore, maximumScore });
}
return result;
}
function x(timeSeconds: number, width = viewWidth): number {
return LEFT + Math.max(0, Math.min(1, timeSeconds / Math.max(1, durationSeconds))) * (width - LEFT - RIGHT);
}
function y(score: number, scaleMaximum = chartMaximumScore): number {
return TOP + (1 - score / scaleMaximum) * PLOT_HEIGHT;
}
function linePath(source: readonly ScorePoint[], field: 'averageScore' | 'maximumScore', duration: number, scaleMaximum: number, width: number): string {
if (source.length === 0) return '';
const commands = [`M ${x(source[0].timeSeconds, width).toFixed(2)} ${y(source[0][field], scaleMaximum).toFixed(2)}`];
for (let index = 1; index < source.length; index += 1) {
const previous = source[index - 1];
const point = source[index];
commands.push(
`L ${x(point.timeSeconds, width).toFixed(2)} ${y(previous[field], scaleMaximum).toFixed(2)}`,
`L ${x(point.timeSeconds, width).toFixed(2)} ${y(point[field], scaleMaximum).toFixed(2)}`
);
}
const last = source.at(-1)!;
commands.push(`L ${x(duration, width).toFixed(2)} ${y(last[field], scaleMaximum).toFixed(2)}`);
return commands.join(' ');
}
function pointAtTime(source: readonly ScorePoint[], timeSeconds: number): ScorePoint {
let low = 0;
let high = source.length - 1;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (source[middle].timeSeconds <= timeSeconds) low = middle;
else high = middle - 1;
}
return source[low] ?? { timeSeconds: 0, averageScore: 0, maximumScore: 0 };
}
function updateHover(clientX: number, target: EventTarget | null): void {
if (!(target instanceof HTMLElement)) return;
const bounds = target.getBoundingClientRect();
const plotLeft = bounds.left + bounds.width * LEFT / viewWidth;
const renderedPlotWidth = bounds.width * plotWidth / viewWidth;
const ratio = Math.max(0, Math.min(1, (clientX - plotLeft) / Math.max(1, renderedPlotWidth)));
hoverTime = ratio * durationSeconds;
tooltipPercent = (LEFT + ratio * plotWidth) / viewWidth * 100;
}
function onPointerMove(event: PointerEvent): void {
updateHover(event.clientX, event.currentTarget);
}
function onKeydown(event: KeyboardEvent): void {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const step = event.key === 'Home' ? -durationSeconds : event.key === 'End' ? durationSeconds : event.key === 'ArrowLeft' ? -1 : 1;
hoverTime = Math.max(0, Math.min(durationSeconds, (hoverTime ?? 0) + step));
tooltipPercent = (LEFT + hoverTime / Math.max(1, durationSeconds) * plotWidth) / viewWidth * 100;
}
function formatScore(score: number): string {
return Math.round(score).toLocaleString('ko-KR');
}
function formatAxisScore(score: number): string {
return score === 0 ? '0' : `${Math.round(score / 10_000).toLocaleString('ko-KR')}만`;
}
function formatTime(timeSeconds: number): string {
const minutes = Math.floor(timeSeconds / 60);
const seconds = Math.floor(timeSeconds % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
</script>
<div class="score-row">
<strong class="lane-label">
<span class="label-heading">
<span>시간에 따른 점수</span>
<small class="legend" aria-label="점수 그래프 범례">
<span><i class="average"></i>평균</span>
<span><i class="maximum"></i>최고</span>
</small>
</span>
{#each yTicks as tick}
<span class="axis-value" style={`top: ${y(tick)}px;`}>{formatAxisScore(tick)}</span>
{/each}
</strong>
<div
class="chart-wrap score-track"
bind:clientWidth={chartWidth}
style={`min-width: ${minimumWidth}px;`}
role="slider"
aria-label="점수를 확인할 시각"
aria-valuemin="0"
aria-valuemax={Math.ceil(durationSeconds)}
aria-valuenow={Math.round(hoverTime ?? 0)}
aria-valuetext={hoveredPoint && hoverTime !== null ? `${formatTime(hoverTime)}, 평균 점수 ${formatScore(hoveredPoint.averageScore)}, 최고 점수 ${formatScore(hoveredPoint.maximumScore)}` : '방향키로 시각을 선택하세요'}
tabindex="0"
on:pointermove={onPointerMove}
on:pointerleave={() => hoverTime = null}
on:focus={() => { if (hoverTime === null) { hoverTime = 0; tooltipPercent = LEFT / viewWidth * 100; } }}
on:blur={() => hoverTime = null}
on:keydown={onKeydown}
>
<svg
viewBox={`0 0 ${viewWidth} ${HEIGHT}`}
role="img"
aria-label="시간에 따른 누적 평균 점수와 최고 점수 그래프"
>
{#each timeTicks as tick}
<line class="time-grid-line" x1={x(tick, viewWidth)} x2={x(tick, viewWidth)} y1={TOP} y2={HEIGHT - BOTTOM} />
{/each}
{#each yTicks as tick}
<line class="grid-line" x1={LEFT} x2={viewWidth - RIGHT} y1={y(tick)} y2={y(tick)} />
{/each}
<path class="score-line maximum-line" d={maximumPath} />
<path class="score-line average-line" d={averagePath} />
{#if hoveredPoint && hoverTime !== null}
<line class="cursor-line" x1={x(hoverTime, viewWidth)} x2={x(hoverTime, viewWidth)} y1={TOP} y2={HEIGHT - BOTTOM} />
<circle class="point maximum-point" cx={x(hoverTime, viewWidth)} cy={y(hoveredPoint.maximumScore)} r="5" />
<circle class="point average-point" cx={x(hoverTime, viewWidth)} cy={y(hoveredPoint.averageScore)} r="5" />
{/if}
</svg>
{#if hoveredPoint && hoverTime !== null}
<div class:align-right={tooltipPercent > 68} class="tooltip" style={`left: ${tooltipPercent}%;`} role="status">
<strong>{formatTime(hoverTime)}</strong>
<span><i class="average"></i>평균 점수 <b>{formatScore(hoveredPoint.averageScore)}</b></span>
<span><i class="maximum"></i>최고 점수 <b>{formatScore(hoveredPoint.maximumScore)}</b></span>
</div>
{/if}
</div>
</div>
<style>
.score-row { display: flex; min-height: 220px; margin-bottom: 8px; }
.lane-label { position: sticky; left: 0; z-index: 4; display: block; flex: 0 0 144px; min-width: 144px; padding: 0; color: #e1e5ec; background: #141920; font-size: 12px; line-height: 1.4; }
.label-heading { position: absolute; top: 50%; left: 12px; display: grid; gap: 7px; width: 88px; transform: translateY(-50%); }
.legend { display: flex; gap: 9px; color: #aab2bf; font-size: 10px; font-weight: 700; }
.legend span, .tooltip span { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.axis-value { position: absolute; right: 7px; color: #747f90; font-size: 10px; font-weight: 650; transform: translateY(-50%); white-space: nowrap; }
.score-track { position: relative; flex: 1 0 auto; height: 220px; overflow: hidden; border-bottom: 1px solid #465062; background: #10141a; }
i { display: inline-block; flex: none; width: 8px; height: 8px; border-radius: 50%; }
i.average { background: #f08db8; }
i.maximum { background: #64cbe4; }
svg { display: block; width: 100%; height: 220px; pointer-events: none; }
.chart-wrap { outline: none; touch-action: pan-y; }
.chart-wrap:focus-visible { z-index: 3; outline: 2px solid #ed8bb5; outline-offset: -2px; }
.grid-line { stroke: rgb(255 255 255 / 9%); stroke-width: 1; }
.time-grid-line { stroke: rgb(255 255 255 / 7%); stroke-width: 1; }
.score-line { fill: none; stroke-linecap: round; stroke-linejoin: round; stroke-width: 2.5; }
.maximum-line { stroke: #64cbe4; }
.average-line { stroke: #f08db8; }
.cursor-line { stroke: rgb(255 255 255 / 58%); stroke-dasharray: 5 4; stroke-width: 1.5; }
.point { stroke: #10141a; stroke-width: 3; }
.maximum-point { fill: #64cbe4; }
.average-point { fill: #f08db8; }
.tooltip { position: absolute; z-index: 5; top: 8px; display: grid; gap: 6px; min-width: 176px; padding: 9px 10px; border: 1px solid #465160; border-radius: 8px; color: #c5ccd6; background: rgb(18 23 30 / 96%); box-shadow: 0 8px 24px rgb(0 0 0 / 35%); font-size: 11px; pointer-events: none; transform: translateX(8px); }
.tooltip.align-right { transform: translateX(calc(-100% - 10px)); }
.tooltip strong { color: #fff; font-size: 12px; }
.tooltip b { margin-left: auto; color: #edf0f4; font-size: 12px; }
@media (max-width: 700px) {
.lane-label { flex-basis: 118px; min-width: 118px; padding: 0 8px; }
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { songOptions, type SongChartSummary, type SongSort } from '$lib/data/song-catalog.ts';
export let charts: readonly SongChartSummary[];
export let difficulty: string;
export let value: string;
const dispatch = createEventDispatcher<{ change: void }>();
let query = '';
let sort: SongSort = 'title';
$: options = songOptions(charts, difficulty, query, sort);
function selectSong(event: Event) {
const nextValue = (event.currentTarget as HTMLSelectElement).value;
if (!nextValue) return;
value = nextValue;
dispatch('change');
}
</script>
<div class="song-picker">
<label class="song-search">
<span>악곡 검색</span>
<input bind:value={query} type="search" placeholder="영문 곡명 검색" autocomplete="off">
</label>
<label class="song-sort">
<span>정렬</span>
<select bind:value={sort}>
<option value="title">알파벳순</option>
<option value="difficulty-asc">난이도 낮은순</option>
<option value="difficulty-desc">난이도 높은순</option>
</select>
</label>
<label class="song-select">
<span>악곡 <small>{options.length}곡</small></span>
<select value={value} on:change={selectSong} disabled={options.length === 0}>
{#if options.length === 0}
<option>검색 결과가 없습니다</option>
{:else}
{#if !options.some((song) => song.id === value)}
<option value="" disabled>검색 결과에서 악곡을 선택하세요</option>
{/if}
{#each options as song}
<option value={song.id}>{song.title}{song.difficultyLevel === null ? '' : ` · Lv. ${song.difficultyLevel}`}</option>
{/each}
{/if}
</select>
</label>
{#if query.trim()}
<p class:no-results={options.length === 0} class="search-status" aria-live="polite">
{options.length > 0
? `${options.length}곡을 찾았습니다. 목록에서 선택하세요.`
: '검색 결과가 없습니다.'}
</p>
{/if}
</div>
<style>
.song-picker { display: grid; grid-template-columns: minmax(180px, .9fr) minmax(140px, .55fr) minmax(260px, 1.4fr); gap: 9px; }
label { display: grid; gap: 7px; min-width: 0; color: #8f98a8; font-size: 14px; font-weight: 750; }
label > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
small { color: #737d8d; font-size: 11px; }
input, select { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
input::placeholder { color: #68717f; opacity: 1; }
input:focus, select:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
select:disabled { color: #747d8b; }
.search-status { grid-column: 1 / -1; margin: -1px 2px 0; color: #8fa99c; font-size: 12px; font-weight: 750; line-height: 1.45; }
.search-status.no-results { color: #d09494; }
@media (max-width: 900px) {
.song-picker { grid-template-columns: 1fr minmax(140px, .55fr); }
.song-select { grid-column: 1 / -1; }
}
@media (max-width: 520px) {
.song-picker { grid-template-columns: 1fr; }
.song-select { grid-column: auto; }
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<script lang="ts">
import { onMount } from 'svelte';
import { dev } from '$app/environment';
import { renderReleaseMarkdown } from '$lib/release-notes/markdown';
import { RELEASES_PAGE_URL } from '$lib/release-notes/release-note-service';
import { currentAppVersion, releaseNotes } from '$lib/release-notes/release-note-store';
import { versionTagIsNewer } from '$lib/release-notes/version';
let previewUpdate = false;
onMount(() => {
previewUpdate = dev && new URLSearchParams(window.location.search).get('previewUpdate') === '1';
void releaseNotes.load();
});
$: latestRelease = $releaseNotes.releases[0] ?? null;
$: updateAvailable = latestRelease !== null && (previewUpdate || versionTagIsNewer(latestRelease.tagName, currentAppVersion));
function publishedDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date);
}
</script>
{#if $releaseNotes.status === 'available' && latestRelease}
<aside class:available={updateAvailable} class="update-notice" aria-live="polite">
<div class="notice-header">
<div class="version-summary">
<span class="label">현재 버전</span>
<strong>v{currentAppVersion.replace(/^v/i, '')}</strong>
{#if previewUpdate}
<span class="status"><b>업데이트 알림 미리보기</b></span>
{:else if updateAvailable}
<span class="status"><b>{latestRelease.tagName}</b> 업데이트를 사용할 수 있습니다.</span>
{:else}
<span class="status">최신 버전입니다.</span>
{/if}
</div>
<a class="action" href={RELEASES_PAGE_URL} target="_blank" rel="noreferrer">업데이트 내역 확인 <span aria-hidden="true">→</span></a>
</div>
{#if updateAvailable}
<section class="latest-note" aria-labelledby="latest-release-title">
<header>
<div>
<span class="release-tag">{latestRelease.tagName}</span>
<h2 id="latest-release-title">{latestRelease.name}</h2>
</div>
<time datetime={latestRelease.publishedAt}>{publishedDate(latestRelease.publishedAt)}</time>
</header>
{#if latestRelease.body.trim()}
<div class="markdown-body">{@html renderReleaseMarkdown(latestRelease.body)}</div>
{:else}
<p class="no-notes">작성된 릴리즈 노트가 없습니다.</p>
{/if}
</section>
{/if}
</aside>
{/if}
<style>
.update-notice { margin-bottom: 12px; border: 1px solid #2c3440; border-radius: 12px; background: #151a22; overflow: hidden; }
.update-notice.available { border-color: rgb(237 139 181 / 38%); background: rgb(237 139 181 / 6%); }
.notice-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 14px 18px; }
.version-summary { display: flex; align-items: baseline; min-width: 0; gap: 10px; }
.label { color: #7f8998; font-size: 13px; font-weight: 800; }
strong { color: #eef1f5; font-size: 15px; }
.status { color: #a4adba; font-size: 14px; }
.status b { color: #f0a1c3; }
.action { flex: 0 0 auto; color: #aab3c0; font-size: 14px; font-weight: 800; text-decoration: none; }
.available .action { color: #f2a6c6; }
.action span { margin-left: 4px; }
.action:hover { color: #ffd0e2; }
.action:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 3px; }
.latest-note { padding: 24px 26px 28px; border-top: 1px solid rgb(237 139 181 / 20%); background: rgb(13 16 22 / 38%); }
.latest-note > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid rgb(255 255 255 / 8%); }
.release-tag { color: #f0a1c3; font-size: 13px; font-weight: 900; }
.latest-note h2 { margin: 6px 0 0; color: #edf0f5; font-size: 22px; letter-spacing: -.025em; }
.latest-note time { flex: 0 0 auto; color: #858f9e; font-size: 13px; }
.markdown-body { padding-top: 18px; color: #b7bfca; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
.markdown-body :global(:first-child) { margin-top: 0; }
.markdown-body :global(:last-child) { margin-bottom: 0; }
.markdown-body :global(h1), .markdown-body :global(h2), .markdown-body :global(h3), .markdown-body :global(h4) { margin: 1.35em 0 .5em; color: #eef1f5; line-height: 1.35; }
.markdown-body :global(h1) { font-size: 22px; }
.markdown-body :global(h2) { font-size: 19px; }
.markdown-body :global(h3) { font-size: 17px; }
.markdown-body :global(p), .markdown-body :global(ul), .markdown-body :global(ol), .markdown-body :global(blockquote), .markdown-body :global(pre) { margin: .75em 0; }
.markdown-body :global(ul), .markdown-body :global(ol) { padding-left: 1.5em; }
.markdown-body :global(a) { color: #f0a1c3; text-underline-offset: 3px; }
.markdown-body :global(code) { padding: 2px 5px; border-radius: 5px; color: #e8b3ca; background: #202631; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .9em; }
.markdown-body :global(pre) { padding: 14px; border: 1px solid #303846; border-radius: 9px; background: #10141a; overflow-x: auto; }
.markdown-body :global(pre code) { padding: 0; color: #c9d0da; background: transparent; }
.markdown-body :global(blockquote) { padding-left: 14px; border-left: 3px solid #596475; color: #929cab; }
.markdown-body :global(img) { max-width: 100%; border-radius: 9px; }
.no-notes { margin: 18px 0 0; color: #818b99; font-size: 14px; }
@media (max-width: 680px) {
.notice-header { align-items: flex-start; flex-direction: column; gap: 8px; }
.version-summary { align-items: flex-start; flex-wrap: wrap; }
.latest-note { padding: 22px 18px 24px; }
.latest-note > header { flex-direction: column; gap: 8px; }
}
</style>
+46 -1
View File
@@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"masterDataVersion": "71e11fbd082eec83d10cff35da7179cbaf319097f0021aa5747fe5a5392b549c", "masterDataVersion": "fbca8c670e074558b24708bf163fff446a614f709afa9d1784395a15444121b0",
"members": [ "members": [
{ {
"id": "chr-00001", "id": "chr-00001",
@@ -329,6 +329,11 @@
], ],
"models": { "models": {
"tree-model-001": [ "tree-model-001": [
{
"groupId": "G-024",
"x": -1,
"y": -10
},
{ {
"groupId": "G-022", "groupId": "G-022",
"x": -1, "x": -1,
@@ -1091,6 +1096,11 @@
} }
], ],
"tree-model-002": [ "tree-model-002": [
{
"groupId": "G-024",
"x": -1,
"y": -10
},
{ {
"groupId": "G-022", "groupId": "G-022",
"x": -1, "x": -1,
@@ -1853,6 +1863,11 @@
} }
], ],
"tree-model-003": [ "tree-model-003": [
{
"groupId": "G-024",
"x": -1,
"y": -10
},
{ {
"groupId": "G-022", "groupId": "G-022",
"x": -1, "x": -1,
@@ -2615,6 +2630,11 @@
} }
], ],
"tree-model-004": [ "tree-model-004": [
{
"groupId": "G-024",
"x": -1,
"y": -10
},
{ {
"groupId": "G-022", "groupId": "G-022",
"x": -1, "x": -1,
@@ -9024,6 +9044,31 @@
} }
} }
], ],
"G-024": [
{
"number": 1,
"characterIds": [],
"grade": 1,
"type": "all_member",
"skillTreePointCost": 3,
"consumptions": [
{
"resourceType": "ResourceType_RESOURCE_TYPE_ITEM",
"resourceId": "item-skill_tree_allmember-1",
"quantity": "160"
}
],
"viewConditionGroupId": "cd-player_level-30",
"effect": {
"id": "skill_tree_effect-mini_game_reward_quantity_up_permil_up-5-skill_tree_effect_target-all-chase",
"type": "mini_game_reward_quantity_up_permil_up",
"value": 5,
"targetId": "skill_tree_effect_target-all",
"trigger": "always",
"description": " 어둠 속 추격의 획득 보상량이 [value/10]% UP"
}
}
],
"R-001": [ "R-001": [
{ {
"number": 1, "number": 1,
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
import type { EventDefinition } from '$lib/engine/event-optimization.ts';
const gamersFeaturedCards = [
'card-00006-5-uniq-0074-00',
'card-00014-5-uniq-0075-00',
'card-00016-5-uniq-0076-00',
'card-00017-5-uniq-0077-00'
] as const;
export const events = [
{
// 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.
id: 'marathon_event-normal-003',
name: '무인도 잭! 한여름의 서바이벌 데이즈',
startsAt: '2026-08-29T03:00:00.000Z',
endsAt: '2026-09-06T10:59:59.999Z',
bonusCharacterIds: ['chr-00006', 'chr-00014', 'chr-00016', 'chr-00017'],
featuredCardIds: gamersFeaturedCards,
challengeSongBonusCardIds: {
m0338: [gamersFeaturedCards[0]],
m0339: [gamersFeaturedCards[1]],
m0340: [gamersFeaturedCards[2]],
m0341: [gamersFeaturedCards[3]],
m0342: gamersFeaturedCards
},
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]
}
}
] as const satisfies readonly EventDefinition[];
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"masterDataVersion": "71e11fbd082eec83d10cff35da7179cbaf319097f0021aa5747fe5a5392b549c", "masterDataVersion": "fbca8c670e074558b24708bf163fff446a614f709afa9d1784395a15444121b0",
"maxCount": 28, "maxCount": 29,
"effects": [ "effects": [
{ {
"count": 1, "count": 1,
+73
View File
@@ -0,0 +1,73 @@
export type ReleaseClockSource = 'server' | 'system';
export type ReleaseClock = {
source: ReleaseClockSource;
now: () => number;
};
type TimeResponse = {
now?: unknown;
};
type ReleaseClockOptions = {
fetcher?: typeof fetch;
wallNow?: () => number;
monotonicNow?: () => number;
online?: boolean;
timeoutMs?: number;
};
function defaultMonotonicNow(): number {
return typeof performance === 'undefined' ? Date.now() : performance.now();
}
function serverClock(serverNow: number, monotonicNow: () => number, baseMonotonic = monotonicNow()): ReleaseClock {
return {
source: 'server',
now: () => serverNow + Math.max(0, monotonicNow() - baseMonotonic)
};
}
function systemClock(wallNow: () => number): ReleaseClock {
return { source: 'system', now: wallNow };
}
export function createInitialReleaseClock(serverNow: unknown, options: Pick<ReleaseClockOptions, 'wallNow' | 'monotonicNow'> = {}): ReleaseClock {
const wallNow = options.wallNow ?? Date.now;
const monotonicNow = options.monotonicNow ?? defaultMonotonicNow;
return typeof serverNow === 'number' && Number.isFinite(serverNow)
? serverClock(serverNow, monotonicNow)
: systemClock(wallNow);
}
export async function synchronizeReleaseClock(options: ReleaseClockOptions = {}): Promise<ReleaseClock> {
const wallNow = options.wallNow ?? Date.now;
const monotonicNow = options.monotonicNow ?? defaultMonotonicNow;
const online = options.online ?? (typeof location === 'undefined' || location.protocol !== 'file:');
if (!online) return systemClock(wallNow);
const fetcher = options.fetcher ?? globalThis.fetch?.bind(globalThis);
if (!fetcher) return systemClock(wallNow);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 3_000);
const requestStarted = monotonicNow();
try {
const response = await fetcher('/api/time', {
cache: 'no-store',
headers: { accept: 'application/json' },
signal: controller.signal
});
if (!response.ok) return systemClock(wallNow);
const payload = await response.json() as TimeResponse;
if (typeof payload.now !== 'number' || !Number.isFinite(payload.now)) return systemClock(wallNow);
const responseReceived = monotonicNow();
const estimatedNow = payload.now + Math.max(0, responseReceived - requestStarted) / 2;
return serverClock(estimatedNow, monotonicNow, responseReceived);
} catch {
return systemClock(wallNow);
} finally {
clearTimeout(timeout);
}
}
+57
View File
@@ -0,0 +1,57 @@
export type SongSort = 'title' | 'difficulty-asc' | 'difficulty-desc';
export type SongChartSummary = {
songId: string;
title: string;
difficulty: string;
difficultyLevel?: number | null;
scoreCalibrationSource?: 'measured' | 'local-estimated-v1' | string;
releaseTime?: number | null;
};
export type SongOption = {
id: string;
title: string;
difficultyLevel: number | null;
};
const englishTitleCollator = new Intl.Collator('en', {
sensitivity: 'base',
numeric: true
});
function normalizedSearch(value: string): string {
return value.normalize('NFKD').toLocaleLowerCase('en').replace(/[^a-z0-9]+/g, ' ').trim();
}
export function songOptions(
charts: readonly SongChartSummary[],
difficulty: string,
query = '',
sort: SongSort = 'title'
): SongOption[] {
const levelBySongId = new Map(charts
.filter((chart) => chart.difficulty === difficulty)
.map((chart) => [chart.songId, chart.difficultyLevel ?? null]));
const options = Array.from(new Map(charts.map((chart) => [chart.songId, {
id: chart.songId,
title: chart.title,
difficultyLevel: levelBySongId.get(chart.songId) ?? null
}])).values());
const search = normalizedSearch(query);
const filtered = search
? options.filter((song) => normalizedSearch(`${song.title} ${song.id}`).includes(search))
: options;
const titleOrder = (left: SongOption, right: SongOption) =>
englishTitleCollator.compare(left.title, right.title) || left.id.localeCompare(right.id);
return filtered.sort((left, right) => {
if (sort === 'title') return titleOrder(left, right);
if (left.difficultyLevel === null) return right.difficultyLevel === null ? titleOrder(left, right) : 1;
if (right.difficultyLevel === null) return -1;
const leftLevel = left.difficultyLevel;
const rightLevel = right.difficultyLevel;
const levelOrder = sort === 'difficulty-desc' ? rightLevel - leftLevel : leftLevel - rightLevel;
return levelOrder || titleOrder(left, right);
});
}
+18
View File
@@ -0,0 +1,18 @@
export const SONG_RELEASE_TIMES: Readonly<Record<string, number>> = {
m0332: 1_787_137_200_000,
m0353: 1_787_151_600_000,
m0335: 1_787_310_000_000,
m0333: 1_787_482_800_000,
m0336: 1_787_655_600_000
};
export function isSongReleased(songId: string, now = Date.now(), catalogReleaseTime?: number | null): boolean {
const releaseTime = typeof catalogReleaseTime === 'number' && Number.isFinite(catalogReleaseTime)
? catalogReleaseTime
: SONG_RELEASE_TIMES[songId];
return releaseTime === undefined || now >= releaseTime;
}
export function releasedSongCharts<T extends { songId: string; releaseTime?: number | null }>(charts: readonly T[], now = Date.now()): T[] {
return charts.filter((chart) => isSongReleased(chart.songId, now, chart.releaseTime));
}
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { effectiveActiveProbability } from './active-probability.ts';
import type { OptimizerCard } from './types.ts';
function card(probabilityPercent: number, activationRateUpPercent = 0): OptimizerCard {
return {
id: 'card',
name: '카드',
active: {
cooldownSeconds: 1,
durationSeconds: 1,
scoreUpPercent: 100,
probabilityPercent,
activationRateUpPercent
},
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
}
test('블루 보드와 스페셜 발동률 UP은 배율을 합산한 뒤 기본 발동률에 곱한다', () => {
assert.ok(Math.abs(effectiveActiveProbability(card(55, 30), 40) - 0.935) < 1e-12);
});
test('합산한 발동률은 100%를 넘지 않는다', () => {
assert.equal(effectiveActiveProbability(card(55, 50), 40), 1);
});
+13
View File
@@ -0,0 +1,13 @@
import type { OptimizerCard } from './types.ts';
/**
* 액티브 기본 발동률에 상시 보너스와 발동 시점의 스페셜 보너스를 합산해 적용한다.
*/
export function effectiveActiveProbability(
card: OptimizerCard,
timedActivationRateUpPercent = 0
): number {
const baseProbability = (card.active.probabilityPercent ?? 100) / 100;
const totalRateUpPercent = (card.active.activationRateUpPercent ?? 0) + timedActivationRateUpPercent;
return Math.max(0, Math.min(1, baseProbability * (1 + totalRateUpPercent / 100)));
}
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { additionalAwakeningsRequired } from './awakening-requirements.ts';
test('미보유 카드를 보유하는 과정은 1회의 개화로 계산한다', () => {
assert.equal(additionalAwakeningsRequired(undefined, 0), 1);
assert.equal(additionalAwakeningsRequired({ owned: false, awakening: 5 }, 0), 1);
assert.equal(additionalAwakeningsRequired({ owned: false }, 5), 6);
});
test('보유 카드는 현재 상태에서 부족한 개화만 계산한다', () => {
assert.equal(additionalAwakeningsRequired({ owned: true, awakening: 2 }, 5), 3);
assert.equal(additionalAwakeningsRequired({ owned: true, awakening: 5 }, 3), 0);
assert.equal(additionalAwakeningsRequired({ owned: true, awakening: 0 }, 0), 0);
});
+21
View File
@@ -0,0 +1,21 @@
export type CardAwakeningState = {
owned?: boolean;
awakening?: number;
};
function normalizeAwakening(value: unknown): number {
return Math.max(0, Math.min(5, Number.isFinite(value) ? Math.round(Number(value)) : 0));
}
/**
* 현재 보유 상태에서 목표 개화까지 필요한 횟수.
* 미보유 카드를 처음 획득하는 과정은 1회의 개화로 계산한다.
*/
export function additionalAwakeningsRequired(
state: CardAwakeningState | undefined,
targetAwakening: number
): number {
const target = normalizeAwakening(targetAwakening);
if (state?.owned !== true) return target + 1;
return Math.max(0, target - normalizeAwakening(state.awakening));
}
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { optimizeBoardConnectionCards, type BoardConnectionOptimizerSource } from './board-connection-optimizer.ts';
const source: BoardConnectionOptimizerSource = {
members: [{ id: 'member', positionGroupId: 'model' }],
models: {
model: [
{ groupId: 'S-center', x: 0, y: 0 },
{ groupId: 'G-left', x: -1, y: 0 },
{ groupId: 'G-right', x: 1, y: 0 },
{ groupId: 'S-right', x: 3, y: 0 },
{ groupId: 'G-far', x: 4, y: 0 }
]
},
nodeGroups: {
'S-center': [{ characterIds: [], type: 'connection', effect: null }],
'S-right': [{ characterIds: [], type: 'connection', effect: null }],
'G-left': [{ characterIds: [], type: 'all_member', effect: { type: 'performance_up', value: 100 } }],
'G-right': [{ characterIds: [], type: 'all_member', effect: { type: 'performance_up', value: 300 } }],
'G-far': [{ characterIds: [], type: 'all_member', effect: { type: 'all_parameter_up', value: 200 } }]
}
};
const leftCard = {
id: 'left-card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: -1, y: 0 }] }]
};
const rightCard = {
id: 'right-card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
};
test('활성 연결 노드 전체에서 보유 카드의 최대 가중치 배치를 찾는다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard]);
assert.deepEqual(result.placements, {
member: { 'S-center': 'left-card', 'S-right': 'right-card' }
});
assert.equal(result.assignments.length, 2);
});
test('비활성 연결 노드와 비활성 효과 노드는 배치 점수에 포함하지 않는다', () => {
const memberBoards = { member: ['G-left'] };
const before = structuredClone(memberBoards);
const result = optimizeBoardConnectionCards(source, memberBoards, [leftCard, rightCard]);
assert.deepEqual(result.placements, { member: { 'S-center': 'left-card' } });
assert.deepEqual(memberBoards, before);
});
test('5개화 카드는 2레벨 연결 범위와 배율을 사용한다', () => {
const awakened = {
id: 'awakened', awakening: 5,
connect: [
{ level: 1, effectPermilUp: 100, extent: [{ x: -1, y: 0 }] },
{ level: 2, effectPermilUp: 200, extent: [{ x: 1, y: 0 }] }
]
};
const result = optimizeBoardConnectionCards(source, { member: ['G-left', 'G-right'] }, [awakened]);
assert.deepEqual(result.placements, { member: { 'S-center': 'awakened' } });
assert.equal(result.totalWeight, 60_000);
});
test('솔로와 단체 10%를 각각 우선하며 단체는 다른 멤버 보너스도 합산한다', () => {
const bonusSource: BoardConnectionOptimizerSource = {
members: [
{ id: 'target', positionGroupId: 'model' },
{ id: 'helper', positionGroupId: 'model' }
],
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
Y: [
{
characterIds: ['target'], type: 'content',
effect: {
id: 'music_singer_type-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20
}
},
{
characterIds: ['helper'], type: 'content',
effect: {
id: 'music_singer_type-group',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20
}
}
]
}
};
const cards = ['a', 'b'].map((id) => ({
id, awakening: 0,
connect: [{ level: 1, effectPermilUp: 4000, extent: [{ x: 1, y: 0 }] }]
}));
const result = optimizeBoardConnectionCards(
bonusSource,
{ target: ['Y'], helper: ['Y'] },
cards,
{ targetMemberId: 'target', requireSoloSongBonus: true, requireGroupSongBonus: true }
);
assert.equal(result.soloSongBonusPercent, 10);
assert.equal(result.groupSongBonusPercent, 10);
assert.equal(result.assignments.length, 2);
});
test('10%에 부족한 활성 노드만 있으면 가능한 보너스까지만 최적화한다', () => {
const bonusSource: BoardConnectionOptimizerSource = {
members: [{ id: 'target', positionGroupId: 'model' }],
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
Y: [{
characterIds: [], type: 'content',
effect: {
id: 'music_singer_type-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20
}
}]
}
};
const result = optimizeBoardConnectionCards(bonusSource, { target: ['Y'] }, [{
id: 'card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
}], { targetMemberId: 'target', requireSoloSongBonus: true });
assert.equal(result.soloSongBonusPercent, 4);
assert.deepEqual(result.placements, { target: { S: 'card' } });
});
test('편성 기반 배치 가중치를 제공하면 해당 효율을 우선한다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard], {
placementWeight: (card, _memberId, socketId) => card.id === 'right-card' && socketId === 'S-right' ? 10_000 : 1
});
assert.equal(result.placements.member['S-right'], 'right-card');
});
test('우선 배치 가중치는 일반 점수보다 먼저 최대화한다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard], {
placementWeight: (card, _memberId, socketId) => card.id === 'right-card' && socketId === 'S-right' ? 10_000 : 1,
placementPriorityWeight: (card, _memberId, socketId) => card.id === 'left-card' && socketId === 'S-right' ? 1 : 0
});
assert.equal(result.placements.member['S-right'], 'left-card');
});
test('리더와 보너스 대상이 같으면 악곡 보너스보다 편성 효율을 우선한다', () => {
const prioritySource: BoardConnectionOptimizerSource = {
members: [{ id: 'leader', positionGroupId: 'model' }],
models: { model: [
{ groupId: 'S-center', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 },
{ groupId: 'S-top', x: 0, y: 3 }, { groupId: 'R', x: 1, y: 3 }
] },
nodeGroups: {
'S-center': [{ characterIds: [], type: 'connection', effect: null }],
'S-top': [{ characterIds: [], type: 'connection', effect: null }],
Y: [{ characterIds: [], type: 'content', effect: {
id: 'music_singer_type-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 50
} }],
R: [{ characterIds: [], type: 'leader', effect: { type: 'performance_up', value: 100 } }]
}
};
const singleCard = [{
id: 'card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
}];
const result = optimizeBoardConnectionCards(prioritySource, { leader: ['Y', 'S-top', 'R'] }, singleCard, {
targetMemberId: 'leader',
requireSoloSongBonus: true,
prioritizePlacementWeight: true,
placementWeight: (_card, _memberId, socketId) => socketId === 'S-top' ? 100 : 0
});
assert.deepEqual(result.placements, { leader: { 'S-top': 'card' } });
assert.equal(result.soloSongBonusPercent, 5);
});
test('선택한 단체 악곡의 가창자 보드만 단체 보너스에 합산한다', () => {
const bonusSource: BoardConnectionOptimizerSource = {
members: [{ id: 'singer', positionGroupId: 'model' }, { id: 'outsider', positionGroupId: 'model' }],
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
Y: [
{ characterIds: ['singer'], type: 'content', effect: { id: 'music_singer_type-group', type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20 } },
{ characterIds: ['outsider'], type: 'content', effect: { id: 'music_singer_type-group', type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 80 } }
]
}
};
const result = optimizeBoardConnectionCards(bonusSource, { singer: ['Y'], outsider: ['Y'] }, [], {
targetMemberId: 'singer', requireGroupSongBonus: true, groupSingerMemberIds: ['singer']
});
assert.equal(result.groupSongBonusPercent, 2);
});
@@ -0,0 +1,226 @@
import { maximumWeightMatching } from './maximum-weight-matching.ts';
type OptimizableBoardEffect = {
id?: string;
type: string;
value: number | null;
trigger?: string | null;
};
type OptimizableBoardNode = {
characterIds: readonly string[];
type: string;
effect: OptimizableBoardEffect | null;
};
type OptimizableBoardPosition = { groupId: string; x: number; y: number };
export type BoardConnectionOptimizerSource = {
members: readonly { id: string; positionGroupId: string }[];
models: Readonly<Record<string, readonly OptimizableBoardPosition[]>>;
nodeGroups: Readonly<Record<string, readonly OptimizableBoardNode[]>>;
};
export type OptimizableConnectionCard = {
id: string;
awakening: number;
connect: readonly {
level: number;
effectPermilUp: number;
extent: readonly { x: number; y: number }[];
}[];
};
export type BoardConnectionAssignment = {
memberId: string;
socketId: string;
cardId: string;
weight: number;
};
export type BoardConnectionOptimizationResult = {
placements: Record<string, Record<string, string>>;
assignments: BoardConnectionAssignment[];
totalWeight: number;
soloSongBonusPercent: number;
groupSongBonusPercent: number;
};
export type BoardConnectionOptimizationOptions = {
targetMemberId?: string;
requireSoloSongBonus?: boolean;
requireGroupSongBonus?: boolean;
placementWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
placementPriorityWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
prioritizePlacementWeight?: boolean;
groupSingerMemberIds?: readonly string[];
};
function nodeForMember(
source: BoardConnectionOptimizerSource,
groupId: string,
memberId: string
): OptimizableBoardNode | null {
const nodes = source.nodeGroups[groupId] ?? [];
return nodes.find((node) => node.characterIds.includes(memberId))
?? nodes.find((node) => node.characterIds.length === 0)
?? null;
}
function effectValue(effect: OptimizableBoardEffect | null): number {
if (!effect || effect.value === null || effect.trigger === 'work') return 0;
if (effect.type === 'live_active_skill_cool_time_shorten_permil_up') return 0;
// Song bonuses are contextual and are valued only when the user requests
// the corresponding solo/group target below.
if (effect.type === 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type') return 0;
const value = Math.max(0, Number(effect.value) || 0);
return effect.type.startsWith('all_parameter_up') ? value * 3 : value;
}
function selectedEffect(card: OptimizableConnectionCard) {
const level = card.awakening >= 5 ? 2 : 1;
return card.connect.find((effect) => effect.level === level) ?? card.connect.at(-1) ?? null;
}
function songBonusModes(
effect: OptimizableBoardEffect | null,
ownerId: string,
targetMemberId: string,
groupSingerMemberIds?: readonly string[]
): ('solo' | 'group')[] {
if (!effect?.id || effect.type !== 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type') return [];
const mode = effect.id.match(/music_singer_type-(all|group|solo)/)?.[1];
if (mode === 'all') return ['solo', 'group'];
if (mode === 'solo' && ownerId === targetMemberId) return ['solo'];
// A unit song bonus may be supplied by another participating singer's board.
if (mode === 'group' && (!groupSingerMemberIds || groupSingerMemberIds.includes(ownerId))) return ['group'];
return [];
}
/**
* Reassigns owned connection cards across active connection sockets without
* changing the supplied node activation state.
*/
export function optimizeBoardConnectionCards(
source: BoardConnectionOptimizerSource,
memberBoards: Readonly<Record<string, readonly string[]>>,
ownedCards: readonly OptimizableConnectionCard[],
options: BoardConnectionOptimizationOptions = {}
): BoardConnectionOptimizationResult {
const targetMemberId = options.targetMemberId ?? '';
let baseSoloBonus = 0;
let baseGroupBonus = 0;
const slots = source.members.flatMap((member) => {
const positions = source.models[member.positionGroupId] ?? [];
const centerId = positions.find((position) => position.x === 0 && position.y === 0)?.groupId;
const active = new Set(memberBoards[member.id] ?? []);
if (centerId) active.add(centerId);
for (const position of positions) {
if (!active.has(position.groupId)) continue;
const node = nodeForMember(source, position.groupId, member.id);
const value = Math.max(0, Number(node?.effect?.value) || 0);
for (const mode of songBonusModes(node?.effect ?? null, member.id, targetMemberId, options.groupSingerMemberIds)) {
if (mode === 'solo') baseSoloBonus += value;
else baseGroupBonus += value;
}
}
return positions
.filter((position) => active.has(position.groupId) && nodeForMember(source, position.groupId, member.id)?.type === 'connection')
.map((socket) => ({ member, socket, positions, active }));
});
const cards = ownedCards.filter((card) => selectedEffect(card));
const emptyResult = {
placements: {}, assignments: [], totalWeight: 0,
soloSongBonusPercent: Math.min(10, baseSoloBonus / 10),
groupSongBonusPercent: Math.min(10, baseGroupBonus / 10)
};
if (cards.length === 0 || slots.length === 0) return emptyResult;
const soloBoosts = cards.map(() => slots.map(() => 0));
const groupBoosts = cards.map(() => slots.map(() => 0));
const genericWeights = cards.map((card, cardIndex) => slots.map(({ member, socket, positions, active }, slotIndex) => {
const effect = selectedEffect(card);
if (!effect) return 0;
const positionByCoordinate = new Map(positions.map((position) => [`${position.x},${position.y}`, position]));
const coveredValue = effect.extent.reduce((total, offset) => {
const position = positionByCoordinate.get(`${socket.x + offset.x},${socket.y + offset.y}`);
if (!position || !active.has(position.groupId)) return total;
const nodeEffect = nodeForMember(source, position.groupId, member.id)?.effect ?? null;
const value = effectValue(nodeEffect);
const boost = Math.max(0, Number(nodeEffect?.value) || 0) * Math.max(0, effect.effectPermilUp);
for (const mode of songBonusModes(nodeEffect, member.id, targetMemberId, options.groupSingerMemberIds)) {
if (mode === 'solo') soloBoosts[cardIndex][slotIndex] += boost;
else groupBoosts[cardIndex][slotIndex] += boost;
}
return total + value;
}, 0);
const defaultWeight = coveredValue * Math.max(0, effect.effectPermilUp);
return Math.max(0, options.placementWeight?.(card, member.id, socket.groupId) ?? defaultWeight);
}));
const priorityUnits = cards.map((card) => slots.map(({ member, socket }) =>
Math.max(0, Math.round((options.placementPriorityWeight?.(card, member.id, socket.groupId) ?? 0) * 1000))
));
const maximumAssignments = Math.min(cards.length, slots.length);
const genericBudget = Math.max(1, ...genericWeights.flatMap((row) => row)) * maximumAssignments;
const weights = genericWeights.map((row, cardIndex) => row.map((weight, slotIndex) =>
priorityUnits[cardIndex][slotIndex] * (genericBudget + 1) + weight
));
const priorityScale = Math.max(1, ...weights.flatMap((row) => row)) * Math.max(cards.length, slots.length) + 1;
const ratios = options.requireSoloSongBonus && options.requireGroupSongBonus
? [1 / 256, 1 / 16, 1 / 4, 1, 4, 16, 256]
: [1];
const priorityMultipliers = options.requireSoloSongBonus || options.requireGroupSongBonus
? [0, 1 / 1_000_000, 1 / 10_000, 1 / 100, 1, 100]
: [0];
const candidates = priorityMultipliers.flatMap((priorityMultiplier) => ratios.map((ratio) => {
const prioritized = weights.map((row, cardIndex) => row.map((weight, slotIndex) => weight + priorityScale * (
(options.requireSoloSongBonus ? soloBoosts[cardIndex][slotIndex] * ratio : 0)
+ (options.requireGroupSongBonus ? groupBoosts[cardIndex][slotIndex] : 0)
) * priorityMultiplier));
const matches = maximumWeightMatching(prioritized);
const genericWeight = matches.reduce((total, match) => total + weights[match.rowIndex][match.columnIndex], 0);
const soloBonus = baseSoloBonus + matches.reduce((total, match) => total + soloBoosts[match.rowIndex][match.columnIndex] / 1000, 0);
const groupBonus = baseGroupBonus + matches.reduce((total, match) => total + groupBoosts[match.rowIndex][match.columnIndex] / 1000, 0);
const feasible = (!options.requireSoloSongBonus || soloBonus >= 100)
&& (!options.requireGroupSongBonus || groupBonus >= 100);
const fulfillment = Math.min(
options.requireSoloSongBonus ? soloBonus / 100 : Number.POSITIVE_INFINITY,
options.requireGroupSongBonus ? groupBonus / 100 : Number.POSITIVE_INFINITY
);
const totalFulfillment = (options.requireSoloSongBonus ? Math.min(1, soloBonus / 100) : 0)
+ (options.requireGroupSongBonus ? Math.min(1, groupBonus / 100) : 0);
return { matches, genericWeight, soloBonus, groupBonus, feasible, fulfillment, totalFulfillment };
}));
const best = candidates.sort((left, right) => options.prioritizePlacementWeight
? right.genericWeight - left.genericWeight
|| Number(right.feasible) - Number(left.feasible)
|| right.fulfillment - left.fulfillment
|| right.totalFulfillment - left.totalFulfillment
: Number(right.feasible) - Number(left.feasible)
|| right.fulfillment - left.fulfillment
|| right.totalFulfillment - left.totalFulfillment
|| right.genericWeight - left.genericWeight)[0];
const matches = best.matches;
const placements: Record<string, Record<string, string>> = {};
const assignments = matches.map((match) => {
const card = cards[match.rowIndex];
const slot = slots[match.columnIndex];
(placements[slot.member.id] ??= {})[slot.socket.groupId] = card.id;
return {
memberId: slot.member.id,
socketId: slot.socket.groupId,
cardId: card.id,
weight: genericWeights[match.rowIndex][match.columnIndex]
};
});
return {
placements,
assignments,
totalWeight: assignments.reduce((total, assignment) => total + assignment.weight, 0),
soloSongBonusPercent: Math.min(10, best.soloBonus / 10),
groupSongBonusPercent: Math.min(10, best.groupBonus / 10)
};
}
+6 -2
View File
@@ -104,7 +104,7 @@ test('같은 기수의 보드 보너스는 카드별로 최대 900까지만 적
assert.equal(result.flatByCardId['card-b'].performance, 0); assert.equal(result.flatByCardId['card-b'].performance, 0);
}); });
test('복수 소속 카드는 각 기수의 900 상한을 따로 적용한다', () => { test('복수 소속 카드도 모든 기수 보너스를 합쳐 카드별 900까지만 적용한다', () => {
const multiGroupingSummary: BoardSummarySource = { const multiGroupingSummary: BoardSummarySource = {
nodeGroups: { nodeGroups: {
'G-20': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 900, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_1' } }], 'G-20': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 900, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_1' } }],
@@ -114,7 +114,11 @@ test('복수 소속 카드는 각 기수의 900 상한을 따로 적용한다',
}; };
const multiGroupingCards = [{ id: 'fubuki', characterId: 'fubuki', type: '퓨어', generation: 'hololive 1기생 / GAMERS' }]; const multiGroupingCards = [{ id: 'fubuki', characterId: 'fubuki', type: '퓨어', generation: 'hololive 1기생 / GAMERS' }];
const firstGenerationMaxed = calculateBoardEffects(multiGroupingSummary, { account: ['G-20'] }, multiGroupingCards, 'fubuki');
const gamersAdded = calculateBoardEffects(multiGroupingSummary, { account: ['G-20', 'G-21'] }, multiGroupingCards, 'fubuki');
const result = calculateBoardEffects(multiGroupingSummary, { account: ['G-20', 'G-21', 'G-22'] }, multiGroupingCards, 'fubuki'); const result = calculateBoardEffects(multiGroupingSummary, { account: ['G-20', 'G-21', 'G-22'] }, multiGroupingCards, 'fubuki');
assert.equal(result.flatByCardId.fubuki.performance, 1800); assert.equal(firstGenerationMaxed.flatByCardId.fubuki.performance, 900);
assert.equal(gamersAdded.flatByCardId.fubuki.performance, 900);
assert.equal(result.flatByCardId.fubuki.performance, 900);
}); });
+3 -5
View File
@@ -141,7 +141,7 @@ export function calculateBoardEffects(
let scoreSupportPercent = 0; let scoreSupportPercent = 0;
let directSongScoreBonusPercent = 0; let directSongScoreBonusPercent = 0;
const multipliers = connectionMultipliers(summary, memberBoards, connections); const multipliers = connectionMultipliers(summary, memberBoards, connections);
const generationBonusByCardAndGrouping: Record<string, Record<string, number>> = {}; const generationBonusByCardId: Record<string, number> = {};
for (const [ownerId, activeGroupIds] of Object.entries(memberBoards)) { for (const [ownerId, activeGroupIds] of Object.entries(memberBoards)) {
for (const groupId of activeGroupIds) { for (const groupId of activeGroupIds) {
@@ -176,13 +176,11 @@ export function calculateBoardEffects(
const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value; const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value;
const effectTargets = targets(effect, ownerId, cards); const effectTargets = targets(effect, ownerId, cards);
if (effect.type === 'all_parameter_up_for_character_grouping') { if (effect.type === 'all_parameter_up_for_character_grouping') {
const groupingId = effect.targetId ?? 'unknown-grouping';
for (const card of effectTargets) { for (const card of effectTargets) {
if (overrides?.generationBonusByCardId?.[card.id] !== undefined) continue; if (overrides?.generationBonusByCardId?.[card.id] !== undefined) continue;
const cardBonuses = generationBonusByCardAndGrouping[card.id] ??= {}; const currentBonus = generationBonusByCardId[card.id] ?? 0;
const currentBonus = cardBonuses[groupingId] ?? 0;
const appliedValue = Math.max(0, Math.min(normalizedValue, generationBonusCap - currentBonus)); const appliedValue = Math.max(0, Math.min(normalizedValue, generationBonusCap - currentBonus));
cardBonuses[groupingId] = currentBonus + appliedValue; generationBonusByCardId[card.id] = currentBonus + appliedValue;
for (const stat of stats) destination[card.id][stat] += appliedValue; for (const stat of stats) destination[card.id][stat] += appliedValue;
} }
continue; continue;
+6
View File
@@ -5,6 +5,7 @@ import { toOptimizerCard, type OptimizerSourceCard } from './card-adapter.ts';
const source: OptimizerSourceCard = { const source: OptimizerSourceCard = {
id: 'card', id: 'card',
name: '카드', name: '카드',
power: { statsAtMaxLevel: { performance: 101, technique: 202, sense: 303 } },
details: { details: {
active: [ active: [
{ level: 1, coolTimeSeconds: 10, durationSeconds: 1, effect: '스코어가 10% UP' }, { level: 1, coolTimeSeconds: 10, durationSeconds: 1, effect: '스코어가 10% UP' },
@@ -29,6 +30,11 @@ test('액티브는 1개화, 스페셜은 3개화부터 2레벨을 적용한다',
assert.equal(specialAwakened.special.scoreUpPercent, 40); assert.equal(specialAwakened.special.scoreUpPercent, 40);
}); });
test('2개화부터 보드 제외 기본 파라미터 합계에 10%를 적용한다', () => {
assert.equal(toOptimizerCard(source, 0).naturalParameterTotal, 606);
assert.equal(toOptimizerCard(source, 2).naturalParameterTotal, 669);
});
test('4개화 패시브의 타입별 스코어 서포트 대상과 수치를 변환한다', () => { test('4개화 패시브의 타입별 스코어 서포트 대상과 수치를 변환한다', () => {
const passiveSource: OptimizerSourceCard = { const passiveSource: OptimizerSourceCard = {
...source, ...source,
+13
View File
@@ -16,6 +16,13 @@ export type OptimizerSourceCard = {
type?: string; type?: string;
generation?: string; generation?: string;
versionName?: string; versionName?: string;
power?: {
statsAtMaxLevel?: {
performance: number;
technique: number;
sense: number;
};
};
details: { details: {
passive?: readonly { level: number; description: string }[]; passive?: readonly { level: number; description: string }[];
active: readonly SkillLevel[]; active: readonly SkillLevel[];
@@ -87,6 +94,11 @@ export function toOptimizerCard(
const specialConditionMet = skillConditionSatisfied(special?.condition ?? '', unitMembers); const specialConditionMet = skillConditionSatisfied(special?.condition ?? '', unitMembers);
const activeScore = activeConditionMet ? scorePercent(active?.effect) : 0; const activeScore = activeConditionMet ? scorePercent(active?.effect) : 0;
const specialScore = specialConditionMet ? scorePercent(special?.effect) : 0; const specialScore = specialConditionMet ? scorePercent(special?.effect) : 0;
const baseStats = source.power?.statsAtMaxLevel;
const potentialRate = awakening >= 2 ? 10 : 0;
const naturalParameterTotal = baseStats
? Object.values(baseStats).reduce((sum, value) => sum + Math.ceil(value * (100 + potentialRate) / 100), 0)
: 0;
if (!active || active.coolTimeSeconds === null || active.durationSeconds === null || activeScore === null) { if (!active || active.coolTimeSeconds === null || active.durationSeconds === null || activeScore === null) {
throw new Error(`${source.name}의 액티브 스코어 정보를 계산에 사용할 수 없습니다.`); throw new Error(`${source.name}의 액티브 스코어 정보를 계산에 사용할 수 없습니다.`);
@@ -98,6 +110,7 @@ export function toOptimizerCard(
return { return {
id: source.id, id: source.id,
name: source.versionName ? `${source.name} (${source.versionName})` : source.name, name: source.versionName ? `${source.name} (${source.versionName})` : source.name,
naturalParameterTotal,
type: source.type, type: source.type,
generation: source.generation, generation: source.generation,
active: { active: {
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
type SpecialLevel = {
description: string;
effect: string;
};
type CardSummary = {
cards: Array<{
name: string;
details: { special: SpecialLevel[] };
}>;
};
test('스페셜 설명의 추가 효과가 요약 데이터에서 누락되지 않는다', async () => {
const path = new URL('../data/cards-summary.json', import.meta.url);
const summary = JSON.parse(await readFile(path, 'utf8')) as CardSummary;
const additionalEffectMarkers = ['스킬 발동률', 'GOOD 이상이 PERFECT가 된다', '라이프가'];
const missing = summary.cards.flatMap((card) => card.details.special.flatMap((level) =>
additionalEffectMarkers
.filter((marker) => level.description.includes(marker) && !level.effect.includes(marker))
.map((marker) => `${card.name} (${marker}): ${level.description}`)));
assert.deepEqual(missing, []);
});
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { activeEffectPercent, sortCardsByActive, type CardSummary, type SkillLevel } from '../models/card-list.ts';
function skill(level: number, values: Partial<SkillLevel> = {}): SkillLevel {
return {
level,
description: '',
coolTimeSeconds: null,
probabilityPercent: null,
durationSeconds: null,
...values
};
}
function card(id: string, active?: SkillLevel[]): CardSummary {
return {
id,
rarity: 5,
type: '큐트',
name: id,
versionName: '',
generation: '',
highestStat: '퍼포먼스',
details: { title: '', passive: [], active, special: [], costume: null }
};
}
test('sorts numeric active values in both directions and keeps missing values last', () => {
const cards = [
card('missing'),
card('slow', [skill(1, { coolTimeSeconds: 24 })]),
card('fast', [skill(1, { coolTimeSeconds: 18 })])
];
assert.deepEqual(sortCardsByActive(cards, 'coolTime', 'asc', 1).map(({ id }) => id), ['fast', 'slow', 'missing']);
assert.deepEqual(sortCardsByActive(cards, 'coolTime', 'desc', 1).map(({ id }) => id), ['slow', 'fast', 'missing']);
});
test('uses the selected skill level and preserves source order for ties', () => {
const cards = [
card('first', [skill(1, { effect: '스코어가 80% UP' }), skill(2, { effect: '스코어가 120% UP' })]),
card('second', [skill(1, { effect: '스코어가 90% UP' }), skill(2, { effect: '스코어가 100% UP' })]),
card('same', [skill(1, { effect: '스코어가 90% UP' }), skill(2, { effect: '스코어가 100% UP' })])
];
assert.deepEqual(sortCardsByActive(cards, 'effect', 'desc', 1).map(({ id }) => id), ['second', 'same', 'first']);
assert.deepEqual(sortCardsByActive(cards, 'effect', 'desc', 2).map(({ id }) => id), ['first', 'second', 'same']);
});
test('falls back to the active description when the separated effect is unavailable', () => {
assert.equal(activeEffectPercent(skill(1, { description: '10초 동안 스코어가 85% UP' })), 85);
assert.equal(activeEffectPercent(skill(1, { effect: '라이프가 회복됩니다.' })), null);
});
+56 -1
View File
@@ -1,6 +1,12 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { generateCombinationCandidates, resolveCandidateAwakening } from './index.ts'; import {
combinationSearchLimits,
generateAnchoredCombinationCandidates,
generateCombinationCandidates,
resolveCandidateAwakening,
selectMemberDiverseCandidates
} from './index.ts';
const cards = [ const cards = [
{ id: 'a-1', characterId: 'a', score: 10 }, { id: 'a-1', characterId: 'a', score: 10 },
@@ -44,3 +50,52 @@ test('보유한 자동 후보는 설정값보다 실제 개화가 높으면 실
assert.equal(resolveCandidateAwakening(4, true, 2), 4); assert.equal(resolveCandidateAwakening(4, true, 2), 4);
assert.equal(resolveCandidateAwakening(2, false, 5), 2); assert.equal(resolveCandidateAwakening(2, false, 5), 2);
}); });
test('빈 멤버 수에 비례해 선별 및 정밀 분석 후보 수를 늘린다', () => {
assert.deepEqual(combinationSearchLimits(4), {
openSlots: 1,
screeningLimit: 5_000,
baseExactFinalistLimit: 56,
alternativeExactFinalistLimit: 56
});
assert.deepEqual(combinationSearchLimits(3), {
openSlots: 2,
screeningLimit: 10_000,
baseExactFinalistLimit: 112,
alternativeExactFinalistLimit: 112
});
assert.deepEqual(combinationSearchLimits(0), {
openSlots: 5,
screeningLimit: 25_000,
baseExactFinalistLimit: 280,
alternativeExactFinalistLimit: 280
});
});
test('각 자동 후보를 임시 축으로 삼은 조합을 선별한다', () => {
const anchoredCards = [
{ id: 'a', characterId: 'a', score: 100 },
{ id: 'b', characterId: 'b', score: 90 },
{ id: 'c', characterId: 'c', score: 80 },
{ id: 'd', characterId: 'd', score: 1 }
];
const result = generateAnchoredCombinationCandidates(
[],
anchoredCards,
2,
[{ scoreCard: (card) => card.score }],
1
);
assert.ok(result.some((combination) => combination.some((card) => card.id === 'd')));
});
test('정밀 분석 후보에 각 카드의 최고 대표 조합을 남긴다', () => {
const candidates = [
{ cards: [{ id: 'a', characterId: 'a' }, { id: 'b', characterId: 'b' }], score: 100 },
{ cards: [{ id: 'a', characterId: 'a' }, { id: 'c', characterId: 'c' }], score: 90 },
{ cards: [{ id: 'd', characterId: 'd' }, { id: 'e', characterId: 'e' }], score: 20 }
];
const result = selectMemberDiverseCandidates(candidates, (candidate) => candidate.cards, (candidate) => candidate.score, [], 10);
assert.equal(result.length, 3);
assert.ok(result.some((candidate) => candidate.cards.some((card) => card.id === 'e')));
});
+88
View File
@@ -8,6 +8,33 @@ type BeamState<T extends CombinationSearchCard> = {
score: number; score: number;
}; };
export type CombinationSearchLimits = {
openSlots: number;
screeningLimit: number;
baseExactFinalistLimit: number;
alternativeExactFinalistLimit: number;
};
export type CombinationScoringStrategy<T extends CombinationSearchCard> = {
scoreCard: (card: T) => number;
scoreCombination?: (cards: readonly T[]) => number;
};
const SCREENING_LIMIT_PER_OPEN_SLOT = 5_000;
const EXACT_FINALIST_LIMIT_PER_OPEN_SLOT = 56;
export function combinationSearchLimits(fixedCardCount: number, targetSize = 5): CombinationSearchLimits {
const normalizedTargetSize = Math.max(1, Math.floor(targetSize));
const normalizedFixedCardCount = Math.min(normalizedTargetSize, Math.max(0, Math.floor(fixedCardCount)));
const openSlots = Math.max(1, normalizedTargetSize - normalizedFixedCardCount);
return {
openSlots,
screeningLimit: SCREENING_LIMIT_PER_OPEN_SLOT * openSlots,
baseExactFinalistLimit: EXACT_FINALIST_LIMIT_PER_OPEN_SLOT * openSlots,
alternativeExactFinalistLimit: EXACT_FINALIST_LIMIT_PER_OPEN_SLOT * openSlots
};
}
export function resolveCandidateAwakening( export function resolveCandidateAwakening(
configuredAwakening: number, configuredAwakening: number,
owned: boolean, owned: boolean,
@@ -63,3 +90,64 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
.sort((left, right) => right.score - left.score) .sort((left, right) => right.score - left.score)
.map((state) => state.cards); .map((state) => state.cards);
} }
export function generateAnchoredCombinationCandidates<T extends CombinationSearchCard>(
fixedCards: readonly T[],
candidateCards: readonly T[],
targetSize: number,
strategies: readonly CombinationScoringStrategy<T>[],
perAnchorLimit: number
): T[][] {
if (strategies.length === 0 || perAnchorLimit <= 0 || fixedCards.length >= targetSize) return [];
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
const anchors = candidateCards.filter((card) => !fixedCharacters.has(card.characterId));
const perStrategyLimit = Math.max(1, Math.ceil(perAnchorLimit / strategies.length));
const combined = new Map<string, T[]>();
for (const anchor of anchors) {
const anchored = new Map<string, T[]>();
for (const strategy of strategies) {
for (const cards of generateCombinationCandidates(
[...fixedCards, anchor],
candidateCards,
targetSize,
strategy.scoreCard,
perStrategyLimit,
strategy.scoreCombination
)) {
anchored.set(cards.map((card) => card.id).sort().join('|'), cards);
}
}
for (const [key, cards] of Array.from(anchored.entries()).slice(0, perAnchorLimit)) {
combined.set(key, cards);
}
}
return Array.from(combined.values());
}
export function selectMemberDiverseCandidates<T>(
candidates: readonly T[],
cardsOf: (candidate: T) => readonly { id: string }[],
scoreOf: (candidate: T) => number,
fixedCardIds: readonly string[],
limit: number
): T[] {
if (limit <= 0) return [];
const fixedIds = new Set(fixedCardIds);
const ranked = [...candidates].sort((left, right) => scoreOf(right) - scoreOf(left));
const bestByCardId = new Map<string, T>();
for (const candidate of ranked) {
for (const card of cardsOf(candidate)) {
if (!fixedIds.has(card.id) && !bestByCardId.has(card.id)) bestByCardId.set(card.id, candidate);
}
}
const selected = new Map<string, T>();
for (const candidate of bestByCardId.values()) {
const key = cardsOf(candidate).map((card) => card.id).sort().join('|');
selected.set(key, candidate);
if (selected.size >= limit) break;
}
return Array.from(selected.values());
}
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { events } from '../data/events.ts';
import { activeEventAt, eventBonusBreakdown, eventChallengeSongScoreBonusPercent, eventPointIndex } from './event-optimization.ts';
const currentEvent = events[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);
assert.equal(activeEventAt(events, Date.parse('2026-09-06T11:00:00.000Z')), null);
});
test('편성·신규 카드·개화 보너스를 카드별로 합산한다', () => {
const cards = [
{ id: currentEvent.featuredCardIds[0], characterId: currentEvent.bonusCharacterIds[0], rarity: 5 },
{ id: 'regular-event-member', characterId: currentEvent.bonusCharacterIds[1], rarity: 4 },
{ id: 'other-member', characterId: 'chr-other', rarity: 3 }
];
const awakening = new Map([[cards[0].id, 5], [cards[1].id, 2], [cards[2].id, 1]]);
const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakening.get(card.id) ?? 0);
assert.deepEqual(bonus, {
formationPercent: 60,
featuredCardPercent: 30,
awakeningPercent: 38,
totalPercent: 128
});
});
test('과제곡에 대응하는 신규 카드가 있을 때만 스코어 보너스를 적용한다', () => {
assert.equal(eventChallengeSongScoreBonusPercent(currentEvent, 'm0338', [currentEvent.featuredCardIds[0]]), 10);
assert.equal(eventChallengeSongScoreBonusPercent(currentEvent, 'm0338', [currentEvent.featuredCardIds[1]]), 0);
assert.equal(eventChallengeSongScoreBonusPercent(currentEvent, 'm0342', [currentEvent.featuredCardIds[3]]), 10);
});
test('예상 점수에 이벤트 획득 배율을 적용해 비교 지수를 만든다', () => {
assert.equal(eventPointIndex(1_000_000, 128), 2_280_000);
});
+69
View File
@@ -0,0 +1,69 @@
export type EventCard = {
id: string;
characterId: string;
rarity: number;
};
export type EventDefinition = {
id: string;
name: string;
startsAt: string;
endsAt: string;
bonusCharacterIds: readonly string[];
featuredCardIds: readonly string[];
challengeSongBonusCardIds: Readonly<Record<string, readonly string[]>>;
formationBonusPercent: number;
featuredCardBonusPercent: number;
challengeSongScoreBonusPercent: number;
awakeningBonusPercentByRarity: Readonly<Record<number, readonly number[]>>;
};
export type EventBonusBreakdown = {
formationPercent: number;
featuredCardPercent: number;
awakeningPercent: number;
totalPercent: number;
};
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;
}
export function eventBonusBreakdown(
event: EventDefinition,
cards: readonly EventCard[],
awakeningForCard: (card: EventCard) => number
): EventBonusBreakdown {
const bonusCharacters = new Set(event.bonusCharacterIds);
const featuredCards = new Set(event.featuredCardIds);
let formationPercent = 0;
let featuredCardPercent = 0;
let awakeningPercent = 0;
for (const card of cards) {
if (bonusCharacters.has(card.characterId)) formationPercent += event.formationBonusPercent;
if (featuredCards.has(card.id)) featuredCardPercent += event.featuredCardBonusPercent;
const awakening = Math.max(0, Math.min(5, Math.trunc(awakeningForCard(card))));
awakeningPercent += event.awakeningBonusPercentByRarity[card.rarity]?.[awakening] ?? 0;
}
return {
formationPercent,
featuredCardPercent,
awakeningPercent,
totalPercent: formationPercent + featuredCardPercent + awakeningPercent
};
}
export function eventChallengeSongScoreBonusPercent(
event: EventDefinition,
songId: string,
cardIds: readonly string[]
): number {
const bonusCards = new Set(event.challengeSongBonusCardIds[songId] ?? []);
return cardIds.some((cardId) => bonusCards.has(cardId)) ? event.challengeSongScoreBonusPercent : 0;
}
export function eventPointIndex(score: number, eventBonusPercent: number): number {
return Math.round(score * (1 + eventBonusPercent / 100));
}
+102 -1
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts'; import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts'; import type { ExactSongChart, OptimizerCard } from './types.ts';
const cards: OptimizerCard[] = ['a', 'b', 'c', 'd', 'e'].map((id, index) => ({ const cards: OptimizerCard[] = ['a', 'b', 'c', 'd', 'e'].map((id, index) => ({
@@ -31,10 +32,11 @@ test('정확 분석 결과에 최적 조합의 액티브와 스페셜 지속 구
assert.ok(result.activeWindows.every((window) => window.endSeconds > window.startSeconds)); assert.ok(result.activeWindows.every((window) => window.endSeconds > window.startSeconds));
}); });
test('패시브 스코어 서포트는 순서상 앞선 대상 타입의 지정 인원에게 적용한다', () => { test('패시브 스코어 서포트는 기본 파라미터 합계가 높은 지정 인원에게 적용한다', () => {
const supported = cards.map((card, index) => ({ const supported = cards.map((card, index) => ({
...card, ...card,
type: index < 3 ? '퓨어' : '해피', type: index < 3 ? '퓨어' : '해피',
naturalParameterTotal: [100, 300, 200, 50, 50][index],
passiveScoreSupportEffects: index === 4 passiveScoreSupportEffects: index === 4
? [{ percent: 20, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }] ? [{ percent: 20, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }]
: [] : []
@@ -42,5 +44,104 @@ test('패시브 스코어 서포트는 순서상 앞선 대상 타입의 지정
const baseline = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100 }); const baseline = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100 });
const result = optimizeExactOrderAndCooldowns(supported, chart, { perfectNoteScore: 100 }); const result = optimizeExactOrderAndCooldowns(supported, chart, { perfectNoteScore: 100 });
assert.deepEqual(passiveScoreSupportByCard(supported), [0, 20, 20, 0, 0]);
assert.ok(result.theoreticalMaxScore > baseline.theoreticalMaxScore); assert.ok(result.theoreticalMaxScore > baseline.theoreticalMaxScore);
}); });
test('스페셜 발동 순서를 탐색해 가장 높은 액티브를 강한 구간에 배치한다', () => {
const formation: OptimizerCard[] = [
{ id: 'a', name: 'a', type: '퓨어', naturalParameterTotal: 200, active: { cooldownSeconds: 1, durationSeconds: 0.9, scoreUpPercent: 105 }, special: { durationSeconds: 0, scoreUpPercent: 0 }, allowedCooldownReductions: [0] },
{ id: 'b', name: 'b', type: '퓨어', naturalParameterTotal: 100, active: { cooldownSeconds: 1, durationSeconds: 0.9, scoreUpPercent: 10 }, special: { durationSeconds: 0, scoreUpPercent: 0 }, allowedCooldownReductions: [0] },
{ id: 'c', name: 'c', type: '퓨어', naturalParameterTotal: 300, active: { cooldownSeconds: 1, durationSeconds: 0.9, scoreUpPercent: 110 }, special: { durationSeconds: 2, scoreUpPercent: 100 }, allowedCooldownReductions: [0] },
{ id: 'd', name: 'd', type: '해피', active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 }, special: { durationSeconds: 0, scoreUpPercent: 0 }, allowedCooldownReductions: [0] },
{
id: 'support',
name: 'support',
type: '해피',
active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 },
special: { durationSeconds: 0, scoreUpPercent: 0 },
passiveScoreSupportEffects: [{ percent: 20, target: 'group', targetGroup: '퓨어 타입', targetCount: 2 }],
allowedCooldownReductions: [0]
}
];
const targetChart: ExactSongChart = {
songId: 'formation-order-test',
difficulty: 'expert',
durationSeconds: 3,
scoreRatio: 1,
notes: [
{ timeSeconds: 0.5, type: 'regular', isFlick: false },
{ timeSeconds: 1.1, type: 'regular', isFlick: false }
],
specialStartSeconds: [0, null, null, null, null]
};
const result = optimizeExactOrderAndCooldowns(formation, targetChart, { perfectNoteScore: 100 });
const firstActiveWinner = result.effectiveActiveWindows.find((window) => window.startSeconds <= 1.1 && 1.1 < window.endSeconds);
assert.equal(result.order[0], 'c');
assert.equal(firstActiveWinner?.cardId, 'c');
assert.ok(Math.abs((firstActiveWinner?.scoreUpPercent ?? 0) - 242) < 1e-9);
});
test('예상 점수보다 이론상 고점을 우선해 스페셜 순서를 선택한다', () => {
const formation: OptimizerCard[] = [
{
id: 'active',
name: 'active',
active: { cooldownSeconds: 1, durationSeconds: 1, scoreUpPercent: 100, probabilityPercent: 10 },
special: { durationSeconds: 0, scoreUpPercent: 0 },
allowedCooldownReductions: [0]
},
{
id: 'score-support',
name: 'score-support',
active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 },
special: { durationSeconds: 1, scoreUpPercent: 200 },
allowedCooldownReductions: [0]
},
{
id: 'rate-support',
name: 'rate-support',
active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 },
special: { durationSeconds: 1, scoreUpPercent: 0, activationRateUpPercent: 900 },
allowedCooldownReductions: [0]
},
...['filler-a', 'filler-b'].map((id): OptimizerCard => ({
id,
name: id,
active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 },
special: { durationSeconds: 0, scoreUpPercent: 0 },
allowedCooldownReductions: [0]
}))
];
const targetChart: ExactSongChart = {
songId: 'maximum-priority-test',
difficulty: 'expert',
durationSeconds: 2,
scoreRatio: 1,
notes: [{ timeSeconds: 1.1, type: 'regular', isFlick: false }],
specialStartSeconds: [0.5, null, null, null, null]
};
const result = optimizeExactOrderAndCooldowns(formation, targetChart, { perfectNoteScore: 100 });
assert.equal(result.order[0], 'score-support');
assert.equal(result.theoreticalMaxScore, 400);
assert.equal(result.expectedScore, 130);
});
test('이벤트 효율 탐색에서는 예상 점수를 우선해 스페셜 순서를 선택할 수 있다', () => {
const probabilistic = cards.map((card, index) => ({
...card,
active: {
...card.active,
probabilityPercent: index === 0 ? 5 : 100,
scoreUpPercent: index === 0 ? 500 : card.active.scoreUpPercent
}
}));
const maximum = optimizeExactOrderAndCooldowns(probabilistic, chart, { perfectNoteScore: 100 });
const expected = optimizeExactOrderAndCooldowns(probabilistic, chart, { perfectNoteScore: 100, objective: 'expected' });
assert.ok(expected.expectedScore >= maximum.expectedScore);
});
+28 -31
View File
@@ -1,5 +1,6 @@
import { calculateExactScore } from './score.ts'; import { effectiveActiveProbability } from './active-probability.ts';
import { skillTargetBelongsTo } from './skill-condition.ts'; import { calculateExactScore, finalNoteScore } from './score.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts'; import { effectiveCooldownSeconds } from './timeline.ts';
import { COOLDOWN_REDUCTIONS } from './types.ts'; import { COOLDOWN_REDUCTIONS } from './types.ts';
import type { import type {
@@ -17,24 +18,6 @@ type CandidateProfile = {
probability: Float64Array; probability: Float64Array;
}; };
function passiveScoreSupportByCard(order: readonly OptimizerCard[]): number[] {
const totals = order.map(() => 0);
order.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = order.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = order
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.slice(0, effect.targetCount ?? order.length)
.map(({ index }) => index);
for (const index of indexes) totals[index] += effect.percent;
}
});
return totals;
}
function permutations<T>(items: readonly T[]): T[][] { function permutations<T>(items: readonly T[]): T[][] {
if (items.length <= 1) return [Array.from(items)]; if (items.length <= 1) return [Array.from(items)];
const result: T[][] = []; const result: T[][] = [];
@@ -103,7 +86,7 @@ function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): M
let effectiveProbability = probabilityByAttempt.get(attempt); let effectiveProbability = probabilityByAttempt.get(attempt);
if (effectiveProbability === undefined) { if (effectiveProbability === undefined) {
const activationBonus = specialRate(order, chart, activationTime, 'activationRateUpPercent'); const activationBonus = specialRate(order, chart, activationTime, 'activationRateUpPercent');
effectiveProbability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationBonus / 100)); effectiveProbability = effectiveActiveProbability(card, activationBonus);
probabilityByAttempt.set(attempt, effectiveProbability); probabilityByAttempt.set(attempt, effectiveProbability);
} }
boost[noteIndex] = card.active.scoreUpPercent / 100 * boost[noteIndex] = card.active.scoreUpPercent / 100 *
@@ -197,7 +180,7 @@ function scoreAssignment(
profiles: ReadonlyMap<string, CandidateProfile>, profiles: ReadonlyMap<string, CandidateProfile>,
base: Float64Array, base: Float64Array,
directBonus: number directBonus: number
): number { ): { expectedScore: number; theoreticalMaxScore: number } {
const selected = cards.map((card) => { const selected = cards.map((card) => {
const reduction = reductions[card.id] ?? 0; const reduction = reductions[card.id] ?? 0;
const profile = profiles.get(`${card.id}:${reduction}`); const profile = profiles.get(`${card.id}:${reduction}`);
@@ -206,7 +189,8 @@ function scoreAssignment(
}); });
const boosts = new Float64Array(cards.length); const boosts = new Float64Array(cards.length);
const probabilities = new Float64Array(cards.length); const probabilities = new Float64Array(cards.length);
let rawScore = 0; let expectedScore = 0;
let theoreticalMaxScore = 0;
for (let noteIndex = 0; noteIndex < base.length; noteIndex += 1) { for (let noteIndex = 0; noteIndex < base.length; noteIndex += 1) {
let count = 0; let count = 0;
@@ -229,9 +213,10 @@ function scoreAssignment(
expectedBoost += previousFailed * probabilities[position] * boosts[position]; expectedBoost += previousFailed * probabilities[position] * boosts[position];
previousFailed *= 1 - probabilities[position]; previousFailed *= 1 - probabilities[position];
} }
rawScore += base[noteIndex] * (1 + expectedBoost); expectedScore += finalNoteScore(base[noteIndex] * (1 + expectedBoost), directBonus);
theoreticalMaxScore += finalNoteScore(base[noteIndex] * (1 + (count > 0 ? boosts[0] : 0)), directBonus);
} }
return Math.floor(Math.floor(rawScore) * (1 + directBonus / 100) + Number.EPSILON * Math.max(1, rawScore) * 4); return { expectedScore, theoreticalMaxScore };
} }
export function optimizeExactOrderAndCooldowns( export function optimizeExactOrderAndCooldowns(
@@ -242,23 +227,34 @@ export function optimizeExactOrderAndCooldowns(
if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) { if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) {
throw new Error('서로 다른 카드 5장이 필요합니다.'); throw new Error('서로 다른 카드 5장이 필요합니다.');
} }
const orders = permutations(cards);
const reductionAssignments = assignments(cards); const reductionAssignments = assignments(cards);
const base = baseScores(chart, options.perfectNoteScore); const base = baseScores(chart, options.perfectNoteScore);
const totalCandidates = orders.length * reductionAssignments.length; const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1);
const totalCandidates = orderCount * reductionAssignments.length;
let evaluatedCandidates = 0; let evaluatedCandidates = 0;
let bestScore = Number.NEGATIVE_INFINITY; let bestTheoreticalMaxScore = Number.NEGATIVE_INFINITY;
let bestExpectedScore = Number.NEGATIVE_INFINITY;
let bestOrder: OptimizerCard[] | null = null; let bestOrder: OptimizerCard[] | null = null;
let bestReductions: Record<string, CooldownReduction> | null = null; let bestReductions: Record<string, CooldownReduction> | null = null;
let bestReductionTotal = Number.POSITIVE_INFINITY; let bestReductionTotal = Number.POSITIVE_INFINITY;
for (const order of orders) { for (const order of permutations(cards)) {
const profiles = makeProfiles(order, chart); const profiles = makeProfiles(order, chart);
for (const reductions of reductionAssignments) { for (const reductions of reductionAssignments) {
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0); const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
const reductionTotal = Object.values(reductions).reduce<number>((sum, value) => sum + value, 0); const reductionTotal = Object.values(reductions).reduce<number>((sum, value) => sum + value, 0);
if (score > bestScore || (score === bestScore && reductionTotal < bestReductionTotal)) { const expectedFirst = options.objective === 'expected';
bestScore = score; const isBetterPrimary = expectedFirst
? score.expectedScore > bestExpectedScore
: score.theoreticalMaxScore > bestTheoreticalMaxScore;
const isBetterSecondary = expectedFirst
? score.expectedScore === bestExpectedScore && score.theoreticalMaxScore > bestTheoreticalMaxScore
: score.theoreticalMaxScore === bestTheoreticalMaxScore && score.expectedScore > bestExpectedScore;
const isCheaperTie = score.theoreticalMaxScore === bestTheoreticalMaxScore &&
score.expectedScore === bestExpectedScore && reductionTotal < bestReductionTotal;
if (isBetterPrimary || isBetterSecondary || isCheaperTie) {
bestTheoreticalMaxScore = score.theoreticalMaxScore;
bestExpectedScore = score.expectedScore;
bestOrder = order; bestOrder = order;
bestReductions = reductions; bestReductions = reductions;
bestReductionTotal = reductionTotal; bestReductionTotal = reductionTotal;
@@ -273,6 +269,7 @@ export function optimizeExactOrderAndCooldowns(
return { return {
...calculateExactScore(bestOrder, bestReductions, chart, options), ...calculateExactScore(bestOrder, bestReductions, chart, options),
order: bestOrder.map((card) => card.id), order: bestOrder.map((card) => card.id),
formationOrder: cards.map((card) => card.id),
cooldownReductions: { ...bestReductions }, cooldownReductions: { ...bestReductions },
...windows, ...windows,
evaluatedCandidates evaluatedCandidates
+5
View File
@@ -1,11 +1,16 @@
export * from './active-probability.ts';
export * from './card-adapter.ts'; export * from './card-adapter.ts';
export * from './combination-search.ts'; export * from './combination-search.ts';
export * from './awakening-requirements.ts';
export * from './board-effects.ts'; export * from './board-effects.ts';
export * from './board-connection-optimizer.ts';
export * from './exact-optimizer.ts'; export * from './exact-optimizer.ts';
export * from './event-optimization.ts';
export * from './memory.ts'; export * from './memory.ts';
export * from './maximum-weight-matching.ts'; export * from './maximum-weight-matching.ts';
export * from './optimizer.ts'; export * from './optimizer.ts';
export * from './score.ts'; export * from './score.ts';
export * from './score-probability.ts';
export * from './skill-condition.ts'; export * from './skill-condition.ts';
export * from './timeline.ts'; export * from './timeline.ts';
export * from './types.ts'; export * from './types.ts';
+12
View File
@@ -29,6 +29,18 @@ test('5장과 네 가지 감소율은 122,880개 후보를 만든다', () => {
assert.equal(countOptimizationCandidates(cards), 122_880); assert.equal(countOptimizationCandidates(cards), 122_880);
}); });
test('제한 인원 패시브가 있어도 편성 순서 후보를 추가하지 않는다', () => {
const targeted = cards.map((card, index) => ({
...card,
type: index < 3 ? '퓨어' : '해피',
passiveScoreSupportEffects: index === 4
? [{ percent: 11, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }]
: []
}));
assert.equal(countOptimizationCandidates(targeted), 122_880);
});
test('노트가 몰린 첫 스페셜 슬롯에 가장 강한 스페셜 카드를 배치한다', () => { test('노트가 몰린 첫 스페셜 슬롯에 가장 강한 스페셜 카드를 배치한다', () => {
const fixedReductionCards = cards.map((card) => ({ ...card, allowedCooldownReductions: [0] as const })); const fixedReductionCards = cards.map((card) => ({ ...card, allowedCooldownReductions: [0] as const }));
const result = optimizeOrderAndCooldowns(fixedReductionCards, chart); const result = optimizeOrderAndCooldowns(fixedReductionCards, chart);
+27
View File
@@ -0,0 +1,27 @@
import { skillTargetBelongsTo } from './skill-condition.ts';
import type { OptimizerCard } from './types.ts';
export function passiveScoreSupportByCard(cards: readonly OptimizerCard[]): number[] {
const totals = cards.map(() => 0);
cards.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = cards.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = cards
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.sort((left, right) =>
(right.card.naturalParameterTotal ?? 0) - (left.card.naturalParameterTotal ?? 0) ||
left.card.id.localeCompare(right.card.id))
.slice(0, effect.targetCount ?? cards.length)
.map(({ index }) => index);
for (const index of indexes) totals[index] += effect.percent;
}
});
return totals;
}
export function passiveScoreSupportAt(cards: readonly OptimizerCard[], targetIndex: number): number {
return passiveScoreSupportByCard(cards)[targetIndex] ?? 0;
}
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createInitialReleaseClock, synchronizeReleaseClock } from '../data/release-clock.ts';
test('서버 시각은 시스템 시각 변경과 무관하게 단조 시계로 흐른다', async () => {
let monotonic = 100;
let wall = 9_999_999;
const clock = await synchronizeReleaseClock({
fetcher: async () => {
monotonic = 140;
return { ok: true, json: async () => ({ now: 1_000 }) } as Response;
},
wallNow: () => wall,
monotonicNow: () => monotonic,
online: true
});
assert.equal(clock.source, 'server');
assert.equal(clock.now(), 1_020);
wall = 1;
monotonic = 240;
assert.equal(clock.now(), 1_120);
});
test('오프라인에서는 요청하지 않고 현재 시스템 시각을 사용한다', async () => {
let wall = 2_000;
let requested = false;
const clock = await synchronizeReleaseClock({
fetcher: async () => {
requested = true;
throw new Error('호출되면 안 됨');
},
wallNow: () => wall,
online: false
});
assert.equal(requested, false);
assert.equal(clock.source, 'system');
assert.equal(clock.now(), 2_000);
wall = 3_000;
assert.equal(clock.now(), 3_000);
});
test('온라인 시각 요청 실패 시에만 시스템 시각으로 대체한다', async () => {
const clock = await synchronizeReleaseClock({
fetcher: async () => { throw new Error('offline'); },
wallNow: () => 4_000,
online: true
});
assert.equal(clock.source, 'system');
assert.equal(clock.now(), 4_000);
});
test('초기 서버 시각도 시스템 시각 대신 단조 시계로 진행한다', () => {
let monotonic = 10;
const clock = createInitialReleaseClock(5_000, {
wallNow: () => 99_999,
monotonicNow: () => monotonic
});
monotonic = 35;
assert.equal(clock.source, 'server');
assert.equal(clock.now(), 5_025);
});
+106
View File
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { estimateScoreAchievementProbabilities } from './index.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
function card(
id: string,
scoreUpPercent: number,
probabilityPercent: number,
durationSeconds = 1
): OptimizerCard {
return {
id,
name: id,
active: { cooldownSeconds: 1, durationSeconds, scoreUpPercent, probabilityPercent },
special: { durationSeconds: 0, scoreUpPercent: 0 },
allowedCooldownReductions: [0]
};
}
function chart(times: number[], specialStartSeconds: (number | null)[] = []): ExactSongChart {
return {
songId: 'test',
difficulty: 'expert',
durationSeconds: 5,
scoreRatio: 1,
notes: times.map((timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false })),
specialStartSeconds
};
}
test('한 번의 발동이 여러 노트에 영향을 주어도 최고점 확률은 한 번만 곱한다', () => {
const result = estimateScoreAchievementProbabilities(
[card('main', 100, 50, 0.9)],
{ main: 0 },
chart([1.1, 1.5]),
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 1 }
);
assert.equal(result.maximum, 0.5);
});
test('최고점에 영향을 주지 않는 약한 액티브는 최고점 확률에서 제외한다', () => {
const result = estimateScoreAchievementProbabilities(
[card('strong', 100, 50), card('weak', 20, 10)],
{ strong: 0, weak: 0 },
chart([1.1]),
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 2 }
);
assert.equal(result.maximum, 0.5);
});
test('같은 최고 효과의 액티브가 겹치면 둘 중 하나가 발동할 확률을 사용한다', () => {
const result = estimateScoreAchievementProbabilities(
[card('left', 100, 50), card('right', 100, 50)],
{ left: 0, right: 0 },
chart([1.1]),
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 3 }
);
assert.equal(result.maximum, 0.75);
});
test('스페셜의 액티브 발동률 증가를 발동 시점에 적용한다', () => {
const main = card('main', 100, 50);
const support: OptimizerCard = {
...card('support', 0, 0),
special: { durationSeconds: 3, scoreUpPercent: 0, activationRateUpPercent: 50 }
};
const result = estimateScoreAchievementProbabilities(
[main, support],
{ main: 0, support: 0 },
chart([1.1], [null, 0]),
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 4 }
);
assert.equal(result.maximum, 0.75);
});
test('블루 보드와 스페셜의 발동률 증가 배율을 합산한다', () => {
const main = card('main', 100, 55);
main.active.activationRateUpPercent = 30;
const support: OptimizerCard = {
...card('support', 0, 0),
special: { durationSeconds: 3, scoreUpPercent: 0, activationRateUpPercent: 40 }
};
const result = estimateScoreAchievementProbabilities(
[main, support],
{ main: 0, support: 0 },
chart([1.1], [null, 0]),
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 6 }
);
assert.ok(Math.abs(result.maximum - 0.935) < 1e-12);
});
test('확정 발동이면 모든 달성 확률이 100%다', () => {
const result = estimateScoreAchievementProbabilities(
[card('main', 100, 100)],
{ main: 0 },
chart([1.1, 2.1, 3.1]),
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 5 }
);
assert.deepEqual(result, {
maximum: 1,
atLeast99Percent: 1,
atLeast95Percent: 1,
samples: 1_000
});
});
+237
View File
@@ -0,0 +1,237 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
import type {
CooldownReduction,
ExactScoreOptions,
ExactSongChart,
OptimizerCard,
ScoreAchievementProbabilities
} from './types.ts';
type Attempt = {
key: string;
probability: number;
};
type NoteCandidate = {
attemptIndex: number;
boost: number;
};
type ScoreGroup = {
candidates: readonly NoteCandidate[];
scores: readonly number[];
};
type PreparedProbabilityModel = {
attempts: readonly Attempt[];
groups: readonly ScoreGroup[];
maximumScore: number;
maximumClauses: readonly (readonly number[])[];
};
function specialActive(card: OptimizerCard, start: number | null | undefined, time: number): boolean {
return start !== null && start !== undefined && start <= time && time < start + card.special.durationSeconds;
}
function specialRate(
cards: readonly OptimizerCard[],
chart: ExactSongChart,
time: number,
field: 'scoreUpPercent' | 'activationRateUpPercent'
): number {
return cards.reduce((sum, card, slot) => sum +
(specialActive(card, chart.specialStartSeconds[slot], time) ? card.special[field] ?? 0 : 0), 0);
}
function comboBonus(combo: number): number {
return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0;
}
function finalNoteScore(score: number, percent: number): number {
const scaled = score * (1 + percent / 100);
return Math.ceil(scaled - Number.EPSILON * Math.max(1, scaled) * 4);
}
function scoreNotes(chart: ExactSongChart): ExactSongChart['notes'] {
return chart.notes;
}
function prepareModel(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
options: ExactScoreOptions
): PreparedProbabilityModel {
const attempts: Attempt[] = [];
const attemptIndexes = new Map<string, number>();
const passiveSupport = passiveScoreSupportByCard(cards);
const grouped = new Map<string, { candidates: NoteCandidate[]; scores: number[] }>();
const clauses: number[][] = [];
let maximumScore = 0;
scoreNotes(chart).forEach((note, noteIndex) => {
const candidates: NoteCandidate[] = [];
const specialSupport = specialRate(cards, chart, note.timeSeconds, 'scoreUpPercent');
cards.forEach((card, cardIndex) => {
const reduction = reductions[card.id] ?? 0;
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const attemptNumber = Math.floor((note.timeSeconds + 1e-9) / interval);
if (attemptNumber <= 0) return;
const activationTime = attemptNumber * interval;
if (note.timeSeconds >= activationTime + card.active.durationSeconds) return;
const key = `${card.id}:${attemptNumber}`;
let attemptIndex = attemptIndexes.get(key);
if (attemptIndex === undefined) {
const activationBonus = specialRate(cards, chart, activationTime, 'activationRateUpPercent');
const probability = effectiveActiveProbability(card, activationBonus);
attemptIndex = attempts.length;
attemptIndexes.set(key, attemptIndex);
attempts.push({ key, probability });
}
candidates.push({
attemptIndex,
boost: card.active.scoreUpPercent / 100 *
(1 + (specialSupport + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100)
});
});
candidates.sort((left, right) => right.boost - left.boost || left.attemptIndex - right.attemptIndex);
const combo = noteIndex + 1;
const baseNoteScore = note.type === 'mid'
? Math.ceil(options.perfectNoteScore * 0.1)
: options.perfectNoteScore * (note.isFlick ? 1.05 : 1);
const rawBaseline = baseNoteScore * (1 + comboBonus(combo));
const directBonus = options.directSongScoreBonusPercent ?? 0;
const scores = [
...candidates.map((candidate) => finalNoteScore(rawBaseline * (1 + candidate.boost), directBonus)),
finalNoteScore(rawBaseline, directBonus)
];
maximumScore += scores[0];
if (candidates.length > 0) {
clauses.push(candidates
.filter((_, index) => scores[index] === scores[0])
.map((candidate) => candidate.attemptIndex));
}
const signature = candidates.map((candidate) => `${candidate.attemptIndex}:${candidate.boost.toPrecision(15)}`).join('|');
const existing = grouped.get(signature);
if (existing) scores.forEach((score, index) => { existing.scores[index] += score; });
else grouped.set(signature, { candidates, scores });
});
return {
attempts,
groups: Array.from(grouped.values()),
maximumScore,
maximumClauses: simplifyClauses(clauses)
};
}
function simplifyClauses(clauses: readonly (readonly number[])[]): number[][] {
const unique = new Map<string, number[]>();
for (const clause of clauses) {
const normalized = Array.from(new Set(clause)).sort((left, right) => left - right);
unique.set(normalized.join(','), normalized);
}
const sorted = Array.from(unique.values()).sort((left, right) => left.length - right.length);
return sorted.filter((clause, index) => !sorted.slice(0, index).some((candidate) =>
candidate.length <= clause.length && candidate.every((variable) => clause.includes(variable))));
}
function exactClauseProbability(clauses: readonly (readonly number[])[], attempts: readonly Attempt[]): number {
const memo = new Map<string, number>();
const solve = (current: readonly (readonly number[])[]): number => {
if (current.length === 0) return 1;
if (current.some((clause) => clause.length === 0)) return 0;
const simplified = simplifyClauses(current);
const key = simplified.map((clause) => clause.join(',')).join(';');
const cached = memo.get(key);
if (cached !== undefined) return cached;
const required = new Set(simplified.filter((clause) => clause.length === 1).map((clause) => clause[0]));
if (required.size > 0) {
let requiredProbability = 1;
for (const variable of required) requiredProbability *= attempts[variable]?.probability ?? 0;
const remaining = simplified.filter((clause) => !clause.some((variable) => required.has(variable)));
const result = requiredProbability * solve(remaining);
memo.set(key, result);
return result;
}
const occurrences = new Map<number, number>();
simplified.forEach((clause) => clause.forEach((variable) =>
occurrences.set(variable, (occurrences.get(variable) ?? 0) + 1)));
const variable = Array.from(occurrences).sort((left, right) => right[1] - left[1])[0][0];
const probability = attempts[variable]?.probability ?? 0;
const whenTrue = simplified.filter((clause) => !clause.includes(variable));
const whenFalse = simplified.map((clause) => clause.filter((candidate) => candidate !== variable));
const result = probability * solve(whenTrue) + (1 - probability) * solve(whenFalse);
memo.set(key, result);
return result;
};
return solve(clauses);
}
function hashSeed(cards: readonly OptimizerCard[], reductions: Readonly<Record<string, CooldownReduction>>): number {
const value = cards.map((card) => `${card.id}:${reductions[card.id] ?? 0}`).join('|');
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function randomGenerator(seed: number): () => number {
let state = seed || 0x9e3779b9;
return () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ value >>> 15, value | 1);
value ^= value + Math.imul(value ^ value >>> 7, value | 61);
return ((value ^ value >>> 14) >>> 0) / 4294967296;
};
}
export function estimateScoreAchievementProbabilities(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
options: ExactScoreOptions & { sampleCount?: number; seed?: number }
): ScoreAchievementProbabilities {
const model = prepareModel(cards, reductions, chart, options);
const samples = Math.max(1, Math.round(options.sampleCount ?? 25_000));
const random = randomGenerator(options.seed ?? hashSeed(cards, reductions));
const active = new Uint8Array(model.attempts.length);
let atLeast99 = 0;
let atLeast95 = 0;
const threshold99 = model.maximumScore * 0.99;
const threshold95 = model.maximumScore * 0.95;
for (let sample = 0; sample < samples; sample += 1) {
model.attempts.forEach((attempt, index) => { active[index] = random() < attempt.probability ? 1 : 0; });
let score = 0;
for (const group of model.groups) {
let scoreIndex = group.candidates.length;
for (let index = 0; index < group.candidates.length; index += 1) {
if (active[group.candidates[index].attemptIndex]) {
scoreIndex = index;
break;
}
}
score += group.scores[scoreIndex];
}
if (score >= threshold99) atLeast99 += 1;
if (score >= threshold95) atLeast95 += 1;
}
return {
maximum: exactClauseProbability(model.maximumClauses, model.attempts),
atLeast99Percent: atLeast99 / samples,
atLeast95Percent: atLeast95 / samples,
samples
};
}
+52 -5
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import scoreCalibrations from '../../../data/score-calibrations.json' with { type: 'json' };
import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts'; import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts'; import type { ExactSongChart, OptimizerCard } from './types.ts';
@@ -20,14 +21,14 @@ test('종합력과 악곡 비율로 PERFECT 1노트 기본점수를 계산한다
assert.equal(perfectNoteScoreFromPower(0, 200, 777), 777); assert.equal(perfectNoteScoreFromPower(0, 200, 777), 777);
}); });
test('플릭 1.05배와 같은 시각 홀드 중간 판정의 단일 가중치로 환산비율을 계산한다', () => { test('플릭 1.05배와 홀드 중간 판정 각각의 가중치로 환산비율을 계산한다', () => {
const ratio = scoreRatioFromNotes([ const ratio = scoreRatioFromNotes([
{ timeSeconds: 1, type: 'regular', isFlick: false }, { timeSeconds: 1, type: 'regular', isFlick: false },
{ timeSeconds: 2, type: 'regular', isFlick: true }, { timeSeconds: 2, type: 'regular', isFlick: true },
{ timeSeconds: 3, type: 'mid', isFlick: false }, { timeSeconds: 3, type: 'mid', isFlick: false },
{ timeSeconds: 3, type: 'mid', isFlick: false } { timeSeconds: 3, type: 'mid', isFlick: false }
]); ]);
assert.ok(Math.abs(ratio - 2.15 * 0.3713416563352296) < 1e-12); assert.ok(Math.abs(ratio - 2.25 * 0.3713416563352296) < 1e-12);
}); });
test('기본점수의 소수점을 유지하고 가창 보너스 적용 후 노트별로 올림한다', () => { test('기본점수의 소수점을 유지하고 가창 보너스 적용 후 노트별로 올림한다', () => {
@@ -51,13 +52,13 @@ test('홀드 중간 노트는 10%, 플릭은 1.05배로 계산한다', () => {
assert.equal(result.baselineScore, 60 + 630); assert.equal(result.baselineScore, 60 + 630);
}); });
test('같은 시각에 중복된 홀드 중간 판정은 한 번만 계산한다', () => { test('같은 시각의 여러 홀드 중간 판정을 각각 계산한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([ const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'mid', isFlick: false }, { timeSeconds: 1, type: 'mid', isFlick: false },
{ timeSeconds: 1, type: 'mid', isFlick: false } { timeSeconds: 1, type: 'mid', isFlick: false }
]), { perfectNoteScore: 600 }); ]), { perfectNoteScore: 600 });
assert.equal(result.baselineScore, 60); assert.equal(result.baselineScore, 120);
assert.equal(result.notes.length, 1); assert.equal(result.notes.length, 2);
}); });
test('액티브 발동 확률과 스페셜 스코어 서포트를 기대 점수에 반영한다', () => { test('액티브 발동 확률과 스페셜 스코어 서포트를 기대 점수에 반영한다', () => {
@@ -74,3 +75,49 @@ test('액티브 발동 확률과 스페셜 스코어 서포트를 기대 점수
assert.equal(result.expectedScore, 1_200); assert.equal(result.expectedScore, 1_200);
assert.equal(result.theoreticalMaxScore, 1_800); assert.equal(result.theoreticalMaxScore, 1_800);
}); });
test('블루 보드와 스페셜의 발동률 증가 배율을 합산해 기대 점수에 반영한다', () => {
const main: OptimizerCard = {
id: 'main',
name: '메인',
active: {
cooldownSeconds: 1,
durationSeconds: 1,
scoreUpPercent: 100,
probabilityPercent: 55,
activationRateUpPercent: 30
},
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
const support: OptimizerCard = {
id: 'support',
name: '서포트',
active: { cooldownSeconds: 999, durationSeconds: 0, scoreUpPercent: 0, probabilityPercent: 0 },
special: { durationSeconds: 3, scoreUpPercent: 0, activationRateUpPercent: 40 }
};
const result = calculateExactScore([main, support], {}, {
...chart([{ timeSeconds: 1, type: 'regular', isFlick: false }]),
specialStartSeconds: [null, 0]
}, { perfectNoteScore: 100 });
assert.equal(result.expectedScore, 194);
assert.equal(result.theoreticalMaxScore, 200);
});
test('등록된 실측 ratio가 모든 1노트 측정값을 재현한다', () => {
for (const calibration of scoreCalibrations.charts) {
for (const measurement of calibration.measurements) {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'regular', isFlick: false }
]), {
perfectNoteScore: measurement.overallPower / calibration.scoreRatio,
directSongScoreBonusPercent: measurement.directSongScoreBonusPercent
});
assert.equal(
result.baselineScore,
measurement.normalPerfectScore,
`${calibration.songId} ${calibration.difficulty}`
);
}
}
});
+5 -31
View File
@@ -1,5 +1,6 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { effectiveCooldownSeconds } from './timeline.ts'; import { effectiveCooldownSeconds } from './timeline.ts';
import { skillTargetBelongsTo } from './skill-condition.ts'; import { passiveScoreSupportAt } from './passive-support.ts';
import type { import type {
CooldownReduction, CooldownReduction,
ExactScoreOptions, ExactScoreOptions,
@@ -18,13 +19,10 @@ type ActiveCandidate = {
export const CHART_SCORE_WEIGHT_COEFFICIENT = 0.3713416563352296; export const CHART_SCORE_WEIGHT_COEFFICIENT = 0.3713416563352296;
export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number { export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number {
const countedMidTimes = new Set<number>();
let weight = 0; let weight = 0;
for (const note of notes) { for (const note of notes) {
if (note.type === 'mid') { if (note.type === 'mid') {
if (countedMidTimes.has(note.timeSeconds)) continue;
countedMidTimes.add(note.timeSeconds);
weight += 0.1; weight += 0.1;
continue; continue;
} }
@@ -34,24 +32,6 @@ export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number {
return weight * CHART_SCORE_WEIGHT_COEFFICIENT; return weight * CHART_SCORE_WEIGHT_COEFFICIENT;
} }
function passiveScoreSupportAt(cards: readonly OptimizerCard[], targetIndex: number): number {
let total = 0;
cards.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = cards.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = cards
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.slice(0, effect.targetCount ?? cards.length)
.map(({ index }) => index);
if (indexes.includes(targetIndex)) total += effect.percent;
}
});
return total;
}
function comboBonus(combo: number): number { function comboBonus(combo: number): number {
if (combo >= 1_000) return 0.1; if (combo >= 1_000) return 0.1;
if (combo >= 100) return Math.floor(combo / 100) * 0.01; if (combo >= 100) return Math.floor(combo / 100) * 0.01;
@@ -107,7 +87,7 @@ function activeCandidates(
const activationTime = attempt * interval; const activationTime = attempt * interval;
if (timeSeconds >= activationTime + card.active.durationSeconds) return []; if (timeSeconds >= activationTime + card.active.durationSeconds) return [];
const activationRateBonus = activationRateBonusAt(cardsInOrder, chart, activationTime); const activationRateBonus = activationRateBonusAt(cardsInOrder, chart, activationTime);
const probability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationRateBonus / 100)); const probability = effectiveActiveProbability(card, activationRateBonus);
const support = specialSupport + (card.scoreSupportPercent ?? 0) + passiveScoreSupportAt(cardsInOrder, memberIndex); const support = specialSupport + (card.scoreSupportPercent ?? 0) + passiveScoreSupportAt(cardsInOrder, memberIndex);
return [{ return [{
memberIndex, memberIndex,
@@ -128,7 +108,7 @@ function expectedStrongestBoost(candidates: readonly ActiveCandidate[]): number
return expectedBoost; return expectedBoost;
} }
function finalNoteScore(score: number, percent: number): number { export function finalNoteScore(score: number, percent: number): number {
const scaled = score * (1 + percent / 100); const scaled = score * (1 + percent / 100);
return Math.ceil(scaled - Number.EPSILON * Math.max(1, scaled) * 4); return Math.ceil(scaled - Number.EPSILON * Math.max(1, scaled) * 4);
} }
@@ -147,13 +127,7 @@ export function calculateExactScore(
options: ExactScoreOptions options: ExactScoreOptions
): ExactScoreResult { ): ExactScoreResult {
const directBonus = options.directSongScoreBonusPercent ?? 0; const directBonus = options.directSongScoreBonusPercent ?? 0;
const countedMidTimes = new Set<number>(); const scoreNotes = chart.notes;
const scoreNotes = chart.notes.filter((note) => {
if (note.type !== 'mid') return true;
if (countedMidTimes.has(note.timeSeconds)) return false;
countedMidTimes.add(note.timeSeconds);
return true;
});
const notePoints = scoreNotes.map((note, index) => { const notePoints = scoreNotes.map((note, index) => {
const combo = index + 1; const combo = index + 1;
const baseNoteScore = note.type === 'mid' const baseNoteScore = note.type === 'mid'
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { songOptions } from '../data/song-catalog.ts';
const charts = [
{ songId: 'm2', title: 'Zeta Song', difficulty: 'expert', difficultyLevel: 25 },
{ songId: 'm1', title: 'Alpha Song', difficulty: 'expert', difficultyLevel: 30 },
{ songId: 'm1', title: 'Alpha Song', difficulty: 'hard', difficultyLevel: 18 },
{ songId: 'm3', title: 'Another Song', difficulty: 'expert', difficultyLevel: 25 },
{ songId: 'm4', title: 'Unknown Song', difficulty: 'expert' }
];
test('악곡 목록은 기본적으로 영문 제목 알파벳순으로 정렬된다', () => {
assert.deepEqual(songOptions(charts, 'expert').map((song) => song.id), ['m1', 'm3', 'm4', 'm2']);
});
test('영문 제목은 대소문자 구분 없이 검색하고 곡 ID도 검색한다', () => {
assert.deepEqual(songOptions(charts, 'expert', 'ALPHA').map((song) => song.id), ['m1']);
assert.deepEqual(songOptions(charts, 'expert', 'm2').map((song) => song.id), ['m2']);
});
test('선택한 채보의 레벨로 난이도 낮은순과 높은순 정렬을 지원한다', () => {
assert.deepEqual(songOptions(charts, 'expert', '', 'difficulty-asc').map((song) => song.id), ['m3', 'm2', 'm1', 'm4']);
assert.deepEqual(songOptions(charts, 'expert', '', 'difficulty-desc').map((song) => song.id), ['m1', 'm3', 'm2', 'm4']);
assert.equal(songOptions(charts, 'hard')[0].difficultyLevel, 18);
});
+43
View File
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isSongReleased, releasedSongCharts, SONG_RELEASE_TIMES } from '../data/song-release.ts';
test('신규 악곡 공개 시각을 HolodoriDB의 한국 시각과 일치시킨다', () => {
assert.deepEqual(SONG_RELEASE_TIMES, {
m0332: Date.parse('2026-08-19T20:00:00+09:00'),
m0353: Date.parse('2026-08-20T00:00:00+09:00'),
m0335: Date.parse('2026-08-21T20:00:00+09:00'),
m0333: Date.parse('2026-08-23T20:00:00+09:00'),
m0336: Date.parse('2026-08-25T20:00:00+09:00')
});
});
test('공개 시각 전에는 신규 악곡을 숨기고 공개 시각부터 표시한다', () => {
const releaseTime = SONG_RELEASE_TIMES.m0332;
assert.equal(isSongReleased('m0332', releaseTime - 1), false);
assert.equal(isSongReleased('m0332', releaseTime), true);
assert.equal(isSongReleased('existing-song', releaseTime - 1), true);
});
test('채보 목록에서 공개되지 않은 악곡의 모든 난이도를 제외한다', () => {
const charts = [
{ songId: 'existing-song', difficulty: 'expert' },
{ songId: 'm0332', difficulty: 'expert' },
{ songId: 'm0332', difficulty: 'hard' }
];
assert.deepEqual(releasedSongCharts(charts, SONG_RELEASE_TIMES.m0332 - 1), [charts[0]]);
assert.deepEqual(releasedSongCharts(charts, SONG_RELEASE_TIMES.m0332), charts);
});
test('채보 카탈로그의 HolodoriDB 공개 시각을 수동 목록보다 우선한다', () => {
const releaseTime = Date.parse('2026-08-30T00:00:00+09:00');
const charts = [
{ songId: 'm0359', difficulty: 'expert', releaseTime },
{ songId: 'm0359', difficulty: 'hard', releaseTime }
];
assert.deepEqual(releasedSongCharts(charts, releaseTime - 1), []);
assert.deepEqual(releasedSongCharts(charts, releaseTime), charts);
});
+15
View File
@@ -5,6 +5,8 @@ export type CooldownReduction = (typeof COOLDOWN_REDUCTIONS)[number];
export type OptimizerCard = { export type OptimizerCard = {
id: string; id: string;
name: string; name: string;
/** 보드·패시브·의상·메모리를 제외한 현재 레벨/개화의 파라미터 합계. */
naturalParameterTotal?: number;
type?: string; type?: string;
generation?: string; generation?: string;
active: { active: {
@@ -12,6 +14,8 @@ export type OptimizerCard = {
durationSeconds: number; durationSeconds: number;
scoreUpPercent: number; scoreUpPercent: number;
probabilityPercent?: number; probabilityPercent?: number;
/** 블루 보드처럼 액티브 기본 발동률에 더해지는 상시 발동률 UP 배율. */
activationRateUpPercent?: number;
}; };
special: { special: {
durationSeconds: number; durationSeconds: number;
@@ -88,6 +92,7 @@ export type ExactSongChart = {
difficulty: string; difficulty: string;
durationSeconds: number; durationSeconds: number;
scoreRatio: number | null; scoreRatio: number | null;
scoreCalibrationSource?: 'measured' | 'local-estimated-v1' | string;
notes: readonly ExactChartNote[]; notes: readonly ExactChartNote[];
specialStartSeconds: readonly (number | null)[]; specialStartSeconds: readonly (number | null)[];
singerType?: string | null; singerType?: string | null;
@@ -116,7 +121,16 @@ export type ExactScoreOptions = {
directSongScoreBonusPercent?: number; directSongScoreBonusPercent?: number;
}; };
export type ScoreAchievementProbabilities = {
maximum: number;
atLeast99Percent: number;
atLeast95Percent: number;
samples: number;
};
export type ExactOptimizationResult = ExactScoreResult & { export type ExactOptimizationResult = ExactScoreResult & {
/** 분석한 5인 편성. 기존 저장 데이터 호환을 위해 필드명을 유지한다. */
formationOrder: readonly string[];
order: readonly string[]; order: readonly string[];
cooldownReductions: Readonly<Record<string, CooldownReduction>>; cooldownReductions: Readonly<Record<string, CooldownReduction>>;
activeWindows: readonly SkillWindow[]; activeWindows: readonly SkillWindow[];
@@ -126,5 +140,6 @@ export type ExactOptimizationResult = ExactScoreResult & {
}; };
export type ExactOptimizeOptions = ExactScoreOptions & { export type ExactOptimizeOptions = ExactScoreOptions & {
objective?: 'maximum' | 'expected';
onProgress?: (progress: OptimizationProgress) => void; onProgress?: (progress: OptimizationProgress) => void;
}; };
+27
View File
@@ -91,3 +91,30 @@ test('조건부 기수 패시브는 복수 소속 멤버도 대상으로 삼는
assert.equal(result.passiveTotal, 20); assert.equal(result.passiveTotal, 20);
}); });
test('지정 인원 능력치 패시브는 해당 능력치가 아닌 전체 파라미터 합계 상위 멤버에게 적용한다', () => {
const source = {
...card('source', '퓨어 타입 2명의 퍼포먼스가 10% UP'),
type: '해피'
};
const targets = [
{ id: 'performance-high', performance: 200, technique: 0, sense: 0 },
{ id: 'total-high', performance: 100, technique: 200, sense: 0 },
{ id: 'total-second', performance: 150, technique: 100, sense: 0 }
].map(({ id, performance, technique, sense }) => ({
card: {
...card(id, ''),
type: '퓨어',
power: { maxLevel: 80, statsAtMaxLevel: { performance, technique, sense } }
},
awakening: 0
}));
const result = calculateUnitPower([
{ card: source, awakening: 0 },
...targets,
{ card: card('other', ''), awakening: 0 }
]);
assert.equal(result.passiveTotal, 25);
});
+5 -1
View File
@@ -89,7 +89,11 @@ function targetIndexes(
return members return members
.map((member, index) => ({ member, index })) .map((member, index) => ({ member, index }))
.filter(({ member }) => skillTargetBelongsTo(member.card, target[1])) .filter(({ member }) => skillTargetBelongsTo(member.card, target[1]))
.sort((left, right) => parameters[right.index][stat] - parameters[left.index][stat] || left.index - right.index) .sort((left, right) => {
const rightTotal = stats.reduce((sum, key) => sum + parameters[right.index][key], 0);
const leftTotal = stats.reduce((sum, key) => sum + parameters[left.index][key], 0);
return rightTotal - leftTotal || left.member.card.id.localeCompare(right.member.card.id);
})
.slice(0, count) .slice(0, count)
.map(({ index }) => index); .map(({ index }) => index);
} }
+82
View File
@@ -0,0 +1,82 @@
import type { CardListSortKey, SortDirection } from './user-data.ts';
export type CardType = '큐트' | '퓨어' | '해피';
export type HighestStat = '퍼포먼스' | '테크닉' | '센스';
export type SkillLevel = {
level: number;
description: string;
coolTimeSeconds: number | null;
probabilityPercent: number | null;
durationSeconds: number | null;
effect?: string;
condition?: string;
};
export type CardDetails = {
title: string;
passive: SkillLevel[];
active?: SkillLevel[];
special: SkillLevel[];
costume: { name: string; description: string } | null;
};
export type CardSummary = {
id: string;
rarity: number;
type: CardType;
name: string;
versionName: string;
generation: string;
highestStat: HighestStat;
details: CardDetails;
};
export function skillAtLevel(
levels: readonly SkillLevel[] | undefined,
selectedLevel: number
): SkillLevel | undefined {
return levels?.find((level) => level.level === selectedLevel) ?? levels?.at(-1);
}
export function activeEffectPercent(skill: SkillLevel | undefined): number | null {
const match = (skill?.effect || skill?.description)?.match(/스코어(?:가|\s+서포트\s+효과)\s*(\d+(?:\.\d+)?)%/);
return match ? Number(match[1]) : null;
}
export function activeSortValue(
card: CardSummary,
sortKey: CardListSortKey,
selectedLevel: number
): number | null {
const active = skillAtLevel(card.details.active, selectedLevel);
if (!active) return null;
const value = {
coolTime: active.coolTimeSeconds,
probability: active.probabilityPercent,
duration: active.durationSeconds,
effect: activeEffectPercent(active)
}[sortKey];
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
export function sortCardsByActive(
cards: readonly CardSummary[],
sortKey: CardListSortKey | null,
sortDirection: SortDirection,
selectedLevel: number
): CardSummary[] {
if (!sortKey) return [...cards];
const direction = sortDirection === 'asc' ? 1 : -1;
return cards
.map((card, index) => ({ card, index, value: activeSortValue(card, sortKey, selectedLevel) }))
.sort((left, right) => {
if (left.value === null) return right.value === null ? left.index - right.index : 1;
if (right.value === null) return -1;
return (left.value - right.value) * direction || left.index - right.index;
})
.map(({ card }) => card);
}
+7
View File
@@ -12,6 +12,10 @@ export type SavedDeck = {
updatedAt: string; updatedAt: string;
}; };
export type CardListSortKey = 'coolTime' | 'probability' | 'duration' | 'effect';
export type SortDirection = 'asc' | 'desc';
export type CardSkillLevel = 1 | 2;
export type CardListPreferences = { export type CardListPreferences = {
types: string[]; types: string[];
stats: string[]; stats: string[];
@@ -20,6 +24,9 @@ export type CardListPreferences = {
activeProbabilities: string[]; activeProbabilities: string[];
cardVariants: string[]; cardVariants: string[];
generations: string[]; generations: string[];
sortKey: CardListSortKey | null;
sortDirection: SortDirection;
skillLevel: CardSkillLevel;
}; };
export type BoardCardPlacements = Record<string, Record<string, string>>; export type BoardCardPlacements = Record<string, Record<string, string>>;
+48
View File
@@ -0,0 +1,48 @@
import { marked, Renderer } from 'marked';
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function safeUrl(value: string): string | null {
const trimmed = value.trim();
if (trimmed.startsWith('#') || trimmed.startsWith('/')) return trimmed;
try {
const url = new URL(trimmed);
return ['http:', 'https:'].includes(url.protocol) ? url.href : null;
} catch {
return null;
}
}
const renderer = new Renderer();
renderer.html = ({ text }) => escapeHtml(text);
renderer.link = function ({ href, title, tokens }) {
const label = this.parser.parseInline(tokens);
const url = safeUrl(href);
if (!url) return label;
const titleAttribute = title ? ` title="${escapeHtml(title)}"` : '';
return `<a href="${escapeHtml(url)}"${titleAttribute} target="_blank" rel="noreferrer">${label}</a>`;
};
renderer.image = ({ href, title, text }) => {
const url = safeUrl(href);
if (!url) return escapeHtml(text);
const titleAttribute = title ? ` title="${escapeHtml(title)}"` : '';
return `<img src="${escapeHtml(url)}" alt="${escapeHtml(text)}"${titleAttribute} loading="lazy">`;
};
export function renderReleaseMarkdown(markdown: string): string {
return marked.parse(markdown, {
async: false,
breaks: true,
gfm: true,
renderer
}) as string;
}
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderReleaseMarkdown } from './markdown.ts';
import { ReleaseNoteService, type ReleaseNoteConfig } from './release-note-service.ts';
import { versionTagIsNewer, versionTagMatches } from './version.ts';
const config: ReleaseNoteConfig = {
owner: 'owner',
repo: 'repo',
includePrereleases: false,
perPage: 30,
timeoutMs: 100
};
function response(payload: unknown, ok = true): Response {
return { ok, json: async () => payload } as Response;
}
test('정식 릴리즈만 최신순으로 변환하고 앱 실행 중 결과를 재사용한다', async () => {
let requests = 0;
const service = new ReleaseNoteService(config, async () => {
requests += 1;
return response([
{ id: 1, tag_name: 'v1.0.0', name: null, published_at: '2026-01-01T00:00:00Z', body: null, html_url: 'https://example.com/1', draft: false, prerelease: false },
{ id: 3, tag_name: 'v1.2.0-beta', name: 'beta', published_at: '2026-03-01T00:00:00Z', body: '', html_url: 'https://example.com/3', draft: false, prerelease: true },
{ id: 2, tag_name: 'v1.1.0', name: 'one one', published_at: '2026-02-01T00:00:00Z', body: '# 변경', html_url: 'https://example.com/2', draft: false, prerelease: false },
{ id: 4, tag_name: 'v9.0.0', name: 'draft', published_at: '2026-04-01T00:00:00Z', body: '', html_url: 'https://example.com/4', draft: true, prerelease: false }
]);
});
const first = await service.load({ online: true });
const second = await service.load({ online: true });
assert.equal(requests, 1);
assert.deepEqual(first, second);
assert.deepEqual(first?.map((release) => release.tagName), ['v1.1.0', 'v1.0.0']);
assert.equal(first?.[1].name, 'v1.0.0');
assert.equal(first?.[1].body, '');
});
test('오프라인 및 API 실패는 예외 없이 unavailable 값으로 캐시한다', async () => {
let requests = 0;
const service = new ReleaseNoteService(config, async () => {
requests += 1;
return response({}, false);
});
assert.equal(await service.load({ online: false }), null);
assert.equal(await service.load({ online: true }), null);
assert.equal(requests, 0);
});
test('현재 버전 표시는 v 접두사와 빌드 메타데이터를 무시한다', () => {
assert.equal(versionTagMatches('v1.0.4', '1.0.4'), true);
assert.equal(versionTagMatches('1.0.4+offline', 'v1.0.4'), true);
assert.equal(versionTagMatches('v1.0.3', '1.0.4'), false);
assert.equal(versionTagIsNewer('v1.1.0', '1.0.4'), true);
assert.equal(versionTagIsNewer('v1.0.4', '1.0.4'), false);
assert.equal(versionTagIsNewer('v1.0.3', '1.0.4'), false);
});
test('릴리즈 Markdown의 원문 HTML과 위험한 링크를 실행 가능한 태그로 만들지 않는다', () => {
const rendered = renderReleaseMarkdown('<script>alert(1)</script> [위험](javascript:alert(1))');
assert.equal(rendered.includes('<script>'), false);
assert.equal(rendered.includes('href="javascript:'), false);
assert.match(rendered, /&lt;script&gt;/);
});
@@ -0,0 +1,137 @@
export type ReleaseNote = {
id: number;
tagName: string;
name: string;
publishedAt: string;
body: string;
url: string;
};
export type ReleaseNoteConfig = {
owner: string;
repo: string;
includePrereleases: boolean;
perPage: number;
timeoutMs: number;
};
type GitHubRelease = {
id?: unknown;
tag_name?: unknown;
name?: unknown;
published_at?: unknown;
body?: unknown;
html_url?: unknown;
draft?: unknown;
prerelease?: unknown;
};
type LoadOptions = {
online?: boolean;
};
export const RELEASE_NOTE_CONFIG: ReleaseNoteConfig = {
owner: 'DD4SN',
repo: 'HolodoriCalc',
// 정식 릴리즈만 노출한다. 베타 릴리즈도 보여주려면 true로 변경한다.
includePrereleases: false,
perPage: 30,
timeoutMs: 6_000
};
export const RELEASES_PAGE_URL = `https://github.com/${RELEASE_NOTE_CONFIG.owner}/${RELEASE_NOTE_CONFIG.repo}/releases`;
function browserIsOnline(): boolean {
return typeof navigator !== 'undefined'
&& navigator.onLine
&& typeof location !== 'undefined'
&& location.protocol !== 'file:';
}
function toReleaseNote(value: GitHubRelease): ReleaseNote | null {
if (typeof value.id !== 'number'
|| typeof value.tag_name !== 'string'
|| typeof value.published_at !== 'string'
|| typeof value.html_url !== 'string') return null;
return {
id: value.id,
tagName: value.tag_name,
name: typeof value.name === 'string' && value.name.trim() ? value.name : value.tag_name,
publishedAt: value.published_at,
body: typeof value.body === 'string' ? value.body : '',
url: value.html_url
};
}
export class ReleaseNoteService {
private readonly config: ReleaseNoteConfig;
private readonly fetcher: typeof fetch | undefined;
private cached: ReleaseNote[] | null | undefined;
private pending: Promise<ReleaseNote[] | null> | null = null;
constructor(
config: ReleaseNoteConfig = RELEASE_NOTE_CONFIG,
fetcher: typeof fetch | undefined = globalThis.fetch?.bind(globalThis)
) {
this.config = config;
this.fetcher = fetcher;
}
get releasesUrl(): string {
return `https://github.com/${this.config.owner}/${this.config.repo}/releases`;
}
async load(options: LoadOptions = {}): Promise<ReleaseNote[] | null> {
if (this.cached !== undefined) return this.cached;
if (this.pending) return this.pending;
const online = options.online ?? browserIsOnline();
if (!online || !this.fetcher) {
this.cached = null;
return null;
}
this.pending = this.request();
try {
this.cached = await this.pending;
return this.cached;
} finally {
this.pending = null;
}
}
private async request(): Promise<ReleaseNote[] | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
const apiUrl = `https://api.github.com/repos/${this.config.owner}/${this.config.repo}/releases?per_page=${this.config.perPage}`;
try {
const response = await this.fetcher!(apiUrl, {
headers: {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
},
signal: controller.signal
});
if (!response.ok) return null;
const payload: unknown = await response.json();
if (!Array.isArray(payload)) return null;
return payload
.filter((release): release is GitHubRelease => Boolean(release) && typeof release === 'object')
.filter((release) => release.draft !== true)
.filter((release) => this.config.includePrereleases || release.prerelease !== true)
.map(toReleaseNote)
.filter((release): release is ReleaseNote => release !== null)
.sort((left, right) => Date.parse(right.publishedAt) - Date.parse(left.publishedAt));
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
}
export const releaseNoteService = new ReleaseNoteService();
@@ -0,0 +1,34 @@
import { writable } from 'svelte/store';
import packageInfo from '../../../package.json';
import { releaseNoteService, type ReleaseNote } from './release-note-service';
import { versionTagMatches } from './version';
export type ReleaseNoteStatus = 'idle' | 'loading' | 'available' | 'unavailable';
export type ReleaseNoteState = {
status: ReleaseNoteStatus;
releases: ReleaseNote[];
};
const state = writable<ReleaseNoteState>({ status: 'idle', releases: [] });
let loadStarted = false;
export const currentAppVersion = packageInfo.version;
export const releaseNotes = {
subscribe: state.subscribe,
async load(): Promise<void> {
if (loadStarted) return;
loadStarted = true;
state.set({ status: 'loading', releases: [] });
const loaded = await releaseNoteService.load();
state.set(loaded && loaded.length > 0
? { status: 'available', releases: loaded }
: { status: 'unavailable', releases: [] });
}
};
export function isCurrentVersionTag(tagName: string, currentVersion = currentAppVersion): boolean {
return versionTagMatches(tagName, currentVersion);
}
+20
View File
@@ -0,0 +1,20 @@
export function normalizedVersion(version: string): string {
return version.trim().replace(/^v/i, '').split('+', 1)[0];
}
export function versionTagMatches(tagName: string, currentVersion: string): boolean {
return normalizedVersion(tagName) === normalizedVersion(currentVersion);
}
export function versionTagIsNewer(tagName: string, currentVersion: string): boolean {
const candidate = normalizedVersion(tagName).split('-', 1)[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
const current = normalizedVersion(currentVersion).split('-', 1)[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
const length = Math.max(candidate.length, current.length, 3);
for (let index = 0; index < length; index += 1) {
const difference = (candidate[index] ?? 0) - (current[index] ?? 0);
if (difference !== 0) return difference > 0;
}
return false;
}
+19 -2
View File
@@ -1,4 +1,4 @@
import type { CardListPreferences, CollectionCardState, UserData } from '$lib/models/user-data'; import type { CardListPreferences, CardListSortKey, CardSkillLevel, CollectionCardState, SortDirection, UserData } from '$lib/models/user-data';
import { createUserData } from '$lib/models/user-data'; import { createUserData } from '$lib/models/user-data';
export const USER_DATA_STORAGE_KEY = 'holodori:user-data:v1'; export const USER_DATA_STORAGE_KEY = 'holodori:user-data:v1';
@@ -17,6 +17,20 @@ function finiteNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback; return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
} }
function cardListSortKey(value: unknown, fallback: CardListSortKey | null): CardListSortKey | null {
return value === 'coolTime' || value === 'probability' || value === 'duration' || value === 'effect'
? value
: fallback;
}
function sortDirection(value: unknown, fallback: SortDirection): SortDirection {
return value === 'asc' || value === 'desc' ? value : fallback;
}
function cardSkillLevel(value: unknown, fallback: CardSkillLevel): CardSkillLevel {
return value === 1 || value === 2 ? value : fallback;
}
function parseCollection(value: Record<string, unknown>): UserData['collection'] { function parseCollection(value: Record<string, unknown>): UserData['collection'] {
return Object.fromEntries( return Object.fromEntries(
Object.entries(value).flatMap(([cardId, state]) => { Object.entries(value).flatMap(([cardId, state]) => {
@@ -79,7 +93,10 @@ export function parseUserData(text: string, fallbackPreferences: CardListPrefere
specialConditions: stringArray(preferences.specialConditions, fallbackPreferences.specialConditions), specialConditions: stringArray(preferences.specialConditions, fallbackPreferences.specialConditions),
activeProbabilities: stringArray(preferences.activeProbabilities, fallbackPreferences.activeProbabilities), activeProbabilities: stringArray(preferences.activeProbabilities, fallbackPreferences.activeProbabilities),
cardVariants: stringArray(preferences.cardVariants, fallbackPreferences.cardVariants), cardVariants: stringArray(preferences.cardVariants, fallbackPreferences.cardVariants),
generations: stringArray(preferences.generations, fallbackPreferences.generations) generations: stringArray(preferences.generations, fallbackPreferences.generations),
sortKey: cardListSortKey(preferences.sortKey, fallbackPreferences.sortKey),
sortDirection: sortDirection(preferences.sortDirection, fallbackPreferences.sortDirection),
skillLevel: cardSkillLevel(preferences.skillLevel, fallbackPreferences.skillLevel)
} }
}; };
} }
+2 -2
View File
@@ -1,8 +1,8 @@
:root { :root {
--page-max-width: 1520px; --page-max-width: 1520px;
--page-gutter: clamp(40px, 5vw, 96px); --page-gutter: clamp(40px, 5vw, 96px);
--page-title-size: 72px; --page-title-size: 40px;
--page-content-top: 48px; --page-content-top: 24px;
--page-header-bottom: 28px; --page-header-bottom: 28px;
--page-description-size: 16px; --page-description-size: 16px;
} }
+96 -13
View File
@@ -1,7 +1,7 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import { countOptimizationCandidates, optimizeExactOrderAndCooldowns } from '../engine/index.ts'; import { countOptimizationCandidates, estimateScoreAchievementProbabilities, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts'; import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from '../engine/index.ts';
type OptimizeRequest = { type OptimizeRequest = {
type: 'optimize-exact'; type: 'optimize-exact';
@@ -9,6 +9,7 @@ type OptimizeRequest = {
chart: ExactSongChart; chart: ExactSongChart;
perfectNoteScore: number; perfectNoteScore: number;
directSongScoreBonusPercent?: number; directSongScoreBonusPercent?: number;
includeAchievementProbabilities?: boolean;
}; };
type CombinationCandidate = { type CombinationCandidate = {
@@ -16,6 +17,9 @@ type CombinationCandidate = {
overallPower: number; overallPower: number;
perfectNoteScore: number; perfectNoteScore: number;
directSongScoreBonusPercent?: number; directSongScoreBonusPercent?: number;
eventBonusPercent?: number;
rankingMultiplier?: number;
optimizationObjective?: 'maximum' | 'expected';
}; };
type CombinationRequest = { type CombinationRequest = {
@@ -24,7 +28,46 @@ type CombinationRequest = {
chart: ExactSongChart; chart: ExactSongChart;
}; };
self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) => { type RankedCombination = {
result: ExactOptimizationResult;
overallPower: number;
cards?: OptimizerCard[];
directSongScoreBonusPercent?: number;
eventBonusPercent?: number;
rankingMultiplier?: number;
achievementProbabilities?: ScoreAchievementProbabilities;
};
function rankingScore(ranking: RankedCombination): number {
const score = ranking.rankingMultiplier === undefined
? ranking.result.theoreticalMaxScore
: ranking.result.expectedScore;
return score * (ranking.rankingMultiplier ?? 1);
}
function compareRankings(left: RankedCombination, right: RankedCombination): number {
return rankingScore(right) - rankingScore(left) ||
right.result.expectedScore - left.result.expectedScore ||
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore;
}
type AchievementRequest = {
type: 'estimate-achievement';
rankings: RankedCombination[];
chart: ExactSongChart;
sampleCount?: number;
};
function cardsForResult(cards: readonly OptimizerCard[], result: ExactOptimizationResult): OptimizerCard[] {
const cardById = new Map(cards.map((card) => [card.id, card]));
return result.order.map((cardId) => {
const card = cardById.get(cardId);
if (!card) throw new Error('확률 계산 대상의 스페셜 순서를 복원하지 못했습니다.');
return card;
});
}
self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | AchievementRequest>) => {
try { try {
if (event.data.type === 'optimize-exact') { if (event.data.type === 'optimize-exact') {
@@ -34,33 +77,73 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) =>
onProgress: (progress) => self.postMessage({ type: 'progress', progress }) onProgress: (progress) => self.postMessage({ type: 'progress', progress })
}; };
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options); const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
self.postMessage({ type: 'result', result }); let achievementProbabilities: ScoreAchievementProbabilities | undefined;
if (event.data.includeAchievementProbabilities) {
const orderedCards = cardsForResult(event.data.cards, result);
achievementProbabilities = estimateScoreAchievementProbabilities(
orderedCards,
result.cooldownReductions,
event.data.chart,
{ ...options, sampleCount: 25_000 }
);
}
self.postMessage({ type: 'result', result, achievementProbabilities });
return;
}
if (event.data.type === 'estimate-achievement') {
const request = event.data;
const rankings = request.rankings.map((ranking, rankingIndex) => {
if (!ranking.cards) throw new Error('확률 계산에 필요한 카드 정보가 없습니다.');
const orderedCards = cardsForResult(ranking.cards, ranking.result);
const achievementProbabilities = estimateScoreAchievementProbabilities(
orderedCards,
ranking.result.cooldownReductions,
event.data.chart,
{
perfectNoteScore: ranking.result.perfectNoteScore,
directSongScoreBonusPercent: ranking.directSongScoreBonusPercent,
sampleCount: request.sampleCount
}
);
self.postMessage({
type: 'achievement-progress',
progress: { evaluatedCandidates: rankingIndex + 1, totalCandidates: request.rankings.length }
});
return { ...ranking, achievementProbabilities };
});
self.postMessage({ type: 'achievement-result', rankings });
return; return;
} }
const totalCandidates = event.data.candidates.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0); const totalCandidates = event.data.candidates.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0);
let completedCandidates = 0; let completedCandidates = 0;
let best: { result: ExactOptimizationResult; overallPower: number } | null = null; let best: RankedCombination | null = null;
const rankings: { result: ExactOptimizationResult; overallPower: number }[] = []; const rankings: RankedCombination[] = [];
for (const candidate of event.data.candidates) { for (const candidate of event.data.candidates) {
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, { const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
perfectNoteScore: candidate.perfectNoteScore, perfectNoteScore: candidate.perfectNoteScore,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent, directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
objective: candidate.optimizationObjective,
onProgress: (progress) => self.postMessage({ onProgress: (progress) => self.postMessage({
type: 'progress', type: 'progress',
progress: { evaluatedCandidates: completedCandidates + progress.evaluatedCandidates, totalCandidates } progress: { evaluatedCandidates: completedCandidates + progress.evaluatedCandidates, totalCandidates }
}) })
}); });
completedCandidates += countOptimizationCandidates(candidate.cards); completedCandidates += countOptimizationCandidates(candidate.cards);
if (!best || result.theoreticalMaxScore > best.result.theoreticalMaxScore || const ranking: RankedCombination = {
(result.theoreticalMaxScore === best.result.theoreticalMaxScore && result.expectedScore > best.result.expectedScore)) { result,
best = { result, overallPower: candidate.overallPower }; overallPower: candidate.overallPower,
} cards: candidate.cards,
rankings.push({ result, overallPower: candidate.overallPower }); directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
eventBonusPercent: candidate.eventBonusPercent,
rankingMultiplier: candidate.rankingMultiplier
};
if (!best || compareRankings(ranking, best) < 0) best = ranking;
rankings.push(ranking);
} }
if (!best) throw new Error('정밀 분석할 조합이 없습니다.'); if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
rankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore || rankings.sort(compareRankings);
right.result.expectedScore - left.result.expectedScore);
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, 10) }); self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, 10) });
} catch (error) { } catch (error) {
self.postMessage({ self.postMessage({
+5
View File
@@ -0,0 +1,5 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = () => ({
serverNow: Date.now()
});
+42 -3
View File
@@ -1,6 +1,28 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { events } from '$lib/data/events.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { activeEventAt } from '$lib/engine/event-optimization.ts';
import '$lib/styles/layout-tokens.css'; import '$lib/styles/layout-tokens.css';
let eventClock = createInitialReleaseClock($page.data?.serverNow);
let eventNow = eventClock.now();
$: showEventTab = Boolean(activeEventAt(events, eventNow));
onMount(() => {
let mounted = true;
void synchronizeReleaseClock().then((clock) => {
if (!mounted) return;
eventClock = clock;
eventNow = eventClock.now();
});
const timer = window.setInterval(() => eventNow = eventClock.now(), 1_000);
return () => {
mounted = false;
window.clearInterval(timer);
};
});
</script> </script>
<header class="site-nav"> <header class="site-nav">
@@ -10,7 +32,15 @@
<a href="/cards" aria-current={$page.url.pathname.startsWith('/cards') ? 'page' : undefined}>카드 목록</a> <a href="/cards" aria-current={$page.url.pathname.startsWith('/cards') ? 'page' : undefined}>카드 목록</a>
<a href="/analysis" aria-current={$page.url.pathname.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a> <a href="/analysis" aria-current={$page.url.pathname.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
<a href="/combination" aria-current={$page.url.pathname.startsWith('/combination') ? '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}
<a href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a> <a href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
<a
class="report-link"
href="https://docs.google.com/forms/d/1bShzsxNN-iTk0uU3RfMfmLkYXYmUTmIWMmCtQmfl_Ik"
target="_blank"
rel="noreferrer"
aria-label="실측값 제보 Google 폼을 새 탭에서 열기"
>실측값 제보 <span aria-hidden="true">↗</span></a>
</nav> </nav>
</div> </div>
</header> </header>
@@ -34,6 +64,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px;
width: min(var(--page-max-width), calc(100% - var(--page-gutter))); width: min(var(--page-max-width), calc(100% - var(--page-gutter)));
min-height: 62px; min-height: 62px;
margin: 0 auto; margin: 0 auto;
@@ -47,25 +78,33 @@
text-decoration: none; text-decoration: none;
} }
nav { display: flex; align-items: center; gap: 4px; } nav { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: flex-end; gap: 4px; overflow-x: auto; scrollbar-width: none; }
nav::-webkit-scrollbar { display: none; }
nav a { nav a {
flex: 0 0 auto;
padding: 8px 12px; padding: 8px 12px;
border-radius: 9px; border-radius: 9px;
color: #929cad; color: #929cad;
font-size: 13px; font-size: 13px;
font-weight: 750; font-weight: 750;
text-decoration: none; text-decoration: none;
white-space: nowrap;
} }
nav a:hover { color: #e2e6ed; background: rgb(255 255 255 / 5%); } nav a:hover { color: #e2e6ed; background: rgb(255 255 255 / 5%); }
nav a[aria-current='page'] { color: #f1a1c3; background: rgb(239 139 182 / 12%); } nav a[aria-current='page'] { color: #f1a1c3; background: rgb(239 139 182 / 12%); }
.report-link { flex: 0 0 auto; padding: 8px 12px; border: 1px solid #ef8bb6; border-radius: 9px; color: #21121a; background: linear-gradient(135deg, #f3a3c5, #e77ead); box-shadow: 0 0 18px rgb(239 139 182 / 18%); font-size: 13px; font-weight: 800; text-decoration: none; white-space: nowrap; }
.report-link:hover { color: #160d12; background: linear-gradient(135deg, #f8bad3, #ee94ba); box-shadow: 0 0 22px rgb(239 139 182 / 30%); }
.report-link span { margin-left: 2px; font-size: 11px; }
a:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 2px; } a:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 2px; }
@media (max-width: 560px) { @media (max-width: 560px) {
:global(:root) { --page-gutter: 24px; } :global(:root) { --page-gutter: 24px; }
.nav-inner { width: calc(100% - 24px); min-height: 54px; } .nav-inner { width: calc(100% - 24px); min-height: 54px; }
.brand { font-size: 15px; } .brand { font-size: 13px; }
nav a { padding: 7px 9px; font-size: 14px; } nav { gap: 2px; }
nav a { padding: 7px 7px; font-size: 12px; }
.report-link { padding: 7px 8px; font-size: 12px; }
} }
</style> </style>
+6
View File
@@ -1,9 +1,15 @@
<script lang="ts">
import UpdateNotice from '$lib/components/UpdateNotice.svelte';
</script>
<svelte:head> <svelte:head>
<title>홀로도리 계산기</title> <title>홀로도리 계산기</title>
<meta name="description" content="홀로라이브 드림즈의 편성, 스킬 순서와 멤버 보드를 분석하는 계산기" /> <meta name="description" content="홀로라이브 드림즈의 편성, 스킬 순서와 멤버 보드를 분석하는 계산기" />
</svelte:head> </svelte:head>
<main> <main>
<UpdateNotice />
<aside class="start-guide" aria-label="시작 안내"> <aside class="start-guide" aria-label="시작 안내">
<p><span>처음 사용한다면</span><strong>사용자 데이터를 먼저 설정하세요.</strong> 보유 카드와 개화 단계, 멤버별 보드를 실제 분석에 반영할 수 있습니다.</p> <p><span>처음 사용한다면</span><strong>사용자 데이터를 먼저 설정하세요.</strong> 보유 카드와 개화 단계, 멤버별 보드를 실제 분석에 반영할 수 있습니다.</p>
<a href="/data">설정하기 <span aria-hidden="true">→</span></a> <a href="/data">설정하기 <span aria-hidden="true">→</span></a>
+528 -63
View File
@@ -1,12 +1,18 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores';
import cardSummary from '$lib/data/cards-summary.json'; import cardSummary from '$lib/data/cards-summary.json';
import boardSummary from '$lib/data/boards-summary.json'; import boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json'; import memorySummary from '$lib/data/memory-summary.json';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json'; import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { loadExactSongChart } from '$lib/data/chart-loader.ts'; import { loadExactSongChart } from '$lib/data/chart-loader.ts';
import { calculateBoardEffects, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts'; import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, SkillWindow } from '$lib/engine/index.ts'; import { releasedSongCharts } from '$lib/data/song-release.ts';
import PresetManager from '$lib/components/PresetManager.svelte';
import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte';
import SongPicker from '$lib/components/SongPicker.svelte';
import { calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage'; import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts'; import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
@@ -15,6 +21,16 @@
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>; type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
type MemberBoards = Record<string, string[]>; type MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, Record<string, string>>; type BoardCards = Record<string, Record<string, string>>;
type ConnectionOptimizationProposal = {
placements: BoardCards;
assignments: { memberId: string; socketId: string; previousCardId: string | null; cardId: string | null }[];
beforeExpected: number;
afterExpected: number;
beforeMaximum: number;
afterMaximum: number;
beforeActivationRateUp: number;
afterActivationRateUp: number;
};
type DisplayEffectiveWindow = SkillWindow & { type DisplayEffectiveWindow = SkillWindow & {
baseScoreUpPercent: number; baseScoreUpPercent: number;
minScoreUpPercent: number; minScoreUpPercent: number;
@@ -28,19 +44,35 @@
cardIds: string[]; cardIds: string[];
awakenings: number[]; awakenings: number[];
fixedCooldownReductions?: (CooldownReduction | null)[]; fixedCooldownReductions?: (CooldownReduction | null)[];
allBlueNodes?: boolean[];
generationBonusOverrides?: (number | null)[];
songScoreBonusOverride?: number | null;
leaderCharacterId: string; leaderCharacterId: string;
leaderOutfitCardId: string; leaderOutfitCardId: string;
}; };
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2'; const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const ANALYSIS_PRESET_STORAGE_KEY = 'holodori.analysis.presets.v1';
const LEGACY_ANALYSIS_STORAGE_KEY = 'holodori.analysis.v1'; const LEGACY_ANALYSIS_STORAGE_KEY = 'holodori.analysis.v1';
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const; const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const; const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5); const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
const charts = exactChartCatalog.charts; const allCharts = exactChartCatalog.charts;
const songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values()); const initialReleaseClock = createInitialReleaseClock($page.data?.serverNow);
let currentReleaseClock = initialReleaseClock;
let releaseNow = currentReleaseClock.now();
let charts = releasedSongCharts(allCharts, releaseNow);
let songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
const cardById = new Map(fiveStarCards.map((card) => [card.id, card])); const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
const allCardById = new Map(cardSummary.cards.map((card) => [card.id, card]));
const boardMembers = boardSummary.members;
const blueGroupsByCharacter = Object.fromEntries(boardSummary.members.map((member) => [
member.id,
(boardSummary.models as Record<string, { groupId: string }[]>)[member.positionGroupId]
?.filter((position) => position.groupId.startsWith('B'))
.map((position) => position.groupId) ?? []
]));
function uniqueMemberCards(cards: readonly CardRow[], limit = Number.POSITIVE_INFINITY): CardRow[] { function uniqueMemberCards(cards: readonly CardRow[], limit = Number.POSITIVE_INFINITY): CardRow[] {
const memberIds = new Set<string>(); const memberIds = new Set<string>();
@@ -63,15 +95,27 @@
let selectedCardIds = uniqueMemberCards(fiveStarCards, 5).map((card) => card.id); let selectedCardIds = uniqueMemberCards(fiveStarCards, 5).map((card) => card.id);
let selectedAwakenings = selectedCardIds.map(() => 5); let selectedAwakenings = selectedCardIds.map(() => 5);
let fixedCooldownReductions: (CooldownReduction | null)[] = selectedCardIds.map(() => null); let fixedCooldownReductions: (CooldownReduction | null)[] = selectedCardIds.map(() => null);
let allBlueNodes = selectedCardIds.map(() => false);
let generationBonusOverrides: (number | null)[] = selectedCardIds.map(() => null);
let songScoreBonusOverride: number | null = null;
let leaderCharacterId = leaderMembers[0]?.characterId ?? ''; let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? ''; let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
let worker: Worker | null = null; let worker: Worker | null = null;
let running = false; let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 }; let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null; let result: ExactOptimizationResult | null = null;
let achievementProbabilities: ScoreAchievementProbabilities | undefined;
let errorMessage = ''; let errorMessage = '';
let analysisReady = false; let analysisReady = false;
let connectionOptimizationRunning = false;
let connectionOptimizationProgress: { stage: string; completed: number; total: number } | null = null;
let connectionOptimizationError = '';
let connectionOptimizationProposal: ConnectionOptimizationProposal | null = null;
let connectionOptimizationSnapshot: BoardCards | null = null;
$: charts = releasedSongCharts(allCharts, releaseNow);
$: 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 ?? '';
$: selectedSong = songs.find((song) => song.id === selectedSongId) ?? songs[0]; $: selectedSong = songs.find((song) => song.id === selectedSongId) ?? songs[0];
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty); $: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId); $: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
@@ -92,9 +136,16 @@
awakening: collection[card.id]?.awakening ?? 0, awakening: collection[card.id]?.awakening ?? 0,
connect: card.details.connect connect: card.details.connect
})); }));
$: boardEffects = calculateBoardEffects(boardSummary, memberBoards, selectedSourceCards, leaderCharacterId, selectedChart, { $: effectiveMemberBoards = boardsForSelectedCards(memberBoards, selectedCardIds, allBlueNodes);
$: generationBonusByCardId = Object.fromEntries(selectedCardIds
.map((cardId, index) => [cardId, generationBonusOverrides[index]] as const)
.filter((entry): entry is readonly [string, number] => entry[1] !== null));
$: boardEffects = calculateBoardEffects(boardSummary, effectiveMemberBoards, selectedSourceCards, leaderCharacterId, selectedChart, {
placements: boardCards, placements: boardCards,
cards: connectionCards cards: connectionCards
}, {
generationBonusByCardId,
...(songScoreBonusOverride === null ? {} : { directSongScoreBonusPercent: songScoreBonusOverride })
}); });
$: memoryPermil = memoryParameterPermil(memoryCount, memorySummary.effects); $: memoryPermil = memoryParameterPermil(memoryCount, memorySummary.effects);
$: unitPower = (() => { $: unitPower = (() => {
@@ -109,10 +160,9 @@
})(); })();
$: overallPower = unitPower.overallPower; $: overallPower = unitPower.overallPower;
$: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null); $: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null);
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: timelineDuration = selectedChart?.durationSeconds ?? 0; $: timelineDuration = selectedChart?.durationSeconds ?? 0;
$: noteCountsBySecond = result ? countNotesBySecond(result, timelineDuration) : [];
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []); $: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []);
$: maxNotesPerSecond = Math.max(1, ...noteCountsBySecond);
$: timelineTicks = buildTimelineTicks(timelineDuration); $: timelineTicks = buildTimelineTicks(timelineDuration);
$: timelineWidth = Math.max(720, timelineDuration * 6); $: timelineWidth = Math.max(720, timelineDuration * 6);
$: candidateCount = (() => { $: candidateCount = (() => {
@@ -126,6 +176,19 @@
return 0; return 0;
} }
})(); })();
$: presetSnapshot = {
schemaVersion: 2,
songId: selectedSongId,
difficulty,
cardIds: selectedCardIds,
awakenings: selectedAwakenings,
fixedCooldownReductions,
allBlueNodes,
generationBonusOverrides,
songScoreBonusOverride,
leaderCharacterId,
leaderOutfitCardId
} satisfies SavedAnalysisState;
$: if (analysisReady) saveAnalysisState({ $: if (analysisReady) saveAnalysisState({
schemaVersion: 2, schemaVersion: 2,
songId: selectedSongId, songId: selectedSongId,
@@ -133,11 +196,23 @@
cardIds: selectedCardIds, cardIds: selectedCardIds,
awakenings: selectedAwakenings, awakenings: selectedAwakenings,
fixedCooldownReductions, fixedCooldownReductions,
allBlueNodes,
generationBonusOverrides,
songScoreBonusOverride,
leaderCharacterId, leaderCharacterId,
leaderOutfitCardId leaderOutfitCardId
}); });
onMount(() => { onMount(() => {
let mounted = true;
void synchronizeReleaseClock().then((clock) => {
if (!mounted) return;
currentReleaseClock = clock;
releaseNow = currentReleaseClock.now();
});
const releaseClock = window.setInterval(() => {
releaseNow = currentReleaseClock.now();
}, 1_000);
const applyStoredUserData = (stored: string | null) => { const applyStoredUserData = (stored: string | null) => {
const parsed = stored ? JSON.parse(stored) : null; const parsed = stored ? JSON.parse(stored) : null;
collection = parsed?.collection && typeof parsed.collection === 'object' ? parsed.collection : {}; collection = parsed?.collection && typeof parsed.collection === 'object' ? parsed.collection : {};
@@ -182,6 +257,7 @@
if (event.data.type === 'progress') progress = event.data.progress; if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'result') { if (event.data.type === 'result') {
result = event.data.result; result = event.data.result;
achievementProbabilities = event.data.achievementProbabilities;
running = false; running = false;
} }
if (event.data.type === 'error') { if (event.data.type === 'error') {
@@ -200,6 +276,8 @@
}; };
window.addEventListener('storage', syncStorage); window.addEventListener('storage', syncStorage);
return () => { return () => {
mounted = false;
window.clearInterval(releaseClock);
window.removeEventListener('storage', syncStorage); window.removeEventListener('storage', syncStorage);
worker?.terminate(); worker?.terminate();
}; };
@@ -230,6 +308,7 @@
if (!legacy && state.schemaVersion !== 2) return; if (!legacy && state.schemaVersion !== 2) return;
if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId; if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
if (state.difficulty === 'hard' || state.difficulty === 'expert') difficulty = state.difficulty; if (state.difficulty === 'hard' || state.difficulty === 'expert') difficulty = state.difficulty;
songScoreBonusOverride = normalizeSongScoreBonusOverride(state.songScoreBonusOverride);
if (Array.isArray(state.cardIds) && state.cardIds.length === 5) { if (Array.isArray(state.cardIds) && state.cardIds.length === 5) {
const restoredCards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card)); const restoredCards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
@@ -240,6 +319,8 @@
return normalizeAwakening(state.awakenings?.[index]); return normalizeAwakening(state.awakenings?.[index]);
}); });
fixedCooldownReductions = restoredCards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index])); fixedCooldownReductions = restoredCards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index]));
allBlueNodes = restoredCards.map((_, index) => state.allBlueNodes?.[index] === true);
generationBonusOverrides = restoredCards.map((_, index) => normalizeGenerationBonusOverride(state.generationBonusOverrides?.[index]));
} }
} }
@@ -254,6 +335,17 @@
} }
} }
function applyPreset(event: CustomEvent<unknown>): void {
try {
restoreAnalysisState(JSON.stringify(event.detail));
result = null;
achievementProbabilities = undefined;
errorMessage = '';
} catch {
errorMessage = '이 프리셋의 분석 조건을 불러오지 못했습니다.';
}
}
function updateAwakening(slot: number, awakening: number): void { function updateAwakening(slot: number, awakening: number): void {
const normalized = normalizeAwakening(awakening); const normalized = normalizeAwakening(awakening);
selectedAwakenings = selectedAwakenings.map((value, index) => index === slot ? normalized : value); selectedAwakenings = selectedAwakenings.map((value, index) => index === slot ? normalized : value);
@@ -269,8 +361,60 @@
result = null; result = null;
} }
function memberSelectedInAnotherSlot(card: CardRow, slot: number): boolean { function normalizeGenerationBonusOverride(value: unknown): number | null {
return selectedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId); if (typeof value !== 'number' || !Number.isFinite(value)) return null;
return Math.max(0, Math.min(900, Math.round(value)));
}
function sanitizeGenerationBonusInput(input: HTMLInputElement, previousValue: number | null): string {
if (input.value === '') return '';
if (!/^\d+$/.test(input.value) || Number(input.value) > 900) {
input.value = previousValue === null ? '' : String(previousValue);
return input.value;
}
input.value = String(Number(input.value));
return input.value;
}
function toggleAllBlueNodes(slot: number): void {
allBlueNodes = allBlueNodes.map((enabled, index) => index === slot ? !enabled : enabled);
result = null;
}
function updateGenerationBonus(slot: number, rawValue: string): void {
const parsed = Number(rawValue);
const value = rawValue.trim() === '' || !Number.isFinite(parsed) ? null : normalizeGenerationBonusOverride(parsed);
generationBonusOverrides = generationBonusOverrides.map((bonus, index) => index === slot ? value : bonus);
result = null;
}
function normalizeSongScoreBonusOverride(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return null;
return Math.min(10, value);
}
function updateSongScoreBonusOverride(input: HTMLInputElement): void {
const parsed = Number(input.value);
if (input.value.trim() === '' || !Number.isFinite(parsed) || parsed < 0) input.value = '';
if (parsed > 10) input.value = '10';
songScoreBonusOverride = input.value === '' ? null : Math.min(10, parsed);
result = null;
}
function boardsForSelectedCards(
storedBoards: MemberBoards,
cardIds: readonly string[],
blueNodeOverrides: readonly boolean[]
): MemberBoards {
const boards = Object.fromEntries(Object.entries(storedBoards).map(([characterId, groupIds]) => [characterId, [...groupIds]]));
cardIds.forEach((cardId, index) => {
if (!blueNodeOverrides[index]) return;
const characterId = cardById.get(cardId)?.characterId;
if (!characterId) return;
const nonBlueGroups = (boards[characterId] ?? []).filter((groupId) => !groupId.startsWith('B'));
boards[characterId] = [...nonBlueGroups, ...(blueGroupsByCharacter[characterId] ?? [])];
});
return boards;
} }
function updateCard(slot: number, cardId: string): void { function updateCard(slot: number, cardId: string): void {
@@ -306,15 +450,217 @@
...adapted, ...adapted,
active: { active: {
...adapted.active, ...adapted.active,
probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) activationRateUpPercent: activationRate
}, },
scoreSupportPercent: unitPower.outfitScoreSupportPercent + boardEffects.scoreSupportPercent scoreSupportPercent: unitPower.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
}; };
} }
function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>) {
const localBoardEffects = calculateBoardEffects(boardSummary, effectiveMemberBoards, selectedSourceCards, leaderCharacterId, chartSummary, {
placements,
cards: connectionCards
}, {
generationBonusByCardId,
...(songScoreBonusOverride === null ? {} : { directSongScoreBonusPercent: songScoreBonusOverride })
});
const localUnitPower = calculateUnitPower(selectedCardIds.map((id, index) => ({
card: cardById.get(id)!, awakening: selectedAwakenings[index]
})), leaderOutfit, localBoardEffects, memoryPermil, memberPowerUpPercent);
const cards = selectedSourceCards.map((card, index) => {
const fixedReduction = fixedCooldownReductions[index];
const adapted = toOptimizerCard(
card,
selectedAwakenings[index] ?? 0,
fixedReduction === null || fixedReduction === undefined
? localBoardEffects.allowedCooldownReductionsByCardId[card.id]
: [fixedReduction],
selectedSourceCards
);
return {
...adapted,
active: { ...adapted.active, activationRateUpPercent: localBoardEffects.activationRateUpByCardId[card.id] ?? 0 },
scoreSupportPercent: localUnitPower.outfitScoreSupportPercent + localBoardEffects.scoreSupportPercent
};
});
return { cards, boardEffects: localBoardEffects, unitPower: localUnitPower };
}
function connectionSocketName(memberId: string, socketId: string): string {
const member = boardMembers.find((item) => item.id === memberId);
const socket = member
? (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId]?.find((position) => position.groupId === socketId)
: null;
if (!socket || (socket.x === 0 && socket.y === 0)) return '중앙';
if (socket.x < 0) return '왼쪽';
if (socket.x > 0) return '오른쪽';
return socket.y > 0 ? '위쪽' : '아래쪽';
}
function isBlueConnectionSocket(memberId: string, socketId: string): boolean {
const member = boardMembers.find((item) => item.id === memberId);
const socket = member
? (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId]?.find((position) => position.groupId === socketId)
: null;
return (socket?.x ?? 0) < 0;
}
function totalActivationRateUp(context: ReturnType<typeof connectionScoreContext>): number {
return Object.values(context.boardEffects.activationRateUpByCardId).reduce((total, value) => total + value, 0);
}
function saveBoardCardPlacements(placements: BoardCards): void {
const raw = localStorage.getItem(USER_DATA_STORAGE_KEY);
const stored = raw ? JSON.parse(raw) : {};
stored.boardCards = placements;
stored.updatedAt = new Date().toISOString();
localStorage.setItem(USER_DATA_STORAGE_KEY, JSON.stringify(stored));
boardCards = placements;
}
function clearConnectionOptimizationProposal(): void {
connectionOptimizationProposal = null;
connectionOptimizationError = '';
}
async function optimizeConnectionPlacements(): Promise<void> {
if (!result || !selectedChart || selectedSourceCards.length !== 5) return;
const analysisResult = result;
const chartSummary = selectedChart;
connectionOptimizationRunning = true;
connectionOptimizationProgress = { stage: '채보 준비 중', completed: 0, total: 0 };
connectionOptimizationError = '';
connectionOptimizationProposal = null;
try {
const chart = await loadExactSongChart(chartSummary.path);
const beforeContext = connectionScoreContext(boardCards, chartSummary);
const emptyContext = connectionScoreContext({}, chartSummary);
const orderedCards = (context: ReturnType<typeof connectionScoreContext>) => analysisResult.order.map((cardId) =>
context.cards.find((card) => card.id === cardId)!
);
const referenceScore = (context: ReturnType<typeof connectionScoreContext>) => calculateExactScore(
orderedCards(context), analysisResult.cooldownReductions, chart, {
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent
}
).expectedScore;
const emptyScore = referenceScore(emptyContext);
const emptyActivationRateUp = totalActivationRateUp(emptyContext);
const beforeActivationRateUp = totalActivationRateUp(beforeContext);
const candidateCards = cardSummary.cards
.filter((card) => card.details.connect.length > 0 && collection[card.id]?.owned)
.map((card) => ({ id: card.id, awakening: collection[card.id]?.awakening ?? 0, connect: card.details.connect }));
if (candidateCards.length === 0) throw new Error('최적화에 사용할 보유 연결 카드가 없습니다.');
const placementMetricCache = new Map<string, { score: number; activationRateUp: number }>();
const placementMetrics = (card: OptimizableConnectionCard, memberId: string, socketId: string) => {
const effect = card.connect.find((entry) => entry.level === (card.awakening >= 5 ? 2 : 1)) ?? card.connect.at(-1);
const effectKey = effect ? `${effect.effectPermilUp}:${effect.extent.map((offset) => `${offset.x},${offset.y}`).join(';')}` : card.id;
const key = `${memberId}:${socketId}:${effectKey}`;
const cached = placementMetricCache.get(key);
if (cached) return cached;
const context = connectionScoreContext({ [memberId]: { [socketId]: card.id } }, chartSummary);
const metrics = {
score: referenceScore(context) - emptyScore,
activationRateUp: totalActivationRateUp(context) - emptyActivationRateUp
};
placementMetricCache.set(key, metrics);
return metrics;
};
const activeConnectionSlots = boardMembers.flatMap((member) => {
const positions = (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId] ?? [];
const active = new Set(effectiveMemberBoards[member.id] ?? []);
const center = positions.find((position) => position.x === 0 && position.y === 0);
if (center) active.add(center.groupId);
return positions.filter((position) => active.has(position.groupId) && position.groupId.startsWith('S'))
.map((position) => ({ memberId: member.id, socketId: position.groupId }));
});
const totalPlacementChecks = candidateCards.length * activeConnectionSlots.length;
connectionOptimizationProgress = { stage: '카드별 배치 효과 계산 중', completed: 0, total: totalPlacementChecks };
let completedPlacementChecks = 0;
for (const card of candidateCards) {
for (const slot of activeConnectionSlots) {
placementMetrics(card, slot.memberId, slot.socketId);
completedPlacementChecks += 1;
if (completedPlacementChecks % 8 === 0 || completedPlacementChecks === totalPlacementChecks) {
connectionOptimizationProgress = { stage: '카드별 배치 효과 계산 중', completed: completedPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
}
}
connectionOptimizationProgress = { stage: '최적 조합 선택 중', completed: totalPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, {
placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score,
placementPriorityWeight: (card, memberId, socketId) => isBlueConnectionSocket(memberId, socketId)
? placementMetrics(card, memberId, socketId).activationRateUp
: 0
});
if (optimized.assignments.length === 0) throw new Error('현재 활성 노드에 효과를 주는 연결 카드 배치를 찾지 못했습니다.');
connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const afterContext = connectionScoreContext(optimized.placements, chartSummary);
const after = optimizeExactOrderAndCooldowns(afterContext.cards, chart, {
perfectNoteScore: perfectNoteScoreFromPower(afterContext.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent
});
const afterActivationRateUp = totalActivationRateUp(afterContext);
if (afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
throw new Error('현재 배치보다 액티브 스킬 발동률 증가량이 낮아 기존 배치를 유지합니다.');
}
if (Math.abs(afterActivationRateUp - beforeActivationRateUp) < 1e-9 && after.expectedScore < analysisResult.expectedScore) {
throw new Error(`현재 배치의 예상 점수(${analysisResult.expectedScore.toLocaleString('ko-KR')})가 탐색 결과보다 높아 기존 배치를 유지합니다.`);
}
const changedAssignments = boardMembers.flatMap((member) => {
const before = boardCards[member.id] ?? {};
const afterPlacements = optimized.placements[member.id] ?? {};
return [...new Set([...Object.keys(before), ...Object.keys(afterPlacements)])]
.filter((socketId) => before[socketId] !== afterPlacements[socketId])
.map((socketId) => ({
memberId: member.id,
socketId,
previousCardId: before[socketId] ?? null,
cardId: afterPlacements[socketId] ?? null
}));
});
connectionOptimizationProposal = {
placements: optimized.placements,
assignments: changedAssignments,
beforeExpected: analysisResult.expectedScore,
afterExpected: after.expectedScore,
beforeMaximum: analysisResult.theoreticalMaxScore,
afterMaximum: after.theoreticalMaxScore,
beforeActivationRateUp,
afterActivationRateUp
};
} catch (error) {
connectionOptimizationError = error instanceof Error ? error.message : '연결 카드 최적화를 완료하지 못했습니다.';
} finally {
connectionOptimizationRunning = false;
connectionOptimizationProgress = null;
}
}
async function applyConnectionOptimization(): Promise<void> {
if (!connectionOptimizationProposal) return;
connectionOptimizationSnapshot = Object.fromEntries(Object.entries(boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
saveBoardCardPlacements(connectionOptimizationProposal.placements);
connectionOptimizationProposal = null;
await analyze();
}
async function undoConnectionOptimization(): Promise<void> {
if (!connectionOptimizationSnapshot) return;
saveBoardCardPlacements(connectionOptimizationSnapshot);
connectionOptimizationSnapshot = null;
connectionOptimizationProposal = null;
await analyze();
}
async function analyze(): Promise<void> { async function analyze(): Promise<void> {
errorMessage = ''; errorMessage = '';
clearConnectionOptimizationProposal();
result = null; result = null;
achievementProbabilities = undefined;
if (!worker || !selectedSong || !selectedChart) return; if (!worker || !selectedSong || !selectedChart) return;
if (new Set(selectedCardIds).size !== 5) { if (new Set(selectedCardIds).size !== 5) {
errorMessage = '서로 다른 카드 5장을 선택해 주세요.'; errorMessage = '서로 다른 카드 5장을 선택해 주세요.';
@@ -342,7 +688,8 @@
cards, cards,
chart, chart,
perfectNoteScore, perfectNoteScore,
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
includeAchievementProbabilities: true
}); });
} catch (error) { } catch (error) {
errorMessage = error instanceof Error ? error.message : '분석 입력을 준비하지 못했습니다.'; errorMessage = error instanceof Error ? error.message : '분석 입력을 준비하지 못했습니다.';
@@ -354,21 +701,23 @@
return card ? cardLabel(card) : cardId; return card ? cardLabel(card) : cardId;
} }
function countNotesBySecond(scoreResult: ExactOptimizationResult, durationSeconds: number): number[] { function probabilityLabel(value: number | undefined): string {
const counts = Array.from({ length: Math.ceil(durationSeconds) }, () => 0); if (value === undefined) return '—';
for (const note of scoreResult.notes) { if (value <= 0) return '0%';
const second = Math.floor(note.timeSeconds); if (value < 0.000001) return '<0.0001%';
if (second >= 0 && second < counts.length) counts[second] += 1; if (value < 0.0001) return `${(value * 100).toFixed(4)}%`;
} if (value < 0.01) return `${(value * 100).toFixed(2)}%`;
return counts; return `${(value * 100).toFixed(1)}%`;
}
function scoreThreshold(maximumScore: number, ratio: number): string {
return Math.ceil(maximumScore * ratio).toLocaleString('ko-KR');
} }
function buildTimelineTicks(durationSeconds: number): number[] { function buildTimelineTicks(durationSeconds: number): number[] {
if (durationSeconds <= 0) return []; if (durationSeconds <= 0) return [];
const interval = 10; const interval = 10;
const ticks = Array.from({ length: Math.floor(durationSeconds / interval) + 1 }, (_, index) => index * interval); return Array.from({ length: Math.floor(durationSeconds / interval) + 1 }, (_, index) => index * interval);
if (ticks.at(-1) !== durationSeconds) ticks.push(durationSeconds);
return ticks;
} }
function timelinePosition(seconds: number): number { function timelinePosition(seconds: number): number {
@@ -380,7 +729,7 @@
} }
function timelineTrackStyle(): string { function timelineTrackStyle(): string {
return `width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`; return `min-width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`;
} }
function memberColor(cardId: string): string { function memberColor(cardId: string): string {
@@ -430,7 +779,10 @@
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>배치 분석</h1> <h1>배치 분석</h1>
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 멤버 배치 순서와 빈도 감소 조합을 찾습니다.</p> <p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.</p>
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured'}
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
{/if}
</div> </div>
</header> </header>
@@ -441,33 +793,37 @@
</div> </div>
<div class="song-fields"> <div class="song-fields">
<label>악곡 <div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
<select bind:value={selectedSongId} on:change={() => result = null}>
{#each songs as song}<option value={song.id}>{song.title}</option>{/each}
</select>
</label>
<label>난이도 <label>난이도
<select bind:value={difficulty} on:change={() => result = null}> <select bind:value={difficulty} on:change={() => result = null}>
<option value="expert">Expert</option> <option value="expert">Expert</option>
<option value="hard">Hard</option> <option value="hard">Hard</option>
</select> </select>
</label> </label>
<label>가창 보너스 덮어쓰기 (0~10%)
<input class="number-input" type="number" min="0" max="10" step="any" value={songScoreBonusOverride ?? ''} placeholder="실제 보드값" on:input={(event) => updateSongScoreBonusOverride(event.currentTarget)}>
</label>
<div class="power-summary"><span>자동 계산 종합력</span><strong>{overallPower.toLocaleString('ko-KR')}</strong></div> <div class="power-summary"><span>자동 계산 종합력</span><strong>{overallPower.toLocaleString('ko-KR')}</strong></div>
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>1노트 {Math.ceil(perfectNoteScore).toLocaleString('ko-KR')}점</span>{#if boardEffects.directSongScoreBonusPercent > 0}<span>가창 보너스 {boardEffects.directSongScoreBonusPercent}%</span>{/if}</div> <div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>1노트 {Math.ceil(perfectNoteScore).toLocaleString('ko-KR')}점 · {scoreCalibrationLabel}</span>{#if boardEffects.directSongScoreBonusPercent > 0 || songScoreBonusOverride !== null}<span>가창 보너스 {boardEffects.directSongScoreBonusPercent}%{songScoreBonusOverride === null ? '' : ' · 덮어쓰기'}</span>{/if}</div>
</div> </div>
<div class="card-fields"> <div class="card-fields">
<div class="card-field-header" aria-hidden="true">
<span>카드 선택</span>
<span>개화</span>
<span>빈도 감소 고정</span>
<div><span>파란 노드 전체</span><span>기수 보너스<br>덮어쓰기 (0~900)</span></div>
</div>
{#each selectedCardIds as selectedId, slot} {#each selectedCardIds as selectedId, slot}
<div class="card-field"> <div class="card-field">
<label><span>{slot + 1}번 선택</span> <label><span>{slot + 1}번 선택</span>
<select value={selectedId} on:change={(event) => updateCard(slot, event.currentTarget.value)}> <select value={selectedId} on:change={(event) => updateCard(slot, event.currentTarget.value)}>
{#each fiveStarCards as card} {#each fiveStarCards as card}
<option value={card.id} disabled={memberSelectedInAnotherSlot(card, slot)}>{collection[card.id]?.owned ? '● ' : ''}{cardLabel(card)}</option> <option value={card.id} disabled={selectedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId)}>{collection[card.id]?.owned ? '● ' : ''}{cardLabel(card)}</option>
{/each} {/each}
</select> </select>
</label> </label>
<div class="awakening"> <div class="awakening">
<span>개화</span>
<div class="awakening-stepper"> <div class="awakening-stepper">
<button <button
type="button" type="button"
@@ -485,7 +841,6 @@
</div> </div>
</div> </div>
<div class="cooldown-lock"> <div class="cooldown-lock">
<span>빈도 감소 고정</span>
<div class="reduction-options"> <div class="reduction-options">
{#each REDUCTION_LOCK_OPTIONS as reduction} {#each REDUCTION_LOCK_OPTIONS as reduction}
<button <button
@@ -497,9 +852,17 @@
{/each} {/each}
</div> </div>
</div> </div>
<div class="board-overrides">
<label class="override-checkbox">
<input type="checkbox" checked={allBlueNodes[slot]} aria-label={`${slot + 1}번 카드 파란 노드 전체 활성화`} on:change={() => toggleAllBlueNodes(slot)}>
<span aria-hidden="true"></span>
</label>
<input class="generation-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="3" value={generationBonusOverrides[slot] ?? ''} placeholder="미사용" aria-label={`${slot + 1}번 카드 기수 보너스 덮어쓰기 (0~900)`} on:input={(event) => updateGenerationBonus(slot, sanitizeGenerationBonusInput(event.currentTarget, generationBonusOverrides[slot] ?? null))}>
</div>
</div> </div>
{/each} {/each}
</div> </div>
<p class="board-guide">‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다. 기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 실제 보드 수치를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다.</p>
<div class="leader-fields"> <div class="leader-fields">
<label>리더 <label>리더
<select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}> <select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}>
@@ -518,6 +881,8 @@
</div> </div>
<p class="power-detail">최대 레벨 기준 · 기본 {unitPower.baseParameterTotal.toLocaleString('ko-KR')} + 개화 {unitPower.potentialTotal.toLocaleString('ko-KR')} + 패시브 {unitPower.passiveTotal.toLocaleString('ko-KR')} + 의상 {unitPower.outfitTotal.toLocaleString('ko-KR')} + 보드 {unitPower.boardTotal.toLocaleString('ko-KR')} + 메모리 {unitPower.memoryTotal.toLocaleString('ko-KR')} ({memoryCount}개) + 멤버 강화 {unitPower.memberPowerUpTotal.toLocaleString('ko-KR')} ({memberPowerUpPercent.toLocaleString('ko-KR')}%)</p> <p class="power-detail">최대 레벨 기준 · 기본 {unitPower.baseParameterTotal.toLocaleString('ko-KR')} + 개화 {unitPower.potentialTotal.toLocaleString('ko-KR')} + 패시브 {unitPower.passiveTotal.toLocaleString('ko-KR')} + 의상 {unitPower.outfitTotal.toLocaleString('ko-KR')} + 보드 {unitPower.boardTotal.toLocaleString('ko-KR')} + 메모리 {unitPower.memoryTotal.toLocaleString('ko-KR')} ({memoryCount}개) + 멤버 강화 {unitPower.memberPowerUpTotal.toLocaleString('ko-KR')} ({memberPowerUpPercent.toLocaleString('ko-KR')}%)</p>
<PresetManager storageKey={ANALYSIS_PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} />
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if} {#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
<button class="analyze" type="button" disabled={running || candidateCount === 0} on:click={analyze}> <button class="analyze" type="button" disabled={running || candidateCount === 0} on:click={analyze}>
{running ? `분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 배치 분석'} {running ? `분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 배치 분석'}
@@ -528,16 +893,66 @@
<section class="panel result" aria-labelledby="result-title"> <section class="panel result" aria-labelledby="result-title">
<div class="section-title"><div><span>02</span><h2 id="result-title">분석 결과</h2></div></div> <div class="section-title"><div><span>02</span><h2 id="result-title">분석 결과</h2></div></div>
{#if result} {#if result}
<ol class="order-list"> <div class="order-sections">
{#each result.order as cardId, slot} <section class="order-section">
<li><span class="slot">{slot + 1}</span><strong>{resultCardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li> <h3>추천 스페셜 발동 순서</h3>
{/each} <ol class="order-list">
</ol> {#each result.order as cardId, slot}
<li><span class="slot">{slot + 1}</span><strong>{resultCardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each}
</ol>
</section>
</div>
<div class="score-grid"> <div class="score-grid">
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong></div> <div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div> <div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div> <div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
</div> </div>
<div class="probability-grid">
<div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div>
<div><span>최고점의 99% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast99Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.99)}점 이상</small></div>
<div><span>최고점의 95% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast95Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.95)}점 이상</small></div>
</div>
<p class="probability-guide">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
<div class="connection-optimizer-heading">
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 파란색 영역은 액티브 발동률을 먼저 최대화한 뒤 예상 점수를 비교합니다.</p></div>
<div class="connection-optimizer-actions">
{#if connectionOptimizationSnapshot}<button type="button" disabled={running || connectionOptimizationRunning} on:click={undoConnectionOptimization}>직전 배치 복원</button>{/if}
<button type="button" class="primary" disabled={running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
</div>
</div>
{#if connectionOptimizationProgress}
<div class="connection-optimizer-progress" aria-live="polite">
<div><strong>{connectionOptimizationProgress.stage}</strong>{#if connectionOptimizationProgress.total > 0}<span>{connectionOptimizationProgress.completed.toLocaleString('ko-KR')} / {connectionOptimizationProgress.total.toLocaleString('ko-KR')}</span>{/if}</div>
{#if connectionOptimizationProgress.total > 0}<progress max={connectionOptimizationProgress.total} value={connectionOptimizationProgress.completed}></progress>{/if}
</div>
{/if}
{#if connectionOptimizationError}<p class="connection-optimizer-error" role="alert">{connectionOptimizationError}</p>{/if}
{#if connectionOptimizationProposal}
<div class="connection-proposal" aria-live="polite">
<div class="connection-score-comparison">
<span>예상 점수 <strong>{connectionOptimizationProposal.beforeExpected.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterExpected.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterExpected >= connectionOptimizationProposal.beforeExpected ? '+' : ''}{(connectionOptimizationProposal.afterExpected - connectionOptimizationProposal.beforeExpected).toLocaleString('ko-KR')}</b></span>
<span>이론상 고점 <strong>{connectionOptimizationProposal.beforeMaximum.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterMaximum.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterMaximum >= connectionOptimizationProposal.beforeMaximum ? '+' : ''}{(connectionOptimizationProposal.afterMaximum - connectionOptimizationProposal.beforeMaximum).toLocaleString('ko-KR')}</b></span>
<span>액티브 발동률 UP 합계 <strong>{connectionOptimizationProposal.beforeActivationRateUp.toLocaleString('ko-KR', { maximumFractionDigits: 2 })}% → {connectionOptimizationProposal.afterActivationRateUp.toLocaleString('ko-KR', { maximumFractionDigits: 2 })}%</strong><b>{connectionOptimizationProposal.afterActivationRateUp >= connectionOptimizationProposal.beforeActivationRateUp ? '+' : ''}{(connectionOptimizationProposal.afterActivationRateUp - connectionOptimizationProposal.beforeActivationRateUp).toLocaleString('ko-KR', { maximumFractionDigits: 2 })}%p</b></span>
</div>
{#if connectionOptimizationProposal.assignments.length > 0}
<div class="connection-assignment-list">
{#each connectionOptimizationProposal.assignments as assignment (`${assignment.memberId}:${assignment.socketId}`)}
{@const member = boardMembers.find((item) => item.id === assignment.memberId)}
{@const previousCard = assignment.previousCardId ? allCardById.get(assignment.previousCardId) : null}
{@const card = assignment.cardId ? allCardById.get(assignment.cardId) : null}
<span><small>{member?.name ?? assignment.memberId} · {connectionSocketName(assignment.memberId, assignment.socketId)}</small><strong>{previousCard ? cardLabel(previousCard) : '비어 있음'} → {card ? cardLabel(card) : '배치 해제'}</strong></span>
{/each}
</div>
<button type="button" class="apply-connection" on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
{:else}
<p class="connection-no-changes">현재 연결 카드 배치가 이미 최적입니다.</p>
{/if}
</div>
{/if}
</section>
<section class="skill-timeline" aria-labelledby="timeline-title"> <section class="skill-timeline" aria-labelledby="timeline-title">
<div class="timeline-heading"> <div class="timeline-heading">
@@ -548,14 +963,7 @@
</div> </div>
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프"> <div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}> <div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
<div class="timeline-row note-row"> <ScoreTimelineChart notes={result.notes} durationSeconds={timelineDuration} minimumWidth={timelineWidth} timeTicks={timelineTicks} />
<strong class="lane-label">초당 노트</strong>
<div class="timeline-track note-track" style={timelineTrackStyle()} aria-label={`최대 초당 ${maxNotesPerSecond}개`}>
{#each noteCountsBySecond as count, second}
<span class="note-bar" style={`left: ${timelinePosition(second)}%; width: ${100 / Math.max(1, noteCountsBySecond.length)}%; height: ${count / maxNotesPerSecond * 100}%; opacity: ${count === 0 ? 0 : 0.18 + count / maxNotesPerSecond * 0.82};`} title={`${second}~${second + 1}초 · ${count}개`}></span>
{/each}
</div>
</div>
{#each result.order as cardId, slot} {#each result.order as cardId, slot}
<div class="timeline-row member-row"> <div class="timeline-row member-row">
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong> <strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong>
@@ -620,6 +1028,8 @@
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: var(--page-header-bottom); } .page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: var(--page-header-bottom); }
h1 { margin: 0; color: #f3f5f8; font-size: var(--page-title-size); line-height: 1.15; letter-spacing: -.045em; } h1 { margin: 0; color: #f3f5f8; font-size: var(--page-title-size); line-height: 1.15; letter-spacing: -.045em; }
.page-description { margin: 10px 0 0; color: #929cab; font-size: var(--page-description-size); line-height: 1.6; } .page-description { margin: 10px 0 0; color: #929cab; font-size: var(--page-description-size); line-height: 1.6; }
.calibration-warning { width: fit-content; margin: 10px 0 0; padding: 8px 11px; border: 1px solid #604653; border-radius: 8px; color: #c8aeb9; background: #261d23; font-size: 13px; line-height: 1.55; }
.calibration-warning strong { margin-right: 5px; color: #f1a3c4; }
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; } .panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
.section-title, .section-title > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; } .section-title, .section-title > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.section-title > div { justify-content: flex-start; } .section-title > div { justify-content: flex-start; }
@@ -627,31 +1037,45 @@
h2 { margin: 0; font-size: 18px; } h2 { margin: 0; font-size: 18px; }
.section-title > strong { color: #aeb6c4; font-size: 14px; } .section-title > strong { color: #aeb6c4; font-size: 14px; }
label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; } label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
select { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; } select, .number-input { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
select:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); } select:focus, .number-input:focus, .generation-input:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
.song-fields { display: grid; grid-template-columns: minmax(0, 2fr) clamp(110px, 11vw, 170px) clamp(140px, 14vw, 220px); align-items: end; gap: 12px; margin-top: 22px; } .song-fields { display: grid; grid-template-columns: minmax(0, 4fr) clamp(110px, 11vw, 170px) clamp(190px, 17vw, 250px) clamp(140px, 14vw, 220px); align-items: start; gap: 12px; margin-top: 22px; }
.power-summary { display: grid; gap: 4px; min-height: 42px; padding: 7px 11px; border-radius: 9px; background: #22202a; } .song-picker-field { min-width: 0; }
.power-summary { display: grid; gap: 4px; min-height: 42px; margin-top: 25px; padding: 7px 11px; border-radius: 9px; background: #22202a; }
.power-summary span { color: #929cab; font-size: 12px; font-weight: 750; } .power-summary span { color: #929cab; font-size: 12px; font-weight: 750; }
.power-summary strong { color: #f2a3c4; font-size: 16px; } .power-summary strong { color: #f2a3c4; font-size: 16px; }
.chart-meta { display: flex; gap: 8px; height: 42px; align-items: center; } .chart-meta { display: flex; grid-column: 1 / -1; gap: 8px; height: 42px; align-items: center; }
.chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 13px; white-space: nowrap; } .chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 13px; white-space: nowrap; }
.card-fields { display: grid; gap: 9px; margin-top: 18px; } .card-fields { display: grid; gap: 9px; margin-top: 18px; }
.card-field { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(130px, 12vw, 180px) clamp(304px, 27vw, 400px); gap: 9px; } .card-field-header, .card-field { display: grid; grid-template-columns: minmax(280px, 1fr) clamp(120px, 10vw, 160px) clamp(280px, 24vw, 360px) clamp(180px, 16vw, 240px); gap: 9px; }
.card-field-header { padding: 0 2px; color: #8f98a8; font-size: 13px; font-weight: 800; }
.card-field-header span { padding-left: 2px; }
.card-field-header > div { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; color: #8f98a8; font-size: 12px; text-align: center; }
.card-field > label { grid-template-columns: 74px minmax(0, 1fr); align-items: center; } .card-field > label { grid-template-columns: 74px minmax(0, 1fr); align-items: center; }
.card-field > .awakening { display: grid; grid-template-columns: 32px minmax(0, 1fr); align-items: center; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; } .card-field > .awakening { min-width: 0; }
.awakening-stepper { display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; } .awakening-stepper { display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 24px; line-height: 1; cursor: pointer; } .awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 24px; line-height: 1; cursor: pointer; }
.awakening-stepper button:hover:not(:disabled) { color: #f5afcc; background: #2d2530; } .awakening-stepper button:hover:not(:disabled) { color: #f5afcc; background: #2d2530; }
.awakening-stepper button:focus-visible { position: relative; z-index: 1; outline: 2px solid #df74a2; outline-offset: -2px; } .awakening-stepper button:focus-visible { position: relative; z-index: 1; outline: 2px solid #df74a2; outline-offset: -2px; }
.awakening-stepper button:disabled { color: #525b69; background: #171c24; cursor: default; } .awakening-stepper button:disabled { color: #525b69; background: #171c24; cursor: default; }
.awakening-stepper strong { color: #e8ebf1; font-size: 14px; text-align: center; white-space: nowrap; } .awakening-stepper strong { color: #e8ebf1; font-size: 14px; text-align: center; white-space: nowrap; }
.cooldown-lock { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; } .cooldown-lock { min-width: 0; }
.reduction-options { display: grid; grid-template-columns: 1.35fr repeat(4, 1fr); height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; } .reduction-options { display: grid; grid-template-columns: 1.35fr repeat(4, 1fr); height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.reduction-options button { min-width: 0; padding: 0 6px; border: 0; border-left: 1px solid #2d3440; color: #8f98a8; background: transparent; font-size: 13px; font-weight: 750; cursor: pointer; } .reduction-options button { min-width: 0; padding: 0 6px; border: 0; border-left: 1px solid #2d3440; color: #8f98a8; background: transparent; font-size: 13px; font-weight: 750; cursor: pointer; }
.reduction-options button:first-child { border-left: 0; } .reduction-options button:first-child { border-left: 0; }
.reduction-options button:hover { color: #e9edf3; background: #202630; } .reduction-options button:hover { color: #e9edf3; background: #202630; }
.reduction-options button.active { color: #171019; background: #ed8bb5; } .reduction-options button.active { color: #171019; background: #ed8bb5; }
.reduction-options button:focus-visible { position: relative; z-index: 1; outline: 2px solid #f1a0c2; outline-offset: -2px; } .reduction-options button:focus-visible { position: relative; z-index: 1; outline: 2px solid #f1a0c2; outline-offset: -2px; }
.board-overrides { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; align-self: end; height: 42px; }
.override-checkbox { display: grid; place-items: center; cursor: pointer; }
.override-checkbox input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.override-checkbox span { display: grid; place-items: center; width: 22px; height: 22px; border: 1px solid #4a5361; border-radius: 6px; background: #11151b; }
.override-checkbox input:checked + span { border-color: #ed8bb5; background: #ed8bb5; }
.override-checkbox input:checked + span::after { content: '✓'; color: #171019; font-size: 14px; font-weight: 950; }
.override-checkbox input:focus-visible + span { outline: 3px solid rgb(216 111 156 / 20%); }
.generation-input { align-self: center; width: 100%; min-width: 0; height: 32px; padding: 0 7px; border: 1px solid #414957; border-radius: 7px; color: #e8ebf1; background: #11151b; font-size: 12px; font-weight: 800; text-align: center; }
.generation-input::placeholder { color: #68717f; opacity: 1; }
.board-guide { margin: 8px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
.leader-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 16px; } .leader-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 16px; }
.outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 12px; margin-top: 9px; padding: 11px 13px; border: 1px solid #2d3440; border-radius: 9px; background: #12161c; } .outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 12px; margin-top: 9px; padding: 11px 13px; border: 1px solid #2d3440; border-radius: 9px; background: #12161c; }
.outfit-effect span { color: #d993b1; font-size: 13px; font-weight: 850; } .outfit-effect span { color: #d993b1; font-size: 13px; font-weight: 850; }
@@ -662,7 +1086,10 @@
.analyze:disabled { opacity: .55; cursor: default; } .analyze:disabled { opacity: .55; cursor: default; }
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; } progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
.error { margin: 16px 0 0; color: #ff9f9f; font-size: 13px; } .error { margin: 16px 0 0; color: #ff9f9f; font-size: 13px; }
.order-list { display: grid; gap: 8px; padding: 0; margin: 22px 0 0; list-style: none; } .order-sections { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 22px; }
.order-section { min-width: 0; }
.order-section h3 { margin: 0 0 9px; color: #aeb7c5; font-size: 14px; }
.order-list { display: grid; gap: 8px; padding: 0; margin: 0; list-style: none; }
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; } .order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; } .slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -671,6 +1098,40 @@
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; } .score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
.score-summary span { color: #a9b1be; font-size: 14px; } .score-summary span { color: #a9b1be; font-size: 14px; }
.score-summary strong { color: #f2a3c4; font-size: 20px; } .score-summary strong { color: #f2a3c4; font-size: 20px; }
.probability-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }
.probability-grid > div { display: grid; gap: 7px; padding: 16px; border: 1px solid #303744; border-radius: 10px; background: #171d25; }
.probability-grid span { color: #9ca5b3; font-size: 14px; }
.probability-grid strong { color: #e5e9ef; font-size: 20px; }
.probability-grid small { color: #8d96a5; font-size: 12px; font-weight: 700; }
.probability-guide { margin: 9px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; }
.connection-optimizer { margin-top: 18px; padding: 18px; border: 1px solid #3b5445; border-radius: 13px; background: #151e19; }
.connection-optimizer-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.connection-optimizer-heading h3 { margin: 0; color: #dce8e0; font-size: 17px; }
.connection-optimizer-heading p { margin: 5px 0 0; color: #8fa095; font-size: 13px; }
.connection-optimizer-actions { display: flex; flex: 0 0 auto; gap: 7px; }
.connection-optimizer button { min-height: 38px; padding: 8px 12px; border: 1px solid #46574c; border-radius: 8px; color: #becbc2; background: #202a23; font-weight: 800; cursor: pointer; }
.connection-optimizer button.primary, .connection-optimizer button.apply-connection { border-color: #69b184; color: #101913; background: #78c493; }
.connection-optimizer button:disabled { opacity: .55; cursor: default; }
.connection-optimizer-progress { display: grid; gap: 7px; margin-top: 14px; padding: 10px 12px; border: 1px solid #314238; border-radius: 9px; background: #111a15; }
.connection-optimizer-progress > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.connection-optimizer-progress strong { color: #b9dac5; font-size: 13px; }
.connection-optimizer-progress span { color: #86a391; font-size: 12px; font-weight: 800; }
.connection-optimizer-progress progress { width: 100%; height: 8px; overflow: hidden; border: 0; border-radius: 999px; color: #78c493; background: #27322c; appearance: none; }
.connection-optimizer-progress progress::-webkit-progress-bar { border-radius: 999px; background: #27322c; }
.connection-optimizer-progress progress::-webkit-progress-value { border-radius: 999px; background: #78c493; }
.connection-optimizer-progress progress::-moz-progress-bar { border-radius: 999px; background: #78c493; }
.connection-optimizer-error { margin: 12px 0 0; color: #e3a0a0; font-size: 13px; }
.connection-proposal { display: grid; gap: 12px; margin-top: 14px; padding-top: 14px; border-top: 1px solid rgb(120 196 147 / 18%); }
.connection-score-comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.connection-score-comparison span { display: grid; gap: 5px; padding: 10px 12px; border-radius: 8px; color: #91a59a; background: #111813; font-size: 12px; }
.connection-score-comparison strong { color: #e0ebe4; font-size: 14px; }
.connection-score-comparison b { color: #82d09e; font-size: 13px; }
.connection-assignment-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 6px; max-height: 240px; overflow-y: auto; }
.connection-assignment-list > span { display: grid; gap: 3px; padding: 8px 10px; border: 1px solid #304038; border-radius: 7px; background: #121915; }
.connection-assignment-list small { color: #819287; font-size: 11px; }
.connection-assignment-list strong { overflow: hidden; color: #dbe7df; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.connection-no-changes { margin: 0; padding: 10px 12px; border-radius: 8px; color: #a9cdb6; background: #18271d; font-size: 13px; font-weight: 750; }
.connection-proposal .apply-connection { justify-self: end; }
.empty { color: #858f9f; font-size: 14px; line-height: 1.65; } .empty { color: #858f9f; font-size: 14px; line-height: 1.65; }
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; } .skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; } .timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
@@ -680,16 +1141,13 @@
.timeline-legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; } .timeline-legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; } .timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
.timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; } .timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; }
.timeline-canvas { min-width: 100%; padding: 12px 12px 8px 0; } .timeline-canvas { min-width: 100%; padding: 12px 0 8px; }
.timeline-row { display: flex; min-height: 36px; } .timeline-row { display: flex; min-height: 36px; }
.timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); } .timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); }
.lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } .lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 11px; } .lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 11px; }
.timeline-track { position: relative; flex: none; } .timeline-track { position: relative; flex: 1 0 auto; }
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; } .timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
.note-row { min-height: 68px; }
.note-track { height: 52px; align-self: center; border-bottom: 1px solid #465062; }
.note-bar { position: absolute; bottom: 0; min-height: 1px; border-right: 1px solid #10141a; background: #8ca9d7; }
.skill-track { height: 36px; } .skill-track { height: 36px; }
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); } .skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
.active-window { top: 12px; height: 12px; opacity: .72; } .active-window { top: 12px; height: 12px; opacity: .72; }
@@ -706,29 +1164,36 @@
.time-axis span:last-child { transform: translateX(-100%); } .time-axis span:last-child { transform: translateX(-100%); }
.empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; border: 1px dashed #303744; border-radius: 12px; padding: 28px; text-align: center; } .empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; border: 1px dashed #303744; border-radius: 12px; padding: 28px; text-align: center; }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.card-field { grid-template-columns: minmax(0, 1fr) 130px 304px; } .card-field-header, .card-field { grid-template-columns: minmax(0, 1fr) 120px 280px 180px; }
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.order-sections { grid-template-columns: 1fr; }
main { width: calc(100% - 24px); padding-top: var(--page-content-top); } main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
.page-header { align-items: flex-start; } .page-header { align-items: flex-start; }
.panel { padding: 17px; } .panel { padding: 17px; }
.song-fields { grid-template-columns: 1fr 100px; } .song-fields { grid-template-columns: minmax(0, 1fr) 100px; }
.power-summary { grid-column: 1 / -1; } .power-summary { grid-column: 1 / -1; margin-top: 0; }
.chart-meta { grid-column: 1 / -1; height: auto; } .chart-meta { grid-column: 1 / -1; height: auto; }
.card-field { grid-template-columns: minmax(0, 1fr) 102px; } .card-field { grid-template-columns: minmax(0, 1fr) 102px; }
.card-field-header { display: none; }
.card-field > label { grid-template-columns: 58px minmax(0, 1fr); } .card-field > label { grid-template-columns: 58px minmax(0, 1fr); }
.card-field > .awakening { grid-template-columns: 1fr; } .card-field > .awakening { grid-template-columns: 1fr; }
.awakening-stepper { grid-template-columns: 28px minmax(0, 1fr) 28px; } .awakening-stepper { grid-template-columns: 28px minmax(0, 1fr) 28px; }
.cooldown-lock { grid-column: 1 / -1; grid-template-columns: 88px minmax(0, 1fr); } .cooldown-lock { grid-column: 1 / -1; grid-template-columns: 88px minmax(0, 1fr); }
.board-overrides { grid-column: 1 / -1; }
.leader-fields { grid-template-columns: 1fr; } .leader-fields { grid-template-columns: 1fr; }
.outfit-effect { grid-template-columns: 1fr; gap: 5px; } .outfit-effect { grid-template-columns: 1fr; gap: 5px; }
.order-list li { grid-template-columns: 30px minmax(0, 1fr); } .order-list li { grid-template-columns: 30px minmax(0, 1fr); }
.reduction { grid-column: 2; justify-self: start; } .reduction { grid-column: 2; justify-self: start; }
.score-grid { grid-template-columns: 1fr; } .score-grid { grid-template-columns: 1fr; }
.connection-optimizer-heading { align-items: stretch; flex-direction: column; }
.connection-optimizer-actions { display: grid; }
.connection-score-comparison { grid-template-columns: 1fr; }
.connection-proposal .apply-connection { justify-self: stretch; }
.timeline-heading { align-items: flex-start; flex-direction: column; } .timeline-heading { align-items: flex-start; flex-direction: column; }
.timeline-legend { justify-content: flex-start; } .timeline-legend { justify-content: flex-start; }
.lane-label { flex-basis: 118px; min-width: 118px; } .lane-label { flex-basis: 118px; min-width: 118px; }
.timeline-canvas { padding-right: 8px; } .timeline-canvas { padding-right: 0; }
} }
</style> </style>
+12
View File
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
export function GET() {
const now = Date.now();
return json({ now, iso: new Date(now).toISOString() }, {
headers: {
'cache-control': 'no-store, no-cache, must-revalidate',
expires: '0',
pragma: 'no-cache'
}
});
}
+249 -73
View File
@@ -1,43 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import cardSummary from '$lib/data/cards-summary.json'; import cardSummary from '$lib/data/cards-summary.json';
import type { CardListPreferences, UserData } from '$lib/models/user-data'; import { skillAtLevel, sortCardsByActive, type CardSummary, type CardType, type HighestStat, type SkillLevel } from '$lib/models/card-list';
import type { CardListPreferences, CardListSortKey, CardSkillLevel, SortDirection, UserData } from '$lib/models/user-data';
import { loadBrowserData, saveBrowserData } from '$lib/storage/browser-storage'; import { loadBrowserData, saveBrowserData } from '$lib/storage/browser-storage';
type CardType = '큐트' | '퓨어' | '해피';
type HighestStat = '퍼포먼스' | '테크닉' | '센스';
type SkillConditionStatus = '일반' | '조건부'; type SkillConditionStatus = '일반' | '조건부';
type ActiveProbability = '높음' | '중간'; type ActiveProbability = '높음' | '중간';
type SkillLevel = {
level: number;
description: string;
coolTimeSeconds: number | null;
probabilityPercent: number | null;
durationSeconds: number | null;
effect?: string;
condition?: string;
};
type CardDetails = {
title: string;
passive: SkillLevel[];
active: SkillLevel[];
special: SkillLevel[];
costume: { name: string; description: string } | null;
};
type CardSummary = {
id: string;
rarity: number;
type: CardType;
name: string;
versionName: string;
generation: string;
highestStat: HighestStat;
details: CardDetails;
};
const allCards = cardSummary.cards as CardSummary[]; const allCards = cardSummary.cards as CardSummary[];
const cards = allCards.filter((card) => card.rarity === 5); const cards = allCards.filter((card) => card.rarity === 5);
const typeOptions: CardType[] = ['큐트', '퓨어', '해피']; const typeOptions: CardType[] = ['큐트', '퓨어', '해피'];
@@ -59,8 +29,10 @@
let selectedActiveProbabilities = [...activeProbabilityOptions]; let selectedActiveProbabilities = [...activeProbabilityOptions];
let selectedCardVariants = [...cardVariantOptions]; let selectedCardVariants = [...cardVariantOptions];
let selectedGenerations = [...generationOptions]; let selectedGenerations = [...generationOptions];
let sortKey: CardListSortKey | null = null;
let sortDirection: SortDirection = 'asc';
let expandedCardId: string | null = null; let expandedCardId: string | null = null;
let selectedSkillLevel = 1; let selectedSkillLevel: CardSkillLevel = 1;
let collection: UserData['collection'] = {}; let collection: UserData['collection'] = {};
let memberBoards: UserData['memberBoards'] = {}; let memberBoards: UserData['memberBoards'] = {};
let boardCards: UserData['boardCards'] = {}; let boardCards: UserData['boardCards'] = {};
@@ -95,14 +67,19 @@
} catch {} } catch {}
}); });
$: filteredCards = cards.filter((card) => $: filteredCards = sortCardsByActive(
selectedTypes.includes(card.type) cards.filter((card) =>
&& selectedStats.includes(card.highestStat) selectedTypes.includes(card.type)
&& selectedActiveConditions.includes(skillConditionStatus(card.details.active)) && selectedStats.includes(card.highestStat)
&& selectedSpecialConditions.includes(skillConditionStatus(card.details.special)) && matchesSkillCondition(selectedActiveConditions, card.details.active ?? [])
&& selectedActiveProbabilities.includes(activeProbabilityStatus(card.details.active)) && selectedSpecialConditions.includes(skillConditionStatus(card.details.special))
&& selectedCardVariants.includes(cardVariantStatus(card)) && matchesActiveProbability(card.details.active ?? [])
&& generationMemberships(card.generation).some((generation) => selectedGenerations.includes(generation)) && selectedCardVariants.includes(cardVariantStatus(card))
&& generationMemberships(card.generation).some((generation) => selectedGenerations.includes(generation))
),
sortKey,
sortDirection,
selectedSkillLevel
); );
$: allSelected = selectedTypes.length === typeOptions.length $: allSelected = selectedTypes.length === typeOptions.length
@@ -113,6 +90,14 @@
&& selectedCardVariants.length === cardVariantOptions.length && selectedCardVariants.length === cardVariantOptions.length
&& selectedGenerations.length === generationOptions.length; && selectedGenerations.length === generationOptions.length;
$: allDeselected = selectedTypes.length === 0
&& selectedStats.length === 0
&& selectedActiveConditions.length === 0
&& selectedSpecialConditions.length === 0
&& selectedActiveProbabilities.length === 0
&& selectedCardVariants.length === 0
&& selectedGenerations.length === 0;
$: if (expandedCardId && !filteredCards.some((card) => card.id === expandedCardId)) { $: if (expandedCardId && !filteredCards.some((card) => card.id === expandedCardId)) {
expandedCardId = null; expandedCardId = null;
} }
@@ -127,6 +112,16 @@
selectedGenerations = [...generationOptions]; selectedGenerations = [...generationOptions];
} }
function selectNone() {
selectedTypes = [];
selectedStats = [];
selectedActiveConditions = [];
selectedSpecialConditions = [];
selectedActiveProbabilities = [];
selectedCardVariants = [];
selectedGenerations = [];
}
function defaultPreferences(): CardListPreferences { function defaultPreferences(): CardListPreferences {
return { return {
types: [...typeOptions], types: [...typeOptions],
@@ -135,7 +130,10 @@
specialConditions: [...skillConditionOptions], specialConditions: [...skillConditionOptions],
activeProbabilities: [...activeProbabilityOptions], activeProbabilities: [...activeProbabilityOptions],
cardVariants: [...cardVariantOptions], cardVariants: [...cardVariantOptions],
generations: [...generationOptions] generations: [...generationOptions],
sortKey: null,
sortDirection: 'asc',
skillLevel: 1
}; };
} }
@@ -147,7 +145,10 @@
specialConditions: [...selectedSpecialConditions], specialConditions: [...selectedSpecialConditions],
activeProbabilities: [...selectedActiveProbabilities], activeProbabilities: [...selectedActiveProbabilities],
cardVariants: [...selectedCardVariants], cardVariants: [...selectedCardVariants],
generations: [...selectedGenerations] generations: [...selectedGenerations],
sortKey,
sortDirection,
skillLevel: selectedSkillLevel
}; };
} }
@@ -160,6 +161,9 @@
selectedCardVariants = data.preferences.cardVariants.filter((value) => cardVariantOptions.includes(value)); selectedCardVariants = data.preferences.cardVariants.filter((value) => cardVariantOptions.includes(value));
selectedGenerations = [...new Set(data.preferences.generations.flatMap(generationMemberships))] selectedGenerations = [...new Set(data.preferences.generations.flatMap(generationMemberships))]
.filter((value) => generationOptions.includes(value)); .filter((value) => generationOptions.includes(value));
sortKey = data.preferences.sortKey;
sortDirection = data.preferences.sortDirection;
selectedSkillLevel = data.preferences.skillLevel;
collection = data.collection; collection = data.collection;
memberBoards = data.memberBoards; memberBoards = data.memberBoards;
boardCards = data.boardCards; boardCards = data.boardCards;
@@ -189,23 +193,55 @@
return levels.some((level) => Boolean(level.condition)) ? '조건부' : '일반'; return levels.some((level) => Boolean(level.condition)) ? '조건부' : '일반';
} }
function activeProbabilityStatus(levels: SkillLevel[]): ActiveProbability { function matchesSkillCondition(selected: SkillConditionStatus[], levels: SkillLevel[]): boolean {
return levels.length === 0
? selected.length === skillConditionOptions.length
: selected.includes(skillConditionStatus(levels));
}
function activeProbabilityStatus(levels: SkillLevel[]): ActiveProbability | null {
if (levels.length === 0) return null;
return levels.some((level) => level.probabilityPercent === 55) ? '높음' : '중간'; return levels.some((level) => level.probabilityPercent === 55) ? '높음' : '중간';
} }
function matchesActiveProbability(levels: SkillLevel[]): boolean {
const status = activeProbabilityStatus(levels);
return status === null
? selectedActiveProbabilities.length === activeProbabilityOptions.length
: selectedActiveProbabilities.includes(status);
}
function toggleCard(cardId: string) { function toggleCard(cardId: string) {
expandedCardId = expandedCardId === cardId ? null : cardId; expandedCardId = expandedCardId === cardId ? null : cardId;
selectedSkillLevel = 1;
} }
function skillAtLevel(levels: SkillLevel[], selectedLevel: number) { function toggleSort(nextSortKey: CardListSortKey) {
return levels.find((level) => level.level === selectedLevel) ?? levels.at(-1); if (sortKey === nextSortKey) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
return;
}
sortKey = nextSortKey;
sortDirection = 'asc';
} }
function probabilityLabel(probabilityPercent: number | null) { function sortButtonLabel(key: CardListSortKey, label: string) {
if (sortKey !== key) return `${label} 오름차순 정렬`;
return `${label} ${sortDirection === 'asc' ? '내림차순' : '오름차순'}으로 변경`;
}
function secondsLabel(seconds: number | null | undefined) {
return seconds == null ? '—' : `${seconds}초`;
}
function probabilityLabel(probabilityPercent: number | null | undefined) {
if (probabilityPercent === 55) return '높음'; if (probabilityPercent === 55) return '높음';
if (probabilityPercent === 46) return '중간'; if (probabilityPercent === 46) return '중간';
return probabilityPercent === null ? '—' : `${probabilityPercent}%`; return probabilityPercent == null ? '—' : `${probabilityPercent}%`;
}
function activeEffectLabel(active: SkillLevel | undefined) {
return active?.effect || active?.description || '—';
} }
function descriptionSegments(description: string, highlightNumbers = false) { function descriptionSegments(description: string, highlightNumbers = false) {
@@ -287,7 +323,10 @@
<section class="filters" aria-labelledby="filter-heading"> <section class="filters" aria-labelledby="filter-heading">
<div class="filter-header"> <div class="filter-header">
<h2 id="filter-heading">출력 조건</h2> <h2 id="filter-heading">출력 조건</h2>
<button class="select-all" type="button" onclick={selectAll} disabled={allSelected}>모두 선택</button> <div class="filter-actions">
<button class="filter-action" type="button" onclick={selectNone} disabled={allDeselected}>모두 취소</button>
<button class="filter-action" type="button" onclick={selectAll} disabled={allSelected}>모두 선택</button>
</div>
</div> </div>
<div class="filter-groups"> <div class="filter-groups">
@@ -392,12 +431,30 @@
</section> </section>
<section class="table-panel" aria-label="카드 목록"> <section class="table-panel" aria-label="카드 목록">
<div class="list-header" aria-hidden="true"> <div class="list-header">
<span>타입</span> <span>타입</span>
<span>주스탯</span> <span>주스탯</span>
<span>기수</span> <span>기수</span>
<span>이름</span> <span>이름</span>
<span></span> <button class="sort-button" type="button" class:active={sortKey === 'coolTime'} aria-label={sortButtonLabel('coolTime', '주기')} onclick={() => toggleSort('coolTime')}>
<span>주기</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'coolTime' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
</button>
<button class="sort-button" type="button" class:active={sortKey === 'probability'} aria-label={sortButtonLabel('probability', '확률')} onclick={() => toggleSort('probability')}>
<span>확률</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'probability' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
</button>
<button class="sort-button" type="button" class:active={sortKey === 'duration'} aria-label={sortButtonLabel('duration', '지속')} onclick={() => toggleSort('duration')}>
<span>지속</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'duration' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
</button>
<button class="sort-button" type="button" class:active={sortKey === 'effect'} aria-label={sortButtonLabel('effect', '효과')} onclick={() => toggleSort('effect')}>
<span>효과</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'effect' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
</button>
<div class="header-level-control">
<span>개화</span>
<div class="level-toggle header-level-toggle" aria-label="목록에 표시할 스킬 개화 상태">
<button type="button" class:active={selectedSkillLevel === 1} aria-pressed={selectedSkillLevel === 1} onclick={() => selectedSkillLevel = 1}>명함</button>
<button type="button" class:active={selectedSkillLevel === 2} aria-pressed={selectedSkillLevel === 2} onclick={() => selectedSkillLevel = 2}>5 개화</button>
</div>
</div>
<span></span> <span></span>
</div> </div>
@@ -448,13 +505,24 @@
{#if card.versionName}<span class="version-badge">{card.versionName}</span>{/if} {#if card.versionName}<span class="version-badge">{card.versionName}</span>{/if}
</span> </span>
</span> </span>
<span class="summary-cell active-cycle-cell active-summary-cell">
<span class="mobile-label">주기</span>
<span class="active-value">{secondsLabel(active?.coolTimeSeconds)}</span>
</span>
<span class="summary-cell active-probability-cell active-summary-cell">
<span class="mobile-label">확률</span>
<span class="active-value">{probabilityLabel(active?.probabilityPercent)}</span>
</span>
<span class="summary-cell active-duration-cell active-summary-cell">
<span class="mobile-label">지속</span>
<span class="active-value">{secondsLabel(active?.durationSeconds)}</span>
</span>
<span class="summary-cell active-effect-cell active-summary-cell">
<span class="mobile-label">효과</span>
<span class="active-effect" title={activeEffectLabel(active)}>{activeEffectLabel(active)}</span>
</span>
<span class="summary-cell level-cell"> <span class="summary-cell level-cell">
{#if expanded} <span class="skill-level-label">{selectedSkillLevel === 1 ? '명함' : '5 개화'}</span>
<div class="level-toggle inline-level-toggle" aria-label="스킬 개화 상태">
<button type="button" class:active={selectedSkillLevel === 1} aria-pressed={selectedSkillLevel === 1} onclick={() => selectedSkillLevel = 1}>명함</button>
<button type="button" class:active={selectedSkillLevel === 2} aria-pressed={selectedSkillLevel === 2} onclick={() => selectedSkillLevel = 2}>5 개화</button>
</div>
{/if}
</span> </span>
<span class="chevron" aria-hidden="true"></span> <span class="chevron" aria-hidden="true"></span>
</div> </div>
@@ -704,7 +772,12 @@
letter-spacing: -0.025em; letter-spacing: -0.025em;
} }
.select-all { .filter-actions {
display: flex;
gap: 8px;
}
.filter-action {
padding: 7px 12px; padding: 7px 12px;
border: 1px solid #dce1ea; border: 1px solid #dce1ea;
border-radius: 9px; border-radius: 9px;
@@ -717,13 +790,13 @@
transition: border-color 150ms ease, color 150ms ease, background 150ms ease; transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
} }
.select-all:hover:not(:disabled) { .filter-action:hover:not(:disabled) {
border-color: #ef8bb5; border-color: #ef8bb5;
color: #d83f82; color: #d83f82;
background: #fff8fb; background: #fff8fb;
} }
.select-all:disabled { .filter-action:disabled {
opacity: 0.42; opacity: 0.42;
cursor: default; cursor: default;
} }
@@ -831,7 +904,8 @@
} }
.table-panel { .table-panel {
overflow: hidden; overflow-x: auto;
overflow-y: hidden;
border: 1px solid #e3e7ef; border: 1px solid #e3e7ef;
border-radius: 22px; border-radius: 22px;
background: rgb(255 255 255 / 90%); background: rgb(255 255 255 / 90%);
@@ -842,11 +916,12 @@
.list-header, .list-header,
.card-summary { .card-summary {
display: grid; display: grid;
grid-template-columns: clamp(72px, 5vw, 88px) clamp(84px, 6vw, 104px) clamp(150px, 11vw, 184px) minmax(280px, 1fr) clamp(116px, 9vw, 144px) 40px; grid-template-columns: 62px 72px 110px minmax(160px, 1fr) 66px 66px 66px minmax(160px, 1.15fr) 190px 32px;
align-items: center; align-items: center;
} }
.list-header { .list-header {
min-width: 1260px;
min-height: 54px; min-height: 54px;
padding: 0 24px; padding: 0 24px;
border-bottom: 1px solid #e8ebf1; border-bottom: 1px solid #e8ebf1;
@@ -856,7 +931,44 @@
font-weight: 700; font-weight: 700;
} }
.list-header .sort-button {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 5px;
width: fit-content;
padding: 6px 7px;
border: 0;
border-radius: 7px;
color: inherit;
background: transparent;
font: inherit;
cursor: pointer;
transition: color 150ms ease, background 150ms ease;
}
.list-header .sort-button:hover,
.list-header .sort-button.active {
color: #d83f82;
background: #fff0f7;
}
.list-header .sort-button:focus-visible {
outline: 3px solid rgb(73 134 231 / 25%);
outline-offset: 1px;
}
.sort-indicator {
color: #a4adbb;
font-size: 12px;
}
.list-header .sort-button.active .sort-indicator {
color: currentcolor;
}
.card-entry { .card-entry {
min-width: 1260px;
border-bottom: 1px solid #edf0f5; border-bottom: 1px solid #edf0f5;
} }
@@ -915,11 +1027,49 @@
gap: 10px; gap: 10px;
} }
.active-summary-cell {
padding-right: 8px;
}
.active-value,
.active-effect {
color: #4d586d;
font-size: 13px;
font-weight: 750;
font-variant-numeric: tabular-nums;
}
.active-effect {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.level-cell { .level-cell {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
.header-level-control {
display: flex;
align-items: center;
gap: 9px;
}
.header-level-toggle {
flex: 0 0 auto;
}
.skill-level-label {
padding: 4px 9px;
border-radius: 999px;
color: #b43d72;
background: #fff0f7;
font-size: 12px;
font-weight: 800;
}
.mobile-label { .mobile-label {
display: none; display: none;
} }
@@ -958,12 +1108,6 @@
background: #fff; background: #fff;
} }
.inline-level-toggle {
position: relative;
z-index: 3;
pointer-events: auto;
}
.level-toggle button { .level-toggle button {
min-width: 50px; min-width: 50px;
padding: 3px 7px; padding: 3px 7px;
@@ -1395,13 +1539,13 @@
box-shadow: 0 14px 42px rgb(0 0 0 / 24%); box-shadow: 0 14px 42px rgb(0 0 0 / 24%);
} }
.select-all { .filter-action {
border-color: #343c4b; border-color: #343c4b;
color: #b5bdcb; color: #b5bdcb;
background: #1d2430; background: #1d2430;
} }
.select-all:hover:not(:disabled) { .filter-action:hover:not(:disabled) {
border-color: #c85b8b; border-color: #c85b8b;
color: #f08db7; color: #f08db7;
background: #2a1c27; background: #2a1c27;
@@ -1442,6 +1586,22 @@
background: #131923; background: #131923;
} }
.list-header .sort-button:hover,
.list-header .sort-button.active {
color: #f08db7;
background: #2a1b26;
}
.active-value,
.active-effect {
color: #c2cad8;
}
.skill-level-label {
color: #f08db7;
background: #2a1b26;
}
.card-entry { .card-entry {
border-color: #282f3d; border-color: #282f3d;
} }
@@ -1574,7 +1734,7 @@
@media (max-width: 1100px) { @media (max-width: 1100px) {
.list-header, .list-header,
.card-summary { .card-summary {
grid-template-columns: 72px 84px 150px minmax(0, 1fr) 116px 36px; grid-template-columns: 62px 72px 110px minmax(160px, 1fr) 66px 66px 66px minmax(160px, 1.15fr) 190px 32px;
} }
} }
@@ -1625,12 +1785,14 @@
} }
.card-entry { .card-entry {
min-width: 0;
padding: 0; padding: 0;
} }
.card-summary { .card-summary {
display: grid; display: grid;
grid-template-columns: 72px 84px minmax(0, 1fr); grid-template-columns: repeat(3, minmax(0, 1fr));
min-width: 0;
gap: 14px 12px; gap: 14px 12px;
padding: 19px 0; padding: 19px 0;
padding-right: 32px; padding-right: 32px;
@@ -1661,6 +1823,20 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.active-summary-cell {
padding-right: 0;
}
.active-effect-cell {
grid-column: 1 / -1;
}
.active-effect {
overflow: visible;
text-overflow: clip;
white-space: normal;
}
.level-cell { .level-cell {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
File diff suppressed because it is too large Load Diff
+152 -54
View File
@@ -36,6 +36,7 @@
awakenings: number[]; awakenings: number[];
leaderCharacterId: string; leaderCharacterId: string;
leaderOutfitCardId: string; leaderOutfitCardId: string;
coreCubeFreeOnly: boolean;
}; };
type GreenNodeCost = { points: number; greenCubes: number; greenCoreCubes: number }; type GreenNodeCost = { points: number; greenCubes: number; greenCoreCubes: number };
type GreenNodeEfficiency = { type GreenNodeEfficiency = {
@@ -54,6 +55,15 @@
upgrades: GreenNodeEfficiency[]; upgrades: GreenNodeEfficiency[];
removals: GreenNodeEfficiency[]; removals: GreenNodeEfficiency[];
}; };
type AutomaticPlacementResult = {
memberId: string;
memberName: string;
socketId: string;
socketName: string;
cardId: string;
cardName: string;
rarity: number;
};
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2'; const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const EFFICIENCY_FORMATION_STORAGE_KEY = 'holodori.green-efficiency-formation.v1'; const EFFICIENCY_FORMATION_STORAGE_KEY = 'holodori.green-efficiency-formation.v1';
const GREEN_CUBE_ID = 'item-skill_tree_allmember-1'; const GREEN_CUBE_ID = 'item-skill_tree_allmember-1';
@@ -84,7 +94,10 @@
specialConditions: ['일반', '조건부'], specialConditions: ['일반', '조건부'],
activeProbabilities: ['높음', '중간'], activeProbabilities: ['높음', '중간'],
cardVariants: ['일반', ...new Set(fiveStarCards.map((card) => card.versionName).filter(Boolean))], cardVariants: ['일반', ...new Set(fiveStarCards.map((card) => card.versionName).filter(Boolean))],
generations: [...new Set(fiveStarCards.map((card) => card.generation).filter(Boolean))] generations: [...new Set(fiveStarCards.map((card) => card.generation).filter(Boolean))],
sortKey: null,
sortDirection: 'asc',
skillLevel: 1
}; };
} }
@@ -100,12 +113,14 @@
let selectedSocketGroupId = ''; let selectedSocketGroupId = '';
let boardCardSearch = ''; let boardCardSearch = '';
let automaticPlacementSnapshot: BoardCardPlacements | null = null; let automaticPlacementSnapshot: BoardCardPlacements | null = null;
let automaticPlacementResult: AutomaticPlacementResult[] = [];
let savedAnalysis: SavedAnalysisState | null = null; let savedAnalysis: SavedAnalysisState | null = null;
let efficiencyFormation: EfficiencyFormationState = { let efficiencyFormation: EfficiencyFormationState = {
cardIds: ['', '', '', '', ''], cardIds: ['', '', '', '', ''],
awakenings: [0, 0, 0, 0, 0], awakenings: [0, 0, 0, 0, 0],
leaderCharacterId: '', leaderCharacterId: '',
leaderOutfitCardId: '' leaderOutfitCardId: '',
coreCubeFreeOnly: false
}; };
let greenEfficiencyResult: GreenEfficiencyResult | null = null; let greenEfficiencyResult: GreenEfficiencyResult | null = null;
let greenEfficiencyRunning = false; let greenEfficiencyRunning = false;
@@ -121,7 +136,10 @@
$: filteredFourStarCards = filteredCards.filter((card) => card.rarity === 4); $: filteredFourStarCards = filteredCards.filter((card) => card.rarity === 4);
$: selectedBoardMember = boardMembers.find((member) => member.id === selectedMemberId) ?? null; $: selectedBoardMember = boardMembers.find((member) => member.id === selectedMemberId) ?? null;
$: selectedActiveBoardNodes = userData.memberBoards[selectedMemberId] ?? []; $: selectedActiveBoardNodes = userData.memberBoards[selectedMemberId] ?? [];
$: selectedActiveBoardNodeCount = new Set([centerNodeId(), ...selectedActiveBoardNodes].filter(Boolean)).size; $: selectedActiveBoardNodeCount = new Set(selectedActiveBoardNodes.filter((nodeId) => nodeId !== centerNodeId())).size;
$: selectedBoardNodeCount = selectedBoardMember
? boardModels[selectedBoardMember.positionGroupId].filter((node) => node.groupId !== centerNodeId()).length
: 0;
$: selectedBoardEdges = selectedBoardMember ? boardEdges(boardModels[selectedBoardMember.positionGroupId]) : []; $: selectedBoardEdges = selectedBoardMember ? boardEdges(boardModels[selectedBoardMember.positionGroupId]) : [];
$: selectedBoardCardPlacements = userData.boardCards[selectedMemberId] ?? {}; $: selectedBoardCardPlacements = userData.boardCards[selectedMemberId] ?? {};
$: selectedConnectionBoosts = connectionBoostsForMember( $: selectedConnectionBoosts = connectionBoostsForMember(
@@ -139,7 +157,8 @@
: []; : [];
$: efficiencyFormationReady = efficiencyFormation.cardIds.length === 5 $: efficiencyFormationReady = efficiencyFormation.cardIds.length === 5
&& efficiencyFormation.cardIds.every((id) => cardById.has(id)) && efficiencyFormation.cardIds.every((id) => cardById.has(id))
&& new Set(efficiencyFormation.cardIds.map((id) => cardById.get(id)?.characterId)).size === 5; && new Set(efficiencyFormation.cardIds.map((id) => cardById.get(id)?.characterId)).size === 5
&& efficiencyFormation.cardIds.some((id) => cardById.get(id)?.characterId === efficiencyFormation.leaderCharacterId);
$: dataView = $page.url.pathname.endsWith('/cards') ? 'cards' : $page.url.pathname.endsWith('/boards') ? 'boards' : 'home'; $: dataView = $page.url.pathname.endsWith('/cards') ? 'cards' : $page.url.pathname.endsWith('/boards') ? 'boards' : 'home';
onMount(() => { onMount(() => {
try { try {
@@ -163,7 +182,8 @@
cardIds: stored.cardIds.map((id) => cardById.has(id) ? id : ''), cardIds: stored.cardIds.map((id) => cardById.has(id) ? id : ''),
awakenings: stored.awakenings.map(normalizeEfficiencyAwakening), awakenings: stored.awakenings.map(normalizeEfficiencyAwakening),
leaderCharacterId: leaderMembers.some((member) => member.characterId === stored.leaderCharacterId) ? stored.leaderCharacterId : '', leaderCharacterId: leaderMembers.some((member) => member.characterId === stored.leaderCharacterId) ? stored.leaderCharacterId : '',
leaderOutfitCardId: cardById.get(stored.leaderOutfitCardId)?.details.costume ? stored.leaderOutfitCardId : '' leaderOutfitCardId: cardById.get(stored.leaderOutfitCardId)?.details.costume ? stored.leaderOutfitCardId : '',
coreCubeFreeOnly: stored.coreCubeFreeOnly === true
}; };
} }
} catch { } catch {
@@ -192,10 +212,6 @@
greenEfficiencyError = ''; greenEfficiencyError = '';
} }
function efficiencyMemberSelectedInAnotherSlot(card: CardSummary, slot: number): boolean {
return efficiencyFormation.cardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId);
}
function updateEfficiencyCard(slot: number, cardId: string) { function updateEfficiencyCard(slot: number, cardId: string) {
const cardIds = efficiencyFormation.cardIds.map((id, index) => index === slot ? cardId : id); const cardIds = efficiencyFormation.cardIds.map((id, index) => index === slot ? cardId : id);
const awakenings = efficiencyFormation.awakenings.map((value, index) => index === slot const awakenings = efficiencyFormation.awakenings.map((value, index) => index === slot
@@ -222,13 +238,19 @@
saveEfficiencyFormation({ ...efficiencyFormation, leaderOutfitCardId: cardId }); saveEfficiencyFormation({ ...efficiencyFormation, leaderOutfitCardId: cardId });
} }
function updateCoreCubeFreeOnly(coreCubeFreeOnly: boolean) {
saveEfficiencyFormation({ ...efficiencyFormation, coreCubeFreeOnly });
}
function importAnalysisFormation() { function importAnalysisFormation() {
if (!savedAnalysis) return; if (!savedAnalysis) return;
const analysis = savedAnalysis;
saveEfficiencyFormation({ saveEfficiencyFormation({
cardIds: [...savedAnalysis.cardIds], cardIds: [...analysis.cardIds],
awakenings: savedAnalysis.awakenings.map(normalizeEfficiencyAwakening), awakenings: analysis.awakenings.map(normalizeEfficiencyAwakening),
leaderCharacterId: savedAnalysis.leaderCharacterId, leaderCharacterId: analysis.leaderCharacterId,
leaderOutfitCardId: savedAnalysis.leaderOutfitCardId leaderOutfitCardId: analysis.leaderOutfitCardId,
coreCubeFreeOnly: efficiencyFormation.coreCubeFreeOnly
}); });
} }
@@ -302,6 +324,35 @@
if (boardViewport.hasPointerCapture(event.pointerId)) boardViewport.releasePointerCapture(event.pointerId); if (boardViewport.hasPointerCapture(event.pointerId)) boardViewport.releasePointerCapture(event.pointerId);
} }
function finishBoardMemberChange() {
selectedSocketGroupId = '';
efficiencyFocusGroupId = '';
boardDragging = false;
void fitBoardViewport();
}
function selectAdjacentBoardMember(offset: -1 | 1) {
if (boardMembers.length < 2) return;
const currentIndex = boardMembers.findIndex((member) => member.id === selectedMemberId);
const nextIndex = (Math.max(0, currentIndex) + offset + boardMembers.length) % boardMembers.length;
selectedMemberId = boardMembers[nextIndex].id;
finishBoardMemberChange();
}
function isKeyboardInputTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return Boolean(target.closest('input, textarea, select, [contenteditable="true"], [contenteditable="plaintext-only"]'));
}
function handleBoardKeyboardNavigation(event: KeyboardEvent) {
if (dataView !== 'boards' || event.defaultPrevented || event.repeat || event.isComposing
|| event.ctrlKey || event.metaKey || event.altKey || isKeyboardInputTarget(event.target)) return;
const key = event.key.toLowerCase();
if (key !== 'a' && key !== 'd') return;
event.preventDefault();
selectAdjacentBoardMember(key === 'a' ? -1 : 1);
}
function persist(next = userData) { function persist(next = userData) {
userData = saveBrowserData(next); userData = saveBrowserData(next);
greenEfficiencyResult = null; greenEfficiencyResult = null;
@@ -523,7 +574,7 @@
}, { points: 0, greenCubes: 0, greenCoreCubes: 0 }); }, { points: 0, greenCubes: 0, greenCoreCubes: 0 });
} }
function currentFormationPower(memberBoards: Record<string, string[]>): number { function currentFormationPower(memberBoards: Record<string, string[]>, boardCards: BoardCardPlacements = userData.boardCards): number {
if (!efficiencyFormationReady) return 0; if (!efficiencyFormationReady) return 0;
const members = efficiencyFormation.cardIds.map((id, index) => ({ const members = efficiencyFormation.cardIds.map((id, index) => ({
card: cardById.get(id)!, card: cardById.get(id)!,
@@ -537,7 +588,7 @@
connect: card.details.connect connect: card.details.connect
})); }));
const boardEffects = calculateBoardEffects(boardSummary, memberBoards, sourceCards, efficiencyFormation.leaderCharacterId, null, { const boardEffects = calculateBoardEffects(boardSummary, memberBoards, sourceCards, efficiencyFormation.leaderCharacterId, null, {
placements: userData.boardCards, placements: boardCards,
cards: connections cards: connections
}); });
const leaderOutfit = cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume ?? null; const leaderOutfit = cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume ?? null;
@@ -579,15 +630,6 @@
|| right.cost.greenCubes - left.cost.greenCubes; || right.cost.greenCubes - left.cost.greenCubes;
} }
function upgradeBeatsRemoval(upgrade: GreenNodeEfficiency, removal: GreenNodeEfficiency): boolean {
const cubeDifference = upgrade.greenCubeEfficiency - removal.greenCubeEfficiency;
if (Math.abs(cubeDifference) > 1e-9) return cubeDifference > 0;
if (upgrade.cost.greenCoreCubes === 0 && removal.cost.greenCoreCubes > 0) return true;
if (upgrade.cost.greenCoreCubes > 0 && removal.cost.greenCoreCubes === 0) return false;
if (upgrade.cost.greenCoreCubes === 0 && removal.cost.greenCoreCubes === 0) return false;
return upgrade.greenCoreCubeEfficiency > removal.greenCoreCubeEfficiency;
}
async function analyzeGreenNodeEfficiency() { async function analyzeGreenNodeEfficiency() {
if (!efficiencyFormationReady) { if (!efficiencyFormationReady) {
greenEfficiencyError = '서로 다른 멤버의 5성 카드 5장을 모두 선택해 주세요.'; greenEfficiencyError = '서로 다른 멤버의 5성 카드 5장을 모두 선택해 주세요.';
@@ -625,6 +667,7 @@
const path = shortestPathToSet(position.groupId, active, positions); const path = shortestPathToSet(position.groupId, active, positions);
const added = path.filter((groupId) => groupId !== center && !active.has(groupId)); const added = path.filter((groupId) => groupId !== center && !active.has(groupId));
if (added.length === 0) continue; if (added.length === 0) continue;
if (efficiencyFormation.coreCubeFreeOnly && greenNodeCost(member.id, added).greenCoreCubes > 0) continue;
const nextBoards = { ...userData.memberBoards, [member.id]: [...new Set([...currentNodes, ...added])] }; const nextBoards = { ...userData.memberBoards, [member.id]: [...new Set([...currentNodes, ...added])] };
const nextPower = currentFormationPower(nextBoards); const nextPower = currentFormationPower(nextBoards);
const intermediateNodes = added.filter((groupId) => groupId !== position.groupId); const intermediateNodes = added.filter((groupId) => groupId !== position.groupId);
@@ -643,6 +686,7 @@
const reachable = reachableNodesForPositions(remaining, positions, center); const reachable = reachableNodesForPositions(remaining, positions, center);
const removed = currentNodes.filter((groupId) => !reachable.has(groupId)); const removed = currentNodes.filter((groupId) => !reachable.has(groupId));
if (removed.length === 0) continue; if (removed.length === 0) continue;
if (efficiencyFormation.coreCubeFreeOnly && greenNodeCost(member.id, removed).greenCoreCubes > 0) continue;
const nextMemberNodes = currentNodes.filter((groupId) => reachable.has(groupId)); const nextMemberNodes = currentNodes.filter((groupId) => reachable.has(groupId));
const nextBoards = { ...userData.memberBoards }; const nextBoards = { ...userData.memberBoards };
if (nextMemberNodes.length > 0) nextBoards[member.id] = nextMemberNodes; if (nextMemberNodes.length > 0) nextBoards[member.id] = nextMemberNodes;
@@ -653,11 +697,9 @@
} }
const sortedRemovals = removals.sort(compareRemovalEfficiency); const sortedRemovals = removals.sort(compareRemovalEfficiency);
const removalThreshold = sortedRemovals[0];
greenEfficiencyResult = { greenEfficiencyResult = {
basePower, basePower,
upgrades: upgrades.sort(compareUpgradeEfficiency) upgrades: upgrades.sort(compareUpgradeEfficiency),
.filter((upgrade) => !removalThreshold || upgradeBeatsRemoval(upgrade, removalThreshold)),
removals: sortedRemovals removals: sortedRemovals
}; };
} catch (error) { } catch (error) {
@@ -769,41 +811,46 @@
if (!socket || userData.boardCards[member.id]?.[socket.groupId]) return []; if (!socket || userData.boardCards[member.id]?.[socket.groupId]) return [];
return [{ member, socket }]; return [{ member, socket }];
}); });
if (candidateCards.length === 0) { if (candidateCards.length === 0) {
showMessage('자동 배치에 사용할 수 있는 미배치 보유 카드가 없습니다.', true); showMessage('자동 배치에 사용할 수 있는 미배치 보유 카드가 없습니다.', true);
return; return;
} }
if (emptyGreenSlots.length === 0) { if (emptyGreenSlots.length === 0) {
showMessage('비어 있는 초록색 카드 슬롯이 없습니다. 기존 배치는 변경하지 않았습니다.'); showMessage('비어 있는 중앙 연결 노드가 없습니다. 기존 배치는 변경하지 않았습니다.');
return; return;
} }
const matches = maximumWeightMatching(candidateCards.map((card) =>
const weights = candidateCards.map((card) => emptyGreenSlots.map(({ member, socket }) => greenSlotScore(member, socket, card))); emptyGreenSlots.map(({ member, socket }) => greenSlotScore(member, socket, card))
const matches = maximumWeightMatching(weights); ));
if (matches.length === 0) { if (matches.length === 0) {
showMessage('활성화된 초록 노드에 효과를 주는 배치 조합을 찾지 못했습니다.', true); showMessage('활성화된 초록 노드에 효과를 주는 중앙 배치를 찾지 못했습니다.', true);
return; return;
} }
automaticPlacementSnapshot = cloneBoardCards(userData.boardCards); automaticPlacementSnapshot = cloneBoardCards(userData.boardCards);
const boardCards = cloneBoardCards(userData.boardCards); const boardCards = cloneBoardCards(userData.boardCards);
for (const match of matches) { automaticPlacementResult = matches.map((match) => {
const card = candidateCards[match.rowIndex]; const card = candidateCards[match.rowIndex];
const slot = emptyGreenSlots[match.columnIndex]; const { member, socket } = emptyGreenSlots[match.columnIndex];
if (!card || !slot) continue; boardCards[member.id] = { ...(boardCards[member.id] ?? {}), [socket.groupId]: card.id };
boardCards[slot.member.id] = { ...(boardCards[slot.member.id] ?? {}), [slot.socket.groupId]: card.id }; return {
} memberId: member.id, memberName: member.name, socketId: socket.groupId, socketName: '중앙',
cardId: card.id, cardName: cardLabel(card), rarity: card.rarity
};
}).sort((left, right) =>
(boardMembers.find((member) => member.id === left.memberId)?.order ?? Number.MAX_SAFE_INTEGER)
- (boardMembers.find((member) => member.id === right.memberId)?.order ?? Number.MAX_SAFE_INTEGER)
);
persist({ ...userData, boardCards }); persist({ ...userData, boardCards });
showMessage(`기존 배치를 유지하고 초록색 빈 슬롯 ${matches.length}곳에 보유 카드를 최적 배치했습니다.`); showMessage(`기존 배치를 유지하고 중앙 연결 노드 ${matches.length}곳에 보유 카드를 자동 배치했습니다.`);
} }
function undoAutomaticPlacement() { function undoAutomaticPlacement() {
if (!automaticPlacementSnapshot) return; if (!automaticPlacementSnapshot) return;
const boardCards = cloneBoardCards(automaticPlacementSnapshot); const boardCards = cloneBoardCards(automaticPlacementSnapshot);
automaticPlacementSnapshot = null; automaticPlacementSnapshot = null;
automaticPlacementResult = [];
persist({ ...userData, boardCards }); persist({ ...userData, boardCards });
showMessage('방금 실행한 초록색 카드 자동 배치를 취소했습니다.'); showMessage('방금 실행한 중앙 연결 노드 자동 배치를 취소했습니다.');
} }
function cardLabel(card: CardSummary): string { function cardLabel(card: CardSummary): string {
@@ -813,7 +860,7 @@
function placedCardName(cardId: string | undefined): string { function placedCardName(cardId: string | undefined): string {
if (!cardId) return '연결 노드'; if (!cardId) return '연결 노드';
const card = connectCardById.get(cardId); const card = connectCardById.get(cardId);
return card ? cardLabel(card) : cardId; return card ? `${card.rarity}★ ${cardLabel(card)}` : cardId;
} }
function connectionBoostsForMember( function connectionBoostsForMember(
@@ -855,6 +902,7 @@
function assignBoardCard(cardId: string) { function assignBoardCard(cardId: string) {
if (!selectedSocketGroupId) return; if (!selectedSocketGroupId) return;
automaticPlacementSnapshot = null; automaticPlacementSnapshot = null;
automaticPlacementResult = [];
const boardCards = Object.fromEntries(Object.entries(userData.boardCards).map(([memberId, placements]) => [memberId, { ...placements }])); const boardCards = Object.fromEntries(Object.entries(userData.boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
for (const [memberId, placements] of Object.entries(boardCards)) { for (const [memberId, placements] of Object.entries(boardCards)) {
for (const [socketId, placedCardId] of Object.entries(placements)) { for (const [socketId, placedCardId] of Object.entries(placements)) {
@@ -870,6 +918,7 @@
function removeBoardCard() { function removeBoardCard() {
if (!selectedSocketGroupId) return; if (!selectedSocketGroupId) return;
automaticPlacementSnapshot = null; automaticPlacementSnapshot = null;
automaticPlacementResult = [];
const placements = { ...(userData.boardCards[selectedMemberId] ?? {}) }; const placements = { ...(userData.boardCards[selectedMemberId] ?? {}) };
delete placements[selectedSocketGroupId]; delete placements[selectedSocketGroupId];
const boardCards = { ...userData.boardCards }; const boardCards = { ...userData.boardCards };
@@ -934,6 +983,8 @@
} }
</script> </script>
<svelte:window on:keydown={handleBoardKeyboardNavigation} />
<svelte:head> <svelte:head>
<title>사용자 데이터 | 홀로도리 계산기</title> <title>사용자 데이터 | 홀로도리 계산기</title>
<meta name="description" content="홀로도리 사용자 데이터와 백업을 관리합니다." /> <meta name="description" content="홀로도리 사용자 데이터와 백업을 관리합니다." />
@@ -1104,13 +1155,26 @@
<div class="automatic-placement"> <div class="automatic-placement">
<div> <div>
<strong>간편 설정</strong> <strong>간편 설정</strong>
<small>모든 멤버의 기본 초록 노드를 활성화하거나, 보유 카드를 중앙 연결 노드의 빈 슬롯에 자동 배치합니다.</small> <small>모든 멤버의 기본 초록 노드를 활성화하거나, 기존 배치를 유지한 채 중앙 연결 노드의 빈 슬롯에 보유 카드를 자동 배치합니다.</small>
</div> </div>
<div class="automatic-placement-actions"> <div class="automatic-placement-actions">
<button type="button" on:click={activateCommonGreenNodesForAllMembers}>모든 멤버 초록 3칸 활성화</button> <button type="button" on:click={activateCommonGreenNodesForAllMembers}>모든 멤버 초록 3칸 활성화</button>
{#if automaticPlacementSnapshot}<button type="button" class="undo" on:click={undoAutomaticPlacement}>방금 배치 취소</button>{/if} {#if automaticPlacementSnapshot}<button type="button" class="undo" on:click={undoAutomaticPlacement}>방금 배치 취소</button>{/if}
<button type="button" class="primary" on:click={optimizeEmptyGreenSlots}>중앙 연결 노드 자동 배치</button> <button type="button" class="primary" on:click={optimizeEmptyGreenSlots}>중앙 연결 노드 자동 배치</button>
</div> </div>
{#if automaticPlacementResult.length > 0}
<div class="automatic-placement-result" aria-live="polite">
<strong>자동 배치 결과 <span>{automaticPlacementResult.length}건</span></strong>
<div class="automatic-placement-result-list">
{#each automaticPlacementResult as placement (placement.memberId)}
<div class="automatic-placement-result-item">
<span>{placement.memberName} 보드</span>
<b><i>{placement.rarity}★</i>{placement.cardName}</b>
</div>
{/each}
</div>
</div>
{/if}
</div> </div>
</section> </section>
<details class="green-efficiency"> <details class="green-efficiency">
@@ -1143,7 +1207,7 @@
<select value={selectedId} on:change={(event) => updateEfficiencyCard(slot, event.currentTarget.value)}> <select value={selectedId} on:change={(event) => updateEfficiencyCard(slot, event.currentTarget.value)}>
<option value="">선택 안 함</option> <option value="">선택 안 함</option>
{#each fiveStarCards as card} {#each fiveStarCards as card}
<option value={card.id} disabled={efficiencyMemberSelectedInAnotherSlot(card, slot)}>{userData.collection[card.id]?.owned ? '● ' : ''}{cardLabel(card)}</option> <option value={card.id} disabled={efficiencyFormation.cardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId)}>{userData.collection[card.id]?.owned ? '● ' : ''}{cardLabel(card)}</option>
{/each} {/each}
</select> </select>
</label> </label>
@@ -1173,6 +1237,15 @@
</label> </label>
</div> </div>
<div class="efficiency-outfit-effect"><span>리더 의상 효과</span><p>{plainSkillText(cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume?.description ?? '') || '적용하지 않음'}</p></div> <div class="efficiency-outfit-effect"><span>리더 의상 효과</span><p>{plainSkillText(cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume?.description ?? '') || '적용하지 않음'}</p></div>
<label class="efficiency-core-filter">
<input
type="checkbox"
checked={efficiencyFormation.coreCubeFreeOnly}
disabled={greenEfficiencyRunning}
on:change={(event) => updateCoreCubeFreeOnly(event.currentTarget.checked)}
/>
<span><strong>코어 큐브 미소모만</strong><small>활성화 또는 초기화 경로 전체에서 그린 코어 큐브를 소모하지 않는 결과만 찾습니다.</small></span>
</label>
</div> </div>
{#if greenEfficiencyError}<p class="efficiency-error" role="alert">{greenEfficiencyError}</p>{/if} {#if greenEfficiencyError}<p class="efficiency-error" role="alert">{greenEfficiencyError}</p>{/if}
{#if greenEfficiencyResult} {#if greenEfficiencyResult}
@@ -1185,7 +1258,7 @@
<div class="efficiency-columns"> <div class="efficiency-columns">
<section class="efficiency-column upgrade" aria-labelledby="upgrade-efficiency-title"> <section class="efficiency-column upgrade" aria-labelledby="upgrade-efficiency-title">
<div class="efficiency-column-heading"> <div class="efficiency-column-heading">
<div><h3 id="upgrade-efficiency-title">우선 활성화</h3><p>초기화 검토 1순위보다 효율이 높은 노드만 표시</p></div> <div><h3 id="upgrade-efficiency-title">우선 활성화</h3><p>초기화 효율보다 낮은 후보도 포함해 효율순으로 최대 20개 표시</p></div>
</div> </div>
{#if greenEfficiencyResult.upgrades.length > 0} {#if greenEfficiencyResult.upgrades.length > 0}
<div class="efficiency-list"> <div class="efficiency-list">
@@ -1201,7 +1274,7 @@
</button> </button>
{/each} {/each}
</div> </div>
{:else}<p class="efficiency-empty">종합력을 올리는 비활성 초록 노드가 없습니다.</p>{/if} {:else}<p class="efficiency-empty">{efficiencyFormation.coreCubeFreeOnly ? '코어 큐브를 소모하지 않고 종합력을 올리는 비활성 초록 노드가 없습니다.' : '종합력을 올리는 비활성 초록 노드가 없습니다.'}</p>{/if}
</section> </section>
<section class="efficiency-column removal" aria-labelledby="removal-efficiency-title"> <section class="efficiency-column removal" aria-labelledby="removal-efficiency-title">
<div class="efficiency-column-heading"> <div class="efficiency-column-heading">
@@ -1221,7 +1294,7 @@
</button> </button>
{/each} {/each}
</div> </div>
{:else}<p class="efficiency-empty">초기화를 검토할 활성 초록 노드가 없습니다.</p>{/if} {:else}<p class="efficiency-empty">{efficiencyFormation.coreCubeFreeOnly ? '코어 큐브를 환급하지 않는 활성 초록 노드가 없습니다.' : '초기화를 검토할 활성 초록 노드가 없습니다.'}</p>{/if}
</section> </section>
</div> </div>
{/if} {/if}
@@ -1230,15 +1303,16 @@
<div class="board-heading"> <div class="board-heading">
<label class="member-select"> <label class="member-select">
<span>멤버 선택</span> <span>멤버 선택</span>
<select bind:value={selectedMemberId} on:change={() => { selectedSocketGroupId = ''; efficiencyFocusGroupId = ''; fitBoardViewport(); }}> <select bind:value={selectedMemberId} on:change={finishBoardMemberChange}>
{#each boardMembers as member}<option value={member.id}>{member.name}</option>{/each} {#each boardMembers as member}<option value={member.id}>{member.name}</option>{/each}
</select> </select>
<small><kbd>A</kbd> 이전 멤버 · <kbd>D</kbd> 다음 멤버</small>
</label> </label>
</div> </div>
{#if selectedBoardMember} {#if selectedBoardMember}
<div class="board-toolbar"> <div class="board-toolbar">
<p><strong>{selectedActiveBoardNodeCount}</strong> / {boardModels[selectedBoardMember.positionGroupId].length}개 활성화</p> <p><strong>{selectedActiveBoardNodeCount}</strong> / {selectedBoardNodeCount}개 활성화</p>
<div><button type="button" on:click={activateAllBlueNodes}>모든 파란 노드 활성화</button><button type="button" on:click={() => setAllBoardNodes(true)}>모두 활성화</button><button type="button" on:click={() => setAllBoardNodes(false)}>모두 해제</button></div> <div><button type="button" on:click={activateAllBlueNodes}>모든 파란 노드 활성화</button><button type="button" on:click={() => setAllBoardNodes(true)}>모두 활성화</button><button type="button" on:click={() => setAllBoardNodes(false)}>모두 해제</button></div>
</div> </div>
<div <div
@@ -1280,7 +1354,7 @@
class:efficiency-focus={position.groupId === efficiencyFocusGroupId} class:efficiency-focus={position.groupId === efficiencyFocusGroupId}
style={`left: ${point.x}px; top: ${point.y}px; width: ${size}px; height: ${size}px;`} style={`left: ${point.x}px; top: ${point.y}px; width: ${size}px; height: ${size}px;`}
title={`${boardNodeLabel(node.type)} · ${boardEffectDescription(node.effect, selectedBoardMember.name) || placedCardName(selectedBoardCardPlacements[position.groupId])}${selectedConnectionBoosts[position.groupId] ? ` · ${active ? '연결 효과 적용' : '연결 범위'} +${selectedConnectionBoosts[position.groupId].percent}%` : ''}`} title={`${boardNodeLabel(node.type)} · ${boardEffectDescription(node.effect, selectedBoardMember.name) || placedCardName(selectedBoardCardPlacements[position.groupId])}${selectedConnectionBoosts[position.groupId] ? ` · ${active ? '연결 효과 적용' : '연결 범위'} +${selectedConnectionBoosts[position.groupId].percent}%` : ''}`}
aria-label={`${boardNodeLabel(node.type)} ${node.grade}등급 노드, ${active ? '활성화됨' : '비활성화됨'}${selectedConnectionBoosts[position.groupId] ? `, 연결 ${active ? '효과 적용 중' : '범위에 포함'}` : ''}`} aria-label={`${boardNodeLabel(node.type)} ${node.grade}등급 노드, ${active ? '활성화됨' : '비활성화됨'}${node.type === 'connection' && selectedBoardCardPlacements[position.groupId] ? `, 배치 카드 ${placedCardName(selectedBoardCardPlacements[position.groupId])}` : ''}${selectedConnectionBoosts[position.groupId] ? `, 연결 ${active ? '효과 적용 중' : '범위에 포함'}` : ''}`}
aria-pressed={active} aria-pressed={active}
on:click={() => handleBoardNodeClick(position.groupId, node)} on:click={() => handleBoardNodeClick(position.groupId, node)}
> >
@@ -1293,7 +1367,12 @@
{#if boardEffectIcon(node)}<img class="board-node-icon" src={boardEffectIcon(node) ?? ''} alt="" draggable="false" />{/if} {#if boardEffectIcon(node)}<img class="board-node-icon" src={boardEffectIcon(node) ?? ''} alt="" draggable="false" />{/if}
{#if node.type === 'connection' && selectedBoardCardPlacements[position.groupId]} {#if node.type === 'connection' && selectedBoardCardPlacements[position.groupId]}
{@const placedCard = connectCardById.get(selectedBoardCardPlacements[position.groupId])} {@const placedCard = connectCardById.get(selectedBoardCardPlacements[position.groupId])}
{#if placedCard}<span class="socket-card" aria-hidden="true">{placedCard.name.slice(0, 2)}</span>{/if} {#if placedCard}
<span class="socket-card" aria-hidden="true">
<span class="socket-card-name">{placedCard.name.slice(0, 2)}</span>
<small class:rarity-five={placedCard.rarity === 5}>{placedCard.rarity}★</small>
</span>
{/if}
{/if} {/if}
</button> </button>
{/if} {/if}
@@ -1452,12 +1531,14 @@
.member-select { display: grid; gap: 5px; width: min(360px, 100%); } .member-select { display: grid; gap: 5px; width: min(360px, 100%); }
.member-select span { color: #818b9c; font-size: 12px; font-weight: 750; } .member-select span { color: #818b9c; font-size: 12px; font-weight: 750; }
.member-select select { height: 38px; padding: 0 10px; border: 1px solid #394252; border-radius: 8px; color: #dfe3ea; background: #12171f; } .member-select select { height: 38px; padding: 0 10px; border: 1px solid #394252; border-radius: 8px; color: #dfe3ea; background: #12171f; }
.member-select small { color: #6f7888; font-size: 11px; }
.member-select kbd { display: inline-grid; min-width: 18px; height: 18px; padding: 0 4px; place-items: center; border: 1px solid #465064; border-bottom-width: 2px; border-radius: 4px; color: #c9d0dc; background: #171d27; font: 700 10px/1 Pretendard, "Noto Sans KR", sans-serif; }
.board-toolbar { display: flex; align-items: center; justify-content: space-between; margin: 20px 0 10px; } .board-toolbar { display: flex; align-items: center; justify-content: space-between; margin: 20px 0 10px; }
.board-toolbar p { margin: 0; color: #929cad; font-size: 14px; } .board-toolbar p { margin: 0; color: #929cad; font-size: 14px; }
.board-toolbar p strong { color: #f0a1c2; font-size: 16px; } .board-toolbar p strong { color: #f0a1c2; font-size: 16px; }
.board-toolbar div { display: flex; gap: 6px; } .board-toolbar div { display: flex; gap: 6px; }
.board-toolbar button { padding: 6px 9px; border: 1px solid #394252; border-radius: 7px; color: #aeb7c5; background: #202630; font-size: 12px; font-weight: 750; cursor: pointer; } .board-toolbar button { padding: 6px 9px; border: 1px solid #394252; border-radius: 7px; color: #aeb7c5; background: #202630; font-size: 12px; font-weight: 750; cursor: pointer; }
.automatic-placement { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid #355044; border-radius: 10px; background: rgb(58 113 83 / 10%); } .automatic-placement { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid #355044; border-radius: 10px; background: rgb(58 113 83 / 10%); }
.automatic-placement > div:first-child { display: grid; gap: 4px; min-width: 0; } .automatic-placement > div:first-child { display: grid; gap: 4px; min-width: 0; }
.automatic-placement strong { color: #b7dec7; font-size: 14px; } .automatic-placement strong { color: #b7dec7; font-size: 14px; }
.automatic-placement small { color: #82988c; font-size: 12px; line-height: 1.45; } .automatic-placement small { color: #82988c; font-size: 12px; line-height: 1.45; }
@@ -1467,7 +1548,15 @@
.automatic-placement button.undo { color: #c4ccd5; background: transparent; } .automatic-placement button.undo { color: #c4ccd5; background: transparent; }
.automatic-placement button:hover { filter: brightness(1.1); } .automatic-placement button:hover { filter: brightness(1.1); }
.automatic-placement button:focus-visible { outline: 3px solid rgb(120 196 147 / 25%); outline-offset: 2px; } .automatic-placement button:focus-visible { outline: 3px solid rgb(120 196 147 / 25%); outline-offset: 2px; }
.green-efficiency { order: 2; margin: 18px 0 0; padding: 0 16px 16px; border: 1px solid #394453; border-radius: 16px; background: #151b23; } .automatic-placement-result { display: grid; flex: 1 0 100%; gap: 9px; padding-top: 12px; border-top: 1px solid rgb(120 196 147 / 18%); }
.automatic-placement-result > strong { display: flex; align-items: center; gap: 7px; color: #d8e9df; font-size: 14px; }
.automatic-placement-result > strong span { color: #78c493; font-size: 12px; }
.automatic-placement-result-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 6px 10px; max-height: 280px; overflow-y: auto; padding-right: 4px; }
.automatic-placement-result-item { display: grid; grid-template-columns: minmax(105px, 0.8fr) minmax(150px, 1.2fr); align-items: center; gap: 10px; min-height: 38px; padding: 7px 10px; border: 1px solid rgb(120 196 147 / 13%); border-radius: 7px; background: rgb(9 18 13 / 34%); }
.automatic-placement-result-item span { color: #91a59a; font-size: 12px; }
.automatic-placement-result-item b { display: flex; align-items: center; gap: 7px; min-width: 0; color: #dbe8df; font-size: 13px; }
.automatic-placement-result-item i { flex: 0 0 auto; color: #e7bd70; font-size: 11px; font-style: normal; }
.green-efficiency { order: 3; margin: 18px 0 0; padding: 0 16px 16px; border: 1px solid #394453; border-radius: 16px; background: #151b23; }
.green-efficiency > summary { display: flex; align-items: center; min-height: 62px; margin: 0 -16px; padding: 12px 16px; color: #dce6e0; cursor: pointer; list-style: none; } .green-efficiency > summary { display: flex; align-items: center; min-height: 62px; margin: 0 -16px; padding: 12px 16px; color: #dce6e0; cursor: pointer; list-style: none; }
.green-efficiency > summary::-webkit-details-marker { display: none; } .green-efficiency > summary::-webkit-details-marker { display: none; }
.green-efficiency > summary:hover { background: rgb(255 255 255 / 3%); } .green-efficiency > summary:hover { background: rgb(255 255 255 / 3%); }
@@ -1503,6 +1592,12 @@
.efficiency-outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 10px; margin-top: 9px; padding: 9px 10px; border-radius: 8px; background: #171e26; } .efficiency-outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 10px; margin-top: 9px; padding: 9px 10px; border-radius: 8px; background: #171e26; }
.efficiency-outfit-effect span { color: #d990af; font-size: 13px; font-weight: 850; } .efficiency-outfit-effect span { color: #d990af; font-size: 13px; font-weight: 850; }
.efficiency-outfit-effect p { margin: 0; color: #a7b0bd; font-size: 13px; line-height: 1.5; } .efficiency-outfit-effect p { margin: 0; color: #a7b0bd; font-size: 13px; line-height: 1.5; }
.efficiency-core-filter { display: flex; align-items: center; gap: 10px; margin-top: 9px; padding: 10px 11px; border: 1px solid #314238; border-radius: 8px; background: #111a15; cursor: pointer; }
.efficiency-core-filter input { width: 18px; height: 18px; margin: 0; accent-color: #78c493; cursor: pointer; }
.efficiency-core-filter input:disabled { cursor: default; }
.efficiency-core-filter > span { display: grid; gap: 2px; }
.efficiency-core-filter strong { color: #dbe5df; font-size: 14px; }
.efficiency-core-filter small { color: #8f9e96; font-size: 12px; line-height: 1.45; }
.efficiency-summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px 12px; margin-top: 14px; padding: 10px 12px; border-radius: 8px; background: #10151c; } .efficiency-summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px 12px; margin-top: 14px; padding: 10px 12px; border-radius: 8px; background: #10151c; }
.efficiency-summary span { color: #8b96a5; font-size: 13px; } .efficiency-summary span { color: #8b96a5; font-size: 13px; }
.efficiency-summary strong { margin-right: 10px; color: #d6dde7; font-size: 16px; } .efficiency-summary strong { margin-right: 10px; color: #d6dde7; font-size: 16px; }
@@ -1554,7 +1649,10 @@
.board-node.connect-applied::after { inset: -8px; border: 4px solid #fff; } .board-node.connect-applied::after { inset: -8px; border: 4px solid #fff; }
.board-node.connect-applied::before { content: '+'; position: absolute; z-index: 4; top: -13px; right: -13px; display: grid; place-items: center; width: 19px; height: 19px; border: 2px solid #151a22; border-radius: 50%; color: #111820; background: #fff; font-size: 13px; font-weight: 950; line-height: 1; pointer-events: none; } .board-node.connect-applied::before { content: '+'; position: absolute; z-index: 4; top: -13px; right: -13px; display: grid; place-items: center; width: 19px; height: 19px; border: 2px solid #151a22; border-radius: 50%; color: #111820; background: #fff; font-size: 13px; font-weight: 950; line-height: 1; pointer-events: none; }
.board-node.efficiency-focus { z-index: 6; outline: 4px solid #fff; outline-offset: 6px; border-radius: 50%; } .board-node.efficiency-focus { z-index: 6; outline: 4px solid #fff; outline-offset: 6px; border-radius: 50%; }
.socket-card { position: absolute; inset: 19%; z-index: 2; display: grid; place-items: center; overflow: hidden; border-radius: 50%; color: #fff; background: #3b2d46; font-size: 12px; font-weight: 900; line-height: 1; pointer-events: none; } .socket-card { position: absolute; inset: 19%; z-index: 2; pointer-events: none; }
.socket-card-name { display: grid; width: 100%; height: 100%; place-items: center; overflow: hidden; border-radius: 50%; color: #fff; background: #3b2d46; font-size: 12px; font-weight: 900; line-height: 1; }
.socket-card small { position: absolute; right: -12px; bottom: -8px; min-width: 24px; padding: 2px 4px; border: 2px solid #151a22; border-radius: 999px; color: #fff; background: #7562c9; font-size: 9px; font-weight: 950; line-height: 1.1; white-space: nowrap; }
.socket-card small.rarity-five { background: #d96f9d; }
.board-node:hover { z-index: 4; transform: translate(-50%, -50%) scale(1.1); } .board-node:hover { z-index: 4; transform: translate(-50%, -50%) scale(1.1); }
.board-node.center { cursor: default; } .board-node.center { cursor: default; }
.board-node:focus-visible { z-index: 5; outline: 3px solid rgb(255 255 255 / 55%); outline-offset: 3px; border-radius: 50%; } .board-node:focus-visible { z-index: 5; outline: 3px solid rgb(255 255 255 / 55%); outline-offset: 3px; border-radius: 50%; }
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { events } from '$lib/data/events.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { activeEventAt } from '$lib/engine/event-optimization.ts';
import CombinationPage from '../combination/+page.svelte';
let clock = createInitialReleaseClock($page.data?.serverNow);
let now = clock.now();
$: activeEvent = activeEventAt(events, now);
onMount(() => {
let mounted = true;
void synchronizeReleaseClock().then((nextClock) => {
if (!mounted) return;
clock = nextClock;
now = clock.now();
});
const timer = window.setInterval(() => now = clock.now(), 1_000);
return () => {
mounted = false;
window.clearInterval(timer);
};
});
</script>
<svelte:head><title>이벤트 Pt 최적화 · 홀로도리 계산기</title></svelte:head>
{#if activeEvent}
<CombinationPage eventMode />
{:else}
<main class="inactive-event">
<h1>진행 중인 이벤트가 없습니다</h1>
<p>포인트 획득 이벤트가 시작되면 이 메뉴와 최적화 기능이 자동으로 활성화됩니다.</p>
</main>
{/if}
<style>
.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; }
</style>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More