feat: improve analysis and release v1.0.2
This commit is contained in:
@@ -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
|
||||
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
|
||||
cardId,versionName,displayName
|
||||
card-06002-5-uniq-0066-00,수영복,
|
||||
```
|
||||
1. [최신 Release](https://github.com/DD4SN/HolodoriCalc/releases/latest)를 엽니다.
|
||||
2. `Assets`에서 `HolodoriCalc-offline-v버전.zip`을 내려받습니다.
|
||||
3. ZIP 파일을 원하는 폴더에 완전히 압축 해제합니다.
|
||||
4. 압축을 해제한 폴더의 `index.html`을 실행합니다.
|
||||
|
||||
- `cardId`: 수정할 카드의 고유 ID
|
||||
- `versionName`: 기본 이름 옆에 표시할 짧은 버전명
|
||||
- `displayName`: 기본 멤버 이름을 완전히 바꿀 때만 입력하며, 평소에는 비워 둡니다.
|
||||
> GitHub가 자동으로 제공하는 `Source code (zip)`은 실행용 배포 파일이 아닙니다. 반드시 `HolodoriCalc-offline-...zip`을 받아 주세요.
|
||||
|
||||
수정 후 `npm run data:sync`를 실행하면 카드 목록에 반영됩니다. 이 파일은 원본 데이터를 다시 수집해도 덮어쓰지 않습니다.
|
||||
Chrome 또는 Edge의 최신 버전을 권장합니다. 일부 브라우저는 로컬 HTML의 저장소나 네트워크 접근을 제한할 수 있습니다.
|
||||
|
||||
## 브라우저 데이터
|
||||
## 처음 사용하는 순서
|
||||
|
||||
사용자 데이터는 서버로 전송하지 않고 현재 브라우저의 `localStorage`에 자동 저장합니다. 별도의 `사용자 데이터` 페이지에서 JSON 백업을 내보내거나 가져오고, 전체 데이터를 초기화할 수 있습니다.
|
||||
### 1. 사용자 데이터를 설정합니다
|
||||
|
||||
- 현재 카드 목록의 출력 조건을 자동으로 복원합니다.
|
||||
- `백업 내보내기`로 버전이 포함된 JSON 파일을 내려받을 수 있습니다.
|
||||
- `백업 가져오기`로 다른 브라우저에서 데이터를 복원할 수 있습니다.
|
||||
- `초기화`는 현재 브라우저에 저장된 수집 내역, 저장 덱, 설정을 모두 기본값으로 되돌립니다.
|
||||
상단 메뉴에서 **사용자 데이터 → 카드**로 이동해 보유 카드와 개화 단계를 입력합니다.
|
||||
|
||||
향후 보유 카드와 저장 덱 기능도 `src/lib/models/user-data.ts`의 동일한 버전 데이터에 저장합니다.
|
||||
- 5성 카드는 `0~5` 개화 단계를 설정합니다.
|
||||
- 4성 카드는 보유 여부와 5개화 여부를 설정합니다.
|
||||
- 보유하지 않은 카드는 개화 단계를 변경할 수 없습니다.
|
||||
- 행을 누르면 보유 여부를 빠르게 전환할 수 있습니다.
|
||||
|
||||
홀로드림스 덱 최적화와 스킬 로스 분석을 통합할 웹사이트의 개발 기반입니다. 현재 단계에는 게임 기능을 포함하지 않습니다.
|
||||

|
||||
|
||||
## 구성
|
||||
**사용자 데이터 → 일반**에서는 메모리 보유 수와 멤버 강화 수치를 설정하고, 전체 사용자 데이터를 백업하거나 복원할 수 있습니다.
|
||||
|
||||
### 2. 멤버 보드를 설정합니다
|
||||
|
||||
**사용자 데이터 → 보드**에서 멤버를 선택한 뒤 실제 게임에서 활성화한 노드를 같은 형태로 입력합니다.
|
||||
|
||||
- 빈 공간을 드래그해 보드를 이동합니다.
|
||||
- 마우스 휠로 확대하거나 축소합니다.
|
||||
- 인접하지 않은 노드를 누르면 중심에서 이어지는 최단 경로가 함께 활성화됩니다.
|
||||
- 보드 슬롯에는 보유 카드를 한 장씩 배치할 수 있습니다.
|
||||
- `모든 멤버 초록 3칸 활성화`와 `중앙 연결 노드 자동 배치`로 일반적인 설정을 빠르게 적용할 수 있습니다.
|
||||
|
||||

|
||||
|
||||
같은 페이지의 **초록 노드 효율 분석**에서는 현재 편성을 기준으로 다음 항목을 비교할 수 있습니다.
|
||||
|
||||
- 아직 활성화하지 않은 노드 중 우선 활성화할 노드
|
||||
- 이미 활성화한 노드 중 초기화를 검토할 노드
|
||||
- 종합력 변화, 필요한 연결 노드 수, 자원 소모량과 그린 큐브 효율
|
||||
|
||||
목록의 항목을 누르면 해당 멤버와 노드로 바로 이동합니다.
|
||||
|
||||
### 3. 카드 정보를 확인합니다
|
||||
|
||||
**카드 목록**에서는 타입, 주스탯, 기수, 액티브 확률, 조건부 스킬과 이격 여부로 카드를 필터링할 수 있습니다. 카드 행을 누르면 패시브·액티브·스페셜·의상 효과가 펼쳐집니다.
|
||||
|
||||

|
||||
|
||||
### 4. 5인 편성의 최적 배치를 분석합니다
|
||||
|
||||
**배치 분석**은 선택한 악곡과 5인 조합을 유지하면서 가장 높은 점수를 내는 멤버 순서와 빈도 감소 조합을 찾습니다.
|
||||
|
||||
1. 악곡과 난이도를 선택합니다.
|
||||
2. 5장의 카드와 개화 단계를 설정합니다.
|
||||
3. 필요한 경우 카드별 빈도 감소를 특정 값으로 고정합니다.
|
||||
4. 리더와 리더 의상을 선택합니다.
|
||||
5. `최적 배치 분석`을 누릅니다.
|
||||
|
||||

|
||||
|
||||
분석 결과에서는 최고·평균 점수, 최적 순서, 카드별 빈도 감소율과 함께 다음 시각화 자료를 확인할 수 있습니다.
|
||||
|
||||
- 초당 노트 분포
|
||||
- 멤버별 액티브 발동 구간
|
||||
- 중첩을 반영한 최종 액티브 적용 구간
|
||||
- 멤버별 스페셜 발동 구간
|
||||
|
||||
분석 조건과 마지막 결과는 브라우저에 저장되며, 같은 악곡과 난이도의 조합 탐색에서 **기준 조합**으로 사용할 수 있습니다.
|
||||
|
||||
### 5. 더 좋은 카드 조합을 탐색합니다
|
||||
|
||||
**조합 탐색**은 악곡, 리더, 리더 의상과 최대 4명의 고정 카드를 기준으로 남은 자리에 들어갈 후보를 비교합니다.
|
||||
|
||||
- 고정하지 않은 자리에는 전체 5성 카드가 후보로 들어갑니다.
|
||||
- 자동 후보의 개화 단계와 기수 보너스 덮어쓰기 값을 지정할 수 있습니다.
|
||||
- 보유 카드의 실제 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.
|
||||
- 고정 멤버별 개화, 빈도 감소, 파란 노드 전체 적용 여부와 기수 보너스를 따로 설정할 수 있습니다.
|
||||
- 한 편성에는 같은 인물의 일반 카드와 이격 카드가 동시에 들어가지 않습니다.
|
||||
- 결과는 최고 점수 기준으로 정렬되며 상위 조합의 상세 분석을 펼쳐볼 수 있습니다.
|
||||
|
||||

|
||||
|
||||
`최대 컴퓨팅 자원 활용`을 켜면 탐색 속도가 빨라질 수 있지만, 분석 중 브라우저와 다른 프로그램의 반응이 느려질 수 있습니다.
|
||||
|
||||
## 데이터 저장과 백업
|
||||
|
||||
사용자 데이터와 분석 조건은 서버로 전송하지 않고 현재 브라우저의 `localStorage`에 자동 저장합니다.
|
||||
|
||||
- 브라우저나 사용자 프로필이 달라지면 별도의 데이터로 취급될 수 있습니다.
|
||||
- 브라우저 데이터 삭제 시 저장 내용도 사라질 수 있습니다.
|
||||
- 새 버전으로 이동하기 전 **사용자 데이터 → 일반 → 백업 내보내기**를 권장합니다.
|
||||
- 백업 JSON은 다른 브라우저나 PC의 **백업 가져오기**에서 복원할 수 있습니다.
|
||||
|
||||
## 업데이트
|
||||
|
||||
홈 화면에서 현재 버전과 GitHub의 최신 Release를 비교합니다. 새 버전이 있으면 오프라인 ZIP 다운로드 버튼이 표시됩니다.
|
||||
|
||||
업데이트는 자동으로 파일을 교체하지 않습니다. 새 ZIP을 내려받아 별도 폴더에 압축을 해제한 뒤 `index.html`을 실행해 주세요. 업데이트 전에 사용자 데이터 백업을 권장합니다.
|
||||
|
||||
## 참고
|
||||
|
||||
- 이 프로젝트는 홀로라이브 드림즈의 비공식 팬 제작 도구입니다.
|
||||
- 게임 업데이트와 확보된 데이터 범위에 따라 실제 게임 결과와 차이가 날 수 있습니다.
|
||||
- 분석 결과는 입력한 카드·개화·보드·메모리·멤버 강화 데이터가 정확하다는 전제에서 계산됩니다.
|
||||
- 카드와 악곡의 원본 게임 데이터는 프로젝트가 직접 소유하거나 공식 제공하는 데이터가 아닙니다.
|
||||
|
||||
<details>
|
||||
<summary><strong>개발 환경과 실행 방법</strong></summary>
|
||||
|
||||
### 기술 구성
|
||||
|
||||
- SvelteKit + TypeScript + `@sveltejs/adapter-node`
|
||||
- SQLite + Drizzle ORM
|
||||
- Web Worker를 위한 독립 모듈 경계
|
||||
- Docker Compose 개발 환경(HMR)
|
||||
- 멀티 스테이지 production 이미지
|
||||
- Docker named volume 기반 SQLite 영속 저장소
|
||||
- Web Worker 기반 클라이언트 계산
|
||||
- Docker / Docker Compose
|
||||
|
||||
## Docker 개발 실행
|
||||
계산 엔진은 UI와 분리하며 `UI → Engine → Models` 의존성 방향을 유지합니다.
|
||||
|
||||
### Docker 개발 실행
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
@@ -56,32 +149,9 @@ docker compose up --build
|
||||
- 웹: <http://localhost:5173>
|
||||
- 상태 확인: <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 이상이 필요합니다.
|
||||
|
||||
@@ -91,33 +161,53 @@ npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
기본 로컬 DB 경로는 `./data/holodream.db`입니다.
|
||||
|
||||
## 검증 명령
|
||||
### 검증과 빌드
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run build
|
||||
npm run build:offline
|
||||
```
|
||||
|
||||
스키마를 변경한 뒤에는 migration을 생성합니다.
|
||||
릴리즈용 오프라인 ZIP 생성:
|
||||
|
||||
```bash
|
||||
npm run db:generate
|
||||
npm run package:offline
|
||||
```
|
||||
|
||||
생성된 migration은 서버가 시작될 때 자동 적용됩니다. 호스트에서 수동 적용하려면 `npm run db:migrate`를 사용합니다.
|
||||
### Production 실행
|
||||
|
||||
## 디렉터리 경계
|
||||
|
||||
```text
|
||||
src/
|
||||
├─ lib/
|
||||
│ ├─ engine/ # UI 프레임워크에 의존하지 않는 순수 계산 로직
|
||||
│ ├─ models/ # UI·엔진·서버가 공유하는 도메인 타입
|
||||
│ ├─ server/db/ # SQLite/Drizzle 서버 전용 모듈
|
||||
│ └─ workers/ # 무거운 최적화 작업의 Web Worker 진입점
|
||||
└─ routes/ # SvelteKit 화면과 API
|
||||
```bash
|
||||
docker compose -f compose.prod.yaml up --build -d
|
||||
```
|
||||
|
||||
의존성 방향은 `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>
|
||||
|
||||
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 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "holodream-tools",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "holodream-tools",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "holodream-tools",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# 홀로도리 계산기 1.0.2
|
||||
|
||||
조합 탐색의 후보 누락을 줄이고, 패시브 적용 대상과 분석 결과 표시를 개선한 업데이트입니다.
|
||||
|
||||
## 주요 변경 사항
|
||||
|
||||
- 빈 편성 슬롯이 많을수록 조합 탐색 후보와 정밀 분석 대상을 확대했습니다.
|
||||
- 여러 선별 기준과 후보별 교체 조합 재검증을 추가해 강한 카드가 조기에 제외될 가능성을 줄였습니다.
|
||||
- 보유 카드의 실제 개화가 자동 후보 설정값보다 높으면 실제 개화를 우선 적용합니다.
|
||||
- 편성 카드 목록과 추천 스페셜 발동 순서를 구분해 표시합니다.
|
||||
- 제한 인원 패시브와 스코어 서포트의 적용 대상을 파라미터 합계 기준으로 판정하도록 수정했습니다.
|
||||
- 배치 분석과 조합 탐색에 최고점, 최고점의 99%, 95% 이상 달성 확률을 추가했습니다.
|
||||
- 중앙 연결 노드 자동 배치 결과에서 어떤 카드가 어느 멤버 보드에 배치됐는지 확인할 수 있습니다.
|
||||
- 초록 노드 효율 분석에 `코어 큐브 미소모만` 검색 옵션을 추가했습니다.
|
||||
- 스크린샷을 포함한 사용자용 README를 추가했습니다.
|
||||
|
||||
## 알려진 문제
|
||||
|
||||
> 달성 확률과 이론상 최고점이 실제 인게임 결과보다 다소 높게 평가되는 현상이 확인되었습니다. 현재 결과는 절대적인 예상 점수보다 조합 간 상대 비교와 참고값으로 활용해 주세요.
|
||||
|
||||
달성 확률은 모든 노트를 PERFECT로 처리한다는 가정 아래 액티브 발동 확률만 반영합니다. 실제 플레이의 판정과 아직 확인되지 않은 게임 내부 계산 방식에 따라 차이가 발생할 수 있습니다.
|
||||
|
||||
## 다운로드
|
||||
|
||||
Release의 Assets에서 `HolodoriCalc-offline-v1.0.2.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
|
||||
@@ -97,11 +97,16 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<ol class="order-list">
|
||||
<div class="order-sections">
|
||||
<section class="order-section">
|
||||
<h3>추천 스페셜 발동 순서</h3>
|
||||
<ol class="order-list compact">
|
||||
{#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>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
{#if showScoreSummary}
|
||||
<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>
|
||||
@@ -164,7 +169,10 @@
|
||||
</section>
|
||||
|
||||
<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; }
|
||||
.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; }
|
||||
@@ -206,6 +214,7 @@
|
||||
.time-axis span:first-child { transform: none; }
|
||||
.time-axis span:last-child { transform: translateX(-100%); }
|
||||
@media (max-width: 700px) {
|
||||
.order-sections { grid-template-columns: 1fr; }
|
||||
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
|
||||
.reduction { grid-column: 2; justify-self: start; }
|
||||
.score-grid { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toOptimizerCard, type OptimizerSourceCard } from './card-adapter.ts';
|
||||
const source: OptimizerSourceCard = {
|
||||
id: 'card',
|
||||
name: '카드',
|
||||
power: { statsAtMaxLevel: { performance: 101, technique: 202, sense: 303 } },
|
||||
details: {
|
||||
active: [
|
||||
{ level: 1, coolTimeSeconds: 10, durationSeconds: 1, effect: '스코어가 10% UP' },
|
||||
@@ -29,6 +30,11 @@ test('액티브는 1개화, 스페셜은 3개화부터 2레벨을 적용한다',
|
||||
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개화 패시브의 타입별 스코어 서포트 대상과 수치를 변환한다', () => {
|
||||
const passiveSource: OptimizerSourceCard = {
|
||||
...source,
|
||||
|
||||
@@ -16,6 +16,13 @@ export type OptimizerSourceCard = {
|
||||
type?: string;
|
||||
generation?: string;
|
||||
versionName?: string;
|
||||
power?: {
|
||||
statsAtMaxLevel?: {
|
||||
performance: number;
|
||||
technique: number;
|
||||
sense: number;
|
||||
};
|
||||
};
|
||||
details: {
|
||||
passive?: readonly { level: number; description: string }[];
|
||||
active: readonly SkillLevel[];
|
||||
@@ -87,6 +94,11 @@ export function toOptimizerCard(
|
||||
const specialConditionMet = skillConditionSatisfied(special?.condition ?? '', unitMembers);
|
||||
const activeScore = activeConditionMet ? scorePercent(active?.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) {
|
||||
throw new Error(`${source.name}의 액티브 스코어 정보를 계산에 사용할 수 없습니다.`);
|
||||
@@ -98,6 +110,7 @@ export function toOptimizerCard(
|
||||
return {
|
||||
id: source.id,
|
||||
name: source.versionName ? `${source.name} (${source.versionName})` : source.name,
|
||||
naturalParameterTotal,
|
||||
type: source.type,
|
||||
generation: source.generation,
|
||||
active: {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { generateCombinationCandidates, resolveCandidateAwakening } from './index.ts';
|
||||
import {
|
||||
combinationSearchLimits,
|
||||
generateAnchoredCombinationCandidates,
|
||||
generateCombinationCandidates,
|
||||
resolveCandidateAwakening,
|
||||
selectMemberDiverseCandidates
|
||||
} from './index.ts';
|
||||
|
||||
const cards = [
|
||||
{ id: 'a-1', characterId: 'a', score: 10 },
|
||||
@@ -44,3 +50,52 @@ test('보유한 자동 후보는 설정값보다 실제 개화가 높으면 실
|
||||
assert.equal(resolveCandidateAwakening(4, true, 2), 4);
|
||||
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')));
|
||||
});
|
||||
|
||||
@@ -8,6 +8,33 @@ type BeamState<T extends CombinationSearchCard> = {
|
||||
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(
|
||||
configuredAwakening: number,
|
||||
owned: boolean,
|
||||
@@ -63,3 +90,64 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.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());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
|
||||
import { passiveScoreSupportByCard } from './passive-support.ts';
|
||||
import type { ExactSongChart, OptimizerCard } from './types.ts';
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
test('패시브 스코어 서포트는 순서상 앞선 대상 타입의 지정 인원에게 적용한다', () => {
|
||||
test('패시브 스코어 서포트는 기본 파라미터 합계가 높은 지정 인원에게 적용한다', () => {
|
||||
const supported = cards.map((card, index) => ({
|
||||
...card,
|
||||
type: index < 3 ? '퓨어' : '해피',
|
||||
naturalParameterTotal: [100, 300, 200, 50, 50][index],
|
||||
passiveScoreSupportEffects: index === 4
|
||||
? [{ percent: 20, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }]
|
||||
: []
|
||||
@@ -42,5 +44,42 @@ test('패시브 스코어 서포트는 순서상 앞선 대상 타입의 지정
|
||||
const baseline = optimizeExactOrderAndCooldowns(cards, 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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { calculateExactScore } from './score.ts';
|
||||
import { skillTargetBelongsTo } from './skill-condition.ts';
|
||||
import { passiveScoreSupportByCard } from './passive-support.ts';
|
||||
import { effectiveCooldownSeconds } from './timeline.ts';
|
||||
import { COOLDOWN_REDUCTIONS } from './types.ts';
|
||||
import type {
|
||||
@@ -17,24 +17,6 @@ type CandidateProfile = {
|
||||
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[][] {
|
||||
if (items.length <= 1) return [Array.from(items)];
|
||||
const result: T[][] = [];
|
||||
@@ -242,17 +224,17 @@ export function optimizeExactOrderAndCooldowns(
|
||||
if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) {
|
||||
throw new Error('서로 다른 카드 5장이 필요합니다.');
|
||||
}
|
||||
const orders = permutations(cards);
|
||||
const reductionAssignments = assignments(cards);
|
||||
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 bestScore = Number.NEGATIVE_INFINITY;
|
||||
let bestOrder: OptimizerCard[] | null = null;
|
||||
let bestReductions: Record<string, CooldownReduction> | null = null;
|
||||
let bestReductionTotal = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const order of orders) {
|
||||
for (const order of permutations(cards)) {
|
||||
const profiles = makeProfiles(order, chart);
|
||||
for (const reductions of reductionAssignments) {
|
||||
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
|
||||
@@ -273,6 +255,7 @@ export function optimizeExactOrderAndCooldowns(
|
||||
return {
|
||||
...calculateExactScore(bestOrder, bestReductions, chart, options),
|
||||
order: bestOrder.map((card) => card.id),
|
||||
formationOrder: cards.map((card) => card.id),
|
||||
cooldownReductions: { ...bestReductions },
|
||||
...windows,
|
||||
evaluatedCandidates
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './memory.ts';
|
||||
export * from './maximum-weight-matching.ts';
|
||||
export * from './optimizer.ts';
|
||||
export * from './score.ts';
|
||||
export * from './score-probability.ts';
|
||||
export * from './skill-condition.ts';
|
||||
export * from './timeline.ts';
|
||||
export * from './types.ts';
|
||||
|
||||
@@ -29,6 +29,18 @@ test('5장과 네 가지 감소율은 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('노트가 몰린 첫 스페셜 슬롯에 가장 강한 스페셜 카드를 배치한다', () => {
|
||||
const fixedReductionCards = cards.map((card) => ({ ...card, allowedCooldownReductions: [0] as const }));
|
||||
const result = optimizeOrderAndCooldowns(fixedReductionCards, chart);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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('확정 발동이면 모든 달성 확률이 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
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
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'] {
|
||||
const countedMidTimes = new Set<number>();
|
||||
return chart.notes.filter((note) => {
|
||||
if (note.type !== 'mid') return true;
|
||||
if (countedMidTimes.has(note.timeSeconds)) return false;
|
||||
countedMidTimes.add(note.timeSeconds);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
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 = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationBonus / 100));
|
||||
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
|
||||
};
|
||||
}
|
||||
+1
-19
@@ -1,5 +1,5 @@
|
||||
import { effectiveCooldownSeconds } from './timeline.ts';
|
||||
import { skillTargetBelongsTo } from './skill-condition.ts';
|
||||
import { passiveScoreSupportAt } from './passive-support.ts';
|
||||
import type {
|
||||
CooldownReduction,
|
||||
ExactScoreOptions,
|
||||
@@ -34,24 +34,6 @@ export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number {
|
||||
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 {
|
||||
if (combo >= 1_000) return 0.1;
|
||||
if (combo >= 100) return Math.floor(combo / 100) * 0.01;
|
||||
|
||||
@@ -5,6 +5,8 @@ export type CooldownReduction = (typeof COOLDOWN_REDUCTIONS)[number];
|
||||
export type OptimizerCard = {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 보드·패시브·의상·메모리를 제외한 현재 레벨/개화의 파라미터 합계. */
|
||||
naturalParameterTotal?: number;
|
||||
type?: string;
|
||||
generation?: string;
|
||||
active: {
|
||||
@@ -116,7 +118,16 @@ export type ExactScoreOptions = {
|
||||
directSongScoreBonusPercent?: number;
|
||||
};
|
||||
|
||||
export type ScoreAchievementProbabilities = {
|
||||
maximum: number;
|
||||
atLeast99Percent: number;
|
||||
atLeast95Percent: number;
|
||||
samples: number;
|
||||
};
|
||||
|
||||
export type ExactOptimizationResult = ExactScoreResult & {
|
||||
/** 분석한 5인 편성. 기존 저장 데이터 호환을 위해 필드명을 유지한다. */
|
||||
formationOrder: readonly string[];
|
||||
order: readonly string[];
|
||||
cooldownReductions: Readonly<Record<string, CooldownReduction>>;
|
||||
activeWindows: readonly SkillWindow[];
|
||||
|
||||
@@ -91,3 +91,30 @@ test('조건부 기수 패시브는 복수 소속 멤버도 대상으로 삼는
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -89,7 +89,11 @@ function targetIndexes(
|
||||
return members
|
||||
.map((member, index) => ({ member, index }))
|
||||
.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)
|
||||
.map(({ index }) => index);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { countOptimizationCandidates, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
|
||||
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts';
|
||||
import { countOptimizationCandidates, estimateScoreAchievementProbabilities, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
|
||||
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from '../engine/index.ts';
|
||||
|
||||
type OptimizeRequest = {
|
||||
type: 'optimize-exact';
|
||||
@@ -9,6 +9,7 @@ type OptimizeRequest = {
|
||||
chart: ExactSongChart;
|
||||
perfectNoteScore: number;
|
||||
directSongScoreBonusPercent?: number;
|
||||
includeAchievementProbabilities?: boolean;
|
||||
};
|
||||
|
||||
type CombinationCandidate = {
|
||||
@@ -24,7 +25,31 @@ type CombinationRequest = {
|
||||
chart: ExactSongChart;
|
||||
};
|
||||
|
||||
self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) => {
|
||||
type RankedCombination = {
|
||||
result: ExactOptimizationResult;
|
||||
overallPower: number;
|
||||
cards?: OptimizerCard[];
|
||||
directSongScoreBonusPercent?: number;
|
||||
achievementProbabilities?: ScoreAchievementProbabilities;
|
||||
};
|
||||
|
||||
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 {
|
||||
if (event.data.type === 'optimize-exact') {
|
||||
@@ -34,14 +59,49 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) =>
|
||||
onProgress: (progress) => self.postMessage({ type: 'progress', progress })
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
const totalCandidates = event.data.candidates.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0);
|
||||
let completedCandidates = 0;
|
||||
let best: { result: ExactOptimizationResult; overallPower: number } | null = null;
|
||||
const rankings: { result: ExactOptimizationResult; overallPower: number }[] = [];
|
||||
let best: RankedCombination | null = null;
|
||||
const rankings: RankedCombination[] = [];
|
||||
for (const candidate of event.data.candidates) {
|
||||
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
|
||||
perfectNoteScore: candidate.perfectNoteScore,
|
||||
@@ -54,9 +114,19 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) =>
|
||||
completedCandidates += countOptimizationCandidates(candidate.cards);
|
||||
if (!best || result.theoreticalMaxScore > best.result.theoreticalMaxScore ||
|
||||
(result.theoreticalMaxScore === best.result.theoreticalMaxScore && result.expectedScore > best.result.expectedScore)) {
|
||||
best = { result, overallPower: candidate.overallPower };
|
||||
best = {
|
||||
result,
|
||||
overallPower: candidate.overallPower,
|
||||
cards: candidate.cards,
|
||||
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent
|
||||
};
|
||||
}
|
||||
rankings.push({ result, overallPower: candidate.overallPower });
|
||||
rankings.push({
|
||||
result,
|
||||
overallPower: candidate.overallPower,
|
||||
cards: candidate.cards,
|
||||
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent
|
||||
});
|
||||
}
|
||||
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
|
||||
rankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
|
||||
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
|
||||
import { calculateBoardEffects, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
|
||||
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, SkillWindow } from '$lib/engine/index.ts';
|
||||
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
|
||||
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
let running = false;
|
||||
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
|
||||
let result: ExactOptimizationResult | null = null;
|
||||
let achievementProbabilities: ScoreAchievementProbabilities | undefined;
|
||||
let errorMessage = '';
|
||||
let analysisReady = false;
|
||||
|
||||
@@ -182,6 +183,7 @@
|
||||
if (event.data.type === 'progress') progress = event.data.progress;
|
||||
if (event.data.type === 'result') {
|
||||
result = event.data.result;
|
||||
achievementProbabilities = event.data.achievementProbabilities;
|
||||
running = false;
|
||||
}
|
||||
if (event.data.type === 'error') {
|
||||
@@ -315,6 +317,7 @@
|
||||
async function analyze(): Promise<void> {
|
||||
errorMessage = '';
|
||||
result = null;
|
||||
achievementProbabilities = undefined;
|
||||
if (!worker || !selectedSong || !selectedChart) return;
|
||||
if (new Set(selectedCardIds).size !== 5) {
|
||||
errorMessage = '서로 다른 카드 5장을 선택해 주세요.';
|
||||
@@ -342,7 +345,8 @@
|
||||
cards,
|
||||
chart,
|
||||
perfectNoteScore,
|
||||
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent
|
||||
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
|
||||
includeAchievementProbabilities: true
|
||||
});
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : '분석 입력을 준비하지 못했습니다.';
|
||||
@@ -354,6 +358,19 @@
|
||||
return card ? cardLabel(card) : cardId;
|
||||
}
|
||||
|
||||
function probabilityLabel(value: number | undefined): string {
|
||||
if (value === undefined) return '—';
|
||||
if (value <= 0) return '0%';
|
||||
if (value < 0.000001) return '<0.0001%';
|
||||
if (value < 0.0001) return `${(value * 100).toFixed(4)}%`;
|
||||
if (value < 0.01) return `${(value * 100).toFixed(2)}%`;
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function scoreThreshold(maximumScore: number, ratio: number): string {
|
||||
return Math.ceil(maximumScore * ratio).toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
function countNotesBySecond(scoreResult: ExactOptimizationResult, durationSeconds: number): number[] {
|
||||
const counts = Array.from({ length: Math.ceil(durationSeconds) }, () => 0);
|
||||
for (const note of scoreResult.notes) {
|
||||
@@ -430,7 +447,7 @@
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>배치 분석</h1>
|
||||
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 멤버 배치 순서와 빈도 감소 조합을 찾습니다.</p>
|
||||
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -528,16 +545,27 @@
|
||||
<section class="panel result" aria-labelledby="result-title">
|
||||
<div class="section-title"><div><span>02</span><h2 id="result-title">분석 결과</h2></div></div>
|
||||
{#if result}
|
||||
<div class="order-sections">
|
||||
<section class="order-section">
|
||||
<h3>추천 스페셜 발동 순서</h3>
|
||||
<ol class="order-list">
|
||||
{#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-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.baselineScore.toLocaleString('ko-KR')}</strong></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="skill-timeline" aria-labelledby="timeline-title">
|
||||
<div class="timeline-heading">
|
||||
@@ -662,7 +690,10 @@
|
||||
.analyze:disabled { opacity: .55; cursor: default; }
|
||||
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -671,6 +702,12 @@
|
||||
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
|
||||
.score-summary span { color: #a9b1be; font-size: 14px; }
|
||||
.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; }
|
||||
.empty { color: #858f9f; font-size: 14px; line-height: 1.65; }
|
||||
.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; }
|
||||
@@ -710,6 +747,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.order-sections { grid-template-columns: 1fr; }
|
||||
main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
|
||||
.page-header { align-items: flex-start; }
|
||||
.panel { padding: 17px; }
|
||||
|
||||
@@ -9,14 +9,17 @@
|
||||
import {
|
||||
calculateBoardEffects,
|
||||
calculateUnitPower,
|
||||
combinationSearchLimits,
|
||||
countOptimizationCandidates,
|
||||
generateAnchoredCombinationCandidates,
|
||||
generateCombinationCandidates,
|
||||
memoryParameterPermil,
|
||||
perfectNoteScoreFromPower,
|
||||
resolveCandidateAwakening,
|
||||
selectMemberDiverseCandidates,
|
||||
toOptimizerCard
|
||||
} from '$lib/engine/index.ts';
|
||||
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard } from '$lib/engine/index.ts';
|
||||
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard, ScoreAchievementProbabilities } from '$lib/engine/index.ts';
|
||||
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
|
||||
|
||||
@@ -33,7 +36,13 @@
|
||||
heuristic: number;
|
||||
skillPotential: number;
|
||||
};
|
||||
type RankedResult = { result: ExactOptimizationResult; overallPower: number };
|
||||
type RankedResult = {
|
||||
result: ExactOptimizationResult;
|
||||
overallPower: number;
|
||||
cards?: OptimizerCard[];
|
||||
directSongScoreBonusPercent?: number;
|
||||
achievementProbabilities?: ScoreAchievementProbabilities;
|
||||
};
|
||||
type DisplayRankedResult = RankedResult & { isBaseline: boolean };
|
||||
type SavedState = {
|
||||
schemaVersion: 1;
|
||||
@@ -64,9 +73,6 @@
|
||||
|
||||
const STORAGE_KEY = 'holodori.combination.v1';
|
||||
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
|
||||
const SCREENING_LIMIT = 4_000;
|
||||
const BASE_EXACT_FINALIST_LIMIT = 48;
|
||||
const ALTERNATIVE_EXACT_FINALIST_LIMIT = 48;
|
||||
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
|
||||
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
|
||||
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
|
||||
@@ -107,13 +113,17 @@
|
||||
let displayedRankings: DisplayRankedResult[] = [];
|
||||
let baselineState: SavedAnalysisState | null = null;
|
||||
let baselineResult: ExactOptimizationResult | null = null;
|
||||
let baselineAchievementProbabilities: ScoreAchievementProbabilities | undefined;
|
||||
let baselineOverallPower = 0;
|
||||
let baselineRunning = false;
|
||||
let baselineProgress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
|
||||
let baselineError = '';
|
||||
let screenedCombinationCount = 0;
|
||||
let exactCombinationCount = 0;
|
||||
let searchPhase: 'idle' | 'screening' | 'exact' = 'idle';
|
||||
let exactPass: 'primary' | 'refinement' = 'primary';
|
||||
let activeSearchChart: ExactSongChart | null = null;
|
||||
let primaryRankings: RankedResult[] = [];
|
||||
let searchPhase: 'idle' | 'screening' | 'exact' | 'probability' = 'idle';
|
||||
let screeningProgress = 0;
|
||||
let errorMessage = '';
|
||||
let ready = false;
|
||||
@@ -126,7 +136,12 @@
|
||||
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
|
||||
$: displayedRankings = buildDisplayedRankings(
|
||||
rankedResults,
|
||||
comparableToBaseline ? { result: baselineResult!, overallPower: baselineOverallPower, isBaseline: true } : null
|
||||
comparableToBaseline ? {
|
||||
result: baselineResult!,
|
||||
overallPower: baselineOverallPower,
|
||||
achievementProbabilities: baselineAchievementProbabilities,
|
||||
isBaseline: true
|
||||
} : null
|
||||
);
|
||||
$: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline();
|
||||
$: if (!result && rankedResults.length > 0) rankedResults = [];
|
||||
@@ -205,8 +220,15 @@
|
||||
worker.onmessage = (event) => {
|
||||
if (event.data.type === 'progress') progress = event.data.progress;
|
||||
if (event.data.type === 'combination-result') {
|
||||
result = event.data.result;
|
||||
rankedResults = Array.isArray(event.data.rankings) ? event.data.rankings : [{ result: event.data.result, overallPower: event.data.overallPower }];
|
||||
const rankings = Array.isArray(event.data.rankings)
|
||||
? event.data.rankings as RankedResult[]
|
||||
: [{ result: event.data.result, overallPower: event.data.overallPower }];
|
||||
completeExactPass(rankings);
|
||||
}
|
||||
if (event.data.type === 'achievement-progress') progress = event.data.progress;
|
||||
if (event.data.type === 'achievement-result') {
|
||||
rankedResults = event.data.rankings as RankedResult[];
|
||||
result = rankedResults[0]?.result ?? null;
|
||||
running = false;
|
||||
searchPhase = 'idle';
|
||||
}
|
||||
@@ -221,6 +243,7 @@
|
||||
if (event.data.type === 'progress') baselineProgress = event.data.progress;
|
||||
if (event.data.type === 'result') {
|
||||
baselineResult = event.data.result;
|
||||
baselineAchievementProbabilities = event.data.achievementProbabilities;
|
||||
baselineRunning = false;
|
||||
}
|
||||
if (event.data.type === 'error') {
|
||||
@@ -405,85 +428,127 @@
|
||||
}
|
||||
|
||||
function screeningCombinations(): CardRow[][] {
|
||||
const { screeningLimit } = combinationSearchLimits(fixedCards.length);
|
||||
if (fixedCards.length === 4) {
|
||||
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, SCREENING_LIMIT);
|
||||
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, screeningLimit);
|
||||
}
|
||||
const perStrategy = Math.ceil(SCREENING_LIMIT / 4);
|
||||
const globalBudget = Math.ceil(screeningLimit * 0.8);
|
||||
const anchoredBudget = screeningLimit - globalBudget;
|
||||
const perStrategy = Math.ceil(globalBudget / 4);
|
||||
const strategies = [
|
||||
{ scoreCard: roughCardScore },
|
||||
{ scoreCard: roughSkillScore },
|
||||
{ scoreCard: roughStatScore },
|
||||
{ scoreCard: roughStatScore, scoreCombination: roughCombinationScore }
|
||||
];
|
||||
const combined = new Map<string, CardRow[]>();
|
||||
for (const score of [roughCardScore, roughSkillScore, roughStatScore]) {
|
||||
for (const cards of generateCombinationCandidates(fixedCards, fiveStarCards, 5, score, perStrategy)) {
|
||||
combined.set(combinationKey(cards), cards);
|
||||
}
|
||||
}
|
||||
for (const strategy of strategies) {
|
||||
for (const cards of generateCombinationCandidates(
|
||||
fixedCards,
|
||||
fiveStarCards,
|
||||
5,
|
||||
roughStatScore,
|
||||
strategy.scoreCard,
|
||||
perStrategy,
|
||||
roughCombinationScore
|
||||
strategy.scoreCombination
|
||||
)) {
|
||||
combined.set(combinationKey(cards), cards);
|
||||
}
|
||||
return Array.from(combined.values()).slice(0, SCREENING_LIMIT);
|
||||
}
|
||||
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
|
||||
const anchorCount = fiveStarCards.filter((card) => !fixedCharacters.has(card.characterId)).length;
|
||||
const perAnchorLimit = Math.max(1, Math.ceil(anchoredBudget / Math.max(1, anchorCount)));
|
||||
for (const cards of generateAnchoredCombinationCandidates(
|
||||
fixedCards,
|
||||
fiveStarCards,
|
||||
5,
|
||||
strategies,
|
||||
perAnchorLimit
|
||||
)) {
|
||||
combined.set(combinationKey(cards), cards);
|
||||
}
|
||||
return Array.from(combined.values()).slice(0, screeningLimit);
|
||||
}
|
||||
|
||||
function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] {
|
||||
if (fixedCards.length === 4) return prepared;
|
||||
const { baseExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
|
||||
const selected = new Map<string, PreparedCandidate>();
|
||||
const add = (candidates: PreparedCandidate[], count: number) => {
|
||||
for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate);
|
||||
};
|
||||
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), 24);
|
||||
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), 12);
|
||||
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), 12);
|
||||
if (selected.size < BASE_EXACT_FINALIST_LIMIT) {
|
||||
const diverse = selectMemberDiverseCandidates(
|
||||
prepared,
|
||||
(candidate) => candidate.cards,
|
||||
(candidate) => candidate.heuristic,
|
||||
fixedCardIds,
|
||||
Math.ceil(baseExactFinalistLimit / 2)
|
||||
);
|
||||
add(diverse, diverse.length);
|
||||
const remainingLimit = Math.max(0, baseExactFinalistLimit - selected.size);
|
||||
const heuristicLimit = Math.ceil(remainingLimit / 2);
|
||||
const skillLimit = Math.ceil(remainingLimit / 4);
|
||||
const powerLimit = remainingLimit - heuristicLimit - skillLimit;
|
||||
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), heuristicLimit);
|
||||
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), skillLimit);
|
||||
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), powerLimit);
|
||||
if (selected.size < baseExactFinalistLimit) {
|
||||
for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) {
|
||||
selected.set(combinationKey(candidate.cards), candidate);
|
||||
if (selected.size >= BASE_EXACT_FINALIST_LIMIT) break;
|
||||
if (selected.size >= baseExactFinalistLimit) break;
|
||||
}
|
||||
}
|
||||
return Array.from(selected.values()).slice(0, BASE_EXACT_FINALIST_LIMIT);
|
||||
return Array.from(selected.values()).slice(0, baseExactFinalistLimit);
|
||||
}
|
||||
|
||||
function addAlternativeVariantFinalists(baseFinalists: PreparedCandidate[]): PreparedCandidate[] {
|
||||
if (fixedCards.length === 4) return baseFinalists;
|
||||
const selected = new Map(baseFinalists.map((candidate) => [combinationKey(candidate.cards), candidate]));
|
||||
const alternatives = new Map<string, PreparedCandidate>();
|
||||
const bestByReplacementId = new Map<string, PreparedCandidate>();
|
||||
function buildRefinementFinalists(rankings: RankedResult[]): PreparedCandidate[] {
|
||||
if (fixedCards.length === 4 || rankings.length === 0) return [];
|
||||
const { alternativeExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
|
||||
const alternatives = new Map<string, { cards: CardRow[]; roughScore: number }>();
|
||||
const topResultNeighbors = new Map<string, { cards: CardRow[]; roughScore: number }>();
|
||||
const secondaryPriority = new Map<string, { cards: CardRow[]; roughScore: number }>();
|
||||
const fixedIds = new Set(fixedCards.map((card) => card.id));
|
||||
|
||||
for (const finalist of baseFinalists) {
|
||||
const sourceCards = finalist.cards.map((card) => cardById.get(card.id)).filter((card): card is CardRow => Boolean(card));
|
||||
for (const [rankingIndex, ranking] of rankings.slice(0, 10).entries()) {
|
||||
const sourceCards = ranking.result.order.map((cardId) => cardById.get(cardId)).filter((card): card is CardRow => Boolean(card));
|
||||
if (sourceCards.length !== 5) continue;
|
||||
const sourceKey = combinationKey(sourceCards);
|
||||
const bestByReplacementId = new Map<string, { cards: CardRow[]; roughScore: number }>();
|
||||
sourceCards.forEach((source, index) => {
|
||||
if (fixedIds.has(source.id)) return;
|
||||
const otherCharacters = new Set(sourceCards.filter((_, cardIndex) => cardIndex !== index).map((card) => card.characterId));
|
||||
for (const replacement of fiveStarCards) {
|
||||
if (replacement.characterId !== source.characterId || replacement.id === source.id) continue;
|
||||
if (replacement.id === source.id || otherCharacters.has(replacement.characterId)) continue;
|
||||
const cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card);
|
||||
const key = combinationKey(cards);
|
||||
if (selected.has(key) || alternatives.has(key)) continue;
|
||||
const prepared = prepareCandidate(cards);
|
||||
alternatives.set(key, prepared);
|
||||
if (key === sourceKey) continue;
|
||||
const candidate = alternatives.get(key) ?? { cards, roughScore: roughCombinationScore(cards) };
|
||||
alternatives.set(key, candidate);
|
||||
if (rankingIndex === 0) topResultNeighbors.set(key, candidate);
|
||||
const previous = bestByReplacementId.get(replacement.id);
|
||||
if (!previous || prepared.heuristic > previous.heuristic) bestByReplacementId.set(replacement.id, prepared);
|
||||
if (!previous || candidate.roughScore > previous.roughScore) bestByReplacementId.set(replacement.id, candidate);
|
||||
}
|
||||
});
|
||||
if (rankingIndex > 0 && rankingIndex < 3) {
|
||||
for (const candidate of bestByReplacementId.values()) secondaryPriority.set(combinationKey(candidate.cards), candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const refinementLimit = Math.max(alternativeExactFinalistLimit, topResultNeighbors.size);
|
||||
const rankedAlternatives = Array.from(alternatives.values()).sort((left, right) =>
|
||||
right.heuristic - left.heuristic || right.skillPotential - left.skillPotential || right.overallPower - left.overallPower);
|
||||
const chosenAlternatives = new Map<string, PreparedCandidate>();
|
||||
for (const candidate of bestByReplacementId.values()) {
|
||||
right.roughScore - left.roughScore || combinationKey(left.cards).localeCompare(combinationKey(right.cards)));
|
||||
const chosenAlternatives = new Map<string, { cards: CardRow[]; roughScore: number }>();
|
||||
for (const candidate of topResultNeighbors.values()) {
|
||||
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
|
||||
}
|
||||
for (const candidate of secondaryPriority.values()) {
|
||||
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
|
||||
if (chosenAlternatives.size >= refinementLimit) break;
|
||||
}
|
||||
for (const candidate of rankedAlternatives) {
|
||||
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
|
||||
if (chosenAlternatives.size >= ALTERNATIVE_EXACT_FINALIST_LIMIT) break;
|
||||
if (chosenAlternatives.size >= refinementLimit) break;
|
||||
}
|
||||
for (const candidate of chosenAlternatives.values()) {
|
||||
selected.set(combinationKey(candidate.cards), candidate);
|
||||
}
|
||||
return Array.from(selected.values());
|
||||
return Array.from(chosenAlternatives.values()).map((candidate) => prepareCandidate(candidate.cards));
|
||||
}
|
||||
|
||||
function prepareCandidate(cards: CardRow[]): PreparedCandidate {
|
||||
@@ -586,7 +651,8 @@
|
||||
cards: optimizerCards,
|
||||
chart,
|
||||
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, chartMetadata.scoreRatio ?? null),
|
||||
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent
|
||||
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
|
||||
includeAchievementProbabilities: true
|
||||
});
|
||||
} catch (error) {
|
||||
baselineError = error instanceof Error ? error.message : '기준 조합을 분석하지 못했습니다.';
|
||||
@@ -599,11 +665,67 @@
|
||||
return `${difference >= 0 ? '+' : ''}${difference.toLocaleString('ko-KR')}`;
|
||||
}
|
||||
|
||||
function probabilityLabel(value: number | undefined): string {
|
||||
if (value === undefined) return '—';
|
||||
if (value <= 0) return '0%';
|
||||
if (value < 0.000001) return '<0.0001%';
|
||||
if (value < 0.0001) return `${(value * 100).toFixed(4)}%`;
|
||||
if (value < 0.01) return `${(value * 100).toFixed(2)}%`;
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function scoreThreshold(maximumScore: number, ratio: number): string {
|
||||
return Math.ceil(maximumScore * ratio).toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
function terminateParallelWorkers(): void {
|
||||
for (const parallelWorker of parallelWorkers) parallelWorker.terminate();
|
||||
parallelWorkers = [];
|
||||
}
|
||||
|
||||
function finalizeRankings(rankings: RankedResult[]): void {
|
||||
const bestByCombination = new Map<string, RankedResult>();
|
||||
for (const ranking of rankings) {
|
||||
const key = [...ranking.result.order].sort().join('|');
|
||||
const previous = bestByCombination.get(key);
|
||||
if (!previous || ranking.result.theoreticalMaxScore > previous.result.theoreticalMaxScore ||
|
||||
(ranking.result.theoreticalMaxScore === previous.result.theoreticalMaxScore && ranking.result.expectedScore > previous.result.expectedScore)) {
|
||||
bestByCombination.set(key, ranking);
|
||||
}
|
||||
}
|
||||
rankedResults = Array.from(bestByCombination.values()).sort((left, right) =>
|
||||
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore || right.result.expectedScore - left.result.expectedScore).slice(0, 10);
|
||||
result = rankedResults[0]?.result ?? null;
|
||||
if (rankedResults.length > 0 && activeSearchChart && worker && rankedResults.every((ranking) => ranking.cards)) {
|
||||
searchPhase = 'probability';
|
||||
progress = { evaluatedCandidates: 0, totalCandidates: rankedResults.length };
|
||||
worker.postMessage({
|
||||
type: 'estimate-achievement',
|
||||
rankings: rankedResults,
|
||||
chart: activeSearchChart,
|
||||
sampleCount: 25_000
|
||||
});
|
||||
return;
|
||||
}
|
||||
running = false;
|
||||
searchPhase = 'idle';
|
||||
}
|
||||
|
||||
function completeExactPass(rankings: RankedResult[]): void {
|
||||
if (exactPass === 'primary' && activeSearchChart) {
|
||||
primaryRankings = rankings;
|
||||
const refinements = buildRefinementFinalists(rankings);
|
||||
if (refinements.length > 0) {
|
||||
exactPass = 'refinement';
|
||||
exactCombinationCount += refinements.length;
|
||||
if (maximizeCompute) runParallelExact(refinements, activeSearchChart);
|
||||
else worker?.postMessage({ type: 'optimize-combinations', candidates: refinements, chart: activeSearchChart });
|
||||
return;
|
||||
}
|
||||
}
|
||||
finalizeRankings([...primaryRankings, ...rankings]);
|
||||
}
|
||||
|
||||
function runParallelExact(finalists: PreparedCandidate[], chart: ExactSongChart): void {
|
||||
terminateParallelWorkers();
|
||||
const reportedCores = typeof navigator === 'undefined' ? 1 : navigator.hardwareConcurrency || 4;
|
||||
@@ -649,11 +771,8 @@
|
||||
if (completedWorkers !== workerCount) return;
|
||||
completedRankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
|
||||
right.result.expectedScore - left.result.expectedScore);
|
||||
rankedResults = completedRankings.slice(0, 10);
|
||||
result = rankedResults[0]?.result ?? null;
|
||||
parallelWorkers = [];
|
||||
running = false;
|
||||
searchPhase = 'idle';
|
||||
completeExactPass(completedRankings.slice(0, 10));
|
||||
}
|
||||
if (event.data.type === 'error') fail(event.data.message);
|
||||
};
|
||||
@@ -672,6 +791,9 @@
|
||||
}
|
||||
running = true;
|
||||
searchPhase = 'screening';
|
||||
exactPass = 'primary';
|
||||
activeSearchChart = null;
|
||||
primaryRankings = [];
|
||||
screeningProgress = 0;
|
||||
progress = { evaluatedCandidates: 0, totalCandidates: 1 };
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -686,10 +808,11 @@
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
const finalists = addAlternativeVariantFinalists(selectExactFinalists(prepared));
|
||||
const finalists = selectExactFinalists(prepared);
|
||||
exactCombinationCount = finalists.length;
|
||||
if (finalists.length === 0) throw new Error('조건에 맞는 카드 조합이 없습니다.');
|
||||
const chart = await loadExactSongChart(selectedChart.path);
|
||||
activeSearchChart = chart;
|
||||
searchPhase = 'exact';
|
||||
if (maximizeCompute) runParallelExact(finalists, chart);
|
||||
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart });
|
||||
@@ -756,7 +879,7 @@
|
||||
<main>
|
||||
<header>
|
||||
<h1>조합 탐색</h1>
|
||||
<p class="page-description">악곡과 고정 멤버 조건을 바탕으로 빈자리에 들어갈 카드를 탐색해 최고 점수 조합을 찾습니다.</p>
|
||||
<p class="page-description">악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.</p>
|
||||
<p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p>
|
||||
</header>
|
||||
{#if baselineState && baselineMatchesConditions}
|
||||
@@ -767,7 +890,7 @@
|
||||
</div>
|
||||
<p class="baseline-condition">{baselineChartMetadata?.title} · {baselineState.difficulty === 'expert' ? 'Expert' : 'Hard'}</p>
|
||||
<div class="baseline-members">
|
||||
{#each baselineResult?.order ?? baselineState.cardIds as cardId, slot}
|
||||
{#each baselineResult?.formationOrder ?? baselineState.cardIds as cardId, slot}
|
||||
<div><span>{slot + 1}</span><strong>{baselineCardLabel(cardId)}</strong></div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -854,7 +977,7 @@
|
||||
</div>
|
||||
<p class="hint">기수 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·악곡 보너스 10% 조건으로 비교합니다.</p>
|
||||
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
|
||||
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
|
||||
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
|
||||
<label class="compute-option">
|
||||
<input type="checkbox" bind:checked={maximizeCompute} disabled={running}>
|
||||
<span aria-hidden="true"></span>
|
||||
@@ -874,15 +997,21 @@
|
||||
<span class="rank-number">{rank + 1}위</span>
|
||||
<span class="rank-members">
|
||||
{#if ranking.isBaseline}<b class="baseline-result-label">기준 조합</b>{/if}
|
||||
{#each ranking.result.order as cardId, memberIndex}
|
||||
{#each ranking.result.formationOrder as cardId, memberIndex}
|
||||
<span class:added-member={!ranking.isBaseline && !fixedCardIds.includes(cardId)}>
|
||||
<small class="member-awakening">{displayedCardAwakening(cardId, ranking.isBaseline)}개화</small>
|
||||
<span class="member-name">{resultCardName(cardId)}</span>
|
||||
</span>
|
||||
{#if memberIndex < ranking.result.order.length - 1}<i aria-hidden="true">·</i>{/if}
|
||||
{#if memberIndex < ranking.result.formationOrder.length - 1}<i aria-hidden="true">·</i>{/if}
|
||||
{/each}
|
||||
</span>
|
||||
<span class="rank-metric"><small>최고 점수</small><strong>{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}</span>
|
||||
<span class="rank-probabilities">
|
||||
<small>최고점 대비 달성 확률</small>
|
||||
<span><b>100%</b><strong>{probabilityLabel(ranking.achievementProbabilities?.maximum)}</strong></span>
|
||||
<span><b>99% 이상</b><strong>{probabilityLabel(ranking.achievementProbabilities?.atLeast99Percent)}</strong><i>{scoreThreshold(ranking.result.theoreticalMaxScore, 0.99)}점</i></span>
|
||||
<span><b>95% 이상</b><strong>{probabilityLabel(ranking.achievementProbabilities?.atLeast95Percent)}</strong><i>{scoreThreshold(ranking.result.theoreticalMaxScore, 0.95)}점</i></span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="ranking-detail">
|
||||
<AnalysisResultView
|
||||
@@ -897,6 +1026,7 @@
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="probability-guide">달성 확률은 모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
|
||||
{:else}
|
||||
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
|
||||
{/if}
|
||||
@@ -980,7 +1110,7 @@
|
||||
.ranking-card { overflow: hidden; border: 1px solid #303744; border-radius: 12px; background: #12161c; }
|
||||
.ranking-card[open] { border-color: #55404f; background: #15151b; }
|
||||
.ranking-card.baseline-ranking { border-color: #665061; background: #1b171d; }
|
||||
.ranking-card summary { position: relative; display: grid; grid-template-columns: 60px minmax(0, 1fr) clamp(170px, 15vw, 230px); align-items: center; gap: 18px; min-height: 80px; padding: 12px 46px 12px 18px; cursor: pointer; list-style: none; }
|
||||
.ranking-card summary { position: relative; display: grid; grid-template-columns: 60px minmax(0, 1fr) clamp(150px, 12vw, 200px) clamp(280px, 23vw, 360px); align-items: center; gap: 18px; min-height: 80px; padding: 12px 46px 12px 18px; cursor: pointer; list-style: none; }
|
||||
.ranking-card summary::-webkit-details-marker { display: none; }
|
||||
.ranking-card summary::after { content: '⌄'; position: absolute; top: 50%; right: 15px; color: #858f9f; font-size: 16px; transform: translateY(-50%); transition: transform .15s; }
|
||||
.ranking-card[open] summary::after { transform: translateY(-50%) rotate(180deg); }
|
||||
@@ -997,6 +1127,13 @@
|
||||
.rank-metric small { color: #9ca5b3; font-size: 13px; font-style: normal; font-weight: 750; }
|
||||
.rank-metric strong { color: #f0a0c2; font-size: 18px; line-height: 1.2; }
|
||||
.rank-metric i { color: #90d8ae; font-size: 12px; font-style: normal; font-weight: 800; }
|
||||
.rank-probabilities { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px 12px; padding-left: 18px; border-left: 1px solid #303744; }
|
||||
.rank-probabilities > small { grid-column: 1 / -1; color: #9ca5b3; font-size: 12px; font-weight: 750; }
|
||||
.rank-probabilities > span { display: grid; gap: 2px; }
|
||||
.rank-probabilities b { color: #7f8998; font-size: 10px; font-weight: 750; }
|
||||
.rank-probabilities strong { color: #dce1e8; font-size: 14px; line-height: 1.2; }
|
||||
.rank-probabilities i { color: #7f8998; font-size: 10px; font-style: normal; font-weight: 700; }
|
||||
.probability-guide { margin: 11px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; }
|
||||
.ranking-detail { padding: 0 16px 18px; border-top: 1px solid #2b313c; }
|
||||
.scores { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
|
||||
.scores div { display: grid; gap: 7px; padding: 15px; border-radius: 10px; background: #22202a; }
|
||||
@@ -1024,6 +1161,7 @@
|
||||
.rank-number { grid-column: 1; grid-row: 1; }
|
||||
.rank-members { grid-column: 2; grid-row: 1; }
|
||||
.rank-metric:nth-of-type(3) { grid-column: 2; grid-row: 2; }
|
||||
.rank-probabilities { grid-column: 1 / -1; grid-row: 3; padding: 10px 0 0; border-top: 1px solid #303744; border-left: 0; }
|
||||
.ranking-detail { padding: 0 10px 14px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
awakenings: number[];
|
||||
leaderCharacterId: string;
|
||||
leaderOutfitCardId: string;
|
||||
coreCubeFreeOnly: boolean;
|
||||
};
|
||||
type GreenNodeCost = { points: number; greenCubes: number; greenCoreCubes: number };
|
||||
type GreenNodeEfficiency = {
|
||||
@@ -54,6 +55,13 @@
|
||||
upgrades: GreenNodeEfficiency[];
|
||||
removals: GreenNodeEfficiency[];
|
||||
};
|
||||
type AutomaticPlacementResult = {
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
cardId: string;
|
||||
cardName: string;
|
||||
rarity: number;
|
||||
};
|
||||
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
|
||||
const EFFICIENCY_FORMATION_STORAGE_KEY = 'holodori.green-efficiency-formation.v1';
|
||||
const GREEN_CUBE_ID = 'item-skill_tree_allmember-1';
|
||||
@@ -100,12 +108,14 @@
|
||||
let selectedSocketGroupId = '';
|
||||
let boardCardSearch = '';
|
||||
let automaticPlacementSnapshot: BoardCardPlacements | null = null;
|
||||
let automaticPlacementResult: AutomaticPlacementResult[] = [];
|
||||
let savedAnalysis: SavedAnalysisState | null = null;
|
||||
let efficiencyFormation: EfficiencyFormationState = {
|
||||
cardIds: ['', '', '', '', ''],
|
||||
awakenings: [0, 0, 0, 0, 0],
|
||||
leaderCharacterId: '',
|
||||
leaderOutfitCardId: ''
|
||||
leaderOutfitCardId: '',
|
||||
coreCubeFreeOnly: false
|
||||
};
|
||||
let greenEfficiencyResult: GreenEfficiencyResult | null = null;
|
||||
let greenEfficiencyRunning = false;
|
||||
@@ -163,7 +173,8 @@
|
||||
cardIds: stored.cardIds.map((id) => cardById.has(id) ? id : ''),
|
||||
awakenings: stored.awakenings.map(normalizeEfficiencyAwakening),
|
||||
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 {
|
||||
@@ -222,13 +233,18 @@
|
||||
saveEfficiencyFormation({ ...efficiencyFormation, leaderOutfitCardId: cardId });
|
||||
}
|
||||
|
||||
function updateCoreCubeFreeOnly(coreCubeFreeOnly: boolean) {
|
||||
saveEfficiencyFormation({ ...efficiencyFormation, coreCubeFreeOnly });
|
||||
}
|
||||
|
||||
function importAnalysisFormation() {
|
||||
if (!savedAnalysis) return;
|
||||
saveEfficiencyFormation({
|
||||
cardIds: [...savedAnalysis.cardIds],
|
||||
awakenings: savedAnalysis.awakenings.map(normalizeEfficiencyAwakening),
|
||||
leaderCharacterId: savedAnalysis.leaderCharacterId,
|
||||
leaderOutfitCardId: savedAnalysis.leaderOutfitCardId
|
||||
leaderOutfitCardId: savedAnalysis.leaderOutfitCardId,
|
||||
coreCubeFreeOnly: efficiencyFormation.coreCubeFreeOnly
|
||||
});
|
||||
}
|
||||
|
||||
@@ -625,6 +641,7 @@
|
||||
const path = shortestPathToSet(position.groupId, active, positions);
|
||||
const added = path.filter((groupId) => groupId !== center && !active.has(groupId));
|
||||
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 nextPower = currentFormationPower(nextBoards);
|
||||
const intermediateNodes = added.filter((groupId) => groupId !== position.groupId);
|
||||
@@ -643,6 +660,7 @@
|
||||
const reachable = reachableNodesForPositions(remaining, positions, center);
|
||||
const removed = currentNodes.filter((groupId) => !reachable.has(groupId));
|
||||
if (removed.length === 0) continue;
|
||||
if (efficiencyFormation.coreCubeFreeOnly && greenNodeCost(member.id, removed).greenCoreCubes > 0) continue;
|
||||
const nextMemberNodes = currentNodes.filter((groupId) => reachable.has(groupId));
|
||||
const nextBoards = { ...userData.memberBoards };
|
||||
if (nextMemberNodes.length > 0) nextBoards[member.id] = nextMemberNodes;
|
||||
@@ -788,12 +806,24 @@
|
||||
|
||||
automaticPlacementSnapshot = cloneBoardCards(userData.boardCards);
|
||||
const boardCards = cloneBoardCards(userData.boardCards);
|
||||
const placementResult: AutomaticPlacementResult[] = [];
|
||||
for (const match of matches) {
|
||||
const card = candidateCards[match.rowIndex];
|
||||
const slot = emptyGreenSlots[match.columnIndex];
|
||||
if (!card || !slot) continue;
|
||||
boardCards[slot.member.id] = { ...(boardCards[slot.member.id] ?? {}), [slot.socket.groupId]: card.id };
|
||||
placementResult.push({
|
||||
memberId: slot.member.id,
|
||||
memberName: slot.member.name,
|
||||
cardId: card.id,
|
||||
cardName: cardLabel(card),
|
||||
rarity: card.rarity
|
||||
});
|
||||
}
|
||||
automaticPlacementResult = placementResult.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 });
|
||||
showMessage(`기존 배치를 유지하고 초록색 빈 슬롯 ${matches.length}곳에 보유 카드를 최적 배치했습니다.`);
|
||||
}
|
||||
@@ -802,6 +832,7 @@
|
||||
if (!automaticPlacementSnapshot) return;
|
||||
const boardCards = cloneBoardCards(automaticPlacementSnapshot);
|
||||
automaticPlacementSnapshot = null;
|
||||
automaticPlacementResult = [];
|
||||
persist({ ...userData, boardCards });
|
||||
showMessage('방금 실행한 초록색 카드 자동 배치를 취소했습니다.');
|
||||
}
|
||||
@@ -1111,6 +1142,19 @@
|
||||
{#if automaticPlacementSnapshot}<button type="button" class="undo" on:click={undoAutomaticPlacement}>방금 배치 취소</button>{/if}
|
||||
<button type="button" class="primary" on:click={optimizeEmptyGreenSlots}>중앙 연결 노드 자동 배치</button>
|
||||
</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>
|
||||
</section>
|
||||
<details class="green-efficiency">
|
||||
@@ -1173,6 +1217,15 @@
|
||||
</label>
|
||||
</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>
|
||||
{#if greenEfficiencyError}<p class="efficiency-error" role="alert">{greenEfficiencyError}</p>{/if}
|
||||
{#if greenEfficiencyResult}
|
||||
@@ -1201,7 +1254,7 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}<p class="efficiency-empty">종합력을 올리는 비활성 초록 노드가 없습니다.</p>{/if}
|
||||
{:else}<p class="efficiency-empty">{efficiencyFormation.coreCubeFreeOnly ? '코어 큐브를 소모하지 않고 종합력을 올리는 비활성 초록 노드가 없습니다.' : '종합력을 올리는 비활성 초록 노드가 없습니다.'}</p>{/if}
|
||||
</section>
|
||||
<section class="efficiency-column removal" aria-labelledby="removal-efficiency-title">
|
||||
<div class="efficiency-column-heading">
|
||||
@@ -1221,7 +1274,7 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}<p class="efficiency-empty">초기화를 검토할 활성 초록 노드가 없습니다.</p>{/if}
|
||||
{:else}<p class="efficiency-empty">{efficiencyFormation.coreCubeFreeOnly ? '코어 큐브를 환급하지 않는 활성 초록 노드가 없습니다.' : '초기화를 검토할 활성 초록 노드가 없습니다.'}</p>{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1457,7 +1510,7 @@
|
||||
.board-toolbar p strong { color: #f0a1c2; font-size: 16px; }
|
||||
.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; }
|
||||
.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 strong { color: #b7dec7; font-size: 14px; }
|
||||
.automatic-placement small { color: #82988c; font-size: 12px; line-height: 1.45; }
|
||||
@@ -1467,6 +1520,14 @@
|
||||
.automatic-placement button.undo { color: #c4ccd5; background: transparent; }
|
||||
.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-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: 2; 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::-webkit-details-marker { display: none; }
|
||||
@@ -1503,6 +1564,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 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-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 span { color: #8b96a5; font-size: 13px; }
|
||||
.efficiency-summary strong { margin-right: 10px; color: #d6dde7; font-size: 16px; }
|
||||
|
||||
Reference in New Issue
Block a user