cw cmd and unstuck and more
This commit is contained in:
1 parent
ca5831c928
commit
ffd6b2e853
11 files changed
+1370
-2
No files matched your search
@@ -16,6 +16,7 @@ Thumbs.db
|
||||
*.swo
|
||||
.vscode/
|
||||
.idea/
|
||||
__pycache__/
|
||||
|
||||
# Local custom configs (environment / machine specific)
|
||||
config.local.json
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# GaC-Srtool 🌌
|
||||
|
||||
## 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% สามารถเปิดใช้งานผ่านเบราว์เซอร์ได้ทันทีโดยไม่ต้องติดตั้งโปรแกรมเพิ่มเติม
|
||||
@@ -131,3 +135,21 @@ Damage Analyst เรียงการ์ดตาม `report.avatars[].slot`
|
||||
หน้า **รายละเอียดตัวละคร → แนวทางบิลด์** แสดงอุปกรณ์และออฟหลักที่พบบ่อยในตัวอย่าง 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; }
|
||||
}
|
||||
+83
-1
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GaC-Srtool</title>
|
||||
<link rel="stylesheet" href="css/styles.css?v=2.3">
|
||||
<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>
|
||||
@@ -240,6 +241,11 @@
|
||||
</div>
|
||||
</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">
|
||||
@@ -392,6 +398,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>
|
||||
@@ -430,6 +473,7 @@
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="btn-inventory-next" disabled data-i18n="inventoryNext">ถัดไป</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3.5. Damage Analyst (In-Game Battle Telemetry & Damage Analysis) -->
|
||||
@@ -1668,10 +1712,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.9"></script>
|
||||
<script type="module" src="js/app.js?v=3.1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +1,9 @@
|
||||
import { AVATAR_METADATA, getAvatarInfo } from './avatars-data.js';
|
||||
import { LIGHTCONE_METADATA, RELIC_SET_METADATA, getLightconeInfo, getRelicSetInfo } from './equipment-data.js';
|
||||
import { initializeItemCatalog } from './item-catalog.js?v=2';
|
||||
import { initializeUnstuck } from './unstuck.js?v=1';
|
||||
import { PRYDWEN_BUILD_GUIDES } from './build-guides-data.js';
|
||||
import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js?v=2.7';
|
||||
import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js?v=2.8';
|
||||
import {
|
||||
getAvatarIconUrl,
|
||||
getLightconeArtUrl,
|
||||
@@ -959,6 +961,12 @@ function applyTranslations() {
|
||||
if (val) elem.alt = val;
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-i18n-aria-label]').forEach(elem => {
|
||||
const key = elem.getAttribute('data-i18n-aria-label');
|
||||
const val = t(key);
|
||||
if (val) elem.setAttribute('aria-label', val);
|
||||
});
|
||||
|
||||
// Update dynamic filter dropdown options
|
||||
updateFilterDropdownTranslations();
|
||||
|
||||
@@ -1601,6 +1609,9 @@ const consoleState = {
|
||||
};
|
||||
|
||||
const GM_COMMAND_SUGGESTIONS = [
|
||||
{ label: '/cw status', insert: '/cw status', description: 'Show saved CW run, gold and HP' },
|
||||
{ label: '/cw gold <total>', insert: '/cw gold ', description: 'Set CW gold in Preparation; keeps adventure currency unchanged' },
|
||||
{ label: '/cw hp <total>', insert: '/cw hp ', description: 'Set CW HP within the current run limit' },
|
||||
{ label: '/avatar <id> [level] [rank]', insert: '/avatar ', description: 'Update a character level and Eidolon rank' },
|
||||
{ label: '/clear lineup', insert: '/clear lineup', description: 'Clear the active lineup' },
|
||||
{ label: '/give <item_id> [count]', insert: '/give ', description: 'Add an item or currency' },
|
||||
@@ -1767,6 +1778,7 @@ async function dispatchGMCommand(rawCmd) {
|
||||
// Handle local /help command immediately
|
||||
if (cmd.toLowerCase() === '/help') {
|
||||
logToConsole('Available GM commands:', 'SYS');
|
||||
logToConsole(' /cw status | /cw gold <total> | /cw hp <total> - CW only; applies in Preparation', 'SYS');
|
||||
logToConsole(' /heal - Restore team HP, SP, and Energy', 'SYS');
|
||||
logToConsole(' /max - Set all characters to Level 80 and E6', 'SYS');
|
||||
logToConsole(' /teleport <plane_id> [floor] - Teleport to a scene', 'SYS');
|
||||
@@ -8908,6 +8920,8 @@ async function init() {
|
||||
await loadCachedStorageData();
|
||||
setupServerModes();
|
||||
setupLanguageSelector();
|
||||
initializeItemCatalog();
|
||||
initializeUnstuck({ apiCall, isConnected: () => state.isConnected, showToast });
|
||||
setupTabs();
|
||||
setupModalSubtabs();
|
||||
setupEvents();
|
||||
|
||||
+175
@@ -6635,6 +6635,181 @@ for (const [language, additions] of Object.entries(LAST_MILE_TRANSLATIONS)) {
|
||||
Object.assign(TRANSLATIONS[language], additions);
|
||||
}
|
||||
|
||||
|
||||
const ITEM_CATALOG_TRANSLATIONS = {
|
||||
th: {
|
||||
tabItemCatalog: '📚 สมุดไอเทม', itemCatalogTitle: '📚 สมุดรวมไอเทม',
|
||||
itemCatalogDescription: 'ค้นชื่อหรือ ID เพื่อดูรูปและรหัสจากตารางเกม รายการที่มีป้าย GaC รองรับใช้จัดการในเครื่องมือกระเป๋าได้ ส่วนรายการอื่นใช้ค้นดู ID เท่านั้น',
|
||||
itemCatalogSearchPlaceholder: 'ค้นหาชื่อหรือ Item ID...', itemCatalogFilterLabel: 'กรองสมุดไอเทม', itemCatalogFilterAll: 'ทั้งหมด',
|
||||
itemCatalogFilterSupported: 'GaC รองรับ', itemCatalogFilterItems: 'ไอเทม',
|
||||
itemCatalogFilterLightCones: 'กรวยแสง', itemCatalogFilterRelics: 'รีลิกส์',
|
||||
itemCatalogSupportedTag: 'GaC รองรับ', itemCatalogBrowseOnlyTag: 'ดูข้อมูลเท่านั้น',
|
||||
itemCatalogUnknownName: 'ไม่มีชื่อในตาราง #{id}', itemCatalogNoImage: 'ไม่มีภาพในข้อมูลเกม',
|
||||
itemCatalogLoading: 'กำลังโหลดสมุดไอเทม...', itemCatalogLoadFailed: 'โหลดสมุดไอเทมไม่สำเร็จ'
|
||||
},
|
||||
en: {
|
||||
tabItemCatalog: '📚 Item Book', itemCatalogTitle: '📚 Item Catalog',
|
||||
itemCatalogDescription: 'Search game table names or IDs and view their icons. Entries marked GaC supported can be managed in the inventory tool; other entries are browse-only.',
|
||||
itemCatalogSearchPlaceholder: 'Search by name or item ID...', itemCatalogFilterLabel: 'Filter item catalog', itemCatalogFilterAll: 'All entries',
|
||||
itemCatalogFilterSupported: 'GaC supported', itemCatalogFilterItems: 'Items',
|
||||
itemCatalogFilterLightCones: 'Light Cones', itemCatalogFilterRelics: 'Relics',
|
||||
itemCatalogSupportedTag: 'GaC supported', itemCatalogBrowseOnlyTag: 'Browse only',
|
||||
itemCatalogUnknownName: 'Unnamed item #{id}', itemCatalogNoImage: 'No icon in game data',
|
||||
itemCatalogLoading: 'Loading item catalog...', itemCatalogLoadFailed: 'Could not load item catalog'
|
||||
},
|
||||
'zh-CN': {
|
||||
tabItemCatalog: '📚 物品图鉴', itemCatalogTitle: '📚 物品目录',
|
||||
itemCatalogDescription: '搜索游戏表格中的名称或 ID 并查看图标。标记为 GaC 支持的条目可在背包工具中管理;其他条目仅供查询。',
|
||||
itemCatalogSearchPlaceholder: '搜索名称或物品 ID...', itemCatalogFilterLabel: '筛选物品目录', itemCatalogFilterAll: '全部条目',
|
||||
itemCatalogFilterSupported: 'GaC 支持', itemCatalogFilterItems: '物品',
|
||||
itemCatalogFilterLightCones: '光锥', itemCatalogFilterRelics: '遗器',
|
||||
itemCatalogSupportedTag: 'GaC 支持', itemCatalogBrowseOnlyTag: '仅供查询',
|
||||
itemCatalogUnknownName: '未命名物品 #{id}', itemCatalogNoImage: '游戏数据未配置图标',
|
||||
itemCatalogLoading: '正在加载物品目录...', itemCatalogLoadFailed: '无法加载物品目录'
|
||||
},
|
||||
'zh-TW': {
|
||||
tabItemCatalog: '📚 物品圖鑑', itemCatalogTitle: '📚 物品目錄',
|
||||
itemCatalogDescription: '搜尋遊戲表格中的名稱或 ID 並查看圖示。標記為 GaC 支援的項目可在背包工具中管理;其他項目僅供查詢。',
|
||||
itemCatalogSearchPlaceholder: '搜尋名稱或物品 ID...', itemCatalogFilterLabel: '篩選物品目錄', itemCatalogFilterAll: '全部項目',
|
||||
itemCatalogFilterSupported: 'GaC 支援', itemCatalogFilterItems: '物品',
|
||||
itemCatalogFilterLightCones: '光錐', itemCatalogFilterRelics: '遺器',
|
||||
itemCatalogSupportedTag: 'GaC 支援', itemCatalogBrowseOnlyTag: '僅供查詢',
|
||||
itemCatalogUnknownName: '未命名物品 #{id}', itemCatalogNoImage: '遊戲資料未設定圖示',
|
||||
itemCatalogLoading: '正在載入物品目錄...', itemCatalogLoadFailed: '無法載入物品目錄'
|
||||
},
|
||||
ja: {
|
||||
tabItemCatalog: '📚 アイテム図鑑', itemCatalogTitle: '📚 アイテム一覧',
|
||||
itemCatalogDescription: 'ゲームデータの名前や ID を検索してアイコンを確認できます。GaC 対応と表示された項目は所持品ツールで管理でき、その他は閲覧のみです。',
|
||||
itemCatalogSearchPlaceholder: '名前またはアイテム ID を検索...', itemCatalogFilterLabel: 'アイテム一覧を絞り込む', itemCatalogFilterAll: 'すべて',
|
||||
itemCatalogFilterSupported: 'GaC 対応', itemCatalogFilterItems: 'アイテム',
|
||||
itemCatalogFilterLightCones: '光円錐', itemCatalogFilterRelics: '遺物',
|
||||
itemCatalogSupportedTag: 'GaC 対応', itemCatalogBrowseOnlyTag: '閲覧のみ',
|
||||
itemCatalogUnknownName: '名前なし #{id}', itemCatalogNoImage: 'ゲームデータに画像なし',
|
||||
itemCatalogLoading: 'アイテム一覧を読み込み中...', itemCatalogLoadFailed: 'アイテム一覧を読み込めませんでした'
|
||||
},
|
||||
ko: {
|
||||
tabItemCatalog: '📚 아이템 도감', itemCatalogTitle: '📚 아이템 목록',
|
||||
itemCatalogDescription: '게임 데이터의 이름이나 ID를 검색해 아이콘을 확인하세요. GaC 지원 표시가 있는 항목은 인벤토리 도구에서 관리할 수 있으며, 나머지는 조회 전용입니다.',
|
||||
itemCatalogSearchPlaceholder: '이름 또는 아이템 ID 검색...', itemCatalogFilterLabel: '아이템 목록 필터', itemCatalogFilterAll: '전체 항목',
|
||||
itemCatalogFilterSupported: 'GaC 지원', itemCatalogFilterItems: '아이템',
|
||||
itemCatalogFilterLightCones: '광추', itemCatalogFilterRelics: '유물',
|
||||
itemCatalogSupportedTag: 'GaC 지원', itemCatalogBrowseOnlyTag: '조회 전용',
|
||||
itemCatalogUnknownName: '이름 없음 #{id}', itemCatalogNoImage: '게임 데이터에 아이콘 없음',
|
||||
itemCatalogLoading: '아이템 목록을 불러오는 중...', itemCatalogLoadFailed: '아이템 목록을 불러오지 못했습니다'
|
||||
},
|
||||
vi: {
|
||||
tabItemCatalog: '📚 Sổ tay vật phẩm', itemCatalogTitle: '📚 Danh mục vật phẩm',
|
||||
itemCatalogDescription: 'Tìm tên hoặc ID trong bảng dữ liệu trò chơi để xem biểu tượng. Mục có nhãn GaC hỗ trợ có thể quản lý trong công cụ túi đồ; mục khác chỉ dùng để tra cứu.',
|
||||
itemCatalogSearchPlaceholder: 'Tìm theo tên hoặc ID vật phẩm...', itemCatalogFilterLabel: 'Lọc danh mục vật phẩm', itemCatalogFilterAll: 'Tất cả mục',
|
||||
itemCatalogFilterSupported: 'GaC hỗ trợ', itemCatalogFilterItems: 'Vật phẩm',
|
||||
itemCatalogFilterLightCones: 'Nón Ánh Sáng', itemCatalogFilterRelics: 'Di Vật',
|
||||
itemCatalogSupportedTag: 'GaC hỗ trợ', itemCatalogBrowseOnlyTag: 'Chỉ tra cứu',
|
||||
itemCatalogUnknownName: 'Vật phẩm chưa có tên #{id}', itemCatalogNoImage: 'Dữ liệu game không có biểu tượng',
|
||||
itemCatalogLoading: 'Đang tải danh mục vật phẩm...', itemCatalogLoadFailed: 'Không tải được danh mục vật phẩm'
|
||||
},
|
||||
id: {
|
||||
tabItemCatalog: '📚 Buku Item', itemCatalogTitle: '📚 Katalog Item',
|
||||
itemCatalogDescription: 'Cari nama atau ID dari tabel game dan lihat ikonnya. Item berlabel didukung GaC dapat dikelola melalui alat inventaris; item lainnya hanya untuk dilihat.',
|
||||
itemCatalogSearchPlaceholder: 'Cari nama atau ID item...', itemCatalogFilterLabel: 'Filter katalog item', itemCatalogFilterAll: 'Semua item',
|
||||
itemCatalogFilterSupported: 'Didukung GaC', itemCatalogFilterItems: 'Item',
|
||||
itemCatalogFilterLightCones: 'Light Cone', itemCatalogFilterRelics: 'Relik',
|
||||
itemCatalogSupportedTag: 'Didukung GaC', itemCatalogBrowseOnlyTag: 'Hanya dilihat',
|
||||
itemCatalogUnknownName: 'Item tanpa nama #{id}', itemCatalogNoImage: 'Tidak ada ikon di data game',
|
||||
itemCatalogLoading: 'Memuat katalog item...', itemCatalogLoadFailed: 'Katalog item gagal dimuat'
|
||||
},
|
||||
es: {
|
||||
tabItemCatalog: '📚 Catálogo', itemCatalogTitle: '📚 Catálogo de objetos',
|
||||
itemCatalogDescription: 'Busca nombres o ID de las tablas del juego y consulta sus iconos. Los elementos compatibles con GaC se pueden gestionar en el inventario; los demás solo se pueden consultar.',
|
||||
itemCatalogSearchPlaceholder: 'Buscar por nombre o ID...', itemCatalogFilterLabel: 'Filtrar catálogo de objetos', itemCatalogFilterAll: 'Todos',
|
||||
itemCatalogFilterSupported: 'Compatible con GaC', itemCatalogFilterItems: 'Objetos',
|
||||
itemCatalogFilterLightCones: 'Conos de luz', itemCatalogFilterRelics: 'Reliquias',
|
||||
itemCatalogSupportedTag: 'Compatible con GaC', itemCatalogBrowseOnlyTag: 'Solo consulta',
|
||||
itemCatalogUnknownName: 'Objeto sin nombre #{id}', itemCatalogNoImage: 'Sin icono en los datos del juego',
|
||||
itemCatalogLoading: 'Cargando catálogo...', itemCatalogLoadFailed: 'No se pudo cargar el catálogo'
|
||||
},
|
||||
fr: {
|
||||
tabItemCatalog: '📚 Catalogue', itemCatalogTitle: '📚 Catalogue des objets',
|
||||
itemCatalogDescription: 'Recherchez les noms ou ID des tables du jeu et consultez leurs icônes. Les éléments compatibles avec GaC peuvent être gérés dans l’inventaire ; les autres sont consultables uniquement.',
|
||||
itemCatalogSearchPlaceholder: 'Rechercher par nom ou ID...', itemCatalogFilterLabel: 'Filtrer le catalogue', itemCatalogFilterAll: 'Tous les éléments',
|
||||
itemCatalogFilterSupported: 'Compatible GaC', itemCatalogFilterItems: 'Objets',
|
||||
itemCatalogFilterLightCones: 'Cônes de lumière', itemCatalogFilterRelics: 'Reliques',
|
||||
itemCatalogSupportedTag: 'Compatible GaC', itemCatalogBrowseOnlyTag: 'Consultation seule',
|
||||
itemCatalogUnknownName: 'Objet sans nom #{id}', itemCatalogNoImage: 'Aucune icône dans les données du jeu',
|
||||
itemCatalogLoading: 'Chargement du catalogue...', itemCatalogLoadFailed: 'Impossible de charger le catalogue'
|
||||
},
|
||||
de: {
|
||||
tabItemCatalog: '📚 Gegenstandsbuch', itemCatalogTitle: '📚 Gegenstandskatalog',
|
||||
itemCatalogDescription: 'Suche nach Namen oder IDs aus den Spieltabellen und sieh dir die Symbole an. GaC-unterstützte Einträge lassen sich im Inventarwerkzeug verwalten; alle anderen dienen nur zum Nachschlagen.',
|
||||
itemCatalogSearchPlaceholder: 'Nach Name oder Gegenstands-ID suchen...', itemCatalogFilterLabel: 'Katalog filtern', itemCatalogFilterAll: 'Alle Einträge',
|
||||
itemCatalogFilterSupported: 'Von GaC unterstützt', itemCatalogFilterItems: 'Gegenstände',
|
||||
itemCatalogFilterLightCones: 'Lichtkegel', itemCatalogFilterRelics: 'Relikte',
|
||||
itemCatalogSupportedTag: 'Von GaC unterstützt', itemCatalogBrowseOnlyTag: 'Nur Nachschlagen',
|
||||
itemCatalogUnknownName: 'Unbenannter Gegenstand #{id}', itemCatalogNoImage: 'Kein Symbol in den Spieldaten',
|
||||
itemCatalogLoading: 'Gegenstandskatalog wird geladen...', itemCatalogLoadFailed: 'Gegenstandskatalog konnte nicht geladen werden'
|
||||
},
|
||||
ru: {
|
||||
tabItemCatalog: '📚 Каталог предметов', itemCatalogTitle: '📚 Каталог предметов',
|
||||
itemCatalogDescription: 'Ищите названия или ID из игровых таблиц и просматривайте значки. Предметы с отметкой GaC можно менять в инструменте инвентаря; остальные доступны только для просмотра.',
|
||||
itemCatalogSearchPlaceholder: 'Поиск по названию или ID...', itemCatalogFilterLabel: 'Фильтр каталога предметов', itemCatalogFilterAll: 'Все записи',
|
||||
itemCatalogFilterSupported: 'Поддерживается GaC', itemCatalogFilterItems: 'Предметы',
|
||||
itemCatalogFilterLightCones: 'Световые конусы', itemCatalogFilterRelics: 'Реликвии',
|
||||
itemCatalogSupportedTag: 'Поддерживается GaC', itemCatalogBrowseOnlyTag: 'Только просмотр',
|
||||
itemCatalogUnknownName: 'Предмет без названия #{id}', itemCatalogNoImage: 'В игровых данных нет значка',
|
||||
itemCatalogLoading: 'Загрузка каталога предметов...', itemCatalogLoadFailed: 'Не удалось загрузить каталог предметов'
|
||||
},
|
||||
pt: {
|
||||
tabItemCatalog: '📚 Catálogo de itens', itemCatalogTitle: '📚 Catálogo de itens',
|
||||
itemCatalogDescription: 'Pesquise nomes ou IDs das tabelas do jogo e veja os ícones. Itens marcados como compatíveis com GaC podem ser gerenciados no inventário; os demais são apenas para consulta.',
|
||||
itemCatalogSearchPlaceholder: 'Pesquisar por nome ou ID...', itemCatalogFilterLabel: 'Filtrar catálogo de itens', itemCatalogFilterAll: 'Todos os itens',
|
||||
itemCatalogFilterSupported: 'Compatível com GaC', itemCatalogFilterItems: 'Itens',
|
||||
itemCatalogFilterLightCones: 'Cones de Luz', itemCatalogFilterRelics: 'Relíquias',
|
||||
itemCatalogSupportedTag: 'Compatível com GaC', itemCatalogBrowseOnlyTag: 'Somente consulta',
|
||||
itemCatalogUnknownName: 'Item sem nome #{id}', itemCatalogNoImage: 'Sem ícone nos dados do jogo',
|
||||
itemCatalogLoading: 'Carregando catálogo de itens...', itemCatalogLoadFailed: 'Não foi possível carregar o catálogo de itens'
|
||||
}
|
||||
};
|
||||
for (const [language, additions] of Object.entries(ITEM_CATALOG_TRANSLATIONS)) {
|
||||
Object.assign(TRANSLATIONS[language], additions);
|
||||
}
|
||||
|
||||
const ITEM_CATALOG_DETAIL_TRANSLATIONS = {
|
||||
th: { itemCatalogDetailTitle: 'รายละเอียดไอเทม', itemCatalogItemId: 'Item ID {id}', itemCatalogSourceTable: 'ตารางต้นทาง', itemCatalogItemDescription: 'คำอธิบาย', itemCatalogLore: 'ข้อมูลเพิ่มเติม', itemCatalogNoDescription: 'ไม่มีคำอธิบายในข้อมูลเกม', itemCatalogNoDetails: 'ไม่มีรายละเอียดเพิ่มเติม', itemCatalogOpenDetails: 'ดูรายละเอียด {name}', itemCatalogQuantity: 'จำนวนที่เลือก', itemCatalogMaxQuantity: 'สูงสุด {count}', itemCatalogClose: 'ปิด', itemCatalogDecrease: 'ลดจำนวน', itemCatalogIncrease: 'เพิ่มจำนวน' },
|
||||
en: { itemCatalogDetailTitle: 'Item Details', itemCatalogItemId: 'Item ID {id}', itemCatalogSourceTable: 'Source tables', itemCatalogItemDescription: 'Description', itemCatalogLore: 'Additional Details', itemCatalogNoDescription: 'No description in game data', itemCatalogNoDetails: 'No additional details', itemCatalogOpenDetails: 'View details for {name}', itemCatalogQuantity: 'Selected quantity', itemCatalogMaxQuantity: 'Maximum {count}', itemCatalogClose: 'Close', itemCatalogDecrease: 'Decrease quantity', itemCatalogIncrease: 'Increase quantity' },
|
||||
'zh-CN': { itemCatalogDetailTitle: '物品详情', itemCatalogItemId: '物品 ID {id}', itemCatalogSourceTable: '来源表', itemCatalogItemDescription: '描述', itemCatalogLore: '补充信息', itemCatalogNoDescription: '游戏数据中没有描述', itemCatalogNoDetails: '没有更多信息', itemCatalogOpenDetails: '查看{name}详情', itemCatalogQuantity: '选择数量', itemCatalogMaxQuantity: '最多 {count}', itemCatalogClose: '关闭', itemCatalogDecrease: '减少数量', itemCatalogIncrease: '增加数量' },
|
||||
'zh-TW': { itemCatalogDetailTitle: '物品詳情', itemCatalogItemId: '物品 ID {id}', itemCatalogSourceTable: '來源表', itemCatalogItemDescription: '說明', itemCatalogLore: '補充資訊', itemCatalogNoDescription: '遊戲資料中沒有說明', itemCatalogNoDetails: '沒有更多資訊', itemCatalogOpenDetails: '查看{name}詳情', itemCatalogQuantity: '選擇數量', itemCatalogMaxQuantity: '最多 {count}', itemCatalogClose: '關閉', itemCatalogDecrease: '減少數量', itemCatalogIncrease: '增加數量' },
|
||||
ja: { itemCatalogDetailTitle: 'アイテム詳細', itemCatalogItemId: 'アイテム ID {id}', itemCatalogSourceTable: '参照テーブル', itemCatalogItemDescription: '説明', itemCatalogLore: '補足情報', itemCatalogNoDescription: 'ゲームデータに説明がありません', itemCatalogNoDetails: '追加情報はありません', itemCatalogOpenDetails: '{name}の詳細を見る', itemCatalogQuantity: '選択数', itemCatalogMaxQuantity: '上限 {count}', itemCatalogClose: '閉じる', itemCatalogDecrease: '数量を減らす', itemCatalogIncrease: '数量を増やす' },
|
||||
ko: { itemCatalogDetailTitle: '아이템 상세 정보', itemCatalogItemId: '아이템 ID {id}', itemCatalogSourceTable: '출처 테이블', itemCatalogItemDescription: '설명', itemCatalogLore: '추가 정보', itemCatalogNoDescription: '게임 데이터에 설명이 없습니다', itemCatalogNoDetails: '추가 정보가 없습니다', itemCatalogOpenDetails: '{name} 상세 정보 보기', itemCatalogQuantity: '선택 수량', itemCatalogMaxQuantity: '최대 {count}', itemCatalogClose: '닫기', itemCatalogDecrease: '수량 줄이기', itemCatalogIncrease: '수량 늘리기' },
|
||||
vi: { itemCatalogDetailTitle: 'Chi tiết vật phẩm', itemCatalogItemId: 'ID vật phẩm {id}', itemCatalogSourceTable: 'Bảng nguồn', itemCatalogItemDescription: 'Mô tả', itemCatalogLore: 'Thông tin thêm', itemCatalogNoDescription: 'Dữ liệu game không có mô tả', itemCatalogNoDetails: 'Không có thông tin thêm', itemCatalogOpenDetails: 'Xem chi tiết {name}', itemCatalogQuantity: 'Số lượng đã chọn', itemCatalogMaxQuantity: 'Tối đa {count}', itemCatalogClose: 'Đóng', itemCatalogDecrease: 'Giảm số lượng', itemCatalogIncrease: 'Tăng số lượng' },
|
||||
id: { itemCatalogDetailTitle: 'Detail Item', itemCatalogItemId: 'ID item {id}', itemCatalogSourceTable: 'Tabel sumber', itemCatalogItemDescription: 'Deskripsi', itemCatalogLore: 'Info Tambahan', itemCatalogNoDescription: 'Tidak ada deskripsi di data game', itemCatalogNoDetails: 'Tidak ada info tambahan', itemCatalogOpenDetails: 'Lihat detail {name}', itemCatalogQuantity: 'Jumlah dipilih', itemCatalogMaxQuantity: 'Maksimal {count}', itemCatalogClose: 'Tutup', itemCatalogDecrease: 'Kurangi jumlah', itemCatalogIncrease: 'Tambah jumlah' },
|
||||
es: { itemCatalogDetailTitle: 'Detalles del objeto', itemCatalogItemId: 'ID del objeto {id}', itemCatalogSourceTable: 'Tablas de origen', itemCatalogItemDescription: 'Descripción', itemCatalogLore: 'Información adicional', itemCatalogNoDescription: 'Sin descripción en los datos del juego', itemCatalogNoDetails: 'Sin más detalles', itemCatalogOpenDetails: 'Ver detalles de {name}', itemCatalogQuantity: 'Cantidad seleccionada', itemCatalogMaxQuantity: 'Máximo {count}', itemCatalogClose: 'Cerrar', itemCatalogDecrease: 'Reducir cantidad', itemCatalogIncrease: 'Aumentar cantidad' },
|
||||
fr: { itemCatalogDetailTitle: 'Détails de l’objet', itemCatalogItemId: 'ID de l’objet {id}', itemCatalogSourceTable: 'Tables sources', itemCatalogItemDescription: 'Description', itemCatalogLore: 'Informations supplémentaires', itemCatalogNoDescription: 'Aucune description dans les données du jeu', itemCatalogNoDetails: 'Aucun autre détail', itemCatalogOpenDetails: 'Voir les détails de {name}', itemCatalogQuantity: 'Quantité choisie', itemCatalogMaxQuantity: 'Maximum {count}', itemCatalogClose: 'Fermer', itemCatalogDecrease: 'Diminuer la quantité', itemCatalogIncrease: 'Augmenter la quantité' },
|
||||
de: { itemCatalogDetailTitle: 'Gegenstandsdetails', itemCatalogItemId: 'Gegenstands-ID {id}', itemCatalogSourceTable: 'Quelltabellen', itemCatalogItemDescription: 'Beschreibung', itemCatalogLore: 'Zusätzliche Details', itemCatalogNoDescription: 'Keine Beschreibung in den Spieldaten', itemCatalogNoDetails: 'Keine weiteren Details', itemCatalogOpenDetails: 'Details für {name} anzeigen', itemCatalogQuantity: 'Ausgewählte Menge', itemCatalogMaxQuantity: 'Maximum {count}', itemCatalogClose: 'Schließen', itemCatalogDecrease: 'Menge verringern', itemCatalogIncrease: 'Menge erhöhen' },
|
||||
ru: { itemCatalogDetailTitle: 'Сведения о предмете', itemCatalogItemId: 'ID предмета {id}', itemCatalogSourceTable: 'Исходные таблицы', itemCatalogItemDescription: 'Описание', itemCatalogLore: 'Дополнительная информация', itemCatalogNoDescription: 'В игровых данных нет описания', itemCatalogNoDetails: 'Дополнительных сведений нет', itemCatalogOpenDetails: 'Подробнее: {name}', itemCatalogQuantity: 'Выбранное количество', itemCatalogMaxQuantity: 'Максимум: {count}', itemCatalogClose: 'Закрыть', itemCatalogDecrease: 'Уменьшить количество', itemCatalogIncrease: 'Увеличить количество' },
|
||||
pt: { itemCatalogDetailTitle: 'Detalhes do item', itemCatalogItemId: 'ID do item {id}', itemCatalogSourceTable: 'Tabelas de origem', itemCatalogItemDescription: 'Descrição', itemCatalogLore: 'Informações adicionais', itemCatalogNoDescription: 'Sem descrição nos dados do jogo', itemCatalogNoDetails: 'Sem detalhes adicionais', itemCatalogOpenDetails: 'Ver detalhes de {name}', itemCatalogQuantity: 'Quantidade selecionada', itemCatalogMaxQuantity: 'Máximo {count}', itemCatalogClose: 'Fechar', itemCatalogDecrease: 'Diminuir quantidade', itemCatalogIncrease: 'Aumentar quantidade' }
|
||||
};
|
||||
for (const [language, additions] of Object.entries(ITEM_CATALOG_DETAIL_TRANSLATIONS)) {
|
||||
Object.assign(TRANSLATIONS[language], additions);
|
||||
}
|
||||
|
||||
const ITEM_CATALOG_INTERACTION_DESCRIPTIONS = {
|
||||
th: 'ค้นหาชื่อหรือ ID แล้วคลิกรูปเพื่ออ่านรายละเอียดและเลือกจำนวน การเลือกจำนวนในหน้านี้ยังไม่เพิ่มไอเทม รายการที่ GaC รองรับจัดการได้ในแท็บกระเป๋า ส่วนรายการอื่นใช้ดูข้อมูลเท่านั้น',
|
||||
en: 'Search by name or ID, then click an image to read details and select a quantity. Selecting a quantity here does not add the item. GaC-supported entries can be managed in the inventory tab; other entries are for browsing.',
|
||||
'zh-CN': '按名称或 ID 搜索,然后点击图片查看详情并选择数量。在此选择数量不会添加物品。GaC 支持的条目可在背包页管理,其他条目仅供查看。',
|
||||
'zh-TW': '依名稱或 ID 搜尋,再點擊圖片查看詳情並選擇數量。在此選擇數量不會新增物品。GaC 支援的項目可在背包頁管理,其他項目僅供瀏覽。',
|
||||
ja: '名前または ID で検索し、画像をクリックすると詳細を確認して数量を選べます。この画面で数量を選んでもアイテムは追加されません。GaC 対応アイテムは所持品タブで管理でき、その他は閲覧のみです。',
|
||||
ko: '이름 또는 ID를 검색한 뒤 이미지를 클릭하면 상세 정보를 확인하고 수량을 선택할 수 있습니다. 여기서 수량을 선택해도 아이템은 추가되지 않습니다. GaC 지원 항목은 인벤토리 탭에서 관리할 수 있고 나머지는 조회 전용입니다.',
|
||||
vi: 'Tìm theo tên hoặc ID rồi nhấn vào hình để xem chi tiết và chọn số lượng. Việc chọn số lượng ở đây không thêm vật phẩm. Vật phẩm được GaC hỗ trợ có thể quản lý trong tab túi đồ; các mục khác chỉ để tra cứu.',
|
||||
id: 'Cari berdasarkan nama atau ID, lalu klik gambar untuk melihat detail dan memilih jumlah. Memilih jumlah di sini tidak menambahkan item. Item yang didukung GaC dapat dikelola di tab inventaris; item lainnya hanya untuk dilihat.',
|
||||
es: 'Busca por nombre o ID y haz clic en una imagen para consultar los detalles y elegir una cantidad. Elegir una cantidad aquí no añade el objeto. Los elementos compatibles con GaC se gestionan en la pestaña de inventario; los demás son solo de consulta.',
|
||||
fr: 'Recherchez un nom ou un ID, puis cliquez sur une image pour consulter les détails et choisir une quantité. Ce choix n’ajoute pas l’objet. Les éléments compatibles avec GaC se gèrent dans l’inventaire ; les autres sont consultables uniquement.',
|
||||
de: 'Suche nach Name oder ID und klicke auf ein Bild, um Details anzuzeigen und eine Menge auszuwählen. Dadurch wird der Gegenstand nicht hinzugefügt. GaC-unterstützte Einträge lassen sich im Inventar verwalten; die übrigen dienen nur zum Nachschlagen.',
|
||||
ru: 'Найдите предмет по названию или ID, затем нажмите на изображение, чтобы посмотреть сведения и выбрать количество. Выбор количества здесь не добавляет предмет. Предметы с поддержкой GaC можно менять во вкладке инвентаря; остальные доступны только для просмотра.',
|
||||
pt: 'Pesquise por nome ou ID e clique na imagem para ver os detalhes e escolher uma quantidade. Escolher a quantidade aqui não adiciona o item. Itens compatíveis com GaC podem ser gerenciados na aba de inventário; os demais servem apenas para consulta.'
|
||||
};
|
||||
for (const [language, description] of Object.entries(ITEM_CATALOG_INTERACTION_DESCRIPTIONS)) {
|
||||
TRANSLATIONS[language].itemCatalogDescription = description;
|
||||
}
|
||||
|
||||
let currentLang = 'th';
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
currentLang = localStorage.getItem('gac_srtool_lang') || 'th';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user