Compare commits
18
Commits
9378f19c14
...
Main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1719781711 | ||
|
|
4108cd04b3 | ||
|
|
62a529ef3f | ||
|
|
feb18074fe | ||
|
|
ffd6b2e853 | ||
|
|
ca5831c928 | ||
|
|
e03e5b1f14 | ||
|
|
b11121e27e | ||
|
|
e8952ecde8 | ||
|
|
51302e36c8 | ||
|
|
1e98bfea7e | ||
|
|
141798c629 | ||
|
|
3575261aa4 | ||
|
|
a2e0f186d1 | ||
|
|
99ecd84499 | ||
|
|
8de2fb5e44 | ||
|
|
5fc5c9e0da | ||
|
|
45456e7df6 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -16,6 +16,7 @@ Thumbs.db
|
||||
*.swo
|
||||
.vscode/
|
||||
.idea/
|
||||
__pycache__/
|
||||
|
||||
# Local custom configs (environment / machine specific)
|
||||
config.local.json
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.github
|
||||
.vercel
|
||||
node_modules
|
||||
dist
|
||||
tools
|
||||
.env*
|
||||
config.local.*
|
||||
*.log
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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;">></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>
|
||||
File diff suppressed because it is too large.
Load diff
+2268
-2
File diff suppressed because it is too large.
Load diff
@@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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.');
|
||||
@@ -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(', ')}`);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"buildCommand": "node scripts/build-vercel.cjs",
|
||||
"outputDirectory": "dist"
|
||||
}
|
||||
Reference in new issue
Block a user