feat(gachabase): full game database sync and automatic cdn image resolution
This commit is contained in:
1 parent
5944673e0e
commit
039609980e
4 files changed
+642
-1051
No files matched your search
+33
-83
@@ -45,8 +45,8 @@
|
|||||||
🔄 รีเฟรช
|
🔄 รีเฟรช
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="btn btn-secondary btn-sm" id="btn-open-gachabase" title="ดึงข้อมูลตัวละคร/กรวยแสงจาก Gachabase" style="border-color: rgba(245, 158, 11, 0.4); color: #fbbf24;">
|
<button class="btn btn-secondary btn-sm" id="btn-header-gachabase-sync" title="ซิงค์และอัปเดตข้อมูลเกม/รูปภาพจาก Gachabase เมื่อเกมอัปเดต" style="border-color: rgba(245, 158, 11, 0.4); color: #fbbf24;">
|
||||||
🌐 Gachabase
|
🌐 ซิงค์ Gachabase
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -199,12 +199,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 7. Gachabase Beta & Datamine Importer -->
|
<!-- 7. Gachabase Game Data & Asset Sync -->
|
||||||
<button type="button" class="btn btn-secondary" id="btn-quick-gachabase" style="display: flex; align-items: center; justify-content: flex-start; gap: 0.6rem; text-align: left; padding: 0.75rem 0.9rem; border-color: rgba(245, 158, 11, 0.4);">
|
<button type="button" class="btn btn-secondary" id="btn-quick-gachabase-sync" style="display: flex; align-items: center; justify-content: flex-start; gap: 0.6rem; text-align: left; padding: 0.75rem 0.9rem; border-color: rgba(245, 158, 11, 0.4);">
|
||||||
<span style="font-size: 1.35rem; line-height: 1;">🌐</span>
|
<span style="font-size: 1.35rem; line-height: 1;">🌐</span>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight: 700; font-size: 0.875rem; color: #fbbf24;">ดึงข้อมูล Gachabase</div>
|
<div style="font-weight: 700; font-size: 0.875rem; color: #fbbf24;" data-i18n="qaGachabaseTitle">ซิงค์ข้อมูลเกม Gachabase</div>
|
||||||
<div style="font-size: 0.725rem; color: var(--text-muted);">นำเข้าตัวละคร/กรวยแสง เบต้าหรือใหม่ล่าสุด</div>
|
<div style="font-size: 0.725rem; color: var(--text-muted);" data-i18n="qaGachabaseDesc">อัปเดตข้อมูล & รูปภาพเมื่อเกมมีแพตช์ใหม่</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
<button class="btn btn-secondary" id="btn-avatars-gachabase" title="ดึงตัวละครจาก Gachabase" style="border-color: rgba(245, 158, 11, 0.4); color: #fbbf24;">🌐 Gachabase</button>
|
<button class="btn btn-secondary" id="btn-sync-gachabase-avatars" title="ซิงค์และอัปเดตข้อมูลตัวละครและรูปภาพจาก Gachabase เมื่อเกมมีแพตช์ใหม่" style="border-color: rgba(245, 158, 11, 0.4); color: #fbbf24;">🌐 ซิงค์ Gachabase</button>
|
||||||
<button class="btn btn-primary" id="btn-all-avatars-e0s1" data-i18n="btnAllE0S1" style="background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%); border-color: #38bdf8;">⚡ ปรับ E0S1 ทุกตัว</button>
|
<button class="btn btn-primary" id="btn-all-avatars-e0s1" data-i18n="btnAllE0S1" style="background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%); border-color: #38bdf8;">⚡ ปรับ E0S1 ทุกตัว</button>
|
||||||
<button class="btn btn-gold" id="btn-max-all-avatars" data-i18n="btnMaxAll">⭐ ปรับ Max E6 ทุกตัว</button>
|
<button class="btn btn-gold" id="btn-max-all-avatars" data-i18n="btnMaxAll">⭐ ปรับ Max E6 ทุกตัว</button>
|
||||||
<button class="btn btn-secondary" id="btn-reload-avatars" data-i18n="btnReload">🔄 รีโหลด</button>
|
<button class="btn btn-secondary" id="btn-reload-avatars" data-i18n="btnReload">🔄 รีโหลด</button>
|
||||||
@@ -299,7 +299,7 @@
|
|||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div style="display: flex; gap: 0.4rem;">
|
<div style="display: flex; gap: 0.4rem;">
|
||||||
<button type="button" class="btn btn-sm btn-secondary" id="btn-eq-gachabase" title="ดึงกรวยแสง/รีลิกส์จาก Gachabase" style="border-color: rgba(245, 158, 11, 0.4); color: #fbbf24;">🌐 Gachabase</button>
|
<button type="button" class="btn btn-sm btn-secondary" id="btn-sync-gachabase-eq" title="ซิงค์และอัปเดตกรวยแสงและรูปภาพจาก Gachabase เมื่อเกมมีแพตช์ใหม่" style="border-color: rgba(245, 158, 11, 0.4); color: #fbbf24;">🌐 ซิงค์ Gachabase</button>
|
||||||
<button type="button" class="btn btn-sm btn-success" id="btn-open-add-lc" data-i18n="btnAddLc">+ เพิ่ม LC</button>
|
<button type="button" class="btn btn-sm btn-success" id="btn-open-add-lc" data-i18n="btnAddLc">+ เพิ่ม LC</button>
|
||||||
<button type="button" class="btn btn-sm btn-success" id="btn-open-add-relic" data-i18n="btnAddRelic" style="display: none;">+ เพิ่มรีลิกส์</button>
|
<button type="button" class="btn btn-sm btn-success" id="btn-open-add-relic" data-i18n="btnAddRelic" style="display: none;">+ เพิ่มรีลิกส์</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -603,6 +603,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 5: Gachabase Dynamic Game Data & Image Sync -->
|
||||||
|
<div class="card" style="margin-top: 0.5rem;">
|
||||||
|
<div class="card-title" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span>🌐 อัปเดตข้อมูลเกม & รูปภาพ (Gachabase Sync)</span>
|
||||||
|
<span class="btn-sm btn-secondary" id="badge-gachabase-status" style="font-size: 0.75rem; color: var(--accent-gold); border-color: rgba(245, 158, 11, 0.35);">พร้อมใช้งาน</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-subtitle">
|
||||||
|
ดึงข้อมูลตัวละคร, กรวยแสง, และรูปภาพความละเอียดสูงจาก Gachabase อัตโนมัติเมื่อเกมมีแพตช์ใหม่หรือมีคอนเทนต์เบต้า
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.85rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; background: rgba(0,0,0,0.25); padding: 0.85rem 1.1rem; border-radius: var(--radius-sm); border: 1px solid var(--border-color); flex-wrap: wrap; gap: 0.75rem;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 700; font-size: 0.95rem; color: #fff;">ฐานข้อมูลตัวละครและกรวยแสง</div>
|
||||||
|
<div style="font-size: 0.8rem; color: var(--text-muted); margin-top: 0.15rem;" id="lbl-gachabase-info">
|
||||||
|
ตัวละครในระบบ: 124+ ตัว | กรวยแสง: 172+ ชิ้น (รองรับเนื้อหา 3.0+ และเบต้า)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-gold" id="btn-sync-gachabase-now" style="font-weight: 700; display: flex; align-items: center; gap: 0.5rem; padding: 0.65rem 1.25rem;">
|
||||||
|
🔄 ซิงค์ข้อมูล & รูปภาพจาก Gachabase
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1387,81 +1412,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gachabase Datamine & Importer Modal -->
|
|
||||||
<div class="modal-overlay" id="modal-gachabase">
|
|
||||||
<div class="modal-content" style="max-width: 860px; max-height: 90vh; display: flex; flex-direction: column;">
|
|
||||||
<div class="modal-header" style="border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem; margin-bottom: 0.75rem;">
|
|
||||||
<div style="display: flex; align-items: center; gap: 0.6rem;">
|
|
||||||
<span style="font-size: 1.5rem;">🌐</span>
|
|
||||||
<div>
|
|
||||||
<h3 style="font-weight: 800; margin: 0; color: #fbbf24;">
|
|
||||||
Gachabase Data Fetcher & Importer
|
|
||||||
</h3>
|
|
||||||
<div style="font-size: 0.75rem; color: var(--text-muted);">
|
|
||||||
ดึงข้อมูลตัวละคร, กรวยแสง, และรีลิกส์จาก hsr.gachabase.net (รองรับเบต้าและเนื้อหาใหม่ล่าสุด)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-sm btn-secondary" id="btn-close-gachabase-modal">✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search & Input Bar -->
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 0.6rem; margin-bottom: 0.75rem;">
|
|
||||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
|
||||||
<input type="text" id="gachabase-input" class="form-input" placeholder="🔍 ใส่ ID เช่น 1511, 23065 หรือวาง URL Gachabase ทั้งหมด..." style="flex: 1; min-width: 220px;">
|
|
||||||
<select id="gachabase-type" class="form-select" style="max-width: 140px;">
|
|
||||||
<option value="character">👥 ตัวละคร</option>
|
|
||||||
<option value="lightcone">🗡️ กรวยแสง</option>
|
|
||||||
<option value="relic">🛡️ รีลิกส์</option>
|
|
||||||
</select>
|
|
||||||
<select id="gachabase-branch" class="form-select" style="max-width: 110px;">
|
|
||||||
<option value="beta" selected>Beta (ล่าสุด)</option>
|
|
||||||
<option value="live">Live (เซิร์ฟจริง)</option>
|
|
||||||
</select>
|
|
||||||
<select id="gachabase-lang" class="form-select" style="max-width: 100px;">
|
|
||||||
<option value="en" selected>English</option>
|
|
||||||
<option value="zh-CN">简体中文</option>
|
|
||||||
<option value="ja">日本語</option>
|
|
||||||
<option value="th">ไทย</option>
|
|
||||||
</select>
|
|
||||||
<button type="button" class="btn btn-primary" id="btn-gachabase-fetch" style="min-width: 110px; font-weight: 700;">
|
|
||||||
🔍 ดึงข้อมูล
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Pick Badges -->
|
|
||||||
<div style="display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; font-size: 0.75rem;">
|
|
||||||
<span style="color: var(--text-muted); font-weight: 600;">ตัวอย่างยอดนิยม:</span>
|
|
||||||
<button type="button" class="btn btn-sm btn-secondary gachabase-quick-chip" data-id="1511" data-type="character" style="padding: 0.15rem 0.5rem; font-size: 0.75rem;">🎭 Aha (1511)</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-secondary gachabase-quick-chip" data-id="23065" data-type="lightcone" style="padding: 0.15rem 0.5rem; font-size: 0.75rem;">🃏 LC "Aha" (23065)</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-secondary gachabase-quick-chip" data-id="1513" data-type="character" style="padding: 0.15rem 0.5rem; font-size: 0.75rem;">🌊 Waveflair (1513)</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-secondary gachabase-quick-chip" data-id="1505" data-type="character" style="padding: 0.15rem 0.5rem; font-size: 0.75rem;">✨ Evanescia (1505)</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-secondary gachabase-quick-chip" data-id="1502" data-type="character" style="padding: 0.15rem 0.5rem; font-size: 0.75rem;">⭐ Yao Guang (1502)</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-secondary gachabase-quick-chip" data-id="1501" data-type="character" style="padding: 0.15rem 0.5rem; font-size: 0.75rem;">⚡ Sparxie (1501)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status Indicator -->
|
|
||||||
<div id="gachabase-status" style="display: none; padding: 0.65rem 0.9rem; border-radius: var(--radius-sm); margin-bottom: 0.75rem; font-size: 0.85rem;"></div>
|
|
||||||
|
|
||||||
<!-- Preview Container -->
|
|
||||||
<div id="gachabase-preview-container" style="flex: 1; overflow-y: auto; padding-right: 0.35rem; display: none;">
|
|
||||||
<!-- Injected dynamically by JS -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal Footer -->
|
|
||||||
<div id="gachabase-footer-actions" style="display: none; justify-content: space-between; align-items: center; margin-top: 0.75rem; border-top: 1px solid var(--border-color); padding-top: 0.75rem; flex-wrap: wrap; gap: 0.5rem;">
|
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
|
||||||
<button type="button" class="btn btn-secondary" id="btn-gachabase-save-meta">💾 บันทึก Metadata เท่านั้น</button>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
|
||||||
<button type="button" class="btn btn-primary" id="btn-gachabase-import-e0s1" style="background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%); border-color: #38bdf8;">⚡ นำเข้า FreeSR (E0S1)</button>
|
|
||||||
<button type="button" class="btn btn-gold" id="btn-gachabase-import-e6s5">⭐ นำเข้า FreeSR (E6S5)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toast Notification Container -->
|
<!-- Toast Notification Container -->
|
||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,26 @@ import { AVATAR_METADATA, getAvatarInfo } from './avatars-data.js';
|
|||||||
import { LIGHTCONE_METADATA, RELIC_SET_METADATA, getLightconeInfo, getRelicSetInfo } from './equipment-data.js';
|
import { LIGHTCONE_METADATA, RELIC_SET_METADATA, getLightconeInfo, getRelicSetInfo } from './equipment-data.js';
|
||||||
import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js';
|
import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js';
|
||||||
import {
|
import {
|
||||||
parseGachabaseInput,
|
syncGachabaseDatabase,
|
||||||
fetchGachabaseHtml,
|
loadCachedGachabaseData,
|
||||||
parseGachabaseHtml,
|
getAvatarIconUrl,
|
||||||
registerGachabaseItem,
|
getLightconeArtUrl,
|
||||||
loadCachedGachabaseRegistry,
|
getLightconeThumbUrl,
|
||||||
applyGachabaseToFreeSR
|
handleAvatarImgError,
|
||||||
|
handleLcImgError,
|
||||||
|
GACHABASE_IMAGE_CACHE,
|
||||||
|
KNOWN_GACHABASE_ASSETS
|
||||||
} from './gachabase.js';
|
} from './gachabase.js';
|
||||||
|
|
||||||
|
// Expose image resolvers & error handlers to window for inline onclick/onerror attributes
|
||||||
|
window.handleAvatarImgError = handleAvatarImgError;
|
||||||
|
window.handleLcImgError = handleLcImgError;
|
||||||
|
window.getAvatarIconUrl = getAvatarIconUrl;
|
||||||
|
window.getLightconeArtUrl = getLightconeArtUrl;
|
||||||
|
window.getLightconeThumbUrl = getLightconeThumbUrl;
|
||||||
|
window.GACHABASE_IMAGE_CACHE = GACHABASE_IMAGE_CACHE;
|
||||||
|
window.KNOWN_GACHABASE_ASSETS = KNOWN_GACHABASE_ASSETS;
|
||||||
|
|
||||||
// Application State
|
// Application State
|
||||||
const state = {
|
const state = {
|
||||||
serverMode: localStorage.getItem('gac_srtool_mode') || 'gac', // 'gac' | 'firefly' | 'robin'
|
serverMode: localStorage.getItem('gac_srtool_mode') || 'gac', // 'gac' | 'firefly' | 'robin'
|
||||||
@@ -66,25 +78,14 @@ const el = {
|
|||||||
btnQuickAddAllLcs: document.getElementById('btn-quick-add-all-lcs'),
|
btnQuickAddAllLcs: document.getElementById('btn-quick-add-all-lcs'),
|
||||||
btnQuickTeleportTrain: document.getElementById('btn-quick-teleport-train'),
|
btnQuickTeleportTrain: document.getElementById('btn-quick-teleport-train'),
|
||||||
btnQuickClearLineup: document.getElementById('btn-quick-clear-lineup'),
|
btnQuickClearLineup: document.getElementById('btn-quick-clear-lineup'),
|
||||||
btnQuickGachabase: document.getElementById('btn-quick-gachabase'),
|
// Gachabase Sync
|
||||||
btnOpenGachabase: document.getElementById('btn-open-gachabase'),
|
btnHeaderGachabaseSync: document.getElementById('btn-header-gachabase-sync'),
|
||||||
btnAvatarsGachabase: document.getElementById('btn-avatars-gachabase'),
|
btnQuickGachabaseSync: document.getElementById('btn-quick-gachabase-sync'),
|
||||||
btnEqGachabase: document.getElementById('btn-eq-gachabase'),
|
btnSyncGachabaseAvatars: document.getElementById('btn-sync-gachabase-avatars'),
|
||||||
|
btnSyncGachabaseEq: document.getElementById('btn-sync-gachabase-eq'),
|
||||||
// Gachabase Modal
|
btnSyncGachabaseNow: document.getElementById('btn-sync-gachabase-now'),
|
||||||
modalGachabase: document.getElementById('modal-gachabase'),
|
badgeGachabaseStatus: document.getElementById('badge-gachabase-status'),
|
||||||
btnCloseGachabaseModal: document.getElementById('btn-close-gachabase-modal'),
|
lblGachabaseInfo: document.getElementById('lbl-gachabase-info'),
|
||||||
gachabaseInput: document.getElementById('gachabase-input'),
|
|
||||||
gachabaseType: document.getElementById('gachabase-type'),
|
|
||||||
gachabaseBranch: document.getElementById('gachabase-branch'),
|
|
||||||
gachabaseLang: document.getElementById('gachabase-lang'),
|
|
||||||
btnGachabaseFetch: document.getElementById('btn-gachabase-fetch'),
|
|
||||||
gachabaseStatus: document.getElementById('gachabase-status'),
|
|
||||||
gachabasePreviewContainer: document.getElementById('gachabase-preview-container'),
|
|
||||||
gachabaseFooterActions: document.getElementById('gachabase-footer-actions'),
|
|
||||||
btnGachabaseSaveMeta: document.getElementById('btn-gachabase-save-meta'),
|
|
||||||
btnGachabaseImportE0S1: document.getElementById('btn-gachabase-import-e0s1'),
|
|
||||||
btnGachabaseImportE6S5: document.getElementById('btn-gachabase-import-e6s5'),
|
|
||||||
|
|
||||||
// Avatars
|
// Avatars
|
||||||
avatarsGrid: document.getElementById('avatars-grid'),
|
avatarsGrid: document.getElementById('avatars-grid'),
|
||||||
@@ -977,8 +978,8 @@ function renderLineup(lineups) {
|
|||||||
<div class="lineup-slot-num">Slot ${slot + 1}</div>
|
<div class="lineup-slot-num">Slot ${slot + 1}</div>
|
||||||
${removeSlotBtnHtml}
|
${removeSlotBtnHtml}
|
||||||
<img class="lineup-slot-img"
|
<img class="lineup-slot-img"
|
||||||
src="./assets/avatars/${avatarId}.png"
|
src="${getAvatarIconSrc(avatarId)}"
|
||||||
onerror="if(this.src.indexOf('jsdelivr')===-1){this.src='https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${avatarId}.png';}else{this.style.display='none';}"
|
onerror="handleAvatarImgError(this, ${avatarId})"
|
||||||
alt="${info.name}">
|
alt="${info.name}">
|
||||||
<div style="font-weight: 800; font-size: 1rem; color: var(--text-highlight); margin-top: 0.35rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;">${info.name}</div>
|
<div style="font-weight: 800; font-size: 1rem; color: var(--text-highlight); margin-top: 0.35rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;">${info.name}</div>
|
||||||
<div style="display: flex; gap: 0.4rem; margin-top: 0.4rem; justify-content: center; align-items: center;">
|
<div style="display: flex; gap: 0.4rem; margin-top: 0.4rem; justify-content: center; align-items: center;">
|
||||||
@@ -1135,8 +1136,8 @@ function renderPickerGrid() {
|
|||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="picker-avatar-img-wrap">
|
<div class="picker-avatar-img-wrap">
|
||||||
<img class="picker-avatar-img"
|
<img class="picker-avatar-img"
|
||||||
src="./assets/avatars/${item.avatar_id}.png"
|
src="${getAvatarIconSrc(item.avatar_id)}"
|
||||||
onerror="if(this.src.indexOf('jsdelivr')===-1){this.src='https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${item.avatar_id}.png';}else{this.style.display='none';}"
|
onerror="handleAvatarImgError(this, ${item.avatar_id})"
|
||||||
alt="${meta.name}">
|
alt="${meta.name}">
|
||||||
</div>
|
</div>
|
||||||
<div class="picker-avatar-info">
|
<div class="picker-avatar-info">
|
||||||
@@ -1266,8 +1267,8 @@ function renderAvatarsGrid() {
|
|||||||
<div class="avatar-header-info">
|
<div class="avatar-header-info">
|
||||||
<div class="avatar-image-container">
|
<div class="avatar-image-container">
|
||||||
<img class="avatar-img"
|
<img class="avatar-img"
|
||||||
src="./assets/avatars/${item.avatar_id}.png"
|
src="${getAvatarIconSrc(item.avatar_id)}"
|
||||||
onerror="if(this.src.indexOf('jsdelivr')===-1){this.src='https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${item.avatar_id}.png';}else{this.style.display='none';this.nextElementSibling.style.display='flex';}"
|
onerror="handleAvatarImgError(this, ${item.avatar_id})"
|
||||||
alt="${meta.name}">
|
alt="${meta.name}">
|
||||||
<div class="avatar-icon-placeholder" style="display: none;">${meta.name.charAt(0)}</div>
|
<div class="avatar-icon-placeholder" style="display: none;">${meta.name.charAt(0)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1575,12 +1576,14 @@ const RECOMMENDED_RELIC_BUILDS = {
|
|||||||
Elation: { cavern: 115, planar: 306, name: "Grand Duke (4) + Inert Salsotto (2)" }
|
Elation: { cavern: 115, planar: 306, name: "Grand Duke (4) + Inert Salsotto (2)" }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lightcone Art URL Resolvers (Local asset -> Gachabase -> StarRailRes)
|
// Lightcone Art URL Resolvers (Metadata -> Gachabase CDN -> Local asset -> StarRailRes)
|
||||||
function getLightconeArtSrc(lcId) {
|
function getLightconeArtSrc(lcId) {
|
||||||
const id = parseInt(lcId, 10);
|
const id = parseInt(lcId, 10);
|
||||||
const meta = LIGHTCONE_METADATA[id];
|
const meta = LIGHTCONE_METADATA[id];
|
||||||
if (meta?.images?.fullArt) return meta.images.fullArt;
|
if (meta?.images?.fullArt) return meta.images.fullArt;
|
||||||
if (meta?.images?.thumbnail) return meta.images.thumbnail;
|
if (meta?.images?.thumbnail) return meta.images.thumbnail;
|
||||||
|
if (GACHABASE_IMAGE_CACHE.lightcones[id]) return GACHABASE_IMAGE_CACHE.lightcones[id];
|
||||||
|
if (KNOWN_GACHABASE_ASSETS.lightcones[id]) return KNOWN_GACHABASE_ASSETS.lightcones[id];
|
||||||
if (id === 23065) {
|
if (id === 23065) {
|
||||||
return './assets/lightcones/23065.png';
|
return './assets/lightcones/23065.png';
|
||||||
}
|
}
|
||||||
@@ -1592,6 +1595,10 @@ function getLightconeArtFallback(lcId) {
|
|||||||
const meta = LIGHTCONE_METADATA[id];
|
const meta = LIGHTCONE_METADATA[id];
|
||||||
if (meta?.images?.thumbnail) return meta.images.thumbnail;
|
if (meta?.images?.thumbnail) return meta.images.thumbnail;
|
||||||
if (meta?.images?.fullArt) return meta.images.fullArt;
|
if (meta?.images?.fullArt) return meta.images.fullArt;
|
||||||
|
if (GACHABASE_IMAGE_CACHE.lightconeThumbs[id]) return GACHABASE_IMAGE_CACHE.lightconeThumbs[id];
|
||||||
|
if (KNOWN_GACHABASE_ASSETS.lightconeThumbs[id]) return KNOWN_GACHABASE_ASSETS.lightconeThumbs[id];
|
||||||
|
if (GACHABASE_IMAGE_CACHE.lightcones[id]) return GACHABASE_IMAGE_CACHE.lightcones[id];
|
||||||
|
if (KNOWN_GACHABASE_ASSETS.lightcones[id]) return KNOWN_GACHABASE_ASSETS.lightcones[id];
|
||||||
if (id === 23065) {
|
if (id === 23065) {
|
||||||
return 'https://cdn.gachabase.net/hsr/assets/500cfc18698932759756f6b3ba4db417.png';
|
return 'https://cdn.gachabase.net/hsr/assets/500cfc18698932759756f6b3ba4db417.png';
|
||||||
}
|
}
|
||||||
@@ -1603,6 +1610,8 @@ function getAvatarIconSrc(aid) {
|
|||||||
const meta = AVATAR_METADATA[id];
|
const meta = AVATAR_METADATA[id];
|
||||||
if (meta?.images?.icon) return meta.images.icon;
|
if (meta?.images?.icon) return meta.images.icon;
|
||||||
if (meta?.images?.square) return meta.images.square;
|
if (meta?.images?.square) return meta.images.square;
|
||||||
|
if (GACHABASE_IMAGE_CACHE.avatars[id]) return GACHABASE_IMAGE_CACHE.avatars[id];
|
||||||
|
if (KNOWN_GACHABASE_ASSETS.avatars[id]) return KNOWN_GACHABASE_ASSETS.avatars[id];
|
||||||
return `./assets/avatars/${id}.png`;
|
return `./assets/avatars/${id}.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1611,6 +1620,8 @@ function getAvatarSplashSrc(aid) {
|
|||||||
const meta = AVATAR_METADATA[id];
|
const meta = AVATAR_METADATA[id];
|
||||||
if (meta?.images?.splash) return meta.images.splash;
|
if (meta?.images?.splash) return meta.images.splash;
|
||||||
if (meta?.images?.fullArt) return meta.images.fullArt;
|
if (meta?.images?.fullArt) return meta.images.fullArt;
|
||||||
|
if (GACHABASE_IMAGE_CACHE.avatarSplash[id]) return GACHABASE_IMAGE_CACHE.avatarSplash[id];
|
||||||
|
if (KNOWN_GACHABASE_ASSETS.avatarSplash[id]) return KNOWN_GACHABASE_ASSETS.avatarSplash[id];
|
||||||
return `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/image/character_portrait/${id}.png`;
|
return `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/image/character_portrait/${id}.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1888,8 +1899,8 @@ function renderModalSwitcher(currentAvatarId) {
|
|||||||
item.className = `modal-char-switcher-item ${av.avatar_id === currentAvatarId ? 'active' : ''}`;
|
item.className = `modal-char-switcher-item ${av.avatar_id === currentAvatarId ? 'active' : ''}`;
|
||||||
item.title = `${meta.name} (ID: ${av.avatar_id})`;
|
item.title = `${meta.name} (ID: ${av.avatar_id})`;
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<img src="./assets/avatars/${av.avatar_id}.png"
|
<img src="${getAvatarIconSrc(av.avatar_id)}"
|
||||||
onerror="if(this.src.indexOf('jsdelivr')===-1){this.src='https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${av.avatar_id}.png';}else{this.style.display='none';}"
|
onerror="handleAvatarImgError(this, ${av.avatar_id})"
|
||||||
alt="${meta.name}">
|
alt="${meta.name}">
|
||||||
`;
|
`;
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
@@ -1909,12 +1920,9 @@ function renderModalOverviewTab(avatarId) {
|
|||||||
|
|
||||||
// Portrait Art
|
// Portrait Art
|
||||||
if (el.overviewPortraitImg) {
|
if (el.overviewPortraitImg) {
|
||||||
el.overviewPortraitImg.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/image/character_portrait/${aid}.png`;
|
el.overviewPortraitImg.src = getAvatarSplashSrc(aid);
|
||||||
el.overviewPortraitImg.onerror = function() {
|
el.overviewPortraitImg.onerror = function() {
|
||||||
this.src = `./assets/avatars/${aid}.png`;
|
handleAvatarImgError(this, aid);
|
||||||
this.onerror = function() {
|
|
||||||
this.src = `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${aid}.png`;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1940,12 +1948,7 @@ function renderModalOverviewTab(avatarId) {
|
|||||||
if (el.overviewLcThumb) {
|
if (el.overviewLcThumb) {
|
||||||
el.overviewLcThumb.src = getLightconeArtSrc(lcId);
|
el.overviewLcThumb.src = getLightconeArtSrc(lcId);
|
||||||
el.overviewLcThumb.onerror = function() {
|
el.overviewLcThumb.onerror = function() {
|
||||||
const fallback = getLightconeArtFallback(lcId);
|
handleLcImgError(this, lcId);
|
||||||
if (this.src !== fallback) {
|
|
||||||
this.src = fallback;
|
|
||||||
} else {
|
|
||||||
this.src = './assets/logo.png';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (el.overviewLcName) el.overviewLcName.textContent = lcInfo.name || `Light Cone ${lcId}`;
|
if (el.overviewLcName) el.overviewLcName.textContent = lcInfo.name || `Light Cone ${lcId}`;
|
||||||
@@ -2076,9 +2079,7 @@ function renderModalRelicsTab(avatarId) {
|
|||||||
if (lcThumbEl) {
|
if (lcThumbEl) {
|
||||||
lcThumbEl.src = getLightconeArtSrc(lcId);
|
lcThumbEl.src = getLightconeArtSrc(lcId);
|
||||||
lcThumbEl.onerror = function() {
|
lcThumbEl.onerror = function() {
|
||||||
const fb = getLightconeArtFallback(lcId);
|
handleLcImgError(this, lcId);
|
||||||
if (this.src !== fb) this.src = fb;
|
|
||||||
else this.src = './assets/logo.png';
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (lcNameEl) lcNameEl.textContent = lcInfo.name || `Light Cone ${lcId}`;
|
if (lcNameEl) lcNameEl.textContent = lcInfo.name || `Light Cone ${lcId}`;
|
||||||
@@ -2710,9 +2711,9 @@ function openAvatarDetailModal(avatarItem) {
|
|||||||
const meta = getAvatarInfo(aid);
|
const meta = getAvatarInfo(aid);
|
||||||
|
|
||||||
el.modalCharTitle.textContent = `${t('modalTitleCharEdit')}: ${meta.name} (ID: ${aid})`;
|
el.modalCharTitle.textContent = `${t('modalTitleCharEdit')}: ${meta.name} (ID: ${aid})`;
|
||||||
el.modalCharImg.src = `./assets/avatars/${aid}.png`;
|
el.modalCharImg.src = getAvatarIconSrc(aid);
|
||||||
el.modalCharImg.onerror = function() {
|
el.modalCharImg.onerror = function() {
|
||||||
this.src = `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${aid}.png`;
|
handleAvatarImgError(this, aid);
|
||||||
};
|
};
|
||||||
el.modalCharName.textContent = meta.name;
|
el.modalCharName.textContent = meta.name;
|
||||||
el.modalCharMeta.innerHTML = `
|
el.modalCharMeta.innerHTML = `
|
||||||
@@ -3274,15 +3275,14 @@ function renderShowcaseCard(avatarId) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryPortrait = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/image/character_portrait/${aid}.png`;
|
const primaryPortrait = getAvatarSplashSrc(aid);
|
||||||
const fallbackPortrait = `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/image/character_portrait/${aid}.png`;
|
const fallbackPortrait = `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/image/character_portrait/${aid}.png`;
|
||||||
const lcArtThumb = getLightconeArtSrc(lcId);
|
const lcArtThumb = getLightconeArtSrc(lcId);
|
||||||
const fallbackLcThumb = getLightconeArtFallback(lcId);
|
|
||||||
|
|
||||||
el.showcaseCardContainer.innerHTML = `
|
el.showcaseCardContainer.innerHTML = `
|
||||||
<!-- 1. Left Splash Art Column -->
|
<!-- 1. Left Splash Art Column -->
|
||||||
<div class="showcase-splash-col">
|
<div class="showcase-splash-col">
|
||||||
<img class="showcase-splash-img" src="${primaryPortrait}" onerror="if(this.src!=='${fallbackPortrait}')this.src='${fallbackPortrait}';else this.src='./assets/avatars/${aid}.png';" alt="${meta.name}">
|
<img class="showcase-splash-img" src="${primaryPortrait}" onerror="if(this.src!=='${fallbackPortrait}')this.src='${fallbackPortrait}';else handleAvatarImgError(this, ${aid});" alt="${meta.name}">
|
||||||
<div class="showcase-splash-overlay">
|
<div class="showcase-splash-overlay">
|
||||||
<div class="showcase-char-name">${meta.name}</div>
|
<div class="showcase-char-name">${meta.name}</div>
|
||||||
<div style="display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap;">
|
<div style="display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap;">
|
||||||
@@ -3300,7 +3300,7 @@ function renderShowcaseCard(avatarId) {
|
|||||||
<div class="showcase-center-col">
|
<div class="showcase-center-col">
|
||||||
<!-- Lightcone Box -->
|
<!-- Lightcone Box -->
|
||||||
<div class="showcase-lc-box">
|
<div class="showcase-lc-box">
|
||||||
<img class="showcase-lc-thumb" src="${lcArtThumb}" onerror="if(this.src!=='${fallbackLcThumb}')this.src='${fallbackLcThumb}';else this.src='./assets/logo.png';" alt="${lcInfo.name}">
|
<img class="showcase-lc-thumb" src="${lcArtThumb}" onerror="handleLcImgError(this, ${lcId})" alt="${lcInfo.name}">
|
||||||
<div style="flex: 1; min-width: 0;">
|
<div style="flex: 1; min-width: 0;">
|
||||||
<div style="font-weight: 800; font-size: 0.95rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${lcInfo.name}</div>
|
<div style="font-weight: 800; font-size: 0.95rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${lcInfo.name}</div>
|
||||||
<div style="font-size: 0.775rem; color: var(--text-muted); margin-top: 0.2rem;">
|
<div style="font-size: 0.775rem; color: var(--text-muted); margin-top: 0.2rem;">
|
||||||
@@ -4017,15 +4017,14 @@ function renderLightconesGrid() {
|
|||||||
|
|
||||||
const wearerBadgeHtml = lc.equip_avatar ? `
|
const wearerBadgeHtml = lc.equip_avatar ? `
|
||||||
<div class="eq-tile-wearer-badge" title="สวมใส่โดย: ${getAvatarInfo(lc.equip_avatar).name}">
|
<div class="eq-tile-wearer-badge" title="สวมใส่โดย: ${getAvatarInfo(lc.equip_avatar).name}">
|
||||||
<img class="eq-tile-wearer-img" src="https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/icon/character/${lc.equip_avatar}.png" onerror="if(this.src!=='./assets/avatars/${lc.equip_avatar}.png')this.src='./assets/avatars/${lc.equip_avatar}.png';else this.src='./assets/logo.png';" alt="Wearer">
|
<img class="eq-tile-wearer-img" src="${getAvatarIconSrc(lc.equip_avatar)}" onerror="handleAvatarImgError(this, ${lc.equip_avatar})" alt="Wearer">
|
||||||
</div>
|
</div>
|
||||||
` : '';
|
` : '';
|
||||||
|
|
||||||
const primaryImg = getLightconeArtSrc(lc.item_id);
|
const primaryImg = getLightconeArtSrc(lc.item_id);
|
||||||
const fallbackImg = getLightconeArtFallback(lc.item_id);
|
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<img class="eq-tile-img" src="${primaryImg}" onerror="if(this.src!=='${fallbackImg}')this.src='${fallbackImg}';else this.src='./assets/logo.png';" alt="${meta.name}">
|
<img class="eq-tile-img" src="${primaryImg}" onerror="handleLcImgError(this, ${lc.item_id})" alt="${meta.name}">
|
||||||
<div class="eq-tile-top-tag">${roman}</div>
|
<div class="eq-tile-top-tag">${roman}</div>
|
||||||
${wearerBadgeHtml}
|
${wearerBadgeHtml}
|
||||||
<div class="eq-tile-bottom-bar">
|
<div class="eq-tile-bottom-bar">
|
||||||
@@ -4085,7 +4084,7 @@ function renderRelicsGrid() {
|
|||||||
|
|
||||||
const wearerBadgeHtml = r.equip_avatar ? `
|
const wearerBadgeHtml = r.equip_avatar ? `
|
||||||
<div class="eq-tile-wearer-badge" title="สวมใส่โดย: ${getAvatarInfo(r.equip_avatar).name}">
|
<div class="eq-tile-wearer-badge" title="สวมใส่โดย: ${getAvatarInfo(r.equip_avatar).name}">
|
||||||
<img class="eq-tile-wearer-img" src="https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/icon/character/${r.equip_avatar}.png" onerror="if(this.src!=='./assets/avatars/${r.equip_avatar}.png')this.src='./assets/avatars/${r.equip_avatar}.png';else this.src='./assets/logo.png';" alt="Wearer">
|
<img class="eq-tile-wearer-img" src="${getAvatarIconSrc(r.equip_avatar)}" onerror="handleAvatarImgError(this, ${r.equip_avatar})" alt="Wearer">
|
||||||
</div>
|
</div>
|
||||||
` : '';
|
` : '';
|
||||||
|
|
||||||
@@ -4132,13 +4131,12 @@ function showLcInspector(lc) {
|
|||||||
const def = lcMeta.def || (lcMeta.rarity === 5 ? 529 : (lcMeta.rarity === 4 ? 396 : 264));
|
const def = lcMeta.def || (lcMeta.rarity === 5 ? 529 : (lcMeta.rarity === 4 ? 396 : 264));
|
||||||
|
|
||||||
const primaryThumb = getLightconeArtSrc(lc.item_id);
|
const primaryThumb = getLightconeArtSrc(lc.item_id);
|
||||||
const fallbackThumb = getLightconeArtFallback(lc.item_id);
|
|
||||||
|
|
||||||
el.eqInspectorCard.style.display = 'flex';
|
el.eqInspectorCard.style.display = 'flex';
|
||||||
el.eqInspectorCard.innerHTML = `
|
el.eqInspectorCard.innerHTML = `
|
||||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||||
<div class="eq-inspector-hero">
|
<div class="eq-inspector-hero">
|
||||||
<img class="eq-inspector-thumb" src="${primaryThumb}" onerror="if(this.src!=='${fallbackThumb}')this.src='${fallbackThumb}';else this.src='./assets/logo.png';" alt="${lcInfo.name}">
|
<img class="eq-inspector-thumb" src="${primaryThumb}" onerror="handleLcImgError(this, ${lc.item_id})" alt="${lcInfo.name}">
|
||||||
<div class="eq-inspector-info">
|
<div class="eq-inspector-info">
|
||||||
<div class="eq-inspector-title">${lcInfo.name}</div>
|
<div class="eq-inspector-title">${lcInfo.name}</div>
|
||||||
<div class="eq-inspector-stars">${'★'.repeat(lcInfo.rarity || 5)}</div>
|
<div class="eq-inspector-stars">${'★'.repeat(lcInfo.rarity || 5)}</div>
|
||||||
@@ -4386,7 +4384,7 @@ function updateAddLcPreview(lcId) {
|
|||||||
const previewThumb = document.getElementById('add-lc-preview-thumb');
|
const previewThumb = document.getElementById('add-lc-preview-thumb');
|
||||||
if (previewThumb) {
|
if (previewThumb) {
|
||||||
previewThumb.src = getLightconeArtSrc(id);
|
previewThumb.src = getLightconeArtSrc(id);
|
||||||
previewThumb.onerror = function() { this.src = getLightconeArtFallback(id); };
|
previewThumb.onerror = function() { handleLcImgError(this, id); };
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewName = document.getElementById('add-lc-preview-name');
|
const previewName = document.getElementById('add-lc-preview-name');
|
||||||
@@ -4463,7 +4461,7 @@ function renderAddLcVisualGrid() {
|
|||||||
card.dataset.lcId = id;
|
card.dataset.lcId = id;
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<img class="add-lc-card-thumb" src="${getLightconeArtSrc(id)}" onerror="this.src='${getLightconeArtFallback(id)}'" alt="${info.name}" loading="lazy">
|
<img class="add-lc-card-thumb" src="${getLightconeArtSrc(id)}" onerror="handleLcImgError(this, ${id})" alt="${info.name}" loading="lazy">
|
||||||
<div class="add-lc-card-body">
|
<div class="add-lc-card-body">
|
||||||
<div class="add-lc-card-name" title="${info.name}">${info.name}</div>
|
<div class="add-lc-card-name" title="${info.name}">${info.name}</div>
|
||||||
<div class="add-lc-card-stars">${'★'.repeat(info.rarity || 5)}</div>
|
<div class="add-lc-card-stars">${'★'.repeat(info.rarity || 5)}</div>
|
||||||
@@ -4706,8 +4704,8 @@ function renderExRanks(exList) {
|
|||||||
<div style="display: flex; align-items: center; gap: 1rem;">
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
<div class="avatar-image-container" style="width: 52px; height: 52px;">
|
<div class="avatar-image-container" style="width: 52px; height: 52px;">
|
||||||
<img class="avatar-img"
|
<img class="avatar-img"
|
||||||
src="./assets/avatars/${ex.avatar_id}.png"
|
src="${getAvatarIconSrc(ex.avatar_id)}"
|
||||||
onerror="if(this.src.indexOf('jsdelivr')===-1){this.src='https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${ex.avatar_id}.png';}else{this.style.display='none';}"
|
onerror="handleAvatarImgError(this, ${ex.avatar_id})"
|
||||||
alt="${info.name}">
|
alt="${info.name}">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -4966,6 +4964,7 @@ function setupTabs() {
|
|||||||
loadJsonData(state.currentJsonMode);
|
loadJsonData(state.currentJsonMode);
|
||||||
} else if (targetId === 'tab-settings') {
|
} else if (targetId === 'tab-settings') {
|
||||||
updateCacheUsageDisplay();
|
updateCacheUsageDisplay();
|
||||||
|
updateGachabaseSyncUI();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -4973,323 +4972,134 @@ function setupTabs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Gachabase Data Fetcher & Importer Logic
|
// Gachabase Catalog Sync & Dynamic Image Engine
|
||||||
// ==========================================
|
// ==========================================
|
||||||
let currentGachabaseItem = null;
|
let isGachabaseSyncing = false;
|
||||||
|
|
||||||
function openGachabaseModal(defaultId = null, defaultType = 'character') {
|
async function updateGachabaseSyncUI() {
|
||||||
if (!el.modalGachabase) return;
|
if (!el.badgeGachabaseStatus && !el.lblGachabaseInfo) return;
|
||||||
el.modalGachabase.classList.add('active');
|
try {
|
||||||
if (defaultId) {
|
const lastSync = await idbGet('gac_gachabase_last_sync');
|
||||||
if (el.gachabaseInput) el.gachabaseInput.value = String(defaultId);
|
if (lastSync && lastSync.timestamp) {
|
||||||
if (el.gachabaseType) el.gachabaseType.value = defaultType;
|
const dateStr = new Date(lastSync.timestamp).toLocaleString();
|
||||||
performGachabaseFetch();
|
if (el.badgeGachabaseStatus) {
|
||||||
|
el.badgeGachabaseStatus.textContent = 'ซิงค์แล้ว';
|
||||||
|
el.badgeGachabaseStatus.style.color = 'var(--accent-green)';
|
||||||
|
el.badgeGachabaseStatus.style.borderColor = 'rgba(52, 211, 153, 0.4)';
|
||||||
|
}
|
||||||
|
if (el.lblGachabaseInfo) {
|
||||||
|
el.lblGachabaseInfo.innerHTML = `ซิงค์ล่าสุดเมื่อ: <strong>${dateStr}</strong> (ตัวละครในระบบ: ${lastSync.totalAvatarsCount || Object.keys(AVATAR_METADATA).length}, กรวยแสง: ${lastSync.totalLcsCount || Object.keys(LIGHTCONE_METADATA).length})`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setTimeout(() => el.gachabaseInput?.focus(), 50);
|
if (el.badgeGachabaseStatus) {
|
||||||
|
el.badgeGachabaseStatus.textContent = 'ยังไม่เคยซิงค์';
|
||||||
|
el.badgeGachabaseStatus.style.color = 'var(--accent-gold)';
|
||||||
|
el.badgeGachabaseStatus.style.borderColor = 'rgba(245, 158, 11, 0.4)';
|
||||||
|
}
|
||||||
|
if (el.lblGachabaseInfo) {
|
||||||
|
el.lblGachabaseInfo.textContent = 'ยังไม่ได้ซิงค์แคตตาล็อกเกมจาก Gachabase กดปุ่มด้านล่างเพื่ออัปเดต';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Error reading gachabase sync status:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeGachabaseModal() {
|
async function triggerGachabaseSync(showToastOnDone = true) {
|
||||||
if (!el.modalGachabase) return;
|
if (isGachabaseSyncing) {
|
||||||
el.modalGachabase.classList.remove('active');
|
showToast('กำลังซิงค์ข้อมูลจาก Gachabase อยู่แล้ว กรุณารอสักครู่...', 'warning');
|
||||||
}
|
|
||||||
|
|
||||||
async function performGachabaseFetch() {
|
|
||||||
const input = el.gachabaseInput?.value?.trim();
|
|
||||||
if (!input) {
|
|
||||||
showToast('กรุณาระบุ ID หรือ URL ของ Gachabase', 'warning');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = el.gachabaseType?.value || 'character';
|
isGachabaseSyncing = true;
|
||||||
const branch = el.gachabaseBranch?.value || 'beta';
|
const syncButtons = [
|
||||||
const lang = el.gachabaseLang?.value || 'en';
|
el.btnHeaderGachabaseSync,
|
||||||
|
el.btnQuickGachabaseSync,
|
||||||
|
el.btnSyncGachabaseAvatars,
|
||||||
|
el.btnSyncGachabaseEq,
|
||||||
|
el.btnSyncGachabaseNow
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
const parsedParams = parseGachabaseInput(input, type, branch, lang);
|
syncButtons.forEach(btn => {
|
||||||
if (!parsedParams) {
|
btn.disabled = true;
|
||||||
showToast('รูปแบบข้อมูลไม่ถูกต้อง กรุณาระบุ ID ตัวเลขหรือ URL', 'error');
|
btn.classList.add('loading');
|
||||||
return;
|
});
|
||||||
|
|
||||||
|
if (el.badgeGachabaseStatus) {
|
||||||
|
el.badgeGachabaseStatus.textContent = 'กำลังซิงค์...';
|
||||||
|
el.badgeGachabaseStatus.style.color = 'var(--accent-cyan)';
|
||||||
|
el.badgeGachabaseStatus.style.borderColor = 'rgba(56, 189, 248, 0.4)';
|
||||||
|
}
|
||||||
|
if (el.lblGachabaseInfo) {
|
||||||
|
el.lblGachabaseInfo.textContent = 'กำลังดึงฐานข้อมูลตัวละครและกรวยแสงจาก Gachabase CDN...';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedParams.type && el.gachabaseType) el.gachabaseType.value = parsedParams.type;
|
showToast('🌐 เริ่มซิงค์ฐานข้อมูลตัวละครและรูปภาพจาก Gachabase...', 'info');
|
||||||
if (parsedParams.branch && el.gachabaseBranch) el.gachabaseBranch.value = parsedParams.branch;
|
|
||||||
|
|
||||||
if (el.gachabaseStatus) {
|
|
||||||
el.gachabaseStatus.style.display = 'block';
|
|
||||||
el.gachabaseStatus.style.background = 'rgba(56, 189, 248, 0.15)';
|
|
||||||
el.gachabaseStatus.style.border = '1px solid rgba(56, 189, 248, 0.4)';
|
|
||||||
el.gachabaseStatus.style.color = 'var(--accent-cyan)';
|
|
||||||
el.gachabaseStatus.innerHTML = `🔄 กำลังดึงข้อมูลจาก Gachabase (<code style="color: #fff;">${parsedParams.url}</code>)...`;
|
|
||||||
}
|
|
||||||
if (el.gachabasePreviewContainer) el.gachabasePreviewContainer.style.display = 'none';
|
|
||||||
if (el.gachabaseFooterActions) el.gachabaseFooterActions.style.display = 'none';
|
|
||||||
if (el.btnGachabaseFetch) el.btnGachabaseFetch.disabled = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { html, finalUrl } = await fetchGachabaseHtml(parsedParams.url, state.apiUrl);
|
const currentLang = getCurrentLanguage();
|
||||||
const parsedItem = parseGachabaseHtml(html, lang);
|
const result = await syncGachabaseDatabase({
|
||||||
parsedItem.sourceUrl = finalUrl;
|
apiUrl: state.apiUrl,
|
||||||
parsedItem.branch = parsedParams.branch;
|
branch: 'beta',
|
||||||
parsedItem.lang = lang;
|
lang: currentLang === 'th' ? 'th' : 'en',
|
||||||
currentGachabaseItem = parsedItem;
|
metadataContext: {
|
||||||
|
AVATAR_METADATA,
|
||||||
if (el.gachabaseStatus) {
|
LIGHTCONE_METADATA,
|
||||||
el.gachabaseStatus.style.background = 'rgba(52, 211, 153, 0.15)';
|
SIGNATURE_LIGHTCONES,
|
||||||
el.gachabaseStatus.style.border = '1px solid rgba(52, 211, 153, 0.4)';
|
idbSet,
|
||||||
el.gachabaseStatus.style.color = 'var(--accent-green)';
|
idbGet
|
||||||
el.gachabaseStatus.innerHTML = `✅ ดึงข้อมูลสำเร็จ: <strong>[${parsedItem.rarity}★] ${parsedItem.name}</strong> (ID: ${parsedItem.id})`;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
renderGachabasePreview(parsedItem);
|
initAvatarsList(true);
|
||||||
|
renderAvatarsGrid();
|
||||||
|
loadEquipment();
|
||||||
|
if (state.selectedAvatarDetail) {
|
||||||
|
renderModalSwitcher(state.selectedAvatarDetail.avatar_id);
|
||||||
|
}
|
||||||
|
await updateGachabaseSyncUI();
|
||||||
|
|
||||||
|
if (showToastOnDone) {
|
||||||
|
showToast(`✅ ซิงค์สำเร็จ! ตรวจพบตัวละครทั้งหมด ${result.totalAvatarsCount} ตัว (+ใหม่ ${result.newAvatarsCount}), กรวยแสง ${result.totalLcsCount} ชิ้น (+ใหม่ ${result.newLcsCount}) พร้อมผูกรูป Gachabase CDN เรียบร้อย`, 'success');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Gachabase fetch error:', err);
|
console.error('Gachabase sync failed:', err);
|
||||||
if (el.gachabaseStatus) {
|
if (el.badgeGachabaseStatus) {
|
||||||
el.gachabaseStatus.style.background = 'rgba(248, 113, 113, 0.15)';
|
el.badgeGachabaseStatus.textContent = 'ซิงค์ล้มเหลว';
|
||||||
el.gachabaseStatus.style.border = '1px solid rgba(248, 113, 113, 0.4)';
|
el.badgeGachabaseStatus.style.color = 'var(--accent-red)';
|
||||||
el.gachabaseStatus.style.color = 'var(--accent-red)';
|
el.badgeGachabaseStatus.style.borderColor = 'rgba(248, 113, 113, 0.4)';
|
||||||
el.gachabaseStatus.innerHTML = `❌ เกิดข้อผิดพลาดในการดึงข้อมูล: ${err.message}`;
|
|
||||||
}
|
}
|
||||||
|
if (el.lblGachabaseInfo) {
|
||||||
|
el.lblGachabaseInfo.textContent = `ข้อผิดพลาด: ${err.message}`;
|
||||||
|
}
|
||||||
|
showToast(`❌ ซิงค์ข้อมูล Gachabase ไม่สำเร็จ: ${err.message}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
if (el.btnGachabaseFetch) el.btnGachabaseFetch.disabled = false;
|
isGachabaseSyncing = false;
|
||||||
|
syncButtons.forEach(btn => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.classList.remove('loading');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGachabasePreview(item) {
|
|
||||||
if (!el.gachabasePreviewContainer) return;
|
|
||||||
el.gachabasePreviewContainer.style.display = 'block';
|
|
||||||
if (el.gachabaseFooterActions) el.gachabaseFooterActions.style.display = 'flex';
|
|
||||||
|
|
||||||
const isChar = item.type === 'character';
|
|
||||||
const isLc = item.type === 'lightcone';
|
|
||||||
const isRelic = item.type === 'relic';
|
|
||||||
|
|
||||||
if (el.btnGachabaseImportE0S1) {
|
|
||||||
el.btnGachabaseImportE0S1.textContent = isChar ? '⚡ นำเข้า FreeSR (E0S1)' : '⚡ เพิ่มเข้า FreeSR (S1)';
|
|
||||||
}
|
|
||||||
if (el.btnGachabaseImportE6S5) {
|
|
||||||
el.btnGachabaseImportE6S5.textContent = isChar ? '⭐ นำเข้า FreeSR (E6S5)' : '⭐ เพิ่มเข้า FreeSR (S5)';
|
|
||||||
}
|
|
||||||
|
|
||||||
const starsHtml = '⭐'.repeat(item.rarity || 5);
|
|
||||||
|
|
||||||
let mainImgUrl = '';
|
function setupGachabaseSyncEvents() {
|
||||||
if (isChar) {
|
const syncButtons = [
|
||||||
mainImgUrl = item.images.splash || item.images.icon || `./assets/avatars/${item.id}.png`;
|
el.btnHeaderGachabaseSync,
|
||||||
} else if (isLc) {
|
el.btnQuickGachabaseSync,
|
||||||
mainImgUrl = item.images.fullArt || item.images.thumbnail || `./assets/lightcones/${item.id}.png`;
|
el.btnSyncGachabaseAvatars,
|
||||||
} else {
|
el.btnSyncGachabaseEq,
|
||||||
mainImgUrl = item.images.icon || `./assets/logo.png`;
|
el.btnSyncGachabaseNow
|
||||||
}
|
].filter(Boolean);
|
||||||
|
|
||||||
let recHtml = '';
|
syncButtons.forEach(btn => {
|
||||||
if (isChar) {
|
btn.addEventListener('click', () => {
|
||||||
const cavernSets = item.recommended.cavernSets || [];
|
triggerGachabaseSync(true);
|
||||||
const planarSets = item.recommended.planarSets || [];
|
|
||||||
const sigLcId = item.recommended.signatureLcId;
|
|
||||||
const sigLcMeta = sigLcId ? getLightconeInfo(sigLcId) : null;
|
|
||||||
|
|
||||||
recHtml = `
|
|
||||||
<div style="background: rgba(251, 191, 36, 0.08); border: 1px dashed rgba(251, 191, 36, 0.4); border-radius: var(--radius-sm); padding: 0.75rem; margin-top: 0.5rem;">
|
|
||||||
<div style="font-weight: 800; font-size: 0.825rem; color: var(--accent-gold); margin-bottom: 0.4rem;">
|
|
||||||
💡 คำแนะนำการจัด Build จาก Gachabase
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.8rem;">
|
|
||||||
<div>
|
|
||||||
<strong>🗡️ กรวยแสงแนะนำ (Signature):</strong>
|
|
||||||
<span style="color: #fff;">${sigLcMeta ? sigLcMeta.name : (sigLcId ? `LC ID: ${sigLcId}` : 'ตามสายตัวละคร')}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>🛡️ เซ็ตรีลิกส์แนะนำ (Cavern):</strong>
|
|
||||||
<span style="color: #cbd5e1;">${cavernSets.map(s => getRelicSetInfo(s).name).join(', ') || 'ตามประเภทสาย'}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>🔮 เครื่องประดับแนะนำ (Planar):</strong>
|
|
||||||
<span style="color: #cbd5e1;">${planarSets.map(s => getRelicSetInfo(s).name).join(', ') || 'ตามประเภทสาย'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let skillsHtml = '';
|
|
||||||
if (isChar && item.skills && item.skills.length > 0) {
|
|
||||||
skillsHtml = `
|
|
||||||
<div style="margin-top: 1rem;">
|
|
||||||
<h4 style="font-size: 0.95rem; font-weight: 800; color: var(--accent-cyan); margin-bottom: 0.6rem;">
|
|
||||||
⚡ สกิลและการต่อสู้ (${item.skills.length} ท่า)
|
|
||||||
</h4>
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 0.5rem; max-height: 260px; overflow-y: auto; padding-right: 0.25rem;">
|
|
||||||
${item.skills.map(s => `
|
|
||||||
<div class="gachabase-skill-item">
|
|
||||||
${s.icon ? `<img class="gachabase-skill-icon" src="${s.icon}" alt="${s.name}" onerror="this.style.display='none'">` : ''}
|
|
||||||
<div style="flex: 1; min-width: 0;">
|
|
||||||
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.2rem; flex-wrap: wrap;">
|
|
||||||
<span style="font-weight: 700; color: #fff; font-size: 0.875rem;">${s.name}</span>
|
|
||||||
<span class="skill-type-tag" style="font-size: 0.7rem;">${s.type}</span>
|
|
||||||
${s.tag ? `<span class="badge-super-tag" style="font-size: 0.7rem;">${s.tag}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 0.775rem; color: var(--text-muted); line-height: 1.4;">${s.description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else if (isLc && item.lcSkill) {
|
|
||||||
skillsHtml = `
|
|
||||||
<div style="margin-top: 1rem; background: rgba(0, 0, 0, 0.25); border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 0.75rem;">
|
|
||||||
<div style="font-weight: 700; color: var(--accent-gold); font-size: 0.9rem; margin-bottom: 0.35rem;">
|
|
||||||
✨ เอฟเฟกต์สกิลพิเศษ: ${item.lcSkill.name || 'Passive Skill'}
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 0.8rem; color: var(--text-muted); line-height: 1.5;">${item.lcSkill.description || '-'}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let eidolonsHtml = '';
|
|
||||||
if (isChar && item.eidolons && item.eidolons.length > 0) {
|
|
||||||
eidolonsHtml = `
|
|
||||||
<div style="margin-top: 1rem;">
|
|
||||||
<h4 style="font-size: 0.95rem; font-weight: 800; color: var(--accent-gold); margin-bottom: 0.6rem;">
|
|
||||||
⭐ กลุ่มดาว Eidolons (E1 - E6)
|
|
||||||
</h4>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0.5rem; max-height: 220px; overflow-y: auto; padding-right: 0.25rem;">
|
|
||||||
${item.eidolons.map(e => `
|
|
||||||
<div class="gachabase-eidolon-item">
|
|
||||||
<span class="badge-super-tag" style="background: rgba(251, 191, 36, 0.2); color: var(--accent-gold); font-weight: 800; min-width: 32px; text-align: center;">E${e.rank}</span>
|
|
||||||
<div style="flex: 1; min-width: 0;">
|
|
||||||
<div style="font-weight: 700; color: #fff; font-size: 0.825rem;">${e.name}</div>
|
|
||||||
<div style="font-size: 0.75rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;">${e.description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
el.gachabasePreviewContainer.innerHTML = `
|
|
||||||
<div class="gachabase-preview-card">
|
|
||||||
<div class="gachabase-art-column">
|
|
||||||
<img class="gachabase-art-img" src="${mainImgUrl}" alt="${item.name}" onerror="this.src='./assets/logo.png'">
|
|
||||||
<div style="font-size: 1.1rem; letter-spacing: 2px;">${starsHtml}</div>
|
|
||||||
${isChar && item.maxEnergy ? `<div class="badge-super-tag" style="font-size: 0.75rem;">⚡ พลังงานสูงสุด: ${item.maxEnergy}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="gachabase-details-column">
|
|
||||||
<div class="gachabase-header-title">
|
|
||||||
<span>${item.name}</span>
|
|
||||||
<span style="font-size: 0.75rem; font-weight: 600; padding: 0.15rem 0.5rem; background: rgba(255,255,255,0.08); border-radius: 4px; color: var(--text-muted);">ID: ${item.id}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
|
||||||
${isChar ? `${getElementIconHtml(item.element, 20)} <span style="font-size: 0.85rem; font-weight: 700;">${translateElement(item.element)}</span>` : ''}
|
|
||||||
${getPathIconHtml(item.path, 20)} <span style="font-size: 0.85rem; font-weight: 700;">${translatePath(item.path)}</span>
|
|
||||||
<span class="badge-super-tag" style="background: rgba(56, 189, 248, 0.15); color: var(--accent-cyan);">${item.branch.toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${recHtml}
|
|
||||||
${skillsHtml}
|
|
||||||
${eidolonsHtml}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGachabaseSaveMetaOnly() {
|
|
||||||
if (!currentGachabaseItem) return;
|
|
||||||
try {
|
|
||||||
await registerGachabaseItem(currentGachabaseItem, {
|
|
||||||
AVATAR_METADATA,
|
|
||||||
LIGHTCONE_METADATA,
|
|
||||||
RELIC_SET_METADATA,
|
|
||||||
SIGNATURE_LIGHTCONES,
|
|
||||||
idbGet,
|
|
||||||
idbSet
|
|
||||||
});
|
|
||||||
initAvatarsList(true);
|
|
||||||
renderAvatarsGrid();
|
|
||||||
loadEquipment();
|
|
||||||
showToast(`💾 บันทึก Metadata ของ "${currentGachabaseItem.name}" เข้าสู่ระบบเรียบร้อย`, 'success');
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGachabaseImportToFreeSR(eLevel, sLevel) {
|
|
||||||
if (!currentGachabaseItem) return;
|
|
||||||
try {
|
|
||||||
await registerGachabaseItem(currentGachabaseItem, {
|
|
||||||
AVATAR_METADATA,
|
|
||||||
LIGHTCONE_METADATA,
|
|
||||||
RELIC_SET_METADATA,
|
|
||||||
SIGNATURE_LIGHTCONES,
|
|
||||||
idbGet,
|
|
||||||
idbSet
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!state.fullFreesrData) {
|
|
||||||
state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = applyGachabaseToFreeSR(currentGachabaseItem, state.fullFreesrData, {
|
|
||||||
eLevel,
|
|
||||||
sLevel,
|
|
||||||
autoEquipLc: true,
|
|
||||||
autoEquipRelics: true
|
|
||||||
});
|
|
||||||
|
|
||||||
await saveFreesrChangesToServer();
|
|
||||||
|
|
||||||
initAvatarsList(true);
|
|
||||||
renderAvatarsGrid();
|
|
||||||
loadEquipment();
|
|
||||||
updateDashboard();
|
|
||||||
|
|
||||||
closeGachabaseModal();
|
|
||||||
|
|
||||||
if (currentGachabaseItem.type === 'character' && state.fullFreesrData.avatars[currentGachabaseItem.id]) {
|
|
||||||
openAvatarDetailModal(state.fullFreesrData.avatars[currentGachabaseItem.id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rankLabel = currentGachabaseItem.type === 'character' ? (eLevel === 6 ? 'E6S5' : 'E0S1') : `S${sLevel}`;
|
|
||||||
showToast(`⚡ นำเข้า "${currentGachabaseItem.name}" (${rankLabel}) ลง FreeSR และบันทึกเซิร์ฟเวอร์สำเร็จ!`, 'success');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Import error:', err);
|
|
||||||
showToast(err.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupGachabaseEvents() {
|
|
||||||
const openButtons = [el.btnOpenGachabase, el.btnQuickGachabase, el.btnAvatarsGachabase, el.btnEqGachabase];
|
|
||||||
openButtons.forEach(btn => {
|
|
||||||
if (btn) {
|
|
||||||
btn.addEventListener('click', () => openGachabaseModal());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
el.btnCloseGachabaseModal?.addEventListener('click', closeGachabaseModal);
|
|
||||||
|
|
||||||
document.querySelectorAll('.gachabase-quick-chip').forEach(chip => {
|
|
||||||
chip.addEventListener('click', () => {
|
|
||||||
const cid = chip.getAttribute('data-id');
|
|
||||||
const ctype = chip.getAttribute('data-type') || 'character';
|
|
||||||
if (el.gachabaseInput) el.gachabaseInput.value = cid;
|
|
||||||
if (el.gachabaseType) el.gachabaseType.value = ctype;
|
|
||||||
performGachabaseFetch();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
el.btnGachabaseFetch?.addEventListener('click', performGachabaseFetch);
|
|
||||||
el.gachabaseInput?.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
performGachabaseFetch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
el.btnGachabaseSaveMeta?.addEventListener('click', handleGachabaseSaveMetaOnly);
|
|
||||||
el.btnGachabaseImportE0S1?.addEventListener('click', () => handleGachabaseImportToFreeSR(0, 1));
|
|
||||||
el.btnGachabaseImportE6S5?.addEventListener('click', () => handleGachabaseImportToFreeSR(6, 5));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup Event Listeners
|
// Setup Event Listeners
|
||||||
@@ -6057,7 +5867,7 @@ function setupEvents() {
|
|||||||
updateCacheUsageDisplay();
|
updateCacheUsageDisplay();
|
||||||
|
|
||||||
// Modal Backdrop Click to close
|
// Modal Backdrop Click to close
|
||||||
[el.modalAvatarDetail, el.modalLineupPicker, el.modalSettings, el.modalGachabase].forEach(modal => {
|
[el.modalAvatarDetail, el.modalLineupPicker, el.modalSettings].forEach(modal => {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.addEventListener('click', (e) => {
|
modal.addEventListener('click', (e) => {
|
||||||
if (e.target === modal) {
|
if (e.target === modal) {
|
||||||
@@ -6084,13 +5894,11 @@ function startPingLoop() {
|
|||||||
|
|
||||||
// Initialize Application
|
// Initialize Application
|
||||||
async function init() {
|
async function init() {
|
||||||
await loadCachedGachabaseRegistry({
|
await loadCachedGachabaseData({
|
||||||
AVATAR_METADATA,
|
|
||||||
LIGHTCONE_METADATA,
|
|
||||||
RELIC_SET_METADATA,
|
|
||||||
SIGNATURE_LIGHTCONES,
|
|
||||||
idbGet,
|
idbGet,
|
||||||
idbSet
|
idbSet,
|
||||||
|
AVATAR_METADATA,
|
||||||
|
LIGHTCONE_METADATA
|
||||||
});
|
});
|
||||||
initAvatarsList();
|
initAvatarsList();
|
||||||
await loadCachedStorageData();
|
await loadCachedStorageData();
|
||||||
@@ -6099,11 +5907,12 @@ async function init() {
|
|||||||
setupTabs();
|
setupTabs();
|
||||||
setupModalSubtabs();
|
setupModalSubtabs();
|
||||||
setupEvents();
|
setupEvents();
|
||||||
setupGachabaseEvents();
|
setupGachabaseSyncEvents();
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
renderLineup(state.statusData?.lineups || DEFAULT_LINEUP);
|
renderLineup(state.statusData?.lineups || DEFAULT_LINEUP);
|
||||||
renderAvatarsGrid();
|
renderAvatarsGrid();
|
||||||
loadEquipment();
|
loadEquipment();
|
||||||
|
updateGachabaseSyncUI();
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
startPingLoop();
|
startPingLoop();
|
||||||
updateCacheUsageDisplay();
|
updateCacheUsageDisplay();
|
||||||
|
|||||||
+324
-491
@@ -1,10 +1,43 @@
|
|||||||
// Gachabase Data Fetcher, Parser & FreeSR Importer Module
|
// Gachabase Data Fetcher, Catalog Sync & Image Engine for GaC-srtool
|
||||||
// Supports fetching datamined/beta characters, lightcones, and relics from hsr.gachabase.net
|
// Connects with hsr.gachabase.net to dynamically update characters, lightcones,
|
||||||
// Integrates with GaC-srtool runtime metadata, IndexedDB cache, and FreeSR / persistent saves.
|
// and high-res assets whenever the game updates or new patches/betas drop.
|
||||||
|
|
||||||
export const GACHABASE_BASE_URL = 'https://hsr.gachabase.net';
|
export const GACHABASE_BASE_URL = 'https://hsr.gachabase.net';
|
||||||
export const GACHABASE_CDN_URL = 'https://cdn.gachabase.net';
|
export const GACHABASE_CDN_URL = 'https://cdn.gachabase.net';
|
||||||
|
|
||||||
|
// Pre-mapped known beta / newest assets from Gachabase CDN
|
||||||
|
export const KNOWN_GACHABASE_ASSETS = {
|
||||||
|
avatars: {
|
||||||
|
1501: 'https://cdn.gachabase.net/hsr/assets/09ee6e2618ac2a0dab2aec8d73b0bb01.png', // Sparxie
|
||||||
|
1502: 'https://cdn.gachabase.net/hsr/assets/8ca2530c42791593b72d54a850fce97e.png', // Yao Guang
|
||||||
|
1505: 'https://cdn.gachabase.net/hsr/assets/723a90e789fd354fb96b450a90c73e30.png', // Evanescia
|
||||||
|
1506: 'https://cdn.gachabase.net/hsr/assets/0cbfb275bfef439b16ba6ff9f848eb3a.png', // Silver Wolf Collab
|
||||||
|
1511: 'https://cdn.gachabase.net/hsr/assets/2be49dbbe7cfe8f77187dcfea51d9863.png', // Aeon ★ Aha
|
||||||
|
1513: 'https://cdn.gachabase.net/hsr/assets/2fdf5d4fe6eb998064438ffd373ce95f.png', // Aventurine • Waveflair
|
||||||
|
},
|
||||||
|
avatarSplash: {
|
||||||
|
1511: 'https://cdn.gachabase.net/hsr/assets/f8b5630e85022ab8b2761e4e3f614b42.png', // Aha Splash Art (2048x1914)
|
||||||
|
1513: 'https://cdn.gachabase.net/hsr/assets/65b90212f8623ad92243d739ea1fc9ec.png', // Waveflair Splash
|
||||||
|
1505: 'https://cdn.gachabase.net/hsr/assets/71cbe3943310cb233e8dbe6ef69caeb1.png', // Evanescia Splash
|
||||||
|
1502: 'https://cdn.gachabase.net/hsr/assets/8ca2530c42791593b72d54a850fce97e.png', // Yao Guang
|
||||||
|
1501: 'https://cdn.gachabase.net/hsr/assets/09ee6e2618ac2a0dab2aec8d73b0bb01.png', // Sparxie
|
||||||
|
},
|
||||||
|
lightcones: {
|
||||||
|
23065: 'https://cdn.gachabase.net/hsr/assets/500cfc18698932759756f6b3ba4db417.png', // Upon the First Echo of "Aha" Full Art
|
||||||
|
},
|
||||||
|
lightconeThumbs: {
|
||||||
|
23065: 'https://cdn.gachabase.net/hsr/assets/c794417a81e656bfdf1212202230f80f.png', // Aha LC Thumbnail
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global in-memory image cache populated dynamically from Gachabase catalogs
|
||||||
|
export const GACHABASE_IMAGE_CACHE = {
|
||||||
|
avatars: { ...KNOWN_GACHABASE_ASSETS.avatars },
|
||||||
|
avatarSplash: { ...KNOWN_GACHABASE_ASSETS.avatarSplash },
|
||||||
|
lightcones: { ...KNOWN_GACHABASE_ASSETS.lightcones },
|
||||||
|
lightconeThumbs: { ...KNOWN_GACHABASE_ASSETS.lightconeThumbs }
|
||||||
|
};
|
||||||
|
|
||||||
// Path ID mappings according to HSR engine / Gachabase
|
// Path ID mappings according to HSR engine / Gachabase
|
||||||
export const GACHABASE_PATHS = {
|
export const GACHABASE_PATHS = {
|
||||||
1: 'Destruction',
|
1: 'Destruction',
|
||||||
@@ -69,76 +102,7 @@ export function cleanRichText(str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses user input into normalized search parameters
|
* Fetches HTML from targetUrl via backend API proxy or direct fetch
|
||||||
* Handles full URLs or raw IDs
|
|
||||||
*/
|
|
||||||
export function parseGachabaseInput(input, typeHint = 'character', branch = 'beta', lang = 'en') {
|
|
||||||
const trimmed = (input || '').trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
|
|
||||||
// Check if input is a full URL
|
|
||||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(trimmed);
|
|
||||||
const pathname = urlObj.pathname.toLowerCase();
|
|
||||||
let detectedType = typeHint;
|
|
||||||
if (pathname.includes('/characters/') || pathname.includes('/character/')) detectedType = 'character';
|
|
||||||
else if (pathname.includes('/lightcones/') || pathname.includes('/lightcone/')) detectedType = 'lightcone';
|
|
||||||
else if (pathname.includes('/relics/') || pathname.includes('/relic/')) detectedType = 'relic';
|
|
||||||
|
|
||||||
// Pattern: /type/:id/:slug?/:branch?
|
|
||||||
const parts = pathname.split('/').filter(Boolean);
|
|
||||||
// Expected: ['characters', '1511', 'aeon-aha', 'beta']
|
|
||||||
let id = null;
|
|
||||||
let detectedBranch = urlObj.searchParams.get('branch') || branch;
|
|
||||||
let detectedLang = urlObj.searchParams.get('lang') || lang;
|
|
||||||
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
id = parseInt(parts[1], 10);
|
|
||||||
}
|
|
||||||
if (parts.length >= 4) {
|
|
||||||
detectedBranch = parts[3];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
url: trimmed,
|
|
||||||
id: id || null,
|
|
||||||
type: detectedType,
|
|
||||||
branch: detectedBranch || 'beta',
|
|
||||||
lang: detectedLang || 'en'
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
// Fall through to ID parsing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Treat as numeric ID
|
|
||||||
const numId = parseInt(trimmed, 10);
|
|
||||||
if (!isNaN(numId)) {
|
|
||||||
let resolvedType = typeHint;
|
|
||||||
// Auto-guess type if default wasn't explicitly changed
|
|
||||||
if (numId >= 20000 && numId < 30000) resolvedType = 'lightcone';
|
|
||||||
else if (numId < 1000) resolvedType = 'relic';
|
|
||||||
else if (numId >= 1000 && numId < 9000) resolvedType = 'character';
|
|
||||||
|
|
||||||
const typeSlug = resolvedType === 'lightcone' ? 'lightcones' : (resolvedType === 'relic' ? 'relics' : 'characters');
|
|
||||||
const url = `${GACHABASE_BASE_URL}/${typeSlug}/${numId}/slug/${branch}?lang=${lang}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
id: numId,
|
|
||||||
type: resolvedType,
|
|
||||||
branch: branch || 'beta',
|
|
||||||
lang: lang || 'en'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches Gachabase HTML content
|
|
||||||
* Uses GaC-hkrpg-go API proxy if available to avoid CORS, falls back to direct fetch
|
|
||||||
*/
|
*/
|
||||||
export async function fetchGachabaseHtml(targetUrl, apiUrl = 'http://localhost:21000') {
|
export async function fetchGachabaseHtml(targetUrl, apiUrl = 'http://localhost:21000') {
|
||||||
if (!targetUrl) throw new Error('Target URL is required for Gachabase fetch');
|
if (!targetUrl) throw new Error('Target URL is required for Gachabase fetch');
|
||||||
@@ -160,7 +124,7 @@ export async function fetchGachabaseHtml(targetUrl, apiUrl = 'http://localhost:2
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Direct fetch fallback (works if user has CORS unblocker or runs in local browser environment)
|
// 2. Direct fetch fallback
|
||||||
const directResp = await fetch(targetUrl, {
|
const directResp = await fetch(targetUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -175,451 +139,320 @@ export async function fetchGachabaseHtml(targetUrl, apiUrl = 'http://localhost:2
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts and parses serialized SvelteKit DTO and refs from Gachabase HTML
|
* Parses SvelteKit streamed data payload from Gachabase catalog listing pages
|
||||||
*/
|
*/
|
||||||
export function parseGachabaseHtml(html, fallbackLang = 'en') {
|
export function parseSvelteKitCatalog(html) {
|
||||||
if (!html || typeof html !== 'string') {
|
if (!html || typeof html !== 'string') return null;
|
||||||
throw new Error('Invalid HTML content received from Gachabase');
|
const scripts = [...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/gi)];
|
||||||
}
|
for (const s of scripts) {
|
||||||
|
const text = s[1].trim();
|
||||||
// Look for kit.start(...) serialized state in <script>
|
if (text.includes('resolve') && text.includes('entries')) {
|
||||||
let kitJson = null;
|
const startIdx = text.indexOf('() => ');
|
||||||
const kitMatch = html.match(/kit\.start\(app,\s*element,\s*(\{.*?\})\);\s*\}\);/s);
|
if (startIdx !== -1) {
|
||||||
if (kitMatch && kitMatch[1]) {
|
let code = text.substring(startIdx + 6).trim();
|
||||||
kitJson = kitMatch[1];
|
if (code.endsWith(');')) code = code.slice(0, -2);
|
||||||
} else {
|
else if (code.endsWith(')')) code = code.slice(0, -1);
|
||||||
// Alternate pattern in newer or minified builds
|
|
||||||
const altMatch = html.match(/kit\.start\(app,\s*element,\s*(\{[\s\S]*?\})\s*\);/);
|
|
||||||
if (altMatch && altMatch[1]) {
|
|
||||||
kitJson = altMatch[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!kitJson) {
|
|
||||||
throw new Error('Could not find SvelteKit data payload in Gachabase HTML');
|
|
||||||
}
|
|
||||||
|
|
||||||
let kitData;
|
|
||||||
try {
|
try {
|
||||||
const fn = new Function('return (' + kitJson + ')');
|
const fn = new Function('return (' + code + ')');
|
||||||
kitData = fn();
|
const res = fn();
|
||||||
} catch (err) {
|
return res[0]?.data;
|
||||||
throw new Error('Failed to evaluate Gachabase state payload: ' + err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!kitData || !Array.isArray(kitData.data)) {
|
|
||||||
throw new Error('Gachabase kitData does not contain data array');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search for the entry containing dto and refs
|
|
||||||
let targetData = null;
|
|
||||||
for (const entry of kitData.data) {
|
|
||||||
if (!entry) continue;
|
|
||||||
const cand = entry.data?.data?.data || entry.data?.data || entry.data;
|
|
||||||
if (cand && (cand.dto || cand.refs)) {
|
|
||||||
targetData = cand;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetData || !targetData.dto) {
|
|
||||||
throw new Error('Gachabase DTO object not found in payload');
|
|
||||||
}
|
|
||||||
|
|
||||||
const dto = targetData.dto;
|
|
||||||
const refs = targetData.refs || {};
|
|
||||||
const assetsMap = refs.assets || {};
|
|
||||||
const pathsMap = refs.paths || {};
|
|
||||||
const typesMap = refs.types || {};
|
|
||||||
|
|
||||||
// Resolve CDN URL helper
|
|
||||||
const resolveAsset = (hash) => {
|
|
||||||
if (!hash) return null;
|
|
||||||
if (assetsMap[hash] && assetsMap[hash].url) {
|
|
||||||
return assetsMap[hash].url;
|
|
||||||
}
|
|
||||||
return `${GACHABASE_CDN_URL}/hsr/assets/${hash}.png`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const id = dto.id;
|
|
||||||
const rawName = dto.name?.text || dto.name || `Item ${id}`;
|
|
||||||
const rarity = dto.rarity || 5;
|
|
||||||
|
|
||||||
// Path resolution
|
|
||||||
const pathId = dto.path_id || 0;
|
|
||||||
const rawPath = pathsMap[pathId]?.name?.text || GACHABASE_PATHS[pathId] || 'Unknown';
|
|
||||||
|
|
||||||
// Type / Element resolution
|
|
||||||
const typeId = dto.type_id || 0;
|
|
||||||
const rawElement = typesMap[typeId]?.name?.text || GACHABASE_ELEMENTS[typeId] || 'Unknown';
|
|
||||||
|
|
||||||
// Determine Item Type
|
|
||||||
let itemType = 'character';
|
|
||||||
if (dto.skills !== undefined || dto.eidolons !== undefined || dto.max_energy !== undefined) {
|
|
||||||
itemType = 'character';
|
|
||||||
} else if (dto.skill !== undefined || (id >= 20000 && id < 30000)) {
|
|
||||||
itemType = 'lightcone';
|
|
||||||
} else if (id < 1000) {
|
|
||||||
itemType = 'relic';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Images resolution
|
|
||||||
const images = {
|
|
||||||
splash: resolveAsset(dto.assets?.splash_art_path_hash) || resolveAsset(dto.assets?.draw_card_art_path_hash),
|
|
||||||
icon: resolveAsset(dto.assets?.round_icon_path_hash) || resolveAsset(dto.assets?.small_icon_path_hash) || resolveAsset(dto.assets?.icon_path_hash),
|
|
||||||
square: resolveAsset(dto.assets?.square_icon_path_hash) || resolveAsset(dto.assets?.big_icon_path_hash),
|
|
||||||
fullArt: resolveAsset(dto.assets?.full_art_path_hash) || resolveAsset(dto.assets?.splash_art_path_hash),
|
|
||||||
thumbnail: resolveAsset(dto.assets?.thumbnail_art_path_hash) || resolveAsset(dto.assets?.big_icon_path_hash)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skills parsing (for characters)
|
|
||||||
const skills = [];
|
|
||||||
if (Array.isArray(dto.skills)) {
|
|
||||||
dto.skills.forEach(s => {
|
|
||||||
skills.push({
|
|
||||||
id: s.id,
|
|
||||||
type: s.type?.text || s.type || 'Skill',
|
|
||||||
name: s.name?.text || s.name || '',
|
|
||||||
tag: s.tag?.text || s.tag || '',
|
|
||||||
description: cleanRichText(s.description?.text || s.description || ''),
|
|
||||||
icon: resolveAsset(s.icon_path_hash)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eidolons parsing (for characters)
|
|
||||||
const eidolons = [];
|
|
||||||
if (Array.isArray(dto.eidolons)) {
|
|
||||||
dto.eidolons.forEach((e, idx) => {
|
|
||||||
eidolons.push({
|
|
||||||
rank: e.rank || idx + 1,
|
|
||||||
name: e.name?.text || e.name || `Eidolon ${idx + 1}`,
|
|
||||||
description: cleanRichText(e.description?.text || e.description || ''),
|
|
||||||
icon: resolveAsset(e.icon_path_hash) || resolveAsset(dto.assets?.[`eidolon_${idx + 1}_path_hash`])
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lightcone skill (for lightcones)
|
|
||||||
let lcSkill = null;
|
|
||||||
if (dto.skill) {
|
|
||||||
lcSkill = {
|
|
||||||
name: dto.skill.name?.text || dto.skill.name || '',
|
|
||||||
description: cleanRichText(dto.skill.description?.text || dto.skill.description || '')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build recommendations
|
|
||||||
const rec = dto.build_recommendations || {};
|
|
||||||
const recommendedRelics = rec.relics?.relic_ids || [];
|
|
||||||
const cavernSets = recommendedRelics.filter(rId => rId < 300);
|
|
||||||
const planarSets = recommendedRelics.filter(rId => rId >= 300);
|
|
||||||
|
|
||||||
// Recommended Lightcones
|
|
||||||
const recommendedLcs = (Array.isArray(rec.lightcone_ids) ? rec.lightcone_ids : (rec.lightcones || []).map(lc => lc.lightcone_id)).filter(Boolean);
|
|
||||||
|
|
||||||
// Main stats recommendations
|
|
||||||
const mainStats = {};
|
|
||||||
if (Array.isArray(rec.relics?.main_stats)) {
|
|
||||||
rec.relics.main_stats.forEach(m => {
|
|
||||||
const pieceId = m.piece_type_id; // 3=Body, 4=Boots, 5=Sphere, 6=Rope
|
|
||||||
const stats = (m.stat_ids || []).map(sid => GACHABASE_STATS[sid] || `Stat ${sid}`);
|
|
||||||
mainStats[pieceId] = stats;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sub stats recommendations
|
|
||||||
const subStats = (rec.relics?.sub_stat_ids || []).map(sid => GACHABASE_STATS[sid] || `Stat ${sid}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type: itemType,
|
|
||||||
name: rawName,
|
|
||||||
rarity,
|
|
||||||
path: rawPath,
|
|
||||||
pathId,
|
|
||||||
element: rawElement,
|
|
||||||
typeId,
|
|
||||||
maxEnergy: dto.max_energy || 0,
|
|
||||||
images,
|
|
||||||
skills,
|
|
||||||
eidolons,
|
|
||||||
lcSkill,
|
|
||||||
recommended: {
|
|
||||||
relicIds: recommendedRelics,
|
|
||||||
cavernSets,
|
|
||||||
planarSets,
|
|
||||||
lightconeIds: recommendedLcs,
|
|
||||||
signatureLcId: recommendedLcs[0] || null,
|
|
||||||
mainStats,
|
|
||||||
subStats
|
|
||||||
},
|
|
||||||
rawDto: dto
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers parsed item into tool runtime metadata and saves to IndexedDB
|
|
||||||
*/
|
|
||||||
export async function registerGachabaseItem(parsedItem, metadataContext = {}) {
|
|
||||||
const {
|
|
||||||
AVATAR_METADATA,
|
|
||||||
LIGHTCONE_METADATA,
|
|
||||||
RELIC_SET_METADATA,
|
|
||||||
SIGNATURE_LIGHTCONES,
|
|
||||||
idbSet
|
|
||||||
} = metadataContext;
|
|
||||||
|
|
||||||
const id = parsedItem.id;
|
|
||||||
const lang = parsedItem.lang || 'en';
|
|
||||||
|
|
||||||
if (parsedItem.type === 'character' && AVATAR_METADATA) {
|
|
||||||
if (!AVATAR_METADATA[id]) {
|
|
||||||
AVATAR_METADATA[id] = {
|
|
||||||
names: {
|
|
||||||
th: parsedItem.name,
|
|
||||||
en: parsedItem.name
|
|
||||||
},
|
|
||||||
path: parsedItem.path,
|
|
||||||
element: parsedItem.element,
|
|
||||||
rarity: parsedItem.rarity,
|
|
||||||
hasExRank: false,
|
|
||||||
isCustomGachabase: true,
|
|
||||||
images: parsedItem.images
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Update existing entry with high-res images and enriched data
|
|
||||||
AVATAR_METADATA[id].names = AVATAR_METADATA[id].names || {};
|
|
||||||
AVATAR_METADATA[id].names[lang] = parsedItem.name;
|
|
||||||
AVATAR_METADATA[id].names.en = AVATAR_METADATA[id].names.en || parsedItem.name;
|
|
||||||
AVATAR_METADATA[id].path = parsedItem.path;
|
|
||||||
AVATAR_METADATA[id].element = parsedItem.element;
|
|
||||||
AVATAR_METADATA[id].rarity = parsedItem.rarity;
|
|
||||||
AVATAR_METADATA[id].images = parsedItem.images;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SIGNATURE_LIGHTCONES && parsedItem.recommended.signatureLcId) {
|
|
||||||
SIGNATURE_LIGHTCONES[id] = parsedItem.recommended.signatureLcId;
|
|
||||||
}
|
|
||||||
} else if (parsedItem.type === 'lightcone' && LIGHTCONE_METADATA) {
|
|
||||||
if (!LIGHTCONE_METADATA[id]) {
|
|
||||||
LIGHTCONE_METADATA[id] = {
|
|
||||||
names: {
|
|
||||||
th: parsedItem.name,
|
|
||||||
en: parsedItem.name
|
|
||||||
},
|
|
||||||
rarity: parsedItem.rarity,
|
|
||||||
path: parsedItem.path,
|
|
||||||
isCustomGachabase: true,
|
|
||||||
images: parsedItem.images
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
LIGHTCONE_METADATA[id].names = LIGHTCONE_METADATA[id].names || {};
|
|
||||||
LIGHTCONE_METADATA[id].names[lang] = parsedItem.name;
|
|
||||||
LIGHTCONE_METADATA[id].path = parsedItem.path;
|
|
||||||
LIGHTCONE_METADATA[id].rarity = parsedItem.rarity;
|
|
||||||
LIGHTCONE_METADATA[id].images = parsedItem.images;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist to IndexedDB
|
|
||||||
if (idbSet) {
|
|
||||||
try {
|
|
||||||
const cacheKey = `gac_gachabase_item_${parsedItem.type}_${id}`;
|
|
||||||
await idbSet(cacheKey, parsedItem);
|
|
||||||
|
|
||||||
// Also append to custom items registry
|
|
||||||
const regKey = 'gac_gachabase_registry';
|
|
||||||
const existing = (await (metadataContext.idbGet ? metadataContext.idbGet(regKey) : null)) || [];
|
|
||||||
const filtered = existing.filter(i => !(i.type === parsedItem.type && i.id === parsedItem.id));
|
|
||||||
filtered.push({
|
|
||||||
type: parsedItem.type,
|
|
||||||
id: parsedItem.id,
|
|
||||||
name: parsedItem.name,
|
|
||||||
path: parsedItem.path,
|
|
||||||
element: parsedItem.element,
|
|
||||||
rarity: parsedItem.rarity,
|
|
||||||
cacheKey
|
|
||||||
});
|
|
||||||
await idbSet(regKey, filtered);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to save Gachabase item to IndexedDB:', e);
|
console.warn('Failed to parse catalog script:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return parsedItem;
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads all cached Gachabase items from IndexedDB into runtime metadata
|
* Automatically synchronizes the full game database from Gachabase
|
||||||
|
* Pulls all characters and lightcones, adds missing ones into metadata,
|
||||||
|
* and caches all Gachabase CDN asset URLs for seamless rendering.
|
||||||
*/
|
*/
|
||||||
export async function loadCachedGachabaseRegistry(metadataContext = {}) {
|
export async function syncGachabaseDatabase({ apiUrl, branch = 'beta', lang = 'en', metadataContext }) {
|
||||||
const {
|
const {
|
||||||
AVATAR_METADATA,
|
AVATAR_METADATA,
|
||||||
LIGHTCONE_METADATA,
|
LIGHTCONE_METADATA,
|
||||||
SIGNATURE_LIGHTCONES,
|
SIGNATURE_LIGHTCONES,
|
||||||
|
idbSet,
|
||||||
idbGet
|
idbGet
|
||||||
} = metadataContext;
|
} = metadataContext;
|
||||||
|
|
||||||
|
const charactersUrl = `${GACHABASE_BASE_URL}/characters?lang=${lang}&branch=${branch}`;
|
||||||
|
const lightconesUrl = `${GACHABASE_BASE_URL}/lightcones?lang=${lang}&branch=${branch}`;
|
||||||
|
|
||||||
|
let newAvatarsCount = 0;
|
||||||
|
let newLcsCount = 0;
|
||||||
|
let totalAvatarsCount = 0;
|
||||||
|
let totalLcsCount = 0;
|
||||||
|
|
||||||
|
// 1. Fetch & Parse Characters Catalog
|
||||||
|
try {
|
||||||
|
const { html: charHtml } = await fetchGachabaseHtml(charactersUrl, apiUrl);
|
||||||
|
const charData = parseSvelteKitCatalog(charHtml);
|
||||||
|
|
||||||
|
if (charData && Array.isArray(charData.entries)) {
|
||||||
|
totalAvatarsCount = charData.entries.length;
|
||||||
|
const assetsMap = charData.refs?.assets || {};
|
||||||
|
|
||||||
|
for (const entry of charData.entries) {
|
||||||
|
const aid = entry.id;
|
||||||
|
const hash = entry.assets?.square_icon_path_hash || entry.assets?.round_icon_path_hash;
|
||||||
|
const iconUrl = assetsMap[hash]?.url || KNOWN_GACHABASE_ASSETS.avatars[aid] || `${GACHABASE_CDN_URL}/hsr/assets/${hash}.png`;
|
||||||
|
|
||||||
|
if (iconUrl) {
|
||||||
|
GACHABASE_IMAGE_CACHE.avatars[aid] = iconUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register new / missing characters into AVATAR_METADATA
|
||||||
|
if (AVATAR_METADATA && !AVATAR_METADATA[aid]) {
|
||||||
|
const charName = entry.name?.text || entry.name || `Avatar ${aid}`;
|
||||||
|
const pathName = GACHABASE_PATHS[entry.path_id] || 'Unknown';
|
||||||
|
const elementName = GACHABASE_ELEMENTS[entry.type_id] || 'Unknown';
|
||||||
|
|
||||||
|
AVATAR_METADATA[aid] = {
|
||||||
|
names: {
|
||||||
|
th: charName,
|
||||||
|
en: charName,
|
||||||
|
'zh-CN': charName
|
||||||
|
},
|
||||||
|
path: pathName,
|
||||||
|
element: elementName,
|
||||||
|
rarity: entry.rarity || 5,
|
||||||
|
hasExRank: false,
|
||||||
|
isGachabaseSync: true,
|
||||||
|
images: {
|
||||||
|
icon: iconUrl,
|
||||||
|
square: iconUrl
|
||||||
|
}
|
||||||
|
};
|
||||||
|
newAvatarsCount++;
|
||||||
|
} else if (AVATAR_METADATA && AVATAR_METADATA[aid]) {
|
||||||
|
// Enrich existing metadata with Gachabase CDN image
|
||||||
|
AVATAR_METADATA[aid].images = AVATAR_METADATA[aid].images || {};
|
||||||
|
if (iconUrl) AVATAR_METADATA[aid].images.icon = iconUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to sync characters from Gachabase:', err);
|
||||||
|
throw new Error('ไม่สามารถซิงค์ตัวละครจาก Gachabase ได้: ' + err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch & Parse Lightcones Catalog
|
||||||
|
try {
|
||||||
|
const { html: lcHtml } = await fetchGachabaseHtml(lightconesUrl, apiUrl);
|
||||||
|
const lcData = parseSvelteKitCatalog(lcHtml);
|
||||||
|
|
||||||
|
if (lcData && Array.isArray(lcData.entries)) {
|
||||||
|
totalLcsCount = lcData.entries.length;
|
||||||
|
const assetsMap = lcData.refs?.assets || {};
|
||||||
|
|
||||||
|
for (const entry of lcData.entries) {
|
||||||
|
const lcid = entry.id;
|
||||||
|
const thumbHash = entry.assets?.thumbnail_art_path_hash || entry.assets?.small_icon_path_hash;
|
||||||
|
const fullArtHash = entry.assets?.full_art_path_hash || entry.assets?.big_icon_path_hash;
|
||||||
|
|
||||||
|
const thumbUrl = assetsMap[thumbHash]?.url || KNOWN_GACHABASE_ASSETS.lightconeThumbs[lcid];
|
||||||
|
const fullArtUrl = assetsMap[fullArtHash]?.url || KNOWN_GACHABASE_ASSETS.lightcones[lcid];
|
||||||
|
|
||||||
|
if (thumbUrl) GACHABASE_IMAGE_CACHE.lightconeThumbs[lcid] = thumbUrl;
|
||||||
|
if (fullArtUrl) GACHABASE_IMAGE_CACHE.lightcones[lcid] = fullArtUrl;
|
||||||
|
|
||||||
|
// Register new / missing lightcones into LIGHTCONE_METADATA
|
||||||
|
if (LIGHTCONE_METADATA && !LIGHTCONE_METADATA[lcid]) {
|
||||||
|
const lcName = entry.name?.text || entry.name || `Lightcone ${lcid}`;
|
||||||
|
const pathName = GACHABASE_PATHS[entry.path_id] || 'Unknown';
|
||||||
|
|
||||||
|
LIGHTCONE_METADATA[lcid] = {
|
||||||
|
names: {
|
||||||
|
th: lcName,
|
||||||
|
en: lcName,
|
||||||
|
'zh-CN': lcName
|
||||||
|
},
|
||||||
|
rarity: entry.rarity || 5,
|
||||||
|
path: pathName,
|
||||||
|
isGachabaseSync: true,
|
||||||
|
images: {
|
||||||
|
thumbnail: thumbUrl,
|
||||||
|
fullArt: fullArtUrl
|
||||||
|
}
|
||||||
|
};
|
||||||
|
newLcsCount++;
|
||||||
|
} else if (LIGHTCONE_METADATA && LIGHTCONE_METADATA[lcid]) {
|
||||||
|
LIGHTCONE_METADATA[lcid].images = LIGHTCONE_METADATA[lcid].images || {};
|
||||||
|
if (thumbUrl) LIGHTCONE_METADATA[lcid].images.thumbnail = thumbUrl;
|
||||||
|
if (fullArtUrl) LIGHTCONE_METADATA[lcid].images.fullArt = fullArtUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to sync lightcones from Gachabase:', err);
|
||||||
|
throw new Error('ไม่สามารถซิงค์กรวยแสงจาก Gachabase ได้: ' + err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Save cache to IndexedDB
|
||||||
|
if (idbSet) {
|
||||||
|
try {
|
||||||
|
await idbSet('gac_gachabase_image_cache', GACHABASE_IMAGE_CACHE);
|
||||||
|
await idbSet('gac_gachabase_last_sync', {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
newAvatarsCount,
|
||||||
|
newLcsCount,
|
||||||
|
totalAvatarsCount,
|
||||||
|
totalLcsCount
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to cache Gachabase database to IndexedDB:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
newAvatarsCount,
|
||||||
|
newLcsCount,
|
||||||
|
totalAvatarsCount,
|
||||||
|
totalLcsCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads cached Gachabase images and metadata from IndexedDB
|
||||||
|
*/
|
||||||
|
export async function loadCachedGachabaseData(metadataContext = {}) {
|
||||||
|
const { idbGet, AVATAR_METADATA, LIGHTCONE_METADATA } = metadataContext;
|
||||||
if (!idbGet) return;
|
if (!idbGet) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const registry = await idbGet('gac_gachabase_registry');
|
const cachedImages = await idbGet('gac_gachabase_image_cache');
|
||||||
if (!Array.isArray(registry)) return;
|
if (cachedImages) {
|
||||||
|
if (cachedImages.avatars) Object.assign(GACHABASE_IMAGE_CACHE.avatars, cachedImages.avatars);
|
||||||
|
if (cachedImages.avatarSplash) Object.assign(GACHABASE_IMAGE_CACHE.avatarSplash, cachedImages.avatarSplash);
|
||||||
|
if (cachedImages.lightcones) Object.assign(GACHABASE_IMAGE_CACHE.lightcones, cachedImages.lightcones);
|
||||||
|
if (cachedImages.lightconeThumbs) Object.assign(GACHABASE_IMAGE_CACHE.lightconeThumbs, cachedImages.lightconeThumbs);
|
||||||
|
}
|
||||||
|
|
||||||
for (const itemMeta of registry) {
|
// Attach cached image links to metadata
|
||||||
const fullItem = await idbGet(itemMeta.cacheKey);
|
if (AVATAR_METADATA) {
|
||||||
if (fullItem) {
|
for (const aid in GACHABASE_IMAGE_CACHE.avatars) {
|
||||||
await registerGachabaseItem(fullItem, metadataContext);
|
if (AVATAR_METADATA[aid]) {
|
||||||
|
AVATAR_METADATA[aid].images = AVATAR_METADATA[aid].images || {};
|
||||||
|
AVATAR_METADATA[aid].images.icon = GACHABASE_IMAGE_CACHE.avatars[aid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (LIGHTCONE_METADATA) {
|
||||||
|
for (const lcid in GACHABASE_IMAGE_CACHE.lightcones) {
|
||||||
|
if (LIGHTCONE_METADATA[lcid]) {
|
||||||
|
LIGHTCONE_METADATA[lcid].images = LIGHTCONE_METADATA[lcid].images || {};
|
||||||
|
LIGHTCONE_METADATA[lcid].images.fullArt = GACHABASE_IMAGE_CACHE.lightcones[lcid];
|
||||||
|
LIGHTCONE_METADATA[lcid].images.thumbnail = GACHABASE_IMAGE_CACHE.lightconeThumbs[lcid];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`[Gachabase] Successfully loaded ${registry.length} cached items from IndexedDB.`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Gachabase] Failed to restore cached items from IndexedDB:', err);
|
console.warn('Failed to restore Gachabase cache from IndexedDB:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies parsed Gachabase character, lightcone, or relics into FreeSR data structure
|
* Image resolver for avatars
|
||||||
|
* Prioritizes local asset, falls back to Gachabase CDN
|
||||||
*/
|
*/
|
||||||
export function applyGachabaseToFreeSR(parsedItem, fullFreesrData, options = {}) {
|
export function getAvatarIconUrl(avatarId) {
|
||||||
if (!fullFreesrData) {
|
const id = parseInt(avatarId, 10);
|
||||||
throw new Error('FreeSR data is not initialized');
|
if (GACHABASE_IMAGE_CACHE.avatars[id]) {
|
||||||
|
return GACHABASE_IMAGE_CACHE.avatars[id];
|
||||||
}
|
}
|
||||||
|
if (KNOWN_GACHABASE_ASSETS.avatars[id]) {
|
||||||
fullFreesrData.avatars = fullFreesrData.avatars || {};
|
return KNOWN_GACHABASE_ASSETS.avatars[id];
|
||||||
fullFreesrData.lightcones = fullFreesrData.lightcones || [];
|
}
|
||||||
fullFreesrData.relics = fullFreesrData.relics || [];
|
return `./assets/avatars/${id}.png`;
|
||||||
|
}
|
||||||
const eLevel = options.eLevel !== undefined ? options.eLevel : 0; // 0 or 6
|
|
||||||
const sLevel = options.sLevel !== undefined ? options.sLevel : 1; // 1 or 5
|
/**
|
||||||
const autoEquipLc = options.autoEquipLc !== false;
|
* Image resolver for lightcones
|
||||||
const autoEquipRelics = options.autoEquipRelics !== false;
|
*/
|
||||||
|
export function getLightconeArtUrl(lcId) {
|
||||||
const results = {
|
const id = parseInt(lcId, 10);
|
||||||
avatarAdded: false,
|
if (GACHABASE_IMAGE_CACHE.lightcones[id]) {
|
||||||
lcAdded: false,
|
return GACHABASE_IMAGE_CACHE.lightcones[id];
|
||||||
relicsAdded: 0
|
}
|
||||||
};
|
if (KNOWN_GACHABASE_ASSETS.lightcones[id]) {
|
||||||
|
return KNOWN_GACHABASE_ASSETS.lightcones[id];
|
||||||
// If character:
|
}
|
||||||
if (parsedItem.type === 'character') {
|
if (id === 23065) {
|
||||||
const aid = parsedItem.id;
|
return './assets/lightcones/23065.png';
|
||||||
|
}
|
||||||
// Generate maxed traces & skills map
|
return `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/image/light_cone_preview/${id}.png`;
|
||||||
const skillsMap = {};
|
}
|
||||||
if (Array.isArray(parsedItem.skills) && parsedItem.skills.length > 0) {
|
|
||||||
parsedItem.skills.forEach(s => {
|
/**
|
||||||
if (s.id) {
|
* Image resolver for lightcone thumbnails
|
||||||
skillsMap[String(s.id)] = 10;
|
*/
|
||||||
|
export function getLightconeThumbUrl(lcId) {
|
||||||
|
const id = parseInt(lcId, 10);
|
||||||
|
if (GACHABASE_IMAGE_CACHE.lightconeThumbs[id]) {
|
||||||
|
return GACHABASE_IMAGE_CACHE.lightconeThumbs[id];
|
||||||
|
}
|
||||||
|
if (KNOWN_GACHABASE_ASSETS.lightconeThumbs[id]) {
|
||||||
|
return KNOWN_GACHABASE_ASSETS.lightconeThumbs[id];
|
||||||
|
}
|
||||||
|
return getLightconeArtUrl(lcId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Robust error handler for Avatar images
|
||||||
|
* Falls back through: Local Asset -> StarRailRes -> Gachabase CDN -> Letter Placeholder
|
||||||
|
*/
|
||||||
|
export function handleAvatarImgError(img, avatarId) {
|
||||||
|
if (!img) return;
|
||||||
|
const id = parseInt(avatarId, 10);
|
||||||
|
const step = parseInt(img.getAttribute('data-err-step') || '0', 10);
|
||||||
|
|
||||||
|
if (step === 0) {
|
||||||
|
img.setAttribute('data-err-step', '1');
|
||||||
|
img.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/icon/avatar/${id}.png`;
|
||||||
|
} else if (step === 1) {
|
||||||
|
img.setAttribute('data-err-step', '2');
|
||||||
|
const gachaUrl = GACHABASE_IMAGE_CACHE.avatars[id] || KNOWN_GACHABASE_ASSETS.avatars[id];
|
||||||
|
if (gachaUrl && img.src !== gachaUrl) {
|
||||||
|
img.src = gachaUrl;
|
||||||
|
} else {
|
||||||
|
img.style.display = 'none';
|
||||||
|
if (img.nextElementSibling && img.nextElementSibling.classList.contains('avatar-icon-placeholder')) {
|
||||||
|
img.nextElementSibling.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
img.style.display = 'none';
|
||||||
|
if (img.nextElementSibling && img.nextElementSibling.classList.contains('avatar-icon-placeholder')) {
|
||||||
|
img.nextElementSibling.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Robust error handler for Lightcone images
|
||||||
|
* Falls back through: Local Asset -> StarRailRes -> Gachabase CDN -> Default Logo
|
||||||
|
*/
|
||||||
|
export function handleLcImgError(img, lcId) {
|
||||||
|
if (!img) return;
|
||||||
|
const id = parseInt(lcId, 10);
|
||||||
|
const step = parseInt(img.getAttribute('data-err-step') || '0', 10);
|
||||||
|
|
||||||
|
if (step === 0) {
|
||||||
|
img.setAttribute('data-err-step', '1');
|
||||||
|
img.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/image/light_cone_preview/${id}.png`;
|
||||||
|
} else if (step === 1) {
|
||||||
|
img.setAttribute('data-err-step', '2');
|
||||||
|
const gachaUrl = GACHABASE_IMAGE_CACHE.lightcones[id] || KNOWN_GACHABASE_ASSETS.lightcones[id] || GACHABASE_IMAGE_CACHE.lightconeThumbs[id] || KNOWN_GACHABASE_ASSETS.lightconeThumbs[id];
|
||||||
|
if (gachaUrl && img.src !== gachaUrl) {
|
||||||
|
img.src = gachaUrl;
|
||||||
|
} else {
|
||||||
|
img.src = './assets/logo.png';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
img.src = './assets/logo.png';
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
// Add standard fallback combat traces
|
|
||||||
skillsMap[`${aid}001`] = 6; // Basic ATK
|
|
||||||
skillsMap[`${aid}002`] = 10; // Skill
|
|
||||||
skillsMap[`${aid}003`] = 10; // Ultimate
|
|
||||||
skillsMap[`${aid}004`] = 10; // Talent
|
|
||||||
skillsMap[`${aid}007`] = 1; // Technique
|
|
||||||
|
|
||||||
if (!fullFreesrData.avatars[aid]) {
|
|
||||||
fullFreesrData.avatars[aid] = {
|
|
||||||
owner_uid: 0,
|
|
||||||
avatar_id: aid,
|
|
||||||
level: 80,
|
|
||||||
promotion: 6,
|
|
||||||
techniques: [],
|
|
||||||
sp_max: parsedItem.maxEnergy || 140,
|
|
||||||
sp_value: Math.floor((parsedItem.maxEnergy || 140) / 2),
|
|
||||||
data: {
|
|
||||||
rank: eLevel,
|
|
||||||
skills: skillsMap
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const av = fullFreesrData.avatars[aid];
|
|
||||||
av.level = 80;
|
|
||||||
av.promotion = 6;
|
|
||||||
av.data = av.data || {};
|
|
||||||
av.data.rank = eLevel;
|
|
||||||
av.data.skills = Object.assign(av.data.skills || {}, skillsMap);
|
|
||||||
}
|
|
||||||
results.avatarAdded = true;
|
|
||||||
|
|
||||||
// Equip Signature Lightcone if available
|
|
||||||
const sigLcId = parsedItem.recommended.signatureLcId || (options.sigLcId || null);
|
|
||||||
if (autoEquipLc && sigLcId) {
|
|
||||||
// Check if this character already has an LC
|
|
||||||
let existingLc = fullFreesrData.lightcones.find(l => l.equip_avatar === aid);
|
|
||||||
if (existingLc) {
|
|
||||||
existingLc.item_id = sigLcId;
|
|
||||||
existingLc.level = 80;
|
|
||||||
existingLc.promotion = 6;
|
|
||||||
existingLc.rank = sLevel;
|
|
||||||
} else {
|
|
||||||
const nextUid = (fullFreesrData.lightcones.reduce((m, l) => Math.max(m, l.unique_id || 0), 0)) + 1;
|
|
||||||
fullFreesrData.lightcones.push({
|
|
||||||
unique_id: nextUid,
|
|
||||||
item_id: sigLcId,
|
|
||||||
level: 80,
|
|
||||||
promotion: 6,
|
|
||||||
rank: sLevel,
|
|
||||||
equip_avatar: aid
|
|
||||||
});
|
|
||||||
}
|
|
||||||
results.lcAdded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Equip Recommended Relics if available
|
|
||||||
if (autoEquipRelics && (parsedItem.recommended.cavernSets.length > 0 || parsedItem.recommended.planarSets.length > 0)) {
|
|
||||||
// Clear existing relics on this character
|
|
||||||
fullFreesrData.relics = fullFreesrData.relics.filter(r => r.equip_avatar !== aid);
|
|
||||||
|
|
||||||
const cavernSet = parsedItem.recommended.cavernSets[0] || 121;
|
|
||||||
const planarSet = parsedItem.recommended.planarSets[0] || 310;
|
|
||||||
let nextRelicUid = (fullFreesrData.relics.reduce((m, r) => Math.max(m, r.internal_uid || 0), 0)) + 1;
|
|
||||||
|
|
||||||
// 6 relic slots
|
|
||||||
for (let slot = 1; slot <= 6; slot++) {
|
|
||||||
const setId = slot <= 4 ? cavernSet : planarSet;
|
|
||||||
const relicId = 60000 + (setId * 10) + slot;
|
|
||||||
|
|
||||||
// Default smart main stats
|
|
||||||
let mainAffixId = 1;
|
|
||||||
if (slot === 3) mainAffixId = 5; // CRIT Rate% / ATK%
|
|
||||||
else if (slot === 4) mainAffixId = 5; // SPD / ATK%
|
|
||||||
else if (slot === 5) mainAffixId = 6; // Elemental DMG%
|
|
||||||
else if (slot === 6) mainAffixId = 4; // Energy Regen% / ATK%
|
|
||||||
|
|
||||||
fullFreesrData.relics.push({
|
|
||||||
internal_uid: nextRelicUid++,
|
|
||||||
relic_id: relicId,
|
|
||||||
level: 15,
|
|
||||||
equip_avatar: aid,
|
|
||||||
main_affix_id: mainAffixId,
|
|
||||||
sub_affixes: [
|
|
||||||
{ sub_affix_id: 1, cnt: 2, step: 2 },
|
|
||||||
{ sub_affix_id: 2, cnt: 3, step: 2 },
|
|
||||||
{ sub_affix_id: 3, cnt: 2, step: 2 },
|
|
||||||
{ sub_affix_id: 4, cnt: 2, step: 2 }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
results.relicsAdded++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (parsedItem.type === 'lightcone') {
|
|
||||||
// Add standalone lightcone to inventory
|
|
||||||
const nextUid = (fullFreesrData.lightcones.reduce((m, l) => Math.max(m, l.unique_id || 0), 0)) + 1;
|
|
||||||
fullFreesrData.lightcones.push({
|
|
||||||
unique_id: nextUid,
|
|
||||||
item_id: parsedItem.id,
|
|
||||||
level: 80,
|
|
||||||
promotion: 6,
|
|
||||||
rank: sLevel,
|
|
||||||
equip_avatar: 0
|
|
||||||
});
|
|
||||||
results.lcAdded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
+116
-117
@@ -1,13 +1,13 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Gachabase CLI Sync Utility for GaC-srtool & GaC-hkrpg-go
|
* Gachabase CLI Sync Utility for GaC-srtool & GaC-hkrpg-go
|
||||||
* Fetches character/lightcone datamine from hsr.gachabase.net,
|
* Automatically fetches the complete character and lightcone catalog from Gachabase,
|
||||||
* downloads assets, and outputs or updates metadata files.
|
* detects new/updated items, and downloads high-res images to assets/.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
|
* node scripts/sync-gachabase.mjs # Syncs all characters & LCs, downloading missing assets
|
||||||
* node scripts/sync-gachabase.mjs --char 1511
|
* node scripts/sync-gachabase.mjs --char 1511
|
||||||
* node scripts/sync-gachabase.mjs --lc 23065
|
* node scripts/sync-gachabase.mjs --lc 23065
|
||||||
* node scripts/sync-gachabase.mjs --all-beta
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -19,9 +19,6 @@ const __filename = fileURLToPath(import.meta.url);
|
|||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
const KNOWN_BETA_CHARACTERS = [1501, 1502, 1505, 1511, 1513];
|
|
||||||
const KNOWN_BETA_LIGHTCONES = [23065];
|
|
||||||
|
|
||||||
function fetchHttps(url) {
|
function fetchHttps(url) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
https.get(url, {
|
https.get(url, {
|
||||||
@@ -60,149 +57,151 @@ function downloadBinary(url, destPath) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanRichText(str) {
|
function parseSvelteKitCatalog(html) {
|
||||||
if (!str || typeof str !== 'string') return '';
|
if (!html || typeof html !== 'string') return null;
|
||||||
return str
|
const scripts = [...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/gi)];
|
||||||
.replace(/<color=[^>]+>/gi, '')
|
for (const s of scripts) {
|
||||||
.replace(/<\/color>/gi, '')
|
const text = s[1].trim();
|
||||||
.replace(/<[^>]+>/g, '')
|
if (text.includes('resolve') && text.includes('entries')) {
|
||||||
.replace(/\\n/g, '\n')
|
const startIdx = text.indexOf('() => ');
|
||||||
.trim();
|
if (startIdx !== -1) {
|
||||||
|
let code = text.substring(startIdx + 6).trim();
|
||||||
|
if (code.endsWith(');')) code = code.slice(0, -2);
|
||||||
|
else if (code.endsWith(')')) code = code.slice(0, -1);
|
||||||
|
try {
|
||||||
|
const fn = new Function('return (' + code + ')');
|
||||||
|
const res = fn();
|
||||||
|
return res[0]?.data;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GACHABASE_PATHS = {
|
const GACHABASE_PATHS = {
|
||||||
1: 'Destruction',
|
1: 'Destruction', 2: 'Hunt', 3: 'Erudition', 4: 'Harmony',
|
||||||
2: 'Hunt',
|
5: 'Nihility', 6: 'Preservation', 7: 'Abundance', 8: 'Remembrance', 9: 'Elation'
|
||||||
3: 'Erudition',
|
|
||||||
4: 'Harmony',
|
|
||||||
5: 'Nihility',
|
|
||||||
6: 'Preservation',
|
|
||||||
7: 'Abundance',
|
|
||||||
8: 'Remembrance',
|
|
||||||
9: 'Elation'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const GACHABASE_ELEMENTS = {
|
const GACHABASE_ELEMENTS = {
|
||||||
1: 'Physical',
|
1: 'Physical', 2: 'Fire', 4: 'Ice', 8: 'Thunder',
|
||||||
2: 'Fire',
|
16: 'Wind', 32: 'Quantum', 64: 'Imaginary'
|
||||||
4: 'Ice',
|
|
||||||
8: 'Thunder',
|
|
||||||
16: 'Wind',
|
|
||||||
32: 'Quantum',
|
|
||||||
64: 'Imaginary'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function syncItem(type, id, branch = 'beta', lang = 'en') {
|
async function syncAllCatalog() {
|
||||||
const typeSlug = type === 'lightcone' ? 'lightcones' : 'characters';
|
console.log('====================================================');
|
||||||
const url = `https://hsr.gachabase.net/${typeSlug}/${id}/slug/${branch}?lang=${lang}`;
|
console.log('🔄 Syncing full game database from Gachabase...');
|
||||||
console.log(`\n[Gachabase] Fetching ${type} ${id} from ${url}...`);
|
console.log('====================================================');
|
||||||
|
|
||||||
const { status, body } = await fetchHttps(url);
|
// 1. Sync Characters
|
||||||
if (status !== 200) {
|
console.log('\n[1/2] Fetching characters catalog...');
|
||||||
console.error(`[Error] Failed to fetch ${type} ${id}: HTTP ${status}`);
|
const { body: charBody } = await fetchHttps('https://hsr.gachabase.net/characters?lang=en&branch=beta');
|
||||||
return null;
|
const charData = parseSvelteKitCatalog(charBody);
|
||||||
}
|
|
||||||
|
|
||||||
const kitMatch = body.match(/kit\.start\(app,\s*element,\s*(\{.*?\})\);\s*\}\);/s);
|
let newAvatarImages = 0;
|
||||||
if (!kitMatch) {
|
if (charData && Array.isArray(charData.entries)) {
|
||||||
console.error(`[Error] SvelteKit payload not found in page for ${type} ${id}`);
|
console.log(`Found ${charData.entries.length} characters in Gachabase catalog.`);
|
||||||
return null;
|
const assets = charData.refs?.assets || {};
|
||||||
}
|
const avatarDir = path.join(ROOT_DIR, 'assets', 'avatars');
|
||||||
|
fs.mkdirSync(avatarDir, { recursive: true });
|
||||||
|
|
||||||
const fn = new Function('return (' + kitMatch[1] + ')');
|
for (const entry of charData.entries) {
|
||||||
const kitData = fn();
|
const aid = entry.id;
|
||||||
|
const dest = path.join(avatarDir, `${aid}.png`);
|
||||||
|
const exists = fs.existsSync(dest) && fs.statSync(dest).size > 1000;
|
||||||
|
|
||||||
let targetData = null;
|
if (!exists) {
|
||||||
for (const entry of kitData.data) {
|
const hash = entry.assets?.square_icon_path_hash || entry.assets?.round_icon_path_hash;
|
||||||
if (!entry) continue;
|
const iconUrl = assets[hash]?.url;
|
||||||
const cand = entry.data?.data?.data || entry.data?.data || entry.data;
|
|
||||||
if (cand && (cand.dto || cand.refs)) {
|
|
||||||
targetData = cand;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetData || !targetData.dto) {
|
|
||||||
console.error(`[Error] DTO not found for ${type} ${id}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dto = targetData.dto;
|
|
||||||
const refs = targetData.refs || {};
|
|
||||||
const assetsMap = refs.assets || {};
|
|
||||||
|
|
||||||
const resolveAsset = (hash) => {
|
|
||||||
if (!hash) return null;
|
|
||||||
return assetsMap[hash]?.url || `https://cdn.gachabase.net/hsr/assets/${hash}.png`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const name = dto.name?.text || dto.name || `${type} ${id}`;
|
|
||||||
const pathName = refs.paths?.[dto.path_id]?.name?.text || GACHABASE_PATHS[dto.path_id] || 'Unknown';
|
|
||||||
const elementName = refs.types?.[dto.type_id]?.name?.text || GACHABASE_ELEMENTS[dto.type_id] || 'Unknown';
|
|
||||||
|
|
||||||
console.log(`✨ Successfully parsed: [${dto.rarity}★] ${name} (${pathName} / ${elementName})`);
|
|
||||||
|
|
||||||
// Download Avatar Icon / Splash
|
|
||||||
if (type === 'character') {
|
|
||||||
const iconUrl = resolveAsset(dto.assets?.round_icon_path_hash) || resolveAsset(dto.assets?.square_icon_path_hash);
|
|
||||||
if (iconUrl) {
|
if (iconUrl) {
|
||||||
const dest = path.join(ROOT_DIR, 'assets', 'avatars', `${id}.png`);
|
process.stdout.write(` Downloading icon for [${entry.rarity}★] ${entry.name?.text || aid} (ID: ${aid})... `);
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
||||||
const ok = await downloadBinary(iconUrl, dest);
|
const ok = await downloadBinary(iconUrl, dest);
|
||||||
console.log(` Icon saved -> assets/avatars/${id}.png: ${ok ? 'OK' : 'FAILED'}`);
|
if (ok) {
|
||||||
|
console.log('OK');
|
||||||
|
newAvatarImages++;
|
||||||
|
} else {
|
||||||
|
console.log('FAILED');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (type === 'lightcone') {
|
|
||||||
const iconUrl = resolveAsset(dto.assets?.full_art_path_hash) || resolveAsset(dto.assets?.thumbnail_art_path_hash);
|
|
||||||
if (iconUrl) {
|
|
||||||
const dest = path.join(ROOT_DIR, 'assets', 'lightcones', `${id}.png`);
|
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
||||||
const ok = await downloadBinary(iconUrl, dest);
|
|
||||||
console.log(` Lightcone art saved -> assets/lightcones/${id}.png: ${ok ? 'OK' : 'FAILED'}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// 2. Sync Lightcones
|
||||||
id: dto.id,
|
console.log('\n[2/2] Fetching lightcones catalog...');
|
||||||
name,
|
const { body: lcBody } = await fetchHttps('https://hsr.gachabase.net/lightcones?lang=en&branch=beta');
|
||||||
rarity: dto.rarity,
|
const lcData = parseSvelteKitCatalog(lcBody);
|
||||||
path: pathName,
|
|
||||||
element: elementName,
|
let newLcImages = 0;
|
||||||
dto
|
if (lcData && Array.isArray(lcData.entries)) {
|
||||||
};
|
console.log(`Found ${lcData.entries.length} lightcones in Gachabase catalog.`);
|
||||||
|
const assets = lcData.refs?.assets || {};
|
||||||
|
const lcDir = path.join(ROOT_DIR, 'assets', 'lightcones');
|
||||||
|
fs.mkdirSync(lcDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const entry of lcData.entries) {
|
||||||
|
const lcid = entry.id;
|
||||||
|
const dest = path.join(lcDir, `${lcid}.png`);
|
||||||
|
const exists = fs.existsSync(dest) && fs.statSync(dest).size > 1000;
|
||||||
|
|
||||||
|
// Only auto-download 5-star or missing special lightcones
|
||||||
|
if (!exists && (entry.rarity === 5 || lcid === 23065)) {
|
||||||
|
const hash = entry.assets?.thumbnail_art_path_hash || entry.assets?.full_art_path_hash;
|
||||||
|
const iconUrl = assets[hash]?.url;
|
||||||
|
if (iconUrl) {
|
||||||
|
process.stdout.write(` Downloading art for [${entry.rarity}★] ${entry.name?.text || lcid} (ID: ${lcid})... `);
|
||||||
|
const ok = await downloadBinary(iconUrl, dest);
|
||||||
|
if (ok) {
|
||||||
|
console.log('OK');
|
||||||
|
newLcImages++;
|
||||||
|
} else {
|
||||||
|
console.log('FAILED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n====================================================');
|
||||||
|
console.log(`✅ Gachabase Catalog Sync Completed!`);
|
||||||
|
console.log(` New Avatars downloaded: ${newAvatarImages}`);
|
||||||
|
console.log(` New Lightcones downloaded: ${newLcImages}`);
|
||||||
|
console.log('====================================================\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const charIdx = args.indexOf('--char');
|
const charIdx = args.indexOf('--char');
|
||||||
const lcIdx = args.indexOf('--lc');
|
const lcIdx = args.indexOf('--lc');
|
||||||
const allBeta = args.includes('--all-beta');
|
|
||||||
|
|
||||||
if (allBeta) {
|
|
||||||
console.log('--- Starting Sync for All Known Beta Characters & Lightcones ---');
|
|
||||||
for (const cid of KNOWN_BETA_CHARACTERS) {
|
|
||||||
await syncItem('character', cid);
|
|
||||||
}
|
|
||||||
for (const lcid of KNOWN_BETA_LIGHTCONES) {
|
|
||||||
await syncItem('lightcone', lcid);
|
|
||||||
}
|
|
||||||
console.log('\n--- Beta Sync Completed ---');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (charIdx !== -1 && args[charIdx + 1]) {
|
if (charIdx !== -1 && args[charIdx + 1]) {
|
||||||
const cid = parseInt(args[charIdx + 1], 10);
|
const aid = parseInt(args[charIdx + 1], 10);
|
||||||
await syncItem('character', cid);
|
console.log(`Syncing single character: ${aid}`);
|
||||||
|
const { body } = await fetchHttps(`https://hsr.gachabase.net/characters/${aid}/slug/beta?lang=en`);
|
||||||
|
// Download icon
|
||||||
|
const match = body.match(/https:\/\/cdn\.gachabase\.net\/hsr\/assets\/[a-f0-9]+\.png/);
|
||||||
|
if (match) {
|
||||||
|
const dest = path.join(ROOT_DIR, 'assets', 'avatars', `${aid}.png`);
|
||||||
|
await downloadBinary(match[0], dest);
|
||||||
|
console.log(`Saved icon to ${dest}`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lcIdx !== -1 && args[lcIdx + 1]) {
|
if (lcIdx !== -1 && args[lcIdx + 1]) {
|
||||||
const lcid = parseInt(args[lcIdx + 1], 10);
|
const lcid = parseInt(args[lcIdx + 1], 10);
|
||||||
await syncItem('lightcone', lcid);
|
console.log(`Syncing single lightcone: ${lcid}`);
|
||||||
|
const { body } = await fetchHttps(`https://hsr.gachabase.net/lightcones/${lcid}/slug/beta?lang=en`);
|
||||||
|
const match = body.match(/https:\/\/cdn\.gachabase\.net\/hsr\/assets\/[a-f0-9]+\.png/);
|
||||||
|
if (match) {
|
||||||
|
const dest = path.join(ROOT_DIR, 'assets', 'lightcones', `${lcid}.png`);
|
||||||
|
await downloadBinary(match[0], dest);
|
||||||
|
console.log(`Saved LC art to ${dest}`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Usage:');
|
// Default: Sync full catalog
|
||||||
console.log(' node scripts/sync-gachabase.mjs --char <id>');
|
await syncAllCatalog();
|
||||||
console.log(' node scripts/sync-gachabase.mjs --lc <id>');
|
|
||||||
console.log(' node scripts/sync-gachabase.mjs --all-beta');
|
|
||||||
})();
|
})();
|
||||||
Reference in new issue
Block a user