Compare commits

..
5 Commits
465 changed files with 444088 additions and 33 deletions
+5
View File
@@ -6,6 +6,11 @@ build
!.env.example
data/*
!data/.gitkeep
!data/card-overrides.csv
!data/holodoridb/
!data/holodoridb/**
data/holodoridb/*.tmp
npm-debug.log*
.DS_Store
.codex-remote-attachments/
*.tsbuildinfo
+36
View File
@@ -1,5 +1,41 @@
# Holodream Tools
## Card master data
Collect the current card-related master data and Korean, English, and Japanese text from the public HolodoriDB diff repositories:
```bash
npm run data:sync
```
The generated manifest, raw relational bundle, and card-by-card bundle are written to `data/holodoridb`. Card artwork, movies, and voices are intentionally excluded.
### 카드 표시명 수동 수정
특별 버전명이나 번역 이름은 `data/card-overrides.csv`에서 관리합니다.
```csv
cardId,versionName,displayName
card-06002-5-uniq-0066-00,수영복,
```
- `cardId`: 수정할 카드의 고유 ID
- `versionName`: 기본 이름 옆에 표시할 짧은 버전명
- `displayName`: 기본 멤버 이름을 완전히 바꿀 때만 입력하며, 평소에는 비워 둡니다.
수정 후 `npm run data:sync`를 실행하면 카드 목록에 반영됩니다. 이 파일은 원본 데이터를 다시 수집해도 덮어쓰지 않습니다.
## 브라우저 데이터
사용자 데이터는 서버로 전송하지 않고 현재 브라우저의 `localStorage`에 자동 저장합니다. 별도의 `사용자 데이터` 페이지에서 JSON 백업을 내보내거나 가져오고, 전체 데이터를 초기화할 수 있습니다.
- 현재 카드 목록의 출력 조건을 자동으로 복원합니다.
- `백업 내보내기`로 버전이 포함된 JSON 파일을 내려받을 수 있습니다.
- `백업 가져오기`로 다른 브라우저에서 데이터를 복원할 수 있습니다.
- `초기화`는 현재 브라우저에 저장된 수집 내역, 저장 덱, 설정을 모두 기본값으로 되돌립니다.
향후 보유 카드와 저장 덱 기능도 `src/lib/models/user-data.ts`의 동일한 버전 데이터에 저장합니다.
홀로드림스 덱 최적화와 스킬 로스 분석을 통합할 웹사이트의 개발 기반입니다. 현재 단계에는 게임 기능을 포함하지 않습니다.
## 구성
+13
View File
@@ -0,0 +1,13 @@
cardId,versionName,displayName
card-00012-5-uniq-0062-00,수영복,
card-00022-5-uniq-0063-00,수영복,
card-00021-5-uniq-0064-00,수영복,
card-00026-5-uniq-0065-00,수영복,
card-06002-5-uniq-0066-00,수영복,
card-00015-5-uniq-0067-00,수영복,
card-00018-5-uniq-0068-00,수영복,
card-00010-5-uniq-0069-00,수영복,
card-00028-5-uniq-0070-00,수영복,
card-03004-5-uniq-0073-00,수영복,
card-04001-5-uniq-0071-00,수영복,
card-04003-5-uniq-0072-00,수영복,
1 cardId versionName displayName
2 card-00012-5-uniq-0062-00 수영복
3 card-00022-5-uniq-0063-00 수영복
4 card-00021-5-uniq-0064-00 수영복
5 card-00026-5-uniq-0065-00 수영복
6 card-06002-5-uniq-0066-00 수영복
7 card-00015-5-uniq-0067-00 수영복
8 card-00018-5-uniq-0068-00 수영복
9 card-00010-5-uniq-0069-00 수영복
10 card-00028-5-uniq-0070-00 수영복
11 card-03004-5-uniq-0073-00 수영복
12 card-04001-5-uniq-0071-00 수영복
13 card-04003-5-uniq-0072-00 수영복
+14
View File
@@ -0,0 +1,14 @@
# HolodoriDB card data
This directory is generated by `npm run data:sync`.
- `manifest.json`: source commits, master-data version, checksums, and validation counts
- `card-master.json`: lossless copies of the card-related master tables and Korean/English/Japanese localization tables
- `cards.json`: the same data joined into one record per card for application use
- `src/lib/data/boards-summary.json`: member board layouts with node coordinates, levels, effects, and costs for application use
The collection script also creates `src/lib/data/cards-summary.json`, a small Korean-only projection used by the card list page.
The ignored source clones are cached under `data/raw/holodoridb` so a later sync only needs to fetch changes.
Binary assets such as card illustrations, movies, and voices are not collected. The source repositories did not contain a license file at the recorded commits, so redistribution terms must be confirmed before publishing the generated data as part of a public service.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+541
View File
@@ -0,0 +1,541 @@
{
"schemaVersion": 1,
"generatedAt": "2026-08-18T01:01:34.166Z",
"source": "HolodoriDB community master-data diff repositories",
"sourceLicense": null,
"sourceLicenseNote": "No LICENSE file was present in the three source repositories at collection time.",
"assetsIncluded": false,
"sources": {
"eng": {
"repository": "https://github.com/HolodoriDB/holodori-db-eng-diff",
"commit": "a15150a8b7413f035f28f8f85d63ab9df122c380",
"commitDate": "2026-08-16T12:45:55Z",
"masterDataVersion": "71e11fbd082eec83d10cff35da7179cbaf319097f0021aa5747fe5a5392b549c"
},
"kor": {
"repository": "https://github.com/HolodoriDB/holodori-db-kor-diff",
"commit": "f5e3c9513edbeec7007e02c0002786291fe1d119",
"commitDate": "2026-08-16T12:18:18Z",
"masterDataVersion": "71e11fbd082eec83d10cff35da7179cbaf319097f0021aa5747fe5a5392b549c"
},
"jpn": {
"repository": "https://github.com/HolodoriDB/holodori-db-jpn-diff",
"commit": "84cb500d8ebf19e306be20faba696123018e49a8",
"commitDate": "2026-08-16T12:55:53Z",
"masterDataVersion": "71e11fbd082eec83d10cff35da7179cbaf319097f0021aa5747fe5a5392b549c"
}
},
"counts": {
"cards": 174,
"rarities": {
"3": 54,
"4": 54,
"5": 66
},
"attributes": {
"1": 57,
"2": 57,
"3": 60
},
"localizedCardNames": {
"eng": 174,
"kor": 174,
"jpn": 174
}
},
"tables": {
"Card": {
"filename": "Card.json",
"count": 174,
"sha256": "bde4e409ce4c184d7a2d4cd34a264e3356a1d0628c3cfdaee881b6f119f6a4a9"
},
"CardLevel": {
"filename": "CardLevel.json",
"count": 1890,
"sha256": "f758ee6688a2f72045be9635b6545a4c82fb15446e388be7447e5ae27672eb2c"
},
"CardLevelLimit": {
"filename": "CardLevelLimit.json",
"count": 45,
"sha256": "f9edf0ad80b67604cf86fc23b5b1782af16ad0866320e83f2132866b6fd9bdaf"
},
"CardPotential": {
"filename": "CardPotential.json",
"count": 15,
"sha256": "b2e54df1a1b58e399c379d98098d800c543d7dca03d31a86092d0c232713343d"
},
"CardPotentialUpgradeItem": {
"filename": "CardPotentialUpgradeItem.json",
"count": 1,
"sha256": "afd82a8731a5b86294c00433d2925cc0149086440acb88bc2c996cd6ad24525c"
},
"Character": {
"filename": "Character.json",
"count": 62,
"sha256": "0cec0ffa96731e25461f47a2125dd3db2111f730c1a912490b7dac74e72a9aec"
},
"CharacterGrouping": {
"filename": "CharacterGrouping.json",
"count": 16,
"sha256": "fc6f0955e9162920b5e5ade93e79443db36b664a5abab8f0f97966f114ccca07"
},
"CharacterProduction": {
"filename": "CharacterProduction.json",
"count": 4,
"sha256": "99570e07ab760ce5fb9b7549ecea131d497a96c9114e8717abca7c339ab37346"
},
"Costume": {
"filename": "Costume.json",
"count": 182,
"sha256": "a45926292833e1927aa8cf2c4427d6937743ed044bc395331734cc6b40182539"
},
"SdCostume": {
"filename": "SdCostume.json",
"count": 290,
"sha256": "2fcf6448638df807915f2bba60188657374cbeb230ec251e44cc84a7c56e65d0"
},
"LiveLeaderSkill": {
"filename": "LiveLeaderSkill.json",
"count": 174,
"sha256": "e58b9e6671ea10c3220422b0da9335ce23659793bf77ada2489b8c48be822a3a"
},
"LivePassiveSkill": {
"filename": "LivePassiveSkill.json",
"count": 174,
"sha256": "d4b40eb960f6314b3ea1c02d5724520bf21bffe99d40dda27f592d903ce05345"
},
"LivePassiveSkillLevel": {
"filename": "LivePassiveSkillLevel.json",
"count": 348,
"sha256": "c1103ef24a22d62e0fe375460e1d247073e21b155f1f296b3d46728b4284235e"
},
"LivePassiveSkillEffect": {
"filename": "LivePassiveSkillEffect.json",
"count": 178,
"sha256": "52f4f6bfae8a2f12494b553fe272920dcc0b1014787ca89d85f919b4a248ec6b"
},
"LiveActiveSkill": {
"filename": "LiveActiveSkill.json",
"count": 176,
"sha256": "2828267edb9a2f9395070a2c12cbc3f0991937644dd606722a969c980a9585d3"
},
"LiveActiveSkillLevel": {
"filename": "LiveActiveSkillLevel.json",
"count": 351,
"sha256": "82970f85b34ac632eef08de873e6cbd826e1e6271efaf8f7ea48235f083dff32"
},
"LiveActiveSkillEffect": {
"filename": "LiveActiveSkillEffect.json",
"count": 52,
"sha256": "5fba3d09bbeccd26d514c2ee5a8cf51ac37f13568e02ba6817718d51733a13c5"
},
"LiveSpecialSkill": {
"filename": "LiveSpecialSkill.json",
"count": 174,
"sha256": "5eed11dfb765627028aaf143f53c3363385a35a3ee884f3bbe190f8d10b3992b"
},
"LiveSpecialSkillLevel": {
"filename": "LiveSpecialSkillLevel.json",
"count": 348,
"sha256": "25c7bebf7027097ac21505bd2aa625384ceaed6ff2ba077ca6d471b22fd09584"
},
"LiveSkillTrigger": {
"filename": "LiveSkillTrigger.json",
"count": 215,
"sha256": "a31e446f4d9d1a920a919261b8106770b56496e1912c33495e42be391b736f76"
},
"LiveSkillEffectTarget": {
"filename": "LiveSkillEffectTarget.json",
"count": 83,
"sha256": "025be13f9b3513e7743269c7fc474c4ae714b6507cfffaa3a8404ccb5635b92a"
},
"LiveSkillEffectFilterCategory": {
"filename": "LiveSkillEffectFilterCategory.json",
"count": 10,
"sha256": "2f359d8c43d583e060b4fa24702690d683ae51602ead49fd9abc743510db1513"
},
"Poster": {
"filename": "Poster.json",
"count": 28,
"sha256": "a75e14e4a75091c41d6d0790d30a96d743eedad50806fc7f85697ca86a56b664"
},
"PosterCollectEffect": {
"filename": "PosterCollectEffect.json",
"count": 50,
"sha256": "d2dbe4f2fbc49a4243e1b77bf2eb4ed4921777849bc13842a54077dd57d482e1"
},
"SkillTreeConnectEffect": {
"filename": "SkillTreeConnectEffect.json",
"count": 40,
"sha256": "11268710d192a838fdc4d3be6b6beeff44a8d0631b84425616ee00da6e4a5715"
},
"SkillTreeConnectEffectExtent": {
"filename": "SkillTreeConnectEffectExtent.json",
"count": 89,
"sha256": "d747891fcf6b5dbb27d2ad355d7bf685769f5eb98825d0fec32312c1bfcf7b93"
},
"SkillTreeEffect": {
"filename": "SkillTreeEffect.json",
"count": 120,
"sha256": "36277a6b971331c0c2ea430f384a425b8670516e3ddb4f95d3dde667bdc45011"
},
"SkillTreeEffectPassiveTrigger": {
"filename": "SkillTreeEffectPassiveTrigger.json",
"count": 81,
"sha256": "0f748c9e53a9560e334fc168577d9a6995bd0811621a80242dc5fdca62d29512"
},
"SkillTreeEffectTarget": {
"filename": "SkillTreeEffectTarget.json",
"count": 79,
"sha256": "974ea67375c3b71d1c3eac5befb7584cae55a4cd7da194095c4d75fce700d101"
},
"SkillTreeEffectValueLimit": {
"filename": "SkillTreeEffectValueLimit.json",
"count": 2,
"sha256": "b89c4b3b8e37658a63d9487562bed9aaaf194274b72f9161916c409c45a9380f"
},
"SkillTreeNode": {
"filename": "SkillTreeNode.json",
"count": 323,
"sha256": "30d2479d6516c96c214caec98380454963733b1ef5f3ab75a171f4f461924605"
},
"SkillTreeNodeAutoSelection": {
"filename": "SkillTreeNodeAutoSelection.json",
"count": 15,
"sha256": "a44de989a6718584e6f151a50f17fefee84e1669987c96d518f810670a3db184"
},
"SkillTreeNodePosition": {
"filename": "SkillTreeNodePosition.json",
"count": 608,
"sha256": "973e1efd5a35a291ddbc0c963b15068ba6df3e504ed18d4cf9246b831c94dffa"
},
"SkillTreePoint": {
"filename": "SkillTreePoint.json",
"count": 54,
"sha256": "7e3236507fd0a4d72ecce6f51f54e77ada8c1bd3b679dff29fca89cae945163a"
}
},
"localizations": {
"eng": {
"Card": {
"filename": "LangCard_Eng.json",
"count": 174,
"sha256": "f9722ef9d6fb3817989f83db9a5c332a17497e7f3952639677e2f9e1fa0abc06"
},
"Character": {
"filename": "LangCharacter_Eng.json",
"count": 402,
"sha256": "0553a6fdc416f398a8c03f74d1e370a19a8156b508fe389fdc65f44bd2f6b4e6"
},
"CharacterGrouping": {
"filename": "LangCharacterGrouping_Eng.json",
"count": 32,
"sha256": "5e1beff92c9f356fc3a88e2ab8dba976fa1a06d61158ac50ea21932e5e8b61a3"
},
"CharacterProduction": {
"filename": "LangCharacterProduction_Eng.json",
"count": 4,
"sha256": "05b767adf73e190838b541666f788d59b5b66e3e46b4e88cc571490e9de4b585"
},
"Costume": {
"filename": "LangCostume_Eng.json",
"count": 182,
"sha256": "db86a28a6b929f0d9f1094f1b1ea39a5aa6dfd298dd050b48cf9147c50b34d67"
},
"SdCostume": {
"filename": "LangSdCostume_Eng.json",
"count": 290,
"sha256": "5dd66fa7fd0b819ad098b0b7909a4b7ecd1cd4d27ee2cc304b477638526ebabc"
},
"GeneratedLiveActiveSkillEffect": {
"filename": "LangGeneratedLiveActiveSkillEffect_Eng.json",
"count": 52,
"sha256": "902e494c75ef55ba354b506d3578ffafda342a6c99cf90fd6573b5a57878202b"
},
"GeneratedLiveActiveSkillLevel": {
"filename": "LangGeneratedLiveActiveSkillLevel_Eng.json",
"count": 351,
"sha256": "3a3d0d2d7e6b7f1f9e98a8d7f4e0dad406422b6cb4fbd720ccae68b4ded5c487"
},
"GeneratedLiveLeaderSkill": {
"filename": "LangGeneratedLiveLeaderSkill_Eng.json",
"count": 174,
"sha256": "1ae291b27f6d99eb2fb25c5ce27aa56ecea77a1e73ec57f06659ddcf51522009"
},
"GeneratedLivePassiveSkillEffect": {
"filename": "LangGeneratedLivePassiveSkillEffect_Eng.json",
"count": 178,
"sha256": "6583ea64ed64d6a2c24fd0b96731c11eb46b0ee2bd27f7febab84cec6b0e76e7"
},
"GeneratedLivePassiveSkillLevel": {
"filename": "LangGeneratedLivePassiveSkillLevel_Eng.json",
"count": 348,
"sha256": "1da7ff4d2b0bd99281aeb0394e799b761088c0066d00bcfa7aaa092bbb361687"
},
"GeneratedLiveSkillEffectTarget": {
"filename": "LangGeneratedLiveSkillEffectTarget_Eng.json",
"count": 83,
"sha256": "ae0721ba79a38a4d55d6e283ec9cef1797a7da979192039cb6a4356fecf5b14c"
},
"GeneratedLiveSkillTrigger": {
"filename": "LangGeneratedLiveSkillTrigger_Eng.json",
"count": 215,
"sha256": "f0437ef8382539ed60d9a4ffc0fcbfe220808f3a453eddc6af3ea451097274e1"
},
"GeneratedLiveSpecialSkillLevel": {
"filename": "LangGeneratedLiveSpecialSkillLevel_Eng.json",
"count": 348,
"sha256": "04dc888915eed7b5fc01572cee0dc480249f4dd54b508eb9e4e6a3bfe6a84e05"
},
"GeneratedSkillTreeConnectEffect": {
"filename": "LangGeneratedSkillTreeConnectEffect_Eng.json",
"count": 40,
"sha256": "35ad31fc5fb00f78aae801e756a6df27f94a02b3040e2be63e4db168b8509f7f"
},
"GeneratedSkillTreeEffect": {
"filename": "LangGeneratedSkillTreeEffect_Eng.json",
"count": 120,
"sha256": "590e393c867912d60fcd2f24b87f1f3b1cba80327019f38bdfa041acfe157433"
},
"GeneratedSkillTreeEffectPassiveTrigger": {
"filename": "LangGeneratedSkillTreeEffectPassiveTrigger_Eng.json",
"count": 81,
"sha256": "73efed3baf79aac747af8b22e913312144c353afb005e59867446117b9edf811"
},
"GeneratedSkillTreeEffectTarget": {
"filename": "LangGeneratedSkillTreeEffectTarget_Eng.json",
"count": 79,
"sha256": "c1debace151aea72fb69a98bb9e22acac6b1c8f96ea9751595adfd6f545b56ce"
},
"LiveSkillEffectFilterCategory": {
"filename": "LangLiveSkillEffectFilterCategory_Eng.json",
"count": 10,
"sha256": "fd8325befb98e9bae01acb2fc85657f5b3c9331ef51abb548a7b8b85be830ef8"
},
"SkillTreeEffect": {
"filename": "LangSkillTreeEffect_Eng.json",
"count": 5,
"sha256": "636c102f7461afcd0b3d1292095a15491dc56b5299938135bb1aac072fc2c7b5"
},
"SkillTreePoint": {
"filename": "LangSkillTreePoint_Eng.json",
"count": 54,
"sha256": "8956dc234d3140b207f13ff3b9243da81bebaa269c43e10d45f1b2499b90e397"
}
},
"kor": {
"Card": {
"filename": "LangCard_Kor.json",
"count": 174,
"sha256": "448ae14796cecd434666618aa5d63ff8348fe94780f9a7c975c2dcb7298a13fd"
},
"Character": {
"filename": "LangCharacter_Kor.json",
"count": 402,
"sha256": "204ba36d7bb77dfa9314e1ea39a25674cf3274761109ba26d881bedc7559af11"
},
"CharacterGrouping": {
"filename": "LangCharacterGrouping_Kor.json",
"count": 32,
"sha256": "8e97c6950f6b8a8dc5f4f597e62fac5328da051484d6d14277db0ea57fec63e6"
},
"CharacterProduction": {
"filename": "LangCharacterProduction_Kor.json",
"count": 4,
"sha256": "05b767adf73e190838b541666f788d59b5b66e3e46b4e88cc571490e9de4b585"
},
"Costume": {
"filename": "LangCostume_Kor.json",
"count": 182,
"sha256": "8eab70215043422bdb2d79e0ed04f831fcfc7b752010968ee559348119bfe3d3"
},
"SdCostume": {
"filename": "LangSdCostume_Kor.json",
"count": 290,
"sha256": "ebedae476122acb831a2c16ce5866c9baba24fc2826cc98a9116b18d2ff47712"
},
"GeneratedLiveActiveSkillEffect": {
"filename": "LangGeneratedLiveActiveSkillEffect_Kor.json",
"count": 52,
"sha256": "2b7ba6c54a88ef28c29bde8df2324fffa75059f56adaab6594b391d75ed678f2"
},
"GeneratedLiveActiveSkillLevel": {
"filename": "LangGeneratedLiveActiveSkillLevel_Kor.json",
"count": 351,
"sha256": "8320427b5fbafc7e53f65c3a9d04737dba0af74b01b1757681a7c69babd6c596"
},
"GeneratedLiveLeaderSkill": {
"filename": "LangGeneratedLiveLeaderSkill_Kor.json",
"count": 174,
"sha256": "b1b4f54738fb6e28a12ca11ebfcf3f74645a3134cbc893519df8ca67b5ee28d7"
},
"GeneratedLivePassiveSkillEffect": {
"filename": "LangGeneratedLivePassiveSkillEffect_Kor.json",
"count": 178,
"sha256": "5d1ada4bef3a0c3cc3e890489b39d2bfaeda999081908c0b657f29a748ae915b"
},
"GeneratedLivePassiveSkillLevel": {
"filename": "LangGeneratedLivePassiveSkillLevel_Kor.json",
"count": 348,
"sha256": "2afe4179bafb998c2d6ddf1deec90302f66d60cd966a471d6b375c205e4eec0b"
},
"GeneratedLiveSkillEffectTarget": {
"filename": "LangGeneratedLiveSkillEffectTarget_Kor.json",
"count": 83,
"sha256": "2793e9e7a528543c598c39e7d0b6c1dcfad12eab808a5be1641af22b2e0e98ce"
},
"GeneratedLiveSkillTrigger": {
"filename": "LangGeneratedLiveSkillTrigger_Kor.json",
"count": 215,
"sha256": "ed8493eb3c373f23403bc2eb71f3e7b40e60cd53ff469bb9333c4d9dbcaaeaad"
},
"GeneratedLiveSpecialSkillLevel": {
"filename": "LangGeneratedLiveSpecialSkillLevel_Kor.json",
"count": 348,
"sha256": "e1d2972614f6d60b54924db9b440c3ce377a6344f537b8313058aa6d3322c72e"
},
"GeneratedSkillTreeConnectEffect": {
"filename": "LangGeneratedSkillTreeConnectEffect_Kor.json",
"count": 40,
"sha256": "9ede8335420be2fc069b7ef4c7dc6d9e11da0a42d011e4b4b19caa6a1b9162f4"
},
"GeneratedSkillTreeEffect": {
"filename": "LangGeneratedSkillTreeEffect_Kor.json",
"count": 120,
"sha256": "753b94f2df410cf8e259bcd29f3b9705c3441d976506f128cb1af8c7af8d2f6b"
},
"GeneratedSkillTreeEffectPassiveTrigger": {
"filename": "LangGeneratedSkillTreeEffectPassiveTrigger_Kor.json",
"count": 81,
"sha256": "e2ee0a7b17ee91bbd639695993101506ee24e46f25b18490832cd25298102b65"
},
"GeneratedSkillTreeEffectTarget": {
"filename": "LangGeneratedSkillTreeEffectTarget_Kor.json",
"count": 79,
"sha256": "3c3614ac203ef45c796538758f10ed17f8ed6f099b5c2955027a9c20a2897196"
},
"LiveSkillEffectFilterCategory": {
"filename": "LangLiveSkillEffectFilterCategory_Kor.json",
"count": 10,
"sha256": "b418e6eb85ed616cd515821cf1ccb4887a825ac56de0387dc8a024e8676b871d"
},
"SkillTreeEffect": {
"filename": "LangSkillTreeEffect_Kor.json",
"count": 5,
"sha256": "ebb1949bb729f9ff6abb7cb32a372813dac13197fa5127e448a54c437ee44137"
},
"SkillTreePoint": {
"filename": "LangSkillTreePoint_Kor.json",
"count": 54,
"sha256": "a5106ba6a2b126ea58ec7be3ba5762a6dc3fb7f3966685bf3373cd531b99279f"
}
},
"jpn": {
"Card": {
"filename": "LangCard_Jpn.json",
"count": 174,
"sha256": "5334af207d35a342dcb2e24df7e0ae6c47f0e41fd1f1ac9b0a93b9238485b46d"
},
"Character": {
"filename": "LangCharacter_Jpn.json",
"count": 402,
"sha256": "95cecc78c797072a84b8638f319daf290347f22726cfda46685fa3f0e86f4fec"
},
"CharacterGrouping": {
"filename": "LangCharacterGrouping_Jpn.json",
"count": 32,
"sha256": "e344e604213a52ef9a3b983928e966a12b76ab3e3f3818b2b75a8dffe27fddb2"
},
"CharacterProduction": {
"filename": "LangCharacterProduction_Jpn.json",
"count": 4,
"sha256": "07828295ff15c0d7e10d1539a616b86c08a1d60bc12222a34fc4640348ef6ef7"
},
"Costume": {
"filename": "LangCostume_Jpn.json",
"count": 182,
"sha256": "dc376c668b8d299c05bd3f8ab302d57475a9ee1455991b462c3dd5168fc1768f"
},
"SdCostume": {
"filename": "LangSdCostume_Jpn.json",
"count": 290,
"sha256": "bce32f3326432c3426dee38abba709bf91aeb04207a9d058156a2e3afca0b83a"
},
"GeneratedLiveActiveSkillEffect": {
"filename": "LangGeneratedLiveActiveSkillEffect_Jpn.json",
"count": 52,
"sha256": "5dfd2e092938fce819c785e93e39ff60547f82992c1b12cdbe68dcf3771ecff0"
},
"GeneratedLiveActiveSkillLevel": {
"filename": "LangGeneratedLiveActiveSkillLevel_Jpn.json",
"count": 351,
"sha256": "ee55ac4522fc5b0f641b38203e4fcdcc4ea340f011553e9b1cf1be5a5c212293"
},
"GeneratedLiveLeaderSkill": {
"filename": "LangGeneratedLiveLeaderSkill_Jpn.json",
"count": 174,
"sha256": "c83e4436db04305094e527d6130390ff3c195fe495277b636f6a21c53986ab83"
},
"GeneratedLivePassiveSkillEffect": {
"filename": "LangGeneratedLivePassiveSkillEffect_Jpn.json",
"count": 178,
"sha256": "a197afd9d1b752c2eec24c65558f1d398b44527a8d5f5f5a2f4220afcb381c3d"
},
"GeneratedLivePassiveSkillLevel": {
"filename": "LangGeneratedLivePassiveSkillLevel_Jpn.json",
"count": 348,
"sha256": "5e2b927e997aa438359e98b94f065d9650517ea81b173305fd706779c9a5ed9d"
},
"GeneratedLiveSkillEffectTarget": {
"filename": "LangGeneratedLiveSkillEffectTarget_Jpn.json",
"count": 83,
"sha256": "d455f598bb25dda6752b6761677d89a9b863dd73957ba1f1711465507ea3e5ff"
},
"GeneratedLiveSkillTrigger": {
"filename": "LangGeneratedLiveSkillTrigger_Jpn.json",
"count": 215,
"sha256": "371bc61e9ae8a86b30ca94e4457decb23183554fe1e4cd15d8e53f0ca628a334"
},
"GeneratedLiveSpecialSkillLevel": {
"filename": "LangGeneratedLiveSpecialSkillLevel_Jpn.json",
"count": 348,
"sha256": "f20e52a776256504c2bf82f00ebd039b9afd94ae665ceaf79117b3756212cc6b"
},
"GeneratedSkillTreeConnectEffect": {
"filename": "LangGeneratedSkillTreeConnectEffect_Jpn.json",
"count": 40,
"sha256": "c66e3e9eeed415e20d40bfa21b22ccac0e0f9927aac17b172c6d5d12e41e7bf1"
},
"GeneratedSkillTreeEffect": {
"filename": "LangGeneratedSkillTreeEffect_Jpn.json",
"count": 120,
"sha256": "4f7fe5cd22633bea68c83f26ceb4cca1e2546d258a4bbd747f224997a5701067"
},
"GeneratedSkillTreeEffectPassiveTrigger": {
"filename": "LangGeneratedSkillTreeEffectPassiveTrigger_Jpn.json",
"count": 81,
"sha256": "3253e2cc541ede84ad76b24cc3a47739f206c8868166cfab684227df9e1b0767"
},
"GeneratedSkillTreeEffectTarget": {
"filename": "LangGeneratedSkillTreeEffectTarget_Jpn.json",
"count": 79,
"sha256": "7e9eeb8f8e9f3f9e5ee4f8f2d50ad59044646425fb137405a03f89d0f67de3a3"
},
"LiveSkillEffectFilterCategory": {
"filename": "LangLiveSkillEffectFilterCategory_Jpn.json",
"count": 10,
"sha256": "fd1bb4245caa5e14b1b9daad05e7475760cf0d1b5a676afc1bab201a31a45e0c"
},
"SkillTreeEffect": {
"filename": "LangSkillTreeEffect_Jpn.json",
"count": 5,
"sha256": "5535633d61e58204f6c42f5f542f662ca2c9fa4739d8ef9d2835d0461a8ddc33"
},
"SkillTreePoint": {
"filename": "LangSkillTreePoint_Jpn.json",
"count": 54,
"sha256": "4afa8d71ccff1a1230cb6704e95fa0cffaeb208e98070a14ac33085291ad17e3"
}
}
}
}
+4
View File
@@ -11,7 +11,11 @@
"build": "vite build",
"preview": "vite preview --host 0.0.0.0",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test:engine": "node --test src/lib/engine/*.test.ts",
"start": "node build",
"data:sync": "node scripts/collect-holodoridb.mjs",
"data:import-songs": "node scripts/import-legacy-song-data.mjs",
"data:collect-charts": "node scripts/collect-exact-charts.mjs",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
+200
View File
@@ -0,0 +1,200 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
const sourceOrigin = process.argv[2] ?? 'https://dreams.wf-calc.net';
const outputDirectory = path.resolve('static/data/charts');
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
const selectedDifficulties = new Set(['HARD', 'EXPERT']);
const chartScoreWeightCoefficient = 0.3713416563352296;
const musicMaster = JSON.parse(await readFile(path.resolve('data/raw/holodoridb/kor/Music.json'), 'utf8'));
const musicById = new Map(musicMaster.map((row) => [row.data?.id ?? row.id, row.data ?? row]));
const singerMetadataOverrides = {
m0334: { singerType: 'solo', singerCharacterIds: ['chr-04001'] }
};
function singerType(value) {
return typeof value === 'string' ? value.split('_').at(-1)?.toLowerCase() ?? null : null;
}
async function fetchText(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} 요청 실패: ${response.status}`);
return response.text();
}
function requiredMatch(text, pattern, label) {
const match = text.match(pattern);
if (!match) throw new Error(`${label}을 찾을 수 없습니다.`);
return match[1];
}
function decodeTemplateLiteral(value) {
return value.replaceAll('\\\\', '\\').replaceAll('\\`', '`').replaceAll('\\${', '${');
}
function decodeSingleQuotedLiteral(value) {
let decoded = '';
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character !== '\\') {
decoded += character;
continue;
}
const escaped = value[index + 1];
index += 1;
if (escaped === '\\' || escaped === "'") decoded += escaped;
else if (escaped === 'n') decoded += '\n';
else if (escaped === 'r') decoded += '\r';
else if (escaped === 't') decoded += '\t';
else if (escaped === 'b') decoded += '\b';
else if (escaped === 'f') decoded += '\f';
else if (escaped === 'u') {
const code = value.slice(index + 1, index + 5);
decoded += String.fromCodePoint(Number.parseInt(code, 16));
index += 4;
} else decoded += escaped;
}
return decoded;
}
function extractJsonParseArgument(moduleText) {
const prefix = "JSON.parse('";
const start = moduleText.indexOf(prefix);
if (start < 0) throw new Error('채보 모듈에서 JSON 데이터를 찾을 수 없습니다.');
const valueStart = start + prefix.length;
for (let index = valueStart; index < moduleText.length; index += 1) {
if (moduleText[index] !== "'") continue;
let slashCount = 0;
for (let cursor = index - 1; cursor >= valueStart && moduleText[cursor] === '\\'; cursor -= 1) {
slashCount += 1;
}
if (slashCount % 2 === 0) {
return decodeSingleQuotedLiteral(moduleText.slice(valueStart, index));
}
}
throw new Error('채보 JSON 문자열의 끝을 찾을 수 없습니다.');
}
function flattenEvents(chartData) {
const noteEvents = chartData.note_events ?? {};
const green = (noteEvents.green_blocks ?? noteEvents.green ?? [])
.flatMap((block) => Array.isArray(block) ? block : [block]);
return [
...(noteEvents.blue ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
...green.map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
...(noteEvents.pink ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular', is_flick: true }))
]
.map((note) => ({
timeSeconds: note.time_seconds,
type: note.type === 'mid' ? 'mid' : 'regular',
isFlick: note.is_flick === true
}))
.filter((note) => Number.isFinite(note.timeSeconds))
.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
}
function scoreRatioFromNotes(notes) {
const countedMidTimes = new Set();
let weight = 0;
for (const note of notes) {
if (note.type === 'mid') {
if (countedMidTimes.has(note.timeSeconds)) continue;
countedMidTimes.add(note.timeSeconds);
weight += 0.1;
} else {
weight += note.isFlick ? 1.05 : 1;
}
}
return weight * chartScoreWeightCoefficient;
}
const home = await fetchText(sourceOrigin);
const mainAsset = requiredMatch(home, /<script type="module" crossorigin src="([^"]+)"/, '메인 스크립트');
const mainModule = await fetchText(new URL(mainAsset, sourceOrigin));
const simulatorAsset = requiredMatch(mainModule, /\.\/UnitSimulatorPage-([^"']+\.js)/, '점수 시뮬레이터 모듈');
const simulatorModule = await fetchText(new URL(`/assets/UnitSimulatorPage-${simulatorAsset}`, sourceOrigin));
const catalogPrefix = 'us=JSON.parse(`';
const catalogStart = simulatorModule.indexOf(catalogPrefix);
const catalogEnd = simulatorModule.indexOf('`)', catalogStart + catalogPrefix.length);
if (catalogStart < 0 || catalogEnd < 0) throw new Error('채보 카탈로그를 찾을 수 없습니다.');
const sourceCatalog = JSON.parse(
decodeTemplateLiteral(simulatorModule.slice(catalogStart + catalogPrefix.length, catalogEnd))
);
const catalogByKey = new Map(sourceCatalog.map((chart) => [chart.key, chart]));
const imports = [];
const importPattern = /charts\/([^"/]+\.json)"[^\n]{0,180}?import\("\.\/([^"/]+\.js)"/g;
for (const match of simulatorModule.matchAll(importPattern)) {
const [, chartFile, assetFile] = match;
const [songId, difficultyAndLevel] = chartFile.split('.', 2);
const difficulty = difficultyAndLevel.split('-')[0].toUpperCase();
if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, chartFile, assetFile });
}
if (imports.length === 0) throw new Error('수집할 Hard/Expert 채보 모듈을 찾지 못했습니다.');
await mkdir(outputDirectory, { recursive: true });
let completed = 0;
const normalizedCharts = [];
const queue = [...imports];
async function worker() {
while (queue.length > 0) {
const item = queue.shift();
if (!item) return;
const moduleText = await fetchText(new URL(`/assets/${item.assetFile}`, sourceOrigin));
const chartData = JSON.parse(extractJsonParseArgument(moduleText));
const sourceEntry = catalogByKey.get(`${item.songId}::${item.difficulty}`);
const music = musicById.get(item.songId);
const singerOverride = singerMetadataOverrides[item.songId];
const notes = flattenEvents(chartData);
const output = {
schemaVersion: 1,
songId: item.songId,
difficulty: item.difficulty.toLowerCase(),
durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0),
scoreRatio: scoreRatioFromNotes(notes),
scoreCalibrationSource: 'measured-formula-v1',
notes,
specialStartSeconds: (chartData.sp_activation_events ?? [])
.sort((left, right) => left.slot - right.slot)
.map((event) => event.time_seconds),
singerType: singerOverride?.singerType ?? singerType(music?.musicSingerType),
singerCharacterIds: singerOverride?.singerCharacterIds ?? music?.characterIds ?? [],
source: {
page: sourceOrigin,
transcriptionStatus: chartData.transcription_status,
susSha256: chartData.source?.sha256 ?? null
}
};
const fileName = `${item.songId}.${item.difficulty.toLowerCase()}.json`;
await writeFile(path.join(outputDirectory, fileName), JSON.stringify(output), 'utf8');
normalizedCharts.push({
songId: item.songId,
title: sourceEntry?.title ?? item.songId,
difficulty: item.difficulty.toLowerCase(),
durationSeconds: output.durationSeconds,
scoreRatio: output.scoreRatio,
totalNotes: notes.length,
singerType: output.singerType,
singerCharacterIds: output.singerCharacterIds,
path: `/data/charts/${fileName}`
});
completed += 1;
if (completed % 20 === 0 || completed === imports.length) {
console.log(`${completed}/${imports.length} 채보 수집 완료`);
}
}
}
await Promise.all(Array.from({ length: 6 }, () => worker()));
normalizedCharts.sort((left, right) => left.songId.localeCompare(right.songId) || left.difficulty.localeCompare(right.difficulty));
await writeFile(catalogPath, `${JSON.stringify({
schemaVersion: 1,
source: sourceOrigin,
charts: normalizedCharts
}, null, 2)}\n`, 'utf8');
console.log(`${normalizedCharts.length}개 정확 채보를 저장했습니다.`);
+759
View File
@@ -0,0 +1,759 @@
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const rawRoot = path.join(projectRoot, 'data', 'raw', 'holodoridb');
const outputRoot = path.join(projectRoot, 'data', 'holodoridb');
const appDataRoot = path.join(projectRoot, 'src', 'lib', 'data');
const cardOverridesPath = path.join(projectRoot, 'data', 'card-overrides.csv');
const offline = process.argv.includes('--offline');
function parseCsvLine(line) {
const values = [];
let value = '';
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const character = line[index];
if (character === '"') {
if (quoted && line[index + 1] === '"') {
value += '"';
index += 1;
} else {
quoted = !quoted;
}
} else if (character === ',' && !quoted) {
values.push(value.trim());
value = '';
} else {
value += character;
}
}
values.push(value.trim());
return values;
}
async function loadCardOverrides() {
const csv = await readFile(cardOverridesPath, 'utf8');
const lines = csv.replace(/^\uFEFF/, '').split(/\r?\n/).filter((line) => line.trim() && !line.trimStart().startsWith('#'));
const headers = parseCsvLine(lines.shift() ?? '');
const cardIdIndex = headers.indexOf('cardId');
const versionNameIndex = headers.indexOf('versionName');
const displayNameIndex = headers.indexOf('displayName');
if (cardIdIndex < 0 || versionNameIndex < 0 || displayNameIndex < 0) {
throw new Error('data/card-overrides.csv must contain cardId, versionName, and displayName columns');
}
return new Map(lines.map((line) => {
const values = parseCsvLine(line);
return [values[cardIdIndex], {
versionName: values[versionNameIndex] ?? '',
displayName: values[displayNameIndex] ?? ''
}];
}));
}
const sources = {
eng: {
repository: 'https://github.com/HolodoriDB/holodori-db-eng-diff.git',
suffix: 'Eng'
},
kor: {
repository: 'https://github.com/HolodoriDB/holodori-db-kor-diff.git',
suffix: 'Kor'
},
jpn: {
repository: 'https://github.com/HolodoriDB/holodori-db-jpn-diff.git',
suffix: 'Jpn'
}
};
// Tables needed to reconstruct every card and every effect exposed by a card.
const masterTableNames = [
'Card',
'CardLevel',
'CardLevelLimit',
'CardPotential',
'CardPotentialUpgradeItem',
'Character',
'CharacterGrouping',
'CharacterProduction',
'Costume',
'SdCostume',
'LiveLeaderSkill',
'LivePassiveSkill',
'LivePassiveSkillLevel',
'LivePassiveSkillEffect',
'LiveActiveSkill',
'LiveActiveSkillLevel',
'LiveActiveSkillEffect',
'LiveSpecialSkill',
'LiveSpecialSkillLevel',
'LiveSkillTrigger',
'LiveSkillEffectTarget',
'LiveSkillEffectFilterCategory',
'Poster',
'PosterCollectEffect',
'SkillTreeConnectEffect',
'SkillTreeConnectEffectExtent',
'SkillTreeEffect',
'SkillTreeEffectPassiveTrigger',
'SkillTreeEffectTarget',
'SkillTreeEffectValueLimit',
'SkillTreeNode',
'SkillTreeNodeAutoSelection',
'SkillTreeNodePosition',
'SkillTreePoint'
];
const localizationTableNames = [
'Card',
'Character',
'CharacterGrouping',
'CharacterProduction',
'Costume',
'SdCostume',
'GeneratedLiveActiveSkillEffect',
'GeneratedLiveActiveSkillLevel',
'GeneratedLiveLeaderSkill',
'GeneratedLivePassiveSkillEffect',
'GeneratedLivePassiveSkillLevel',
'GeneratedLiveSkillEffectTarget',
'GeneratedLiveSkillTrigger',
'GeneratedLiveSpecialSkillLevel',
'GeneratedSkillTreeConnectEffect',
'GeneratedSkillTreeEffect',
'GeneratedSkillTreeEffectPassiveTrigger',
'GeneratedSkillTreeEffectTarget',
'LiveSkillEffectFilterCategory',
'SkillTreeEffect',
'SkillTreePoint'
];
function runGit(args, options = {}) {
return execFileSync('git', args, {
cwd: projectRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
...options
}).trim();
}
function gitAt(repositoryPath, args) {
const normalizedPath = repositoryPath.replaceAll('\\', '/');
return runGit(['-c', `safe.directory=${normalizedPath}`, '-C', repositoryPath, ...args]);
}
async function prepareSource(locale, source) {
const repositoryPath = path.join(rawRoot, locale);
try {
gitAt(repositoryPath, ['rev-parse', '--git-dir']);
if (offline) {
process.stdout.write(`Using cached ${locale} source...\n`);
} else {
process.stdout.write(`Updating ${locale} source...\n`);
gitAt(repositoryPath, ['fetch', '--depth', '1', 'origin', 'main']);
}
} catch {
process.stdout.write(`Cloning ${locale} source...\n`);
await mkdir(path.dirname(repositoryPath), { recursive: true });
runGit(['clone', '--depth', '1', source.repository, repositoryPath]);
}
const commit = gitAt(repositoryPath, ['rev-parse', 'origin/main']);
const commitDate = gitAt(repositoryPath, ['show', '-s', '--format=%cI', commit]);
const commitMessage = gitAt(repositoryPath, ['show', '-s', '--format=%s', commit]);
return {
...source,
repositoryPath,
commit,
commitDate,
commitMessage
};
}
function readSourceFile(source, filename) {
return gitAt(source.repositoryPath, ['show', `${source.commit}:${filename}`]);
}
function parseSourceTable(source, filename) {
const text = readSourceFile(source, filename);
try {
const value = JSON.parse(text);
if (!Array.isArray(value)) {
throw new Error('top-level value is not an array');
}
return { value, text };
} catch (error) {
throw new Error(`Invalid JSON in ${filename} from ${source.commit}: ${error.message}`);
}
}
function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
function unwrap(row) {
return row?.data ?? row;
}
function indexBy(rows, property = 'id') {
return new Map(rows.map((row) => {
const value = unwrap(row);
return [value[property], value];
}));
}
function groupBy(rows, property) {
const groups = new Map();
for (const row of rows) {
const value = unwrap(row);
const key = value[property];
const group = groups.get(key) ?? [];
group.push(value);
groups.set(key, group);
}
return groups;
}
function sortByNumber(values, property) {
return [...values].sort((left, right) => Number(left[property] ?? 0) - Number(right[property] ?? 0));
}
function compactEnum(value) {
if (typeof value !== 'string') return value;
return value.split('_').at(-1)?.toLowerCase() ?? value;
}
function buildTextLookups(localizations) {
return Object.fromEntries(Object.entries(localizations).map(([locale, tables]) => {
const entries = Object.values(tables)
.flat()
.map(unwrap)
.filter((row) => typeof row.id === 'string' && typeof row.text === 'string')
.map((row) => [row.id, row.text]);
return [locale, new Map(entries)];
}));
}
function translateId(textLookups, id) {
if (!id) return {};
return Object.fromEntries(Object.entries(textLookups).map(([locale, lookup]) => [locale, lookup.get(id) ?? null]));
}
function withTranslations(value, textLookups) {
if (!value) return null;
const translations = {};
for (const [key, entry] of Object.entries(value)) {
if (key.endsWith('LangId') && typeof entry === 'string') {
translations[key.slice(0, -6)] = translateId(textLookups, entry);
}
if (key.endsWith('LangIds') && Array.isArray(entry)) {
translations[key.slice(0, -7)] = entry.map((id) => translateId(textLookups, id));
}
}
return Object.keys(translations).length > 0 ? { ...value, translations } : { ...value };
}
function resolveEffects(rows, targetsById, textLookups) {
return rows.map((effect) => ({
...withTranslations(effect, textLookups),
target: effect.liveSkillEffectTargetId
? withTranslations(targetsById.get(effect.liveSkillEffectTargetId), textLookups)
: null
}));
}
function assert(condition, message) {
if (!condition) throw new Error(`Validation failed: ${message}`);
}
const preparedSources = {};
for (const [locale, source] of Object.entries(sources)) {
preparedSources[locale] = await prepareSource(locale, source);
}
const masterVersions = new Set(
Object.values(preparedSources).map((source) => source.commitMessage.replace(/^Master data version\s+/, ''))
);
assert(masterVersions.size === 1, 'locale repositories are not on the same master data version');
const tables = {};
const tableManifest = {};
for (const tableName of masterTableNames) {
const filename = `${tableName}.json`;
const canonical = parseSourceTable(preparedSources.eng, filename);
const hashes = {};
for (const [locale, source] of Object.entries(preparedSources)) {
const sourceText = readSourceFile(source, filename);
hashes[locale] = sha256(sourceText);
}
assert(new Set(Object.values(hashes)).size === 1, `${filename} differs between locale repositories`);
tables[tableName] = canonical.value;
tableManifest[tableName] = { filename, count: canonical.value.length, sha256: hashes.eng };
}
const localizations = {};
const localizationManifest = {};
for (const [locale, source] of Object.entries(preparedSources)) {
localizations[locale] = {};
localizationManifest[locale] = {};
for (const tableName of localizationTableNames) {
const filename = `Lang${tableName}_${source.suffix}.json`;
const parsed = parseSourceTable(source, filename);
localizations[locale][tableName] = parsed.value;
localizationManifest[locale][tableName] = {
filename,
count: parsed.value.length,
sha256: sha256(parsed.text)
};
}
}
const textLookups = buildTextLookups(localizations);
const cards = tables.Card.map(unwrap);
const charactersById = indexBy(tables.Character);
const cardLevelsByGroup = groupBy(tables.CardLevel, 'groupId');
const cardLevelLimitsByGroup = groupBy(tables.CardLevelLimit, 'groupId');
const cardPotentialsByGroup = groupBy(tables.CardPotential, 'groupId');
const passiveSkillsById = indexBy(tables.LivePassiveSkill);
const passiveLevelsBySkill = groupBy(tables.LivePassiveSkillLevel, 'livePassiveSkillId');
const passiveEffectsByGroup = groupBy(tables.LivePassiveSkillEffect, 'groupId');
const activeSkillsById = indexBy(tables.LiveActiveSkill);
const activeLevelsBySkill = groupBy(tables.LiveActiveSkillLevel, 'liveActiveSkillId');
const activeEffectsByGroup = groupBy(tables.LiveActiveSkillEffect, 'groupId');
const specialSkillsById = indexBy(tables.LiveSpecialSkill);
const specialLevelsBySkill = groupBy(tables.LiveSpecialSkillLevel, 'liveSpecialSkillId');
const triggersByGroup = groupBy(tables.LiveSkillTrigger, 'groupId');
const effectTargetsById = indexBy(tables.LiveSkillEffectTarget);
const connectEffectsById = groupBy(tables.SkillTreeConnectEffect, 'id');
const connectExtentsByGroup = groupBy(tables.SkillTreeConnectEffectExtent, 'groupId');
const skillTreeEffectsById = indexBy(tables.SkillTreeEffect);
const skillTreeNodesByGroup = groupBy(tables.SkillTreeNode, 'groupId');
const skillTreePositionsByGroup = groupBy(tables.SkillTreeNodePosition, 'groupId');
const costumesById = indexBy(tables.Costume);
const sdCostumesById = indexBy(tables.SdCostume);
const leaderSkillsById = indexBy(tables.LiveLeaderSkill);
function resolvePassiveSkill(skillId) {
const skill = passiveSkillsById.get(skillId);
if (!skill) return null;
return {
...withTranslations(skill, textLookups),
levels: sortByNumber(passiveLevelsBySkill.get(skillId) ?? [], 'level').map((level) => ({
...withTranslations(level, textLookups),
effects: resolveEffects(
passiveEffectsByGroup.get(level.livePassiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
)
}))
};
}
function resolveActiveSkill(skillId) {
const skill = activeSkillsById.get(skillId);
if (!skill) return null;
return {
...withTranslations(skill, textLookups),
levels: sortByNumber(activeLevelsBySkill.get(skillId) ?? [], 'level').map((level) => ({
...withTranslations(level, textLookups),
effects: resolveEffects(
activeEffectsByGroup.get(level.liveActiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
),
additionalEffects: resolveEffects(
activeEffectsByGroup.get(level.additionalLiveActiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
),
triggers: (triggersByGroup.get(level.additionalLiveSkillTriggerGroupId) ?? [])
.map((trigger) => withTranslations(trigger, textLookups))
}))
};
}
function resolveSpecialSkill(skillId) {
const skill = specialSkillsById.get(skillId);
if (!skill) return null;
return {
...withTranslations(skill, textLookups),
levels: sortByNumber(specialLevelsBySkill.get(skillId) ?? [], 'level').map((level) => ({
...withTranslations(level, textLookups),
effects: resolveEffects(
activeEffectsByGroup.get(level.liveActiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
),
additionalEffects: resolveEffects(
activeEffectsByGroup.get(level.additionalLiveActiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
),
triggers: (triggersByGroup.get(level.additionalLiveSkillTriggerGroupId) ?? [])
.map((trigger) => withTranslations(trigger, textLookups))
}))
};
}
function resolveLeaderSkill(skillId) {
const skill = leaderSkillsById.get(skillId);
if (!skill) return null;
return {
...withTranslations(skill, textLookups),
effects: resolveEffects(
passiveEffectsByGroup.get(skill.livePassiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
)
};
}
function resolveCostume(costumeId, costumeIndex) {
const costume = costumeIndex.get(costumeId);
if (!costume) return null;
return {
...withTranslations(costume, textLookups),
leaderSkill: costume.liveLeaderSkillId ? resolveLeaderSkill(costume.liveLeaderSkillId) : null
};
}
const cardRecords = cards
.map((card) => {
const character = charactersById.get(card.characterId);
const connectEffects = sortByNumber(connectEffectsById.get(card.skillTreeConnectEffectId) ?? [], 'level');
return {
id: card.id,
order: card.order,
rarity: compactEnum(card.rarity),
attribute: compactEnum(card.attributeType),
card: withTranslations(card, textLookups),
character: withTranslations(character, textLookups),
progression: {
levels: sortByNumber(cardLevelsByGroup.get(card.cardLevelGroupId) ?? [], 'level'),
levelLimits: sortByNumber(cardLevelLimitsByGroup.get(card.cardLevelLimitGroupId) ?? [], 'levelLimit'),
potentials: sortByNumber(cardPotentialsByGroup.get(card.cardPotentialGroupId) ?? [], 'upgradeCount')
},
skills: {
passive: resolvePassiveSkill(card.livePassiveSkillId),
active: resolveActiveSkill(card.liveActiveSkillId),
special: resolveSpecialSkill(card.liveSpecialSkillId)
},
skillTreeConnectEffects: connectEffects.map((effect) => ({
...withTranslations(effect, textLookups),
extent: sortByNumber(
connectExtentsByGroup.get(effect.skillTreeConnectEffectExtentGroupId) ?? [],
'positionY'
)
})),
rewards: {
costume: resolveCostume(card.rewardCostumeId, costumesById),
sdCostume: resolveCostume(card.rewardSdCostumeId, sdCostumesById),
potentialAlternativeReward: card.potentialAlternativeReward ?? null,
acquireReward: card.acquireReward ?? null
}
};
})
.sort((left, right) => Number(left.order) - Number(right.order));
assert(cards.length > 0, 'no cards were collected');
assert(new Set(cards.map((card) => card.id)).size === cards.length, 'card IDs are not unique');
assert(cards.every((card) => charactersById.has(card.characterId)), 'one or more cards have no character');
assert(cards.every((card) => passiveSkillsById.has(card.livePassiveSkillId)), 'one or more cards have no passive skill');
assert(cards.every((card) => activeSkillsById.has(card.liveActiveSkillId)), 'one or more cards have no active skill');
assert(cards.every((card) => specialSkillsById.has(card.liveSpecialSkillId)), 'one or more cards have no special skill');
const cardNameCoverage = Object.fromEntries(Object.entries(textLookups).map(([locale, lookup]) => [
locale,
cards.filter((card) => lookup.has(card.nameLangId)).length
]));
const rarityCounts = Object.fromEntries(
Object.entries(Object.groupBy(cards, (card) => compactEnum(card.rarity))).map(([key, values]) => [key, values.length])
);
const attributeCounts = Object.fromEntries(
Object.entries(Object.groupBy(cards, (card) => compactEnum(card.attributeType))).map(([key, values]) => [key, values.length])
);
const generatedAt = new Date().toISOString();
const sourceMetadata = Object.fromEntries(Object.entries(preparedSources).map(([locale, source]) => [locale, {
repository: source.repository.replace(/\.git$/, ''),
commit: source.commit,
commitDate: source.commitDate,
masterDataVersion: source.commitMessage.replace(/^Master data version\s+/, '')
}]));
const manifest = {
schemaVersion: 1,
generatedAt,
source: 'HolodoriDB community master-data diff repositories',
sourceLicense: null,
sourceLicenseNote: 'No LICENSE file was present in the three source repositories at collection time.',
assetsIncluded: false,
sources: sourceMetadata,
counts: {
cards: cards.length,
rarities: rarityCounts,
attributes: attributeCounts,
localizedCardNames: cardNameCoverage
},
tables: tableManifest,
localizations: localizationManifest
};
const masterBundle = {
schemaVersion: 1,
generatedAt,
sources: sourceMetadata,
tables,
localizations
};
const cardBundle = {
schemaVersion: 1,
generatedAt,
sources: sourceMetadata,
cards: cardRecords
};
const cardRecordsById = new Map(cardRecords.map((card) => [card.id, card]));
const cardOverrides = await loadCardOverrides();
for (const cardId of cardOverrides.keys()) {
assert(cardRecordsById.has(cardId), `card override references unknown card: ${cardId}`);
}
function summarizeSkillLevels(levels) {
return levels.map((level) => ({
level: level.level,
description: level.translations?.description?.kor ?? '',
coolTimeSeconds: level.coolTimeMillisecond == null ? null : level.coolTimeMillisecond / 1000,
probabilityPercent: level.activationProbabilityPermilMultiply == null
? null
: level.activationProbabilityPermilMultiply / 10,
durationSeconds: level.effectDurationMillisecond == null ? null : level.effectDurationMillisecond / 1000
}));
}
function koreanDescriptions(records) {
return (records ?? [])
.map((record) => record.translations?.description?.kor ?? '')
.filter(Boolean)
.join(' ');
}
function summarizeConditionalSkillLevels(levels) {
return summarizeSkillLevels(levels).map((summary, index) => {
const condition = koreanDescriptions(levels[index].triggers);
const baseEffect = koreanDescriptions(levels[index].effects);
const conditionalEffect = koreanDescriptions(levels[index].additionalEffects);
return {
...summary,
condition,
effect: condition ? conditionalEffect : baseEffect
};
});
}
function summarizeSpecialSkillLevels(levels) {
return summarizeSkillLevels(levels).map((summary, index) => {
const condition = koreanDescriptions(levels[index].triggers);
const baseEffect = koreanDescriptions(levels[index].effects);
const conditionalEffect = koreanDescriptions(levels[index].additionalEffects);
return {
...summary,
condition,
effect: condition ? [baseEffect, conditionalEffect].filter(Boolean).join(' ') : baseEffect
};
});
}
const typeLabels = { '1': '큐트', '2': '퓨어', '3': '해피' };
const statLabels = {
performancePermilMultiply: '퍼포먼스',
techniquePermilMultiply: '테크닉',
sensePermilMultiply: '센스'
};
const cardSummary = {
schemaVersion: 1,
masterDataVersion: [...masterVersions][0],
cards: cards
.map((card) => {
const character = charactersById.get(card.characterId);
const cardRecord = cardRecordsById.get(card.id);
const cardOverride = cardOverrides.get(card.id);
const highestStat = Object.entries(statLabels)
.sort(([left], [right]) => Number(card[right]) - Number(card[left]))[0][1];
const maxLevel = cardRecord.progression.levels.at(-1);
const parameterBaseValue = Number(maxLevel?.parameterBaseValue ?? 0);
const statsAtMaxLevel = {
performance: Math.round(parameterBaseValue * Number(card.performancePermilMultiply ?? 0) / 1000),
technique: Math.round(parameterBaseValue * Number(card.techniquePermilMultiply ?? 0) / 1000),
sense: Math.round(parameterBaseValue * Number(card.sensePermilMultiply ?? 0) / 1000)
};
return {
id: card.id,
characterId: card.characterId,
memberOrder: character.order,
rarity: Number(compactEnum(card.rarity)),
type: typeLabels[compactEnum(card.attributeType)],
name: cardOverride?.displayName || textLookups.kor.get(character.nameLangId),
versionName: cardOverride?.versionName || '',
generation: textLookups.kor.get(character.regularCharacterGroupingNameLangId) || '',
highestStat,
power: {
maxLevel: Number(maxLevel?.level ?? 0),
statsAtMaxLevel
},
details: {
title: textLookups.kor.get(card.nameLangId),
passive: summarizeSkillLevels(cardRecord.skills.passive.levels),
active: summarizeConditionalSkillLevels(cardRecord.skills.active.levels),
special: summarizeSpecialSkillLevels(cardRecord.skills.special.levels),
connect: cardRecord.skillTreeConnectEffects.map((effect) => ({
level: Number(effect.level ?? 1),
description: effect.translations?.description?.kor ?? '',
effectPermilUp: Number(effect.effectPermilUp ?? 0),
extent: effect.extent.map((position) => ({
x: Number(position.positionX ?? 0),
y: Number(position.positionY ?? 0)
}))
})),
costume: cardRecord.rewards.costume
? {
name: cardRecord.rewards.costume.translations?.name?.kor ?? '',
description: cardRecord.rewards.costume.leaderSkill?.translations?.description?.kor
?? cardRecord.rewards.costume.leaderSkill?.effects?.[0]?.translations?.description?.kor
?? ''
}
: null
}
};
})
.sort((left, right) =>
right.rarity - left.rarity
|| left.memberOrder - right.memberOrder
|| left.id.localeCompare(right.id)
)
.map(({ memberOrder: _memberOrder, ...card }) => card)
};
function skillTreeNodeForCharacter(characterId, groupId) {
const candidates = skillTreeNodesByGroup.get(groupId) ?? [];
return candidates.find((node) => node.characterIds?.includes(characterId))
?? candidates.find((node) => !node.characterIds?.length)
?? null;
}
function stripEnumPrefix(value, prefix) {
return typeof value === 'string' ? value.replace(prefix, '').toLowerCase() : value;
}
function summarizeBoardNode(node) {
const effect = skillTreeEffectsById.get(node.skillTreeEffectId);
return {
number: node.number,
characterIds: node.characterIds ?? [],
grade: node.grade,
type: stripEnumPrefix(node.type, 'SkillTreeNodeType_SKILL_TREE_NODE_TYPE_'),
skillTreePointCost: node.consumptionSkillTreePointQuantity ?? 0,
consumptions: node.consumptions ?? [],
viewConditionGroupId: node.viewConditionGroupId ?? null,
effect: effect ? {
id: effect.id,
type: stripEnumPrefix(effect.effectType, 'SkillTreeEffectType_SKILL_TREE_EFFECT_TYPE_'),
value: effect.value == null ? null : Number(effect.value),
targetId: effect.skillTreeEffectTargetId ?? null,
trigger: stripEnumPrefix(effect.characterTriggerType, 'SkillTreeEffectCharacterTriggerType_SKILL_TREE_EFFECT_CHARACTER_TRIGGER_TYPE_'),
description: textLookups.kor.get(effect.descriptionLangId) ?? ''
} : null
};
}
const boardSummary = {
schemaVersion: 1,
masterDataVersion: [...masterVersions][0],
members: tables.Character
.map(unwrap)
.filter((character) => character.isPlayable && character.skillTreeNodePositionGroupId)
.map((character) => ({
id: character.id,
name: textLookups.kor.get(character.nameLangId) ?? character.nameEng,
order: character.order,
positionGroupId: character.skillTreeNodePositionGroupId
}))
.sort((left, right) => left.order - right.order),
models: Object.fromEntries(
[...new Set(tables.Character.map(unwrap).map((character) => character.skillTreeNodePositionGroupId).filter(Boolean))]
.sort()
.map((groupId) => [groupId, (skillTreePositionsByGroup.get(groupId) ?? [])
.map((position) => ({
groupId: position.skillTreeNodeGroupId,
x: position.positionX ?? 0,
y: position.positionY ?? 0
}))
.sort((left, right) => left.y - right.y || left.x - right.x || left.groupId.localeCompare(right.groupId))])
),
nodeGroups: Object.fromEntries(
[...skillTreeNodesByGroup.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([groupId, nodes]) => [groupId, sortByNumber(nodes, 'number').map(summarizeBoardNode)])
)
};
const memorySummary = {
schemaVersion: 1,
masterDataVersion: [...masterVersions][0],
maxCount: tables.Poster.length,
effects: tables.PosterCollectEffect
.map(unwrap)
.map((effect) => ({
count: Number(effect.threshold),
permil: Number(effect.liveDeckAllParameterUpPermilUp)
}))
.sort((left, right) => left.count - right.count)
};
assert(boardSummary.members.length > 0, 'no member boards were collected');
assert(Object.keys(boardSummary.models).length > 0, 'no board position models were collected');
assert(boardSummary.members.every((member) => boardSummary.models[member.positionGroupId]?.length === 152), 'one or more members have incomplete board positions');
assert(boardSummary.members.every((member) => boardSummary.models[member.positionGroupId].every((position) => skillTreeNodeForCharacter(member.id, position.groupId))), 'one or more members have unresolved board nodes');
assert(memorySummary.maxCount > 0, 'no memory posters were collected');
assert(memorySummary.effects.length > 0, 'no memory collection effects were collected');
await mkdir(outputRoot, { recursive: true });
await mkdir(appDataRoot, { recursive: true });
await Promise.all([
writeFile(path.join(outputRoot, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`),
writeFile(path.join(outputRoot, 'card-master.json'), `${JSON.stringify(masterBundle, null, 2)}\n`),
writeFile(path.join(outputRoot, 'cards.json'), `${JSON.stringify(cardBundle, null, 2)}\n`),
writeFile(path.join(appDataRoot, 'cards-summary.json'), `${JSON.stringify(cardSummary, null, 2)}\n`),
writeFile(path.join(appDataRoot, 'boards-summary.json'), `${JSON.stringify(boardSummary, null, 2)}\n`),
writeFile(path.join(appDataRoot, 'memory-summary.json'), `${JSON.stringify(memorySummary, null, 2)}\n`)
]);
process.stdout.write(`Collected ${cards.length} cards.\n`);
process.stdout.write(`Rarities: ${JSON.stringify(rarityCounts)}\n`);
process.stdout.write(`Localized names: ${JSON.stringify(cardNameCoverage)}\n`);
process.stdout.write(`Member boards: ${boardSummary.members.length}\n`);
process.stdout.write(`Memory posters: ${memorySummary.maxCount}\n`);
process.stdout.write(`Output: ${outputRoot}\n`);
+28
View File
@@ -0,0 +1,28 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
const projectRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/(?=[A-Za-z]:)/, '')), '..');
const catalogPath = path.join(projectRoot, 'src/lib/data/exact-chart-catalog.json');
const musicPath = path.join(projectRoot, 'data/raw/holodoridb/kor/Music.json');
const musicRows = JSON.parse(await readFile(musicPath, 'utf8'));
const musicById = new Map(musicRows.map((row) => [row.data?.id ?? row.id, row.data ?? row]));
const singerMetadataOverrides = {
m0334: { singerType: 'solo', singerCharacterIds: ['chr-04001'] }
};
const compactSingerType = (value) => typeof value === 'string' ? value.split('_').at(-1)?.toLowerCase() ?? null : null;
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
for (const entry of catalog.charts) {
const music = musicById.get(entry.songId);
const singerOverride = singerMetadataOverrides[entry.songId];
entry.singerType = singerOverride?.singerType ?? compactSingerType(music?.musicSingerType);
entry.singerCharacterIds = singerOverride?.singerCharacterIds ?? music?.characterIds ?? [];
const chartPath = path.join(projectRoot, 'static', entry.path.replace(/^\//, '').replace(/^data[\\/]charts[\\/]/, 'data/charts/'));
const chart = JSON.parse(await readFile(chartPath, 'utf8'));
chart.singerType = entry.singerType;
chart.singerCharacterIds = entry.singerCharacterIds;
await writeFile(chartPath, JSON.stringify(chart), 'utf8');
}
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf8');
console.log(`${catalog.charts.length}개 채보에 가창자 정보를 추가했습니다.`);
+71
View File
@@ -0,0 +1,71 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
const sourcePath = process.argv[2];
if (!sourcePath) {
throw new Error('사용법: npm run data:import-songs -- <기존 계산기 HTML 경로>');
}
const source = await readFile(sourcePath, 'utf8');
function parseConstant(name) {
const prefix = `const ${name} = `;
const start = source.indexOf(prefix);
if (start < 0) throw new Error(`${name} 데이터를 찾을 수 없습니다.`);
const valueStart = start + prefix.length;
const valueEnd = source.indexOf(';\n', valueStart);
if (valueEnd < 0) throw new Error(`${name} 데이터의 끝을 찾을 수 없습니다.`);
return JSON.parse(source.slice(valueStart, valueEnd));
}
const songs = parseConstant('SONGS');
const specials = parseConstant('SONG_SPECIAL');
const noteHistograms = parseConstant('SONG_NOTE_HIST');
const difficultyNames = ['hard', 'expert'];
function parseNotes(value) {
if (typeof value !== 'string' || value.length === 0) return [];
return value.split(',').map((noteCount) => Number(noteCount));
}
function specialStarts(songId, difficultyIndex) {
const starts = Array(5).fill(null);
for (const entry of specials[songId]?.[difficultyIndex] ?? []) {
const [slot, startSeconds] = entry;
if (Number.isInteger(slot) && slot >= 0 && slot < starts.length && Number.isFinite(startSeconds)) {
starts[slot] = startSeconds;
}
}
return starts;
}
const normalizedSongs = songs.flatMap(([id, title, durationSeconds]) => {
const histograms = noteHistograms[id];
if (!histograms) return [];
const difficulties = Object.fromEntries(
difficultyNames.flatMap((difficulty, difficultyIndex) => {
const notesPerSecond = parseNotes(histograms[difficultyIndex]);
if (notesPerSecond.length === 0) return [];
return [[difficulty, {
notesPerSecond,
specialStartSeconds: specialStarts(id, difficultyIndex),
totalNotes: notesPerSecond.reduce((sum, noteCount) => sum + noteCount, 0)
}]];
})
);
if (Object.keys(difficulties).length === 0) return [];
return [{ id, title, durationSeconds, difficulties }];
});
const output = {
schemaVersion: 1,
source: path.basename(sourcePath),
songs: normalizedSongs
};
const outputPath = path.resolve('src/lib/data/song-charts.json');
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
console.log(`${normalizedSongs.length}곡의 채보 밀도 데이터를 ${outputPath}에 저장했습니다.`);
@@ -0,0 +1,217 @@
<script lang="ts">
import type { EffectiveSkillWindow, ExactOptimizationResult, SkillWindow } from '$lib/engine/index.ts';
type DisplayEffectiveWindow = SkillWindow & {
baseScoreUpPercent: number;
minScoreUpPercent: number;
maxScoreUpPercent: number;
maxOverlapCount: number;
};
export let result: ExactOptimizationResult;
export let durationSeconds: number;
export let cardName: (cardId: string) => string;
export let instanceId = 'result';
export let baseline: ExactOptimizationResult | null = null;
export let showScoreSummary = true;
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
$: noteCountsBySecond = countNotesBySecond(result, durationSeconds);
$: maxNotesPerSecond = Math.max(1, ...noteCountsBySecond);
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result.effectiveActiveWindows ?? []);
$: timelineTicks = buildTimelineTicks(durationSeconds);
$: timelineWidth = Math.max(720, durationSeconds * 6);
function countNotesBySecond(scoreResult: ExactOptimizationResult, duration: number): number[] {
const counts = Array.from({ length: Math.ceil(duration) }, () => 0);
for (const note of scoreResult.notes) {
const second = Math.floor(note.timeSeconds);
if (second >= 0 && second < counts.length) counts[second] += 1;
}
return counts;
}
function buildTimelineTicks(duration: number): number[] {
if (duration <= 0) return [];
const ticks = Array.from({ length: Math.floor(duration / 10) + 1 }, (_, index) => index * 10);
if (ticks.at(-1) !== duration) ticks.push(duration);
return ticks;
}
function timelinePosition(seconds: number): number {
return durationSeconds > 0 ? Math.max(0, Math.min(100, seconds / durationSeconds * 100)) : 0;
}
function skillWindowStyle(startSeconds: number, endSeconds: number): string {
return `left: ${timelinePosition(startSeconds)}%; width: ${Math.max(0, timelinePosition(endSeconds) - timelinePosition(startSeconds))}%;`;
}
function timelineTrackStyle(): string {
return `width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`;
}
function memberColor(cardId: string): string {
const index = result.order.indexOf(cardId);
return MEMBER_COLORS[index >= 0 ? index % MEMBER_COLORS.length : 0];
}
function coloredWindowStyle(window: SkillWindow): string {
return `${skillWindowStyle(window.startSeconds, window.endSeconds)} --member-color: ${memberColor(window.cardId)};`;
}
function mergeEffectiveActiveWindows(windows: readonly EffectiveSkillWindow[]): DisplayEffectiveWindow[] {
const merged: DisplayEffectiveWindow[] = [];
for (const window of windows) {
const previous = merged.at(-1);
if (previous?.cardId === window.cardId && Math.abs(previous.endSeconds - window.startSeconds) < 0.001) {
previous.endSeconds = window.endSeconds;
previous.minScoreUpPercent = Math.min(previous.minScoreUpPercent, window.scoreUpPercent);
previous.maxScoreUpPercent = Math.max(previous.maxScoreUpPercent, window.scoreUpPercent);
previous.maxOverlapCount = Math.max(previous.maxOverlapCount, window.overlapCount);
continue;
}
merged.push({
...window,
minScoreUpPercent: window.scoreUpPercent,
maxScoreUpPercent: window.scoreUpPercent,
maxOverlapCount: window.overlapCount
});
}
return merged;
}
function effectivePercentLabel(window: DisplayEffectiveWindow): string {
const minimum = Number(window.minScoreUpPercent.toFixed(1));
const maximum = Number(window.maxScoreUpPercent.toFixed(1));
return minimum === maximum ? `${minimum}%` : `${minimum}~${maximum}%`;
}
function formatSeconds(seconds: number): string {
return `${Number(seconds.toFixed(1)).toLocaleString('ko-KR')}초`;
}
function delta(current: number, reference: number): string {
const difference = current - reference;
return `${difference >= 0 ? '+' : ''}${difference.toLocaleString('ko-KR')}`;
}
</script>
<ol class="order-list">
{#each result.order as cardId, slot}
<li><span class="slot">{slot + 1}</span><strong>{cardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each}
</ol>
{#if showScoreSummary}
<div class="score-grid">
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.expectedScore, baseline.expectedScore)}</small>{/if}</div>
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.theoreticalMaxScore, baseline.theoreticalMaxScore)}</small>{/if}</div>
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.baselineScore, baseline.baselineScore)}</small>{/if}</div>
</div>
{/if}
<section class="skill-timeline" aria-labelledby={`timeline-title-${instanceId}`}>
<div class="timeline-heading">
<div><h3 id={`timeline-title-${instanceId}`}>스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div>
<div class="timeline-legend" aria-label="멤버 색상 범례">
{#each result.order as cardId}<span><i style={`background: ${memberColor(cardId)};`}></i>{cardName(cardId)}</span>{/each}
</div>
</div>
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
<div class="timeline-row note-row">
<strong class="lane-label">초당 노트</strong>
<div class="timeline-track note-track" style={timelineTrackStyle()} aria-label={`최대 초당 ${maxNotesPerSecond}개`}>
{#each noteCountsBySecond as count, second}
<span class="note-bar" style={`left: ${timelinePosition(second)}%; width: ${100 / Math.max(1, noteCountsBySecond.length)}%; height: ${count / maxNotesPerSecond * 100}%; opacity: ${count === 0 ? 0 : 0.18 + count / maxNotesPerSecond * 0.82};`} title={`${second}~${second + 1}초 · ${count}개`}></span>
{/each}
</div>
</div>
{#each result.order as cardId, slot}
<div class="timeline-row member-row">
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{cardName(cardId)}</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
<span class="skill-window active-window" style={coloredWindowStyle(window)} title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} role="img" aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`}></span>
{/each}
</div>
</div>
{/each}
<div class="timeline-row summary-row">
<strong class="lane-label summary-label">최종 액티브</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each displayedEffectiveActiveWindows as window}
<span class="skill-window effective-window" style={coloredWindowStyle(window)} title={`${cardName(window.cardId)} 액티브 적용 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · 기본 ${window.baseScoreUpPercent}% → 서포트 반영 ${effectivePercentLabel(window)}${window.maxOverlapCount > 1 ? ` · 최대 ${window.maxOverlapCount}명 중 가장 높은 효과` : ''}`} role="img" aria-label={`${cardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}></span>
{/each}
</div>
</div>
<div class="timeline-row summary-row">
<strong class="lane-label summary-label">스페셜</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each result.specialWindows as window}
<span class="skill-window special-window" style={coloredWindowStyle(window)} title={`${cardName(window.cardId)} 스페셜 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} role="img" aria-label={`${cardName(window.cardId)} 스페셜이 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}></span>
{/each}
</div>
</div>
<div class="timeline-row axis-row" aria-hidden="true">
<span class="lane-label axis-label"></span>
<div class="timeline-track time-axis" style={timelineTrackStyle()}>
{#each timelineTicks as second}<span style={`left: ${timelinePosition(second)}%;`}>{second}초</span>{/each}
</div>
</div>
</div>
</div>
</section>
<style>
.order-list { display: grid; gap: 8px; padding: 0; margin: 18px 0 0; list-style: none; }
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reduction { padding: 6px 8px; border-radius: 7px; color: #b9c1cd; background: #252c36; font-size: 13px; }
.score-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
.score-summary span { color: #a9b1be; font-size: 14px; }
.score-summary strong { color: #f2a3c4; font-size: 20px; }
.score-summary small { color: #90d8ae; font-size: 12px; font-weight: 800; }
.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 h3 { margin: 0; color: #e8ebf1; font-size: 16px; }
.timeline-heading p { margin: 6px 0 0; color: #838d9d; font-size: 13px; }
.timeline-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; color: #99a2b1; font-size: 12px; }
.timeline-legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
.timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; }
.timeline-canvas { min-width: 100%; padding: 12px 12px 8px 0; }
.timeline-row { display: flex; min-height: 36px; }
.timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); }
.lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 11px; }
.timeline-track { position: relative; flex: none; }
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
.note-row { min-height: 68px; }
.note-track { height: 52px; align-self: center; border-bottom: 1px solid #465062; }
.note-bar { position: absolute; bottom: 0; min-height: 1px; border-right: 1px solid #10141a; background: #8ca9d7; }
.skill-track { height: 36px; }
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
.active-window { top: 12px; height: 12px; opacity: .72; }
.summary-row { background: rgb(255 255 255 / 1.5%); }
.summary-label { color: #e1e5ec; font-weight: 800; }
.effective-window { top: 9px; height: 18px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }
.special-window { top: 11px; height: 14px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 20%); }
.axis-row { min-height: 24px; border-top: 1px solid #343c49 !important; }
.time-axis { height: 24px; }
.time-axis::before { display: none; }
.time-axis span { position: absolute; top: 5px; color: #747f90; font-size: 11px; transform: translateX(-50%); white-space: nowrap; }
.time-axis span:first-child { transform: none; }
.time-axis span:last-child { transform: translateX(-100%); }
@media (max-width: 700px) {
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
.reduction { grid-column: 2; justify-self: start; }
.score-grid { grid-template-columns: 1fr; }
.timeline-heading { align-items: flex-start; flex-direction: column; }
.timeline-legend { justify-content: flex-start; }
.lane-label { flex-basis: 118px; min-width: 118px; }
.timeline-canvas { padding-right: 8px; }
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
{
"schemaVersion": 1,
"masterDataVersion": "71e11fbd082eec83d10cff35da7179cbaf319097f0021aa5747fe5a5392b549c",
"maxCount": 28,
"effects": [
{
"count": 1,
"permil": 2
},
{
"count": 2,
"permil": 4
},
{
"count": 3,
"permil": 6
},
{
"count": 4,
"permil": 8
},
{
"count": 5,
"permil": 10
},
{
"count": 6,
"permil": 12
},
{
"count": 7,
"permil": 14
},
{
"count": 8,
"permil": 16
},
{
"count": 9,
"permil": 18
},
{
"count": 10,
"permil": 20
},
{
"count": 11,
"permil": 22
},
{
"count": 12,
"permil": 24
},
{
"count": 13,
"permil": 26
},
{
"count": 14,
"permil": 28
},
{
"count": 15,
"permil": 30
},
{
"count": 16,
"permil": 32
},
{
"count": 17,
"permil": 34
},
{
"count": 18,
"permil": 36
},
{
"count": 19,
"permil": 38
},
{
"count": 20,
"permil": 40
},
{
"count": 21,
"permil": 42
},
{
"count": 22,
"permil": 44
},
{
"count": 23,
"permil": 46
},
{
"count": 24,
"permil": 48
},
{
"count": 25,
"permil": 50
},
{
"count": 26,
"permil": 52
},
{
"count": 27,
"permil": 54
},
{
"count": 28,
"permil": 56
},
{
"count": 29,
"permil": 58
},
{
"count": 30,
"permil": 60
},
{
"count": 31,
"permil": 61
},
{
"count": 32,
"permil": 62
},
{
"count": 33,
"permil": 63
},
{
"count": 34,
"permil": 64
},
{
"count": 35,
"permil": 65
},
{
"count": 36,
"permil": 66
},
{
"count": 37,
"permil": 67
},
{
"count": 38,
"permil": 68
},
{
"count": 39,
"permil": 69
},
{
"count": 40,
"permil": 70
},
{
"count": 41,
"permil": 71
},
{
"count": 42,
"permil": 72
},
{
"count": 43,
"permil": 73
},
{
"count": 44,
"permil": 74
},
{
"count": 45,
"permil": 75
},
{
"count": 46,
"permil": 76
},
{
"count": 47,
"permil": 77
},
{
"count": 48,
"permil": 78
},
{
"count": 49,
"permil": 79
},
{
"count": 50,
"permil": 80
}
]
}
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
# Calculation engine
기존 스킬 로스 계산기의 계산 로직을 UI와 분리해 둘 위치입니다.
- Svelte 컴포넌트나 SvelteKit 서버 모듈을 import하지 않습니다.
- 브라우저, Web Worker, Node.js, 단위 테스트에서 공유할 수 있는 순수 TypeScript로 작성합니다.
- 공용 데이터 타입은 `src/lib/models`에서 가져옵니다.
+120
View File
@@ -0,0 +1,120 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { calculateBoardEffects, type BoardSummarySource } from './board-effects.ts';
const summary: BoardSummarySource = {
members: [{ id: 'a', positionGroupId: 'model-a' }],
models: { 'model-a': [{ groupId: 'S-1', x: 0, y: 0 }, { groupId: 'B-1', x: 1, y: 0 }] },
nodeGroups: {
'B-1': [{ characterIds: [], effect: { type: 'performance_up', value: 100, targetId: 'skill_tree_effect_target-skill_tree_character', trigger: 'always' } }],
'B-2': [{ characterIds: [], effect: { type: 'live_active_skill_cool_time_shorten_permil_up', value: 40, targetId: 'skill_tree_effect_target-skill_tree_character', trigger: 'always' } }],
'R-1': [{ characterIds: [], effect: { type: 'all_parameter_up_permil_up', value: 30, targetId: 'skill_tree_effect_target-all', trigger: 'set_live_leader' } }],
'R-2': [{ characterIds: [], effect: {
id: 'skill_tree_effect-live_active_skill_effect_up_permil_up-100-skill_tree_effect_target-all-skill_tree_effect_passive_trigger-music_skill_tree_character-set_live_leader',
type: 'live_active_skill_effect_up_permil_up', value: 100, targetId: 'skill_tree_effect_target-all', trigger: 'set_live_leader'
} }],
'G-1': [{ characterIds: [], effect: { type: 'all_parameter_up', value: 5, targetId: 'skill_tree_effect_target-all', trigger: 'always' } }],
'G-2': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 50, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_0', trigger: 'always' } }],
'Y-1': [{ characterIds: [], effect: {
id: 'skill_tree_effect-live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type-5-skill_tree_effect_passive_trigger-music_skill_tree_character_and_music_singer_type-solo-always-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 5, trigger: 'always'
} }]
}
};
const cards = [
{ id: 'card-a', characterId: 'a', type: '큐트', generation: '0기생' },
{ id: 'card-b', characterId: 'b', type: '퓨어', generation: '1기생' }
];
test('편성 멤버의 파란 보드, 리더의 빨간 보드와 공통 초록 보드를 합산한다', () => {
const result = calculateBoardEffects(summary, {
a: ['B-1', 'B-2', 'Y-1'],
leader: ['R-1', 'R-2'],
account: ['G-1']
}, cards, 'leader', { singerType: 'solo', singerCharacterIds: ['a'] });
assert.equal(result.flatByCardId['card-a'].performance, 105);
assert.equal(result.flatByCardId['card-b'].performance, 5);
assert.equal(result.rateByCardId['card-a'].sense, 3);
assert.deepEqual(result.allowedCooldownReductionsByCardId['card-a'], [0, 4, 8, 12]);
assert.deepEqual(result.allowedCooldownReductionsByCardId['card-b'], [0, 4, 8, 12]);
assert.equal(result.directSongScoreBonusPercent, 0.5);
assert.equal(result.scoreSupportPercent, 0);
});
test('리더가 악곡 가창자이면 조건부 빨간 스코어 서포트를 적용한다', () => {
const result = calculateBoardEffects(summary, { leader: ['R-2'] }, cards, 'leader', {
singerType: 'group',
singerCharacterIds: ['leader']
});
assert.equal(result.scoreSupportPercent, 10);
});
test('연결 카드가 범위 안의 활성 노드 효과를 증폭한다', () => {
const result = calculateBoardEffects(summary, { a: ['B-1'] }, cards, 'a', null, {
placements: { a: { 'S-1': 'connect-card' } },
cards: [{
id: 'connect-card',
awakening: 0,
connect: [{ level: 1, effectPermilUp: 2000, extent: [{ x: 1, y: 0 }] }]
}]
});
assert.equal(result.flatByCardId['card-a'].performance, 300);
});
test('조합 탐색용 기수 보너스와 악곡 보너스는 실제 노드 합계를 대체한다', () => {
const result = calculateBoardEffects(summary, { account: ['G-2', 'Y-1'] }, cards, 'a', {
singerType: 'solo', singerCharacterIds: ['a']
}, null, {
generationBonusByCardId: { 'card-a': 900 },
directSongScoreBonusPercent: 10
});
assert.equal(result.flatByCardId['card-a'].performance, 900);
assert.equal(result.flatByCardId['card-a'].technique, 900);
assert.equal(result.directSongScoreBonusPercent, 10);
const zeroOverride = calculateBoardEffects(summary, { account: ['G-2'] }, cards, 'a', null, null, {
generationBonusByCardId: { 'card-a': 0 }
});
const customOverride = calculateBoardEffects(summary, { account: ['G-2'] }, cards, 'a', null, null, {
generationBonusByCardId: { 'card-a': 450 }
});
assert.equal(zeroOverride.flatByCardId['card-a'].performance, 0);
assert.equal(customOverride.flatByCardId['card-a'].performance, 450);
});
test('같은 기수의 보드 보너스는 카드별로 최대 900까지만 적용한다', () => {
const cappedSummary: BoardSummarySource = {
nodeGroups: {
'G-10': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 600, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_0' } }],
'G-11': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 500, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_0' } }]
}
};
const result = calculateBoardEffects(cappedSummary, { account: ['G-10', 'G-11'] }, cards, 'a');
assert.equal(result.flatByCardId['card-a'].performance, 900);
assert.equal(result.flatByCardId['card-a'].technique, 900);
assert.equal(result.flatByCardId['card-a'].sense, 900);
assert.equal(result.flatByCardId['card-b'].performance, 0);
});
test('복수 소속 카드는 각 기수의 900 상한을 따로 적용한다', () => {
const multiGroupingSummary: BoardSummarySource = {
nodeGroups: {
'G-20': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 900, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_1' } }],
'G-21': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 900, targetId: 'skill_tree_effect_target-character_grouping-grp-gamers' } }],
'G-22': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 100, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_1' } }]
}
};
const multiGroupingCards = [{ id: 'fubuki', characterId: 'fubuki', type: '퓨어', generation: 'hololive 1기생 / GAMERS' }];
const result = calculateBoardEffects(multiGroupingSummary, { account: ['G-20', 'G-21', 'G-22'] }, multiGroupingCards, 'fubuki');
assert.equal(result.flatByCardId.fubuki.performance, 1800);
});
+215
View File
@@ -0,0 +1,215 @@
import { skillTargetBelongsTo } from './skill-condition.ts';
import type { CooldownReduction } from './types.ts';
import type { UnitStat } from './unit-power.ts';
type BoardEffect = { id?: string; type: string; value: number | null; targetId?: string | null; trigger?: string | null };
type BoardNode = { characterIds: string[]; effect: BoardEffect | null };
type BoardPosition = { groupId: string; x: number; y: number };
type BoardMember = { id: string; positionGroupId: string };
export type BoardSummarySource = {
nodeGroups: Record<string, readonly BoardNode[]>;
models?: Record<string, readonly BoardPosition[]>;
members?: readonly BoardMember[];
};
export type BoardEffectCard = { id: string; characterId: string; type: string; generation: string };
export type BoardEffectSong = { singerType?: string | null; singerCharacterIds?: readonly string[] };
export type BoardConnectionCard = {
id: string;
awakening: number;
connect: readonly {
level: number;
effectPermilUp: number;
extent: readonly { x: number; y: number }[];
}[];
};
export type BoardConnections = {
placements: Readonly<Record<string, Readonly<Record<string, string>>>>;
cards: readonly BoardConnectionCard[];
};
export type BoardEffectOverrides = {
generationBonusByCardId?: Readonly<Record<string, number>>;
directSongScoreBonusPercent?: number;
};
export type BoardEffectsResult = {
flatByCardId: Record<string, Record<UnitStat, number>>;
rateByCardId: Record<string, Record<UnitStat, number>>;
activationRateUpByCardId: Record<string, number>;
allowedCooldownReductionsByCardId: Record<string, readonly CooldownReduction[]>;
scoreSupportPercent: number;
directSongScoreBonusPercent: number;
};
const stats: readonly UnitStat[] = ['performance', 'technique', 'sense'];
const generationBonusCap = 900;
const emptyStats = (): Record<UnitStat, number> => ({ performance: 0, technique: 0, sense: 0 });
const groupingLabels: Record<string, string> = {
'grp-gen_0': '0기생', 'grp-gen_1': '1기생', 'grp-gen_2': '2기생', 'grp-gen_3': '3기생',
'grp-gen_4': '4기생', 'grp-gen_5': '5기생', 'grp-gamers': 'GAMERS', 'grp-holox': 'holoX',
'grp-myth': 'Myth', 'grp-promise': 'Promise', 'grp-advent': 'Advent', 'grp-regloss': 'ReGLOSS',
'grp-indonesia-gen_1': 'ID 1기생', 'grp-indonesia-gen_2': 'ID 2기생', 'grp-indonesia-gen_3': 'ID 3기생'
};
function nodeForMember(summary: BoardSummarySource, groupId: string, memberId: string): BoardNode | null {
const nodes = summary.nodeGroups[groupId] ?? [];
return nodes.find((node) => node.characterIds.includes(memberId))
?? nodes.find((node) => node.characterIds.length === 0)
?? null;
}
function targets(effect: BoardEffect, ownerId: string, cards: readonly BoardEffectCard[]): BoardEffectCard[] {
if (effect.targetId === 'skill_tree_effect_target-skill_tree_character') return cards.filter((card) => card.characterId === ownerId);
const grouping = effect.targetId?.replace('skill_tree_effect_target-character_grouping-', '');
if (grouping && groupingLabels[grouping]) return cards.filter((card) => skillTargetBelongsTo(card, groupingLabels[grouping]));
return [...cards];
}
function affectedStats(type: string): readonly UnitStat[] {
if (type.startsWith('all_parameter_up')) return stats;
if (type.startsWith('performance_up')) return ['performance'];
if (type.startsWith('technique_up')) return ['technique'];
if (type.startsWith('sense_up')) return ['sense'];
return [];
}
function songBonusApplies(effect: BoardEffect, ownerId: string, song?: BoardEffectSong | null): boolean {
if (!effect.id) return false;
const expectedType = effect.id.match(/music_singer_type-(all|group|solo)/)?.[1];
if (expectedType === 'all') return true;
if (!song || expectedType !== song.singerType) return false;
const singers = song.singerCharacterIds ?? [];
if (effect.id.endsWith('-fuwamoko')) {
return singers.length === 2 && singers.includes('chr-04016') && singers.includes('chr-04017');
}
return singers.includes(ownerId);
}
function singerConditionApplies(effect: BoardEffect, ownerId: string, song?: BoardEffectSong | null): boolean {
if (!effect.id?.includes('passive_trigger-music_skill_tree_character')) return true;
return Boolean(song?.singerCharacterIds?.includes(ownerId));
}
function connectionMultipliers(
summary: BoardSummarySource,
memberBoards: Readonly<Record<string, readonly string[]>>,
connections?: BoardConnections | null
): Record<string, Record<string, number>> {
if (!connections || !summary.models || !summary.members) return {};
const cards = new Map(connections.cards.map((card) => [card.id, card]));
const usedCards = new Set<string>();
const result: Record<string, Record<string, number>> = {};
for (const member of summary.members) {
const positions: readonly BoardPosition[] = summary.models[member.positionGroupId] ?? [];
const positionById = new Map<string, BoardPosition>(positions.map((position) => [position.groupId, position]));
const groupIdByCoordinate = new Map<string, string>(positions.map((position) => [`${position.x},${position.y}`, position.groupId]));
const active = new Set(memberBoards[member.id] ?? []);
const center = positions.find((position) => position.x === 0 && position.y === 0)?.groupId;
if (center) active.add(center);
for (const [socketId, cardId] of Object.entries(connections.placements[member.id] ?? {})) {
if (usedCards.has(cardId) || !active.has(socketId)) continue;
const socket = positionById.get(socketId);
const card = cards.get(cardId);
if (!socket || !card) continue;
const level = card.awakening >= 5 ? 2 : 1;
const effect = card.connect.find((entry) => entry.level === level) ?? card.connect.at(-1);
if (!effect) continue;
usedCards.add(cardId);
const memberResult = result[member.id] ??= {};
for (const offset of effect.extent) {
const groupId = groupIdByCoordinate.get(`${socket.x + offset.x},${socket.y + offset.y}`);
if (groupId) memberResult[groupId] = 1 + effect.effectPermilUp / 1000;
}
}
}
return result;
}
export function calculateBoardEffects(
summary: BoardSummarySource,
memberBoards: Readonly<Record<string, readonly string[]>>,
cards: readonly BoardEffectCard[],
leaderCharacterId: string,
song?: BoardEffectSong | null,
connections?: BoardConnections | null,
overrides?: BoardEffectOverrides | null
): BoardEffectsResult {
const flatByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
const rateByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
const activationRateUpByCardId = Object.fromEntries(cards.map((card) => [card.id, 0]));
const unitCharacterIds = new Set(cards.map((card) => card.characterId));
let scoreSupportPercent = 0;
let directSongScoreBonusPercent = 0;
const multipliers = connectionMultipliers(summary, memberBoards, connections);
const generationBonusByCardAndGrouping: Record<string, Record<string, number>> = {};
for (const [ownerId, activeGroupIds] of Object.entries(memberBoards)) {
for (const groupId of activeGroupIds) {
const area = groupId[0];
if (area === 'B' && !unitCharacterIds.has(ownerId)) continue;
if (area === 'R' && ownerId !== leaderCharacterId) continue;
if (area !== 'B' && area !== 'R' && area !== 'G' && area !== 'Y') continue;
const effect = nodeForMember(summary, groupId, ownerId)?.effect;
if (!effect || effect.trigger === 'work' || effect.value === null) continue;
if (!singerConditionApplies(effect, ownerId, song)) continue;
const rawValue = Number(effect.value) * (multipliers[ownerId]?.[groupId] ?? 1);
const value = affectedStats(effect.type).length > 0 ? Math.ceil(rawValue) : rawValue;
if (effect.type === 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type') {
if (songBonusApplies(effect, ownerId, song)) directSongScoreBonusPercent += value / 10;
continue;
}
if (effect.type === 'live_active_skill_cool_time_shorten_permil_up') {
continue;
}
if (effect.type === 'live_active_skill_activation_probability_up_permil_up') {
for (const card of targets(effect, ownerId, cards)) activationRateUpByCardId[card.id] += value / 10;
continue;
}
if (effect.type === 'live_active_skill_effect_up_permil_up') {
scoreSupportPercent += value / 10;
continue;
}
const destination = effect.type.endsWith('_permil_up') ? rateByCardId : flatByCardId;
const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value;
const effectTargets = targets(effect, ownerId, cards);
if (effect.type === 'all_parameter_up_for_character_grouping') {
const groupingId = effect.targetId ?? 'unknown-grouping';
for (const card of effectTargets) {
if (overrides?.generationBonusByCardId?.[card.id] !== undefined) continue;
const cardBonuses = generationBonusByCardAndGrouping[card.id] ??= {};
const currentBonus = cardBonuses[groupingId] ?? 0;
const appliedValue = Math.max(0, Math.min(normalizedValue, generationBonusCap - currentBonus));
cardBonuses[groupingId] = currentBonus + appliedValue;
for (const stat of stats) destination[card.id][stat] += appliedValue;
}
continue;
}
for (const card of effectTargets) {
for (const stat of affectedStats(effect.type)) destination[card.id][stat] += normalizedValue;
}
}
}
for (const [cardId, value] of Object.entries(overrides?.generationBonusByCardId ?? {})) {
if (!flatByCardId[cardId]) continue;
const cappedValue = Math.max(0, Math.min(generationBonusCap, value));
for (const stat of stats) flatByCardId[cardId][stat] += cappedValue;
}
// 쿨타임 감소는 현재 보드 상태가 아니라 최적화 변수다. 모든 멤버가 네 경우를 항상 탐색한다.
const allowedCooldownReductionsByCardId = Object.fromEntries(cards.map((card) => [
card.id,
[0, 4, 8, 12] satisfies CooldownReduction[]
]));
return {
flatByCardId,
rateByCardId,
activationRateUpByCardId,
allowedCooldownReductionsByCardId,
scoreSupportPercent,
directSongScoreBonusPercent: overrides?.directSongScoreBonusPercent ?? Math.min(10, directSongScoreBonusPercent)
};
}
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { toOptimizerCard, type OptimizerSourceCard } from './card-adapter.ts';
const source: OptimizerSourceCard = {
id: 'card',
name: '카드',
details: {
active: [
{ level: 1, coolTimeSeconds: 10, durationSeconds: 1, effect: '스코어가 10% UP' },
{ level: 2, coolTimeSeconds: 8, durationSeconds: 2, effect: '스코어가 20% UP' }
],
special: [
{ level: 1, coolTimeSeconds: null, durationSeconds: 1, effect: '스코어가 30% UP' },
{ level: 2, coolTimeSeconds: null, durationSeconds: 2, effect: '스코어가 40% UP' }
]
}
};
test('액티브는 1개화, 스페셜은 3개화부터 2레벨을 적용한다', () => {
const base = toOptimizerCard(source, 0);
const activeAwakened = toOptimizerCard(source, 1);
const specialAwakened = toOptimizerCard(source, 3);
assert.equal(base.active.scoreUpPercent, 10);
assert.equal(base.special.scoreUpPercent, 30);
assert.equal(activeAwakened.active.scoreUpPercent, 20);
assert.equal(activeAwakened.special.scoreUpPercent, 30);
assert.equal(specialAwakened.special.scoreUpPercent, 40);
});
test('4개화 패시브의 타입별 스코어 서포트 대상과 수치를 변환한다', () => {
const passiveSource: OptimizerSourceCard = {
...source,
type: '퓨어',
generation: 'Promise',
details: {
...source.details,
passive: [
{ level: 1, description: '[attribute=pure]퓨어 타입[/attribute] 2명의 스코어 서포트 효과 8%' },
{ level: 2, description: '[attribute=pure]퓨어 타입[/attribute] 2명의 스코어 서포트 효과 11%' }
]
}
};
assert.deepEqual(toOptimizerCard(passiveSource, 4, undefined, [{ type: '퓨어', generation: 'Promise' }]).passiveScoreSupportEffects, [{
percent: 11,
target: 'group',
targetGroup: '퓨어 타입',
targetCount: 2
}]);
});
+117
View File
@@ -0,0 +1,117 @@
import type { CooldownReduction, OptimizerCard } from './types.ts';
import { plainSkillText, skillConditionSatisfied, type SkillConditionMember } from './skill-condition.ts';
type SkillLevel = {
level: number;
coolTimeSeconds: number | null;
durationSeconds: number | null;
effect?: string;
probabilityPercent?: number | null;
condition?: string;
};
export type OptimizerSourceCard = {
id: string;
name: string;
type?: string;
generation?: string;
versionName?: string;
details: {
passive?: readonly { level: number; description: string }[];
active: readonly SkillLevel[];
special: readonly SkillLevel[];
};
};
function selectedLevel(
levels: readonly SkillLevel[],
awakening: number,
levelUpAwakening: number
): SkillLevel | undefined {
const targetLevel = awakening >= levelUpAwakening ? 2 : 1;
return levels.find((skill) => skill.level === targetLevel) ?? levels.at(-1);
}
function passiveScoreSupportEffects(
source: OptimizerSourceCard,
awakening: number,
unitMembers: readonly SkillConditionMember[]
): NonNullable<OptimizerCard['passiveScoreSupportEffects']> {
const level = awakening >= 4 ? 2 : 1;
const description = plainSkillText(source.details.passive?.find((entry) => entry.level === level)?.description ?? '');
const marker = '스코어 서포트 효과';
const markerIndex = description.indexOf(marker);
if (markerIndex < 0) return [];
const before = description.slice(0, markerIndex).trim();
const percent = Number(description.slice(markerIndex + marker.length).match(/(\d+(?:\.\d+)?)%/)?.[1] ?? 0);
if (percent <= 0) return [];
const conditionSource = before.split('%').at(-1)?.trim() ?? before;
const condition = conditionSource.match(/(.+?\d+명 이상이면)/)?.[1] ?? '';
if (condition && !skillConditionSatisfied(condition, unitMembers)) return [];
const targetText = (before.includes('이면') ? before.slice(before.lastIndexOf('이면') + 2) : before).trim();
if (/전원의?$/.test(targetText)) return [{ percent, target: 'all' }];
if (/자신의?$/.test(targetText)) return [{ percent, target: 'self' }];
const groupTarget = targetText.match(/(.+?)\s*(\d+)명의?$/);
if (!groupTarget) return [];
return [{
percent,
target: 'group',
targetGroup: groupTarget[1].trim(),
targetCount: Number(groupTarget[2])
}];
}
function scorePercent(effect: string | undefined): number | null {
const match = effect?.match(/스코어(?:가|\s+서포트\s+효과)\s*(\d+(?:\.\d+)?)%/);
return match ? Number(match[1]) : null;
}
function activationRatePercent(effect: string | undefined): number {
const match = effect?.match(/스킬\s*발동률이?\s*(\d+(?:\.\d+)?)%/);
return match ? Number(match[1]) : 0;
}
export function toOptimizerCard(
source: OptimizerSourceCard,
awakening: number,
allowedCooldownReductions?: readonly CooldownReduction[],
unitMembers: readonly SkillConditionMember[] = []
): OptimizerCard {
const active = selectedLevel(source.details.active, awakening, 1);
const special = selectedLevel(source.details.special, awakening, 3);
const activeConditionMet = skillConditionSatisfied(active?.condition ?? '', unitMembers);
const specialConditionMet = skillConditionSatisfied(special?.condition ?? '', unitMembers);
const activeScore = activeConditionMet ? scorePercent(active?.effect) : 0;
const specialScore = specialConditionMet ? scorePercent(special?.effect) : 0;
if (!active || active.coolTimeSeconds === null || active.durationSeconds === null || activeScore === null) {
throw new Error(`${source.name}의 액티브 스코어 정보를 계산에 사용할 수 없습니다.`);
}
if (!special || special.durationSeconds === null || specialScore === null) {
throw new Error(`${source.name}의 스페셜 스코어 정보를 계산에 사용할 수 없습니다.`);
}
return {
id: source.id,
name: source.versionName ? `${source.name} (${source.versionName})` : source.name,
type: source.type,
generation: source.generation,
active: {
cooldownSeconds: active.coolTimeSeconds,
durationSeconds: active.durationSeconds,
scoreUpPercent: activeScore,
probabilityPercent: active.probabilityPercent ?? 100
},
special: {
durationSeconds: special.durationSeconds,
scoreUpPercent: specialScore,
activationRateUpPercent: specialConditionMet ? activationRatePercent(special.effect) : 0
},
passiveScoreSupportEffects: passiveScoreSupportEffects(source, awakening, unitMembers),
allowedCooldownReductions
};
}
+22
View File
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { generateCombinationCandidates } from './index.ts';
const cards = [
{ id: 'a-1', characterId: 'a', score: 10 },
{ id: 'a-2', characterId: 'a', score: 20 },
{ id: 'b-1', characterId: 'b', score: 30 },
{ id: 'c-1', characterId: 'c', score: 40 }
];
test('고정 카드를 유지하고 같은 멤버의 이격 카드를 제외한다', () => {
const result = generateCombinationCandidates([cards[0]], cards, 3, (card) => card.score, 20);
assert.equal(result.length, 1);
assert.deepEqual(result[0].map((card) => card.id).sort(), ['a-1', 'b-1', 'c-1']);
});
test('빔 너비만큼 점수가 높은 조합을 남긴다', () => {
const result = generateCombinationCandidates([], cards, 2, (card) => card.score, 2);
assert.equal(result.length, 2);
assert.deepEqual(result[0].map((card) => card.id).sort(), ['b-1', 'c-1']);
});
+55
View File
@@ -0,0 +1,55 @@
export type CombinationSearchCard = {
id: string;
characterId: string;
};
type BeamState<T extends CombinationSearchCard> = {
cards: T[];
score: number;
};
export function generateCombinationCandidates<T extends CombinationSearchCard>(
fixedCards: readonly T[],
candidateCards: readonly T[],
targetSize: number,
scoreCard: (card: T) => number,
beamWidth = 800
): T[][] {
if (targetSize < fixedCards.length) throw new Error('목표 카드 수가 고정 카드 수보다 작습니다.');
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
throw new Error('같은 멤버를 두 번 고정할 수 없습니다.');
}
const fixedIds = new Set(fixedCards.map((card) => card.id));
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
const pool = candidateCards.filter((card) => !fixedIds.has(card.id) && !fixedCharacters.has(card.characterId));
let beam: BeamState<T>[] = [{
cards: Array.from(fixedCards),
score: fixedCards.reduce((sum, card) => sum + scoreCard(card), 0)
}];
for (let size = fixedCards.length; size < targetSize; size += 1) {
const expanded = new Map<string, BeamState<T>>();
for (const state of beam) {
const usedCharacters = new Set(state.cards.map((card) => card.characterId));
const usedIds = new Set(state.cards.map((card) => card.id));
for (const card of pool) {
if (usedIds.has(card.id)) continue;
if (usedCharacters.has(card.characterId)) continue;
const nextState = {
cards: [...state.cards, card],
score: state.score + scoreCard(card)
};
const key = nextState.cards.map((entry) => entry.id).sort().join('|');
if (!expanded.has(key)) expanded.set(key, nextState);
}
}
beam = Array.from(expanded.values())
.sort((left, right) => right.score - left.score || left.cards.map((card) => card.id).join().localeCompare(right.cards.map((card) => card.id).join()))
.slice(0, Math.max(1, beamWidth));
if (beam.length === 0) break;
}
return beam
.filter((state) => state.cards.length === targetSize)
.sort((left, right) => right.score - left.score)
.map((state) => state.cards);
}
+46
View File
@@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
const cards: OptimizerCard[] = ['a', 'b', 'c', 'd', 'e'].map((id, index) => ({
id,
name: id,
active: { cooldownSeconds: 2 + index, durationSeconds: 0.5, scoreUpPercent: 10 + index },
special: { durationSeconds: 1, scoreUpPercent: 20 + index },
allowedCooldownReductions: [0]
}));
const chart: ExactSongChart = {
songId: 'timeline-test',
difficulty: 'expert',
durationSeconds: 8,
scoreRatio: 1,
notes: Array.from({ length: 8 }, (_, timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false })),
specialStartSeconds: [1, 2, 3, 4, 5]
};
test('정확 분석 결과에 최적 조합의 액티브와 스페셜 지속 구간을 포함한다', () => {
const result = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100 });
assert.ok(result.activeWindows.length > 0);
assert.ok(result.effectiveActiveWindows.length > 0);
assert.ok(result.effectiveActiveWindows.every((window) => window.baseScoreUpPercent > 0 && window.scoreUpPercent >= window.baseScoreUpPercent));
assert.equal(result.specialWindows.length, 5);
assert.deepEqual(new Set(result.specialWindows.map((window) => window.cardId)), new Set(result.order));
assert.ok(result.activeWindows.every((window) => window.endSeconds > window.startSeconds));
});
test('패시브 스코어 서포트는 순서상 앞선 대상 타입의 지정 인원에게 적용한다', () => {
const supported = cards.map((card, index) => ({
...card,
type: index < 3 ? '퓨어' : '해피',
passiveScoreSupportEffects: index === 4
? [{ percent: 20, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }]
: []
}));
const baseline = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100 });
const result = optimizeExactOrderAndCooldowns(supported, chart, { perfectNoteScore: 100 });
assert.ok(result.theoreticalMaxScore > baseline.theoreticalMaxScore);
});
+280
View File
@@ -0,0 +1,280 @@
import { calculateExactScore } from './score.ts';
import { skillTargetBelongsTo } from './skill-condition.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
import { COOLDOWN_REDUCTIONS } from './types.ts';
import type {
CooldownReduction,
EffectiveSkillWindow,
ExactOptimizationResult,
ExactOptimizeOptions,
ExactSongChart,
OptimizerCard,
SkillWindow
} from './types.ts';
type CandidateProfile = {
boost: 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[][] {
if (items.length <= 1) return [Array.from(items)];
const result: T[][] = [];
for (let index = 0; index < items.length; index += 1) {
const rest = [...items.slice(0, index), ...items.slice(index + 1)];
for (const permutation of permutations(rest)) result.push([items[index], ...permutation]);
}
return result;
}
function assignments(cards: readonly OptimizerCard[]): Array<Record<string, CooldownReduction>> {
let result: Array<Record<string, CooldownReduction>> = [{}];
for (const card of cards) {
const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
result = result.flatMap((current) => allowed.map((reduction) => ({ ...current, [card.id]: reduction })));
}
return result;
}
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(
order: readonly OptimizerCard[],
chart: ExactSongChart,
time: number,
field: 'scoreUpPercent' | 'activationRateUpPercent'
): number {
let total = 0;
for (let slot = 0; slot < order.length; slot += 1) {
const card = order[slot];
if (specialActive(card, chart.specialStartSeconds[slot], time)) total += card.special[field] ?? 0;
}
return total;
}
function comboBonus(combo: number): number {
return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0;
}
function baseScores(chart: ExactSongChart, perfectNoteScore: number): Float64Array {
return Float64Array.from(chart.notes, (note, index) => {
const base = note.type === 'mid' ? Math.ceil(perfectNoteScore * 0.1) : perfectNoteScore * (note.isFlick ? 1.05 : 1);
return base * (1 + comboBonus(index + 1));
});
}
function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): Map<string, CandidateProfile> {
const profiles = new Map<string, CandidateProfile>();
const noteSupport = Float64Array.from(chart.notes, (note) => specialRate(order, chart, note.timeSeconds, 'scoreUpPercent'));
const passiveSupport = passiveScoreSupportByCard(order);
order.forEach((card, cardIndex) => {
const reductions = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
reductions.forEach((reduction) => {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const boost = new Float64Array(chart.notes.length);
const probability = new Float64Array(chart.notes.length);
const probabilityByAttempt = new Map<number, number>();
chart.notes.forEach((note, noteIndex) => {
const attempt = Math.floor((note.timeSeconds + 1e-9) / interval);
if (attempt <= 0) return;
const activationTime = attempt * interval;
if (note.timeSeconds >= activationTime + card.active.durationSeconds) return;
let effectiveProbability = probabilityByAttempt.get(attempt);
if (effectiveProbability === undefined) {
const activationBonus = specialRate(order, chart, activationTime, 'activationRateUpPercent');
effectiveProbability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationBonus / 100));
probabilityByAttempt.set(attempt, effectiveProbability);
}
boost[noteIndex] = card.active.scoreUpPercent / 100 *
(1 + (noteSupport[noteIndex] + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100);
probability[noteIndex] = effectiveProbability;
});
profiles.set(`${card.id}:${reduction}`, { boost, probability });
});
});
return profiles;
}
function resultWindows(
order: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart
): { activeWindows: SkillWindow[]; effectiveActiveWindows: EffectiveSkillWindow[]; specialWindows: SkillWindow[] } {
const activeWindows = order.flatMap((card) => {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reductions[card.id] ?? 0);
const windows: SkillWindow[] = [];
for (let activation = 1; activation * interval < chart.durationSeconds - 1e-9; activation += 1) {
const startSeconds = activation * interval;
windows.push({
cardId: card.id,
startSeconds,
endSeconds: Math.min(chart.durationSeconds, startSeconds + card.active.durationSeconds),
scoreUpPercent: card.active.scoreUpPercent
});
}
return windows;
});
const specialWindows = order.flatMap((card, slot) => {
const startSeconds = chart.specialStartSeconds[slot];
if (startSeconds === null || startSeconds === undefined) return [];
return [{
cardId: card.id,
startSeconds,
endSeconds: Math.min(chart.durationSeconds, startSeconds + card.special.durationSeconds),
scoreUpPercent: card.special.scoreUpPercent
}];
});
const passiveSupport = passiveScoreSupportByCard(order);
const orderIndex = new Map(order.map((card, index) => [card.id, index]));
const boundaries = Array.from(new Set([
...activeWindows.flatMap((window) => [window.startSeconds, window.endSeconds]),
...specialWindows.flatMap((window) => [window.startSeconds, window.endSeconds])
])).sort((left, right) => left - right);
const effectiveActiveWindows: EffectiveSkillWindow[] = [];
for (let index = 0; index < boundaries.length - 1; index += 1) {
const startSeconds = boundaries[index];
const endSeconds = boundaries[index + 1];
if (endSeconds - startSeconds < 0.01) continue;
const midpoint = (startSeconds + endSeconds) / 2;
const specialSupport = specialRate(order, chart, midpoint, 'scoreUpPercent');
const active = activeWindows
.filter((window) => window.startSeconds <= midpoint && midpoint < window.endSeconds)
.map((window) => {
const cardIndex = orderIndex.get(window.cardId) ?? 0;
const card = order[cardIndex];
return {
window,
effectivePercent: window.scoreUpPercent *
(1 + (specialSupport + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100)
};
})
.sort((left, right) => right.effectivePercent - left.effectivePercent || (orderIndex.get(left.window.cardId) ?? 99) - (orderIndex.get(right.window.cardId) ?? 99));
const winner = active[0];
if (!winner) continue;
const previous = effectiveActiveWindows.at(-1);
if (previous && previous.cardId === winner.window.cardId && Math.abs(previous.scoreUpPercent - winner.effectivePercent) < 1e-9 && Math.abs(previous.endSeconds - startSeconds) < 0.01) {
previous.endSeconds = endSeconds;
previous.overlapCount = Math.max(previous.overlapCount, active.length);
} else {
effectiveActiveWindows.push({
...winner.window,
startSeconds,
endSeconds,
baseScoreUpPercent: winner.window.scoreUpPercent,
scoreUpPercent: winner.effectivePercent,
overlapCount: active.length
});
}
}
return { activeWindows, effectiveActiveWindows, specialWindows };
}
function scoreAssignment(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
profiles: ReadonlyMap<string, CandidateProfile>,
base: Float64Array,
directBonus: number
): number {
const selected = cards.map((card) => {
const reduction = reductions[card.id] ?? 0;
const profile = profiles.get(`${card.id}:${reduction}`);
if (!profile) throw new Error(`${card.name}의 ${reduction}% 감소 프로필이 없습니다.`);
return profile;
});
const boosts = new Float64Array(cards.length);
const probabilities = new Float64Array(cards.length);
let rawScore = 0;
for (let noteIndex = 0; noteIndex < base.length; noteIndex += 1) {
let count = 0;
for (let memberIndex = 0; memberIndex < selected.length; memberIndex += 1) {
const boost = selected[memberIndex].boost[noteIndex];
if (boost <= 0) continue;
let position = count;
while (position > 0 && boosts[position - 1] < boost) {
boosts[position] = boosts[position - 1];
probabilities[position] = probabilities[position - 1];
position -= 1;
}
boosts[position] = boost;
probabilities[position] = selected[memberIndex].probability[noteIndex];
count += 1;
}
let expectedBoost = 0;
let previousFailed = 1;
for (let position = 0; position < count; position += 1) {
expectedBoost += previousFailed * probabilities[position] * boosts[position];
previousFailed *= 1 - probabilities[position];
}
rawScore += base[noteIndex] * (1 + expectedBoost);
}
return Math.floor(Math.floor(rawScore) * (1 + directBonus / 100) + Number.EPSILON * Math.max(1, rawScore) * 4);
}
export function optimizeExactOrderAndCooldowns(
cards: readonly OptimizerCard[],
chart: ExactSongChart,
options: ExactOptimizeOptions
): ExactOptimizationResult {
if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) {
throw new Error('서로 다른 카드 5장이 필요합니다.');
}
const orders = permutations(cards);
const reductionAssignments = assignments(cards);
const base = baseScores(chart, options.perfectNoteScore);
const totalCandidates = orders.length * reductionAssignments.length;
let evaluatedCandidates = 0;
let bestScore = Number.NEGATIVE_INFINITY;
let bestOrder: OptimizerCard[] | null = null;
let bestReductions: Record<string, CooldownReduction> | null = null;
let bestReductionTotal = Number.POSITIVE_INFINITY;
for (const order of orders) {
const profiles = makeProfiles(order, chart);
for (const reductions of reductionAssignments) {
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
const reductionTotal = Object.values(reductions).reduce<number>((sum, value) => sum + value, 0);
if (score > bestScore || (score === bestScore && reductionTotal < bestReductionTotal)) {
bestScore = score;
bestOrder = order;
bestReductions = reductions;
bestReductionTotal = reductionTotal;
}
evaluatedCandidates += 1;
}
options.onProgress?.({ evaluatedCandidates, totalCandidates });
}
if (!bestOrder || !bestReductions) throw new Error('분석 결과를 만들지 못했습니다.');
const windows = resultWindows(bestOrder, bestReductions, chart);
return {
...calculateExactScore(bestOrder, bestReductions, chart, options),
order: bestOrder.map((card) => card.id),
cooldownReductions: { ...bestReductions },
...windows,
evaluatedCandidates
};
}
+12
View File
@@ -0,0 +1,12 @@
export * from './card-adapter.ts';
export * from './combination-search.ts';
export * from './board-effects.ts';
export * from './exact-optimizer.ts';
export * from './memory.ts';
export * from './maximum-weight-matching.ts';
export * from './optimizer.ts';
export * from './score.ts';
export * from './skill-condition.ts';
export * from './timeline.ts';
export * from './types.ts';
export * from './unit-power.ts';
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { maximumWeightMatching } from './maximum-weight-matching.ts';
test('전체 합이 가장 큰 일대일 배치를 선택한다', () => {
const matches = maximumWeightMatching([
[9, 8, 1],
[8, 1, 1]
]);
assert.deepEqual(matches.map(({ rowIndex, columnIndex }) => [rowIndex, columnIndex]).sort(), [[0, 1], [1, 0]]);
assert.equal(matches.reduce((sum, match) => sum + match.weight, 0), 16);
});
test('카드 수가 슬롯보다 많아도 각 슬롯과 카드를 한 번만 사용한다', () => {
const matches = maximumWeightMatching([
[7, 0],
[6, 5],
[0, 9]
]);
assert.equal(matches.length, 2);
assert.equal(new Set(matches.map((match) => match.rowIndex)).size, 2);
assert.equal(new Set(matches.map((match) => match.columnIndex)).size, 2);
assert.equal(matches.reduce((sum, match) => sum + match.weight, 0), 16);
});
test('효과가 없는 배치는 결과에서 제외한다', () => {
assert.deepEqual(maximumWeightMatching([[0, 0]]), []);
});
+84
View File
@@ -0,0 +1,84 @@
export type WeightedMatch = {
rowIndex: number;
columnIndex: number;
weight: number;
};
/**
* Finds a maximum-weight one-to-one assignment for a rectangular matrix.
* Rows or columns that cannot improve the result may remain unmatched.
*/
export function maximumWeightMatching(weights: readonly (readonly number[])[]): WeightedMatch[] {
const rowCount = weights.length;
const columnCount = weights.reduce((maximum, row) => Math.max(maximum, row.length), 0);
if (rowCount === 0 || columnCount === 0) return [];
if (rowCount > columnCount) {
const transposed = Array.from({ length: columnCount }, (_, columnIndex) =>
Array.from({ length: rowCount }, (_, rowIndex) => weights[rowIndex]?.[columnIndex] ?? 0)
);
return maximumWeightMatching(transposed).map((match) => ({
rowIndex: match.columnIndex,
columnIndex: match.rowIndex,
weight: match.weight
}));
}
const maximum = Math.max(0, ...weights.flatMap((row) => [...row]));
const rowPotential = new Array(rowCount + 1).fill(0);
const columnPotential = new Array(columnCount + 1).fill(0);
const matchedRowByColumn = new Array(columnCount + 1).fill(0);
const previousColumn = new Array(columnCount + 1).fill(0);
for (let row = 1; row <= rowCount; row += 1) {
matchedRowByColumn[0] = row;
let currentColumn = 0;
const minimum = new Array(columnCount + 1).fill(Number.POSITIVE_INFINITY);
const used = new Array(columnCount + 1).fill(false);
do {
used[currentColumn] = true;
const currentRow = matchedRowByColumn[currentColumn];
let delta = Number.POSITIVE_INFINITY;
let nextColumn = 0;
for (let column = 1; column <= columnCount; column += 1) {
if (used[column]) continue;
const weight = weights[currentRow - 1]?.[column - 1] ?? 0;
const cost = maximum - Math.max(0, weight);
const reducedCost = cost - rowPotential[currentRow] - columnPotential[column];
if (reducedCost < minimum[column]) {
minimum[column] = reducedCost;
previousColumn[column] = currentColumn;
}
if (minimum[column] < delta) {
delta = minimum[column];
nextColumn = column;
}
}
for (let column = 0; column <= columnCount; column += 1) {
if (used[column]) {
rowPotential[matchedRowByColumn[column]] += delta;
columnPotential[column] -= delta;
} else {
minimum[column] -= delta;
}
}
currentColumn = nextColumn;
} while (matchedRowByColumn[currentColumn] !== 0);
do {
const nextColumn = previousColumn[currentColumn];
matchedRowByColumn[currentColumn] = matchedRowByColumn[nextColumn];
currentColumn = nextColumn;
} while (currentColumn !== 0);
}
const matches: WeightedMatch[] = [];
for (let column = 1; column <= columnCount; column += 1) {
const row = matchedRowByColumn[column];
if (row === 0) continue;
const weight = weights[row - 1]?.[column - 1] ?? 0;
if (weight > 0) matches.push({ rowIndex: row - 1, columnIndex: column - 1, weight });
}
return matches;
}
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { memoryParameterPermil } from './memory.ts';
const effects = [
{ count: 1, permil: 2 },
{ count: 2, permil: 4 },
{ count: 3, permil: 6 }
];
test('보유 메모리 수에 해당하는 누적 파라미터 보너스를 찾는다', () => {
assert.equal(memoryParameterPermil(0, effects), 0);
assert.equal(memoryParameterPermil(2, effects), 4);
assert.equal(memoryParameterPermil(10, effects), 6);
});
test('메모리 수는 음수를 0으로, 소수는 버림해 판정한다', () => {
assert.equal(memoryParameterPermil(-1, effects), 0);
assert.equal(memoryParameterPermil(2.9, effects), 4);
});
+15
View File
@@ -0,0 +1,15 @@
export type MemoryCollectionEffect = {
count: number;
permil: number;
};
export function memoryParameterPermil(
count: number,
effects: readonly MemoryCollectionEffect[]
): number {
const owned = Math.max(0, Math.floor(Number.isFinite(count) ? count : 0));
return effects.reduce(
(rate, effect) => effect.count <= owned && effect.permil > rate ? effect.permil : rate,
0
);
}
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { countOptimizationCandidates, effectiveCooldownSeconds, optimizeOrderAndCooldowns } from './index.ts';
import type { OptimizerCard, SongChartDensity } from './types.ts';
const cards: OptimizerCard[] = [
{ id: 'a', name: 'A', active: { cooldownSeconds: 2, durationSeconds: 0.8, scoreUpPercent: 100 }, special: { durationSeconds: 1, scoreUpPercent: 200 } },
{ id: 'b', name: 'B', active: { cooldownSeconds: 3, durationSeconds: 0.8, scoreUpPercent: 90 }, special: { durationSeconds: 1, scoreUpPercent: 20 } },
{ id: 'c', name: 'C', active: { cooldownSeconds: 4, durationSeconds: 0.8, scoreUpPercent: 80 }, special: { durationSeconds: 1, scoreUpPercent: 20 } },
{ id: 'd', name: 'D', active: { cooldownSeconds: 5, durationSeconds: 0.8, scoreUpPercent: 70 }, special: { durationSeconds: 1, scoreUpPercent: 20 } },
{ id: 'e', name: 'E', active: { cooldownSeconds: 6, durationSeconds: 0.8, scoreUpPercent: 60 }, special: { durationSeconds: 1, scoreUpPercent: 20 } }
];
const chart: SongChartDensity = {
songId: 'test-song',
difficulty: 'expert',
durationSeconds: 8,
notesPerSecond: [0, 0, 1, 1, 2, 2, 12, 12],
specialStartSeconds: [6, 0, 1, 2, 3]
};
test('쿨타임 감소는 기존 계산기의 나눗셈 규칙을 따른다', () => {
assert.equal(effectiveCooldownSeconds(20, 0), 20);
assert.equal(effectiveCooldownSeconds(20, 4), 20 / 1.04);
assert.equal(effectiveCooldownSeconds(20, 12), 20 / 1.12);
});
test('5장과 네 가지 감소율은 122,880개 후보를 만든다', () => {
assert.equal(countOptimizationCandidates(cards), 122_880);
});
test('노트가 몰린 첫 스페셜 슬롯에 가장 강한 스페셜 카드를 배치한다', () => {
const fixedReductionCards = cards.map((card) => ({ ...card, allowedCooldownReductions: [0] as const }));
const result = optimizeOrderAndCooldowns(fixedReductionCards, chart);
assert.equal(result.order[0], 'a');
assert.ok(result.scoreWeight > 0);
});
+148
View File
@@ -0,0 +1,148 @@
import { COOLDOWN_REDUCTIONS } from './types.ts';
import type {
CooldownReduction,
OptimizeOrderOptions,
OptimizerCard,
SimulationResult,
SongChartDensity
} from './types.ts';
import { buildActiveWindows, buildSpecialWindows, simulateOrder } from './timeline.ts';
function permutations<T>(items: readonly T[]): T[][] {
if (items.length <= 1) return [Array.from(items)];
const result: T[][] = [];
for (let index = 0; index < items.length; index += 1) {
const rest = [...items.slice(0, index), ...items.slice(index + 1)];
for (const permutation of permutations(rest)) result.push([items[index], ...permutation]);
}
return result;
}
function reductionAssignments(cards: readonly OptimizerCard[]): Array<Record<string, CooldownReduction>> {
let assignments: Array<Record<string, CooldownReduction>> = [{}];
for (const card of cards) {
const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
assignments = assignments.flatMap((assignment) =>
allowed.map((reduction) => ({ ...assignment, [card.id]: reduction }))
);
}
return assignments;
}
function reductionTotal(reductions: Readonly<Record<string, CooldownReduction>>): number {
return Object.values(reductions).reduce<number>((sum, reduction) => sum + reduction, 0);
}
function fillWindowProfile(
profile: Float64Array,
windows: ReturnType<typeof buildActiveWindows>,
stepSeconds: number
): void {
for (const window of windows) {
const startTick = Math.max(0, Math.ceil(window.startSeconds / stepSeconds - 1e-9));
const endTick = Math.min(profile.length, Math.ceil(window.endSeconds / stepSeconds - 1e-9));
for (let tick = startTick; tick < endTick; tick += 1) {
profile[tick] = Math.max(profile[tick], window.scoreUpPercent);
}
}
}
function makeNoteWeights(chart: SongChartDensity, stepSeconds: number, tickCount: number): Float64Array {
const weights = new Float64Array(tickCount);
for (let tick = 0; tick < tickCount; tick += 1) {
const timeSeconds = tick * stepSeconds;
const second = Math.floor(timeSeconds);
weights[tick] =
(chart.notesPerSecond[second] ?? 0) * Math.min(stepSeconds, chart.durationSeconds - timeSeconds);
}
return weights;
}
export function optimizeOrderAndCooldowns(
cards: readonly OptimizerCard[],
chart: SongChartDensity,
options: OptimizeOrderOptions = {}
): SimulationResult {
if (cards.length !== 5) throw new Error('최고점 순서 분석에는 정확히 5장의 카드가 필요합니다.');
if (new Set(cards.map((card) => card.id)).size !== cards.length) throw new Error('카드 ID는 서로 달라야 합니다.');
const stepSeconds = options.stepSeconds ?? 0.1;
if (!Number.isFinite(stepSeconds) || stepSeconds <= 0) throw new Error('계산 간격은 0보다 커야 합니다.');
const orders = permutations(cards);
const assignments = reductionAssignments(cards);
const totalCandidates = orders.length * assignments.length;
const tickCount = Math.ceil(chart.durationSeconds / stepSeconds);
const noteWeights = makeNoteWeights(chart, stepSeconds, tickCount);
const activeProfiles = new Map<string, Float64Array>();
for (const card of cards) {
const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
for (const reduction of allowed) {
const profile = new Float64Array(tickCount);
fillWindowProfile(
profile,
buildActiveWindows(card, reduction, chart.durationSeconds, stepSeconds),
stepSeconds
);
activeProfiles.set(`${card.id}:${reduction}`, profile);
}
}
const specialMultipliers = orders.map((order) => {
const profile = new Float64Array(tickCount);
fillWindowProfile(profile, buildSpecialWindows(order, chart), stepSeconds);
for (let tick = 0; tick < tickCount; tick += 1) profile[tick] = 1 + profile[tick] / 100;
return profile;
});
let evaluatedCandidates = 0;
let bestScoreWeight = Number.NEGATIVE_INFINITY;
let bestOrder: OptimizerCard[] | null = null;
let bestReductions: Record<string, CooldownReduction> | null = null;
let bestReductionTotal = Number.POSITIVE_INFINITY;
for (const reductions of assignments) {
const activeScore = new Float64Array(tickCount);
for (const card of cards) {
const reduction = reductions[card.id] ?? 0;
const profile = activeProfiles.get(`${card.id}:${reduction}`);
if (!profile) throw new Error(`${card.name}의 쿨타임 감소 ${reduction}% 계산값이 없습니다.`);
for (let tick = 0; tick < tickCount; tick += 1) {
activeScore[tick] = Math.max(activeScore[tick], profile[tick]);
}
}
const currentReductionTotal = reductionTotal(reductions);
for (let orderIndex = 0; orderIndex < orders.length; orderIndex += 1) {
const specialMultiplier = specialMultipliers[orderIndex];
let scoreWeight = 0;
for (let tick = 0; tick < tickCount; tick += 1) {
scoreWeight += noteWeights[tick] * activeScore[tick] * specialMultiplier[tick];
}
const isBetterScore = scoreWeight > bestScoreWeight + 1e-9;
const isCheaperTie =
Math.abs(scoreWeight - bestScoreWeight) <= 1e-9 && currentReductionTotal < bestReductionTotal;
if (isBetterScore || isCheaperTie) {
bestScoreWeight = scoreWeight;
bestOrder = orders[orderIndex];
bestReductions = reductions;
bestReductionTotal = currentReductionTotal;
}
evaluatedCandidates += 1;
}
options.onProgress?.({ evaluatedCandidates, totalCandidates });
}
if (!bestOrder || !bestReductions) throw new Error('분석할 수 있는 후보가 없습니다.');
return simulateOrder(bestOrder, bestReductions, chart, stepSeconds);
}
export function countOptimizationCandidates(cards: readonly OptimizerCard[]): number {
const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1);
const reductionCount = cards.reduce(
(value, card) => value * (card.allowedCooldownReductions?.length || COOLDOWN_REDUCTIONS.length),
1
);
return orderCount * reductionCount;
}
+76
View File
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
const inactiveCard: OptimizerCard = {
id: 'card',
name: '카드',
active: { cooldownSeconds: 999, durationSeconds: 1, scoreUpPercent: 100, probabilityPercent: 100 },
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
function chart(notes: ExactSongChart['notes']): ExactSongChart {
return { songId: 'test', difficulty: 'expert', durationSeconds: 120, scoreRatio: 100, notes, specialStartSeconds: [] };
}
test('종합력과 악곡 비율로 PERFECT 1노트 기본점수를 계산한다', () => {
assert.equal(perfectNoteScoreFromPower(120_000, 200), 600);
assert.equal(perfectNoteScoreFromPower(120_100, 200), 600.5);
assert.equal(perfectNoteScoreFromPower(0, 200, 777), 777);
});
test('플릭 1.05배와 같은 시각 홀드 중간 판정의 단일 가중치로 환산비율을 계산한다', () => {
const ratio = scoreRatioFromNotes([
{ timeSeconds: 1, type: 'regular', isFlick: false },
{ timeSeconds: 2, type: 'regular', isFlick: true },
{ timeSeconds: 3, type: 'mid', isFlick: false },
{ timeSeconds: 3, type: 'mid', isFlick: false }
]);
assert.ok(Math.abs(ratio - 2.15 * 0.3713416563352296) < 1e-12);
});
test('기본점수의 소수점을 유지하고 가창 보너스 적용 후 노트별로 올림한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'regular', isFlick: false }
]), { perfectNoteScore: 1_354.507, directSongScoreBonusPercent: 10 });
assert.equal(result.baselineScore, 1_490);
});
test('100콤보부터 1% 콤보 보너스를 적용한다', () => {
const notes = Array.from({ length: 100 }, (_, index) => ({ timeSeconds: index, type: 'regular' as const, isFlick: false }));
const result = calculateExactScore([inactiveCard], {}, chart(notes), { perfectNoteScore: 600 });
assert.equal(result.baselineScore, 99 * 600 + 606);
});
test('홀드 중간 노트는 10%, 플릭은 1.05배로 계산한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'mid', isFlick: false },
{ timeSeconds: 2, type: 'regular', isFlick: true }
]), { perfectNoteScore: 600 });
assert.equal(result.baselineScore, 60 + 630);
});
test('같은 시각에 중복된 홀드 중간 판정은 한 번만 계산한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'mid', isFlick: false },
{ timeSeconds: 1, type: 'mid', isFlick: false }
]), { perfectNoteScore: 600 });
assert.equal(result.baselineScore, 60);
assert.equal(result.notes.length, 1);
});
test('액티브 발동 확률과 스페셜 스코어 서포트를 기대 점수에 반영한다', () => {
const card: OptimizerCard = {
id: 'score',
name: '스코어 카드',
active: { cooldownSeconds: 1, durationSeconds: 1, scoreUpPercent: 100, probabilityPercent: 50 },
special: { durationSeconds: 2, scoreUpPercent: 100 }
};
const result = calculateExactScore([card], {}, {
...chart([{ timeSeconds: 1, type: 'regular', isFlick: false }]),
specialStartSeconds: [0]
}, { perfectNoteScore: 600 });
assert.equal(result.expectedScore, 1_200);
assert.equal(result.theoreticalMaxScore, 1_800);
});
+187
View File
@@ -0,0 +1,187 @@
import { effectiveCooldownSeconds } from './timeline.ts';
import { skillTargetBelongsTo } from './skill-condition.ts';
import type {
CooldownReduction,
ExactScoreOptions,
ExactScoreResult,
ExactSongChart,
OptimizerCard
} from './types.ts';
type ActiveCandidate = {
memberIndex: number;
probability: number;
boost: number;
};
// QUEEN 및 Our Promise Expert의 인게임 실측값으로 보정한 전역 환산 상수다.
export const CHART_SCORE_WEIGHT_COEFFICIENT = 0.3713416563352296;
export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number {
const countedMidTimes = new Set<number>();
let weight = 0;
for (const note of notes) {
if (note.type === 'mid') {
if (countedMidTimes.has(note.timeSeconds)) continue;
countedMidTimes.add(note.timeSeconds);
weight += 0.1;
continue;
}
weight += note.isFlick ? 1.05 : 1;
}
return weight * CHART_SCORE_WEIGHT_COEFFICIENT;
}
function passiveScoreSupportAt(cards: readonly OptimizerCard[], targetIndex: number): number {
let total = 0;
cards.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = cards.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = cards
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.slice(0, effect.targetCount ?? cards.length)
.map(({ index }) => index);
if (indexes.includes(targetIndex)) total += effect.percent;
}
});
return total;
}
function comboBonus(combo: number): number {
if (combo >= 1_000) return 0.1;
if (combo >= 100) return Math.floor(combo / 100) * 0.01;
return 0;
}
function activeAttempt(card: OptimizerCard, reduction: CooldownReduction, timeSeconds: number): number {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
return Math.floor((timeSeconds + 1e-9) / interval);
}
function specialIsActive(
card: OptimizerCard,
startSeconds: number | null | undefined,
timeSeconds: number
): boolean {
return startSeconds !== null && startSeconds !== undefined &&
startSeconds <= timeSeconds && timeSeconds < startSeconds + card.special.durationSeconds;
}
function specialSupportAt(
cardsInOrder: readonly OptimizerCard[],
chart: ExactSongChart,
timeSeconds: number
): number {
return cardsInOrder.reduce((sum, card, slot) =>
sum + (specialIsActive(card, chart.specialStartSeconds[slot], timeSeconds) ? card.special.scoreUpPercent : 0), 0);
}
function activationRateBonusAt(
cardsInOrder: readonly OptimizerCard[],
chart: ExactSongChart,
timeSeconds: number
): number {
return cardsInOrder.reduce((sum, card, slot) =>
sum + (specialIsActive(card, chart.specialStartSeconds[slot], timeSeconds)
? card.special.activationRateUpPercent ?? 0
: 0), 0);
}
function activeCandidates(
cardsInOrder: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
timeSeconds: number
): ActiveCandidate[] {
const specialSupport = specialSupportAt(cardsInOrder, chart, timeSeconds);
const candidates = cardsInOrder.flatMap((card, memberIndex) => {
const reduction = reductions[card.id] ?? 0;
const attempt = activeAttempt(card, reduction, timeSeconds);
if (attempt <= 0) return [];
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const activationTime = attempt * interval;
if (timeSeconds >= activationTime + card.active.durationSeconds) return [];
const activationRateBonus = activationRateBonusAt(cardsInOrder, chart, activationTime);
const probability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationRateBonus / 100));
const support = specialSupport + (card.scoreSupportPercent ?? 0) + passiveScoreSupportAt(cardsInOrder, memberIndex);
return [{
memberIndex,
probability,
boost: card.active.scoreUpPercent / 100 * (1 + support / 100)
}];
});
return candidates.sort((left, right) => right.boost - left.boost || left.memberIndex - right.memberIndex);
}
function expectedStrongestBoost(candidates: readonly ActiveCandidate[]): number {
let expectedBoost = 0;
let previousFailed = 1;
for (const candidate of candidates) {
expectedBoost += previousFailed * candidate.probability * candidate.boost;
previousFailed *= 1 - candidate.probability;
}
return expectedBoost;
}
function finalNoteScore(score: number, percent: number): number {
const scaled = score * (1 + percent / 100);
return Math.ceil(scaled - Number.EPSILON * Math.max(1, scaled) * 4);
}
export function perfectNoteScoreFromPower(overallPower: number, scoreRatio: number | null, fallback = 600): number {
if (overallPower > 0 && scoreRatio !== null && scoreRatio > 0) {
return Math.max(1, overallPower / scoreRatio);
}
return fallback;
}
export function calculateExactScore(
cardsInOrder: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
options: ExactScoreOptions
): ExactScoreResult {
const directBonus = options.directSongScoreBonusPercent ?? 0;
const countedMidTimes = new Set<number>();
const scoreNotes = chart.notes.filter((note) => {
if (note.type !== 'mid') return true;
if (countedMidTimes.has(note.timeSeconds)) return false;
countedMidTimes.add(note.timeSeconds);
return true;
});
const notePoints = scoreNotes.map((note, index) => {
const combo = index + 1;
const baseNoteScore = note.type === 'mid'
? Math.ceil(options.perfectNoteScore * 0.1)
: options.perfectNoteScore * (note.isFlick ? 1.05 : 1);
const rawBaselineScore = baseNoteScore * (1 + comboBonus(combo));
const candidates = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds);
const baselineScore = finalNoteScore(rawBaselineScore, directBonus);
const expectedScore = finalNoteScore(rawBaselineScore * (1 + expectedStrongestBoost(candidates)), directBonus);
const theoreticalMaxScore = finalNoteScore(rawBaselineScore * (1 + (candidates[0]?.boost ?? 0)), directBonus);
return { timeSeconds: note.timeSeconds, combo, expectedScore, theoreticalMaxScore, baselineScore };
});
const rawExpected = notePoints.reduce((sum, note) => sum + note.expectedScore, 0);
const rawMaximum = notePoints.reduce((sum, note) => sum + note.theoreticalMaxScore, 0);
const rawBaseline = notePoints.reduce((sum, note) => sum + note.baselineScore, 0);
const scoreBySecond = Array.from({ length: Math.ceil(chart.durationSeconds) }, () => 0);
notePoints.forEach((note) => {
const second = Math.floor(note.timeSeconds);
if (second >= 0 && second < scoreBySecond.length) scoreBySecond[second] += note.expectedScore;
});
return {
expectedScore: rawExpected,
theoreticalMaxScore: rawMaximum,
baselineScore: rawBaseline,
perfectNoteScore: options.perfectNoteScore,
scoreBySecond,
notes: notePoints
};
}
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { skillConditionSatisfied, skillTargetBelongsTo } from './skill-condition.ts';
const members = [
{ type: '큐트', generation: 'hololive 0기생' },
{ type: '큐트', generation: 'hololive 0기생' },
{ type: '퓨어', generation: 'Myth' },
{ type: '해피', generation: 'GAMERS' },
{ type: '해피', generation: 'holoX' }
];
test('타입과 기수 조건은 현재 멤버 조합으로 판정한다', () => {
assert.equal(skillConditionSatisfied('[attribute=cute]큐트 타입[/attribute] 2명 이상이면', members), true);
assert.equal(skillConditionSatisfied('Myth(이)가 2명 이상이면', members), false);
assert.equal(skillConditionSatisfied('0기생(이)가 2명 이상이면', members), true);
});
test('콤보와 라이프 조건은 항상 충족한 것으로 판정한다', () => {
assert.equal(skillConditionSatisfied('100콤보 이상이면', members), true);
assert.equal(skillConditionSatisfied('라이프 1000 이상이면', members), true);
});
test('복수 소속 멤버는 표기된 각 기수에 모두 속한다', () => {
const fubuki = { type: '퓨어', generation: 'hololive 1기생 / GAMERS' };
assert.equal(skillTargetBelongsTo(fubuki, '1기생'), true);
assert.equal(skillTargetBelongsTo(fubuki, 'GAMERS'), true);
assert.equal(skillConditionSatisfied('1기생(이)가 1명 이상이면', [fubuki]), true);
});
+51
View File
@@ -0,0 +1,51 @@
export type SkillConditionMember = {
type: string;
generation: string;
};
export function plainSkillText(value: string): string {
return value.replace(/\[[^\]]+\]/g, '').replace(/\s+/g, ' ').trim();
}
function normalizedGroup(value: string): string {
return value
.replace(/^hololive\s+/i, '')
.replace(/\(이\)가$/, '')
.replace(/\s*타입$/, '')
.trim()
.toLowerCase();
}
function memberBelongsTo(member: SkillConditionMember, group: string): boolean {
const normalized = normalizedGroup(group);
const type = normalizedGroup(member.type);
const generation = normalizedGroup(member.generation);
const generations = member.generation
.split('/')
.map((value) => normalizedGroup(value))
.filter(Boolean);
return type === normalized
|| generation === normalized
|| generation.endsWith(normalized)
|| generations.some((value) => value === normalized || value.endsWith(normalized));
}
export function skillConditionSatisfied(
condition: string,
members: readonly SkillConditionMember[]
): boolean {
const description = plainSkillText(condition);
if (!description) return true;
// 콤보와 라이프는 분석에서 최적 플레이를 전제로 항상 충족한다.
if (/\d+콤보 이상이면/.test(description) || /라이프 \d+ 이상이면/.test(description)) return true;
const deckCondition = description.match(/^(.+?)\s*(\d+)명 이상이면$/);
if (!deckCondition) return true;
const requiredCount = Number(deckCondition[2]);
return members.filter((member) => memberBelongsTo(member, deckCondition[1])).length >= requiredCount;
}
export function skillTargetBelongsTo(member: SkillConditionMember, group: string): boolean {
return memberBelongsTo(member, group);
}
+107
View File
@@ -0,0 +1,107 @@
import type {
CooldownReduction,
OptimizerCard,
SimulationResult,
SkillWindow,
SongChartDensity,
TimelinePoint
} from './types.ts';
const EPSILON = 1e-9;
export function ceilToStep(value: number, stepSeconds: number): number {
return Math.ceil(value / stepSeconds - EPSILON) * stepSeconds;
}
export function effectiveCooldownSeconds(baseCooldownSeconds: number, reduction: CooldownReduction): number {
return baseCooldownSeconds / (1 + reduction / 100);
}
export function buildActiveWindows(
card: OptimizerCard,
reduction: CooldownReduction,
durationSeconds: number,
stepSeconds = 0.1
): SkillWindow[] {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const windows: SkillWindow[] = [];
for (let activation = 1; ; activation += 1) {
const startSeconds = ceilToStep(activation * interval, stepSeconds);
if (startSeconds > durationSeconds + EPSILON) break;
windows.push({
cardId: card.id,
startSeconds,
endSeconds: Math.min(durationSeconds, startSeconds + card.active.durationSeconds),
scoreUpPercent: card.active.scoreUpPercent
});
}
return windows;
}
export function buildSpecialWindows(cardsInOrder: readonly OptimizerCard[], chart: SongChartDensity): SkillWindow[] {
return cardsInOrder.slice(0, chart.specialStartSeconds.length).flatMap((card, slot) => {
const startSeconds = chart.specialStartSeconds[slot];
if (startSeconds === null || startSeconds === undefined) return [];
return [{
cardId: card.id,
startSeconds,
endSeconds: Math.min(chart.durationSeconds, startSeconds + card.special.durationSeconds),
scoreUpPercent: card.special.scoreUpPercent
}];
});
}
function strongestWindowAt(windows: readonly SkillWindow[], timeSeconds: number): SkillWindow | null {
let strongest: SkillWindow | null = null;
for (const window of windows) {
if (window.startSeconds <= timeSeconds && timeSeconds < window.endSeconds) {
if (!strongest || window.scoreUpPercent > strongest.scoreUpPercent) strongest = window;
}
}
return strongest;
}
export function simulateOrder(
cardsInOrder: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: SongChartDensity,
stepSeconds = 0.1
): SimulationResult {
const activeWindows = cardsInOrder.flatMap((card) =>
buildActiveWindows(card, reductions[card.id] ?? 0, chart.durationSeconds, stepSeconds)
);
const specialWindows = buildSpecialWindows(cardsInOrder, chart);
const timeline: TimelinePoint[] = [];
let scoreWeight = 0;
const tickCount = Math.ceil(chart.durationSeconds / stepSeconds);
for (let tick = 0; tick < tickCount; tick += 1) {
const timeSeconds = tick * stepSeconds;
const second = Math.floor(timeSeconds);
const noteWeight = (chart.notesPerSecond[second] ?? 0) * Math.min(stepSeconds, chart.durationSeconds - timeSeconds);
const active = strongestWindowAt(activeWindows, timeSeconds);
const special = strongestWindowAt(specialWindows, timeSeconds);
const activeScoreUpPercent = active?.scoreUpPercent ?? 0;
const specialScoreUpPercent = special?.scoreUpPercent ?? 0;
const pointScoreWeight = noteWeight * activeScoreUpPercent * (1 + specialScoreUpPercent / 100);
scoreWeight += pointScoreWeight;
timeline.push({
timeSeconds,
noteWeight,
activeCardId: active?.cardId ?? null,
activeScoreUpPercent,
specialCardId: special?.cardId ?? null,
specialScoreUpPercent,
scoreWeight: pointScoreWeight
});
}
return {
scoreWeight,
order: cardsInOrder.map((card) => card.id),
cooldownReductions: { ...reductions },
activeWindows,
specialWindows,
timeline
};
}
+130
View File
@@ -0,0 +1,130 @@
export const COOLDOWN_REDUCTIONS = [0, 4, 8, 12] as const;
export type CooldownReduction = (typeof COOLDOWN_REDUCTIONS)[number];
export type OptimizerCard = {
id: string;
name: string;
type?: string;
generation?: string;
active: {
cooldownSeconds: number;
durationSeconds: number;
scoreUpPercent: number;
probabilityPercent?: number;
};
special: {
durationSeconds: number;
scoreUpPercent: number;
activationRateUpPercent?: number;
};
scoreSupportPercent?: number;
passiveScoreSupportEffects?: readonly {
percent: number;
target: 'all' | 'self' | 'group';
targetGroup?: string;
targetCount?: number;
}[];
allowedCooldownReductions?: readonly CooldownReduction[];
};
export type SongChartDensity = {
songId: string;
difficulty: string;
durationSeconds: number;
notesPerSecond: readonly number[];
specialStartSeconds: readonly (number | null)[];
};
export type SkillWindow = {
cardId: string;
startSeconds: number;
endSeconds: number;
scoreUpPercent: number;
};
export type EffectiveSkillWindow = SkillWindow & {
baseScoreUpPercent: number;
overlapCount: number;
};
export type TimelinePoint = {
timeSeconds: number;
noteWeight: number;
activeCardId: string | null;
activeScoreUpPercent: number;
specialCardId: string | null;
specialScoreUpPercent: number;
scoreWeight: number;
};
export type SimulationResult = {
scoreWeight: number;
order: readonly string[];
cooldownReductions: Readonly<Record<string, CooldownReduction>>;
activeWindows: readonly SkillWindow[];
specialWindows: readonly SkillWindow[];
timeline: readonly TimelinePoint[];
};
export type OptimizationProgress = {
evaluatedCandidates: number;
totalCandidates: number;
};
export type OptimizeOrderOptions = {
stepSeconds?: number;
onProgress?: (progress: OptimizationProgress) => void;
};
export type ExactChartNote = {
timeSeconds: number;
type: 'regular' | 'mid';
isFlick: boolean;
};
export type ExactSongChart = {
songId: string;
difficulty: string;
durationSeconds: number;
scoreRatio: number | null;
notes: readonly ExactChartNote[];
specialStartSeconds: readonly (number | null)[];
singerType?: string | null;
singerCharacterIds?: readonly string[];
};
export type NoteScorePoint = {
timeSeconds: number;
combo: number;
expectedScore: number;
theoreticalMaxScore: number;
baselineScore: number;
};
export type ExactScoreResult = {
expectedScore: number;
theoreticalMaxScore: number;
baselineScore: number;
perfectNoteScore: number;
scoreBySecond: readonly number[];
notes: readonly NoteScorePoint[];
};
export type ExactScoreOptions = {
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
};
export type ExactOptimizationResult = ExactScoreResult & {
order: readonly string[];
cooldownReductions: Readonly<Record<string, CooldownReduction>>;
activeWindows: readonly SkillWindow[];
effectiveActiveWindows: readonly EffectiveSkillWindow[];
specialWindows: readonly SkillWindow[];
evaluatedCandidates: number;
};
export type ExactOptimizeOptions = ExactScoreOptions & {
onProgress?: (progress: OptimizationProgress) => void;
};
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { calculateUnitPower, type UnitPowerCard } from './unit-power.ts';
function card(id: string, passive: string, awakenedPassive = passive): UnitPowerCard {
return {
id,
characterId: id,
name: id,
type: '큐트',
generation: '0기생',
power: { maxLevel: 80, statsAtMaxLevel: { performance: 100, technique: 100, sense: 100 } },
details: { passive: [{ level: 1, description: passive }, { level: 2, description: awakenedPassive }] }
};
}
test('최대 레벨 기본 스탯, 개화 보너스와 패시브로 종합력을 계산한다', () => {
const result = calculateUnitPower([
{ card: card('a', '0기생(이)가 2명 이상이면 자신의 전체 파라미터가 20% UP'), awakening: 5 },
...['b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 5 }))
]);
assert.equal(result.baseParameterTotal, 1500);
assert.equal(result.potentialTotal, 150);
assert.equal(result.passiveTotal, 66);
assert.equal(result.overallPower, 1716);
});
test('패시브 2레벨은 4개화부터 적용한다', () => {
const passiveCard = card('a', '자신의 전체 파라미터가 10% UP', '자신의 전체 파라미터가 20% UP');
const otherCards = ['b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 }));
const before = calculateUnitPower([{ card: passiveCard, awakening: 3 }, ...otherCards]);
const after = calculateUnitPower([{ card: passiveCard, awakening: 4 }, ...otherCards]);
assert.equal(before.passiveTotal, 33);
assert.equal(after.passiveTotal, 66);
});
test('편성 조건을 만족한 리더 의상 효과를 종합력에 더한다', () => {
const result = calculateUnitPower(
['a', 'b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 })),
{ name: '테스트 의상', description: '0기생(이)가 2명 이상이면 전원의 전체 파라미터가 50% UP' }
);
assert.equal(result.outfitTotal, 750);
assert.equal(result.overallPower, 2250);
});
test('보유 메모리 효과는 개화 적용 후 각 멤버의 능력치마다 올림한다', () => {
const result = calculateUnitPower(
['a', 'b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 2 })),
null,
null,
46
);
assert.equal(result.potentialTotal, 150);
assert.equal(result.memoryTotal, 90);
assert.equal(result.overallPower, 1740);
});
test('멤버 강화는 메모리를 제외한 파라미터·패시브·의상·보드 합계에 적용한다', () => {
const result = calculateUnitPower(
['a', 'b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 })),
null,
null,
100,
10
);
assert.equal(result.memoryTotal, 150);
assert.equal(result.memberPowerUpTotal, 150);
assert.equal(result.overallPower, 1800);
});
test('조건부 기수 패시브는 복수 소속 멤버도 대상으로 삼는다', () => {
const fubuki = {
...card('fubuki', '1기생(이)가 2명 이상이면 1기생2명의 퍼포먼스가 10% UP'),
generation: 'hololive 1기생 / GAMERS'
};
const matsuri = {
...card('matsuri', ''),
generation: 'hololive 1기생'
};
const result = calculateUnitPower([
{ card: fubuki, awakening: 0 },
{ card: matsuri, awakening: 0 },
...['c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 }))
]);
assert.equal(result.passiveTotal, 20);
});
+169
View File
@@ -0,0 +1,169 @@
import { plainSkillText, skillConditionSatisfied, skillTargetBelongsTo } from './skill-condition.ts';
export type UnitStat = 'performance' | 'technique' | 'sense';
export type UnitPowerCard = {
id: string;
characterId: string;
name: string;
type: string;
generation: string;
power: {
maxLevel: number;
statsAtMaxLevel: Record<UnitStat, number>;
};
details: {
passive: readonly { level: number; description: string }[];
};
};
export type UnitPowerMember = {
card: UnitPowerCard;
awakening: number;
};
export type UnitPowerResult = {
overallPower: number;
baseParameterTotal: number;
potentialTotal: number;
passiveTotal: number;
outfitTotal: number;
boardTotal: number;
memoryTotal: number;
memberPowerUpTotal: number;
outfitScoreSupportPercent: number;
};
export type UnitPowerOutfit = {
name: string;
description: string;
};
export type UnitPowerBoardEffects = {
flatByCardId: Record<string, Record<UnitStat, number>>;
rateByCardId: Record<string, Record<UnitStat, number>>;
};
const stats: readonly UnitStat[] = ['performance', 'technique', 'sense'];
function selectedPassive(member: UnitPowerMember): string {
const level = member.awakening >= 4 ? 2 : 1;
return plainSkillText(member.card.details.passive.find((entry) => entry.level === level)?.description ?? '');
}
function conditionSatisfied(description: string, members: readonly UnitPowerMember[]): boolean {
const match = description.match(/^(.+?\d+명 이상이면)/);
if (!match) return true;
return skillConditionSatisfied(match[1], members.map(({ card }) => card));
}
function affectedStats(description: string): readonly UnitStat[] {
if (description.includes('전체 파라미터')) return stats;
if (description.includes('퍼포먼스')) return ['performance'];
if (description.includes('테크닉')) return ['technique'];
if (description.includes('센스')) return ['sense'];
return [];
}
function effectRate(description: string): number {
const matches = [...description.matchAll(/(\d+(?:\.\d+)?)%\s*UP/g)];
return Number(matches.at(-1)?.[1] ?? 0);
}
function targetIndexes(
description: string,
sourceIndex: number,
stat: UnitStat,
members: readonly UnitPowerMember[],
parameters: readonly Record<UnitStat, number>[]
): number[] {
if (description.includes('자신의')) return [sourceIndex];
if (description.includes('전원의')) return members.map((_, index) => index);
const effectText = description.includes('이면')
? description.slice(description.lastIndexOf('이면') + 2).trim()
: description;
const target = effectText.match(/^(.+?)\s*(\d+)명의\s*(?:전체 파라미터|퍼포먼스|테크닉|센스)/);
if (!target) return [];
const count = Number(target[2]);
return members
.map((member, index) => ({ member, index }))
.filter(({ member }) => skillTargetBelongsTo(member.card, target[1]))
.sort((left, right) => parameters[right.index][stat] - parameters[left.index][stat] || left.index - right.index)
.slice(0, count)
.map(({ index }) => index);
}
export function calculateUnitPower(
members: readonly UnitPowerMember[],
outfit?: UnitPowerOutfit | null,
board?: UnitPowerBoardEffects | null,
memoryPermil = 0,
memberPowerUpPercent = 0
): UnitPowerResult {
if (members.length !== 5) throw new Error('유닛 종합력 계산에는 카드 5장이 필요합니다.');
const baseParameters = members.map(({ card }) => ({ ...card.power.statsAtMaxLevel }));
const parameters = baseParameters.map((base, index) => {
const potentialRate = members[index].awakening >= 2 ? 10 : 0;
return Object.fromEntries(stats.map((stat) => [stat, Math.ceil(base[stat] * (100 + potentialRate) / 100)])) as Record<UnitStat, number>;
});
const passiveRates = members.map(() => ({ performance: 0, technique: 0, sense: 0 }));
members.forEach((member, sourceIndex) => {
const description = selectedPassive(member);
if (!conditionSatisfied(description, members)) return;
const rate = effectRate(description);
for (const stat of affectedStats(description)) {
for (const targetIndex of targetIndexes(description, sourceIndex, stat, members, parameters)) {
passiveRates[targetIndex][stat] += rate;
}
}
});
const baseParameterTotal = baseParameters.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const parameterTotal = parameters.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const passiveBonuses = parameters.map((member, index) => Object.fromEntries(stats.map((stat) => [
stat,
Math.ceil(member[stat] * passiveRates[index][stat] / 100)
])) as Record<UnitStat, number>);
const passiveTotal = passiveBonuses.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const outfitDescription = plainSkillText(outfit?.description ?? '');
const outfitActive = conditionSatisfied(outfitDescription, members);
const outfitRate = outfitActive ? effectRate(outfitDescription) : 0;
const outfitStats = outfitActive ? affectedStats(outfitDescription) : [];
const outfitBonuses = parameters.map((member) => Object.fromEntries(stats.map((stat) => [
stat,
outfitStats.includes(stat) ? Math.ceil(member[stat] * outfitRate / 100) : 0
])) as Record<UnitStat, number>);
const outfitTotal = outfitBonuses.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const supportMatch = outfitDescription.match(/스코어 서포트 효과\s*(\d+(?:\.\d+)?)%/);
const outfitScoreSupportPercent = outfitActive ? Number(supportMatch?.[1] ?? 0) : 0;
const boardBonuses = parameters.map((member, index) => Object.fromEntries(stats.map((stat) => {
const cardId = members[index].card.id;
const flat = board?.flatByCardId[cardId]?.[stat] ?? 0;
const rate = board?.rateByCardId[cardId]?.[stat] ?? 0;
return [stat, flat + Math.ceil(member[stat] * rate / 100)];
})) as Record<UnitStat, number>);
const boardTotal = boardBonuses.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const memoryTotal = parameters.reduce((total, member) => total + stats.reduce(
(sum, stat) => sum + Math.ceil(member[stat] * memoryPermil / 1000),
0
), 0);
const memberPowerUpTotal = parameters.reduce((total, member, index) => {
const memberBase = stats.reduce((sum, stat) => sum + member[stat] + passiveBonuses[index][stat] + outfitBonuses[index][stat] + boardBonuses[index][stat], 0);
return total + Math.ceil(memberBase * memberPowerUpPercent / 100);
}, 0);
return {
overallPower: parameterTotal + passiveTotal + outfitTotal + boardTotal + memoryTotal + memberPowerUpTotal,
baseParameterTotal,
potentialTotal: parameterTotal - baseParameterTotal,
passiveTotal,
outfitTotal,
boardTotal,
memoryTotal,
memberPowerUpTotal,
outfitScoreSupportPercent
};
}
+53
View File
@@ -0,0 +1,53 @@
export type CollectionCardState = {
owned: boolean;
awakening: number;
};
export type SavedDeck = {
id: string;
name: string;
cardIds: string[];
settings: Record<string, unknown>;
createdAt: string;
updatedAt: string;
};
export type CardListPreferences = {
types: string[];
stats: string[];
activeConditions: string[];
specialConditions: string[];
activeProbabilities: string[];
cardVariants: string[];
generations: string[];
};
export type BoardCardPlacements = Record<string, Record<string, string>>;
export type UserDataV1 = {
schemaVersion: 1;
updatedAt: string;
memoryCount: number;
memberPowerUpPercent: number;
collection: Record<string, CollectionCardState>;
memberBoards: Record<string, string[]>;
boardCards: BoardCardPlacements;
savedDecks: SavedDeck[];
preferences: CardListPreferences;
};
export type UserData = UserDataV1;
export function createUserData(preferences: CardListPreferences): UserDataV1 {
return {
schemaVersion: 1,
updatedAt: new Date().toISOString(),
memoryCount: 0,
memberPowerUpPercent: 0,
collection: {},
memberBoards: {},
boardCards: {},
savedDecks: [],
preferences
};
}
+100
View File
@@ -0,0 +1,100 @@
import type { CardListPreferences, CollectionCardState, UserData } from '$lib/models/user-data';
import { createUserData } from '$lib/models/user-data';
export const USER_DATA_STORAGE_KEY = 'holodori:user-data:v1';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stringArray(value: unknown, fallback: string[]): string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string')
? [...value]
: [...fallback];
}
function finiteNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function parseCollection(value: Record<string, unknown>): UserData['collection'] {
return Object.fromEntries(
Object.entries(value).flatMap(([cardId, state]) => {
if (!isRecord(state)) return [];
const parsed: CollectionCardState = {
owned: state.owned === true,
awakening: Math.max(0, Math.min(5, Math.round(finiteNumber(state.awakening))))
};
return [[cardId, parsed]];
})
);
}
function parseMemberBoards(value: unknown): UserData['memberBoards'] {
if (!isRecord(value)) return {};
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string[]] =>
Array.isArray(entry[1]) && entry[1].every((nodeId) => typeof nodeId === 'string')
)
);
}
function parseBoardCards(value: unknown): UserData['boardCards'] {
if (!isRecord(value)) return {};
return Object.fromEntries(Object.entries(value).flatMap(([memberId, placements]) => {
if (!isRecord(placements)) return [];
return [[memberId, Object.fromEntries(
Object.entries(placements).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
)]];
}));
}
export function parseUserData(text: string, fallbackPreferences: CardListPreferences): UserData {
const value: unknown = JSON.parse(text);
if (!isRecord(value) || value.schemaVersion !== 1) {
throw new Error('지원하지 않는 백업 파일입니다.');
}
if (!isRecord(value.preferences) || !isRecord(value.collection) || !Array.isArray(value.savedDecks)) {
throw new Error('올바른 홀로도리 백업 파일이 아닙니다.');
}
const preferences = value.preferences;
const defaults = createUserData(fallbackPreferences);
return {
schemaVersion: 1,
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : defaults.updatedAt,
memoryCount: Math.max(0, Math.round(finiteNumber(value.memoryCount))),
memberPowerUpPercent: Math.max(0, Math.min(50, finiteNumber(value.memberPowerUpPercent))),
collection: parseCollection(value.collection),
memberBoards: parseMemberBoards(value.memberBoards),
boardCards: parseBoardCards(value.boardCards),
savedDecks: value.savedDecks as UserData['savedDecks'],
preferences: {
types: stringArray(preferences.types, fallbackPreferences.types),
stats: stringArray(preferences.stats, fallbackPreferences.stats),
activeConditions: stringArray(preferences.activeConditions, fallbackPreferences.activeConditions),
specialConditions: stringArray(preferences.specialConditions, fallbackPreferences.specialConditions),
activeProbabilities: stringArray(preferences.activeProbabilities, fallbackPreferences.activeProbabilities),
cardVariants: stringArray(preferences.cardVariants, fallbackPreferences.cardVariants),
generations: stringArray(preferences.generations, fallbackPreferences.generations)
}
};
}
export function loadBrowserData(fallbackPreferences: CardListPreferences): UserData {
const stored = localStorage.getItem(USER_DATA_STORAGE_KEY);
return stored ? parseUserData(stored, fallbackPreferences) : createUserData(fallbackPreferences);
}
export function saveBrowserData(data: UserData): UserData {
const saved = { ...data, updatedAt: new Date().toISOString() } satisfies UserData;
localStorage.setItem(USER_DATA_STORAGE_KEY, JSON.stringify(saved));
return saved;
}
export function clearBrowserData(): void {
localStorage.removeItem(USER_DATA_STORAGE_KEY);
}
+73
View File
@@ -0,0 +1,73 @@
/// <reference lib="webworker" />
import { countOptimizationCandidates, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts';
type OptimizeRequest = {
type: 'optimize-exact';
cards: OptimizerCard[];
chart: ExactSongChart;
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
};
type CombinationCandidate = {
cards: OptimizerCard[];
overallPower: number;
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
};
type CombinationRequest = {
type: 'optimize-combinations';
candidates: CombinationCandidate[];
chart: ExactSongChart;
};
self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) => {
try {
if (event.data.type === 'optimize-exact') {
const options: ExactOptimizeOptions = {
perfectNoteScore: event.data.perfectNoteScore,
directSongScoreBonusPercent: event.data.directSongScoreBonusPercent,
onProgress: (progress) => self.postMessage({ type: 'progress', progress })
};
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
self.postMessage({ type: 'result', result });
return;
}
const totalCandidates = event.data.candidates.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0);
let completedCandidates = 0;
let best: { result: ExactOptimizationResult; overallPower: number } | null = null;
const rankings: { result: ExactOptimizationResult; overallPower: number }[] = [];
for (const candidate of event.data.candidates) {
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
perfectNoteScore: candidate.perfectNoteScore,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
onProgress: (progress) => self.postMessage({
type: 'progress',
progress: { evaluatedCandidates: completedCandidates + progress.evaluatedCandidates, totalCandidates }
})
});
completedCandidates += countOptimizationCandidates(candidate.cards);
if (!best || result.theoreticalMaxScore > best.result.theoreticalMaxScore ||
(result.theoreticalMaxScore === best.result.theoreticalMaxScore && result.expectedScore > best.result.expectedScore)) {
best = { result, overallPower: candidate.overallPower };
}
rankings.push({ result, overallPower: candidate.overallPower });
}
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
rankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
right.result.expectedScore - left.result.expectedScore);
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, 10) });
} catch (error) {
self.postMessage({
type: 'error',
message: error instanceof Error ? error.message : '분석 중 알 수 없는 오류가 발생했습니다.'
});
}
};
export {};
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import { page } from '$app/stores';
</script>
<header class="site-nav">
<div class="nav-inner">
<a class="brand" href="/" aria-label="홀로도리 홈">홀로도리</a>
<nav aria-label="주요 메뉴">
<a href="/" aria-current={$page.url.pathname === '/' ? 'page' : undefined}>카드 목록</a>
<a href="/analysis" aria-current={$page.url.pathname.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
<a href="/combination" aria-current={$page.url.pathname.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
<a href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
</nav>
</div>
</header>
<slot />
<style>
:global(:root) {
--page-max-width: 1520px;
--page-gutter: clamp(40px, 5vw, 96px);
}
:global(*) { box-sizing: border-box; }
:global(body) { margin: 0; background: #101319; color: #e8ebf1; font-family: Pretendard, "Noto Sans KR", sans-serif; }
.site-nav {
position: sticky;
z-index: 20;
top: 0;
border-bottom: 1px solid rgb(255 255 255 / 8%);
background: rgb(16 19 25 / 88%);
backdrop-filter: blur(14px);
}
.nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
width: min(var(--page-max-width), calc(100% - var(--page-gutter)));
min-height: 62px;
margin: 0 auto;
}
.brand {
color: #f3f5f8;
font-size: 16px;
font-weight: 850;
letter-spacing: -.03em;
text-decoration: none;
}
nav { display: flex; align-items: center; gap: 4px; }
nav a {
padding: 8px 12px;
border-radius: 9px;
color: #929cad;
font-size: 13px;
font-weight: 750;
text-decoration: none;
}
nav a:hover { color: #e2e6ed; background: rgb(255 255 255 / 5%); }
nav a[aria-current='page'] { color: #f1a1c3; background: rgb(239 139 182 / 12%); }
a:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 2px; }
@media (max-width: 560px) {
:global(:root) { --page-gutter: 24px; }
.nav-inner { width: calc(100% - 24px); min-height: 54px; }
.brand { font-size: 15px; }
nav a { padding: 7px 9px; font-size: 14px; }
}
</style>
+1693 -25
View File
File diff suppressed because it is too large Load Diff
+736
View File
@@ -0,0 +1,736 @@
<script lang="ts">
import { onMount } from 'svelte';
import cardSummary from '$lib/data/cards-summary.json';
import boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
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 { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
type CardRow = (typeof cardSummary.cards)[number];
type DifficultyKey = 'hard' | 'expert';
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
type MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, Record<string, string>>;
type DisplayEffectiveWindow = SkillWindow & {
baseScoreUpPercent: number;
minScoreUpPercent: number;
maxScoreUpPercent: number;
maxOverlapCount: number;
};
type SavedAnalysisState = {
schemaVersion: 2;
songId: string;
difficulty: DifficultyKey;
cardIds: string[];
awakenings: number[];
fixedCooldownReductions?: (CooldownReduction | null)[];
leaderCharacterId: string;
leaderOutfitCardId: string;
};
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const LEGACY_ANALYSIS_STORAGE_KEY = 'holodori.analysis.v1';
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
const charts = exactChartCatalog.charts;
const songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
function uniqueMemberCards(cards: readonly CardRow[], limit = Number.POSITIVE_INFINITY): CardRow[] {
const memberIds = new Set<string>();
return cards.filter((card) => {
if (memberIds.has(card.characterId)) return false;
memberIds.add(card.characterId);
return true;
}).slice(0, limit);
}
const leaderMembers = uniqueMemberCards(fiveStarCards);
let collection: CollectionState = {};
let memberBoards: MemberBoards = {};
let boardCards: BoardCards = {};
let memoryCount = 0;
let memberPowerUpPercent = 0;
let selectedSongId = songs[0]?.id ?? '';
let difficulty: DifficultyKey = 'expert';
let selectedCardIds = uniqueMemberCards(fiveStarCards, 5).map((card) => card.id);
let selectedAwakenings = selectedCardIds.map(() => 5);
let fixedCooldownReductions: (CooldownReduction | null)[] = selectedCardIds.map(() => null);
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
let worker: Worker | null = null;
let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null;
let errorMessage = '';
let analysisReady = false;
$: selectedSong = songs.find((song) => song.id === selectedSongId) ?? songs[0];
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
$: leaderOutfitOptions = leaderMember
? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume)
.filter((card, index, cards) => cards.findIndex((candidate) => candidate.details.costume?.name === card.details.costume?.name) === index)
: [];
$: leaderOutfit = cardById.get(leaderOutfitCardId)?.details.costume ?? null;
$: leaderOutfitDescription = (leaderOutfit?.description ?? '')
.replace(/\[[^\]]+\]/g, '')
.replace(/\s+/g, ' ')
.trim();
$: selectedSourceCards = selectedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
$: connectionCards = cardSummary.cards
.filter((card) => card.details.connect.length > 0)
.map((card) => ({
id: card.id,
awakening: collection[card.id]?.awakening ?? 0,
connect: card.details.connect
}));
$: boardEffects = calculateBoardEffects(boardSummary, memberBoards, selectedSourceCards, leaderCharacterId, selectedChart, {
placements: boardCards,
cards: connectionCards
});
$: memoryPermil = memoryParameterPermil(memoryCount, memorySummary.effects);
$: unitPower = (() => {
try {
return calculateUnitPower(selectedCardIds.map((id, index) => ({
card: cardById.get(id)!,
awakening: selectedAwakenings[index]
})), leaderOutfit, boardEffects, memoryPermil, memberPowerUpPercent);
} catch {
return { overallPower: 0, baseParameterTotal: 0, potentialTotal: 0, passiveTotal: 0, outfitTotal: 0, boardTotal: 0, memoryTotal: 0, memberPowerUpTotal: 0, outfitScoreSupportPercent: 0 };
}
})();
$: overallPower = unitPower.overallPower;
$: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null);
$: timelineDuration = selectedChart?.durationSeconds ?? 0;
$: noteCountsBySecond = result ? countNotesBySecond(result, timelineDuration) : [];
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []);
$: maxNotesPerSecond = Math.max(1, ...noteCountsBySecond);
$: timelineTicks = buildTimelineTicks(timelineDuration);
$: timelineWidth = Math.max(720, timelineDuration * 6);
$: candidateCount = (() => {
fixedCooldownReductions;
try {
const sourceCards = selectedSourceCards;
if (new Set(sourceCards.map((card) => card.characterId)).size !== 5) return 0;
const cards = sourceCards.map((card, index) => optimizerCardWithBoard(card, index, sourceCards));
return cards.length === 5 ? countOptimizationCandidates(cards) : 0;
} catch {
return 0;
}
})();
$: if (analysisReady) saveAnalysisState({
schemaVersion: 2,
songId: selectedSongId,
difficulty,
cardIds: selectedCardIds,
awakenings: selectedAwakenings,
fixedCooldownReductions,
leaderCharacterId,
leaderOutfitCardId
});
onMount(() => {
const applyStoredUserData = (stored: string | null) => {
const parsed = stored ? JSON.parse(stored) : null;
collection = parsed?.collection && typeof parsed.collection === 'object' ? parsed.collection : {};
memberBoards = parsed?.memberBoards && typeof parsed.memberBoards === 'object' ? parsed.memberBoards : {};
boardCards = parsed?.boardCards && typeof parsed.boardCards === 'object' ? parsed.boardCards : {};
memoryCount = typeof parsed?.memoryCount === 'number' && Number.isFinite(parsed.memoryCount)
? Math.max(0, Math.min(memorySummary.maxCount, Math.round(parsed.memoryCount)))
: 0;
memberPowerUpPercent = typeof parsed?.memberPowerUpPercent === 'number' && Number.isFinite(parsed.memberPowerUpPercent)
? Math.max(0, Math.min(50, parsed.memberPowerUpPercent))
: 0;
return parsed;
};
try {
const parsed = applyStoredUserData(localStorage.getItem(USER_DATA_STORAGE_KEY));
const owned = uniqueMemberCards(fiveStarCards.filter((card) => collection[card.id]?.owned), 5);
if (owned.length === 5) {
selectedCardIds = owned.map((card) => card.id);
selectedAwakenings = owned.map((card) => collection[card.id]?.awakening ?? 0);
}
} catch {
collection = {};
memberBoards = {};
boardCards = {};
memoryCount = 0;
memberPowerUpPercent = 0;
}
try {
const currentState = localStorage.getItem(ANALYSIS_STORAGE_KEY);
const legacyState = currentState ? null : localStorage.getItem(LEGACY_ANALYSIS_STORAGE_KEY);
restoreAnalysisState(currentState ?? legacyState, Boolean(legacyState));
if (legacyState) localStorage.removeItem(LEGACY_ANALYSIS_STORAGE_KEY);
} catch {
localStorage.removeItem(ANALYSIS_STORAGE_KEY);
} finally {
analysisReady = true;
}
worker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), {
type: 'module'
});
worker.onmessage = (event) => {
if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'result') {
result = event.data.result;
running = false;
}
if (event.data.type === 'error') {
errorMessage = event.data.message;
running = false;
}
};
const syncStorage = (event: StorageEvent) => {
if (event.key !== USER_DATA_STORAGE_KEY) return;
try {
applyStoredUserData(event.newValue);
result = null;
} catch {
// 다른 탭에서 쓰기가 끝나지 않은 순간에는 기존 상태를 유지한다.
}
};
window.addEventListener('storage', syncStorage);
return () => {
window.removeEventListener('storage', syncStorage);
worker?.terminate();
};
});
function cardLabel(card: CardRow, awakening?: number): string {
const variant = card.versionName ? ` · ${card.versionName}` : '';
const growth = awakening === undefined ? '' : ` · ${awakening === 0 ? '명함' : `${awakening}개화`}`;
return `${card.name}${variant}${growth}`;
}
function saveAnalysisState(state: SavedAnalysisState): void {
try {
localStorage.setItem(ANALYSIS_STORAGE_KEY, JSON.stringify(state));
} catch {
// 저장소 사용이 차단된 환경에서도 분석 기능은 계속 동작한다.
}
}
function normalizeAwakening(value: unknown): number {
const awakening = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 0;
return Math.max(0, Math.min(5, awakening));
}
function restoreAnalysisState(stored: string | null, legacy = false): void {
if (!stored) return;
const state = JSON.parse(stored) as Partial<SavedAnalysisState>;
if (!legacy && state.schemaVersion !== 2) return;
if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
if (state.difficulty === 'hard' || state.difficulty === 'expert') difficulty = state.difficulty;
if (Array.isArray(state.cardIds) && state.cardIds.length === 5) {
const restoredCards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
if (restoredCards.length === 5 && new Set(restoredCards.map((card) => card.characterId)).size === 5) {
selectedCardIds = restoredCards.map((card) => card.id);
selectedAwakenings = restoredCards.map((card, index) => {
if (legacy && collection[card.id]?.owned) return normalizeAwakening(collection[card.id]?.awakening);
return normalizeAwakening(state.awakenings?.[index]);
});
fixedCooldownReductions = restoredCards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index]));
}
}
if (typeof state.leaderCharacterId === 'string' && leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) {
leaderCharacterId = state.leaderCharacterId;
const validOutfit = typeof state.leaderOutfitCardId === 'string'
? cardById.get(state.leaderOutfitCardId)
: undefined;
leaderOutfitCardId = validOutfit?.characterId === leaderCharacterId && validOutfit.details.costume
? validOutfit.id
: fiveStarCards.find((card) => card.characterId === leaderCharacterId && card.details.costume)?.id ?? '';
}
}
function updateAwakening(slot: number, awakening: number): void {
const normalized = normalizeAwakening(awakening);
selectedAwakenings = selectedAwakenings.map((value, index) => index === slot ? normalized : value);
result = null;
}
function normalizeFixedReduction(value: unknown): CooldownReduction | null {
return value === 0 || value === 4 || value === 8 || value === 12 ? value : null;
}
function updateFixedReduction(slot: number, reduction: CooldownReduction | null): void {
fixedCooldownReductions = fixedCooldownReductions.map((value, index) => index === slot ? reduction : value);
result = null;
}
function memberSelectedInAnotherSlot(card: CardRow, slot: number): boolean {
return selectedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId);
}
function updateCard(slot: number, cardId: string): void {
selectedCardIds = selectedCardIds.map((id, index) => (index === slot ? cardId : id));
if (collection[cardId]?.owned) updateAwakening(slot, normalizeAwakening(collection[cardId]?.awakening));
result = null;
}
function updateLeader(characterId: string): void {
leaderCharacterId = characterId;
const outfit = fiveStarCards.find((card) => card.characterId === characterId && card.details.costume);
leaderOutfitCardId = outfit?.id ?? '';
result = null;
}
function updateOutfit(cardId: string): void {
leaderOutfitCardId = cardId;
result = null;
}
function optimizerCardWithBoard(card: CardRow, index: number, unitMembers: readonly CardRow[]) {
const fixedReduction = fixedCooldownReductions[index];
const adapted = toOptimizerCard(
card,
selectedAwakenings[index] ?? 0,
fixedReduction === null || fixedReduction === undefined
? boardEffects.allowedCooldownReductionsByCardId[card.id]
: [fixedReduction],
unitMembers
);
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
return {
...adapted,
active: {
...adapted.active,
probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100)
},
scoreSupportPercent: unitPower.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
};
}
async function analyze(): Promise<void> {
errorMessage = '';
result = null;
if (!worker || !selectedSong || !selectedChart) return;
if (new Set(selectedCardIds).size !== 5) {
errorMessage = '서로 다른 카드 5장을 선택해 주세요.';
return;
}
const selectedCards = selectedCardIds.map((id) => cardById.get(id));
if (new Set(selectedCards.map((card) => card?.characterId)).size !== 5) {
errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 편성할 수 없습니다.';
return;
}
const unitMembers = selectedCards.filter((card): card is CardRow => Boolean(card));
try {
const cards = selectedCardIds.map((id, index) => {
const card = cardById.get(id);
if (!card) throw new Error('선택한 카드 정보를 찾을 수 없습니다.');
return optimizerCardWithBoard(card, index, unitMembers);
});
if (!Number.isFinite(overallPower) || overallPower <= 0) throw new Error('선택한 카드의 종합력을 계산하지 못했습니다.');
const response = await fetch(selectedChart.path);
if (!response.ok) throw new Error('정확 채보 데이터를 불러오지 못했습니다.');
const chart = await response.json() as ExactSongChart;
running = true;
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
worker.postMessage({
type: 'optimize-exact',
cards,
chart,
perfectNoteScore,
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent
});
} catch (error) {
errorMessage = error instanceof Error ? error.message : '분석 입력을 준비하지 못했습니다.';
}
}
function resultCardName(cardId: string): string {
const card = cardById.get(cardId);
return card ? cardLabel(card) : cardId;
}
function countNotesBySecond(scoreResult: ExactOptimizationResult, durationSeconds: number): number[] {
const counts = Array.from({ length: Math.ceil(durationSeconds) }, () => 0);
for (const note of scoreResult.notes) {
const second = Math.floor(note.timeSeconds);
if (second >= 0 && second < counts.length) counts[second] += 1;
}
return counts;
}
function buildTimelineTicks(durationSeconds: number): number[] {
if (durationSeconds <= 0) return [];
const interval = 10;
const ticks = Array.from({ length: Math.floor(durationSeconds / interval) + 1 }, (_, index) => index * interval);
if (ticks.at(-1) !== durationSeconds) ticks.push(durationSeconds);
return ticks;
}
function timelinePosition(seconds: number): number {
return timelineDuration > 0 ? Math.max(0, Math.min(100, seconds / timelineDuration * 100)) : 0;
}
function skillWindowStyle(startSeconds: number, endSeconds: number): string {
return `left: ${timelinePosition(startSeconds)}%; width: ${Math.max(0, timelinePosition(endSeconds) - timelinePosition(startSeconds))}%;`;
}
function timelineTrackStyle(): string {
return `width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`;
}
function memberColor(cardId: string): string {
const index = result?.order.indexOf(cardId) ?? -1;
return MEMBER_COLORS[index >= 0 ? index % MEMBER_COLORS.length : 0];
}
function coloredWindowStyle(window: SkillWindow): string {
return `${skillWindowStyle(window.startSeconds, window.endSeconds)} --member-color: ${memberColor(window.cardId)};`;
}
function mergeEffectiveActiveWindows(windows: readonly EffectiveSkillWindow[]): DisplayEffectiveWindow[] {
const merged: DisplayEffectiveWindow[] = [];
for (const window of windows) {
const previous = merged.at(-1);
if (previous?.cardId === window.cardId && Math.abs(previous.endSeconds - window.startSeconds) < 0.001) {
previous.endSeconds = window.endSeconds;
previous.minScoreUpPercent = Math.min(previous.minScoreUpPercent, window.scoreUpPercent);
previous.maxScoreUpPercent = Math.max(previous.maxScoreUpPercent, window.scoreUpPercent);
previous.maxOverlapCount = Math.max(previous.maxOverlapCount, window.overlapCount);
continue;
}
merged.push({
...window,
minScoreUpPercent: window.scoreUpPercent,
maxScoreUpPercent: window.scoreUpPercent,
maxOverlapCount: window.overlapCount
});
}
return merged;
}
function effectivePercentLabel(window: DisplayEffectiveWindow): string {
const minimum = Number(window.minScoreUpPercent.toFixed(1));
const maximum = Number(window.maxScoreUpPercent.toFixed(1));
return minimum === maximum ? `${minimum}%` : `${minimum}~${maximum}%`;
}
function formatSeconds(seconds: number): string {
return `${Number(seconds.toFixed(1)).toLocaleString('ko-KR')}초`;
}
</script>
<svelte:head><title>배치 분석 · 홀로도리</title></svelte:head>
<main>
<header class="page-header">
<div>
<h1>배치 분석</h1>
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 멤버 배치 순서와 빈도 감소 조합을 찾습니다.</p>
</div>
</header>
<section class="panel inputs" aria-labelledby="input-title">
<div class="section-title">
<div><span>01</span><h2 id="input-title">분석 조건</h2></div>
<strong>{candidateCount.toLocaleString('ko-KR')}개 후보</strong>
</div>
<div class="song-fields">
<label>악곡
<select bind:value={selectedSongId} on:change={() => result = null}>
{#each songs as song}<option value={song.id}>{song.title}</option>{/each}
</select>
</label>
<label>난이도
<select bind:value={difficulty} on:change={() => result = null}>
<option value="expert">Expert</option>
<option value="hard">Hard</option>
</select>
</label>
<div class="power-summary"><span>자동 계산 종합력</span><strong>{overallPower.toLocaleString('ko-KR')}</strong></div>
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>1노트 {Math.ceil(perfectNoteScore).toLocaleString('ko-KR')}점</span>{#if boardEffects.directSongScoreBonusPercent > 0}<span>가창 보너스 {boardEffects.directSongScoreBonusPercent}%</span>{/if}</div>
</div>
<div class="card-fields">
{#each selectedCardIds as selectedId, slot}
<div class="card-field">
<label><span>{slot + 1}번 선택</span>
<select value={selectedId} on:change={(event) => updateCard(slot, event.currentTarget.value)}>
{#each fiveStarCards as card}
<option value={card.id} disabled={memberSelectedInAnotherSlot(card, slot)}>{collection[card.id]?.owned ? '● ' : ''}{cardLabel(card)}</option>
{/each}
</select>
</label>
<div class="awakening">
<span>개화</span>
<div class="awakening-stepper">
<button
type="button"
disabled={selectedAwakenings[slot] <= 0}
aria-label={`${slot + 1}번 카드 개화 단계 낮추기`}
on:click={() => updateAwakening(slot, selectedAwakenings[slot] - 1)}
>‹</button>
<strong aria-live="polite">{selectedAwakenings[slot]}</strong>
<button
type="button"
disabled={selectedAwakenings[slot] >= 5}
aria-label={`${slot + 1}번 카드 개화 단계 높이기`}
on:click={() => updateAwakening(slot, selectedAwakenings[slot] + 1)}
>›</button>
</div>
</div>
<div class="cooldown-lock">
<span>빈도 감소 고정</span>
<div class="reduction-options">
{#each REDUCTION_LOCK_OPTIONS as reduction}
<button
type="button"
class:active={fixedCooldownReductions[slot] === reduction}
aria-pressed={fixedCooldownReductions[slot] === reduction}
on:click={() => updateFixedReduction(slot, reduction)}
>{reduction === null ? '미사용' : `${reduction}%`}</button>
{/each}
</div>
</div>
</div>
{/each}
</div>
<div class="leader-fields">
<label>리더
<select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}>
{#each leaderMembers as member}<option value={member.characterId}>{member.name}</option>{/each}
</select>
</label>
<label>리더 의상
<select value={leaderOutfitCardId} on:change={(event) => updateOutfit(event.currentTarget.value)}>
{#each leaderOutfitOptions as card}<option value={card.id}>{card.details.costume?.name}</option>{/each}
</select>
</label>
</div>
<div class="outfit-effect">
<span>리더 의상 효과</span>
<p>{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}</p>
</div>
<p class="power-detail">최대 레벨 기준 · 기본 {unitPower.baseParameterTotal.toLocaleString('ko-KR')} + 개화 {unitPower.potentialTotal.toLocaleString('ko-KR')} + 패시브 {unitPower.passiveTotal.toLocaleString('ko-KR')} + 의상 {unitPower.outfitTotal.toLocaleString('ko-KR')} + 보드 {unitPower.boardTotal.toLocaleString('ko-KR')} + 메모리 {unitPower.memoryTotal.toLocaleString('ko-KR')} ({memoryCount}개) + 멤버 강화 {unitPower.memberPowerUpTotal.toLocaleString('ko-KR')} ({memberPowerUpPercent.toLocaleString('ko-KR')}%)</p>
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
<button class="analyze" type="button" disabled={running || candidateCount === 0} on:click={analyze}>
{running ? `분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 배치 분석'}
</button>
{#if running}<progress value={progress.evaluatedCandidates} max={progress.totalCandidates}></progress>{/if}
</section>
<section class="panel result" aria-labelledby="result-title">
<div class="section-title"><div><span>02</span><h2 id="result-title">분석 결과</h2></div></div>
{#if result}
<ol class="order-list">
{#each result.order as cardId, slot}
<li><span class="slot">{slot + 1}</span><strong>{resultCardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each}
</ol>
<div class="score-grid">
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
</div>
<section class="skill-timeline" aria-labelledby="timeline-title">
<div class="timeline-heading">
<div><h3 id="timeline-title">스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div>
<div class="timeline-legend" aria-label="멤버 색상 범례">
{#each result.order as cardId}<span><i style={`background: ${memberColor(cardId)};`}></i>{resultCardName(cardId)}</span>{/each}
</div>
</div>
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
<div class="timeline-row note-row">
<strong class="lane-label">초당 노트</strong>
<div class="timeline-track note-track" style={timelineTrackStyle()} aria-label={`최대 초당 ${maxNotesPerSecond}개`}>
{#each noteCountsBySecond as count, second}
<span class="note-bar" style={`left: ${timelinePosition(second)}%; width: ${100 / Math.max(1, noteCountsBySecond.length)}%; height: ${count / maxNotesPerSecond * 100}%; opacity: ${count === 0 ? 0 : 0.18 + count / maxNotesPerSecond * 0.82};`} title={`${second}~${second + 1}초 · ${count}개`}></span>
{/each}
</div>
</div>
{#each result.order as cardId, slot}
<div class="timeline-row member-row">
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
<span
class="skill-window active-window"
style={coloredWindowStyle(window)}
title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`}
role="img"
aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`}
></span>
{/each}
</div>
</div>
{/each}
<div class="timeline-row summary-row">
<strong class="lane-label summary-label">최종 액티브</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each displayedEffectiveActiveWindows as window}
<span
class="skill-window effective-window"
style={coloredWindowStyle(window)}
title={`${resultCardName(window.cardId)} 액티브 적용 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · 기본 ${window.baseScoreUpPercent}% → 서포트 반영 ${effectivePercentLabel(window)}${window.maxOverlapCount > 1 ? ` · 최대 ${window.maxOverlapCount}명 중 가장 높은 효과` : ''}`}
role="img"
aria-label={`${resultCardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}
></span>
{/each}
</div>
</div>
<div class="timeline-row summary-row">
<strong class="lane-label summary-label">스페셜</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each result.specialWindows as window}
<span
class="skill-window special-window"
style={coloredWindowStyle(window)}
title={`${resultCardName(window.cardId)} 스페셜 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`}
role="img"
aria-label={`${resultCardName(window.cardId)} 스페셜이 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}
></span>
{/each}
</div>
</div>
<div class="timeline-row axis-row" aria-hidden="true">
<span class="lane-label axis-label"></span>
<div class="timeline-track time-axis" style={timelineTrackStyle()}>
{#each timelineTicks as second}<span style={`left: ${timelinePosition(second)}%;`}>{second}초</span>{/each}
</div>
</div>
</div>
</div>
</section>
{:else}
<div class="empty">조건을 선택하고 분석을 시작하면 최적 배치와 각 멤버의 쿨타임 감소량이 여기에 표시됩니다.</div>
{/if}
</section>
</main>
<style>
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: 46px 0 80px; }
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
h1 { margin: 0; color: #f3f5f8; font-size: clamp(28px, 5vw, 42px); letter-spacing: -.045em; }
.page-description { margin: 10px 0 0; color: #929cab; font-size: 14px; line-height: 1.6; }
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
.section-title, .section-title > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.section-title > div { justify-content: flex-start; }
.section-title span { color: #ef8cb6; font-size: 13px; font-weight: 900; }
h2 { margin: 0; font-size: 18px; }
.section-title > strong { color: #aeb6c4; font-size: 14px; }
label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
select { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
select:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
.song-fields { display: grid; grid-template-columns: minmax(0, 2fr) clamp(110px, 11vw, 170px) clamp(140px, 14vw, 220px); align-items: end; gap: 12px; margin-top: 22px; }
.power-summary { display: grid; gap: 4px; min-height: 42px; padding: 7px 11px; border-radius: 9px; background: #22202a; }
.power-summary span { color: #929cab; font-size: 12px; font-weight: 750; }
.power-summary strong { color: #f2a3c4; font-size: 16px; }
.chart-meta { display: flex; gap: 8px; height: 42px; align-items: center; }
.chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 13px; white-space: nowrap; }
.card-fields { display: grid; gap: 9px; margin-top: 18px; }
.card-field { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(130px, 12vw, 180px) clamp(304px, 27vw, 400px); gap: 9px; }
.card-field > label { grid-template-columns: 74px minmax(0, 1fr); align-items: center; }
.card-field > .awakening { display: grid; grid-template-columns: 32px minmax(0, 1fr); align-items: center; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
.awakening-stepper { display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 24px; line-height: 1; cursor: pointer; }
.awakening-stepper button:hover:not(:disabled) { color: #f5afcc; background: #2d2530; }
.awakening-stepper button:focus-visible { position: relative; z-index: 1; outline: 2px solid #df74a2; outline-offset: -2px; }
.awakening-stepper button:disabled { color: #525b69; background: #171c24; cursor: default; }
.awakening-stepper strong { color: #e8ebf1; font-size: 14px; text-align: center; white-space: nowrap; }
.cooldown-lock { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
.reduction-options { display: grid; grid-template-columns: 1.35fr repeat(4, 1fr); height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.reduction-options button { min-width: 0; padding: 0 6px; border: 0; border-left: 1px solid #2d3440; color: #8f98a8; background: transparent; font-size: 13px; font-weight: 750; cursor: pointer; }
.reduction-options button:first-child { border-left: 0; }
.reduction-options button:hover { color: #e9edf3; background: #202630; }
.reduction-options button.active { color: #171019; background: #ed8bb5; }
.reduction-options button:focus-visible { position: relative; z-index: 1; outline: 2px solid #f1a0c2; outline-offset: -2px; }
.leader-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 16px; }
.outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 12px; margin-top: 9px; padding: 11px 13px; border: 1px solid #2d3440; border-radius: 9px; background: #12161c; }
.outfit-effect span { color: #d993b1; font-size: 13px; font-weight: 850; }
.outfit-effect p { margin: 0; color: #b9c1cd; font-size: 14px; line-height: 1.55; }
.power-detail { margin: 10px 0 0; color: #858f9f; font-size: 13px; text-align: right; }
.analyze { width: 100%; height: 46px; margin-top: 20px; border: 0; border-radius: 10px; color: #171019; background: #ed8bb5; font-weight: 900; cursor: pointer; }
.analyze:hover:not(:disabled) { background: #f2a0c2; }
.analyze:disabled { opacity: .55; cursor: default; }
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
.error { margin: 16px 0 0; color: #ff9f9f; font-size: 13px; }
.order-list { display: grid; gap: 8px; padding: 0; margin: 22px 0 0; list-style: none; }
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reduction { padding: 6px 8px; border-radius: 7px; color: #b9c1cd; background: #252c36; font-size: 13px; }
.score-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
.score-summary span { color: #a9b1be; font-size: 14px; }
.score-summary strong { color: #f2a3c4; font-size: 20px; }
.empty { color: #858f9f; font-size: 14px; line-height: 1.65; }
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
.timeline-heading h3 { margin: 0; color: #e8ebf1; font-size: 16px; }
.timeline-heading p { margin: 6px 0 0; color: #838d9d; font-size: 13px; }
.timeline-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; color: #99a2b1; font-size: 12px; }
.timeline-legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
.timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; }
.timeline-canvas { min-width: 100%; padding: 12px 12px 8px 0; }
.timeline-row { display: flex; min-height: 36px; }
.timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); }
.lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 11px; }
.timeline-track { position: relative; flex: none; }
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
.note-row { min-height: 68px; }
.note-track { height: 52px; align-self: center; border-bottom: 1px solid #465062; }
.note-bar { position: absolute; bottom: 0; min-height: 1px; border-right: 1px solid #10141a; background: #8ca9d7; }
.skill-track { height: 36px; }
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
.active-window { top: 12px; height: 12px; opacity: .72; }
.summary-row { background: rgb(255 255 255 / 1.5%); }
.summary-label { color: #e1e5ec; font-weight: 800; }
.effective-window { top: 9px; height: 18px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }
.special-window { top: 11px; height: 14px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 20%); }
.axis-row { min-height: 24px; border-top: 1px solid #343c49 !important; }
.axis-label { color: #778293; font-size: 12px; font-weight: 750; }
.time-axis { height: 24px; }
.time-axis::before { display: none; }
.time-axis span { position: absolute; top: 5px; color: #747f90; font-size: 11px; transform: translateX(-50%); white-space: nowrap; }
.time-axis span:first-child { transform: none; }
.time-axis span:last-child { transform: translateX(-100%); }
.empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; border: 1px dashed #303744; border-radius: 12px; padding: 28px; text-align: center; }
@media (max-width: 1100px) {
.card-field { grid-template-columns: minmax(0, 1fr) 130px 304px; }
}
@media (max-width: 700px) {
main { width: calc(100% - 24px); padding-top: 28px; }
.page-header { align-items: flex-start; }
.panel { padding: 17px; }
.song-fields { grid-template-columns: 1fr 100px; }
.power-summary { grid-column: 1 / -1; }
.chart-meta { grid-column: 1 / -1; height: auto; }
.card-field { grid-template-columns: minmax(0, 1fr) 102px; }
.card-field > label { grid-template-columns: 58px minmax(0, 1fr); }
.card-field > .awakening { grid-template-columns: 1fr; }
.awakening-stepper { grid-template-columns: 28px minmax(0, 1fr) 28px; }
.cooldown-lock { grid-column: 1 / -1; grid-template-columns: 88px minmax(0, 1fr); }
.leader-fields { grid-template-columns: 1fr; }
.outfit-effect { grid-template-columns: 1fr; gap: 5px; }
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
.reduction { grid-column: 2; justify-self: start; }
.score-grid { grid-template-columns: 1fr; }
.timeline-heading { align-items: flex-start; flex-direction: column; }
.timeline-legend { justify-content: flex-start; }
.lane-label { flex-basis: 118px; min-width: 118px; }
.timeline-canvas { padding-right: 8px; }
}
</style>
+909
View File
@@ -0,0 +1,909 @@
<script lang="ts">
import { onMount } from 'svelte';
import cardSummary from '$lib/data/cards-summary.json';
import boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
import {
calculateBoardEffects,
calculateUnitPower,
countOptimizationCandidates,
generateCombinationCandidates,
memoryParameterPermil,
perfectNoteScoreFromPower,
toOptimizerCard
} from '$lib/engine/index.ts';
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard } from '$lib/engine/index.ts';
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
type CardRow = (typeof cardSummary.cards)[number];
type DifficultyKey = 'hard' | 'expert';
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
type MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, Record<string, string>>;
type PreparedCandidate = {
cards: OptimizerCard[];
overallPower: number;
perfectNoteScore: number;
directSongScoreBonusPercent: number;
heuristic: number;
skillPotential: number;
};
type RankedResult = { result: ExactOptimizationResult; overallPower: number };
type SavedState = {
schemaVersion: 1;
songId: string;
difficulty: DifficultyKey;
fixedCardIds: string[];
awakening: number;
candidateGenerationBonus?: number | null;
fixedAwakenings?: number[];
fixedCooldownReductions?: (CooldownReduction | null)[];
fixedAllBlueNodes?: boolean[];
fixedMaxGenerationBonuses?: boolean[];
fixedGenerationBonusOverrides?: (number | null)[];
maximizeCompute?: boolean;
leaderCharacterId: string;
leaderOutfitCardId: string;
};
type SavedAnalysisState = {
schemaVersion: 2;
songId: string;
difficulty: DifficultyKey;
cardIds: string[];
awakenings: number[];
fixedCooldownReductions: (CooldownReduction | null)[];
leaderCharacterId: string;
leaderOutfitCardId: string;
};
const STORAGE_KEY = 'holodori.combination.v1';
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const SCREENING_LIMIT = 3_000;
const EXACT_FINALIST_LIMIT = 24;
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
const charts = exactChartCatalog.charts;
const blueGroupsByCharacter = Object.fromEntries(boardSummary.members.map((member) => [
member.id,
(boardSummary.models as Record<string, { groupId: string }[]>)[member.positionGroupId]
?.filter((position) => position.groupId.startsWith('B'))
.map((position) => position.groupId) ?? []
]));
const songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
const leaderMembers = fiveStarCards.filter((card, index, cards) => cards.findIndex((entry) => entry.characterId === card.characterId) === index);
let collection: CollectionState = {};
let memberBoards: MemberBoards = {};
let boardCards: BoardCards = {};
let memoryCount = 0;
let memberPowerUpPercent = 0;
let selectedSongId = songs[0]?.id ?? '';
let difficulty: DifficultyKey = 'expert';
let fixedCardIds = ['', '', '', ''];
let candidateAwakening = 5;
let candidateGenerationBonus: number | null = null;
let fixedAwakenings = [5, 5, 5, 5];
let fixedCooldownReductions: (CooldownReduction | null)[] = [null, null, null, null];
let fixedAllBlueNodes = [false, false, false, false];
let fixedGenerationBonusOverrides: (number | null)[] = [null, null, null, null];
let maximizeCompute = false;
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
let worker: Worker | null = null;
let parallelWorkers: Worker[] = [];
let baselineWorker: Worker | null = null;
let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null;
let rankedResults: RankedResult[] = [];
let baselineState: SavedAnalysisState | null = null;
let baselineResult: ExactOptimizationResult | null = null;
let baselineOverallPower = 0;
let baselineRunning = false;
let baselineProgress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let baselineError = '';
let screenedCombinationCount = 0;
let exactCombinationCount = 0;
let searchPhase: 'idle' | 'screening' | 'exact' = 'idle';
let screeningProgress = 0;
let errorMessage = '';
let ready = false;
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
$: baselineChartMetadata = baselineState
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
: undefined;
$: baselineMatchesConditions = Boolean(baselineState?.songId === selectedSongId && baselineState?.difficulty === difficulty);
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
$: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline();
$: if (!result && rankedResults.length > 0) rankedResults = [];
$: fixedCards = fixedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
$: leaderOutfitOptions = leaderMember
? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume)
.filter((card, index, cards) => cards.findIndex((entry) => entry.details.costume?.name === card.details.costume?.name) === index)
: [];
$: leaderOutfit = cardById.get(leaderOutfitCardId)?.details.costume ?? null;
$: leaderOutfitDescription = (leaderOutfit?.description ?? '')
.replace(/\[[^\]]+\]/g, '')
.replace(/\s+/g, ' ')
.trim();
$: connectionCards = cardSummary.cards.filter((card) => card.details.connect.length > 0).map((card) => ({
id: card.id,
awakening: collection[card.id]?.awakening ?? 0,
connect: card.details.connect
}));
$: if (ready) saveState({
schemaVersion: 1,
songId: selectedSongId,
difficulty,
fixedCardIds,
awakening: candidateAwakening,
candidateGenerationBonus,
fixedAwakenings,
fixedCooldownReductions,
fixedAllBlueNodes,
fixedGenerationBonusOverrides,
maximizeCompute,
leaderCharacterId,
leaderOutfitCardId
});
onMount(() => {
try {
const stored = JSON.parse(localStorage.getItem(USER_DATA_STORAGE_KEY) ?? 'null');
collection = stored?.collection && typeof stored.collection === 'object' ? stored.collection : {};
memberBoards = stored?.memberBoards && typeof stored.memberBoards === 'object' ? stored.memberBoards : {};
boardCards = stored?.boardCards && typeof stored.boardCards === 'object' ? stored.boardCards : {};
memoryCount = Number.isFinite(stored?.memoryCount) ? stored.memoryCount : 0;
memberPowerUpPercent = Number.isFinite(stored?.memberPowerUpPercent) ? stored.memberPowerUpPercent : 0;
} catch {
collection = {};
}
try {
const state = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null') as SavedState | null;
if (state?.schemaVersion === 1) {
if (songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
if (state.difficulty === 'hard' || state.difficulty === 'expert') difficulty = state.difficulty;
fixedCardIds = Array.from({ length: 4 }, (_, index) => cardById.has(state.fixedCardIds?.[index]) ? state.fixedCardIds[index] : '');
candidateAwakening = normalizeAwakening(state.awakening);
candidateGenerationBonus = typeof state.candidateGenerationBonus === 'number' && Number.isFinite(state.candidateGenerationBonus)
? normalizeGenerationBonus(state.candidateGenerationBonus)
: null;
fixedAwakenings = Array.from({ length: 4 }, (_, index) => normalizeAwakening(state.fixedAwakenings?.[index] ?? state.awakening));
fixedCooldownReductions = Array.from({ length: 4 }, (_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index]));
fixedAllBlueNodes = Array.from({ length: 4 }, (_, index) => state.fixedAllBlueNodes?.[index] === true);
fixedGenerationBonusOverrides = Array.from({ length: 4 }, (_, index) => {
const savedValue = state.fixedGenerationBonusOverrides?.[index];
if (typeof savedValue === 'number' && Number.isFinite(savedValue)) return normalizeGenerationBonus(savedValue);
return state.fixedMaxGenerationBonuses?.[index] === true ? 900 : null;
});
maximizeCompute = state.maximizeCompute === true;
if (leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) leaderCharacterId = state.leaderCharacterId;
const outfit = cardById.get(state.leaderOutfitCardId);
leaderOutfitCardId = outfit?.characterId === leaderCharacterId && outfit.details.costume
? outfit.id
: fiveStarCards.find((card) => card.characterId === leaderCharacterId && card.details.costume)?.id ?? '';
}
} catch {
localStorage.removeItem(STORAGE_KEY);
}
worker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (event) => {
if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'combination-result') {
result = event.data.result;
rankedResults = Array.isArray(event.data.rankings) ? event.data.rankings : [{ result: event.data.result, overallPower: event.data.overallPower }];
running = false;
searchPhase = 'idle';
}
if (event.data.type === 'error') {
errorMessage = event.data.message;
running = false;
searchPhase = 'idle';
}
};
baselineWorker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), { type: 'module' });
baselineWorker.onmessage = (event) => {
if (event.data.type === 'progress') baselineProgress = event.data.progress;
if (event.data.type === 'result') {
baselineResult = event.data.result;
baselineRunning = false;
}
if (event.data.type === 'error') {
baselineError = event.data.message;
baselineRunning = false;
}
};
try {
baselineState = readAnalysisState(localStorage.getItem(ANALYSIS_STORAGE_KEY));
} catch {
baselineState = null;
baselineError = '배치 분석의 저장 데이터를 읽지 못했습니다.';
}
ready = true;
return () => {
worker?.terminate();
terminateParallelWorkers();
baselineWorker?.terminate();
};
});
function normalizeAwakening(value: unknown): number {
return Math.max(0, Math.min(5, Number.isFinite(value) ? Math.round(Number(value)) : 5));
}
function normalizeFixedReduction(value: unknown): CooldownReduction | null {
return value === 0 || value === 4 || value === 8 || value === 12 ? value : null;
}
function normalizeGenerationBonus(value: unknown): number {
return Math.max(0, Math.min(900, Number.isFinite(value) ? Math.round(Number(value)) : 0));
}
function saveState(state: SavedState): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function readAnalysisState(stored: string | null): SavedAnalysisState | null {
if (!stored) return null;
const state = JSON.parse(stored) as Partial<SavedAnalysisState>;
if (state.schemaVersion !== 2 || !Array.isArray(state.cardIds) || state.cardIds.length !== 5) return null;
const cards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
if (cards.length !== 5 || new Set(cards.map((card) => card.characterId)).size !== 5) return null;
return {
schemaVersion: 2,
songId: typeof state.songId === 'string' ? state.songId : '',
difficulty: state.difficulty === 'hard' ? 'hard' : 'expert',
cardIds: cards.map((card) => card.id),
awakenings: cards.map((_, index) => Number.isFinite(state.awakenings?.[index]) ? normalizeAwakening(state.awakenings?.[index]) : 0),
fixedCooldownReductions: cards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index])),
leaderCharacterId: typeof state.leaderCharacterId === 'string' ? state.leaderCharacterId : '',
leaderOutfitCardId: typeof state.leaderOutfitCardId === 'string' ? state.leaderOutfitCardId : ''
};
}
function cardLabel(card: CardRow): string {
return `${collection[card.id]?.owned ? '● ' : ''}${card.name}${card.versionName ? ` · ${card.versionName}` : ''}`;
}
function selectedInAnotherSlot(card: CardRow, slot: number): boolean {
return fixedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId);
}
function updateFixedCard(slot: number, cardId: string): void {
const cardChanged = fixedCardIds[slot] !== cardId;
fixedCardIds = fixedCardIds.map((id, index) => index === slot ? cardId : id);
if (cardChanged) {
fixedCooldownReductions = fixedCooldownReductions.map((reduction, index) => index === slot ? null : reduction);
fixedGenerationBonusOverrides = fixedGenerationBonusOverrides.map((bonus, index) => index === slot ? null : bonus);
}
if (cardId && collection[cardId]?.owned) {
fixedAwakenings = fixedAwakenings.map((value, index) => index === slot ? normalizeAwakening(collection[cardId]?.awakening) : value);
}
result = null;
}
function updateFixedAwakening(slot: number, value: number): void {
fixedAwakenings = fixedAwakenings.map((awakening, index) => index === slot ? normalizeAwakening(value) : awakening);
result = null;
}
function updateCandidateAwakening(value: number): void {
candidateAwakening = normalizeAwakening(value);
result = null;
}
function updateFixedReduction(slot: number, value: CooldownReduction | null): void {
fixedCooldownReductions = fixedCooldownReductions.map((reduction, index) => index === slot ? value : reduction);
result = null;
}
function toggleFixedAllBlueNodes(slot: number): void {
fixedAllBlueNodes = fixedAllBlueNodes.map((enabled, index) => index === slot ? !enabled : enabled);
result = null;
}
function updateFixedGenerationBonus(slot: number, rawValue: string): void {
const parsed = Number(rawValue);
const value = rawValue.trim() === '' || !Number.isFinite(parsed) ? null : normalizeGenerationBonus(parsed);
fixedGenerationBonusOverrides = fixedGenerationBonusOverrides.map((bonus, index) => index === slot ? value : bonus);
result = null;
}
function updateCandidateGenerationBonus(rawValue: string): void {
const parsed = Number(rawValue);
candidateGenerationBonus = rawValue.trim() === '' || !Number.isFinite(parsed) ? null : normalizeGenerationBonus(parsed);
result = null;
}
function fixedSlot(cardId: string): number {
return fixedCardIds.indexOf(cardId);
}
function awakeningForCard(card: CardRow): number {
const slot = fixedSlot(card.id);
return slot >= 0 ? fixedAwakenings[slot] : candidateAwakening;
}
function updateLeader(characterId: string): void {
leaderCharacterId = characterId;
leaderOutfitCardId = fiveStarCards.find((card) => card.characterId === characterId && card.details.costume)?.id ?? '';
result = null;
}
function roughCardScore(card: CardRow): number {
const statTotal = Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0);
const { activeValue, specialValue } = roughSkillValues(card);
return statTotal * (1 + activeValue / 100 + specialValue / 500);
}
function roughStatScore(card: CardRow): number {
return Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0);
}
function roughSkillScore(card: CardRow): number {
const { adapted, activeValue, specialValue } = roughSkillValues(card);
return activeValue + specialValue + (adapted.scoreSupportPercent ?? 0);
}
function roughSkillValues(card: CardRow) {
const adapted = toOptimizerCard(card, awakeningForCard(card), [0], [card]);
const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds /
Math.max(1, adapted.active.cooldownSeconds) * ((adapted.active.probabilityPercent ?? 100) / 100);
const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100;
return { adapted, activeValue, specialValue };
}
function combinationKey(cards: readonly CardRow[] | readonly OptimizerCard[]): string {
return cards.map((card) => card.id).sort().join('|');
}
function boardsForCombination(cards: readonly CardRow[]): MemberBoards {
const boards = Object.fromEntries(Object.entries(memberBoards).map(([characterId, groupIds]) => [characterId, Array.from(groupIds)]));
for (const card of cards) {
const slot = fixedSlot(card.id);
if (slot >= 0 && !fixedAllBlueNodes[slot]) continue;
const existingNonBlue = (boards[card.characterId] ?? []).filter((groupId) => !groupId.startsWith('B'));
boards[card.characterId] = [...existingNonBlue, ...(blueGroupsByCharacter[card.characterId] ?? [])];
}
return boards;
}
function screeningCombinations(): CardRow[][] {
if (fixedCards.length === 4) {
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, SCREENING_LIMIT);
}
const perStrategy = Math.ceil(SCREENING_LIMIT / 3);
const combined = new Map<string, CardRow[]>();
for (const score of [roughCardScore, roughSkillScore, roughStatScore]) {
for (const cards of generateCombinationCandidates(fixedCards, fiveStarCards, 5, score, perStrategy)) {
combined.set(combinationKey(cards), cards);
}
}
return Array.from(combined.values()).slice(0, SCREENING_LIMIT);
}
function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] {
if (fixedCards.length === 4) return prepared;
const selected = new Map<string, PreparedCandidate>();
const add = (candidates: PreparedCandidate[], count: number) => {
for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate);
};
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), 12);
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), 6);
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), 6);
if (selected.size < EXACT_FINALIST_LIMIT) {
for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) {
selected.set(combinationKey(candidate.cards), candidate);
if (selected.size >= EXACT_FINALIST_LIMIT) break;
}
}
return Array.from(selected.values()).slice(0, EXACT_FINALIST_LIMIT);
}
function prepareCandidate(cards: CardRow[]): PreparedCandidate {
const generationBonusByCardId = Object.fromEntries(cards
.filter((card) => {
const slot = fixedSlot(card.id);
return slot < 0 ? candidateGenerationBonus !== null : fixedGenerationBonusOverrides[slot] !== null;
})
.map((card) => {
const slot = fixedSlot(card.id);
return [card.id, slot < 0 ? candidateGenerationBonus ?? 0 : fixedGenerationBonusOverrides[slot] ?? 0];
}));
const boardEffects = calculateBoardEffects(boardSummary, boardsForCombination(cards), cards, leaderCharacterId, selectedChart, {
placements: boardCards,
cards: connectionCards
}, {
generationBonusByCardId,
directSongScoreBonusPercent: 10
});
const power = calculateUnitPower(
cards.map((card) => ({ card, awakening: awakeningForCard(card) })),
leaderOutfit,
boardEffects,
memoryParameterPermil(memoryCount, memorySummary.effects),
memberPowerUpPercent
);
const optimizerCards = cards.map((card) => {
const slot = fixedSlot(card.id);
const fixedReduction = slot >= 0 ? fixedCooldownReductions[slot] : null;
const adapted = toOptimizerCard(
card,
awakeningForCard(card),
fixedReduction === null ? boardEffects.allowedCooldownReductionsByCardId[card.id] : [fixedReduction],
cards
);
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
return {
...adapted,
active: { ...adapted.active, probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) },
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
};
});
const skillPotential = optimizerCards.reduce((sum, card) => sum +
card.active.scoreUpPercent * card.active.durationSeconds / Math.max(1, card.active.cooldownSeconds) * ((card.active.probabilityPercent ?? 100) / 100) +
card.special.scoreUpPercent * card.special.durationSeconds / 100 +
(card.scoreSupportPercent ?? 0) +
(card.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => effectSum + effect.percent * (effect.target === 'all' ? 5 : effect.targetCount ?? 1), 0), 0);
return {
cards: optimizerCards,
overallPower: power.overallPower,
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, selectedChart?.scoreRatio ?? null),
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
heuristic: power.overallPower * (1 + skillPotential / 500),
skillPotential
};
}
async function analyzeBaseline(): Promise<void> {
const state = baselineState;
const chartMetadata = baselineChartMetadata;
if (!state || !chartMetadata || !baselineWorker || !baselineMatchesConditions) return;
baselineRunning = true;
baselineError = '';
baselineProgress = { evaluatedCandidates: 0, totalCandidates: 1 };
try {
const cards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
if (cards.length !== 5) throw new Error('기준 조합의 카드 정보를 찾지 못했습니다.');
const outfit = cardById.get(state.leaderOutfitCardId)?.details.costume ?? null;
const boardEffects = calculateBoardEffects(boardSummary, memberBoards, cards, state.leaderCharacterId, chartMetadata, {
placements: boardCards,
cards: connectionCards
});
const power = calculateUnitPower(
cards.map((card, index) => ({ card, awakening: state.awakenings[index] })),
outfit,
boardEffects,
memoryParameterPermil(memoryCount, memorySummary.effects),
memberPowerUpPercent
);
const optimizerCards = cards.map((card, index) => {
const fixedReduction = state.fixedCooldownReductions[index];
const adapted = toOptimizerCard(
card,
state.awakenings[index],
fixedReduction === null ? boardEffects.allowedCooldownReductionsByCardId[card.id] : [fixedReduction],
cards
);
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
return {
...adapted,
active: { ...adapted.active, probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) },
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
};
});
const response = await fetch(chartMetadata.path);
if (!response.ok) throw new Error('기준 조합의 정확 채보 데이터를 불러오지 못했습니다.');
const chart = await response.json() as ExactSongChart;
baselineOverallPower = power.overallPower;
baselineProgress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(optimizerCards) };
baselineWorker.postMessage({
type: 'optimize-exact',
cards: optimizerCards,
chart,
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, chartMetadata.scoreRatio ?? null),
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent
});
} catch (error) {
baselineError = error instanceof Error ? error.message : '기준 조합을 분석하지 못했습니다.';
baselineRunning = false;
}
}
function scoreDelta(current: number, baseline: number): string {
const difference = current - baseline;
return `${difference >= 0 ? '+' : ''}${difference.toLocaleString('ko-KR')}`;
}
function terminateParallelWorkers(): void {
for (const parallelWorker of parallelWorkers) parallelWorker.terminate();
parallelWorkers = [];
}
function runParallelExact(finalists: PreparedCandidate[], chart: ExactSongChart): void {
terminateParallelWorkers();
const reportedCores = typeof navigator === 'undefined' ? 1 : navigator.hardwareConcurrency || 4;
const workerCount = Math.min(finalists.length, Math.max(1, reportedCores));
const groups = Array.from({ length: workerCount }, () => [] as PreparedCandidate[]);
finalists.forEach((candidate, index) => groups[index % workerCount].push(candidate));
const groupProgress = Array.from({ length: workerCount }, () => 0);
const totalCandidates = finalists.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0);
const completedRankings: RankedResult[] = [];
let completedWorkers = 0;
let failed = false;
progress = { evaluatedCandidates: 0, totalCandidates };
const fail = (message: string) => {
if (failed) return;
failed = true;
terminateParallelWorkers();
errorMessage = message;
running = false;
searchPhase = 'idle';
};
groups.forEach((candidates, workerIndex) => {
const parallelWorker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), { type: 'module' });
parallelWorkers.push(parallelWorker);
parallelWorker.onmessage = (event) => {
if (failed) return;
if (event.data.type === 'progress') {
groupProgress[workerIndex] = event.data.progress.evaluatedCandidates;
progress = {
evaluatedCandidates: groupProgress.reduce((sum, value) => sum + value, 0),
totalCandidates
};
}
if (event.data.type === 'combination-result') {
const rankings = Array.isArray(event.data.rankings)
? event.data.rankings as RankedResult[]
: [{ result: event.data.result, overallPower: event.data.overallPower }];
completedRankings.push(...rankings);
completedWorkers += 1;
parallelWorker.terminate();
if (completedWorkers !== workerCount) return;
completedRankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
right.result.expectedScore - left.result.expectedScore);
rankedResults = completedRankings.slice(0, 10);
result = rankedResults[0]?.result ?? null;
parallelWorkers = [];
running = false;
searchPhase = 'idle';
}
if (event.data.type === 'error') fail(event.data.message);
};
parallelWorker.onerror = () => fail('병렬 분석 Worker에서 오류가 발생했습니다.');
parallelWorker.postMessage({ type: 'optimize-combinations', candidates, chart });
});
}
async function search(): Promise<void> {
errorMessage = '';
result = null;
if (!worker || !selectedChart) return;
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 고정할 수 없습니다.';
return;
}
running = true;
searchPhase = 'screening';
screeningProgress = 0;
progress = { evaluatedCandidates: 0, totalCandidates: 1 };
await new Promise((resolve) => setTimeout(resolve, 0));
try {
const combinations = screeningCombinations();
screenedCombinationCount = combinations.length;
const prepared: PreparedCandidate[] = [];
for (let index = 0; index < combinations.length; index += 1) {
prepared.push(prepareCandidate(combinations[index]));
if (index % 50 === 49 || index === combinations.length - 1) {
screeningProgress = (index + 1) / combinations.length;
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
const finalists = selectExactFinalists(prepared);
exactCombinationCount = finalists.length;
if (finalists.length === 0) throw new Error('조건에 맞는 카드 조합이 없습니다.');
const response = await fetch(selectedChart.path);
if (!response.ok) throw new Error('정확 채보 데이터를 불러오지 못했습니다.');
const chart = await response.json() as ExactSongChart;
searchPhase = 'exact';
if (maximizeCompute) runParallelExact(finalists, chart);
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart });
} catch (error) {
errorMessage = error instanceof Error ? error.message : '조합 탐색을 준비하지 못했습니다.';
running = false;
searchPhase = 'idle';
}
}
function baselineCardLabel(cardId: string): string {
const card = cardById.get(cardId);
const index = baselineState?.cardIds.indexOf(cardId) ?? -1;
const awakening = index >= 0 ? baselineState?.awakenings[index] ?? 0 : 0;
return card ? `${card.name}${card.versionName ? ` · ${card.versionName}` : ''} · ${awakening}개화` : cardId;
}
function resultCardName(cardId: string): string {
const card = cardById.get(cardId);
return card ? cardLabel(card) : cardId;
}
</script>
<svelte:head><title>조합 탐색 · 홀로도리</title></svelte:head>
<main>
<header>
<h1>조합 탐색</h1>
<p class="page-description">악곡과 고정 멤버 조건을 바탕으로 빈자리에 들어갈 카드를 탐색해 최고 점수 조합을 찾습니다.</p>
<p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p>
</header>
{#if baselineState && baselineMatchesConditions}
<section class="panel baseline-panel" aria-labelledby="baseline-title">
<div class="section-title">
<div><span>00</span><h2 id="baseline-title">기준 조합</h2></div>
<strong>배치 분석 저장값</strong>
</div>
<p class="baseline-condition">{baselineChartMetadata?.title} · {baselineState.difficulty === 'expert' ? 'Expert' : 'Hard'}</p>
<div class="baseline-members">
{#each baselineResult?.order ?? baselineState.cardIds as cardId, slot}
<div><span>{slot + 1}</span><strong>{baselineCardLabel(cardId)}</strong></div>
{/each}
</div>
{#if baselineResult}
<div class="scores baseline-scores">
<div><span>종합력</span><strong>{baselineOverallPower.toLocaleString('ko-KR')}</strong></div>
<div><span>평균 점수</span><strong>{baselineResult.expectedScore.toLocaleString('ko-KR')}</strong></div>
<div><span>최고 점수</span><strong>{baselineResult.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
</div>
{:else if baselineRunning}
<p class="baseline-status">배치 분석의 저장 조건으로 기준 점수를 계산하고 있습니다.</p>
<progress value={baselineProgress.evaluatedCandidates} max={Math.max(1, baselineProgress.totalCandidates)}></progress>
{:else if baselineError}
<p class="error" role="alert">{baselineError}</p>
{/if}
</section>
{/if}
<section class="panel" aria-labelledby="conditions-title">
<div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>5성 전체 대상</strong></div>
<div class="song-fields">
<label>악곡<select bind:value={selectedSongId} on:change={() => result = null}>{#each songs as song}<option value={song.id}>{song.title}</option>{/each}</select></label>
<label>난이도<select bind:value={difficulty} on:change={() => result = null}><option value="expert">Expert</option><option value="hard">Hard</option></select></label>
</div>
<div class="candidate-fields">
<div class="candidate-awakening">
<span>자동 후보 개화</span>
<div class="awakening-stepper">
<button type="button" disabled={candidateAwakening <= 0} aria-label="자동 후보 개화 낮추기" on:click={() => updateCandidateAwakening(candidateAwakening - 1)}>‹</button>
<strong>{candidateAwakening}</strong>
<button type="button" disabled={candidateAwakening >= 5} aria-label="자동 후보 개화 높이기" on:click={() => updateCandidateAwakening(candidateAwakening + 1)}>›</button>
</div>
</div>
<label>자동 후보 기수 보너스 덮어쓰기<input class="number-input" type="text" inputmode="numeric" pattern="[0-9]*" value={candidateGenerationBonus ?? ''} placeholder="미사용" on:change={(event) => updateCandidateGenerationBonus(event.currentTarget.value)}></label>
</div>
<p class="candidate-guide">기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.</p>
<div class="fixed-fields">
<div class="fixed-column-headings">
<span></span><span></span><span></span>
<div><span>파란 노드 전체</span><span class="generation-heading">기수 보너스<br>덮어쓰기</span></div>
</div>
{#each fixedCardIds as selectedId, slot}
<div class="fixed-field">
<label>{slot + 1}번 고정
<select value={selectedId} on:change={(event) => updateFixedCard(slot, event.currentTarget.value)}>
<option value="">고정하지 않음</option>
{#each fiveStarCards as card}<option value={card.id} disabled={selectedInAnotherSlot(card, slot)}>{cardLabel(card)}</option>{/each}
</select>
</label>
<div class="fixed-awakening">
<span>개화</span>
<div class="awakening-stepper">
<button type="button" disabled={!selectedId || fixedAwakenings[slot] <= 0} aria-label={`${slot + 1}번 고정 카드 개화 낮추기`} on:click={() => updateFixedAwakening(slot, fixedAwakenings[slot] - 1)}>‹</button>
<strong>{fixedAwakenings[slot]}</strong>
<button type="button" disabled={!selectedId || fixedAwakenings[slot] >= 5} aria-label={`${slot + 1}번 고정 카드 개화 높이기`} on:click={() => updateFixedAwakening(slot, fixedAwakenings[slot] + 1)}>›</button>
</div>
</div>
<div class="fixed-reduction">
<span>빈도 감소 고정</span>
<div class="reduction-options">
{#each REDUCTION_LOCK_OPTIONS as reduction}
<button type="button" disabled={!selectedId} class:active={fixedCooldownReductions[slot] === reduction} aria-pressed={fixedCooldownReductions[slot] === reduction} on:click={() => updateFixedReduction(slot, reduction)}>{reduction === null ? '미사용' : `${reduction}%`}</button>
{/each}
</div>
</div>
<div class="fixed-overrides">
<label class="override-checkbox">
<input type="checkbox" disabled={!selectedId} checked={fixedAllBlueNodes[slot]} aria-label={`${slot + 1}번 고정 카드 파란 노드 전체 활성화`} on:change={() => toggleFixedAllBlueNodes(slot)}>
<span aria-hidden="true"></span>
</label>
<input class="generation-input" type="text" inputmode="numeric" pattern="[0-9]*" disabled={!selectedId} value={fixedGenerationBonusOverrides[slot] ?? ''} placeholder="미사용" aria-label={`${slot + 1}번 고정 카드 기수 보너스 덮어쓰기`} on:change={(event) => updateFixedGenerationBonus(slot, event.currentTarget.value)}>
</div>
</div>
{/each}
</div>
<p class="fixed-guide">‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다.</p>
<div class="leader-fields">
<label>리더<select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}>{#each leaderMembers as member}<option value={member.characterId}>{member.name}</option>{/each}</select></label>
<label>리더 의상<select bind:value={leaderOutfitCardId} on:change={() => result = null}>{#each leaderOutfitOptions as card}<option value={card.id}>{card.details.costume?.name}</option>{/each}</select></label>
</div>
<div class="outfit-effect">
<span>리더 의상 효과</span>
<p>{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}</p>
</div>
<p class="hint">기수 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·악곡 보너스 10% 조건으로 비교합니다.</p>
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
<label class="compute-option">
<input type="checkbox" bind:checked={maximizeCompute} disabled={running}>
<span aria-hidden="true"></span>
<strong>최대 컴퓨팅 자원 활용</strong>
</label>
<p class="compute-warning">탐색 속도는 빨라지지만, 분석 중 브라우저 및 기기 사용이 불편할 수 있습니다.</p>
{#if running}<progress value={searchPhase === 'screening' ? screeningProgress : progress.evaluatedCandidates} max={searchPhase === 'screening' ? 1 : progress.totalCandidates}></progress>{/if}
</section>
<section class="panel" aria-labelledby="result-title">
<div class="section-title"><div><span>02</span><h2 id="result-title">탐색 결과</h2></div>{#if result}<strong>{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 정밀 분석 · 상위 {rankedResults.length}개</strong>{/if}</div>
{#if rankedResults.length > 0}
<div class="ranking-list">
{#each rankedResults as ranking, rank}
<details class="ranking-card" open={rank === 0}>
<summary>
<span class="rank-number">{rank + 1}위</span>
<span class="rank-members">
{#each ranking.result.order as cardId, memberIndex}
<span class:added-member={!fixedCardIds.includes(cardId)}>
{resultCardName(cardId)}
</span>
{#if memberIndex < ranking.result.order.length - 1}<i aria-hidden="true">·</i>{/if}
{/each}
</span>
<span class="rank-metric"><small>최고 점수</small><strong>{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if comparableToBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}</span>
</summary>
<div class="ranking-detail">
<AnalysisResultView
result={ranking.result}
durationSeconds={selectedChart?.durationSeconds ?? 0}
cardName={resultCardName}
instanceId={`combination-${rank}`}
baseline={comparableToBaseline ? baselineResult : null}
showScoreSummary={false}
/>
</div>
</details>
{/each}
</div>
{:else}
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
{/if}
</section>
</main>
<style>
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: 46px 0 80px; }
header { margin-bottom: 28px; }
h1 { margin: 0; color: #f3f5f8; font-size: clamp(28px, 5vw, 42px); letter-spacing: -.045em; }
.page-description { margin: 10px 0 0; color: #929cab; font-size: 14px; line-height: 1.6; }
.baseline-guide { width: fit-content; margin: 10px 0 0; padding: 8px 11px; border-radius: 8px; color: #8d96a5; background: #1b2029; font-size: 13px; line-height: 1.55; }
.baseline-guide strong { color: #dba0b9; }
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
.section-title, .section-title > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.section-title > div { justify-content: flex-start; }
.section-title span { color: #ef8cb6; font-size: 13px; font-weight: 900; }
.section-title h2 { margin: 0; font-size: 18px; }
.section-title > strong { color: #aeb6c4; font-size: 14px; }
.baseline-panel { border-color: #4a3a4b; background: #1c1920; }
.baseline-condition { margin: 12px 0 0; color: #b8a8b4; font-size: 14px; }
.baseline-members { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 7px; margin-top: 14px; }
.baseline-members div { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; gap: 7px; min-height: 42px; padding: 7px 9px; border: 1px solid #37303a; border-radius: 9px; background: #141218; }
.baseline-members span { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; color: #181118; background: #d987ac; font-size: 12px; font-weight: 900; }
.baseline-members strong { overflow: hidden; color: #e8e1e7; font-size: 12px; line-height: 1.35; text-overflow: ellipsis; }
.baseline-status { margin: 15px 0 0; color: #9ba4b1; font-size: 14px; }
label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
select, .number-input { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
select:focus, .number-input:focus, .generation-input:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
.song-fields { display: grid; grid-template-columns: minmax(0, 1fr) clamp(130px, 11vw, 180px); gap: 10px; margin-top: 22px; }
.candidate-fields { display: grid; grid-template-columns: clamp(130px, 11vw, 180px) clamp(260px, 24vw, 380px); gap: 10px; margin-top: 14px; padding-top: 14px; border-top: 1px solid #292f39; }
.candidate-awakening { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
.candidate-guide { margin: 7px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
.fixed-fields { display: grid; gap: 9px; margin-top: 16px; }
.fixed-guide { margin: 8px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
.fixed-column-headings { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(120px, 11vw, 170px) clamp(304px, 26vw, 390px) clamp(180px, 16vw, 240px); gap: 9px; margin-bottom: -3px; }
.fixed-column-headings > div { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; color: #8f98a8; font-size: 12px; font-weight: 800; text-align: center; }
.generation-heading { line-height: 1.25; white-space: nowrap; }
.fixed-field { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(120px, 11vw, 170px) clamp(304px, 26vw, 390px) clamp(180px, 16vw, 240px); gap: 9px; }
.fixed-awakening, .fixed-reduction { display: grid; align-self: end; align-items: center; height: 42px; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
.fixed-awakening { grid-template-columns: 32px minmax(0, 1fr); }
.fixed-reduction { grid-template-columns: 88px minmax(0, 1fr); }
.awakening-stepper { display: grid; grid-template-columns: 30px minmax(0, 1fr) 30px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 22px; cursor: pointer; }
.awakening-stepper strong { color: #e8ebf1; font-size: 14px; text-align: center; }
.reduction-options { display: grid; grid-template-columns: 1.35fr repeat(4, 1fr); height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.reduction-options button { min-width: 0; padding: 0 5px; border: 0; border-left: 1px solid #2d3440; color: #8f98a8; background: transparent; font-size: 12px; font-weight: 750; cursor: pointer; }
.reduction-options button:first-child { border-left: 0; }
.reduction-options button.active { color: #171019; background: #ed8bb5; }
.fixed-overrides { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; align-self: end; height: 42px; }
.override-checkbox { display: grid; place-items: center; cursor: pointer; }
.override-checkbox input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.override-checkbox span { display: grid; place-items: center; width: 22px; height: 22px; border: 1px solid #4a5361; border-radius: 6px; background: #11151b; transition: border-color .12s, background .12s; }
.override-checkbox input:checked + span { border-color: #ed8bb5; background: #ed8bb5; }
.override-checkbox input:checked + span::after { content: '✓'; color: #171019; font-size: 14px; font-weight: 950; }
.override-checkbox input:focus-visible + span { outline: 3px solid rgb(216 111 156 / 20%); }
.override-checkbox input:disabled + span { opacity: .35; cursor: default; }
.generation-input { align-self: center; width: 100%; min-width: 0; height: 32px; padding: 0 7px; border: 1px solid #414957; border-radius: 7px; color: #e8ebf1; background: #11151b; font-size: 12px; font-weight: 800; text-align: center; }
.generation-input::placeholder { color: #68717f; opacity: 1; }
.generation-input:disabled { opacity: .35; }
.awakening-stepper button:disabled, .reduction-options button:disabled { opacity: .35; cursor: default; }
.leader-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 26px; padding-top: 20px; border-top: 1px solid #303641; }
.outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 12px; margin-top: 9px; padding: 11px 13px; border: 1px solid #2d3440; border-radius: 9px; background: #12161c; }
.outfit-effect span { color: #d993b1; font-size: 13px; font-weight: 850; }
.outfit-effect p { margin: 0; color: #b9c1cd; font-size: 14px; line-height: 1.55; }
.hint { margin: 13px 0 0; color: #858f9f; font-size: 13px; line-height: 1.6; }
.error { margin: 14px 0 0; color: #ff9f9f; font-size: 13px; }
.search { width: 100%; height: 46px; margin-top: 18px; border: 0; border-radius: 10px; color: #171019; background: #ed8bb5; font-weight: 900; cursor: pointer; }
.search:disabled { opacity: .55; cursor: default; }
.compute-option { display: flex; align-items: center; gap: 9px; width: fit-content; margin-top: 12px; cursor: pointer; }
.compute-option input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.compute-option span { display: grid; place-items: center; width: 20px; height: 20px; border: 1px solid #4a5361; border-radius: 6px; background: #11151b; }
.compute-option input:checked + span { border-color: #ed8bb5; background: #ed8bb5; }
.compute-option input:checked + span::after { content: '✓'; color: #171019; font-size: 13px; font-weight: 950; }
.compute-option input:focus-visible + span { outline: 3px solid rgb(216 111 156 / 20%); }
.compute-option input:disabled + span, .compute-option input:disabled ~ strong { opacity: .5; cursor: default; }
.compute-option strong { color: #c9d0da; font-size: 14px; }
.compute-warning { margin: 6px 0 0 29px; color: #a98686; font-size: 13px; line-height: 1.5; }
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
.ranking-list { display: grid; gap: 10px; margin-top: 18px; }
.ranking-card { overflow: hidden; border: 1px solid #303744; border-radius: 12px; background: #12161c; }
.ranking-card[open] { border-color: #55404f; background: #15151b; }
.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::-webkit-details-marker { display: none; }
.ranking-card summary::after { content: '⌄'; position: absolute; top: 50%; right: 15px; color: #858f9f; font-size: 16px; transform: translateY(-50%); transition: transform .15s; }
.ranking-card[open] summary::after { transform: translateY(-50%) rotate(180deg); }
.rank-number { display: grid; place-items: center; width: 48px; height: 34px; border-radius: 8px; color: #171019; background: #ed8bb5; font-size: 14px; font-weight: 900; }
.rank-members { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; color: #dce1e8; font-size: 13px; font-weight: 800; line-height: 1.55; }
.rank-members > span { white-space: nowrap; }
.rank-members > i { flex: 0 0 auto; color: #596270; font-style: normal; }
.rank-members .added-member { color: #f3a5c6; }
.rank-metric { display: grid; gap: 4px; }
.rank-metric small { color: #9ca5b3; font-size: 13px; font-style: normal; font-weight: 750; }
.rank-metric strong { color: #f0a0c2; font-size: 18px; line-height: 1.2; }
.rank-metric i { color: #90d8ae; font-size: 12px; font-style: normal; font-weight: 800; }
.ranking-detail { padding: 0 16px 18px; border-top: 1px solid #2b313c; }
.scores { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
.scores div { display: grid; gap: 7px; padding: 15px; border-radius: 10px; background: #22202a; }
.scores span { color: #a9b1be; font-size: 14px; }
.scores strong { color: #f2a3c4; font-size: 19px; }
.baseline-scores { margin-top: 10px; }
.empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; padding: 28px; border: 1px dashed #303744; border-radius: 12px; color: #858f9f; font-size: 14px; text-align: center; }
@media (max-width: 1100px) {
.fixed-column-headings,
.fixed-field { grid-template-columns: minmax(0, 1fr) 120px 304px 180px; }
}
@media (max-width: 700px) {
main { width: calc(100% - 24px); padding-top: 28px; }
.panel { padding: 17px; }
.song-fields { grid-template-columns: 1fr 100px; }
.candidate-fields { grid-template-columns: 1fr; }
.fixed-column-headings { grid-template-columns: minmax(0, 1fr) 102px; }
.fixed-column-headings > span { display: none; }
.fixed-column-headings > div { grid-column: 1 / -1; }
.fixed-field { grid-template-columns: minmax(0, 1fr) 102px; }
.fixed-reduction, .fixed-overrides { grid-column: 1 / -1; }
.fixed-awakening { grid-template-columns: 1fr; }
.leader-fields, .scores { grid-template-columns: 1fr; }
.outfit-effect { grid-template-columns: 1fr; gap: 5px; }
.baseline-members { grid-template-columns: 1fr; }
.ranking-card summary { grid-template-columns: 48px minmax(0, 1fr); gap: 12px 10px; padding: 13px 34px 13px 12px; }
.rank-number { grid-column: 1; grid-row: 1; }
.rank-members { grid-column: 2; grid-row: 1; }
.rank-metric:nth-of-type(3) { grid-column: 2; grid-row: 2; }
.ranking-detail { padding: 0 10px 14px; }
}
</style>
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import UserDataPage from '../+page.svelte';
</script>
<UserDataPage />
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import UserDataPage from '../+page.svelte';
</script>
<UserDataPage />
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

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