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.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 = `
+
+
+

+
${starsHtml}
+ ${isChar && item.maxEnergy ? `
⚡ พลังงานสูงสุด: ${item.maxEnergy}
` : ''}
+
+
+
+
+
+
+ ${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