feat: add card catalog and filters
This commit is contained in:
@@ -6,6 +6,10 @@ build
|
||||
!.env.example
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
!data/card-overrides.csv
|
||||
!data/holodoridb/
|
||||
!data/holodoridb/**
|
||||
data/holodoridb/*.tmp
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# 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`를 실행하면 카드 목록에 반영됩니다. 이 파일은 원본 데이터를 다시 수집해도 덮어쓰지 않습니다.
|
||||
|
||||
홀로드림스 덱 최적화와 스킬 로스 분석을 통합할 웹사이트의 개발 기반입니다. 현재 단계에는 게임 기능을 포함하지 않습니다.
|
||||
|
||||
## 구성
|
||||
|
||||
@@ -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,수영복,
|
||||
|
@@ -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
+196731
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"start": "node build",
|
||||
"data:sync": "node scripts/collect-holodoridb.mjs",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
|
||||
@@ -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
+1595
-26
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
Reference in New Issue
Block a user