Compare commits

...
18 Commits
Author SHA1 Message Date
CAtlas0784 1719781711 Document Vercel URL and camera controls 2026-10-29 00:49:41 +07:00
CAtlas0784 4108cd04b3 Add camera settings and Vercel deployment 2026-10-29 00:13:34 +07:00
CAtlas0784 62a529ef3f feat: select Anomaly Arbitration round 2026-10-25 00:45:23 +07:00
CAtlas0784 feb18074fe feat: add monster settings and AA room selection controls 2026-10-24 19:12:10 +07:00
CAtlas0784 ffd6b2e853 cw cmd and unstuck and more 2026-10-21 16:30:08 +07:00
CAtlas0784 ca5831c928 fix: show memosprite contribution and Other damage category 2026-10-20 00:40:04 +07:00
CAtlas0784 e03e5b1f14 fix: preserve battle slot order and display native damage categories 2026-10-19 19:42:42 +07:00
CAtlas0784 b11121e27e feat: complete srtool localization and analyst updates 2026-10-19 02:58:44 +07:00
CAtlas0784 e8952ecde8 fix: preserve team order in damage analyst 2026-10-19 02:01:37 +07:00
CAtlas0784 51302e36c8 feat: complete localization and add character build guides 2026-10-19 01:54:29 +07:00
CAtlas0784 1e98bfea7e Merge main deployment policy 2026-10-18 22:42:43 +07:00
CAtlas0784 141798c629 fix: deploy Pages only from main 2026-10-18 22:42:35 +07:00
CAtlas0784 3575261aa4 Merge latest Srtool updates into main 2026-10-18 22:42:18 +07:00
CAtlas0784 a2e0f186d1 fix: stamp Pages build with deployed branch 2026-10-18 22:40:11 +07:00
CAtlas0784 99ecd84499 fix: show damage shares only when reported 2026-10-18 22:37:29 +07:00
CAtlas0784 8de2fb5e44 fix: refresh cached Srtool client bundle 2026-10-18 22:27:21 +07:00
C 5fc5c9e0da Merge pull request #4 from CAtlas0784/test-gay
fix: use API client for damage reports
2026-10-18 22:21:48 +07:00
CAtlas0784 45456e7df6 fix: use API client for damage reports 2026-10-18 22:20:41 +07:00
20 changed files with 15015 additions and 397 deletions

No files matched your search

+7 -1
View File
@@ -2,7 +2,7 @@ name: Deploy GaC-Srtool to GitHub Pages
on:
push:
branches: ["main", "test-gay"]
branches: ["main"]
workflow_dispatch:
permissions:
@@ -16,6 +16,7 @@ concurrency:
jobs:
deploy:
if: github.ref == 'refs/heads/main'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
@@ -25,6 +26,11 @@ jobs:
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Generate deployment branch metadata
env:
DEPLOY_BRANCH: ${{ github.ref_name }}
run: |
node -e 'const fs = require("node:fs"); const data = { branch: process.env.DEPLOY_BRANCH, version: "1.0.0", updatedAt: new Date().toISOString() }; fs.writeFileSync("version.json", JSON.stringify(data, null, 2) + "\n");'
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
+1
View File
@@ -16,6 +16,7 @@ Thumbs.db
*.swo
.vscode/
.idea/
__pycache__/
# Local custom configs (environment / machine specific)
config.local.json
+9
View File
@@ -0,0 +1,9 @@
.git
.github
.vercel
node_modules
dist
tools
.env*
config.local.*
*.log
+42
View File
@@ -1,5 +1,13 @@
# GaC-Srtool 🌌
ใช้งานเว็บได้ที่ [GaCy Srtool บน Vercel](https://gacy-srtool.vercel.app/) เปิดหน้าตั้งค่าเพื่อเชื่อมต่อ SDK ของเซิร์ฟเวอร์ที่ใช้งานอยู่
ในโหมด GaC หน้า **ตั้งค่าตัวละคร / สวิตช์ระบบพิเศษ** เลือก Freecam หรือ FaceCam และตั้งระยะเริ่มต้นของ FaceCam ได้ หลังบันทึกให้กด Tab ในเกมเพื่อเปิดหรือปิดกล้อง; ลูกกลิ้งปรับระยะชั่วคราวและกลับเป็นค่าที่ตั้งไว้เมื่อเปิดใหม่
## Unstuck / แก้ตัวหมุนค้าง
เมื่อเชื่อมต่อ GaC server ใช้ปุ่ม **Unstuck** ใน GM Command Console หรือพิมพ์ `/unstuck` ได้ เว็บจะรอผลตอบรับจากเกมก่อนแสดงว่าสำเร็จ ต้องเปิดเกมและเชื่อมต่อเซิร์ฟเวอร์อยู่ คำสั่งนี้ปลดการบล็อก UI ระหว่างรอแพ็กเก็ตผ่าน heartbeat โดยไม่ลบเซฟ CW หรือเปลี่ยนแมพ และไม่แทนการแก้ handler ที่ขาด ต้องใช้ SDK server ที่รองรับ `/api/client/unstuck`
เว็บแอปพลิเคชันแดชบอร์ด (Web Dashboard & Management Tool) สำหรับจัดการข้อมูลตัวละคร, จัดทีม, ปรับแต่งรีลิกส์/กรวยแสง, วิเคราะห์ดาเมจจากการต่อสู้จริง และจัดการไฟล์เซฟ (`freesr-data.json`, `persistent`) ของเซิร์ฟเวอร์ **GaC-hkrpg-go** (และเซิร์ฟเวอร์มาตรฐาน FreeSR)
ทำงานแบบ Client-side 100% สามารถเปิดใช้งานผ่านเบราว์เซอร์ได้ทันทีโดยไม่ต้องติดตั้งโปรแกรมเพิ่มเติม
@@ -40,6 +48,7 @@
### 👥 4. ระบบจัดการตัวละคร (Avatars & Character Builder)
- **ข้อมูลครบถ้วน:** ครอบคลุมตัวละครทั้งหมดในเกมตาม Resource มาตรฐานและเนื้อหาล่าสุด
- **คู่มือ Build รายตัว:** เปิดดูกรวยแสง, เซ็ตรีลิกส์/มิติ และออฟหลักที่พบบ่อยจากข้อมูลตัวอย่าง Endgame พร้อมโหมด/จำนวนตัวอย่าง วันที่เก็บข้อมูล และลิงก์คู่มือเต็มของ Prydwen
- **ปรับแต่งสถานะ:** เลเวล (1-80), เลื่อนขั้น (Ascension 0-6), ระดับ Eidolon (E0-E6), และร่องรอยสกิล (Traces 10/10)
- **ปุ่มลัดจัดการด่วน:**
- ⚡ *ปรับ E0S1 ทุกตัว:* ตั้งค่าตัวละครทุกตัวเป็น E0 พร้อมกรวยแสงประจำตัว S1 เลเวล 80
@@ -57,6 +66,13 @@
- การบันทึกแก้เฉพาะจำนวนไอเทมใน `freesr-data.json` ไม่เขียนทับตัวละคร, LC, Relic หรือ Battle Config
- รองรับเฉพาะ GaC-hkrpg-go ผ่าน `/api/inventory/materials`; บันทึกลงไฟล์ทันทีและต้องเข้าเกมใหม่เพื่อโหลด session พร้อมจำนวนที่แก้แล้ว
### 👾 6.1 Monster Setting
- แยก Memory of Chaos, Pure Fiction, Apocalyptic Shadow และ Anomaly Arbitration เพื่อดูชื่อด่าน/มอนสเตอร์ รูป, level และ wave จาก metadata Nanoka ที่ตรงกับ `client_version` ของเซิร์ฟเวอร์และ resource `challenge_data.json` / `resources/StageConfig.json` (อ่านอย่างเดียว); เมื่อ Client เปลี่ยนเวอร์ชัน API จะดึง metadata ของเวอร์ชันนั้นให้อัตโนมัติ โดยไม่ใช้ข้อมูลจากเวอร์ชันอื่น
- เปลี่ยนการเลือกกลุ่ม/ด่านเป็นการ์ด และ Custom Enemy ค้นมอนด้วยชื่อหรือ ID พร้อมรูป/ชื่อจาก Nanoka; พิมพ์ ID เองได้ และแสดงรูปตัวอย่างขนาดใหญ่ขึ้น
- Anomaly Arbitration เป็นโหมด Endgame ที่อ่าน stage จาก resource เช่นเดียวกับโหมดอื่น ไม่ใช่ Custom Enemy; Aha's Help มีแท็บแยกจาก AA
- แก้ Custom Enemy ได้เฉพาะ Golden / Crimson Calyx และต้องเชื่อมต่อ GaC-Srtool; การกดใช้ stage จาก Client จะล้าง custom waves เพื่อให้กลับไปใช้ resource
- Weekly Boss, Stagnant Shadow, Cavern of Corrosion, Endgame และ Simulated Universe ไม่รับ custom waves จากหน้านี้
### 💾 7. จัดการไฟล์เซฟ GaC (Persistent & FreeSR Data)
- **ตั้งค่าตัวเอก (ผู้บุกเบิก):** สลับเพศ (Caelus / Stelle) และเปลี่ยนธาตุ (กายภาพ, ไฟ, จินตภาพ, ความจำ)
- **สลับพาธ March 7th:** เลือกระหว่างพาธการอนุรักษ์ (น้ำแข็ง) และพาธล่าสังหาร (จินตภาพ)
@@ -121,4 +137,30 @@ GaC-Srtool สื่อสารกับเซิร์ฟเวอร์ GaC-h
### บันทึกตัวละครและ Traces
Damage Analyst เรียงการ์ดตาม `report.avatars[].slot` จาก Battle จริง ไม่เปลี่ยนตามทีมหน้าโลก รายงานเก่าที่ไม่มี slot คงลำดับเดิม และแสดงประเภทดาเมจเพิ่มเติมที่ API ส่งมา เช่น Elation Damage / True Damage ทดสอบลำดับด้วย `node --test tests/damage-order.test.cjs` ข้อมูลสดต้องใช้ SDK และ `damage_analyst_live.lua` ฉบับที่อ่าน StatisticsComponent ของ Battle ปัจจุบัน
การบันทึกจากหน้ารายละเอียดส่งข้อมูลตัวละครทั้งชุดผ่าน `/api/freesr` ในคำขอเดียว รวมถึง `data.skills` และ `data.skills_by_anchor_type` สำหรับ Traces เพื่อไม่ให้ server sync ข้อมูลระดับ/Eidolon ระหว่างทางก่อนข้อมูล Trace จะถูกบันทึก `saveAvatarDetails(closeModal = true)` ยังรองรับการเรียกจากปุ่ม build ที่ไม่ต้องปิดหน้าต่าง ถ้า server ตอบผิดพลาดจะแสดงข้อความบันทึกไม่สำเร็จแทนการแจ้งว่าสำเร็จ
### ข้อมูล Build และแหล่งที่มา
หน้า **รายละเอียดตัวละคร → แนวทางบิลด์** แสดงอุปกรณ์และออฟหลักที่พบบ่อยในตัวอย่าง Endgame จากข้อมูลที่แสดงบน Prydwen โดยใส่โหมด/จำนวนผู้เล่นและวันที่เก็บ snapshot ไว้กำกับ ตัวเลขเป็นสัดส่วนการใช้งานในกลุ่มตัวอย่าง ไม่ใช่คะแนนจัดอันดับหรือคำแนะนำทางการ ตัวละครที่ snapshot ไม่มีตัวอย่างจะแสดงลิงก์ไปคู่มือของ Prydwen แทนการแสดงข้อมูลสำรองที่อาจทำให้เข้าใจผิด
ข้อมูลที่สร้างไว้ใน `js/build-guides-data.js` ใช้ชุดข้อมูล MIT จาก [LvlUrArti/MocDataProcessed](https://huggingface.co/datasets/LvlUrArti/MocDataProcessed) ซึ่งระบุว่าเป็นข้อมูลที่ประมวลผลจากเว็บไซต์ Prydwen โปรดให้เครดิต LvlUrArti เมื่อเผยแพร่หรือวิเคราะห์ข้อมูลต่อ สามารถอัปเดต snapshot ด้วย `npm run sync-build-guides` ได้ โดยสคริปต์จะเลือกเวอร์ชันล่าสุดที่มีใน dataset และจับคู่ตัวละครตาม ID จาก `js/avatars-data.js`.
### Item Book
The **Item Book** scans the pinned game-data revision for every `ItemConfig`-named table that contains item-definition rows. In this revision, 26 candidate tables were checked and 18 supplied item rows: 5,472 rows resolve to 5,414 unique item IDs. The **GaC supported** badge is derived from the Go server's `InventoryItemConfig.json`; other entries are browse-only.
Click an item's image to open its details, including its ID, source table, type, game description, and a quantity selector. The selected quantity is local to the dialog and does not call the server or add an item. Where the game table defines `PileLimit`, the selector uses it as the maximum.
Names and descriptions come from the pinned game TextMap and StarRailRes indexes for all 13 Srtool languages. At this revision, 5,150 of 5,414 item names have source-localized text; missing source names fall back to English or the item ID. Icons load from the pinned StarRailRes revision: 5,261 entries have a verified image file, while the remaining 153 retain their original game icon paths and show the missing-image state. The installed client stores these missing images inside encrypted `.block` bundles, so they are not extracted by this catalog builder.
Regenerate `assets/item-catalog.json` from the inventory catalog revision used by the Go server:
```bash
python tools/build-item-catalog.py --inventory-config <path-to-GaC-hkrpg-go>/resources/InventoryItemConfig.json
```
## CW GM commands
In the connected GM Console: `/cw status`, `/cw gold <total>`, `/cw hp <total>`. Values affect only the active CW run. Changes are queued and applied during Preparation; they wait while fighting. Gold sets the total, rather than adding repeatedly. HP must fit the run limit. Check the server's `CW_GM applied` log or query status to confirm application. Commands are also included in console suggestions and `/help`.
+1
View File
@@ -0,0 +1 @@
{"source":{"gameRepository":"https://gitlab.com/Dimbreath/turnbasedgamedata","gameRevision":"312b4597691e0a29cf9828a15b592757dba86949","candidateTables":26,"gameTables":["ExcelOutput/ActivityItemConfigAvatar.json","ExcelOutput/ActivityItemConfigAvatarLD.json","ExcelOutput/ItemConfig.json","ExcelOutput/ItemConfigAvatar.json","ExcelOutput/ItemConfigAvatarLD.json","ExcelOutput/ItemConfigAvatarPlayerIcLD.json","ExcelOutput/ItemConfigAvatarPlayerIcon.json","ExcelOutput/ItemConfigAvatarRank.json","ExcelOutput/ItemConfigAvatarRankLD.json","ExcelOutput/ItemConfigAvatarSkin.json","ExcelOutput/ItemConfigBadge.json","ExcelOutput/ItemConfigBook.json","ExcelOutput/ItemConfigDisk.json","ExcelOutput/ItemConfigEquipment.json","ExcelOutput/ItemConfigLD.json","ExcelOutput/ItemConfigPlayerRoomDynamic.json","ExcelOutput/ItemConfigRelic.json","ExcelOutput/ItemConfigTrainDynamic.json"],"tableScan":[{"table":"ExcelOutput/ActivityItemConfigAvatar.json","rows":19,"itemRows":19},{"table":"ExcelOutput/ActivityItemConfigAvatarLD.json","rows":6,"itemRows":6},{"table":"ExcelOutput/AvatarExpItemConfig.json","rows":3,"itemRows":0},{"table":"ExcelOutput/EquipmentExpItemConfig.json","rows":3,"itemRows":0},{"table":"ExcelOutput/EventMuseumItemConfig.json","rows":18,"itemRows":0},{"table":"ExcelOutput/GMAccountItemConfig.json","rows":0,"itemRows":0},{"table":"ExcelOutput/ItemConfig.json","rows":2941,"itemRows":2941},{"table":"ExcelOutput/ItemConfigAvatar.json","rows":99,"itemRows":99},{"table":"ExcelOutput/ItemConfigAvatarLD.json","rows":4,"itemRows":4},{"table":"ExcelOutput/ItemConfigAvatarPlayerIcLD.json","rows":4,"itemRows":4},{"table":"ExcelOutput/ItemConfigAvatarPlayerIcon.json","rows":94,"itemRows":94},{"table":"ExcelOutput/ItemConfigAvatarRank.json","rows":94,"itemRows":94},{"table":"ExcelOutput/ItemConfigAvatarRankLD.json","rows":4,"itemRows":4},{"table":"ExcelOutput/ItemConfigAvatarSkin.json","rows":8,"itemRows":8},{"table":"ExcelOutput/ItemConfigAvatarTest.json","rows":70,"itemRows":0},{"table":"ExcelOutput/ItemConfigAvatarTestRank.json","rows":65,"itemRows":0},{"table":"ExcelOutput/ItemConfigBadge.json","rows":21,"itemRows":21},{"table":"ExcelOutput/ItemConfigBook.json","rows":762,"itemRows":762},{"table":"ExcelOutput/ItemConfigDisk.json","rows":272,"itemRows":272},{"table":"ExcelOutput/ItemConfigEquipment.json","rows":170,"itemRows":170},{"table":"ExcelOutput/ItemConfigLD.json","rows":69,"itemRows":69},{"table":"ExcelOutput/ItemConfigPlayerRoomDynamic.json","rows":79,"itemRows":79},{"table":"ExcelOutput/ItemConfigRelic.json","rows":774,"itemRows":774},{"table":"ExcelOutput/ItemConfigTrainDynamic.json","rows":52,"itemRows":52},{"table":"ExcelOutput/MessageItemConfig.json","rows":13779,"itemRows":0},{"table":"ExcelOutput/RestaurantShopItemConfig.json","rows":6,"itemRows":0}],"assetRepository":"Mar-7th/StarRailRes","assetRevision":"dbe8cdfcfb0bf657b9fe1cf92d5537f118ccb487","assetBaseUrl":"https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@dbe8cdfcfb0bf657b9fe1cf92d5537f118ccb487/","assetFallbackBaseUrl":"https://raw.githubusercontent.com/Mar-7th/StarRailRes/dbe8cdfcfb0bf657b9fe1cf92d5537f118ccb487/","languages":["th","en","zh-CN","zh-TW","ja","ko","vi","id","es","fr","de","ru","pt"],"notes":"Includes every ItemConfig table with item-definition rows at the pinned game-data revision. Items absent from InventoryItemConfig are browse-only; the catalog does not add or summon items. Icons use only files present in the pinned public StarRailRes tree. gameIconPaths retain the source paths for items without a mapped public icon."},"counts":{"total":5414,"definitionRows":5472,"uniqueIds":5414,"supported":3550,"withImages":5261,"withoutConfiguredImage":153,"withDescriptions":4647,"nameCoverageByLanguage":{"th":5150,"en":5150,"zh-CN":5150,"zh-TW":5150,"ja":5150,"ko":5150,"vi":5150,"id":5150,"es":5150,"fr":5150,"de":5150,"ru":5150,"pt":5150},"descriptionCoverageByLanguage":{"th":3080,"en":3080,"zh-CN":3080,"zh-TW":3080,"ja":3080,"ko":3080,"vi":3080,"id":3080,"es":3080,"fr":3080,"de":3080,"ru":3080,"pt":3080},"backgroundDescriptionCoverageByLanguage":{"th":4328,"en":4328,"zh-CN":4328,"zh-TW":4328,"ja":4328,"ko":4328,"vi":4328,"id":4328,"es":4328,"fr":4328,"de":4328,"ru":4328,"pt":4328},"candidateTables":26,"definitionTables":18},"items":[{"id":1,"category":"item","type":"Virtual","subType":"Virtual","rarity":5,"names":{"th":"Stellar Jade","en":"Stellar Jade","zh-CN":"星琼","zh-TW":"星瓊","ja":"星玉","ko":"성옥","vi":"Ngọc Ánh Sao","id":"Stellar Jade","es":"Jade estelar","fr":"Jade stellaire","de":"Stellare Jade","ru":"Звёздный нефрит","pt":"Jade Estelar"},"descriptions":{},"backgroundDescriptions":{"th":"ประกายแสงอันล้ำค่าพุ่งผ่านท้องฟ้ายามราตรีราวกับดาวตก คว้าเอาไว้ก่อนที่มันจะดับสูญ บางทีอาจจะใช้แลกเป็นบางสิ่งไLine truncated
+217
View File
@@ -0,0 +1,217 @@
.item-catalog-toolbar {
display: flex;
align-items: center;
gap: .65rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.item-catalog-toolbar .form-input { flex: 1 1 260px; min-width: 200px; }
.item-catalog-toolbar .form-select { flex: 0 1 230px; min-width: 190px; }
.item-catalog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: .75rem;
}
.item-catalog-card {
min-width: 0;
display: grid;
grid-template-columns: 68px minmax(0, 1fr);
align-items: center;
gap: .8rem;
padding: .8rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
transition: border-color .15s ease, background .15s ease;
}
.item-catalog-card:hover {
border-color: rgba(56, 189, 248, .45);
background: var(--bg-card-hover);
}
.item-catalog-art {
width: 68px;
height: 68px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: .15rem;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, .035);
}
.item-catalog-art-button {
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
}
.item-catalog-art-button:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 3px; }
.item-catalog-art-button:hover .item-catalog-art { border-color: var(--accent-gold); transform: translateY(-1px); }
.item-catalog-image {
width: 62px;
height: 62px;
object-fit: contain;
}
.item-catalog-art-missing {
color: var(--text-muted);
text-align: center;
padding: .2rem;
}
.item-catalog-art-missing span { font-size: 1.25rem; line-height: 1; color: var(--accent-cyan); }
.item-catalog-art-missing small { font-size: .55rem; line-height: 1.1; }
.item-catalog-info { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: .2rem; }
.item-catalog-name {
max-width: 100%;
color: var(--text-primary);
font-size: .84rem;
line-height: 1.25;
font-weight: 750;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow-wrap: anywhere;
}
.item-catalog-id { color: var(--accent-cyan); font: 700 .75rem/1.2 monospace; }
.item-catalog-details {
max-width: 100%;
color: var(--text-muted);
font-size: .65rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-catalog-support {
margin-top: .12rem;
padding: .14rem .4rem;
border-radius: 999px;
font-size: .59rem;
line-height: 1.25;
font-weight: 750;
}
.item-catalog-support.is-supported { color: #34d399; background: rgba(16, 185, 129, .12); }
.item-catalog-support.is-browse-only { color: #fbbf24; background: rgba(245, 158, 11, .11); }
.item-catalog-empty { grid-column: 1 / -1; padding: 2rem 1rem; text-align: center; color: var(--text-muted); }
.item-catalog-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
padding: .75rem 1rem;
border-top: 1px solid var(--border-color);
}
@media (max-width: 540px) {
.item-catalog-grid { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
.item-catalog-toolbar .form-select { flex: 1 1 100%; }
}
.inventory-mode-switch {
display: flex;
gap: .5rem;
margin-bottom: 1rem;
padding: .35rem;
width: fit-content;
max-width: 100%;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-secondary);
}
.inventory-mode-btn {
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: .55rem .85rem;
color: var(--text-muted);
background: transparent;
font: inherit;
font-size: .8rem;
font-weight: 750;
cursor: pointer;
}
.inventory-mode-btn:hover { color: var(--text-primary); }
.inventory-mode-btn.active {
color: var(--accent-cyan);
background: rgba(56, 189, 248, .1);
border-color: rgba(56, 189, 248, .25);
}
.inventory-mode-panel[hidden] { display: none !important; }
.item-catalog-dialog {
width: min(660px, calc(100vw - 2rem));
max-width: none;
max-height: min(86vh, 760px);
padding: 0;
color: var(--text-primary);
border: 1px solid rgba(244, 190, 86, .45);
border-radius: 14px;
background: linear-gradient(145deg, #202333, #11141f 68%);
box-shadow: 0 24px 90px rgba(0, 0, 0, .72), 0 0 36px rgba(244, 190, 86, .09);
}
.item-catalog-dialog::backdrop { background: rgba(4, 6, 14, .78); backdrop-filter: blur(5px); }
.item-catalog-detail-shell { overflow: auto; max-height: min(86vh, 760px); }
.item-catalog-detail-header,
.item-catalog-detail-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: .8rem;
padding: .85rem 1rem;
background: rgba(255, 255, 255, .035);
}
.item-catalog-detail-header { color: var(--accent-gold); font-weight: 800; border-bottom: 1px solid var(--border-color); }
.item-catalog-dialog-close {
width: 2rem;
height: 2rem;
border: 1px solid var(--border-color);
border-radius: 50%;
color: var(--text-primary);
background: rgba(255, 255, 255, .06);
font-size: 1.3rem;
line-height: 1;
cursor: pointer;
}
.item-catalog-detail-body { display: grid; grid-template-columns: 144px minmax(0, 1fr); gap: 1rem; padding: 1.15rem; }
.item-catalog-detail-art {
width: 144px;
height: 144px;
display: grid;
place-items: center;
align-self: start;
overflow: hidden;
border: 1px solid rgba(244, 190, 86, .38);
border-radius: 12px;
background: radial-gradient(circle, rgba(244, 190, 86, .13), rgba(255, 255, 255, .03) 70%);
color: var(--text-muted);
text-align: center;
font-size: .8rem;
}
.item-catalog-detail-art img { width: 90%; height: 90%; object-fit: contain; }
.item-catalog-detail-copy { min-width: 0; }
.item-catalog-detail-copy h2 { margin: .05rem 0 .35rem; font-size: 1.15rem; line-height: 1.3; overflow-wrap: anywhere; }
.item-catalog-detail-id { color: var(--accent-cyan); font: 700 .78rem/1.3 monospace; }
.item-catalog-detail-source { margin-top: .25rem; color: var(--text-muted); font-size: .66rem; line-height: 1.35; overflow-wrap: anywhere; }
.item-catalog-detail-meta { margin-top: .5rem; color: var(--accent-gold); font-size: .78rem; line-height: 1.5; }
.item-catalog-detail-section { margin-top: .85rem; }
.item-catalog-detail-section h3 { margin: 0 0 .3rem; color: var(--text-muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; }
.item-catalog-detail-description,
.item-catalog-detail-lore { color: #e1e4ee; font-size: .83rem; line-height: 1.55; white-space: pre-line; overflow-wrap: anywhere; }
.item-catalog-detail-quantity { display: flex; align-items: center; gap: .75rem; padding: .9rem 1.15rem; border-top: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); }
.item-catalog-detail-quantity > label { color: var(--text-primary); font-size: .82rem; font-weight: 750; }
.item-catalog-amount-stepper { display: inline-flex; align-items: center; overflow: hidden; border: 1px solid var(--border-color); border-radius: 8px; background: rgba(0, 0, 0, .22); }
.item-catalog-amount-stepper button { width: 2.2rem; height: 2.2rem; border: 0; color: var(--accent-gold); background: transparent; font-size: 1.25rem; cursor: pointer; }
.item-catalog-amount-stepper button:hover { background: rgba(244, 190, 86, .12); }
.item-catalog-amount-stepper input { width: 5.2rem; height: 2.2rem; border: 0; border-right: 1px solid var(--border-color); border-left: 1px solid var(--border-color); outline: 0; color: var(--text-primary); background: transparent; text-align: center; font: 700 .95rem/1 monospace; }
.item-catalog-amount-limit { color: var(--text-muted); font-size: .72rem; }
.item-catalog-detail-footer { justify-content: flex-end; }
.item-catalog-detail-lore[hidden] { display: none !important; }
@media (max-width: 480px) {
.inventory-mode-switch { width: 100%; }
.inventory-mode-btn { flex: 1; padding-inline: .45rem; }
}
@media (max-width: 520px) {
.item-catalog-detail-body { grid-template-columns: 88px minmax(0, 1fr); gap: .75rem; padding: .85rem; }
.item-catalog-detail-art { width: 88px; height: 88px; }
.item-catalog-detail-quantity { flex-wrap: wrap; gap: .55rem; }
}
+557 -20
View File
@@ -233,6 +233,11 @@ body {
margin: 0 auto;
}
/* Give the stage browser the full available width when it is active. */
.main-container:has(#tab-monster-settings.active) {
max-width: none;
}
.tab-content {
display: none;
animation: fadeIn 0.25s ease-out;
@@ -845,6 +850,202 @@ body {
border-color: rgba(99, 120, 160, 0.45);
}
/* Aha's Help level picker */
.aha-help-card {
position: relative;
display: flex;
flex-direction: column;
gap: 1.1rem;
margin: 0.25rem 0 1rem;
padding: clamp(1rem, 2.2vw, 1.5rem);
border: 1px solid rgba(245, 180, 75, 0.28);
border-radius: 18px;
background:
radial-gradient(ellipse at 100% 0, rgba(245, 158, 11, 0.09), transparent 42%),
linear-gradient(145deg, rgba(25, 29, 42, 0.96), rgba(15, 19, 30, 0.96));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
.aha-help-heading {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.aha-help-emblem {
display: grid;
place-items: center;
flex: 0 0 48px;
width: 48px;
height: 48px;
border: 1px solid rgba(251, 191, 36, 0.4);
border-radius: 15px;
background: linear-gradient(145deg, rgba(251, 191, 36, 0.2), rgba(245, 158, 11, 0.06));
color: #fcd34d;
font-size: 1.65rem;
text-shadow: 0 0 16px rgba(251, 191, 36, 0.55);
}
.aha-help-heading-copy {
min-width: 0;
}
.aha-help-eyebrow {
margin: 0 0 0.3rem;
color: #fbbf24;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.12em;
}
.aha-help-heading h3 {
margin: 0;
color: var(--text-highlight);
font-size: clamp(1.1rem, 2vw, 1.35rem);
line-height: 1.25;
}
.aha-help-heading p {
max-width: 760px;
margin: 0.4rem 0 0;
color: var(--text-muted);
font-size: 0.9rem;
line-height: 1.55;
}
.aha-help-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 205px), 1fr));
gap: 0.75rem;
}
.aha-help-option {
position: relative;
display: flex;
min-width: 0;
min-height: 112px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.45rem;
padding: 1rem 2.4rem 1rem 1rem;
border: 1px solid rgba(110, 123, 148, 0.28);
border-radius: 13px;
background: rgba(9, 13, 22, 0.62);
color: var(--text-main);
text-align: left;
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, box-shadow 150ms ease, transform 150ms ease;
}
.aha-help-option:hover {
transform: translateY(-2px);
border-color: rgba(251, 191, 36, 0.55);
background: rgba(32, 34, 42, 0.92);
}
.aha-help-option:focus-visible {
outline: 3px solid rgba(56, 189, 248, 0.8);
outline-offset: 2px;
}
.aha-help-option.is-selected {
border-color: rgba(251, 191, 36, 0.88);
background: linear-gradient(145deg, rgba(120, 82, 22, 0.34), rgba(37, 31, 27, 0.82));
box-shadow: 0 0 0 1px rgba(251, 191, 36, 0.18), 0 8px 24px rgba(0, 0, 0, 0.17);
}
.aha-help-option[data-kind="off"].is-selected {
border-color: rgba(148, 163, 184, 0.7);
background: linear-gradient(145deg, rgba(71, 85, 105, 0.3), rgba(24, 29, 40, 0.88));
box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.15);
}
.aha-help-option-title {
color: var(--text-highlight);
font-size: 1.05rem;
font-weight: 800;
line-height: 1.25;
}
.aha-help-option-detail {
color: var(--text-muted);
font-size: 0.82rem;
line-height: 1.45;
}
.aha-help-option-check {
position: absolute;
top: 0.8rem;
right: 0.85rem;
display: grid;
width: 25px;
height: 25px;
place-items: center;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 50%;
color: transparent;
font-size: 0.8rem;
font-weight: 900;
}
.aha-help-option.is-selected .aha-help-option-check {
border-color: #fbbf24;
background: #fbbf24;
color: #171717;
}
.aha-help-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-top: 0.15rem;
}
.aha-help-save-state {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.6rem;
color: var(--text-muted);
font-size: 0.86rem;
line-height: 1.45;
}
.aha-help-state-dot {
flex: 0 0 9px;
width: 9px;
height: 9px;
border-radius: 50%;
background: #94a3b8;
box-shadow: 0 0 9px currentColor;
}
.aha-help-save-state[data-state="saved"] .aha-help-state-dot { background: #34d399; }
.aha-help-save-state[data-state="unsaved"] .aha-help-state-dot { background: #fbbf24; }
.aha-help-save-state[data-state="error"] .aha-help-state-dot { background: #fb7185; }
.aha-help-save-state[data-state="unavailable"] .aha-help-state-dot { background: #94a3b8; }
.aha-help-save-button {
display: inline-flex;
min-height: 46px;
align-items: center;
justify-content: center;
gap: 0.55rem;
padding: 0.7rem 1.15rem;
white-space: nowrap;
}
.aha-help-native-select {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.switch {
position: relative;
display: inline-block;
@@ -903,6 +1104,11 @@ input:checked + .slider:before {
letter-spacing: 0.05em;
}
#btn-save-aha-help:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.form-input, .form-select {
width: 100%;
padding: 0.65rem 0.85rem;
@@ -2593,32 +2799,80 @@ input:checked + .slider:before {
/* ==========================================
GM Command Dispatcher & Live Log Console
========================================== */
.console-quick-pills {
.console-suggestions {
margin-top: -0.45rem;
border: 1px solid rgba(56, 189, 248, 0.25);
border-radius: 7px;
overflow: hidden;
background: #080c13;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.42);
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
.console-suggestions[hidden] {
display: none;
}
.console-suggestions-header {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: space-between;
padding: 0.32rem 0.65rem;
color: #94a3b8;
background: rgba(255, 255, 255, 0.035);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
font-size: 0.7rem;
}
.console-pill-btn {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e2e8f0;
padding: 0.3rem 0.65rem;
border-radius: 9999px;
font-size: 0.775rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
.console-suggestion-count {
color: #fbbf24;
}
.console-suggestion-option {
display: flex;
width: 100%;
align-items: center;
gap: 0.3rem;
gap: 0.75rem;
padding: 0.35rem 0.65rem;
color: #38e8f2;
text-align: left;
background: transparent;
border: 0;
cursor: pointer;
font: inherit;
}
.console-pill-btn:hover {
background: rgba(56, 189, 248, 0.15);
border-color: rgba(56, 189, 248, 0.4);
color: var(--accent-cyan);
transform: translateY(-1px);
.console-suggestion-option:hover,
.console-suggestion-option[aria-selected="true"] {
color: #fff;
background: rgba(56, 189, 248, 0.13);
}
.console-suggestion-option::before {
content: ">";
color: #34d399;
font-weight: 800;
}
.console-suggestion-command {
font-size: 0.8rem;
}
.console-suggestion-description {
margin-left: auto;
color: #64748b;
font-family: inherit;
font-size: 0.68rem;
}
.console-suggestion-option:hover .console-suggestion-description,
.console-suggestion-option[aria-selected="true"] .console-suggestion-description {
color: #94a3b8;
}
@media (max-width: 640px) {
.console-suggestion-description {
display: none;
}
}
.console-terminal-wrapper {
@@ -3209,3 +3463,286 @@ input:checked + .slider:before {
flex-shrink: 0;
opacity: 0.85;
}
.build-guide-content {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.build-guide-topline,
.build-guide-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.build-guide-title {
color: var(--text-highlight);
font-size: 1.05rem;
font-weight: 800;
}
.build-guide-meta,
.build-guide-note,
.build-guide-sample,
.build-guide-footer {
color: var(--text-muted);
font-size: 0.78rem;
}
.build-guide-note {
margin: 0;
line-height: 1.55;
}
.build-guide-sample {
display: inline-flex;
align-self: flex-start;
border: 1px solid rgba(56, 189, 248, 0.28);
border-radius: 999px;
background: rgba(56, 189, 248, 0.08);
color: #bae6fd;
padding: 0.3rem 0.7rem;
}
.build-guide-low-sample {
color: var(--accent-gold);
font-size: 0.75rem;
}
.build-guide-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.build-guide-card {
min-width: 0;
padding: 0.85rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: rgba(18, 24, 38, 0.72);
}
.build-guide-card h4 {
margin: 0 0 0.6rem;
color: var(--accent-cyan);
font-size: 0.85rem;
}
.build-guide-ranked-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
}
.build-guide-ranked-list li {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.6rem;
font-size: 0.78rem;
line-height: 1.4;
}
.build-guide-ranked-list li span {
min-width: 0;
}
.build-guide-ranked-list li strong {
flex-shrink: 0;
color: var(--accent-gold);
font-size: 0.72rem;
}
.build-guide-empty,
.build-guide-no-data {
color: var(--text-muted);
font-size: 0.78rem;
line-height: 1.5;
}
.build-guide-stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.build-guide-stat-grid > div > span {
display: block;
margin-bottom: 0.4rem;
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 700;
}
.build-guide-footer {
border-top: 1px solid var(--border-color);
padding-top: 0.65rem;
}
.build-guide-footer a:not(.btn) {
color: var(--accent-cyan);
}
@media (max-width: 640px) {
.build-guide-grid,
.build-guide-stat-grid {
grid-template-columns: minmax(0, 1fr);
}
.aha-help-actions {
align-items: stretch;
flex-direction: column;
}
.aha-help-save-button {
width: 100%;
}
}
@media (max-width: 390px) {
.aha-help-options {
grid-template-columns: minmax(0, 1fr);
}
.aha-help-heading {
gap: 0.7rem;
}
.aha-help-emblem {
flex-basis: 40px;
width: 40px;
height: 40px;
}
}
/* Anomaly Arbitration monster wave editor */
.monster-settings-intro { margin-bottom: 1rem; }
.monster-settings-rule { display: flex; gap: .65rem; align-items: flex-start; margin-top: .8rem; padding: .8rem 1rem; border: 1px solid rgba(251, 191, 36, .28); border-radius: 10px; background: rgba(245, 158, 11, .08); color: var(--text-main); line-height: 1.55; }
.monster-settings-tabs { display: flex; gap: .5rem; margin: 1rem 0; padding: .45rem; overflow-x: auto; border: 1px solid var(--border-color); border-radius: 12px; background: var(--bg-secondary); }
.monster-settings-tab { flex: 1 0 auto; min-height: 44px; padding: .65rem .9rem; border: 1px solid transparent; border-radius: 9px; background: transparent; color: var(--text-muted); font: inherit; font-weight: 700; white-space: nowrap; cursor: pointer; }
.monster-settings-tab:hover { color: var(--text-main); background: rgba(148, 163, 184, .08); }
.monster-settings-tab.active { color: var(--text-highlight); border-color: rgba(56, 189, 248, .36); background: rgba(14, 165, 233, .13); }
.monster-settings-tab-aha.active { color: #fcd34d; border-color: rgba(251, 191, 36, .45); background: rgba(245, 158, 11, .12); }
.monster-settings-status { margin: .75rem 0; padding: .8rem 1rem; border: 1px solid var(--border-color); border-radius: 10px; color: var(--text-muted); background: var(--bg-card); line-height: 1.5; }
.monster-settings-status[data-state="saved"] { color: #6ee7b7; }
.monster-settings-status[data-state="error"] { color: #fda4af; }
.monster-settings-status[data-state="loading"] { color: #7dd3fc; }
.monster-settings-view { display: grid; gap: 1rem; }
.monster-settings-picker-section { display: grid; gap: .65rem; }
.monster-settings-picker-title { margin: 0; color: var(--text-muted); font-size: .88rem; font-weight: 800; letter-spacing: .02em; }
.monster-settings-group-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 290px), 1fr)); gap: .75rem; }
.monster-settings-group-card { display: flex; min-width: 0; min-height: 78px; align-items: center; justify-content: space-between; gap: .75rem; padding: .75rem .85rem; border: 1px solid var(--border-color); border-radius: 12px; background: var(--bg-card); color: var(--text-main); text-align: left; cursor: pointer; transition: border-color 140ms ease, background 140ms ease, transform 140ms ease; }
.monster-settings-group-card:hover, .monster-settings-stage-card:hover { transform: translateY(-1px); border-color: rgba(56, 189, 248, .45); background: rgba(25, 35, 51, .95); }
.monster-settings-group-card.active { border-color: rgba(56, 189, 248, .72); background: linear-gradient(125deg, rgba(14, 165, 233, .16), rgba(15, 23, 42, .9)); box-shadow: inset 0 0 0 1px rgba(56, 189, 248, .1); }
.monster-settings-group-copy, .monster-settings-stage-copy { display: grid; gap: .3rem; min-width: 0; }
.monster-settings-group-name { color: var(--text-main); font-size: .91rem; line-height: 1.3; overflow-wrap: anywhere; }
.monster-settings-group-meta, .monster-settings-stage-meta { color: var(--text-muted); font-size: .75rem; line-height: 1.3; }
.monster-settings-monster.group-feature { flex: 0 0 56px; width: 56px; padding: 0; border: 0; background: transparent; }
.monster-settings-monster.group-feature .monster-settings-monster-portrait { width: 56px; height: 56px; flex-basis: 56px; }
.monster-settings-monster.group-feature .monster-settings-monster-copy { display: none; }
.monster-settings-stage-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 310px), 1fr)); gap: .75rem; }
.monster-settings-stage-card { display: flex; min-width: 0; min-height: 144px; align-items: center; gap: .9rem; padding: .85rem; border: 1px solid var(--border-color); border-radius: 14px; background: var(--bg-card); color: var(--text-main); text-align: left; cursor: pointer; transition: border-color 140ms ease, background 140ms ease, transform 140ms ease; }
.monster-settings-stage-card.active { border-color: rgba(56, 189, 248, .76); background: linear-gradient(125deg, rgba(14, 165, 233, .16), rgba(15, 23, 42, .94)); box-shadow: inset 0 0 0 1px rgba(56, 189, 248, .12); }
.monster-settings-stage-card.is-boss { border-color: rgba(251, 191, 36, .3); }
.monster-settings-stage-card.is-boss.active { border-color: rgba(251, 191, 36, .78); background: linear-gradient(125deg, rgba(245, 158, 11, .16), rgba(15, 23, 42, .94)); }
.monster-settings-stage-card:focus-visible, .monster-settings-group-card:focus-visible { outline: 2px solid rgba(56, 189, 248, .75); outline-offset: 2px; }
.monster-settings-monster.stage-feature { flex: 0 0 92px; width: 92px; flex-direction: column; align-items: center; padding: 0; border: 0; background: transparent; }
.monster-settings-monster.stage-feature .monster-settings-monster-portrait { width: 88px; height: 88px; flex-basis: 88px; border-radius: 12px; }
.monster-settings-monster.stage-feature .monster-settings-monster-copy { display: none; }
.monster-settings-stage-copy { flex: 1; }
.monster-settings-stage-copy strong { color: var(--text-main); font-size: .92rem; line-height: 1.28; overflow-wrap: anywhere; }
.monster-settings-stage-card.is-boss .monster-settings-stage-meta:first-of-type { color: #fcd34d; }
.monster-settings-view[hidden] { display: none; }
.monster-aa-round-control { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, .9fr); align-items: center; gap: 1.25rem; padding: 1rem 1.1rem; border: 1px solid rgba(56, 189, 248, .28); border-radius: 14px; background: linear-gradient(120deg, rgba(14, 165, 233, .1), rgba(15, 23, 42, .72)); }
.monster-aa-round-header { display: grid; gap: .6rem; min-width: 0; }
.monster-aa-round-copy h3 { margin: 0; color: var(--text-highlight); font-size: 1rem; }
.monster-aa-round-copy p { margin: .35rem 0 0; color: var(--text-muted); line-height: 1.5; }
.monster-aa-round-status { color: var(--text-muted); font-size: .88rem; line-height: 1.45; overflow-wrap: anywhere; }
.monster-aa-round-status.is-pending { color: #86efac; }
.monster-aa-round-status.is-error { color: #fda4af; }
.monster-aa-round-picker-wrap { display: grid; gap: .5rem; min-width: 0; }
.monster-aa-round-picker-label { color: var(--text-muted); font-size: .82rem; font-weight: 800; }
.monster-aa-round-picker { min-height: 46px; width: 100%; }
.monster-aa-round-picker:disabled { opacity: .55; cursor: not-allowed; }
.monster-settings-stage-heading { display: flex; align-items: center; gap: .65rem; flex-wrap: wrap; }
.monster-settings-stage-heading h3 { margin: 0; color: var(--text-highlight); font-size: 1.1rem; }
.monster-settings-badge { padding: .25rem .55rem; border: 1px solid rgba(192, 132, 252, .3); border-radius: 999px; color: #d8b4fe; background: rgba(168, 85, 247, .1); font-size: .75rem; font-weight: 700; }
.monster-settings-battle { display: grid; gap: .7rem; padding: 1rem; border: 1px solid var(--border-color); border-radius: 12px; background: var(--bg-card); }
.monster-settings-battle h4 { margin: 0; color: var(--text-main); }
.monster-settings-encounter-heading { display: flex; align-items: center; gap: .55rem; flex-wrap: wrap; }
.monster-settings-id-badge { color: var(--text-muted); font-size: .76rem; }
.monster-settings-meta { display: flex; gap: .55rem; flex-wrap: wrap; color: var(--text-muted); font-size: .8rem; }
.monster-settings-wave { display: flex; align-items: flex-start; gap: .8rem; padding: .7rem; border: 1px solid rgba(148, 163, 184, .13); border-radius: 9px; background: rgba(148, 163, 184, .035); }
.monster-settings-wave strong { flex: 0 0 85px; color: var(--text-muted); font-size: .82rem; }
.monster-settings-monsters { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 230px), 1fr)); gap: .65rem; flex: 1; min-width: 0; }
.monster-settings-monster { display: flex; align-items: center; gap: .75rem; min-width: 0; padding: .5rem .65rem; border: 1px solid rgba(56, 189, 248, .16); border-radius: 10px; background: rgba(14, 165, 233, .055); }
.monster-settings-monster-portrait { position: relative; display: grid; flex: 0 0 72px; width: 72px; height: 72px; place-items: center; overflow: hidden; border-radius: 10px; background: rgba(15, 23, 42, .8); font-size: 1.55rem; }
.monster-settings-monster-portrait img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.monster-settings-monster-copy { display: grid; gap: .18rem; min-width: 0; }
.monster-settings-monster-copy strong { overflow-wrap: anywhere; color: var(--text-main); font-size: .86rem; line-height: 1.25; }
.monster-settings-monster-copy code { width: fit-content; color: var(--text-muted); font-size: .72rem; }
.monster-settings-monster.compact { padding: .25rem; }
.monster-settings-empty { padding: 1.4rem; border: 1px dashed rgba(148, 163, 184, .3); border-radius: 12px; color: var(--text-muted); background: var(--bg-card); text-align: center; line-height: 1.6; }
.monster-settings-aha-help[hidden] { display: none; }
.monster-settings-aha-help { margin-top: 1rem; }
.monster-custom-card { display: grid; gap: .85rem; }
.monster-custom-card h3 { margin: 0; color: var(--text-highlight); }
.monster-custom-card > .card-subtitle { margin: 0; }
.monster-settings-source { color: var(--text-muted); font-size: .82rem; }
.monster-custom-wave-list { display: grid; gap: .8rem; }
.monster-custom-wave { display: grid; gap: .75rem; padding: .9rem; border: 1px solid rgba(110, 123, 148, .23); border-radius: 12px; background: rgba(9, 13, 22, .5); }
.monster-custom-wave-heading, .monster-custom-row, .monster-custom-toolbar, .monster-custom-actions, .monster-custom-save-actions { display: flex; align-items: center; gap: .65rem; }
.monster-custom-wave-heading, .monster-custom-toolbar { justify-content: space-between; }
.monster-custom-wave-heading strong { color: var(--text-highlight); }
.monster-custom-rows { display: grid; gap: .55rem; }
.monster-custom-row { align-items: flex-end; padding: .7rem; border: 1px solid rgba(110, 123, 148, .16); border-radius: 10px; background: rgba(255, 255, 255, .018); }
.monster-custom-field { display: flex; min-width: 0; flex: 1 1 0; flex-direction: column; gap: .35rem; color: var(--text-muted); font-size: .76rem; font-weight: 700; }
.monster-custom-field input { width: 100%; min-height: 42px; padding: .55rem .65rem; border: 1px solid rgba(110, 123, 148, .32); border-radius: 8px; background: rgba(7, 10, 17, .76); color: var(--text-main); }
.monster-custom-field input:focus { outline: 2px solid rgba(56, 189, 248, .55); outline-offset: 1px; }
.monster-custom-monster-preview { min-height: 62px; }
.monster-custom-monster-field { min-width: 250px; }
.monster-custom-picker { position: relative; width: 100%; }
.monster-custom-picker-results { position: absolute; z-index: 30; top: calc(100% + .35rem); left: 0; right: 0; display: grid; max-height: 340px; overflow-y: auto; padding: .35rem; border: 1px solid rgba(92, 115, 148, .55); border-radius: 12px; background: #111827; box-shadow: 0 18px 42px rgba(0, 0, 0, .5); }
.monster-custom-picker-results[hidden] { display: none; }
.monster-custom-picker-option { display: flex; width: 100%; min-width: 0; padding: .25rem; border: 0; border-radius: 8px; background: transparent; color: var(--text-main); text-align: left; cursor: pointer; }
.monster-custom-picker-option:hover, .monster-custom-picker-option:focus-visible { outline: none; background: rgba(56, 189, 248, .14); }
.monster-custom-picker-option .monster-settings-monster { width: 100%; padding: .25rem .35rem; border: 0; background: transparent; }
.monster-custom-picker-option .monster-settings-monster-portrait { flex-basis: 52px; width: 52px; height: 52px; }
.monster-custom-picker-option .monster-settings-monster-copy strong { font-size: .84rem; }
.monster-custom-picker-option .monster-settings-monster-copy code { font-size: .7rem; }
.monster-custom-monster-preview .monster-settings-monster { width: fit-content; max-width: 100%; padding: .2rem .35rem; }
.monster-custom-monster-preview .monster-settings-monster-portrait { flex-basis: 80px; width: 80px; height: 80px; }
.monster-custom-monster-preview .monster-settings-monster-copy strong { font-size: .76rem; }
.monster-custom-monster-preview .monster-settings-monster-copy code { font-size: .66rem; }
.monster-custom-remove { min-width: 42px; min-height: 42px; }
.monster-custom-add-enemy { justify-self: start; }
.monster-custom-save-actions { justify-content: flex-end; }
.monster-custom-save-actions .monster-settings-status { margin: 0; }
.monster-custom-card button:disabled, .monster-custom-card input:disabled { cursor: not-allowed; opacity: .55; }
@media (max-width: 700px) {
.monster-settings-wave { flex-direction: column; }
.monster-settings-monsters { width: 100%; }
.monster-settings-stage-grid { grid-template-columns: 1fr; }
.monster-aa-round-control { grid-template-columns: 1fr; align-items: stretch; }
.monster-custom-toolbar, .monster-custom-save-actions { align-items: stretch; flex-direction: column; }
.monster-custom-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); }
.monster-custom-save-actions .btn { width: 100%; }
.monster-custom-row { align-items: stretch; flex-wrap: wrap; }
.monster-custom-field { flex-basis: calc(50% - .65rem); }
.monster-custom-monster-field { flex-basis: 100%; }
.monster-custom-remove { margin-left: auto; }
}
@media (max-width: 390px) {
.monster-settings-wave { flex-direction: column; }
.monster-custom-field { flex-basis: 100%; }
}
+256 -112
View File
@@ -3,8 +3,9 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>[test-gay] GaC-Srtool — Ultimate Star Rail Server & Build Manager</title>
<link rel="stylesheet" href="css/styles.css?v=2.1">
<title>GaC-Srtool</title>
<link rel="stylesheet" href="css/styles.css?v=2.16">
<link rel="stylesheet" href="css/item-catalog.css?v=2">
<link rel="icon" type="image/png" href="assets/logo.png">
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
</head>
@@ -17,7 +18,7 @@
<div>
<div class="brand-title" style="display: flex; align-items: center; gap: 0.5rem;">
<span>GaC-Srtool</span>
<span class="badge-branch" id="header-branch-badge">test-gay</span>
<span class="badge-branch" id="header-branch-badge" style="display: none;"></span>
</div>
<div class="brand-subtitle" data-i18n="appSubtitle">Star Rail FreeSR & Server Controller</div>
</div>
@@ -25,26 +26,26 @@
<div class="header-controls">
<!-- Server Mode Selector -->
<div class="server-mode-badge" title="โหมดการเชื่อมต่อเซิร์ฟเวอร์">
<div class="server-mode-badge" title="โหมดการเชื่อมต่อเซิร์ฟเวอร์" data-i18n-title="lblServerModeActive">
<span>🌐</span>
<select id="server-mode-select" class="server-mode-select">
<option value="gac" selected>GaC-hkrpg-go</option>
<option value="firefly">FireflyGo</option>
<option value="robin">RobinSR</option>
<option value="firefly" disabled data-i18n="serverModeFireflyPaused">FireflyGo (กำลังพัฒนา)</option>
<option value="robin" disabled data-i18n="serverModeRobinPaused">RobinSR (กำลังพัฒนา)</option>
</select>
</div>
<!-- Language Selector (13 Languages) -->
<select id="lang-select" class="form-select btn-sm" style="width: auto; padding: 0.4rem 0.75rem; background: var(--bg-secondary); cursor: pointer;" title="Language / ภาษา">
<select id="lang-select" class="form-select btn-sm" style="width: auto; padding: 0.4rem 0.75rem; background: var(--bg-secondary); cursor: pointer;" title="Language / ภาษา" data-i18n-title="languageSelectorTitle">
</select>
<!-- Connection Pill -->
<div class="connection-pill" id="btn-open-settings" title="คลิกเพื่อตั้งค่าการเชื่อมต่อ API">
<div class="connection-pill" id="btn-open-settings" title="คลิกเพื่อตั้งค่าการเชื่อมต่อ API" data-i18n-title="apiSettingsTooltip">
<span class="status-dot" id="status-dot"></span>
<span id="connection-status-text">กำลังตรวจสอบ...</span>
</div>
<button class="btn btn-secondary btn-sm" id="btn-quick-ping" title="รีเฟรชการเชื่อมต่อ" data-i18n="refresh">
<button class="btn btn-secondary btn-sm" id="btn-quick-ping" title="รีเฟรชการเชื่อมต่อ" data-i18n="refresh" data-i18n-title="refresh">
🔄 รีเฟรช
</button>
</div>
@@ -56,16 +57,17 @@
<button class="tab-btn" data-tab="tab-avatars" data-i18n="tabAvatars">👥 ตัวละคร</button>
<button class="tab-btn" data-tab="tab-equipment" data-i18n="tabEquipment">🗡️ กรวยแสง & รีลิกส์</button>
<button class="tab-btn" data-tab="tab-inventory" data-i18n="tabInventory">🎒 ไอเทมในกระเป๋า</button>
<button class="tab-btn" data-tab="tab-monster-settings" id="tab-btn-monster-settings" data-i18n="tabMonsterSettings">👾 Monster Setting</button>
<button class="tab-btn" data-tab="tab-damage-analyst" id="tab-btn-damage-analyst" data-i18n="tabDamageAnalyst">💥 Damage Analyst</button>
<button class="tab-btn" data-tab="tab-persistent" id="tab-btn-persistent" data-i18n="tabPersistent">💾 จัดการเซฟ GaC</button>
<button class="tab-btn" data-tab="tab-exrank" id="tab-btn-exrank" data-i18n="tabExrank">⚡ สกิลพิเศษ Ex-Rank</button>
<button class="tab-btn" data-tab="tab-json" data-i18n="tabJson">📝 จัดการไฟล์ JSON</button>
<button class="tab-btn" data-tab="tab-settings" data-i18n="tabSettings">⚙️ ตั้งค่า</button>
<div class="nav-branch-pill" id="nav-branch-pill" title="Git Branch ที่ใช้งานอยู่">
<div class="nav-branch-pill" id="nav-branch-pill" title="Git Branch ที่ใช้งานอยู่" data-i18n-title="branchTooltip" style="display: none;">
<svg class="branch-icon" viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
<path fill-rule="evenodd" d="M11.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122V6A2.5 2.5 0 0110 8.5H6a1 1 0 00-1 1v1.128a2.251 2.251 0 11-1.5 0V5.372a2.25 2.25 0 111.5 0v1.836A2.492 2.492 0 016 7h4a1 1 0 001-1v-.628A2.25 2.25 0 019.5 3.25zM4.25 12a.75.75 0 100 1.5.75.75 0 000-1.5zM3.5 3.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0z"></path>
</svg>
<span id="nav-branch-name">test-gay</span>
<span id="nav-branch-name"></span>
</div>
</nav>
@@ -118,7 +120,7 @@
<div class="card">
<div class="card-title">
<span data-i18n="serverStatusTitle">📍 สถานะเซิร์ฟเวอร์ & พิกัดปัจจุบัน</span>
<span class="btn-sm btn-secondary" id="badge-version">API Live</span>
<span class="btn-sm btn-secondary" id="badge-version" data-i18n="apiLive">API Live</span>
</div>
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
<div>
@@ -154,7 +156,7 @@
<span data-i18n="miniBattleTitle">สรุปการต่อสู้ล่าสุด & ดาเมจทีม</span>
</span>
<div style="display: flex; align-items: center; gap: 0.4rem;">
<span class="badge-super-tag" id="badge-mini-battle-status" style="background: rgba(156, 163, 175, 0.12); color: var(--text-muted); font-weight: 800; border: 1px solid rgba(156, 163, 175, 0.25);">
<span class="badge-super-tag" id="badge-mini-battle-status" data-i18n="miniNoBattleTitle" style="background: rgba(156, 163, 175, 0.12); color: var(--text-muted); font-weight: 800; border: 1px solid rgba(156, 163, 175, 0.25);">
ยังไม่มีข้อมูลการต่อสู้
</span>
<button type="button" class="btn btn-sm btn-secondary" id="btn-mini-view-full-analyst" style="font-size: 0.725rem; padding: 0.2rem 0.55rem; color: var(--accent-cyan); border-color: rgba(56, 189, 248, 0.35); font-weight: 700;" title="เปิดหน้า Damage Analyst ตัวเต็ม" data-i18n="miniViewFullAnalyst">
@@ -228,34 +230,23 @@
<div class="card-title" style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<span style="display: flex; align-items: center; gap: 0.5rem;">
<span>💻</span>
<span data-i18n="consoleTitle">คอนโซลคำสั่ง GM & Log สด</span>
<span>GM Command Console & Live Log</span>
</span>
<div style="display: flex; gap: 0.35rem;">
<button type="button" class="btn btn-sm btn-secondary" id="btn-console-copy-log" title="คัดลอก Log ทั้งหมด" style="padding: 0.2rem 0.6rem; font-size: 0.75rem;" data-i18n="consoleCopyLog">
📋 คัดลอก
<button type="button" class="btn btn-sm btn-secondary" id="btn-console-copy-log" title="Copy all logs" style="padding: 0.2rem 0.6rem; font-size: 0.75rem;">
📋 Copy
</button>
<button type="button" class="btn btn-sm btn-secondary" id="btn-console-clear-log" title="ล้างหน้าจอ Terminal" style="padding: 0.2rem 0.6rem; font-size: 0.75rem;" data-i18n="consoleClearLog">
🗑️ ล้าง Log
<button type="button" class="btn btn-sm btn-secondary" id="btn-console-clear-log" title="Clear terminal log" style="padding: 0.2rem 0.6rem; font-size: 0.75rem;">
🗑️ Clear
</button>
</div>
</div>
<p class="card-subtitle" style="margin-bottom: 0.25rem;" data-i18n="consoleSubtitle">ส่งคำสั่ง GM / Server commands แบบเรียลไทม์ พร้อมติดตามสถานะและ Packet Log</p>
<!-- Quick GM Command Pills -->
<div>
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 0.35rem;" data-i18n="consoleQuickTitle">
⚡ คำสั่งด่วนที่ใช้บ่อย (Quick Commands)
</div>
<div class="console-quick-pills">
<button type="button" class="console-pill-btn" data-cmd="/heal" title="ฟื้นฟู HP/Energy ทั้งทีม" data-i18n="quickCmdHeal">💖 /heal</button>
<button type="button" class="console-pill-btn" data-cmd="/give 102 100000" title="รับ 100,000 Stellar Jades" data-i18n="quickCmdJades">💎 +100k Jades</button>
<button type="button" class="console-pill-btn" data-cmd="/give 2 10000000" title="รับ 10,000,000 Credits" data-i18n="quickCmdCredits">💰 +10M Credits</button>
<button type="button" class="console-pill-btn" data-cmd="/max" title="อัปเต็มเลเวล 80 E6 ทุกตัว" data-i18n="quickCmdMax">⚡ /max</button>
<button type="button" class="console-pill-btn" data-cmd="/teleport 20001 1" title="วาร์ปไปรถไฟ Astral Express" data-i18n="quickCmdExpress">🚂 รถไฟ Express</button>
<button type="button" class="console-pill-btn" data-cmd="/clear lineup" title="ล้างตัวละครในทีม" data-i18n="quickCmdClearLineup">🧹 เคลียร์ทีม</button>
<button type="button" class="console-pill-btn" data-cmd="/help" title="ดูรายการคำสั่งทั้งหมด" data-i18n="quickCmdHelp">❓ /help</button>
</div>
<p class="card-subtitle" style="margin-bottom: 0.25rem;">Send GM and server commands, with live status and packet logs.</p>
<div style="display:flex; align-items:center; gap:0.65rem; flex-wrap:wrap;">
<button type="button" class="btn btn-sm btn-secondary" id="btn-client-unstuck">Unstuck</button>
<span id="client-unstuck-description" style="font-size:0.75rem; color:var(--text-muted);">Clear a stuck loading spinner without restarting the game. Keeps saved CW progress.</span>
</div>
<div id="client-unstuck-status" role="status" aria-live="polite" style="font-size:0.75rem; color:var(--text-muted);"></div>
<!-- Interactive Terminal Screen -->
<div class="console-terminal-wrapper">
@@ -274,15 +265,22 @@
<form id="form-console-cmd" style="display: flex; gap: 0.5rem; align-items: center; margin-top: 0.25rem;">
<div class="console-input-wrap" style="flex: 1; display: flex; align-items: center; background: rgba(0,0,0,0.4); border: 1px solid var(--border-color); border-radius: 6px; padding: 0 0.75rem;">
<span style="color: var(--accent-cyan); font-family: monospace; font-weight: 800; font-size: 1.1rem; margin-right: 0.5rem;">&gt;</span>
<input type="text" id="console-cmd-input" class="form-input" data-i18n-placeholder="consolePlaceholder" placeholder="พิมพ์คำสั่ง GM เช่น /heal, /max, /teleport 20411, /give 102 1000..." style="background: transparent; border: none; padding: 0.6rem 0; font-family: monospace; font-size: 0.85rem; box-shadow: none;" autocomplete="off">
<input type="text" id="console-cmd-input" class="form-input" placeholder="Type a command, e.g. /heal, /teleport 20411, /give 102 1000" style="background: transparent; border: none; padding: 0.6rem 0; font-family: monospace; font-size: 0.85rem; box-shadow: none;" autocomplete="off" role="combobox" aria-autocomplete="list" aria-controls="console-suggestion-options" aria-expanded="false">
</div>
<button type="submit" class="btn btn-primary" id="btn-console-cmd-send" data-i18n="consoleSend" style="padding: 0.6rem 1.25rem; font-weight: 700; white-space: nowrap;">
ส่งคำสั่ง
<button type="submit" class="btn btn-primary" id="btn-console-cmd-send" style="padding: 0.6rem 1.25rem; font-weight: 700; white-space: nowrap;">
Run
</button>
</form>
<div id="console-command-suggestions" class="console-suggestions" hidden>
<div class="console-suggestions-header">
<span>Command suggestions</span>
<span id="console-suggestion-count" class="console-suggestion-count">‹ 0/0 ›</span>
</div>
<div id="console-suggestion-options" role="listbox" aria-label="Command suggestions"></div>
</div>
<div style="font-size: 0.725rem; color: var(--text-muted); display: flex; justify-content: space-between; align-items: center;">
<span data-i18n="consoleHistoryHint">💡 รองรับกด <kbd style="background: rgba(255,255,255,0.1); padding: 0.1rem 0.35rem; border-radius: 3px; font-family: monospace;">↑</kbd> <kbd style="background: rgba(255,255,255,0.1); padding: 0.1rem 0.35rem; border-radius: 3px; font-family: monospace;">↓</kbd> เพื่อเลือกประวัติคำสั่งก่อนหน้า</span>
<span id="lbl-console-conn-status" style="color: var(--accent-green);">● เซิร์ฟเวอร์ออนไลน์</span>
<span>Use ↑ / ↓ to browse suggestions or command history. Press Tab to complete.</span>
<span id="lbl-console-conn-status" style="color: var(--accent-green);">● Server Online</span>
</div>
</div>
</div>
@@ -355,20 +353,20 @@
<!-- Search & Filter Controls -->
<div style="display: flex; gap: 0.4rem; flex: 1; max-width: 620px; flex-wrap: wrap;">
<input type="text" id="eq-search" class="form-input" placeholder="🔍 ค้นหาชื่อหรือ ID อุปกรณ์..." style="flex: 1; min-width: 160px;">
<input type="text" id="eq-search" class="form-input" data-i18n-placeholder="equipmentSearchPlaceholder" placeholder="🔍 ค้นหาชื่อหรือ ID อุปกรณ์..." style="flex: 1; min-width: 160px;">
<select id="eq-filter-rarity" class="form-select" style="max-width: 110px;">
<option value="all" data-i18n="filterAllRarity">ทุกดาว</option>
<option value="5" data-i18n="filter5Star">5 ดาว</option>
<option value="4" data-i18n="filter4Star">4 ดาว</option>
<option value="3">3 ดาว</option>
<option value="3" data-i18n="filter3Star">3 ดาว</option>
</select>
<select id="eq-filter-equipped" class="form-select" style="max-width: 130px;">
<option value="all">สถานะทั้งหมด</option>
<option value="equipped">สวมใส่อยู่</option>
<option value="unequipped">ในกระเป๋า</option>
<option value="all" data-i18n="filterAllStatus">สถานะทั้งหมด</option>
<option value="equipped" data-i18n="filterEquipped">สวมใส่อยู่</option>
<option value="unequipped" data-i18n="filterInInventory">ในกระเป๋า</option>
</select>
<select id="eq-filter-extra" class="form-select" style="max-width: 120px;">
<option value="all">ทุกประเภท</option>
<option value="all" data-i18n="filterAllTypes">ทุกประเภท</option>
</select>
</div>
@@ -401,6 +399,43 @@
<!-- Material inventory editor: edits only Client-catalog item quantities. -->
<section id="tab-inventory" class="tab-content">
<div class="inventory-mode-switch" role="tablist" aria-label="Inventory views">
<button type="button" id="btn-inventory-catalog" class="inventory-mode-btn active" role="tab" aria-selected="true" aria-controls="item-catalog-panel" data-i18n="itemCatalogTitle">📚 สมุดรวมไอเทม</button>
<button type="button" id="btn-inventory-manage" class="inventory-mode-btn" role="tab" aria-selected="false" aria-controls="inventory-manage-panel" data-i18n="inventoryTitle">🎒 จัดการจำนวนไอเทม</button>
</div>
<div id="item-catalog-panel" class="inventory-mode-panel active" role="tabpanel">
<div class="card" style="margin-bottom:1rem;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;">
<div>
<div class="card-title" data-i18n="itemCatalogTitle">📚 สมุดรวมไอเทม</div>
<p class="card-subtitle" data-i18n="itemCatalogDescription">ค้นชื่อหรือ ID เพื่อดูรูปและรหัสจากตารางเกม</p>
</div>
<span id="item-catalog-count" class="badge-super-tag">—</span>
</div>
<div class="item-catalog-toolbar">
<input type="search" id="item-catalog-search" class="form-input" data-i18n-placeholder="itemCatalogSearchPlaceholder" placeholder="ค้นหาชื่อหรือ Item ID...">
<select id="item-catalog-filter" class="form-select" aria-label="Filter item catalog">
<option value="all" data-i18n="itemCatalogFilterAll">ทั้งหมด</option>
<option value="supported" data-i18n="itemCatalogFilterSupported">GaC รองรับ</option>
<option value="item" data-i18n="itemCatalogFilterItems">ไอเทม</option>
<option value="lightcone" data-i18n="itemCatalogFilterLightCones">กรวยแสง</option>
<option value="relic" data-i18n="itemCatalogFilterRelics">รีลิกส์</option>
</select>
</div>
<div id="item-catalog-status" role="status" aria-live="polite" style="font-size:.8rem;color:var(--text-muted);margin-top:.7rem;" data-i18n="itemCatalogLoading">กำลังโหลดสมุดไอเทม...</div>
</div>
<div class="card" style="padding:1rem 1rem 0;overflow:hidden;">
<div id="item-catalog-grid" class="item-catalog-grid">
<div class="item-catalog-empty" data-i18n="itemCatalogLoading">กำลังโหลดสมุดไอเทม...</div>
</div>
<div class="item-catalog-pagination">
<button type="button" class="btn btn-sm btn-secondary" id="item-catalog-prev" disabled data-i18n="inventoryPrevious">ก่อนหน้า</button>
<span id="item-catalog-page-label" style="font-size:.8rem;color:var(--text-muted);"></span>
<button type="button" class="btn btn-sm btn-secondary" id="item-catalog-next" disabled data-i18n="inventoryNext">ถัดไป</button>
</div>
</div>
</div>
<div id="inventory-manage-panel" class="inventory-mode-panel" role="tabpanel" hidden>
<div class="card" style="margin-bottom: 1rem;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;">
<div>
@@ -439,6 +474,57 @@
<button type="button" class="btn btn-sm btn-secondary" id="btn-inventory-next" disabled data-i18n="inventoryNext">ถัดไป</button>
</div>
</div>
</div>
</section>
<!-- Monster settings: resource-backed stage inspection plus Calyx-only custom waves. -->
<section id="tab-monster-settings" class="tab-content">
<div class="card monster-settings-intro">
<div class="card-title"><span data-i18n="monsterSettingsTitle">👾 Monster Setting</span></div>
<p class="card-subtitle" data-i18n="monsterSettingsDescription">ตรวจสอบข้อมูลด่านและคลื่นมอนจาก resource ของ Client ตามเวอร์ชัน ปรับ custom monster ได้เฉพาะ Golden / Crimson Calyx เท่านั้น</p>
<div class="monster-settings-rule" role="note">
<span aria-hidden="true">🔒</span>
<span data-i18n="monsterSettingsRule">Weekly Boss, Stagnant Shadow, Cavern of Corrosion และ Endgame ใช้ข้อมูลด่านจาก Client และแก้คลื่นมอนผ่าน Srtool ไม่ได้</span>
</div>
</div>
<div class="monster-settings-tabs" role="tablist" aria-label="Monster settings modes">
<button type="button" class="monster-settings-tab active" role="tab" aria-selected="true" data-monster-mode="moc" data-i18n="monsterModeMoc">Memory of Chaos</button>
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="pf" data-i18n="monsterModePf">Pure Fiction</button>
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="as" data-i18n="monsterModeAs">Apocalyptic Shadow</button>
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="aa" data-i18n="monsterModeAa">Anomaly Arbitration</button>
<button type="button" class="monster-settings-tab monster-settings-tab-aha" id="monster-mode-aha" role="tab" aria-selected="false" aria-controls="monster-settings-aha-help" data-monster-mode="aha" data-i18n="ahaHelpTitle">Aha's Help</button>
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="custom" data-i18n="monsterModeCustom">Custom Enemy</button>
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="su" data-i18n="monsterModeSu">Simulated Universe</button>
</div>
<div id="monster-settings-status" class="monster-settings-status" role="status" aria-live="polite" data-i18n="monsterSettingsLoading">กำลังอ่าน stage จาก resource ของ Client...</div>
<div id="monster-settings-view" class="monster-settings-view" role="tabpanel" aria-live="polite"></div>
<div id="monster-settings-aha-help" class="monster-settings-aha-help" role="tabpanel" aria-labelledby="monster-mode-aha" hidden>
<div class="aha-help-card" id="aha-help-card">
<div class="aha-help-heading">
<div class="aha-help-emblem" aria-hidden="true">✦</div>
<div class="aha-help-heading-copy">
<h3 data-i18n="ahaHelpTitle">Aha's Help</h3>
<p data-i18n="ahaHelpDescription">Choose the support level for the next battle that includes Voracity Corruption. This setting is separate from Aha Global.</p>
</div>
</div>
<div class="aha-help-options" id="aha-help-options" role="group" aria-label="Aha's Help level" aria-describedby="aha-help-status"></div>
<select id="aha-help-level" class="aha-help-native-select" aria-hidden="true" tabindex="-1" disabled>
<option value="" data-i18n="ahaHelpLoading">Loading levels…</option>
</select>
<div class="aha-help-actions">
<div class="aha-help-save-state" id="aha-help-status" role="status" aria-live="polite" data-state="loading">
<span class="aha-help-state-dot" aria-hidden="true"></span>
<span id="aha-help-status-text" data-i18n="ahaHelpLoading">Loading levels…</span>
</div>
<button type="button" class="btn btn-success aha-help-save-button" id="btn-save-aha-help" disabled>
<span aria-hidden="true">💾</span>
<span data-i18n="ahaHelpSave">Save Aha's Help</span>
</button>
</div>
</div>
</div>
</section>
<!-- 3.5. Damage Analyst (In-Game Battle Telemetry & Damage Analysis) -->
@@ -488,7 +574,7 @@
<div class="card" style="padding: 1.2rem; border-left: 4px solid #f59e0b; background: linear-gradient(135deg, rgba(245,158,11,0.06) 0%, rgba(20,20,28,0.7) 100%);">
<div style="font-size: 0.775rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase;" data-i18n="analystCycles">จำนวนรอบ (Cycles)</div>
<div style="font-size: 1.85rem; font-weight: 900; color: #fbbf24; letter-spacing: -0.5px; margin: 0.25rem 0;" id="analyst-cycles">0 รอบ</div>
<div style="font-size: 0.75rem; color: var(--text-muted);" id="analyst-cycles-note">MoC / PF / AS Standard</div>
<div style="font-size: 0.75rem; color: var(--text-muted);" id="analyst-cycles-note" data-i18n="analystCyclesNote">MoC / PF / AS Standard</div>
</div>
<div class="card" style="padding: 1.2rem; border-left: 4px solid #38bdf8; background: linear-gradient(135deg, rgba(56,189,248,0.06) 0%, rgba(20,20,28,0.7) 100%);">
@@ -587,7 +673,18 @@
<div style="display: flex; flex-direction: column; gap: 1rem; margin-top: 1rem;">
<div class="ex-switch-card">
<div>
<div style="font-weight: 700;">Silver Wolf Global Feature</div>
<div style="font-weight: 700;">Freecam / FaceCam</div>
<div style="font-size: 0.8rem; color: var(--text-muted);">เลือกโหมด แล้วกด Tab ในเกมเพื่อเปิดหรือปิดกล้อง</div>
<label for="camera-mode">โหมดกล้อง</label>
<select id="camera-mode"><option value="freecam">Freecam</option><option value="facecam">FaceCam (ทดลอง)</option></select>
<label for="camera-distance">ระยะ FaceCam</label>
<input id="camera-distance" type="number" min="0.5" max="5" step="0.1" value="2">
<button type="button" class="btn btn-success" id="btn-save-camera">บันทึกกล้อง</button>
</div>
</div>
<div class="ex-switch-card">
<div>
<div style="font-weight: 700;" data-i18n="globalFeatureSilverWolf">Silver Wolf Global Feature</div>
<div style="font-size: 0.8rem; color: var(--text-muted);" data-i18n="swGlobalDesc">เปิดใช้งานฟังก์ชันพิเศษ SW ทั่วทั้งเซิร์ฟเวอร์</div>
</div>
<label class="switch">
@@ -598,7 +695,7 @@
<div class="ex-switch-card">
<div>
<div style="font-weight: 700;">Castorice Global Feature</div>
<div style="font-weight: 700;" data-i18n="globalFeatureCastorice">Castorice Global Feature</div>
<div style="font-size: 0.8rem; color: var(--text-muted);" data-i18n="castoriceGlobalDesc">เปิดใช้งานฟังก์ชันพิเศษ Castorice ทั่วทั้งเซิร์ฟเวอร์</div>
</div>
<label class="switch">
@@ -609,7 +706,7 @@
<div class="ex-switch-card">
<div>
<div style="font-weight: 700;">Aeon ★ Aha Global Feature</div>
<div style="font-weight: 700;" data-i18n="globalFeatureAha">Aeon ★ Aha Global Feature</div>
<div style="font-size: 0.8rem; color: var(--text-muted);" data-i18n="ahaGlobalDesc">เปิดใช้งานฟังก์ชันพิเศษ Aeon ★ Aha ทั่วทั้งเซิร์ฟเวอร์</div>
</div>
<label class="switch">
@@ -624,6 +721,7 @@
</div>
</div>
</div>
</section>
<!-- 5. Ex-Rank Tab -->
@@ -690,8 +788,8 @@
<label class="form-label" data-i18n="lblServerMode">โหมดเซิร์ฟเวอร์</label>
<select id="setting-server-mode" class="form-select">
<option value="gac">GaC-hkrpg-go</option>
<option value="firefly">FireflyGo</option>
<option value="robin">RobinSR</option>
<option value="firefly" disabled data-i18n="serverModeFireflyPaused">FireflyGo (กำลังพัฒนา)</option>
<option value="robin" disabled data-i18n="serverModeRobinPaused">RobinSR (กำลังพัฒนา)</option>
</select>
</div>
@@ -727,7 +825,7 @@
<span style="font-weight: 700; font-size: 0.875rem;" data-i18n="lblPersistenceInfo">ระบบบันทึกทีมและข้อมูล (Lineup Storage)</span>
<span id="badge-persistence-mode" class="badge-super-tag" style="padding: 0.15rem 0.55rem; font-size: 0.75rem;">Persistent API (GaC)</span>
</div>
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.35rem;" id="desc-persistence-mode">
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.35rem;" id="desc-persistence-mode" data-i18n="persistentDescription">
GaC Server เก็บทีมใน persistent.json (รองรับโหมดสร้างสรรค์) • สำหรับ FireflyGo เก็บใน freesr-data.json
</div>
</div>
@@ -800,7 +898,7 @@
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem;">
<span style="color: var(--text-muted);" data-i18n="lblCacheSize">สถานะหน่วยความจำ LocalStorage:</span>
<strong id="lbl-cache-usage" style="color: var(--accent-cyan); font-family: monospace;">ตรวจวัด...</strong>
<strong id="lbl-cache-usage" style="color: var(--accent-cyan); font-family: monospace;">—</strong>
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem; flex-wrap: wrap;">
@@ -869,6 +967,9 @@
<button type="button" class="modal-subtab-btn" data-modaltab="modaltab-showcase" data-i18n="modalTabShowcase">
🖼️ การ์ดสรุปบิลด์
</button>
<button type="button" class="modal-subtab-btn" data-modaltab="modaltab-build-guide" data-i18n="modalTabBuildGuide">
📖 แนวทาง Build
</button>
</div>
<!-- Sub Tab 0: Overview & Combat Stats (Neonteam style https://srtools.neonteam.dev/1001/detail) -->
@@ -876,7 +977,7 @@
<div class="overview-layout">
<!-- Left: Big portrait art & character identity -->
<div class="overview-portrait-box">
<img id="overview-portrait-img" class="overview-portrait-img" src="" alt="Character Art">
<img id="overview-portrait-img" class="overview-portrait-img" src="" alt="Character Art" data-i18n-alt="characterArtwork">
<div class="overview-portrait-info">
<div id="overview-char-name" style="font-size: 1.35rem; font-weight: 800; color: #fff;">-</div>
<div id="overview-char-meta" style="display: flex; gap: 0.4rem; align-items: center; margin-top: 0.35rem; flex-wrap: wrap;"></div>
@@ -903,10 +1004,10 @@
<!-- Quick Equipped Equipment Glance -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem;">
<!-- LC Glance -->
<div class="card" id="overview-lc-card" style="background: rgba(18, 24, 38, 0.7); border: 1px solid var(--border-color); padding: 0.85rem; cursor: pointer; transition: all 0.2s;" title="คลิกเพื่อจัดการ Light Cone">
<div class="card" id="overview-lc-card" style="background: rgba(18, 24, 38, 0.7); border: 1px solid var(--border-color); padding: 0.85rem; cursor: pointer; transition: all 0.2s;" title="คลิกเพื่อจัดการ Light Cone" data-i18n-title="overviewEquippedLc">
<div style="font-size: 0.75rem; color: var(--text-muted); font-weight: 700; margin-bottom: 0.5rem; display: flex; justify-content: space-between;">
<span data-i18n="overviewEquippedLc">🗡️ กรวยแสงที่สวมใส่</span>
<span style="color: var(--accent-cyan); font-size: 0.7rem;">จัดการ ➔</span>
<span style="color: var(--accent-cyan); font-size: 0.7rem;" data-i18n="btnManage">จัดการ ➔</span>
</div>
<div style="display: flex; gap: 0.65rem; align-items: center;">
<img id="overview-lc-thumb" src="" alt="" style="width: 44px; height: 60px; border-radius: 4px; object-fit: cover; border: 1px solid var(--border-color);">
@@ -918,10 +1019,10 @@
</div>
<!-- Relics Glance -->
<div class="card" id="overview-relics-card" style="background: rgba(18, 24, 38, 0.7); border: 1px solid var(--border-color); padding: 0.85rem; cursor: pointer; transition: all 0.2s;" title="คลิกเพื่อจัดการรีลิกส์">
<div class="card" id="overview-relics-card" style="background: rgba(18, 24, 38, 0.7); border: 1px solid var(--border-color); padding: 0.85rem; cursor: pointer; transition: all 0.2s;" title="คลิกเพื่อจัดการรีลิกส์" data-i18n-title="overviewEquippedRelics">
<div style="font-size: 0.75rem; color: var(--text-muted); font-weight: 700; margin-bottom: 0.5rem; display: flex; justify-content: space-between;">
<span data-i18n="overviewEquippedRelics">🛡️ รีลิกส์และเซ็ต</span>
<span style="color: var(--accent-cyan); font-size: 0.7rem;">จัดการ ➔</span>
<span style="color: var(--accent-cyan); font-size: 0.7rem;" data-i18n="btnManage">จัดการ ➔</span>
</div>
<div id="overview-relics-summary" style="display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.75rem;">
<!-- Set bonuses active -->
@@ -954,10 +1055,10 @@
</div>
<!-- Equipped Lightcone Slot in Equipment Tab -->
<div class="card" id="relics-equipped-lc-box" style="background: rgba(18, 24, 38, 0.75); border: 1px solid var(--border-color); padding: 0.85rem 1rem; margin-bottom: 1rem; cursor: pointer; transition: all 0.2s;" title="คลิกเพื่อเลือกเปลี่ยน Light Cone">
<div class="card" id="relics-equipped-lc-box" style="background: rgba(18, 24, 38, 0.75); border: 1px solid var(--border-color); padding: 0.85rem 1rem; margin-bottom: 1rem; cursor: pointer; transition: all 0.2s;" title="คลิกเพื่อเลือกเปลี่ยน Light Cone" data-i18n-title="overviewEquippedLc">
<div style="font-size: 0.75rem; color: var(--text-muted); font-weight: 700; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--accent-cyan); font-weight: 800;">🗡️ กรวยแสงที่สวมใส่ (คลิกเพื่อเลือกเปลี่ยน)</span>
<span style="color: var(--accent-gold); font-size: 0.75rem; font-weight: 700;">เปลี่ยนกรวยแสง ➔</span>
<span style="color: var(--accent-cyan); font-weight: 800;" data-i18n="overviewEquippedLc">🗡️ กรวยแสงที่สวมใส่ (คลิกเพื่อเลือกเปลี่ยน)</span>
<span style="color: var(--accent-gold); font-size: 0.75rem; font-weight: 700;" data-i18n="btnChangeLc">เปลี่ยนกรวยแสง ➔</span>
</div>
<div style="display: flex; gap: 0.85rem; align-items: center;">
<img id="relics-tab-lc-thumb" src="" alt="" style="width: 46px; height: 64px; border-radius: 6px; object-fit: cover; border: 1px solid var(--accent-gold);">
@@ -974,7 +1075,7 @@
<span id="relics-tab-lc-stats" style="color: var(--accent-cyan); font-family: monospace;">HP 952 / ATK 582 / DEF 529</span>
</div>
</div>
<button type="button" class="btn btn-sm btn-primary" style="pointer-events: none;">เลือกเปลี่ยน</button>
<button type="button" class="btn btn-sm btn-primary" style="pointer-events: none;" data-i18n="btnChangeLc">เลือกเปลี่ยน</button>
</div>
</div>
@@ -1011,7 +1112,7 @@
<div class="skill-row-card">
<div class="skill-row-header">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<img id="skill-icon-basic" class="skill-row-icon" src="" alt="Basic ATK">
<img id="skill-icon-basic" class="skill-row-icon" src="" alt="Basic ATK" data-i18n-alt="modalSkillBasic">
<div>
<div style="display: flex; align-items: center; gap: 0.4rem;">
<span class="skill-type-tag" data-i18n="modalSkillBasic">โจมตีปกติ</span>
@@ -1034,7 +1135,7 @@
<div class="skill-row-card">
<div class="skill-row-header">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<img id="skill-icon-skill" class="skill-row-icon" src="" alt="Skill">
<img id="skill-icon-skill" class="skill-row-icon" src="" alt="Skill" data-i18n-alt="modalSkillSkill">
<div>
<div style="display: flex; align-items: center; gap: 0.4rem;">
<span class="skill-type-tag" data-i18n="modalSkillSkill">สกิลต่อสู้</span>
@@ -1057,7 +1158,7 @@
<div class="skill-row-card">
<div class="skill-row-header">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<img id="skill-icon-ultra" class="skill-row-icon" src="" alt="Ultimate">
<img id="skill-icon-ultra" class="skill-row-icon" src="" alt="Ultimate" data-i18n-alt="modalSkillUltra">
<div>
<div style="display: flex; align-items: center; gap: 0.4rem;">
<span class="skill-type-tag" data-i18n="modalSkillUltra">ท่าไม้ตาย</span>
@@ -1080,7 +1181,7 @@
<div class="skill-row-card">
<div class="skill-row-header">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<img id="skill-icon-talent" class="skill-row-icon" src="" alt="Talent">
<img id="skill-icon-talent" class="skill-row-icon" src="" alt="Talent" data-i18n-alt="modalSkillTalent">
<div>
<div style="display: flex; align-items: center; gap: 0.4rem;">
<span class="skill-type-tag" data-i18n="modalSkillTalent">พรสวรรค์</span>
@@ -1110,7 +1211,7 @@
<div class="skill-row-card" style="padding: 0.75rem 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<img id="skill-icon-technique" class="skill-row-icon" src="" alt="Technique">
<img id="skill-icon-technique" class="skill-row-icon" src="" alt="Technique" data-i18n-alt="modalTraceTechnique">
<div>
<span class="skill-type-tag" data-i18n="modalTraceTechnique">เทคนิค</span>
<span id="skill-real-name-technique" class="skill-name">-</span>
@@ -1129,8 +1230,8 @@
<div class="trace-card-item">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<img id="trace-icon-a2" class="trace-icon-small" src="" alt="A2">
<span id="trace-name-a2" style="font-weight: 700; font-size: 0.875rem;">🌟 ร่องรอยพิเศษ A2</span>
<img id="trace-icon-a2" class="trace-icon-small" src="" alt="A2" data-i18n-alt="traceA2">
<span id="trace-name-a2" style="font-weight: 700; font-size: 0.875rem;" data-i18n="traceA2">🌟 ร่องรอยพิเศษ A2</span>
</div>
<label class="switch">
<input type="checkbox" id="check-trace-a2" checked>
@@ -1143,8 +1244,8 @@
<div class="trace-card-item">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<img id="trace-icon-a4" class="trace-icon-small" src="" alt="A4">
<span id="trace-name-a4" style="font-weight: 700; font-size: 0.875rem;">🌟 ร่องรอยพิเศษ A4</span>
<img id="trace-icon-a4" class="trace-icon-small" src="" alt="A4" data-i18n-alt="traceA4">
<span id="trace-name-a4" style="font-weight: 700; font-size: 0.875rem;" data-i18n="traceA4">🌟 ร่องรอยพิเศษ A4</span>
</div>
<label class="switch">
<input type="checkbox" id="check-trace-a4" checked>
@@ -1157,8 +1258,8 @@
<div class="trace-card-item">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<img id="trace-icon-a6" class="trace-icon-small" src="" alt="A6">
<span id="trace-name-a6" style="font-weight: 700; font-size: 0.875rem;">🌟 ร่องรอยพิเศษ A6</span>
<img id="trace-icon-a6" class="trace-icon-small" src="" alt="A6" data-i18n-alt="traceA6">
<span id="trace-name-a6" style="font-weight: 700; font-size: 0.875rem;" data-i18n="traceA6">🌟 ร่องรอยพิเศษ A6</span>
</div>
<label class="switch">
<input type="checkbox" id="check-trace-a6" checked>
@@ -1227,9 +1328,9 @@
<div class="lc-preview-container">
<!-- Left: Full vertical artwork card -->
<div class="lc-art-wrapper" id="modal-lc-art-wrapper">
<img id="modal-lc-full-img" class="lc-full-img" src="" alt="Light Cone Artwork">
<img id="modal-lc-full-img" class="lc-full-img" src="" alt="Light Cone Artwork" data-i18n-alt="lightConeArtwork">
<div id="modal-lc-no-art" class="lc-placeholder-text" style="display: none;">
🗡️ ไม่มีรูปตัวอย่าง
<span data-i18n="lightConeArtworkMissing">🗡️ ไม่มีรูปตัวอย่าง</span>
</div>
</div>
@@ -1294,7 +1395,7 @@
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
<div style="font-weight: 800; font-size: 1rem; color: var(--text-highlight); display: flex; align-items: center; gap: 0.5rem;">
<span data-i18n="modalTabShowcase">🖼️ การ์ดแสดงผล</span>
<span class="btn-sm btn-secondary" id="showcase-build-tag">Build Overview</span>
<span class="btn-sm btn-secondary" id="showcase-build-tag" data-i18n="overviewStatsTitle">Build Overview</span>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button type="button" class="btn btn-sm btn-gold" id="btn-showcase-auto-relics" data-i18n="showcaseAutoRelics">
@@ -1317,6 +1418,11 @@
</div>
</div>
<!-- Sub Tab 5: Per-character build data and Prydwen guide -->
<div id="modaltab-build-guide" class="modal-subtab-content" style="display: none;">
<div id="build-guide-content" class="build-guide-content"></div>
</div>
<!-- Modal Footer -->
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem; justify-content: flex-end; flex-wrap: wrap;">
<button type="button" class="btn btn-secondary" id="btn-modal-cancel-avatar" data-i18n="modalBtnClose">
@@ -1379,7 +1485,7 @@
<img id="add-lc-preview-thumb" src="" alt="" style="width: 44px; height: 60px; border-radius: 4px; object-fit: cover; border: 1px solid var(--accent-gold); background: #0b0d14; flex-shrink: 0;">
<div style="flex: 1; min-width: 0;">
<div style="font-size: 0.7rem; color: var(--accent-gold); font-weight: 800; text-transform: uppercase;">
✓ กรวยแสงที่เลือก (Selected)
<span data-i18n="selectedLightConeLabel">✓ กรวยแสงที่เลือก (Selected)</span>
</div>
<div id="add-lc-preview-name" style="font-weight: 800; font-size: 0.95rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">-</div>
<div id="add-lc-preview-meta" style="display: flex; gap: 0.65rem; font-size: 0.72rem; color: var(--text-muted); margin-top: 0.15rem; align-items: center; flex-wrap: wrap;">
@@ -1392,7 +1498,7 @@
<!-- 2. Search & Filter Bar -->
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.6rem; flex-wrap: wrap;">
<input type="text" id="add-lc-search" class="form-input" placeholder="🔍 ค้นหากรวยแสง (ชื่อหรือ ID)..." style="flex: 1; min-width: 160px;">
<input type="text" id="add-lc-search" class="form-input" data-i18n-placeholder="lightConeSearchPlaceholder" placeholder="🔍 ค้นหากรวยแสง (ชื่อหรือ ID)..." style="flex: 1; min-width: 160px;">
<select id="add-lc-filter-path" class="form-select" style="max-width: 140px;">
<option value="all">ทุกสาย (All)</option>
<option value="Destruction">ทำลายล้าง</option>
@@ -1406,10 +1512,10 @@
<option value="Elation">ปีติ</option>
</select>
<select id="add-lc-filter-rarity" class="form-select" style="max-width: 110px;">
<option value="all">ทุกระดับดาว</option>
<option value="5" selected>★5 ดาว</option>
<option value="4">★4 ดาว</option>
<option value="3">★3 ดาว</option>
<option value="all" data-i18n="filterAllRarity">ทุกระดับดาว</option>
<option value="5" selected data-i18n="filter5Star">★5 ดาว</option>
<option value="4" data-i18n="filter4Star">★4 ดาว</option>
<option value="3" data-i18n="filter3Star">★3 ดาว</option>
</select>
</div>
@@ -1429,13 +1535,13 @@
<input type="number" id="add-lc-promotion" class="form-input" min="0" max="6" value="6" required>
</div>
<div class="form-group" style="margin-bottom: 0;">
<label class="form-label" style="font-size: 0.75rem;">Superimpose (ขัดเกลา)</label>
<label class="form-label" data-i18n="superimpositionLabel" style="font-size: 0.75rem;">Superimpose (ขัดเกลา)</label>
<select id="add-lc-rank" class="form-select" required>
<option value="5" selected>S5 (ขัดเกลา 5)</option>
<option value="4">S4 (ขัดเกลา 4)</option>
<option value="3">S3 (ขัดเกลา 3)</option>
<option value="2">S2 (ขัดเกลา 2)</option>
<option value="1">S1 (ขัดเกลา 1)</option>
<option value="5" selected data-superimposition-rank>S5 (ขัดเกลา 5)</option>
<option value="4" data-superimposition-rank>S4 (ขัดเกลา 4)</option>
<option value="3" data-superimposition-rank>S3 (ขัดเกลา 3)</option>
<option value="2" data-superimposition-rank>S2 (ขัดเกลา 2)</option>
<option value="1" data-superimposition-rank>S1 (ขัดเกลา 1)</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 0;">
@@ -1474,12 +1580,12 @@
<div class="form-group" style="margin-bottom: 0;">
<label class="form-label" data-i18n="lblRelicSlot">ชิ้นส่วน (Slot)</label>
<select id="add-relic-slot" class="form-select" required>
<option value="1">1: ส่วนหัว (Head / HP)</option>
<option value="2">2: ส่วนมือ (Hands / ATK)</option>
<option value="3">3: ลำตัว (Body)</option>
<option value="4">4: รองเท้า (Feet)</option>
<option value="5">5: ลูกแก้ว (Planar Sphere)</option>
<option value="6">6: สร้อยเชื่อมต่อ (Link Rope)</option>
<option value="1" data-relic-slot="head">1: ส่วนหัว (Head / HP)</option>
<option value="2" data-relic-slot="hands">2: ส่วนมือ (Hands / ATK)</option>
<option value="3" data-relic-slot="body">3: ลำตัว (Body)</option>
<option value="4" data-relic-slot="feet">4: รองเท้า (Feet)</option>
<option value="5" data-relic-slot="sphere">5: ลูกแก้ว (Planar Sphere)</option>
<option value="6" data-relic-slot="rope">6: สร้อยเชื่อมต่อ (Link Rope)</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 0;">
@@ -1513,12 +1619,12 @@
<div class="modal-content relic-picker-modal-content">
<div class="modal-header" style="margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<img id="modal-slot-picker-icon" src="" alt="Slot" style="width: 28px; height: 28px; object-fit: contain;">
<img id="modal-slot-picker-icon" src="" alt="Slot" data-i18n-alt="slot" style="width: 28px; height: 28px; object-fit: contain;">
<div>
<h3 id="modal-slot-picker-title" style="font-weight: 800; font-size: 1.15rem; margin: 0;" data-i18n="slotPickerTitle">
🛡️ เลือกสวมใส่รีลิกส์
</h3>
<div id="modal-slot-picker-subtitle" style="font-size: 0.75rem; color: var(--text-muted); margin-top: 2px;">
<div id="modal-slot-picker-subtitle" style="font-size: 0.75rem; color: var(--text-muted); margin-top: 2px;" data-i18n="slotPickerSubtitle">
เลือกสวมใส่ชิ้นที่มีอยู่ หรือสร้างชิ้นใหม่ตรงตามบิลด์ที่แนะนำ
</div>
</div>
@@ -1567,15 +1673,15 @@
<!-- Substats Customizer -->
<div class="slot-picker-substats-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap; gap: 0.5rem;">
<div style="font-weight: 700; font-size: 0.85rem; color: var(--text-highlight);">
<div style="font-weight: 700; font-size: 0.85rem; color: var(--text-highlight);" data-i18n="substatCustomizerTitle">
🔧 ปรับแต่งออฟรอง (Substats Customizer):
</div>
<div class="substat-presets-strip">
<button type="button" class="btn-substat-preset" data-preset="god" title="ออฟแนะนำตามตัวละคร">⭐ ออฟเทพแนะนำ</button>
<button type="button" class="btn-substat-preset" data-preset="crit" title="เน้นคริล้วน">🎯 คริ Rate/DMG</button>
<button type="button" class="btn-substat-preset" data-preset="spd" title="เน้นความเร็ว">⚡ สปีด SPD</button>
<button type="button" class="btn-substat-preset" data-preset="break" title="เน้นทำลายล้าง">🔨 Break Effect</button>
<button type="button" class="btn-substat-preset" data-preset="tank" title="เน้นถึก ป้องกัน เลือด">🛡️ ถึก HP/DEF</button>
<button type="button" class="btn-substat-preset" data-preset="god" data-i18n="substatPresetRecommended" data-i18n-title="substatPresetRecommended" title="ออฟแนะนำตามตัวละคร">⭐ ออฟเทพแนะนำ</button>
<button type="button" class="btn-substat-preset" data-preset="crit" data-i18n="substatPresetCrit" data-i18n-title="substatPresetCrit" title="เน้นคริล้วน">🎯 คริ Rate/DMG</button>
<button type="button" class="btn-substat-preset" data-preset="spd" data-i18n="substatPresetSpeed" data-i18n-title="substatPresetSpeed" title="เน้นความเร็ว">⚡ สปีด SPD</button>
<button type="button" class="btn-substat-preset" data-preset="break" data-i18n="substatPresetBreak" data-i18n-title="substatPresetBreak" title="เน้นทำลายล้าง">🔨 Break Effect</button>
<button type="button" class="btn-substat-preset" data-preset="tank" data-i18n="substatPresetTank" data-i18n-title="substatPresetTank" title="เน้นถึก ป้องกัน เลือด">🛡️ ถึก HP/DEF</button>
</div>
</div>
@@ -1585,9 +1691,9 @@
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 0.65rem; padding: 0.4rem 0.75rem; background: rgba(10, 14, 23, 0.75); border-radius: var(--radius-sm); border: 1px solid var(--border-color); font-size: 0.775rem;">
<span style="color: var(--text-muted);">
💡 รวมจำนวนโรล (Rolls): <strong id="slot-picker-total-rolls" style="color: var(--accent-gold); font-family: monospace; font-size: 0.9rem;">9</strong> / 9 โรล (ระดับ 5★ เลเวล 15)
<span data-i18n="substatRollCountLabel">💡 รวมจำนวนโรล (Rolls):</span> <strong id="slot-picker-total-rolls" style="color: var(--accent-gold); font-family: monospace; font-size: 0.9rem;">9</strong> <span id="slot-picker-roll-limit" data-i18n="substatRollLimit">/ 9 โรล (ระดับ 5★ เลเวล 15)</span>
</span>
<button type="button" class="btn btn-sm btn-secondary" id="btn-reset-substats" style="font-size: 0.725rem; padding: 0.2rem 0.6rem;">↺ คืนค่าเดิม</button>
<button type="button" class="btn btn-sm btn-secondary" id="btn-reset-substats" data-i18n="substatResetButton" style="font-size: 0.725rem; padding: 0.2rem 0.6rem;">↺ คืนค่าเดิม</button>
</div>
</div>
@@ -1621,7 +1727,7 @@
<!-- Search & Filters -->
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem; flex-wrap: wrap;">
<input type="text" id="lc-picker-search" class="form-input" placeholder="🔍 ค้นหาชื่อหรือ ID กรวยแสง..." style="flex: 1; min-width: 160px;">
<input type="text" id="lc-picker-search" class="form-input" data-i18n-placeholder="lightConeSearchPlaceholder" placeholder="🔍 ค้นหาชื่อหรือ ID กรวยแสง..." style="flex: 1; min-width: 160px;">
<select id="lc-picker-filter-path" class="form-select" style="max-width: 140px;">
<option value="all">ทุกสาย</option>
<option value="same" selected>ตรงสายตัวละคร</option>
@@ -1635,12 +1741,12 @@
<option value="Remembrance">ความทรงจำ</option>
<option value="Elation">ปีติ</option>
</select>
<select id="lc-picker-rank" class="form-select" style="max-width: 110px;" title="ระดับการขัดเกลาเมื่อสวมใส่">
<option value="5" selected>ขัดเกลา S5</option>
<option value="4">ขัดเกลา S4</option>
<option value="3">ขัดเกลา S3</option>
<option value="2">ขัดเกลา S2</option>
<option value="1">ขัดเกลา S1</option>
<select id="lc-picker-rank" class="form-select" style="max-width: 110px;" data-i18n-title="superimpositionSelectTitle" title="ระดับการขัดเกลาเมื่อสวมใส่">
<option value="5" selected data-superimposition-rank>ขัดเกลา S5</option>
<option value="4" data-superimposition-rank>ขัดเกลา S4</option>
<option value="3" data-superimposition-rank>ขัดเกลา S3</option>
<option value="2" data-superimposition-rank>ขัดเกลา S2</option>
<option value="1" data-superimposition-rank>ขัดเกลา S1</option>
</select>
</div>
@@ -1669,10 +1775,48 @@
</div>
</div>
<dialog id="item-catalog-detail" class="item-catalog-dialog" aria-labelledby="item-catalog-detail-name">
<div class="item-catalog-detail-shell">
<header class="item-catalog-detail-header">
<span data-i18n="itemCatalogDetailTitle">Item Details</span>
<button type="button" class="item-catalog-dialog-close" id="item-catalog-detail-close" data-i18n-aria-label="itemCatalogClose" aria-label="Close">×</button>
</header>
<div class="item-catalog-detail-body">
<div class="item-catalog-detail-art" id="item-catalog-detail-art"></div>
<div class="item-catalog-detail-copy">
<h2 id="item-catalog-detail-name"></h2>
<div class="item-catalog-detail-id" id="item-catalog-detail-id"></div>
<div class="item-catalog-detail-source"><span data-i18n="itemCatalogSourceTable">Source tables</span>: <span id="item-catalog-detail-source"></span></div>
<div class="item-catalog-detail-meta" id="item-catalog-detail-meta"></div>
<section class="item-catalog-detail-section">
<h3 data-i18n="itemCatalogItemDescription">Description</h3>
<div class="item-catalog-detail-description" id="item-catalog-detail-description"></div>
</section>
<section class="item-catalog-detail-section item-catalog-detail-lore" id="item-catalog-detail-lore-section" hidden>
<h3 data-i18n="itemCatalogLore">Background</h3>
<div id="item-catalog-detail-lore"></div>
</section>
</div>
</div>
<div class="item-catalog-detail-quantity">
<label for="item-catalog-detail-amount" data-i18n="itemCatalogQuantity">Quantity</label>
<div class="item-catalog-amount-stepper">
<button type="button" id="item-catalog-amount-minus" data-i18n-aria-label="itemCatalogDecrease" aria-label="Decrease quantity">−</button>
<input type="number" id="item-catalog-detail-amount" inputmode="numeric" min="1" max="1" value="1">
<button type="button" id="item-catalog-amount-plus" data-i18n-aria-label="itemCatalogIncrease" aria-label="Increase quantity">+</button>
</div>
<span id="item-catalog-amount-limit" class="item-catalog-amount-limit"></span>
</div>
<footer class="item-catalog-detail-footer">
<button type="button" class="btn btn-secondary" id="item-catalog-detail-done" data-i18n="itemCatalogClose">Close</button>
</footer>
</div>
</dialog>
<!-- Toast Notification Container -->
<div class="toast-container" id="toast-container"></div>
<!-- Script Modules -->
<script type="module" src="js/app.js?v=2.1"></script>
<script type="module" src="js/app.js?v=3.3"></script>
</body>
</html>
+1513 -262
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2268 -2
View File
File diff suppressed because it is too large. Load diff
+285
View File
@@ -0,0 +1,285 @@
import { getCurrentLanguage, t, translatePath } from './i18n.js?v=2.8';
const CATALOG_URL = new URL('../assets/item-catalog.json?v=2', import.meta.url);
const PAGE_SIZE = 48;
let catalog = null;
let catalogLoading = null;
let page = 0;
let activeDetailId = null;
const elements = () => ({
tab: document.querySelector('.tab-btn[data-tab="tab-inventory"]'),
browseButton: document.getElementById('btn-inventory-catalog'),
manageButton: document.getElementById('btn-inventory-manage'),
catalogPanel: document.getElementById('item-catalog-panel'),
managePanel: document.getElementById('inventory-manage-panel'),
search: document.getElementById('item-catalog-search'),
filter: document.getElementById('item-catalog-filter'),
grid: document.getElementById('item-catalog-grid'),
status: document.getElementById('item-catalog-status'),
count: document.getElementById('item-catalog-count'),
pageLabel: document.getElementById('item-catalog-page-label'),
previous: document.getElementById('item-catalog-prev'),
next: document.getElementById('item-catalog-next'),
detail: document.getElementById('item-catalog-detail'),
detailArt: document.getElementById('item-catalog-detail-art'),
detailName: document.getElementById('item-catalog-detail-name'),
detailId: document.getElementById('item-catalog-detail-id'),
detailSource: document.getElementById('item-catalog-detail-source'),
detailMeta: document.getElementById('item-catalog-detail-meta'),
detailDescription: document.getElementById('item-catalog-detail-description'),
detailLore: document.getElementById('item-catalog-detail-lore'),
detailLoreSection: document.getElementById('item-catalog-detail-lore-section'),
amount: document.getElementById('item-catalog-detail-amount'),
amountLimit: document.getElementById('item-catalog-amount-limit')
});
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
})[char]);
}
function plainGameText(value) {
return String(value ?? '').replace(/<[^>]*>/g, '').replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\\t/g, ' ').trim();
}
function displayName(item) {
const names = item.names || {};
return plainGameText(names[getCurrentLanguage()] || names.en || t('itemCatalogUnknownName', { id: item.id }));
}
function assetUrl(path, base) {
if (typeof path !== 'string' || !/^(icon|image)\/[A-Za-z0-9_./-]+\.png$/.test(path) || path.includes('..')) return '';
try { return new URL(path, base).href; } catch { return ''; }
}
function itemCategoryLabel(category) {
const key = {
item: 'itemCatalogFilterItems',
lightcone: 'itemCatalogFilterLightCones',
relic: 'itemCatalogFilterRelics'
}[category];
return key ? t(key) : t('itemCatalogFilterItems');
}
function visibleItems() {
if (!catalog) return [];
const query = String(elements().search?.value || '').trim().toLocaleLowerCase();
const category = elements().filter?.value || 'all';
return catalog.items.filter(item => {
if (category === 'supported' && !item.supported) return false;
if (category !== 'all' && category !== 'supported' && item.category !== category) return false;
if (!query) return true;
if (String(item.id).includes(query)) return true;
return Object.values(item.names || {}).some(name => plainGameText(name).toLocaleLowerCase().includes(query));
});
}
function imageMarkup(item, source) {
const src = assetUrl(item.icon, source.assetBaseUrl);
if (!src) {
return '<button type="button" class="item-catalog-art-button" data-item-catalog-id="' + item.id + '" aria-label="' + escapeHtml(t('itemCatalogOpenDetails', { name: displayName(item) })) + '"><span class="item-catalog-art item-catalog-art-missing" title="' + escapeHtml(t('itemCatalogNoImage')) + '"><span aria-hidden="true">✦</span><small>' + escapeHtml(t('itemCatalogNoImage')) + '</small></span></button>';
}
const fallback = assetUrl(item.icon, source.assetFallbackBaseUrl);
return '<button type="button" class="item-catalog-art-button" data-item-catalog-id="' + item.id + '" aria-label="' + escapeHtml(t('itemCatalogOpenDetails', { name: displayName(item) })) + '"><span class="item-catalog-art"><img class="item-catalog-image" src="' + escapeHtml(src) + '" data-fallback="' + escapeHtml(fallback) + '" alt="" loading="lazy" decoding="async"></span></button>';
}
function localizedItemText(item, field) {
const values = item[field] || {};
return values[getCurrentLanguage()] || values.en || '';
}
function openItemDetails(itemId) {
const el = elements();
const item = catalog?.items.find(row => row.id === Number(itemId));
if (!item || !el.detail) return;
activeDetailId = item.id;
const name = displayName(item);
const src = assetUrl(item.icon, catalog.source.assetBaseUrl);
const fallback = assetUrl(item.icon, catalog.source.assetFallbackBaseUrl);
const description = localizedItemText(item, 'descriptions');
const lore = localizedItemText(item, 'backgroundDescriptions');
const rarity = Number(item.rarity);
const rarityText = Number.isInteger(rarity) && rarity > 0 && rarity <= 5 ? '★'.repeat(rarity) : '';
const category = itemCategoryLabel(item.category);
const type = [item.type, item.subType].filter((value, index, values) => value && values.indexOf(value) === index).join(' · ');
const path = item.path ? translatePath(item.path) : '';
const slot = item.slot ? translatePath(item.slot) : '';
const meta = [category, type, slot, path, rarityText].filter(Boolean).join(' · ');
el.detailName.textContent = name;
el.detailId.textContent = t('itemCatalogItemId', { id: item.id });
el.detailSource.textContent = (item.sourceTables || []).map(path => path.split('/').pop()).join(', ') || t('itemCatalogNoDetails');
el.detailMeta.textContent = meta || t('itemCatalogNoDetails');
el.detailMeta.dataset.rarity = rarityText ? String(rarity) : '';
el.detailDescription.textContent = plainGameText(description || lore || t('itemCatalogNoDescription'));
el.detailLoreSection.hidden = !description || !lore || description === lore;
el.detailLore.textContent = el.detailLoreSection.hidden ? '' : plainGameText(lore);
if (src) {
el.detailArt.innerHTML = '<img src="' + escapeHtml(src) + '" data-fallback="' + escapeHtml(fallback) + '" alt="' + escapeHtml(name) + '">';
const image = el.detailArt.querySelector('img');
image.addEventListener('error', () => {
if (image.dataset.fallback && !image.dataset.fallbackTried) {
image.dataset.fallbackTried = 'true';
image.src = image.dataset.fallback;
} else {
el.detailArt.textContent = t('itemCatalogNoImage');
}
});
} else {
el.detailArt.textContent = t('itemCatalogNoImage');
}
const limit = Number(item.pileLimit);
const hasLimit = Number.isSafeInteger(limit) && limit > 0;
el.amount.min = '1';
if (hasLimit) {
el.amount.max = String(limit);
el.amountLimit.textContent = t('itemCatalogMaxQuantity', { count: limit.toLocaleString() });
} else {
el.amount.removeAttribute('max');
el.amountLimit.textContent = '';
}
el.amount.value = '1';
if (!el.detail.open) el.detail.showModal();
}
function updateSelectedAmount(delta = 0) {
const input = elements().amount;
if (!input) return;
const max = Number(input.max);
const hasMax = Number.isSafeInteger(max) && max > 0;
const current = Number.parseInt(input.value, 10);
const next = Math.max(1, (Number.isFinite(current) ? current : 1) + delta);
input.value = String(hasMax ? Math.min(max, next) : next);
}
function renderCatalog() {
const el = elements();
if (!el.grid || !catalog) return;
el.filter.setAttribute('aria-label', t('itemCatalogFilterLabel'));
const filtered = visibleItems();
const pages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
page = Math.min(page, pages - 1);
const start = page * PAGE_SIZE;
const rows = filtered.slice(start, start + PAGE_SIZE);
el.count.textContent = t('inventoryItemTotal', { count: filtered.length });
el.pageLabel.textContent = t('inventoryPage', { page: page + 1, pages, count: filtered.length });
el.previous.disabled = page === 0;
el.next.disabled = page >= pages - 1;
if (!rows.length) {
el.grid.innerHTML = '<div class="item-catalog-empty">' + escapeHtml(t('inventoryNoMatch')) + '</div>';
return;
}
el.grid.innerHTML = rows.map(item => {
const name = displayName(item);
const category = itemCategoryLabel(item.category);
const subtype = item.slot || item.subType || item.type || '';
const path = item.path ? translatePath(item.path) : '';
const rarity = Number(item.rarity);
const rarityText = Number.isInteger(rarity) && rarity > 0 && rarity <= 5 ? '★'.repeat(rarity) : '';
const details = [category, subtype, path, rarityText].filter(Boolean).join(' · ');
const supportText = item.supported ? t('itemCatalogSupportedTag') : t('itemCatalogBrowseOnlyTag');
return '<article class="item-catalog-card">' +
imageMarkup(item, catalog.source) +
'<div class="item-catalog-info">' +
'<div class="item-catalog-name" title="' + escapeHtml(name) + '">' + escapeHtml(name) + '</div>' +
'<div class="item-catalog-id">ID ' + item.id + '</div>' +
'<div class="item-catalog-details" title="' + escapeHtml(details) + '">' + escapeHtml(details) + '</div>' +
'<span class="item-catalog-support ' + (item.supported ? 'is-supported' : 'is-browse-only') + '">' + escapeHtml(supportText) + '</span>' +
'</div>' +
'</article>';
}).join('');
el.grid.querySelectorAll('.item-catalog-image').forEach(image => {
image.addEventListener('error', () => {
if (image.dataset.fallback && !image.dataset.fallbackTried) {
image.dataset.fallbackTried = 'true';
image.src = image.dataset.fallback;
return;
}
const placeholder = document.createElement('div');
placeholder.className = 'item-catalog-art item-catalog-art-missing';
placeholder.title = t('itemCatalogNoImage');
const mark = document.createElement('span');
mark.setAttribute('aria-hidden', 'true');
mark.textContent = '✦';
const label = document.createElement('small');
label.textContent = t('itemCatalogNoImage');
placeholder.append(mark, label);
image.parentElement.replaceWith(placeholder);
});
});
el.grid.querySelectorAll('[data-item-catalog-id]').forEach(button => {
button.addEventListener('click', () => openItemDetails(button.dataset.itemCatalogId));
});
}
async function loadCatalog() {
const el = elements();
if (catalog) return catalog;
if (catalogLoading) return catalogLoading;
el.status.textContent = t('itemCatalogLoading');
catalogLoading = fetch(CATALOG_URL).then(response => {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
}).then(data => {
if (!Array.isArray(data.items) || !data.source?.assetBaseUrl) throw new Error('Invalid catalog data');
catalog = data;
el.status.textContent = t('inventoryItemTotal', { count: data.counts.total });
renderCatalog();
return catalog;
}).catch(error => {
el.status.textContent = t('itemCatalogLoadFailed') + ': ' + error.message;
el.grid.innerHTML = '<div class="item-catalog-empty">' + escapeHtml(t('itemCatalogLoadFailed')) + '</div>';
catalogLoading = null;
throw error;
});
return catalogLoading;
}
function setInventoryView(view) {
const el = elements();
const showCatalog = view === 'catalog';
el.catalogPanel.hidden = !showCatalog;
el.managePanel.hidden = showCatalog;
el.browseButton.classList.toggle('active', showCatalog);
el.manageButton.classList.toggle('active', !showCatalog);
el.browseButton.setAttribute('aria-selected', String(showCatalog));
el.manageButton.setAttribute('aria-selected', String(!showCatalog));
if (showCatalog) loadCatalog().catch(() => {});
}
export function initializeItemCatalog() {
const el = elements();
el.search?.addEventListener('input', () => { page = 0; renderCatalog(); });
el.filter?.addEventListener('change', () => { page = 0; renderCatalog(); });
el.previous?.addEventListener('click', () => { page = Math.max(0, page - 1); renderCatalog(); });
el.next?.addEventListener('click', () => { page += 1; renderCatalog(); });
document.getElementById('item-catalog-detail-close')?.addEventListener('click', () => el.detail?.close());
document.getElementById('item-catalog-detail-done')?.addEventListener('click', () => el.detail?.close());
el.detail?.addEventListener('close', () => { activeDetailId = null; });
document.getElementById('item-catalog-amount-minus')?.addEventListener('click', () => updateSelectedAmount(-1));
document.getElementById('item-catalog-amount-plus')?.addEventListener('click', () => updateSelectedAmount(1));
el.amount?.addEventListener('change', () => {
const value = Number.parseInt(el.amount.value, 10);
const max = Number(el.amount.max);
el.amount.value = String(Math.max(1, Number.isFinite(value) ? (Number.isSafeInteger(max) && max > 0 ? Math.min(max, value) : value) : 1));
});
el.detail?.addEventListener('click', event => {
if (event.target === el.detail) el.detail.close();
});
el.tab?.addEventListener('click', () => { if (!el.catalogPanel.hidden) loadCatalog().catch(() => {}); });
el.browseButton?.addEventListener('click', () => setInventoryView('catalog'));
el.manageButton?.addEventListener('click', () => setInventoryView('manage'));
document.getElementById('lang-select')?.addEventListener('change', () => {
if (catalog) renderCatalog();
else if (el.status) el.status.textContent = t('itemCatalogLoading');
if (catalog && el.detail?.open && activeDetailId !== null) openItemDetails(activeDetailId);
});
}
+66
View File
@@ -0,0 +1,66 @@
import { getCurrentLanguage } from './i18n.js?v=2.8';
const strings = {
en: {
button: 'Unstuck',
description: 'Clear a stuck loading spinner without restarting the game. Keeps saved CW progress.',
offline: 'Connect to the GaC server first.',
queued: 'Recovery queued. Waiting for the game…',
completed: 'The game cleared its network wait. Try closing or retrying the page.',
delayed: 'No acknowledgement from the game yet. Recovery is still queued; the game must be connected.',
failed: 'The game could not clear the wait.',
unsupported: 'This server does not support Unstuck yet.'
},
th: {
button: 'Unstuck / แก้ค้าง',
description: 'ปลดตัวหมุนค้างโดยไม่ต้องออกเกม และเก็บเซฟรอบ CW ไว้',
offline: 'เชื่อมต่อเซิร์ฟเวอร์ GaC ก่อน',
queued: 'ส่งคำสั่งแล้ว กำลังรอเกมตอบกลับ…',
completed: 'เกมปลดการรอแพ็กเก็ตแล้ว ลองปิดหน้าต่างหรือกดใหม่ได้',
delayed: 'เกมยังไม่ตอบรับ คำสั่งยังอยู่ในคิวและต้องเชื่อมต่อเกมไว้',
failed: 'เกมปลดการรอไม่สำเร็จ',
unsupported: 'เซิร์ฟเวอร์นี้ยังไม่รองรับ Unstuck'
}
};
export function initializeUnstuck({ apiCall, isConnected, showToast }) {
const button = document.getElementById('btn-client-unstuck');
const description = document.getElementById('client-unstuck-description');
const status = document.getElementById('client-unstuck-status');
if (!button) return;
const text = () => strings[getCurrentLanguage()] || strings.en;
const translate = () => {
button.textContent = text().button;
if (description) description.textContent = text().description;
};
translate();
document.getElementById('lang-select')?.addEventListener('change', translate);
button.addEventListener('click', async () => {
if (!isConnected()) { showToast(text().offline, 'error'); return; }
button.disabled = true;
try {
const result = await apiCall('/api/client/unstuck', 'POST', {});
if (!result?.request_id || result.state !== 'queued') throw new Error(text().unsupported);
if (status) status.textContent = text().queued;
for (let attempt = 0; attempt < 16; attempt++) {
await new Promise(resolve => setTimeout(resolve, 750));
const acknowledgement = await apiCall('/api/client/unstuck');
if (acknowledgement?.request_id !== result.request_id) continue;
if (acknowledgement.state === 'completed') {
if (status) status.textContent = text().completed;
showToast(text().completed, 'success');
return;
}
if (acknowledgement.state === 'failed') {
throw new Error(acknowledgement.error || text().failed);
}
}
if (status) status.textContent = text().delayed;
} catch (error) {
if (status) status.textContent = error.message;
showToast(error.message, 'error');
} finally {
button.disabled = false;
}
});
}
+1
View File
@@ -5,6 +5,7 @@
"main": "index.html",
"scripts": {
"sync-branch": "node scripts/sync-branch.js",
"sync-build-guides": "node scripts/sync-build-guides.mjs",
"dev": "node scripts/sync-branch.js && npx serve . -l 5173",
"start": "node scripts/sync-branch.js && npx serve . -l 5173",
"serve": "npx serve . -l 5173"
+13
View File
@@ -0,0 +1,13 @@
// Only the static web application is published; repository tools and local
// configuration are not copied to the public deployment.
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const output = path.join(root, 'dist');
fs.mkdirSync(output, { recursive: true });
for (const name of ['index.html', 'version.json', 'assets', 'css', 'js']) {
const source = path.join(root, name);
if (!fs.existsSync(source)) throw new Error(`Missing static application input: ${name}`);
fs.cpSync(source, path.join(output, name), { recursive: true, force: true });
}
console.log('Static application prepared for Vercel.');
+151
View File
@@ -0,0 +1,151 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const avatarSource = await readFile(resolve(repoRoot, 'js/avatars-data.js'), 'utf8');
const metadataMatch = avatarSource.match(/export const AVATAR_METADATA = (\{[\s\S]*?\n\});/);
if (!metadataMatch) throw new Error('Could not read AVATAR_METADATA from js/avatars-data.js');
const avatarMetadata = JSON.parse(metadataMatch[1]);
const headers = { 'User-Agent': 'GaC-Srtool build guide data sync' };
const treeResponse = await fetch('https://huggingface.co/api/datasets/LvlUrArti/MocDataProcessed/tree/main?recursive=true', { headers });
if (!treeResponse.ok) throw new Error(`Dataset file listing failed: HTTP ${treeResponse.status}`);
const files = await treeResponse.json();
const versions = [...new Set(files
.filter(file => file.type === 'directory' && /^\d+\.\d+\.\d+$/.test(file.path))
.map(file => file.path))]
.sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
const version = versions.at(-1);
if (!version) throw new Error('No versioned build snapshot was found in the dataset');
const [configResponse, buildsResponse] = await Promise.all([
fetch('https://huggingface.co/datasets/LvlUrArti/MocDataProcessed/resolve/main/config.json', { headers }),
fetch(`https://huggingface.co/datasets/LvlUrArti/MocDataProcessed/resolve/main/${version}/builds.json`, { headers })
]);
if (!configResponse.ok || !buildsResponse.ok) {
throw new Error(`Could not download snapshot ${version} (config ${configResponse.status}, builds ${buildsResponse.status})`);
}
const [config, sourceBuilds] = await Promise.all([configResponse.json(), buildsResponse.json()]);
const snapshot = config[version];
if (!snapshot?.collect_date || !Array.isArray(sourceBuilds)) {
throw new Error(`Snapshot ${version} is missing its date or build rows`);
}
const slugOverrides = {
'1001': 'march-7th',
'1112': 'topaz',
'1213': 'imbibitor-lunae',
'1225': 'tingyun-fugue',
'1224': 'march-7th-swordmaster',
'1413': 'march-7th-evernight',
'1414': 'dan-heng-permansor-terrae',
'1506': 'silver-wolf-lv-999',
'1507': 'blade-mortenax',
'1510': 'himeko-nova',
'1511': 'aeon-aha',
'1512': 'robin-summeretto',
'1513': 'aventurine-waveflair',
'8001': 'trailblazer-destruction',
'8002': 'trailblazer-destruction',
'8003': 'trailblazer-preservation',
'8004': 'trailblazer-preservation',
'8005': 'trailblazer-harmony',
'8006': 'trailblazer-harmony',
'8007': 'trailblazer-remembrance',
'8008': 'trailblazer-remembrance',
'8009': 'trailblazer-elation',
'8010': 'trailblazer-elation'
};
function slugify(name = '') {
return name.toLowerCase()
.replace(/•/g, ' ')
.replace(/&/g, ' ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
function rankedValues(row, namePrefix, usagePrefix, count = 4) {
const values = [];
for (let index = 1; index <= count; index += 1) {
const name = String(row[`${namePrefix}${index}`] || '').trim();
const usage = Number(row[`${usagePrefix}${index}_app`] ?? row[`${namePrefix}${index}_app`] ?? 0);
if (name && usage > 0) values.push({ name, usage: Math.round(usage * 100) / 100 });
}
return values.sort((left, right) => right.usage - left.usage);
}
function rankedStats(row, statPrefix) {
const stats = [];
for (let index = 1; index <= 3; index += 1) {
const name = String(row[`${statPrefix}_stats_${index}`] || '').trim();
const usage = Number(row[`${statPrefix}_stats_${index}_app`] || 0);
if (name && usage > 0) stats.push({ name, usage: Math.round(usage * 100) / 100 });
}
return stats.sort((left, right) => right.usage - left.usage);
}
const endgameModes = [
['moc', 'Memory of Chaos'],
['pf', 'Pure Fiction'],
['as', 'Apocalyptic Shadow'],
['aa', 'Anomaly Arbitration']
];
const getLargestSample = row => endgameModes.reduce((best, [key, label]) => {
const count = Number(row[`sample_size_players_${key}`] || 0);
return count > best.count ? { count, mode: label } : best;
}, { count: 0, mode: '' });
// The source file includes current and comparison rows with duplicate slugs; keep
// the row with the largest current endgame sample instead of letting the last
// comparison row silently replace it.
const sourceBySlug = new Map();
for (const row of sourceBuilds) {
const previous = sourceBySlug.get(row.char);
if (!previous || getLargestSample(row).count > getLargestSample(previous).count) {
sourceBySlug.set(row.char, row);
}
}
const profiles = {};
const missingSlugs = new Set();
for (const [avatarId, metadata] of Object.entries(avatarMetadata)) {
const slug = slugOverrides[avatarId] || slugify(metadata.names?.en);
const row = sourceBySlug.get(slug);
if (!row) {
missingSlugs.add(slug);
continue;
}
profiles[avatarId] = {
slug,
...getLargestSample(row),
lightCones: rankedValues(row, 'weapons_', 'weapons_', 5),
relicSets: rankedValues(row, 'artifacts_', 'artifacts_', 5),
planarSets: rankedValues(row, 'planars_', 'planars_', 5),
mainStats: {
body: rankedStats(row, 'body'),
feet: rankedStats(row, 'feet'),
sphere: rankedStats(row, 'sphere'),
rope: rankedStats(row, 'rope')
}
};
}
const output = {
source: {
dataset: 'LvlUrArti/MocDataProcessed',
license: 'MIT',
credit: 'LvlUrArti',
version,
collectedAt: snapshot.collect_date,
note: 'Prydwen endgame build usage sample; not a curated or official ranking.'
},
profiles
};
const outputPath = resolve(repoRoot, 'js/build-guides-data.js');
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `// Generated by scripts/sync-build-guides.mjs; see README for source and attribution.\nexport const PRYDWEN_BUILD_GUIDES = ${JSON.stringify(output, null, 2)};\n`, 'utf8');
console.log(`Wrote ${Object.keys(profiles).length} avatar profiles from snapshot ${version} (${snapshot.collect_date}).`);
if (missingSlugs.size) console.log(`No dataset row for: ${[...missingSlugs].sort().join(', ')}`);
+34
View File
@@ -0,0 +1,34 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const source = fs.readFileSync(path.join(__dirname, '../js/app.js'), 'utf8');
const start = source.indexOf('function orderBattleReportAvatars(');
const end = source.indexOf('\nfunction renderDamageAnalyst(', start);
const context = { state: { statusData: { lineups: { 0: 30, 1: 20, 2: 10 } } } };
vm.createContext(context);
vm.runInContext(source.slice(start, end), context);
test('battle slots override a different overworld lineup without mutating the report', () => {
const avatars = [{ avatar_id: 20, slot: 2 }, { avatar_id: 30, slot: 0 }, { avatar_id: 10, slot: 1 }];
const result = context.orderBattleReportAvatars(avatars);
assert.deepEqual(Array.from(result, a => a.avatar_id), [30, 10, 20]);
assert.deepEqual(avatars.map(a => a.avatar_id), [20, 30, 10]);
});
test('historical reports keep recorded order when battle slots are absent', () => {
const result = context.orderBattleReportAvatars([{ avatar_id: 10 }, { avatar_id: 30 }, { avatar_id: 20 }]);
assert.deepEqual(Array.from(result, a => a.avatar_id), [10, 30, 20]);
});
test('Other includes unclassified and missing category damage exactly once', () => {
const result = context.damageBreakdownForAvatar({ total_damage: 1000, total_break_damage: 100,
attack_type_damage: { Normal: 100, BPSkill: 200, Ultra: 100, Insert: 50, TrueDamage: 150 } });
assert.equal(result.other, 500);
assert.equal(result.normal + result.skill + result.ultimate + result.breakDamage + result.other, 1000);
});
test('Other never becomes negative when the native breakdown overlaps', () => {
assert.equal(context.damageBreakdownForAvatar({total_damage: 10, attack_type_damage: {Normal: 20}}).other, 0);
});
+68
View File
@@ -0,0 +1,68 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const catalogPath = path.join(__dirname, '..', 'assets', 'item-catalog.json');
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
test('item book matches its pinned game and image catalog revisions', () => {
assert.equal(catalog.source.gameRevision, '312b4597691e0a29cf9828a15b592757dba86949');
assert.equal(catalog.source.assetRevision, 'dbe8cdfcfb0bf657b9fe1cf92d5537f118ccb487');
assert.equal(catalog.items.length, catalog.counts.total);
assert.equal(new Set(catalog.items.map(item => item.id)).size, catalog.items.length);
assert.equal(catalog.items.filter(item => item.supported).length, catalog.counts.supported);
assert.equal(catalog.items.filter(item => item.icon).length, catalog.counts.withImages);
assert.equal(catalog.items.filter(item => !item.icon).length, catalog.counts.withoutConfiguredImage);
assert.equal(catalog.counts.total, 5414);
assert.equal(catalog.counts.definitionRows, 5472);
assert.equal(catalog.counts.candidateTables, 26);
assert.equal(catalog.counts.definitionTables, 18);
assert.equal(catalog.counts.supported, 3550);
assert.equal(catalog.counts.withImages, 5261);
assert.equal(catalog.source.gameTables.length, catalog.counts.definitionTables);
assert.ok(catalog.items.some(item => item.sourceTables.length > 1), 'overlapping IDs retain every source table');
});
test('catalog image paths are safe and all visible catalog strings are translated', async () => {
for (const item of catalog.items) {
assert.ok(['item', 'lightcone', 'relic'].includes(item.category), `invalid category for ${item.id}`);
assert.ok(item.sourceTables.length > 0, `missing source table for ${item.id}`);
assert.deepEqual(Object.keys(item.names).sort(), [...catalog.source.languages].sort());
assert.ok(item.gameIconPaths.every(path => typeof path === 'string' && path.length > 0));
if (item.icon) {
assert.match(item.icon, /^(icon|image)\/[A-Za-z0-9_./-]+\.png$/);
assert.ok(!item.icon.includes('..'), `unsafe icon path for ${item.id}`);
}
for (const textMapField of ['descriptions', 'backgroundDescriptions']) {
assert.ok(Object.keys(item[textMapField]).every(language => catalog.source.languages.includes(language)));
assert.ok(Object.values(item[textMapField]).every(value => typeof value === 'string'));
}
}
const { TRANSLATIONS } = await import('../js/i18n.js');
const languages = catalog.source.languages;
assert.deepEqual(Object.keys(TRANSLATIONS).sort(), [...languages].sort());
const catalogKeys = Object.keys(TRANSLATIONS.en).filter(key => key.startsWith('itemCatalog'));
assert.ok(catalogKeys.length > 0);
for (const language of languages) {
for (const key of catalogKeys) {
assert.ok(TRANSLATIONS[language][key], `missing ${key} translation for ${language}`);
}
}
});
test('item detail dialog supports inspection and local quantity selection without a spawn action', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
const dialog = html.match(/<dialog id="item-catalog-detail"[\s\S]*?<\/dialog>/)?.[0];
assert.ok(dialog, 'item detail dialog exists outside the hidden tab content');
assert.match(dialog, /<input[^>]*id="item-catalog-detail-amount"[^>]*type="number"|<input[^>]*type="number"[^>]*id="item-catalog-detail-amount"/);
assert.match(dialog, /id="item-catalog-detail-description"/);
assert.match(dialog, /id="item-catalog-detail-source"/);
assert.doesNotMatch(dialog, /spawn|summon|เสก|เพิ่มเข้ากระเป๋า/i);
const moduleSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'item-catalog.js'), 'utf8');
assert.match(moduleSource, /showModal\(\)/);
assert.match(moduleSource, /item\.pileLimit/);
assert.doesNotMatch(moduleSource, /apiRequest|method\s*:\s*['"]POST/i);
});
+437
View File
@@ -0,0 +1,437 @@
#!/usr/bin/env python3
"""Build the read-only item encyclopedia from pinned game and image data."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
GAME_PROJECT = "Dimbreath/turnbasedgamedata"
ASSET_PROJECT = "Mar-7th/StarRailRes"
ASSET_LANGUAGES = {
"th": "th", "en": "en", "zh-CN": "cn", "zh-TW": "cht", "ja": "jp",
"ko": "kr", "vi": "vi", "id": "id", "es": "es", "fr": "fr",
"de": "de", "ru": "ru", "pt": "pt",
}
TEXTMAP_TOKENS = {
"th": ("TH",), "en": ("EN",), "zh-CN": ("CHS",), "zh-TW": ("CHT",),
"ja": ("JP",), "ko": ("KR",), "vi": ("VI",), "id": ("ID",),
"es": ("ES",), "fr": ("FR",), "de": ("DE",), "ru": ("RU",), "pt": ("PT",),
}
TEXT_FIELDS = ("ItemName", "ItemDesc", "ItemBGDesc")
ICON_FIELDS = ("ItemIconPath", "ItemCurrencyIconPath", "ItemAvatarIconPath", "ItemFigureIconPath")
def request_bytes(url: str, attempts: int = 4) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": "GaC-Srtool-item-catalog/1.0"})
last_error = None
for attempt in range(attempts):
try:
with urllib.request.urlopen(request, timeout=90) as response:
return response.read()
except (OSError, urllib.error.URLError) as error:
last_error = error
if attempt + 1 < attempts:
time.sleep(1.5 * (attempt + 1))
raise RuntimeError(f"Could not download {url}: {last_error}")
def request_json(url: str):
return json.loads(request_bytes(url))
def cache_path(cache_dir: Path, key: str) -> Path:
return cache_dir / (re.sub(r"[^A-Za-z0-9_.-]+", "_", key) + ".json")
def cached_json(url: str, cache_dir: Path, key: str):
path = cache_path(cache_dir, key)
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
payload = request_bytes(url)
temporary = path.with_suffix(".tmp")
temporary.write_bytes(payload)
temporary.replace(path)
return json.loads(path.read_bytes())
def gitlab_api(project: str, endpoint: str) -> str:
return f"https://gitlab.com/api/v4/projects/{urllib.parse.quote(project, safe='')}/{endpoint}"
def gitlab_raw(project: str, revision: str, file_path: str) -> str:
encoded = urllib.parse.quote(file_path, safe="")
return gitlab_api(project, f"repository/files/{encoded}/raw?ref={revision}")
def discover_files(project: str, revision: str, directory: str) -> list[str]:
names: list[str] = []
page = 1
while True:
query = urllib.parse.urlencode({
"ref": revision, "path": directory, "recursive": "true", "per_page": 100, "page": page,
})
response = request_json(gitlab_api(project, f"repository/tree?{query}"))
names.extend(entry["path"] for entry in response if entry.get("type") == "blob")
if len(response) < 100:
break
page += 1
if page > 100:
raise RuntimeError(f"Unexpectedly large file listing under {directory}")
return sorted(names)
def fetch_game_json_files(paths: list[str], revision: str, cache_dir: Path) -> dict[str, object]:
result: dict[str, object] = {}
def load_one(path: str):
return path, cached_json(gitlab_raw(GAME_PROJECT, revision, path), cache_dir, f"game_{path}")
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(load_one, path) for path in paths]
for future in as_completed(futures):
path, value = future.result()
result[path] = value
return result
def parse_rarity(value, index_row=None) -> int | None:
if isinstance(value, int):
return value if 1 <= value <= 5 else None
if index_row and isinstance(index_row.get("rarity"), int):
rarity = index_row["rarity"]
return rarity if 1 <= rarity <= 5 else None
rarity = str(value or "").lower()
for label, number in (("superrare", 5), ("veryrare", 5), ("rare", 4), ("notnormal", 3), ("normal", 2), ("low", 1)):
if label in rarity:
return number
return None
def item_hash(row: dict, field: str):
value = row.get(field)
if isinstance(value, dict):
value = value.get("Hash")
if value is None or value == "":
return None
return str(value)
def row_image_paths(row: dict) -> list[str]:
values = []
for field in ICON_FIELDS:
value = row.get(field)
if isinstance(value, str) and value and value not in values:
values.append(value.replace("\\", "/").lstrip("/"))
return values
def rows_preference(path: str) -> tuple[int, str]:
base = Path(path).name
if base == "ItemConfig.json":
return 0, path
if base in ("ItemConfigEquipment.json", "ItemConfigRelic.json"):
return 1, path
return 2, path
def resolve_source_icon(game_paths: list[str], category: str, item_id: int, indexes: dict, asset_files: set[str]) -> str | None:
index_group = {"item": "items", "lightcone": "light_cones", "relic": "relics"}[category]
index_row = indexes.get("en", {}).get(index_group, {}).get(str(item_id))
if isinstance(index_row, dict):
icon = index_row.get("icon")
if isinstance(icon, str) and icon in asset_files:
return icon
for source_path in game_paths:
candidates: list[str] = []
if source_path.startswith("SpriteOutput/ItemIcon/LightConeIcons/"):
candidates.append(f"icon/light_cone/{item_id}.png")
if source_path.startswith("SpriteOutput/ItemIcon/RelicIcons/"):
relic_row = indexes.get("en", {}).get("relics", {}).get(str(item_id))
if isinstance(relic_row, dict):
candidates.append(relic_row.get("icon", ""))
if source_path.startswith("SpriteOutput/ItemIcon/"):
suffix = source_path.removeprefix("SpriteOutput/ItemIcon/")
candidates.extend((f"icon/item/{suffix}", f"icon/{suffix}"))
if source_path.startswith("SpriteOutput/AvatarIcon/Avatar/"):
suffix = source_path.removeprefix("SpriteOutput/AvatarIcon/Avatar/")
candidates.extend((f"image/character_portrait/{suffix}", f"icon/avatar/{suffix}"))
if source_path.startswith("SpriteOutput/AvatarRoundIcon/Avatar/"):
suffix = source_path.removeprefix("SpriteOutput/AvatarRoundIcon/Avatar/")
candidates.append(f"icon/avatar/{suffix}")
if source_path.startswith("SpriteOutput/AvatarShopIcon/Avatar/"):
suffix = source_path.removeprefix("SpriteOutput/AvatarShopIcon/Avatar/")
candidates.extend((f"icon/avatar/{suffix}", f"image/character_portrait/{suffix}"))
if source_path.startswith("SpriteOutput/AvatarShopIcon/AvatarSkin/"):
suffix = source_path.removeprefix("SpriteOutput/AvatarShopIcon/AvatarSkin/")
candidates.extend((f"image/character_portrait/{suffix}", f"icon/item/Skin/{suffix}"))
if source_path.startswith("SpriteOutput/ItemIcon/Skin/"):
suffix = source_path.removeprefix("SpriteOutput/ItemIcon/Skin/")
candidates.extend((f"icon/item/Skin/{suffix}", f"icon/item/{suffix}"))
if source_path.startswith("SpriteOutput/ItemFigures/"):
suffix = source_path.removeprefix("SpriteOutput/ItemFigures/")
candidates.extend((f"image/item/{suffix}", f"image/items/{suffix}"))
for candidate in candidates:
if candidate and candidate in asset_files:
return candidate
return None
def read_asset_indexes(revision: str, cache_dir: Path, asset_files: set[str]) -> dict[str, dict[str, dict]]:
base = f"https://raw.githubusercontent.com/{ASSET_PROJECT}/{revision}/index_new"
kinds = ("items", "light_cones", "relics")
indexes: dict[str, dict[str, dict]] = {}
jobs = {}
with ThreadPoolExecutor(max_workers=12) as pool:
for language, asset_language in ASSET_LANGUAGES.items():
indexes[language] = {}
for kind in kinds:
url = f"{base}/{asset_language}/{kind}.json"
key = f"starrailres_{asset_language}_{kind}"
jobs[pool.submit(cached_json, url, cache_dir, key)] = (language, kind)
for future in as_completed(jobs):
language, kind = jobs[future]
try:
data = future.result()
except RuntimeError:
# Some locales omit a data family; localized text maps fill its names.
data = {}
if not isinstance(data, dict):
data = {}
indexes[language][kind] = data
# The pinned repository tree is the authority for whether an icon URL exists.
for language in indexes:
for kind, table in indexes[language].items():
for row in table.values():
icon = row.get("icon") if isinstance(row, dict) else None
if icon and icon not in asset_files:
row.pop("icon", None)
return indexes
def textmap_files_by_language(paths: list[str]) -> dict[str, list[str]]:
result = {}
for language, tokens in TEXTMAP_TOKENS.items():
matched = []
for path in paths:
name = Path(path).name.upper()
if not name.startswith("TEXTMAP") or not name.endswith(".JSON"):
continue
token = name[len("TEXTMAP"):-len(".JSON")].split("_")[0]
if token in tokens:
matched.append(path)
result[language] = sorted(matched)
return result
def localized_textmaps(paths: list[str], revision: str, cache_dir: Path, wanted_hashes: set[str]) -> dict[str, dict[str, str]]:
maps: dict[str, dict[str, str]] = {language: {} for language in TEXTMAP_TOKENS}
by_language = textmap_files_by_language(paths)
for language, files in by_language.items():
if not files or not wanted_hashes:
continue
print(f"Reading {language} text maps ({len(files)} file(s))", flush=True)
for path in files:
data = cached_json(gitlab_raw(GAME_PROJECT, revision, path), cache_dir, f"game_{path}")
if isinstance(data, dict):
for key in wanted_hashes:
value = data.get(key)
if isinstance(value, str) and value:
maps[language][key] = value
del data
print(f" localized strings: {len(maps[language])}", flush=True)
return maps
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--inventory-config", required=True, type=Path, help="Go resource InventoryItemConfig.json")
parser.add_argument("--output", type=Path, default=Path("assets/item-catalog.json"))
parser.add_argument("--cache-dir", type=Path, default=Path(tempfile.gettempdir()) / "gac-srtool-item-catalog-cache")
args = parser.parse_args()
inventory = json.loads(args.inventory_config.read_text(encoding="utf-8"))
game_revision = inventory["source"]["revision"]
supported_ids = {int(value) for value in inventory["item_ids"]}
supported_lcs = {int(row["id"]) for row in inventory.get("lightcones", [])}
supported_relics = {int(row["relic_id"]) for row in inventory.get("relics", [])}
supported_ids.update(supported_lcs)
supported_ids.update(supported_relics)
asset_revision = "dbe8cdfcfb0bf657b9fe1cf92d5537f118ccb487"
print("Discovering game item tables...", flush=True)
excel_files = discover_files(GAME_PROJECT, game_revision, "ExcelOutput")
candidates = [path for path in excel_files if "ItemConfig" in Path(path).name and path.lower().endswith(".json")]
tables = fetch_game_json_files(candidates, game_revision, args.cache_dir)
item_rows: dict[int, list[tuple[str, dict]]] = {}
table_scan = []
for path in sorted(candidates):
data = tables[path]
valid_rows = [row for row in data if isinstance(row, dict) and isinstance(row.get("ID"), int) and row.get("ID") > 0 and isinstance(row.get("ItemMainType"), str)] if isinstance(data, list) else []
table_scan.append({"table": path, "rows": len(data) if isinstance(data, list) else 0, "itemRows": len(valid_rows)})
for row in valid_rows:
item_rows.setdefault(row["ID"], []).append((path, row))
definition_tables = [row["table"] for row in table_scan if row["itemRows"]]
if not item_rows:
raise RuntimeError("No item definition rows were found in ItemConfig tables")
print(f"Accepted {len(definition_tables)} tables; {sum(row['itemRows'] for row in table_scan)} rows; {len(item_rows)} unique IDs", flush=True)
print("Reading pinned asset index...", flush=True)
tree_url = f"https://api.github.com/repos/{ASSET_PROJECT}/git/trees/{asset_revision}?recursive=1"
tree = request_json(tree_url)
if tree.get("truncated"):
raise RuntimeError("Pinned asset tree was truncated; refusing incomplete image coverage")
asset_files = {entry["path"] for entry in tree.get("tree", []) if entry.get("type") == "blob"}
indexes = read_asset_indexes(asset_revision, args.cache_dir, asset_files)
hashes = set()
for rows in item_rows.values():
for _, row in rows:
for field in TEXT_FIELDS:
value = item_hash(row, field)
if value:
hashes.add(value)
print(f"Resolving {len(hashes)} game text keys across 13 locales...", flush=True)
textmap_paths = discover_files(GAME_PROJECT, game_revision, "TextMap")
textmaps = localized_textmaps(textmap_paths, game_revision, args.cache_dir, hashes)
existing_item_names = indexes.get("en", {}).get("items", {})
existing_lc_names = indexes.get("en", {}).get("light_cones", {})
existing_relic_names = indexes.get("en", {}).get("relics", {})
merged = []
name_coverage = {language: 0 for language in ASSET_LANGUAGES}
description_coverage = {language: 0 for language in ASSET_LANGUAGES}
background_description_coverage = {language: 0 for language in ASSET_LANGUAGES}
for item_id in sorted(item_rows):
source_rows = sorted(item_rows[item_id], key=lambda pair: rows_preference(pair[0]))
all_rows = [row for _, row in source_rows]
all_paths = []
for row in all_rows:
for path in row_image_paths(row):
if path not in all_paths:
all_paths.append(path)
preferred = all_rows[0]
if item_id in supported_lcs:
category, index_kind = "lightcone", "light_cones"
elif item_id in supported_relics:
category, index_kind = "relic", "relics"
else:
category, index_kind = "item", "items"
index_rows = indexes.get("en", {}).get(index_kind, {})
index_row = index_rows.get(str(item_id), {}) if isinstance(index_rows, dict) else {}
name_hash = next((item_hash(row, "ItemName") for _, row in source_rows if item_hash(row, "ItemName")), None)
desc_hash = next((item_hash(row, "ItemDesc") for _, row in source_rows if item_hash(row, "ItemDesc")), None)
bg_desc_hash = next((item_hash(row, "ItemBGDesc") for _, row in source_rows if item_hash(row, "ItemBGDesc")), None)
names = {}
descriptions = {}
background_descriptions = {}
for language in ASSET_LANGUAGES:
locale_index = indexes.get(language, {}).get(index_kind, {})
locale_row = locale_index.get(str(item_id), {}) if isinstance(locale_index, dict) else {}
textmap = textmaps.get(language, {})
name = textmap.get(name_hash) if name_hash else None
if not name and isinstance(locale_row, dict):
name = locale_row.get("name")
if not name and isinstance(index_row, dict):
name = index_row.get("name")
if name:
names[language] = name
name_coverage[language] += 1
desc = textmap.get(desc_hash) if desc_hash else None
if not desc and isinstance(locale_row, dict):
desc = locale_row.get("desc")
if desc and language:
descriptions[language] = desc
description_coverage[language] += 1
bg_desc = textmap.get(bg_desc_hash) if bg_desc_hash else None
if bg_desc:
background_descriptions[language] = bg_desc
background_description_coverage[language] += 1
# Fill sparse locales with the English name only if the source has no localized text.
english_name = names.get("en") or (index_row.get("name") if isinstance(index_row, dict) else None) or f"Item {item_id}"
for language in ASSET_LANGUAGES:
names.setdefault(language, english_name)
icon = resolve_source_icon(all_paths, category, item_id, indexes, asset_files)
rarity = parse_rarity(preferred.get("Rarity"), index_row)
output_row = {
"id": item_id,
"category": category,
"type": preferred.get("ItemMainType", ""),
"subType": preferred.get("ItemSubType", ""),
"rarity": rarity,
"names": names,
"descriptions": descriptions,
"backgroundDescriptions": background_descriptions,
"icon": icon,
"gameIconPaths": all_paths,
"sourceTables": sorted({path for path, _ in source_rows}),
"supported": item_id in supported_ids,
}
pile_limit = next((row.get("PileLimit") for _, row in source_rows if isinstance(row.get("PileLimit"), int)), None)
if pile_limit:
output_row["pileLimit"] = pile_limit
if category == "lightcone":
output_row["path"] = index_row.get("path") if isinstance(index_row, dict) else None
elif category == "relic":
if isinstance(index_row, dict):
output_row["setId"] = int(index_row["set_id"]) if str(index_row.get("set_id", "")).isdigit() else None
output_row["slot"] = index_row.get("type")
merged.append(output_row)
counts = {
"total": len(merged),
"definitionRows": sum(row["itemRows"] for row in table_scan),
"uniqueIds": len(merged),
"supported": sum(row["supported"] for row in merged),
"withImages": sum(bool(row["icon"]) for row in merged),
"withoutConfiguredImage": sum(not row["icon"] for row in merged),
"withDescriptions": sum(bool(row["descriptions"] or row["backgroundDescriptions"]) for row in merged),
"nameCoverageByLanguage": name_coverage,
"descriptionCoverageByLanguage": description_coverage,
"backgroundDescriptionCoverageByLanguage": background_description_coverage,
"candidateTables": len(candidates),
"definitionTables": len(definition_tables),
}
output = {
"source": {
"gameRepository": f"https://gitlab.com/{GAME_PROJECT}",
"gameRevision": game_revision,
"candidateTables": len(candidates),
"gameTables": definition_tables,
"tableScan": table_scan,
"assetRepository": ASSET_PROJECT,
"assetRevision": asset_revision,
"assetBaseUrl": f"https://cdn.jsdelivr.net/gh/{ASSET_PROJECT}@{asset_revision}/",
"assetFallbackBaseUrl": f"https://raw.githubusercontent.com/{ASSET_PROJECT}/{asset_revision}/",
"languages": list(ASSET_LANGUAGES),
"notes": "Includes every ItemConfig table with item-definition rows at the pinned game-data revision. Items absent from InventoryItemConfig are browse-only; the catalog does not add or summon items. Icons use only files present in the pinned public StarRailRes tree. gameIconPaths retain the source paths for items without a mapped public icon.",
},
"counts": counts,
"items": merged,
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
print(json.dumps(counts, ensure_ascii=False, indent=2), flush=True)
print(f"Wrote {len(merged)} item rows to {args.output}", flush=True)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as error: # give the CLI one actionable line on network/schema failures
print(f"item catalog build failed: {error}", file=sys.stderr)
raise
+4
View File
@@ -0,0 +1,4 @@
{
"buildCommand": "node scripts/build-vercel.cjs",
"outputDirectory": "dist"
}