feat: add card catalog and filters

This commit is contained in:
2026-08-18 00:03:27 +09:00
parent c3a3ff5e17
commit dd3ea911f6
19 changed files with 345062 additions and 26 deletions
+4
View File
@@ -6,6 +6,10 @@ build
!.env.example !.env.example
data/* data/*
!data/.gitkeep !data/.gitkeep
!data/card-overrides.csv
!data/holodoridb/
!data/holodoridb/**
data/holodoridb/*.tmp
npm-debug.log* npm-debug.log*
.DS_Store .DS_Store
*.tsbuildinfo *.tsbuildinfo
+25
View File
@@ -1,5 +1,30 @@
# Holodream Tools # 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`를 실행하면 카드 목록에 반영됩니다. 이 파일은 원본 데이터를 다시 수집해도 덮어쓰지 않습니다.
홀로드림스 덱 최적화와 스킬 로스 분석을 통합할 웹사이트의 개발 기반입니다. 현재 단계에는 게임 기능을 포함하지 않습니다. 홀로드림스 덱 최적화와 스킬 로스 분석을 통합할 웹사이트의 개발 기반입니다. 현재 단계에는 게임 기능을 포함하지 않습니다.
## 구성 ## 구성
+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 수영복
+13
View File
@@ -0,0 +1,13 @@
# 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
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
+511
View File
@@ -0,0 +1,511 @@
{
"schemaVersion": 1,
"generatedAt": "2026-08-17T10:58:53.307Z",
"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"
},
"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"
}
},
"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"
}
}
}
}
+1
View File
@@ -12,6 +12,7 @@
"preview": "vite preview --host 0.0.0.0", "preview": "vite preview --host 0.0.0.0",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"start": "node build", "start": "node build",
"data:sync": "node scripts/collect-holodoridb.mjs",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio"
+637
View File
@@ -0,0 +1,637 @@
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');
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',
'SkillTreeConnectEffect',
'SkillTreeConnectEffectExtent',
'SkillTreeEffect',
'SkillTreeEffectPassiveTrigger',
'SkillTreeEffectTarget',
'SkillTreeEffectValueLimit'
];
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']);
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 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];
return {
id: card.id,
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,
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),
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)
};
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`)
]);
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(`Output: ${outputRoot}\n`);
File diff suppressed because it is too large Load Diff
+1594 -25
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
# Stat icon sources
Retrieved from the stat display on HolodoriDB on 2026-08-17.
- `performance.webp`: https://api.holodori.best/manual_assets/stat-performance.webp
- `technique.webp`: https://api.holodori.best/manual_assets/stat-technique.webp
- `sense.webp`: https://api.holodori.best/manual_assets/stat-sense.webp
These assets are used by HolodoriDB as monochrome CSS mask images. Verify the
original game asset rights and distribution terms before public deployment.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+12
View File
@@ -0,0 +1,12 @@
# Type icon sources
Retrieved from the card list on HolodoriDB on 2026-08-17.
- `cute.webp`: https://api.holodori.best/manual_assets/attr-cute.webp
- `pure.webp`: https://api.holodori.best/manual_assets/attr-pure.webp
- `happy.webp`: https://api.holodori.best/manual_assets/attr-happy.webp
The downloaded WebP files contain only a VP8L image-data chunk and no EXIF,
XMP, ICC profile, comments, attribution strings, or embedded source URLs.
Verify the original game asset rights and distribution terms before public
deployment.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB