feat: improve analysis and release v1.0.2

This commit is contained in:
2026-08-19 13:47:49 +09:00
parent 488d652e61
commit e259f82b69
30 changed files with 1222 additions and 205 deletions
+163 -73
View File
@@ -1,53 +1,146 @@
# Holodream Tools # 홀로도리 계산기
## Card master data 홀로라이브 드림즈의 카드와 멤버 성장 상태를 관리하고, 악곡별 최적 배치와 카드 조합을 분석하는 비공식 도구입니다.
Collect the current card-related master data and Korean, English, and Japanese text from the public HolodoriDB diff repositories: [최신 버전 다운로드](https://github.com/DD4SN/HolodoriCalc/releases/latest) · 한국어 전용 · PC 환경 권장
```bash ![홀로도리 계산기 홈 화면](docs/images/home.png)
npm run data:sync
```
The generated manifest, raw relational bundle, and card-by-card bundle are written to `data/holodoridb`. Card artwork, movies, and voices are intentionally excluded. ## 주요 기능
### 카드 표시명 수동 수정 - 5성 카드의 타입, 주스탯, 기수와 스킬 정보 조회
- 보유 카드와 개화 단계, 메모리, 멤버 강화 수치 저장
- 멤버별 보드 노드와 연결 카드 설정
- 현재 편성에서 초록 노드의 자원 대비 효율 분석
- 특정 악곡과 5인 편성의 최적 배치 및 빈도 감소 조합 분석
- 고정 멤버를 기준으로 남은 자리에 들어갈 카드 조합 탐색
- 액티브·스페셜 발동 구간과 노트 분포 시각화
- 사용자 데이터 백업과 복원
- 인터넷 연결 없이 실행 가능한 오프라인 버전 제공
특별 버전명이나 번역 이름은 `data/card-overrides.csv`에서 관리합니다. ## 다운로드 및 실행
```csv 1. [최신 Release](https://github.com/DD4SN/HolodoriCalc/releases/latest)를 엽니다.
cardId,versionName,displayName 2. `Assets`에서 `HolodoriCalc-offline-v버전.zip`을 내려받습니다.
card-06002-5-uniq-0066-00,수영복, 3. ZIP 파일을 원하는 폴더에 완전히 압축 해제합니다.
``` 4. 압축을 해제한 폴더의 `index.html`을 실행합니다.
- `cardId`: 수정할 카드의 고유 ID > GitHub가 자동으로 제공하는 `Source code (zip)`은 실행용 배포 파일이 아닙니다. 반드시 `HolodoriCalc-offline-...zip`을 받아 주세요.
- `versionName`: 기본 이름 옆에 표시할 짧은 버전명
- `displayName`: 기본 멤버 이름을 완전히 바꿀 때만 입력하며, 평소에는 비워 둡니다.
수정 후 `npm run data:sync`를 실행하면 카드 목록에 반영됩니다. 이 파일은 원본 데이터를 다시 수집해도 덮어쓰지 않습니다. Chrome 또는 Edge의 최신 버전을 권장합니다. 일부 브라우저는 로컬 HTML의 저장소나 네트워크 접근을 제한할 수 있습니다.
## 브라우저 데이터 ## 처음 사용하는 순서
사용자 데이터는 서버로 전송하지 않고 현재 브라우저의 `localStorage`에 자동 저장합니다. 별도의 `사용자 데이터` 페이지에서 JSON 백업을 내보내거나 가져오고, 전체 데이터를 초기화할 수 있습니다. ### 1. 사용자 데이터를 설정합니다
- 현재 카드 목록의 출력 조건을 자동으로 복원합니다. 상단 메뉴에서 **사용자 데이터 → 카드**로 이동해 보유 카드와 개화 단계를 입력합니다.
- `백업 내보내기`로 버전이 포함된 JSON 파일을 내려받을 수 있습니다.
- `백업 가져오기`로 다른 브라우저에서 데이터를 복원할 수 있습니다.
- `초기화`는 현재 브라우저에 저장된 수집 내역, 저장 덱, 설정을 모두 기본값으로 되돌립니다.
향후 보유 카드와 저장 덱 기능도 `src/lib/models/user-data.ts`의 동일한 버전 데이터에 저장합니다. - 5성 카드는 `0~5` 개화 단계를 설정합니다.
- 4성 카드는 보유 여부와 5개화 여부를 설정합니다.
- 보유하지 않은 카드는 개화 단계를 변경할 수 없습니다.
- 행을 누르면 보유 여부를 빠르게 전환할 수 있습니다.
홀로드림스 덱 최적화와 스킬 로스 분석을 통합할 웹사이트의 개발 기반입니다. 현재 단계에는 게임 기능을 포함하지 않습니다. ![카드 보유 및 개화 설정](docs/images/user-cards.png)
## 구성 **사용자 데이터 → 일반**에서는 메모리 보유 수와 멤버 강화 수치를 설정하고, 전체 사용자 데이터를 백업하거나 복원할 수 있습니다.
### 2. 멤버 보드를 설정합니다
**사용자 데이터 → 보드**에서 멤버를 선택한 뒤 실제 게임에서 활성화한 노드를 같은 형태로 입력합니다.
- 빈 공간을 드래그해 보드를 이동합니다.
- 마우스 휠로 확대하거나 축소합니다.
- 인접하지 않은 노드를 누르면 중심에서 이어지는 최단 경로가 함께 활성화됩니다.
- 보드 슬롯에는 보유 카드를 한 장씩 배치할 수 있습니다.
- `모든 멤버 초록 3칸 활성화`와 `중앙 연결 노드 자동 배치`로 일반적인 설정을 빠르게 적용할 수 있습니다.
![멤버 보드 설정](docs/images/member-board.png)
같은 페이지의 **초록 노드 효율 분석**에서는 현재 편성을 기준으로 다음 항목을 비교할 수 있습니다.
- 아직 활성화하지 않은 노드 중 우선 활성화할 노드
- 이미 활성화한 노드 중 초기화를 검토할 노드
- 종합력 변화, 필요한 연결 노드 수, 자원 소모량과 그린 큐브 효율
목록의 항목을 누르면 해당 멤버와 노드로 바로 이동합니다.
### 3. 카드 정보를 확인합니다
**카드 목록**에서는 타입, 주스탯, 기수, 액티브 확률, 조건부 스킬과 이격 여부로 카드를 필터링할 수 있습니다. 카드 행을 누르면 패시브·액티브·스페셜·의상 효과가 펼쳐집니다.
![카드 목록과 상세 스킬](docs/images/cards.png)
### 4. 5인 편성의 최적 배치를 분석합니다
**배치 분석**은 선택한 악곡과 5인 조합을 유지하면서 가장 높은 점수를 내는 멤버 순서와 빈도 감소 조합을 찾습니다.
1. 악곡과 난이도를 선택합니다.
2. 5장의 카드와 개화 단계를 설정합니다.
3. 필요한 경우 카드별 빈도 감소를 특정 값으로 고정합니다.
4. 리더와 리더 의상을 선택합니다.
5. `최적 배치 분석`을 누릅니다.
![배치 분석 조건 설정](docs/images/analysis.png)
분석 결과에서는 최고·평균 점수, 최적 순서, 카드별 빈도 감소율과 함께 다음 시각화 자료를 확인할 수 있습니다.
- 초당 노트 분포
- 멤버별 액티브 발동 구간
- 중첩을 반영한 최종 액티브 적용 구간
- 멤버별 스페셜 발동 구간
분석 조건과 마지막 결과는 브라우저에 저장되며, 같은 악곡과 난이도의 조합 탐색에서 **기준 조합**으로 사용할 수 있습니다.
### 5. 더 좋은 카드 조합을 탐색합니다
**조합 탐색**은 악곡, 리더, 리더 의상과 최대 4명의 고정 카드를 기준으로 남은 자리에 들어갈 후보를 비교합니다.
- 고정하지 않은 자리에는 전체 5성 카드가 후보로 들어갑니다.
- 자동 후보의 개화 단계와 기수 보너스 덮어쓰기 값을 지정할 수 있습니다.
- 보유 카드의 실제 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.
- 고정 멤버별 개화, 빈도 감소, 파란 노드 전체 적용 여부와 기수 보너스를 따로 설정할 수 있습니다.
- 한 편성에는 같은 인물의 일반 카드와 이격 카드가 동시에 들어가지 않습니다.
- 결과는 최고 점수 기준으로 정렬되며 상위 조합의 상세 분석을 펼쳐볼 수 있습니다.
![조합 탐색 조건과 기준 조합](docs/images/combination.png)
`최대 컴퓨팅 자원 활용`을 켜면 탐색 속도가 빨라질 수 있지만, 분석 중 브라우저와 다른 프로그램의 반응이 느려질 수 있습니다.
## 데이터 저장과 백업
사용자 데이터와 분석 조건은 서버로 전송하지 않고 현재 브라우저의 `localStorage`에 자동 저장합니다.
- 브라우저나 사용자 프로필이 달라지면 별도의 데이터로 취급될 수 있습니다.
- 브라우저 데이터 삭제 시 저장 내용도 사라질 수 있습니다.
- 새 버전으로 이동하기 전 **사용자 데이터 → 일반 → 백업 내보내기**를 권장합니다.
- 백업 JSON은 다른 브라우저나 PC의 **백업 가져오기**에서 복원할 수 있습니다.
## 업데이트
홈 화면에서 현재 버전과 GitHub의 최신 Release를 비교합니다. 새 버전이 있으면 오프라인 ZIP 다운로드 버튼이 표시됩니다.
업데이트는 자동으로 파일을 교체하지 않습니다. 새 ZIP을 내려받아 별도 폴더에 압축을 해제한 뒤 `index.html`을 실행해 주세요. 업데이트 전에 사용자 데이터 백업을 권장합니다.
## 참고
- 이 프로젝트는 홀로라이브 드림즈의 비공식 팬 제작 도구입니다.
- 게임 업데이트와 확보된 데이터 범위에 따라 실제 게임 결과와 차이가 날 수 있습니다.
- 분석 결과는 입력한 카드·개화·보드·메모리·멤버 강화 데이터가 정확하다는 전제에서 계산됩니다.
- 카드와 악곡의 원본 게임 데이터는 프로젝트가 직접 소유하거나 공식 제공하는 데이터가 아닙니다.
<details>
<summary><strong>개발 환경과 실행 방법</strong></summary>
### 기술 구성
- SvelteKit + TypeScript + `@sveltejs/adapter-node` - SvelteKit + TypeScript + `@sveltejs/adapter-node`
- SQLite + Drizzle ORM - SQLite + Drizzle ORM
- Web Worker를 위한 독립 모듈 경계 - Web Worker 기반 클라이언트 계산
- Docker Compose 개발 환경(HMR) - Docker / Docker Compose
- 멀티 스테이지 production 이미지
- Docker named volume 기반 SQLite 영속 저장소
## Docker 개발 실행 계산 엔진은 UI와 분리하며 `UI → Engine → Models` 의존성 방향을 유지합니다.
### Docker 개발 실행
```bash ```bash
docker compose up --build docker compose up --build
@@ -56,32 +149,9 @@ docker compose up --build
- 웹: <http://localhost:5173> - 웹: <http://localhost:5173>
- 상태 확인: <http://localhost:5173/api/health> - 상태 확인: <http://localhost:5173/api/health>
소스 파일을 수정하면 Windows의 Docker Desktop에서도 polling 기반 HMR이 동작합니다. 종료할 때 `docker compose down`은 SQLite 볼륨을 보존합니다. `docker compose down --volumes`는 저장된 볼륨까지 제거하므로 주의하세요.
종료: ### 호스트에서 실행
```bash
docker compose down
```
`docker compose down`은 SQLite 볼륨을 보존합니다. 데이터까지 지우려는 경우에만 `docker compose down --volumes`를 사용하세요.
## production 실행
```bash
docker compose -f compose.prod.yaml up --build -d
```
- 웹: <http://localhost:3000>
- 상태 확인: <http://localhost:3000/api/health>
종료:
```bash
docker compose -f compose.prod.yaml down
```
## 호스트에서 실행
Node.js 22.12 이상이 필요합니다. Node.js 22.12 이상이 필요합니다.
@@ -91,33 +161,53 @@ npm ci
npm run dev npm run dev
``` ```
기본 로컬 DB 경로는 `./data/holodream.db`입니다. ### 검증과 빌드
## 검증 명령
```bash ```bash
npm run check npm run check
npm run build npm run build
npm run build:offline
``` ```
스키마를 변경한 뒤에는 migration을 생성합니다. 릴리즈용 오프라인 ZIP 생성:
```bash ```bash
npm run db:generate npm run package:offline
``` ```
생성된 migration은 서버가 시작될 때 자동 적용됩니다. 호스트에서 수동 적용하려면 `npm run db:migrate`를 사용합니다. ### Production 실행
## 디렉터리 경계 ```bash
docker compose -f compose.prod.yaml up --build -d
```text
src/
├─ lib/
│ ├─ engine/ # UI 프레임워크에 의존하지 않는 순수 계산 로직
│ ├─ models/ # UI·엔진·서버가 공유하는 도메인 타입
│ ├─ server/db/ # SQLite/Drizzle 서버 전용 모듈
│ └─ workers/ # 무거운 최적화 작업의 Web Worker 진입점
└─ routes/ # SvelteKit 화면과 API
``` ```
의존성 방향은 `UI → Engine → Models`를 유지합니다. 계산 엔진은 Svelte나 서버 모듈을 import하지 않습니다. - 웹: <http://localhost:3000>
- 상태 확인: <http://localhost:3000/api/health>
</details>
<details>
<summary><strong>게임 데이터 갱신과 카드 표시명 수정</strong></summary>
공개 HolodoriDB diff 저장소에서 카드 관련 마스터 데이터와 한국어·영어·일본어 텍스트를 수집합니다.
```bash
npm run data:sync
```
생성된 데이터는 `data/holodoridb`에 저장합니다. 카드 일러스트, 영상과 음성은 수집 대상에서 제외합니다.
특별 버전명이나 번역 이름은 `data/card-overrides.csv`에서 관리합니다.
```csv
cardId,versionName,displayName
card-06002-5-uniq-0066-00,수영복,
```
- `cardId`: 수정할 카드의 고유 ID
- `versionName`: 이름 옆에 표시할 짧은 버전명
- `displayName`: 기본 멤버 이름을 완전히 바꿀 때만 입력
수정 후 `npm run data:sync`를 실행하면 반영됩니다. 이 파일은 원본 데이터를 다시 수집해도 덮어쓰지 않습니다.
</details>
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

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "holodream-tools", "name": "holodream-tools",
"version": "1.0.1", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "holodream-tools", "name": "holodream-tools",
"version": "1.0.1", "version": "1.0.2",
"engines": { "engines": {
"node": ">=22.12.0" "node": ">=22.12.0"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "holodream-tools", "name": "holodream-tools",
"version": "1.0.1", "version": "1.0.2",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+25
View File
@@ -0,0 +1,25 @@
# 홀로도리 계산기 1.0.2
조합 탐색의 후보 누락을 줄이고, 패시브 적용 대상과 분석 결과 표시를 개선한 업데이트입니다.
## 주요 변경 사항
- 빈 편성 슬롯이 많을수록 조합 탐색 후보와 정밀 분석 대상을 확대했습니다.
- 여러 선별 기준과 후보별 교체 조합 재검증을 추가해 강한 카드가 조기에 제외될 가능성을 줄였습니다.
- 보유 카드의 실제 개화가 자동 후보 설정값보다 높으면 실제 개화를 우선 적용합니다.
- 편성 카드 목록과 추천 스페셜 발동 순서를 구분해 표시합니다.
- 제한 인원 패시브와 스코어 서포트의 적용 대상을 파라미터 합계 기준으로 판정하도록 수정했습니다.
- 배치 분석과 조합 탐색에 최고점, 최고점의 99%, 95% 이상 달성 확률을 추가했습니다.
- 중앙 연결 노드 자동 배치 결과에서 어떤 카드가 어느 멤버 보드에 배치됐는지 확인할 수 있습니다.
- 초록 노드 효율 분석에 `코어 큐브 미소모만` 검색 옵션을 추가했습니다.
- 스크린샷을 포함한 사용자용 README를 추가했습니다.
## 알려진 문제
> 달성 확률과 이론상 최고점이 실제 인게임 결과보다 다소 높게 평가되는 현상이 확인되었습니다. 현재 결과는 절대적인 예상 점수보다 조합 간 상대 비교와 참고값으로 활용해 주세요.
달성 확률은 모든 노트를 PERFECT로 처리한다는 가정 아래 액티브 발동 확률만 반영합니다. 실제 플레이의 판정과 아직 확인되지 않은 게임 내부 계산 방식에 따라 차이가 발생할 수 있습니다.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.2.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
+12 -3
View File
@@ -97,11 +97,16 @@
} }
</script> </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} {#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> <li><span class="slot">{slot + 1}</span><strong>{cardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each} {/each}
</ol> </ol>
</section>
</div>
{#if showScoreSummary} {#if showScoreSummary}
<div class="score-grid"> <div class="score-grid">
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.expectedScore, baseline.expectedScore)}</small>{/if}</div> <div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.expectedScore, baseline.expectedScore)}</small>{/if}</div>
@@ -164,7 +169,10 @@
</section> </section>
<style> <style>
.order-list { display: grid; gap: 8px; padding: 0; margin: 18px 0 0; list-style: none; } .order-sections { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 18px; }
.order-section { min-width: 0; }
.order-section h3 { margin: 0 0 9px; color: #aeb7c5; font-size: 14px; }
.order-list { display: grid; gap: 8px; padding: 0; margin: 0; list-style: none; }
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; } .order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; } .slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -206,6 +214,7 @@
.time-axis span:first-child { transform: none; } .time-axis span:first-child { transform: none; }
.time-axis span:last-child { transform: translateX(-100%); } .time-axis span:last-child { transform: translateX(-100%); }
@media (max-width: 700px) { @media (max-width: 700px) {
.order-sections { grid-template-columns: 1fr; }
.order-list li { grid-template-columns: 30px minmax(0, 1fr); } .order-list li { grid-template-columns: 30px minmax(0, 1fr); }
.reduction { grid-column: 2; justify-self: start; } .reduction { grid-column: 2; justify-self: start; }
.score-grid { grid-template-columns: 1fr; } .score-grid { grid-template-columns: 1fr; }
+6
View File
@@ -5,6 +5,7 @@ import { toOptimizerCard, type OptimizerSourceCard } from './card-adapter.ts';
const source: OptimizerSourceCard = { const source: OptimizerSourceCard = {
id: 'card', id: 'card',
name: '카드', name: '카드',
power: { statsAtMaxLevel: { performance: 101, technique: 202, sense: 303 } },
details: { details: {
active: [ active: [
{ level: 1, coolTimeSeconds: 10, durationSeconds: 1, effect: '스코어가 10% UP' }, { level: 1, coolTimeSeconds: 10, durationSeconds: 1, effect: '스코어가 10% UP' },
@@ -29,6 +30,11 @@ test('액티브는 1개화, 스페셜은 3개화부터 2레벨을 적용한다',
assert.equal(specialAwakened.special.scoreUpPercent, 40); assert.equal(specialAwakened.special.scoreUpPercent, 40);
}); });
test('2개화부터 보드 제외 기본 파라미터 합계에 10%를 적용한다', () => {
assert.equal(toOptimizerCard(source, 0).naturalParameterTotal, 606);
assert.equal(toOptimizerCard(source, 2).naturalParameterTotal, 669);
});
test('4개화 패시브의 타입별 스코어 서포트 대상과 수치를 변환한다', () => { test('4개화 패시브의 타입별 스코어 서포트 대상과 수치를 변환한다', () => {
const passiveSource: OptimizerSourceCard = { const passiveSource: OptimizerSourceCard = {
...source, ...source,
+13
View File
@@ -16,6 +16,13 @@ export type OptimizerSourceCard = {
type?: string; type?: string;
generation?: string; generation?: string;
versionName?: string; versionName?: string;
power?: {
statsAtMaxLevel?: {
performance: number;
technique: number;
sense: number;
};
};
details: { details: {
passive?: readonly { level: number; description: string }[]; passive?: readonly { level: number; description: string }[];
active: readonly SkillLevel[]; active: readonly SkillLevel[];
@@ -87,6 +94,11 @@ export function toOptimizerCard(
const specialConditionMet = skillConditionSatisfied(special?.condition ?? '', unitMembers); const specialConditionMet = skillConditionSatisfied(special?.condition ?? '', unitMembers);
const activeScore = activeConditionMet ? scorePercent(active?.effect) : 0; const activeScore = activeConditionMet ? scorePercent(active?.effect) : 0;
const specialScore = specialConditionMet ? scorePercent(special?.effect) : 0; const specialScore = specialConditionMet ? scorePercent(special?.effect) : 0;
const baseStats = source.power?.statsAtMaxLevel;
const potentialRate = awakening >= 2 ? 10 : 0;
const naturalParameterTotal = baseStats
? Object.values(baseStats).reduce((sum, value) => sum + Math.ceil(value * (100 + potentialRate) / 100), 0)
: 0;
if (!active || active.coolTimeSeconds === null || active.durationSeconds === null || activeScore === null) { if (!active || active.coolTimeSeconds === null || active.durationSeconds === null || activeScore === null) {
throw new Error(`${source.name}의 액티브 스코어 정보를 계산에 사용할 수 없습니다.`); throw new Error(`${source.name}의 액티브 스코어 정보를 계산에 사용할 수 없습니다.`);
@@ -98,6 +110,7 @@ export function toOptimizerCard(
return { return {
id: source.id, id: source.id,
name: source.versionName ? `${source.name} (${source.versionName})` : source.name, name: source.versionName ? `${source.name} (${source.versionName})` : source.name,
naturalParameterTotal,
type: source.type, type: source.type,
generation: source.generation, generation: source.generation,
active: { active: {
+56 -1
View File
@@ -1,6 +1,12 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { generateCombinationCandidates, resolveCandidateAwakening } from './index.ts'; import {
combinationSearchLimits,
generateAnchoredCombinationCandidates,
generateCombinationCandidates,
resolveCandidateAwakening,
selectMemberDiverseCandidates
} from './index.ts';
const cards = [ const cards = [
{ id: 'a-1', characterId: 'a', score: 10 }, { id: 'a-1', characterId: 'a', score: 10 },
@@ -44,3 +50,52 @@ test('보유한 자동 후보는 설정값보다 실제 개화가 높으면 실
assert.equal(resolveCandidateAwakening(4, true, 2), 4); assert.equal(resolveCandidateAwakening(4, true, 2), 4);
assert.equal(resolveCandidateAwakening(2, false, 5), 2); assert.equal(resolveCandidateAwakening(2, false, 5), 2);
}); });
test('빈 멤버 수에 비례해 선별 및 정밀 분석 후보 수를 늘린다', () => {
assert.deepEqual(combinationSearchLimits(4), {
openSlots: 1,
screeningLimit: 5_000,
baseExactFinalistLimit: 56,
alternativeExactFinalistLimit: 56
});
assert.deepEqual(combinationSearchLimits(3), {
openSlots: 2,
screeningLimit: 10_000,
baseExactFinalistLimit: 112,
alternativeExactFinalistLimit: 112
});
assert.deepEqual(combinationSearchLimits(0), {
openSlots: 5,
screeningLimit: 25_000,
baseExactFinalistLimit: 280,
alternativeExactFinalistLimit: 280
});
});
test('각 자동 후보를 임시 축으로 삼은 조합을 선별한다', () => {
const anchoredCards = [
{ id: 'a', characterId: 'a', score: 100 },
{ id: 'b', characterId: 'b', score: 90 },
{ id: 'c', characterId: 'c', score: 80 },
{ id: 'd', characterId: 'd', score: 1 }
];
const result = generateAnchoredCombinationCandidates(
[],
anchoredCards,
2,
[{ scoreCard: (card) => card.score }],
1
);
assert.ok(result.some((combination) => combination.some((card) => card.id === 'd')));
});
test('정밀 분석 후보에 각 카드의 최고 대표 조합을 남긴다', () => {
const candidates = [
{ cards: [{ id: 'a', characterId: 'a' }, { id: 'b', characterId: 'b' }], score: 100 },
{ cards: [{ id: 'a', characterId: 'a' }, { id: 'c', characterId: 'c' }], score: 90 },
{ cards: [{ id: 'd', characterId: 'd' }, { id: 'e', characterId: 'e' }], score: 20 }
];
const result = selectMemberDiverseCandidates(candidates, (candidate) => candidate.cards, (candidate) => candidate.score, [], 10);
assert.equal(result.length, 3);
assert.ok(result.some((candidate) => candidate.cards.some((card) => card.id === 'e')));
});
+88
View File
@@ -8,6 +8,33 @@ type BeamState<T extends CombinationSearchCard> = {
score: number; score: number;
}; };
export type CombinationSearchLimits = {
openSlots: number;
screeningLimit: number;
baseExactFinalistLimit: number;
alternativeExactFinalistLimit: number;
};
export type CombinationScoringStrategy<T extends CombinationSearchCard> = {
scoreCard: (card: T) => number;
scoreCombination?: (cards: readonly T[]) => number;
};
const SCREENING_LIMIT_PER_OPEN_SLOT = 5_000;
const EXACT_FINALIST_LIMIT_PER_OPEN_SLOT = 56;
export function combinationSearchLimits(fixedCardCount: number, targetSize = 5): CombinationSearchLimits {
const normalizedTargetSize = Math.max(1, Math.floor(targetSize));
const normalizedFixedCardCount = Math.min(normalizedTargetSize, Math.max(0, Math.floor(fixedCardCount)));
const openSlots = Math.max(1, normalizedTargetSize - normalizedFixedCardCount);
return {
openSlots,
screeningLimit: SCREENING_LIMIT_PER_OPEN_SLOT * openSlots,
baseExactFinalistLimit: EXACT_FINALIST_LIMIT_PER_OPEN_SLOT * openSlots,
alternativeExactFinalistLimit: EXACT_FINALIST_LIMIT_PER_OPEN_SLOT * openSlots
};
}
export function resolveCandidateAwakening( export function resolveCandidateAwakening(
configuredAwakening: number, configuredAwakening: number,
owned: boolean, owned: boolean,
@@ -63,3 +90,64 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
.sort((left, right) => right.score - left.score) .sort((left, right) => right.score - left.score)
.map((state) => state.cards); .map((state) => state.cards);
} }
export function generateAnchoredCombinationCandidates<T extends CombinationSearchCard>(
fixedCards: readonly T[],
candidateCards: readonly T[],
targetSize: number,
strategies: readonly CombinationScoringStrategy<T>[],
perAnchorLimit: number
): T[][] {
if (strategies.length === 0 || perAnchorLimit <= 0 || fixedCards.length >= targetSize) return [];
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
const anchors = candidateCards.filter((card) => !fixedCharacters.has(card.characterId));
const perStrategyLimit = Math.max(1, Math.ceil(perAnchorLimit / strategies.length));
const combined = new Map<string, T[]>();
for (const anchor of anchors) {
const anchored = new Map<string, T[]>();
for (const strategy of strategies) {
for (const cards of generateCombinationCandidates(
[...fixedCards, anchor],
candidateCards,
targetSize,
strategy.scoreCard,
perStrategyLimit,
strategy.scoreCombination
)) {
anchored.set(cards.map((card) => card.id).sort().join('|'), cards);
}
}
for (const [key, cards] of Array.from(anchored.entries()).slice(0, perAnchorLimit)) {
combined.set(key, cards);
}
}
return Array.from(combined.values());
}
export function selectMemberDiverseCandidates<T>(
candidates: readonly T[],
cardsOf: (candidate: T) => readonly { id: string }[],
scoreOf: (candidate: T) => number,
fixedCardIds: readonly string[],
limit: number
): T[] {
if (limit <= 0) return [];
const fixedIds = new Set(fixedCardIds);
const ranked = [...candidates].sort((left, right) => scoreOf(right) - scoreOf(left));
const bestByCardId = new Map<string, T>();
for (const candidate of ranked) {
for (const card of cardsOf(candidate)) {
if (!fixedIds.has(card.id) && !bestByCardId.has(card.id)) bestByCardId.set(card.id, candidate);
}
}
const selected = new Map<string, T>();
for (const candidate of bestByCardId.values()) {
const key = cardsOf(candidate).map((card) => card.id).sort().join('|');
selected.set(key, candidate);
if (selected.size >= limit) break;
}
return Array.from(selected.values());
}
+40 -1
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts'; import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts'; import type { ExactSongChart, OptimizerCard } from './types.ts';
const cards: OptimizerCard[] = ['a', 'b', 'c', 'd', 'e'].map((id, index) => ({ const cards: OptimizerCard[] = ['a', 'b', 'c', 'd', 'e'].map((id, index) => ({
@@ -31,10 +32,11 @@ test('정확 분석 결과에 최적 조합의 액티브와 스페셜 지속 구
assert.ok(result.activeWindows.every((window) => window.endSeconds > window.startSeconds)); assert.ok(result.activeWindows.every((window) => window.endSeconds > window.startSeconds));
}); });
test('패시브 스코어 서포트는 순서상 앞선 대상 타입의 지정 인원에게 적용한다', () => { test('패시브 스코어 서포트는 기본 파라미터 합계가 높은 지정 인원에게 적용한다', () => {
const supported = cards.map((card, index) => ({ const supported = cards.map((card, index) => ({
...card, ...card,
type: index < 3 ? '퓨어' : '해피', type: index < 3 ? '퓨어' : '해피',
naturalParameterTotal: [100, 300, 200, 50, 50][index],
passiveScoreSupportEffects: index === 4 passiveScoreSupportEffects: index === 4
? [{ percent: 20, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }] ? [{ percent: 20, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }]
: [] : []
@@ -42,5 +44,42 @@ test('패시브 스코어 서포트는 순서상 앞선 대상 타입의 지정
const baseline = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100 }); const baseline = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100 });
const result = optimizeExactOrderAndCooldowns(supported, chart, { perfectNoteScore: 100 }); const result = optimizeExactOrderAndCooldowns(supported, chart, { perfectNoteScore: 100 });
assert.deepEqual(passiveScoreSupportByCard(supported), [0, 20, 20, 0, 0]);
assert.ok(result.theoreticalMaxScore > baseline.theoreticalMaxScore); assert.ok(result.theoreticalMaxScore > baseline.theoreticalMaxScore);
}); });
test('스페셜 발동 순서를 탐색해 가장 높은 액티브를 강한 구간에 배치한다', () => {
const formation: OptimizerCard[] = [
{ id: 'a', name: 'a', type: '퓨어', naturalParameterTotal: 200, active: { cooldownSeconds: 1, durationSeconds: 0.9, scoreUpPercent: 105 }, special: { durationSeconds: 0, scoreUpPercent: 0 }, allowedCooldownReductions: [0] },
{ id: 'b', name: 'b', type: '퓨어', naturalParameterTotal: 100, active: { cooldownSeconds: 1, durationSeconds: 0.9, scoreUpPercent: 10 }, special: { durationSeconds: 0, scoreUpPercent: 0 }, allowedCooldownReductions: [0] },
{ id: 'c', name: 'c', type: '퓨어', naturalParameterTotal: 300, active: { cooldownSeconds: 1, durationSeconds: 0.9, scoreUpPercent: 110 }, special: { durationSeconds: 2, scoreUpPercent: 100 }, allowedCooldownReductions: [0] },
{ id: 'd', name: 'd', type: '해피', active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 }, special: { durationSeconds: 0, scoreUpPercent: 0 }, allowedCooldownReductions: [0] },
{
id: 'support',
name: 'support',
type: '해피',
active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 },
special: { durationSeconds: 0, scoreUpPercent: 0 },
passiveScoreSupportEffects: [{ percent: 20, target: 'group', targetGroup: '퓨어 타입', targetCount: 2 }],
allowedCooldownReductions: [0]
}
];
const targetChart: ExactSongChart = {
songId: 'formation-order-test',
difficulty: 'expert',
durationSeconds: 3,
scoreRatio: 1,
notes: [
{ timeSeconds: 0.5, type: 'regular', isFlick: false },
{ timeSeconds: 1.1, type: 'regular', isFlick: false }
],
specialStartSeconds: [0, null, null, null, null]
};
const result = optimizeExactOrderAndCooldowns(formation, targetChart, { perfectNoteScore: 100 });
const firstActiveWinner = result.effectiveActiveWindows.find((window) => window.startSeconds <= 1.1 && 1.1 < window.endSeconds);
assert.equal(result.order[0], 'c');
assert.equal(firstActiveWinner?.cardId, 'c');
assert.ok(Math.abs((firstActiveWinner?.scoreUpPercent ?? 0) - 242) < 1e-9);
});
+5 -22
View File
@@ -1,5 +1,5 @@
import { calculateExactScore } from './score.ts'; import { calculateExactScore } from './score.ts';
import { skillTargetBelongsTo } from './skill-condition.ts'; import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts'; import { effectiveCooldownSeconds } from './timeline.ts';
import { COOLDOWN_REDUCTIONS } from './types.ts'; import { COOLDOWN_REDUCTIONS } from './types.ts';
import type { import type {
@@ -17,24 +17,6 @@ type CandidateProfile = {
probability: Float64Array; probability: Float64Array;
}; };
function passiveScoreSupportByCard(order: readonly OptimizerCard[]): number[] {
const totals = order.map(() => 0);
order.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = order.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = order
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.slice(0, effect.targetCount ?? order.length)
.map(({ index }) => index);
for (const index of indexes) totals[index] += effect.percent;
}
});
return totals;
}
function permutations<T>(items: readonly T[]): T[][] { function permutations<T>(items: readonly T[]): T[][] {
if (items.length <= 1) return [Array.from(items)]; if (items.length <= 1) return [Array.from(items)];
const result: T[][] = []; const result: T[][] = [];
@@ -242,17 +224,17 @@ export function optimizeExactOrderAndCooldowns(
if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) { if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) {
throw new Error('서로 다른 카드 5장이 필요합니다.'); throw new Error('서로 다른 카드 5장이 필요합니다.');
} }
const orders = permutations(cards);
const reductionAssignments = assignments(cards); const reductionAssignments = assignments(cards);
const base = baseScores(chart, options.perfectNoteScore); const base = baseScores(chart, options.perfectNoteScore);
const totalCandidates = orders.length * reductionAssignments.length; const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1);
const totalCandidates = orderCount * reductionAssignments.length;
let evaluatedCandidates = 0; let evaluatedCandidates = 0;
let bestScore = Number.NEGATIVE_INFINITY; let bestScore = Number.NEGATIVE_INFINITY;
let bestOrder: OptimizerCard[] | null = null; let bestOrder: OptimizerCard[] | null = null;
let bestReductions: Record<string, CooldownReduction> | null = null; let bestReductions: Record<string, CooldownReduction> | null = null;
let bestReductionTotal = Number.POSITIVE_INFINITY; let bestReductionTotal = Number.POSITIVE_INFINITY;
for (const order of orders) { for (const order of permutations(cards)) {
const profiles = makeProfiles(order, chart); const profiles = makeProfiles(order, chart);
for (const reductions of reductionAssignments) { for (const reductions of reductionAssignments) {
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0); const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
@@ -273,6 +255,7 @@ export function optimizeExactOrderAndCooldowns(
return { return {
...calculateExactScore(bestOrder, bestReductions, chart, options), ...calculateExactScore(bestOrder, bestReductions, chart, options),
order: bestOrder.map((card) => card.id), order: bestOrder.map((card) => card.id),
formationOrder: cards.map((card) => card.id),
cooldownReductions: { ...bestReductions }, cooldownReductions: { ...bestReductions },
...windows, ...windows,
evaluatedCandidates evaluatedCandidates
+1
View File
@@ -6,6 +6,7 @@ export * from './memory.ts';
export * from './maximum-weight-matching.ts'; export * from './maximum-weight-matching.ts';
export * from './optimizer.ts'; export * from './optimizer.ts';
export * from './score.ts'; export * from './score.ts';
export * from './score-probability.ts';
export * from './skill-condition.ts'; export * from './skill-condition.ts';
export * from './timeline.ts'; export * from './timeline.ts';
export * from './types.ts'; export * from './types.ts';
+12
View File
@@ -29,6 +29,18 @@ test('5장과 네 가지 감소율은 122,880개 후보를 만든다', () => {
assert.equal(countOptimizationCandidates(cards), 122_880); assert.equal(countOptimizationCandidates(cards), 122_880);
}); });
test('제한 인원 패시브가 있어도 편성 순서 후보를 추가하지 않는다', () => {
const targeted = cards.map((card, index) => ({
...card,
type: index < 3 ? '퓨어' : '해피',
passiveScoreSupportEffects: index === 4
? [{ percent: 11, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }]
: []
}));
assert.equal(countOptimizationCandidates(targeted), 122_880);
});
test('노트가 몰린 첫 스페셜 슬롯에 가장 강한 스페셜 카드를 배치한다', () => { test('노트가 몰린 첫 스페셜 슬롯에 가장 강한 스페셜 카드를 배치한다', () => {
const fixedReductionCards = cards.map((card) => ({ ...card, allowedCooldownReductions: [0] as const })); const fixedReductionCards = cards.map((card) => ({ ...card, allowedCooldownReductions: [0] as const }));
const result = optimizeOrderAndCooldowns(fixedReductionCards, chart); const result = optimizeOrderAndCooldowns(fixedReductionCards, chart);
+27
View File
@@ -0,0 +1,27 @@
import { skillTargetBelongsTo } from './skill-condition.ts';
import type { OptimizerCard } from './types.ts';
export function passiveScoreSupportByCard(cards: readonly OptimizerCard[]): number[] {
const totals = cards.map(() => 0);
cards.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = cards.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = cards
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.sort((left, right) =>
(right.card.naturalParameterTotal ?? 0) - (left.card.naturalParameterTotal ?? 0) ||
left.card.id.localeCompare(right.card.id))
.slice(0, effect.targetCount ?? cards.length)
.map(({ index }) => index);
for (const index of indexes) totals[index] += effect.percent;
}
});
return totals;
}
export function passiveScoreSupportAt(cards: readonly OptimizerCard[], targetIndex: number): number {
return passiveScoreSupportByCard(cards)[targetIndex] ?? 0;
}
+90
View File
@@ -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
});
});
+242
View File
@@ -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
View File
@@ -1,5 +1,5 @@
import { effectiveCooldownSeconds } from './timeline.ts'; import { effectiveCooldownSeconds } from './timeline.ts';
import { skillTargetBelongsTo } from './skill-condition.ts'; import { passiveScoreSupportAt } from './passive-support.ts';
import type { import type {
CooldownReduction, CooldownReduction,
ExactScoreOptions, ExactScoreOptions,
@@ -34,24 +34,6 @@ export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number {
return weight * CHART_SCORE_WEIGHT_COEFFICIENT; return weight * CHART_SCORE_WEIGHT_COEFFICIENT;
} }
function passiveScoreSupportAt(cards: readonly OptimizerCard[], targetIndex: number): number {
let total = 0;
cards.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = cards.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = cards
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.slice(0, effect.targetCount ?? cards.length)
.map(({ index }) => index);
if (indexes.includes(targetIndex)) total += effect.percent;
}
});
return total;
}
function comboBonus(combo: number): number { function comboBonus(combo: number): number {
if (combo >= 1_000) return 0.1; if (combo >= 1_000) return 0.1;
if (combo >= 100) return Math.floor(combo / 100) * 0.01; if (combo >= 100) return Math.floor(combo / 100) * 0.01;
+11
View File
@@ -5,6 +5,8 @@ export type CooldownReduction = (typeof COOLDOWN_REDUCTIONS)[number];
export type OptimizerCard = { export type OptimizerCard = {
id: string; id: string;
name: string; name: string;
/** 보드·패시브·의상·메모리를 제외한 현재 레벨/개화의 파라미터 합계. */
naturalParameterTotal?: number;
type?: string; type?: string;
generation?: string; generation?: string;
active: { active: {
@@ -116,7 +118,16 @@ export type ExactScoreOptions = {
directSongScoreBonusPercent?: number; directSongScoreBonusPercent?: number;
}; };
export type ScoreAchievementProbabilities = {
maximum: number;
atLeast99Percent: number;
atLeast95Percent: number;
samples: number;
};
export type ExactOptimizationResult = ExactScoreResult & { export type ExactOptimizationResult = ExactScoreResult & {
/** 분석한 5인 편성. 기존 저장 데이터 호환을 위해 필드명을 유지한다. */
formationOrder: readonly string[];
order: readonly string[]; order: readonly string[];
cooldownReductions: Readonly<Record<string, CooldownReduction>>; cooldownReductions: Readonly<Record<string, CooldownReduction>>;
activeWindows: readonly SkillWindow[]; activeWindows: readonly SkillWindow[];
+27
View File
@@ -91,3 +91,30 @@ test('조건부 기수 패시브는 복수 소속 멤버도 대상으로 삼는
assert.equal(result.passiveTotal, 20); assert.equal(result.passiveTotal, 20);
}); });
test('지정 인원 능력치 패시브는 해당 능력치가 아닌 전체 파라미터 합계 상위 멤버에게 적용한다', () => {
const source = {
...card('source', '퓨어 타입 2명의 퍼포먼스가 10% UP'),
type: '해피'
};
const targets = [
{ id: 'performance-high', performance: 200, technique: 0, sense: 0 },
{ id: 'total-high', performance: 100, technique: 200, sense: 0 },
{ id: 'total-second', performance: 150, technique: 100, sense: 0 }
].map(({ id, performance, technique, sense }) => ({
card: {
...card(id, ''),
type: '퓨어',
power: { maxLevel: 80, statsAtMaxLevel: { performance, technique, sense } }
},
awakening: 0
}));
const result = calculateUnitPower([
{ card: source, awakening: 0 },
...targets,
{ card: card('other', ''), awakening: 0 }
]);
assert.equal(result.passiveTotal, 25);
});
+5 -1
View File
@@ -89,7 +89,11 @@ function targetIndexes(
return members return members
.map((member, index) => ({ member, index })) .map((member, index) => ({ member, index }))
.filter(({ member }) => skillTargetBelongsTo(member.card, target[1])) .filter(({ member }) => skillTargetBelongsTo(member.card, target[1]))
.sort((left, right) => parameters[right.index][stat] - parameters[left.index][stat] || left.index - right.index) .sort((left, right) => {
const rightTotal = stats.reduce((sum, key) => sum + parameters[right.index][key], 0);
const leftTotal = stats.reduce((sum, key) => sum + parameters[left.index][key], 0);
return rightTotal - leftTotal || left.member.card.id.localeCompare(right.member.card.id);
})
.slice(0, count) .slice(0, count)
.map(({ index }) => index); .map(({ index }) => index);
} }
+78 -8
View File
@@ -1,7 +1,7 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import { countOptimizationCandidates, optimizeExactOrderAndCooldowns } from '../engine/index.ts'; import { countOptimizationCandidates, estimateScoreAchievementProbabilities, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts'; import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from '../engine/index.ts';
type OptimizeRequest = { type OptimizeRequest = {
type: 'optimize-exact'; type: 'optimize-exact';
@@ -9,6 +9,7 @@ type OptimizeRequest = {
chart: ExactSongChart; chart: ExactSongChart;
perfectNoteScore: number; perfectNoteScore: number;
directSongScoreBonusPercent?: number; directSongScoreBonusPercent?: number;
includeAchievementProbabilities?: boolean;
}; };
type CombinationCandidate = { type CombinationCandidate = {
@@ -24,7 +25,31 @@ type CombinationRequest = {
chart: ExactSongChart; 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 { try {
if (event.data.type === 'optimize-exact') { if (event.data.type === 'optimize-exact') {
@@ -34,14 +59,49 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) =>
onProgress: (progress) => self.postMessage({ type: 'progress', progress }) onProgress: (progress) => self.postMessage({ type: 'progress', progress })
}; };
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options); const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
self.postMessage({ type: 'result', result }); let achievementProbabilities: ScoreAchievementProbabilities | undefined;
if (event.data.includeAchievementProbabilities) {
const orderedCards = cardsForResult(event.data.cards, result);
achievementProbabilities = estimateScoreAchievementProbabilities(
orderedCards,
result.cooldownReductions,
event.data.chart,
{ ...options, sampleCount: 25_000 }
);
}
self.postMessage({ type: 'result', result, achievementProbabilities });
return;
}
if (event.data.type === 'estimate-achievement') {
const request = event.data;
const rankings = request.rankings.map((ranking, rankingIndex) => {
if (!ranking.cards) throw new Error('확률 계산에 필요한 카드 정보가 없습니다.');
const orderedCards = cardsForResult(ranking.cards, ranking.result);
const achievementProbabilities = estimateScoreAchievementProbabilities(
orderedCards,
ranking.result.cooldownReductions,
event.data.chart,
{
perfectNoteScore: ranking.result.perfectNoteScore,
directSongScoreBonusPercent: ranking.directSongScoreBonusPercent,
sampleCount: request.sampleCount
}
);
self.postMessage({
type: 'achievement-progress',
progress: { evaluatedCandidates: rankingIndex + 1, totalCandidates: request.rankings.length }
});
return { ...ranking, achievementProbabilities };
});
self.postMessage({ type: 'achievement-result', rankings });
return; return;
} }
const totalCandidates = event.data.candidates.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0); const totalCandidates = event.data.candidates.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0);
let completedCandidates = 0; let completedCandidates = 0;
let best: { result: ExactOptimizationResult; overallPower: number } | null = null; let best: RankedCombination | null = null;
const rankings: { result: ExactOptimizationResult; overallPower: number }[] = []; const rankings: RankedCombination[] = [];
for (const candidate of event.data.candidates) { for (const candidate of event.data.candidates) {
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, { const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
perfectNoteScore: candidate.perfectNoteScore, perfectNoteScore: candidate.perfectNoteScore,
@@ -54,9 +114,19 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) =>
completedCandidates += countOptimizationCandidates(candidate.cards); completedCandidates += countOptimizationCandidates(candidate.cards);
if (!best || result.theoreticalMaxScore > best.result.theoreticalMaxScore || if (!best || result.theoreticalMaxScore > best.result.theoreticalMaxScore ||
(result.theoreticalMaxScore === best.result.theoreticalMaxScore && result.expectedScore > best.result.expectedScore)) { (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('정밀 분석할 조합이 없습니다.'); if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
rankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore || rankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
+42 -4
View File
@@ -6,7 +6,7 @@
import exactChartCatalog from '$lib/data/exact-chart-catalog.json'; import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { loadExactSongChart } from '$lib/data/chart-loader.ts'; import { loadExactSongChart } from '$lib/data/chart-loader.ts';
import { calculateBoardEffects, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts'; import { 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 { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts'; import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
@@ -69,6 +69,7 @@
let running = false; let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 }; let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null; let result: ExactOptimizationResult | null = null;
let achievementProbabilities: ScoreAchievementProbabilities | undefined;
let errorMessage = ''; let errorMessage = '';
let analysisReady = false; let analysisReady = false;
@@ -182,6 +183,7 @@
if (event.data.type === 'progress') progress = event.data.progress; if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'result') { if (event.data.type === 'result') {
result = event.data.result; result = event.data.result;
achievementProbabilities = event.data.achievementProbabilities;
running = false; running = false;
} }
if (event.data.type === 'error') { if (event.data.type === 'error') {
@@ -315,6 +317,7 @@
async function analyze(): Promise<void> { async function analyze(): Promise<void> {
errorMessage = ''; errorMessage = '';
result = null; result = null;
achievementProbabilities = undefined;
if (!worker || !selectedSong || !selectedChart) return; if (!worker || !selectedSong || !selectedChart) return;
if (new Set(selectedCardIds).size !== 5) { if (new Set(selectedCardIds).size !== 5) {
errorMessage = '서로 다른 카드 5장을 선택해 주세요.'; errorMessage = '서로 다른 카드 5장을 선택해 주세요.';
@@ -342,7 +345,8 @@
cards, cards,
chart, chart,
perfectNoteScore, perfectNoteScore,
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
includeAchievementProbabilities: true
}); });
} catch (error) { } catch (error) {
errorMessage = error instanceof Error ? error.message : '분석 입력을 준비하지 못했습니다.'; errorMessage = error instanceof Error ? error.message : '분석 입력을 준비하지 못했습니다.';
@@ -354,6 +358,19 @@
return card ? cardLabel(card) : cardId; 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[] { function countNotesBySecond(scoreResult: ExactOptimizationResult, durationSeconds: number): number[] {
const counts = Array.from({ length: Math.ceil(durationSeconds) }, () => 0); const counts = Array.from({ length: Math.ceil(durationSeconds) }, () => 0);
for (const note of scoreResult.notes) { for (const note of scoreResult.notes) {
@@ -430,7 +447,7 @@
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>배치 분석</h1> <h1>배치 분석</h1>
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 멤버 배치 순서와 빈도 감소 조합을 찾습니다.</p> <p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.</p>
</div> </div>
</header> </header>
@@ -528,16 +545,27 @@
<section class="panel result" aria-labelledby="result-title"> <section class="panel result" aria-labelledby="result-title">
<div class="section-title"><div><span>02</span><h2 id="result-title">분석 결과</h2></div></div> <div class="section-title"><div><span>02</span><h2 id="result-title">분석 결과</h2></div></div>
{#if result} {#if result}
<div class="order-sections">
<section class="order-section">
<h3>추천 스페셜 발동 순서</h3>
<ol class="order-list"> <ol class="order-list">
{#each result.order as cardId, slot} {#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> <li><span class="slot">{slot + 1}</span><strong>{resultCardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each} {/each}
</ol> </ol>
</section>
</div>
<div class="score-grid"> <div class="score-grid">
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong></div> <div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div> <div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div> <div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
</div> </div>
<div class="probability-grid">
<div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div>
<div><span>최고점의 99% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast99Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.99)}점 이상</small></div>
<div><span>최고점의 95% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast95Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.95)}점 이상</small></div>
</div>
<p class="probability-guide">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
<section class="skill-timeline" aria-labelledby="timeline-title"> <section class="skill-timeline" aria-labelledby="timeline-title">
<div class="timeline-heading"> <div class="timeline-heading">
@@ -662,7 +690,10 @@
.analyze:disabled { opacity: .55; cursor: default; } .analyze:disabled { opacity: .55; cursor: default; }
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; } progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
.error { margin: 16px 0 0; color: #ff9f9f; font-size: 13px; } .error { margin: 16px 0 0; color: #ff9f9f; font-size: 13px; }
.order-list { display: grid; gap: 8px; padding: 0; margin: 22px 0 0; list-style: none; } .order-sections { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 22px; }
.order-section { min-width: 0; }
.order-section h3 { margin: 0 0 9px; color: #aeb7c5; font-size: 14px; }
.order-list { display: grid; gap: 8px; padding: 0; margin: 0; list-style: none; }
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; } .order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; } .slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -671,6 +702,12 @@
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; } .score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
.score-summary span { color: #a9b1be; font-size: 14px; } .score-summary span { color: #a9b1be; font-size: 14px; }
.score-summary strong { color: #f2a3c4; font-size: 20px; } .score-summary strong { color: #f2a3c4; font-size: 20px; }
.probability-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }
.probability-grid > div { display: grid; gap: 7px; padding: 16px; border: 1px solid #303744; border-radius: 10px; background: #171d25; }
.probability-grid span { color: #9ca5b3; font-size: 14px; }
.probability-grid strong { color: #e5e9ef; font-size: 20px; }
.probability-grid small { color: #8d96a5; font-size: 12px; font-weight: 700; }
.probability-guide { margin: 9px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; }
.empty { color: #858f9f; font-size: 14px; line-height: 1.65; } .empty { color: #858f9f; font-size: 14px; line-height: 1.65; }
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; } .skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; } .timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
@@ -710,6 +747,7 @@
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.order-sections { grid-template-columns: 1fr; }
main { width: calc(100% - 24px); padding-top: var(--page-content-top); } main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
.page-header { align-items: flex-start; } .page-header { align-items: flex-start; }
.panel { padding: 17px; } .panel { padding: 17px; }
+195 -57
View File
@@ -9,14 +9,17 @@
import { import {
calculateBoardEffects, calculateBoardEffects,
calculateUnitPower, calculateUnitPower,
combinationSearchLimits,
countOptimizationCandidates, countOptimizationCandidates,
generateAnchoredCombinationCandidates,
generateCombinationCandidates, generateCombinationCandidates,
memoryParameterPermil, memoryParameterPermil,
perfectNoteScoreFromPower, perfectNoteScoreFromPower,
resolveCandidateAwakening, resolveCandidateAwakening,
selectMemberDiverseCandidates,
toOptimizerCard toOptimizerCard
} from '$lib/engine/index.ts'; } 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 { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts'; import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
@@ -33,7 +36,13 @@
heuristic: number; heuristic: number;
skillPotential: 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 DisplayRankedResult = RankedResult & { isBaseline: boolean };
type SavedState = { type SavedState = {
schemaVersion: 1; schemaVersion: 1;
@@ -64,9 +73,6 @@
const STORAGE_KEY = 'holodori.combination.v1'; const STORAGE_KEY = 'holodori.combination.v1';
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2'; 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 REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5); const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
const cardById = new Map(fiveStarCards.map((card) => [card.id, card])); const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
@@ -107,13 +113,17 @@
let displayedRankings: DisplayRankedResult[] = []; let displayedRankings: DisplayRankedResult[] = [];
let baselineState: SavedAnalysisState | null = null; let baselineState: SavedAnalysisState | null = null;
let baselineResult: ExactOptimizationResult | null = null; let baselineResult: ExactOptimizationResult | null = null;
let baselineAchievementProbabilities: ScoreAchievementProbabilities | undefined;
let baselineOverallPower = 0; let baselineOverallPower = 0;
let baselineRunning = false; let baselineRunning = false;
let baselineProgress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 }; let baselineProgress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let baselineError = ''; let baselineError = '';
let screenedCombinationCount = 0; let screenedCombinationCount = 0;
let exactCombinationCount = 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 screeningProgress = 0;
let errorMessage = ''; let errorMessage = '';
let ready = false; let ready = false;
@@ -126,7 +136,12 @@
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions); $: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
$: displayedRankings = buildDisplayedRankings( $: displayedRankings = buildDisplayedRankings(
rankedResults, 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 (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline();
$: if (!result && rankedResults.length > 0) rankedResults = []; $: if (!result && rankedResults.length > 0) rankedResults = [];
@@ -205,8 +220,15 @@
worker.onmessage = (event) => { worker.onmessage = (event) => {
if (event.data.type === 'progress') progress = event.data.progress; if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'combination-result') { if (event.data.type === 'combination-result') {
result = event.data.result; const rankings = Array.isArray(event.data.rankings)
rankedResults = Array.isArray(event.data.rankings) ? event.data.rankings : [{ result: event.data.result, overallPower: event.data.overallPower }]; ? 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; running = false;
searchPhase = 'idle'; searchPhase = 'idle';
} }
@@ -221,6 +243,7 @@
if (event.data.type === 'progress') baselineProgress = event.data.progress; if (event.data.type === 'progress') baselineProgress = event.data.progress;
if (event.data.type === 'result') { if (event.data.type === 'result') {
baselineResult = event.data.result; baselineResult = event.data.result;
baselineAchievementProbabilities = event.data.achievementProbabilities;
baselineRunning = false; baselineRunning = false;
} }
if (event.data.type === 'error') { if (event.data.type === 'error') {
@@ -405,85 +428,127 @@
} }
function screeningCombinations(): CardRow[][] { function screeningCombinations(): CardRow[][] {
const { screeningLimit } = combinationSearchLimits(fixedCards.length);
if (fixedCards.length === 4) { 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[]>(); const combined = new Map<string, CardRow[]>();
for (const score of [roughCardScore, roughSkillScore, roughStatScore]) { for (const strategy of strategies) {
for (const cards of generateCombinationCandidates(fixedCards, fiveStarCards, 5, score, perStrategy)) {
combined.set(combinationKey(cards), cards);
}
}
for (const cards of generateCombinationCandidates( for (const cards of generateCombinationCandidates(
fixedCards, fixedCards,
fiveStarCards, fiveStarCards,
5, 5,
roughStatScore, strategy.scoreCard,
perStrategy, perStrategy,
roughCombinationScore strategy.scoreCombination
)) { )) {
combined.set(combinationKey(cards), cards); 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[] { function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] {
if (fixedCards.length === 4) return prepared; if (fixedCards.length === 4) return prepared;
const { baseExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
const selected = new Map<string, PreparedCandidate>(); const selected = new Map<string, PreparedCandidate>();
const add = (candidates: PreparedCandidate[], count: number) => { const add = (candidates: PreparedCandidate[], count: number) => {
for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate); for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate);
}; };
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), 24); const diverse = selectMemberDiverseCandidates(
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), 12); prepared,
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), 12); (candidate) => candidate.cards,
if (selected.size < BASE_EXACT_FINALIST_LIMIT) { (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)) { for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) {
selected.set(combinationKey(candidate.cards), candidate); 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[] { function buildRefinementFinalists(rankings: RankedResult[]): PreparedCandidate[] {
if (fixedCards.length === 4) return baseFinalists; if (fixedCards.length === 4 || rankings.length === 0) return [];
const selected = new Map(baseFinalists.map((candidate) => [combinationKey(candidate.cards), candidate])); const { alternativeExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
const alternatives = new Map<string, PreparedCandidate>(); const alternatives = new Map<string, { cards: CardRow[]; roughScore: number }>();
const bestByReplacementId = new Map<string, PreparedCandidate>(); 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)); const fixedIds = new Set(fixedCards.map((card) => card.id));
for (const finalist of baseFinalists) { for (const [rankingIndex, ranking] of rankings.slice(0, 10).entries()) {
const sourceCards = finalist.cards.map((card) => cardById.get(card.id)).filter((card): card is CardRow => Boolean(card)); 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) => { sourceCards.forEach((source, index) => {
if (fixedIds.has(source.id)) return; if (fixedIds.has(source.id)) return;
const otherCharacters = new Set(sourceCards.filter((_, cardIndex) => cardIndex !== index).map((card) => card.characterId));
for (const replacement of fiveStarCards) { 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 cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card);
const key = combinationKey(cards); const key = combinationKey(cards);
if (selected.has(key) || alternatives.has(key)) continue; if (key === sourceKey) continue;
const prepared = prepareCandidate(cards); const candidate = alternatives.get(key) ?? { cards, roughScore: roughCombinationScore(cards) };
alternatives.set(key, prepared); alternatives.set(key, candidate);
if (rankingIndex === 0) topResultNeighbors.set(key, candidate);
const previous = bestByReplacementId.get(replacement.id); 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) => const rankedAlternatives = Array.from(alternatives.values()).sort((left, right) =>
right.heuristic - left.heuristic || right.skillPotential - left.skillPotential || right.overallPower - left.overallPower); right.roughScore - left.roughScore || combinationKey(left.cards).localeCompare(combinationKey(right.cards)));
const chosenAlternatives = new Map<string, PreparedCandidate>(); const chosenAlternatives = new Map<string, { cards: CardRow[]; roughScore: number }>();
for (const candidate of bestByReplacementId.values()) { for (const candidate of topResultNeighbors.values()) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate); 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) { for (const candidate of rankedAlternatives) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate); 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()) { return Array.from(chosenAlternatives.values()).map((candidate) => prepareCandidate(candidate.cards));
selected.set(combinationKey(candidate.cards), candidate);
}
return Array.from(selected.values());
} }
function prepareCandidate(cards: CardRow[]): PreparedCandidate { function prepareCandidate(cards: CardRow[]): PreparedCandidate {
@@ -586,7 +651,8 @@
cards: optimizerCards, cards: optimizerCards,
chart, chart,
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, chartMetadata.scoreRatio ?? null), perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, chartMetadata.scoreRatio ?? null),
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
includeAchievementProbabilities: true
}); });
} catch (error) { } catch (error) {
baselineError = error instanceof Error ? error.message : '기준 조합을 분석하지 못했습니다.'; baselineError = error instanceof Error ? error.message : '기준 조합을 분석하지 못했습니다.';
@@ -599,11 +665,67 @@
return `${difference >= 0 ? '+' : ''}${difference.toLocaleString('ko-KR')}`; 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 { function terminateParallelWorkers(): void {
for (const parallelWorker of parallelWorkers) parallelWorker.terminate(); for (const parallelWorker of parallelWorkers) parallelWorker.terminate();
parallelWorkers = []; 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 { function runParallelExact(finalists: PreparedCandidate[], chart: ExactSongChart): void {
terminateParallelWorkers(); terminateParallelWorkers();
const reportedCores = typeof navigator === 'undefined' ? 1 : navigator.hardwareConcurrency || 4; const reportedCores = typeof navigator === 'undefined' ? 1 : navigator.hardwareConcurrency || 4;
@@ -649,11 +771,8 @@
if (completedWorkers !== workerCount) return; if (completedWorkers !== workerCount) return;
completedRankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore || completedRankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
right.result.expectedScore - left.result.expectedScore); right.result.expectedScore - left.result.expectedScore);
rankedResults = completedRankings.slice(0, 10);
result = rankedResults[0]?.result ?? null;
parallelWorkers = []; parallelWorkers = [];
running = false; completeExactPass(completedRankings.slice(0, 10));
searchPhase = 'idle';
} }
if (event.data.type === 'error') fail(event.data.message); if (event.data.type === 'error') fail(event.data.message);
}; };
@@ -672,6 +791,9 @@
} }
running = true; running = true;
searchPhase = 'screening'; searchPhase = 'screening';
exactPass = 'primary';
activeSearchChart = null;
primaryRankings = [];
screeningProgress = 0; screeningProgress = 0;
progress = { evaluatedCandidates: 0, totalCandidates: 1 }; progress = { evaluatedCandidates: 0, totalCandidates: 1 };
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
@@ -686,10 +808,11 @@
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
} }
} }
const finalists = addAlternativeVariantFinalists(selectExactFinalists(prepared)); const finalists = selectExactFinalists(prepared);
exactCombinationCount = finalists.length; exactCombinationCount = finalists.length;
if (finalists.length === 0) throw new Error('조건에 맞는 카드 조합이 없습니다.'); if (finalists.length === 0) throw new Error('조건에 맞는 카드 조합이 없습니다.');
const chart = await loadExactSongChart(selectedChart.path); const chart = await loadExactSongChart(selectedChart.path);
activeSearchChart = chart;
searchPhase = 'exact'; searchPhase = 'exact';
if (maximizeCompute) runParallelExact(finalists, chart); if (maximizeCompute) runParallelExact(finalists, chart);
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart }); else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart });
@@ -756,7 +879,7 @@
<main> <main>
<header> <header>
<h1>조합 탐색</h1> <h1>조합 탐색</h1>
<p class="page-description">악곡과 고정 멤버 조건을 바탕으로 빈자리에 들어갈 카드를 탐색해 최고 점수 조합을 찾습니다.</p> <p class="page-description">악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.</p>
<p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p> <p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p>
</header> </header>
{#if baselineState && baselineMatchesConditions} {#if baselineState && baselineMatchesConditions}
@@ -767,7 +890,7 @@
</div> </div>
<p class="baseline-condition">{baselineChartMetadata?.title} · {baselineState.difficulty === 'expert' ? 'Expert' : 'Hard'}</p> <p class="baseline-condition">{baselineChartMetadata?.title} · {baselineState.difficulty === 'expert' ? 'Expert' : 'Hard'}</p>
<div class="baseline-members"> <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> <div><span>{slot + 1}</span><strong>{baselineCardLabel(cardId)}</strong></div>
{/each} {/each}
</div> </div>
@@ -854,7 +977,7 @@
</div> </div>
<p class="hint">기수 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·악곡 보너스 10% 조건으로 비교합니다.</p> <p class="hint">기수 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·악곡 보너스 10% 조건으로 비교합니다.</p>
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if} {#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"> <label class="compute-option">
<input type="checkbox" bind:checked={maximizeCompute} disabled={running}> <input type="checkbox" bind:checked={maximizeCompute} disabled={running}>
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
@@ -874,15 +997,21 @@
<span class="rank-number">{rank + 1}위</span> <span class="rank-number">{rank + 1}위</span>
<span class="rank-members"> <span class="rank-members">
{#if ranking.isBaseline}<b class="baseline-result-label">기준 조합</b>{/if} {#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)}> <span class:added-member={!ranking.isBaseline && !fixedCardIds.includes(cardId)}>
<small class="member-awakening">{displayedCardAwakening(cardId, ranking.isBaseline)}개화</small> <small class="member-awakening">{displayedCardAwakening(cardId, ranking.isBaseline)}개화</small>
<span class="member-name">{resultCardName(cardId)}</span> <span class="member-name">{resultCardName(cardId)}</span>
</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} {/each}
</span> </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-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> </summary>
<div class="ranking-detail"> <div class="ranking-detail">
<AnalysisResultView <AnalysisResultView
@@ -897,6 +1026,7 @@
</details> </details>
{/each} {/each}
</div> </div>
<p class="probability-guide">달성 확률은 모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
{:else} {:else}
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div> <div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
{/if} {/if}
@@ -980,7 +1110,7 @@
.ranking-card { overflow: hidden; border: 1px solid #303744; border-radius: 12px; background: #12161c; } .ranking-card { overflow: hidden; border: 1px solid #303744; border-radius: 12px; background: #12161c; }
.ranking-card[open] { border-color: #55404f; background: #15151b; } .ranking-card[open] { border-color: #55404f; background: #15151b; }
.ranking-card.baseline-ranking { border-color: #665061; background: #1b171d; } .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::-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 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); } .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 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 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-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; } .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 { 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; } .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-number { grid-column: 1; grid-row: 1; }
.rank-members { grid-column: 2; grid-row: 1; } .rank-members { grid-column: 2; grid-row: 1; }
.rank-metric:nth-of-type(3) { grid-column: 2; grid-row: 2; } .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; } .ranking-detail { padding: 0 10px 14px; }
} }
</style> </style>
+73 -6
View File
@@ -36,6 +36,7 @@
awakenings: number[]; awakenings: number[];
leaderCharacterId: string; leaderCharacterId: string;
leaderOutfitCardId: string; leaderOutfitCardId: string;
coreCubeFreeOnly: boolean;
}; };
type GreenNodeCost = { points: number; greenCubes: number; greenCoreCubes: number }; type GreenNodeCost = { points: number; greenCubes: number; greenCoreCubes: number };
type GreenNodeEfficiency = { type GreenNodeEfficiency = {
@@ -54,6 +55,13 @@
upgrades: GreenNodeEfficiency[]; upgrades: GreenNodeEfficiency[];
removals: GreenNodeEfficiency[]; removals: GreenNodeEfficiency[];
}; };
type AutomaticPlacementResult = {
memberId: string;
memberName: string;
cardId: string;
cardName: string;
rarity: number;
};
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2'; const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const EFFICIENCY_FORMATION_STORAGE_KEY = 'holodori.green-efficiency-formation.v1'; const EFFICIENCY_FORMATION_STORAGE_KEY = 'holodori.green-efficiency-formation.v1';
const GREEN_CUBE_ID = 'item-skill_tree_allmember-1'; const GREEN_CUBE_ID = 'item-skill_tree_allmember-1';
@@ -100,12 +108,14 @@
let selectedSocketGroupId = ''; let selectedSocketGroupId = '';
let boardCardSearch = ''; let boardCardSearch = '';
let automaticPlacementSnapshot: BoardCardPlacements | null = null; let automaticPlacementSnapshot: BoardCardPlacements | null = null;
let automaticPlacementResult: AutomaticPlacementResult[] = [];
let savedAnalysis: SavedAnalysisState | null = null; let savedAnalysis: SavedAnalysisState | null = null;
let efficiencyFormation: EfficiencyFormationState = { let efficiencyFormation: EfficiencyFormationState = {
cardIds: ['', '', '', '', ''], cardIds: ['', '', '', '', ''],
awakenings: [0, 0, 0, 0, 0], awakenings: [0, 0, 0, 0, 0],
leaderCharacterId: '', leaderCharacterId: '',
leaderOutfitCardId: '' leaderOutfitCardId: '',
coreCubeFreeOnly: false
}; };
let greenEfficiencyResult: GreenEfficiencyResult | null = null; let greenEfficiencyResult: GreenEfficiencyResult | null = null;
let greenEfficiencyRunning = false; let greenEfficiencyRunning = false;
@@ -163,7 +173,8 @@
cardIds: stored.cardIds.map((id) => cardById.has(id) ? id : ''), cardIds: stored.cardIds.map((id) => cardById.has(id) ? id : ''),
awakenings: stored.awakenings.map(normalizeEfficiencyAwakening), awakenings: stored.awakenings.map(normalizeEfficiencyAwakening),
leaderCharacterId: leaderMembers.some((member) => member.characterId === stored.leaderCharacterId) ? stored.leaderCharacterId : '', leaderCharacterId: leaderMembers.some((member) => member.characterId === stored.leaderCharacterId) ? stored.leaderCharacterId : '',
leaderOutfitCardId: cardById.get(stored.leaderOutfitCardId)?.details.costume ? stored.leaderOutfitCardId : '' leaderOutfitCardId: cardById.get(stored.leaderOutfitCardId)?.details.costume ? stored.leaderOutfitCardId : '',
coreCubeFreeOnly: stored.coreCubeFreeOnly === true
}; };
} }
} catch { } catch {
@@ -222,13 +233,18 @@
saveEfficiencyFormation({ ...efficiencyFormation, leaderOutfitCardId: cardId }); saveEfficiencyFormation({ ...efficiencyFormation, leaderOutfitCardId: cardId });
} }
function updateCoreCubeFreeOnly(coreCubeFreeOnly: boolean) {
saveEfficiencyFormation({ ...efficiencyFormation, coreCubeFreeOnly });
}
function importAnalysisFormation() { function importAnalysisFormation() {
if (!savedAnalysis) return; if (!savedAnalysis) return;
saveEfficiencyFormation({ saveEfficiencyFormation({
cardIds: [...savedAnalysis.cardIds], cardIds: [...savedAnalysis.cardIds],
awakenings: savedAnalysis.awakenings.map(normalizeEfficiencyAwakening), awakenings: savedAnalysis.awakenings.map(normalizeEfficiencyAwakening),
leaderCharacterId: savedAnalysis.leaderCharacterId, leaderCharacterId: savedAnalysis.leaderCharacterId,
leaderOutfitCardId: savedAnalysis.leaderOutfitCardId leaderOutfitCardId: savedAnalysis.leaderOutfitCardId,
coreCubeFreeOnly: efficiencyFormation.coreCubeFreeOnly
}); });
} }
@@ -625,6 +641,7 @@
const path = shortestPathToSet(position.groupId, active, positions); const path = shortestPathToSet(position.groupId, active, positions);
const added = path.filter((groupId) => groupId !== center && !active.has(groupId)); const added = path.filter((groupId) => groupId !== center && !active.has(groupId));
if (added.length === 0) continue; if (added.length === 0) continue;
if (efficiencyFormation.coreCubeFreeOnly && greenNodeCost(member.id, added).greenCoreCubes > 0) continue;
const nextBoards = { ...userData.memberBoards, [member.id]: [...new Set([...currentNodes, ...added])] }; const nextBoards = { ...userData.memberBoards, [member.id]: [...new Set([...currentNodes, ...added])] };
const nextPower = currentFormationPower(nextBoards); const nextPower = currentFormationPower(nextBoards);
const intermediateNodes = added.filter((groupId) => groupId !== position.groupId); const intermediateNodes = added.filter((groupId) => groupId !== position.groupId);
@@ -643,6 +660,7 @@
const reachable = reachableNodesForPositions(remaining, positions, center); const reachable = reachableNodesForPositions(remaining, positions, center);
const removed = currentNodes.filter((groupId) => !reachable.has(groupId)); const removed = currentNodes.filter((groupId) => !reachable.has(groupId));
if (removed.length === 0) continue; if (removed.length === 0) continue;
if (efficiencyFormation.coreCubeFreeOnly && greenNodeCost(member.id, removed).greenCoreCubes > 0) continue;
const nextMemberNodes = currentNodes.filter((groupId) => reachable.has(groupId)); const nextMemberNodes = currentNodes.filter((groupId) => reachable.has(groupId));
const nextBoards = { ...userData.memberBoards }; const nextBoards = { ...userData.memberBoards };
if (nextMemberNodes.length > 0) nextBoards[member.id] = nextMemberNodes; if (nextMemberNodes.length > 0) nextBoards[member.id] = nextMemberNodes;
@@ -788,12 +806,24 @@
automaticPlacementSnapshot = cloneBoardCards(userData.boardCards); automaticPlacementSnapshot = cloneBoardCards(userData.boardCards);
const boardCards = cloneBoardCards(userData.boardCards); const boardCards = cloneBoardCards(userData.boardCards);
const placementResult: AutomaticPlacementResult[] = [];
for (const match of matches) { for (const match of matches) {
const card = candidateCards[match.rowIndex]; const card = candidateCards[match.rowIndex];
const slot = emptyGreenSlots[match.columnIndex]; const slot = emptyGreenSlots[match.columnIndex];
if (!card || !slot) continue; if (!card || !slot) continue;
boardCards[slot.member.id] = { ...(boardCards[slot.member.id] ?? {}), [slot.socket.groupId]: card.id }; 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 }); persist({ ...userData, boardCards });
showMessage(`기존 배치를 유지하고 초록색 빈 슬롯 ${matches.length}곳에 보유 카드를 최적 배치했습니다.`); showMessage(`기존 배치를 유지하고 초록색 빈 슬롯 ${matches.length}곳에 보유 카드를 최적 배치했습니다.`);
} }
@@ -802,6 +832,7 @@
if (!automaticPlacementSnapshot) return; if (!automaticPlacementSnapshot) return;
const boardCards = cloneBoardCards(automaticPlacementSnapshot); const boardCards = cloneBoardCards(automaticPlacementSnapshot);
automaticPlacementSnapshot = null; automaticPlacementSnapshot = null;
automaticPlacementResult = [];
persist({ ...userData, boardCards }); persist({ ...userData, boardCards });
showMessage('방금 실행한 초록색 카드 자동 배치를 취소했습니다.'); showMessage('방금 실행한 초록색 카드 자동 배치를 취소했습니다.');
} }
@@ -1111,6 +1142,19 @@
{#if automaticPlacementSnapshot}<button type="button" class="undo" on:click={undoAutomaticPlacement}>방금 배치 취소</button>{/if} {#if automaticPlacementSnapshot}<button type="button" class="undo" on:click={undoAutomaticPlacement}>방금 배치 취소</button>{/if}
<button type="button" class="primary" on:click={optimizeEmptyGreenSlots}>중앙 연결 노드 자동 배치</button> <button type="button" class="primary" on:click={optimizeEmptyGreenSlots}>중앙 연결 노드 자동 배치</button>
</div> </div>
{#if automaticPlacementResult.length > 0}
<div class="automatic-placement-result" aria-live="polite">
<strong>자동 배치 결과 <span>{automaticPlacementResult.length}건</span></strong>
<div class="automatic-placement-result-list">
{#each automaticPlacementResult as placement (placement.memberId)}
<div class="automatic-placement-result-item">
<span>{placement.memberName} 보드</span>
<b><i>{placement.rarity}★</i>{placement.cardName}</b>
</div>
{/each}
</div>
</div>
{/if}
</div> </div>
</section> </section>
<details class="green-efficiency"> <details class="green-efficiency">
@@ -1173,6 +1217,15 @@
</label> </label>
</div> </div>
<div class="efficiency-outfit-effect"><span>리더 의상 효과</span><p>{plainSkillText(cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume?.description ?? '') || '적용하지 않음'}</p></div> <div class="efficiency-outfit-effect"><span>리더 의상 효과</span><p>{plainSkillText(cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume?.description ?? '') || '적용하지 않음'}</p></div>
<label class="efficiency-core-filter">
<input
type="checkbox"
checked={efficiencyFormation.coreCubeFreeOnly}
disabled={greenEfficiencyRunning}
on:change={(event) => updateCoreCubeFreeOnly(event.currentTarget.checked)}
/>
<span><strong>코어 큐브 미소모만</strong><small>활성화 또는 초기화 경로 전체에서 그린 코어 큐브를 소모하지 않는 결과만 찾습니다.</small></span>
</label>
</div> </div>
{#if greenEfficiencyError}<p class="efficiency-error" role="alert">{greenEfficiencyError}</p>{/if} {#if greenEfficiencyError}<p class="efficiency-error" role="alert">{greenEfficiencyError}</p>{/if}
{#if greenEfficiencyResult} {#if greenEfficiencyResult}
@@ -1201,7 +1254,7 @@
</button> </button>
{/each} {/each}
</div> </div>
{:else}<p class="efficiency-empty">종합력을 올리는 비활성 초록 노드가 없습니다.</p>{/if} {:else}<p class="efficiency-empty">{efficiencyFormation.coreCubeFreeOnly ? '코어 큐브를 소모하지 않고 종합력을 올리는 비활성 초록 노드가 없습니다.' : '종합력을 올리는 비활성 초록 노드가 없습니다.'}</p>{/if}
</section> </section>
<section class="efficiency-column removal" aria-labelledby="removal-efficiency-title"> <section class="efficiency-column removal" aria-labelledby="removal-efficiency-title">
<div class="efficiency-column-heading"> <div class="efficiency-column-heading">
@@ -1221,7 +1274,7 @@
</button> </button>
{/each} {/each}
</div> </div>
{:else}<p class="efficiency-empty">초기화를 검토할 활성 초록 노드가 없습니다.</p>{/if} {:else}<p class="efficiency-empty">{efficiencyFormation.coreCubeFreeOnly ? '코어 큐브를 환급하지 않는 활성 초록 노드가 없습니다.' : '초기화를 검토할 활성 초록 노드가 없습니다.'}</p>{/if}
</section> </section>
</div> </div>
{/if} {/if}
@@ -1457,7 +1510,7 @@
.board-toolbar p strong { color: #f0a1c2; font-size: 16px; } .board-toolbar p strong { color: #f0a1c2; font-size: 16px; }
.board-toolbar div { display: flex; gap: 6px; } .board-toolbar div { display: flex; gap: 6px; }
.board-toolbar button { padding: 6px 9px; border: 1px solid #394252; border-radius: 7px; color: #aeb7c5; background: #202630; font-size: 12px; font-weight: 750; cursor: pointer; } .board-toolbar button { padding: 6px 9px; border: 1px solid #394252; border-radius: 7px; color: #aeb7c5; background: #202630; font-size: 12px; font-weight: 750; cursor: pointer; }
.automatic-placement { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid #355044; border-radius: 10px; background: rgb(58 113 83 / 10%); } .automatic-placement { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid #355044; border-radius: 10px; background: rgb(58 113 83 / 10%); }
.automatic-placement > div:first-child { display: grid; gap: 4px; min-width: 0; } .automatic-placement > div:first-child { display: grid; gap: 4px; min-width: 0; }
.automatic-placement strong { color: #b7dec7; font-size: 14px; } .automatic-placement strong { color: #b7dec7; font-size: 14px; }
.automatic-placement small { color: #82988c; font-size: 12px; line-height: 1.45; } .automatic-placement small { color: #82988c; font-size: 12px; line-height: 1.45; }
@@ -1467,6 +1520,14 @@
.automatic-placement button.undo { color: #c4ccd5; background: transparent; } .automatic-placement button.undo { color: #c4ccd5; background: transparent; }
.automatic-placement button:hover { filter: brightness(1.1); } .automatic-placement button:hover { filter: brightness(1.1); }
.automatic-placement button:focus-visible { outline: 3px solid rgb(120 196 147 / 25%); outline-offset: 2px; } .automatic-placement button:focus-visible { outline: 3px solid rgb(120 196 147 / 25%); outline-offset: 2px; }
.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 { 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 { display: flex; align-items: center; min-height: 62px; margin: 0 -16px; padding: 12px 16px; color: #dce6e0; cursor: pointer; list-style: none; }
.green-efficiency > summary::-webkit-details-marker { display: none; } .green-efficiency > summary::-webkit-details-marker { display: none; }
@@ -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 { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 10px; margin-top: 9px; padding: 9px 10px; border-radius: 8px; background: #171e26; }
.efficiency-outfit-effect span { color: #d990af; font-size: 13px; font-weight: 850; } .efficiency-outfit-effect span { color: #d990af; font-size: 13px; font-weight: 850; }
.efficiency-outfit-effect p { margin: 0; color: #a7b0bd; font-size: 13px; line-height: 1.5; } .efficiency-outfit-effect p { margin: 0; color: #a7b0bd; font-size: 13px; line-height: 1.5; }
.efficiency-core-filter { display: flex; align-items: center; gap: 10px; margin-top: 9px; padding: 10px 11px; border: 1px solid #314238; border-radius: 8px; background: #111a15; cursor: pointer; }
.efficiency-core-filter input { width: 18px; height: 18px; margin: 0; accent-color: #78c493; cursor: pointer; }
.efficiency-core-filter input:disabled { cursor: default; }
.efficiency-core-filter > span { display: grid; gap: 2px; }
.efficiency-core-filter strong { color: #dbe5df; font-size: 14px; }
.efficiency-core-filter small { color: #8f9e96; font-size: 12px; line-height: 1.45; }
.efficiency-summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px 12px; margin-top: 14px; padding: 10px 12px; border-radius: 8px; background: #10151c; } .efficiency-summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px 12px; margin-top: 14px; padding: 10px 12px; border-radius: 8px; background: #10151c; }
.efficiency-summary span { color: #8b96a5; font-size: 13px; } .efficiency-summary span { color: #8b96a5; font-size: 13px; }
.efficiency-summary strong { margin-right: 10px; color: #d6dde7; font-size: 16px; } .efficiency-summary strong { margin-right: 10px; color: #d6dde7; font-size: 16px; }