diff --git a/assets/avatars/1501.png b/assets/avatars/1501.png index 0d043c6..1735925 100644 Binary files a/assets/avatars/1501.png and b/assets/avatars/1501.png differ diff --git a/assets/avatars/1502.png b/assets/avatars/1502.png index 838f05f..e4c1a09 100644 Binary files a/assets/avatars/1502.png and b/assets/avatars/1502.png differ diff --git a/assets/avatars/1505.png b/assets/avatars/1505.png index 85509e1..3d33ed0 100644 Binary files a/assets/avatars/1505.png and b/assets/avatars/1505.png differ diff --git a/assets/avatars/1513.png b/assets/avatars/1513.png index 1e55cbf..4ae4f77 100644 Binary files a/assets/avatars/1513.png and b/assets/avatars/1513.png differ diff --git a/css/styles.css b/css/styles.css index eb87a07..b46dd0a 100644 --- a/css/styles.css +++ b/css/styles.css @@ -2401,4 +2401,106 @@ input:checked + .slider:before { filter: grayscale(0.8); } +/* Gachabase Importer & Modal Styling */ +.gachabase-quick-chip { + background: rgba(255, 255, 255, 0.05) !important; + border: 1px solid var(--border-color) !important; + color: var(--text-main) !important; + cursor: pointer; + transition: var(--transition); +} + +.gachabase-quick-chip:hover { + background: rgba(251, 191, 36, 0.15) !important; + border-color: var(--accent-gold) !important; + color: var(--accent-gold) !important; +} + +.gachabase-preview-card { + display: flex; + gap: 1.25rem; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 1.25rem; + margin-bottom: 1rem; +} + +.gachabase-art-column { + width: 220px; + min-width: 220px; + display: flex; + flex-direction: column; + gap: 0.75rem; + align-items: center; +} + +.gachabase-art-img { + width: 100%; + max-height: 280px; + object-fit: cover; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + background: rgba(0, 0, 0, 0.4); +} + +.gachabase-details-column { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.gachabase-header-title { + font-size: 1.4rem; + font-weight: 800; + color: #fff; + display: flex; + align-items: center; + gap: 0.6rem; +} + +.gachabase-skill-item { + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 0.6rem 0.8rem; + display: flex; + gap: 0.75rem; + align-items: flex-start; +} + +.gachabase-skill-icon { + width: 36px; + height: 36px; + min-width: 36px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.4); + border: 1px solid var(--border-color); + object-fit: contain; + padding: 2px; +} + +.gachabase-eidolon-item { + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 0.5rem 0.75rem; + display: flex; + gap: 0.6rem; + align-items: center; +} + +@media (max-width: 768px) { + .gachabase-preview-card { + flex-direction: column; + align-items: center; + } + .gachabase-art-column { + width: 100%; + } +} + + diff --git a/index.html b/index.html index 2b047e1..e028b08 100644 --- a/index.html +++ b/index.html @@ -44,6 +44,10 @@ + + @@ -194,6 +198,15 @@
เคลียร์ทุกช่องเพื่อจัดทีมใหม่
+ + + @@ -233,6 +246,7 @@
+ @@ -285,6 +299,7 @@
+
@@ -1372,6 +1387,81 @@
+ + +
diff --git a/js/app.js b/js/app.js index 63b5e48..f0d5f01 100644 --- a/js/app.js +++ b/js/app.js @@ -1,6 +1,14 @@ import { AVATAR_METADATA, getAvatarInfo } from './avatars-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 { + parseGachabaseInput, + fetchGachabaseHtml, + parseGachabaseHtml, + registerGachabaseItem, + loadCachedGachabaseRegistry, + applyGachabaseToFreeSR +} from './gachabase.js'; // Application State const state = { @@ -58,6 +66,25 @@ const el = { btnQuickAddAllLcs: document.getElementById('btn-quick-add-all-lcs'), btnQuickTeleportTrain: document.getElementById('btn-quick-teleport-train'), btnQuickClearLineup: document.getElementById('btn-quick-clear-lineup'), + btnQuickGachabase: document.getElementById('btn-quick-gachabase'), + btnOpenGachabase: document.getElementById('btn-open-gachabase'), + btnAvatarsGachabase: document.getElementById('btn-avatars-gachabase'), + btnEqGachabase: document.getElementById('btn-eq-gachabase'), + + // Gachabase Modal + modalGachabase: document.getElementById('modal-gachabase'), + btnCloseGachabaseModal: document.getElementById('btn-close-gachabase-modal'), + 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 avatarsGrid: document.getElementById('avatars-grid'), @@ -756,8 +783,26 @@ function setupServerModes() { } // Initialize Avatars Database: Populates all known avatars from metadata -function initAvatarsList() { - if (state.avatarsList.length > 0) return; +function initAvatarsList(force = false) { + if (state.avatarsList.length > 0 && !force) { + let missing = false; + for (const aidStr in AVATAR_METADATA) { + const aid = parseInt(aidStr, 10); + if (!state.avatarsList.some(a => a.avatar_id === aid)) { + state.avatarsList.push({ + avatar_id: aid, + level: 80, + promotion: 6, + rank: 6 + }); + missing = true; + } + } + if (missing) { + state.avatarsList.sort((a, b) => b.avatar_id - a.avatar_id); + } + return; + } const list = []; for (const aidStr in AVATAR_METADATA) { const aid = parseInt(aidStr, 10); @@ -772,6 +817,7 @@ function initAvatarsList() { state.avatarsList = list; } + // Default lineup matching our persistent server data const DEFAULT_LINEUP = { 0: 1511, 1: 1506, 2: 1502, 3: 1503 }; @@ -1532,6 +1578,9 @@ const RECOMMENDED_RELIC_BUILDS = { // Lightcone Art URL Resolvers (Local asset -> Gachabase -> StarRailRes) function getLightconeArtSrc(lcId) { const id = parseInt(lcId, 10); + const meta = LIGHTCONE_METADATA[id]; + if (meta?.images?.fullArt) return meta.images.fullArt; + if (meta?.images?.thumbnail) return meta.images.thumbnail; if (id === 23065) { return './assets/lightcones/23065.png'; } @@ -1540,12 +1589,32 @@ function getLightconeArtSrc(lcId) { function getLightconeArtFallback(lcId) { const id = parseInt(lcId, 10); + const meta = LIGHTCONE_METADATA[id]; + if (meta?.images?.thumbnail) return meta.images.thumbnail; + if (meta?.images?.fullArt) return meta.images.fullArt; if (id === 23065) { return 'https://cdn.gachabase.net/hsr/assets/500cfc18698932759756f6b3ba4db417.png'; } return `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/image/light_cone_preview/${id}.png`; } +function getAvatarIconSrc(aid) { + const id = parseInt(aid, 10); + const meta = AVATAR_METADATA[id]; + if (meta?.images?.icon) return meta.images.icon; + if (meta?.images?.square) return meta.images.square; + return `./assets/avatars/${id}.png`; +} + +function getAvatarSplashSrc(aid) { + const id = parseInt(aid, 10); + const meta = AVATAR_METADATA[id]; + if (meta?.images?.splash) return meta.images.splash; + if (meta?.images?.fullArt) return meta.images.fullArt; + return `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/image/character_portrait/${id}.png`; +} + + // Modal Sub-tabs Handler function switchModalSubtab(targetId) { const tabBtns = document.querySelectorAll('.modal-subtab-btn'); @@ -4903,6 +4972,326 @@ function setupTabs() { }); } +// ========================================== +// Gachabase Data Fetcher & Importer Logic +// ========================================== +let currentGachabaseItem = null; + +function openGachabaseModal(defaultId = null, defaultType = 'character') { + if (!el.modalGachabase) return; + el.modalGachabase.classList.add('active'); + if (defaultId) { + if (el.gachabaseInput) el.gachabaseInput.value = String(defaultId); + if (el.gachabaseType) el.gachabaseType.value = defaultType; + performGachabaseFetch(); + } else { + setTimeout(() => el.gachabaseInput?.focus(), 50); + } +} + +function closeGachabaseModal() { + if (!el.modalGachabase) return; + el.modalGachabase.classList.remove('active'); +} + +async function performGachabaseFetch() { + const input = el.gachabaseInput?.value?.trim(); + if (!input) { + showToast('กรุณาระบุ ID หรือ URL ของ Gachabase', 'warning'); + return; + } + + const type = el.gachabaseType?.value || 'character'; + const branch = el.gachabaseBranch?.value || 'beta'; + const lang = el.gachabaseLang?.value || 'en'; + + const parsedParams = parseGachabaseInput(input, type, branch, lang); + if (!parsedParams) { + showToast('รูปแบบข้อมูลไม่ถูกต้อง กรุณาระบุ ID ตัวเลขหรือ URL', 'error'); + return; + } + + if (parsedParams.type && el.gachabaseType) el.gachabaseType.value = parsedParams.type; + 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 (${parsedParams.url})...`; + } + if (el.gachabasePreviewContainer) el.gachabasePreviewContainer.style.display = 'none'; + if (el.gachabaseFooterActions) el.gachabaseFooterActions.style.display = 'none'; + if (el.btnGachabaseFetch) el.btnGachabaseFetch.disabled = true; + + try { + const { html, finalUrl } = await fetchGachabaseHtml(parsedParams.url, state.apiUrl); + const parsedItem = parseGachabaseHtml(html, lang); + parsedItem.sourceUrl = finalUrl; + parsedItem.branch = parsedParams.branch; + parsedItem.lang = lang; + currentGachabaseItem = parsedItem; + + if (el.gachabaseStatus) { + el.gachabaseStatus.style.background = 'rgba(52, 211, 153, 0.15)'; + el.gachabaseStatus.style.border = '1px solid rgba(52, 211, 153, 0.4)'; + el.gachabaseStatus.style.color = 'var(--accent-green)'; + el.gachabaseStatus.innerHTML = `✅ ดึงข้อมูลสำเร็จ: [${parsedItem.rarity}★] ${parsedItem.name} (ID: ${parsedItem.id})`; + } + + renderGachabasePreview(parsedItem); + } catch (err) { + console.error('Gachabase fetch error:', err); + if (el.gachabaseStatus) { + el.gachabaseStatus.style.background = 'rgba(248, 113, 113, 0.15)'; + el.gachabaseStatus.style.border = '1px solid rgba(248, 113, 113, 0.4)'; + el.gachabaseStatus.style.color = 'var(--accent-red)'; + el.gachabaseStatus.innerHTML = `❌ เกิดข้อผิดพลาดในการดึงข้อมูล: ${err.message}`; + } + } finally { + if (el.btnGachabaseFetch) el.btnGachabaseFetch.disabled = false; + } +} + +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 = ''; + if (isChar) { + mainImgUrl = item.images.splash || item.images.icon || `./assets/avatars/${item.id}.png`; + } else if (isLc) { + mainImgUrl = item.images.fullArt || item.images.thumbnail || `./assets/lightcones/${item.id}.png`; + } else { + mainImgUrl = item.images.icon || `./assets/logo.png`; + } + + let recHtml = ''; + if (isChar) { + const cavernSets = item.recommended.cavernSets || []; + const planarSets = item.recommended.planarSets || []; + const sigLcId = item.recommended.signatureLcId; + const sigLcMeta = sigLcId ? getLightconeInfo(sigLcId) : null; + + recHtml = ` +
+
+ 💡 คำแนะนำการจัด Build จาก Gachabase +
+
+
+ 🗡️ กรวยแสงแนะนำ (Signature): + ${sigLcMeta ? sigLcMeta.name : (sigLcId ? `LC ID: ${sigLcId}` : 'ตามสายตัวละคร')} +
+
+ 🛡️ เซ็ตรีลิกส์แนะนำ (Cavern): + ${cavernSets.map(s => getRelicSetInfo(s).name).join(', ') || 'ตามประเภทสาย'} +
+
+ 🔮 เครื่องประดับแนะนำ (Planar): + ${planarSets.map(s => getRelicSetInfo(s).name).join(', ') || 'ตามประเภทสาย'} +
+
+
+ `; + } + + let skillsHtml = ''; + if (isChar && item.skills && item.skills.length > 0) { + skillsHtml = ` +
+

+ ⚡ สกิลและการต่อสู้ (${item.skills.length} ท่า) +

+
+ ${item.skills.map(s => ` +
+ ${s.icon ? `${s.name}` : ''} +
+
+ ${s.name} + ${s.type} + ${s.tag ? `${s.tag}` : ''} +
+
${s.description}
+
+
+ `).join('')} +
+
+ `; + } else if (isLc && item.lcSkill) { + skillsHtml = ` +
+
+ ✨ เอฟเฟกต์สกิลพิเศษ: ${item.lcSkill.name || 'Passive Skill'} +
+
${item.lcSkill.description || '-'}
+
+ `; + } + + let eidolonsHtml = ''; + if (isChar && item.eidolons && item.eidolons.length > 0) { + eidolonsHtml = ` +
+

+ ⭐ กลุ่มดาว Eidolons (E1 - E6) +

+
+ ${item.eidolons.map(e => ` +
+ E${e.rank} +
+
${e.name}
+
${e.description}
+
+
+ `).join('')} +
+
+ `; + } + + el.gachabasePreviewContainer.innerHTML = ` +
+
+ ${item.name} +
${starsHtml}
+ ${isChar && item.maxEnergy ? `
⚡ พลังงานสูงสุด: ${item.maxEnergy}
` : ''} +
+ +
+
+ ${item.name} + ID: ${item.id} +
+ +
+ ${isChar ? `${getElementIconHtml(item.element, 20)} ${translateElement(item.element)}` : ''} + ${getPathIconHtml(item.path, 20)} ${translatePath(item.path)} + ${item.branch.toUpperCase()} +
+ + ${recHtml} + ${skillsHtml} + ${eidolonsHtml} +
+
+ `; +} + +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 function setupEvents() { // Quick Ping & Refresh @@ -5668,7 +6057,7 @@ function setupEvents() { updateCacheUsageDisplay(); // Modal Backdrop Click to close - [el.modalAvatarDetail, el.modalLineupPicker, el.modalSettings].forEach(modal => { + [el.modalAvatarDetail, el.modalLineupPicker, el.modalSettings, el.modalGachabase].forEach(modal => { if (modal) { modal.addEventListener('click', (e) => { if (e.target === modal) { @@ -5695,6 +6084,14 @@ function startPingLoop() { // Initialize Application async function init() { + await loadCachedGachabaseRegistry({ + AVATAR_METADATA, + LIGHTCONE_METADATA, + RELIC_SET_METADATA, + SIGNATURE_LIGHTCONES, + idbGet, + idbSet + }); initAvatarsList(); await loadCachedStorageData(); setupServerModes(); @@ -5702,6 +6099,7 @@ async function init() { setupTabs(); setupModalSubtabs(); setupEvents(); + setupGachabaseEvents(); applyTranslations(); renderLineup(state.statusData?.lineups || DEFAULT_LINEUP); renderAvatarsGrid(); diff --git a/js/gachabase.js b/js/gachabase.js new file mode 100644 index 0000000..a195296 --- /dev/null +++ b/js/gachabase.js @@ -0,0 +1,625 @@ +// Gachabase Data Fetcher, Parser & FreeSR Importer Module +// Supports fetching datamined/beta characters, lightcones, and relics from hsr.gachabase.net +// Integrates with GaC-srtool runtime metadata, IndexedDB cache, and FreeSR / persistent saves. + +export const GACHABASE_BASE_URL = 'https://hsr.gachabase.net'; +export const GACHABASE_CDN_URL = 'https://cdn.gachabase.net'; + +// Path ID mappings according to HSR engine / Gachabase +export const GACHABASE_PATHS = { + 1: 'Destruction', + 2: 'Hunt', + 3: 'Erudition', + 4: 'Harmony', + 5: 'Nihility', + 6: 'Preservation', + 7: 'Abundance', + 8: 'Remembrance', + 9: 'Elation' +}; + +// Element / Type ID mappings +export const GACHABASE_ELEMENTS = { + 1: 'Physical', + 2: 'Fire', + 4: 'Ice', + 8: 'Thunder', // Lightning + 16: 'Wind', + 32: 'Quantum', + 64: 'Imaginary' +}; + +// Stat ID mappings +export const GACHABASE_STATS = { + 1: 'HP', + 2: 'ATK', + 3: 'DEF', + 21: 'HP%', + 22: 'ATK%', + 23: 'DEF%', + 31: 'Physical DMG%', + 32: 'Fire DMG%', + 33: 'Ice DMG%', + 34: 'Lightning DMG%', + 35: 'Wind DMG%', + 36: 'Quantum DMG%', + 37: 'Imaginary DMG%', + 41: 'Break Effect%', + 42: 'Outgoing Healing Boost%', + 43: 'Max Energy', + 44: 'Energy Regeneration Rate%', + 45: 'Effect Hit Rate%', + 46: 'Effect RES%', + 51: 'SPD', + 52: 'CRIT Rate%', + 53: 'CRIT DMG%' +}; + +/** + * Strips rich-text tags like , , , from text strings + */ +export function cleanRichText(str) { + if (!str || typeof str !== 'string') return ''; + return str + .replace(/]+>/gi, '') + .replace(/<\/color>/gi, '') + .replace(/<[^>]+>/g, '') + .replace(/\\n/g, '\n') + .trim(); +} + +/** + * Parses user input into normalized search parameters + * 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') { + if (!targetUrl) throw new Error('Target URL is required for Gachabase fetch'); + + // 1. Try Backend Proxy + if (apiUrl) { + try { + const cleanApi = apiUrl.replace(/\/+$/, ''); + const proxyUrl = `${cleanApi}/api/gachabase/fetch?url=${encodeURIComponent(targetUrl)}`; + const resp = await fetch(proxyUrl, { method: 'GET', headers: { 'Accept': 'application/json' } }); + if (resp.ok) { + const json = await resp.json(); + if (json && json.html) { + return { html: json.html, finalUrl: json.url || targetUrl }; + } + } + } catch (err) { + console.warn('Backend Gachabase proxy unreachable, attempting direct fetch...', err); + } + } + + // 2. Direct fetch fallback (works if user has CORS unblocker or runs in local browser environment) + const directResp = await fetch(targetUrl, { + method: 'GET', + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' + } + }); + if (!directResp.ok) { + throw new Error(`Direct fetch failed with status ${directResp.status}`); + } + const html = await directResp.text(); + return { html, finalUrl: targetUrl }; +} + +/** + * Extracts and parses serialized SvelteKit DTO and refs from Gachabase HTML + */ +export function parseGachabaseHtml(html, fallbackLang = 'en') { + if (!html || typeof html !== 'string') { + throw new Error('Invalid HTML content received from Gachabase'); + } + + // Look for kit.start(...) serialized state in