diff --git a/css/styles.css b/css/styles.css index 8e115bf..7591110 100644 --- a/css/styles.css +++ b/css/styles.css @@ -2256,6 +2256,55 @@ input:checked + .slider:before { letter-spacing: 0.5px; } +/* FireflyGo extra_data controls */ +.ff-group { + display: flex; + flex-direction: column; + gap: 0.55rem; + background: rgba(15, 23, 42, 0.45); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 0.85rem 1rem; + min-width: 0; +} + +.ff-group-title { + font-size: 0.85rem; + font-weight: 800; + color: var(--text-highlight); +} + +.ff-field { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 0; +} + +.ff-field .form-input { + width: 100%; + min-width: 0; +} + +.ff-toggle { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + font-size: 0.85rem; + color: var(--text-main); + cursor: pointer; + min-height: 40px; +} + +.ff-toggle > span:first-child { + min-width: 0; +} + +.form-hint { + line-height: 1.45; +} + .lc-passive-card { background: rgba(11, 15, 25, 0.75); border: 1px solid rgba(56, 189, 248, 0.25); @@ -5000,3 +5049,11 @@ input:checked + .slider:before { } + +/* FireflyGo extras card: keep controls finger-sized on phones. */ +@media (max-width: 640px) { + #card-firefly-extras .card-title { font-size: 1rem; } + #card-firefly-extras .ff-toggle { min-height: 44px; } + #card-firefly-extras .btn { flex: 1 1 100%; } + #card-firefly-extras .json-textarea { min-height: 96px; } +} diff --git a/index.html b/index.html index 680e21f..39f4d87 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ GaCy-Srtool - + @@ -30,7 +30,7 @@ 🌐 @@ -56,7 +56,7 @@ - + @@ -92,7 +92,7 @@ -
+
🎮 ทีมปัจจุบัน @@ -117,7 +117,7 @@
-
+
📍 สถานะเซิร์ฟเวอร์ & พิกัดปัจจุบัน API Live @@ -782,6 +782,42 @@
+ +
+
+ 📡 สร้างไฟล์ FreeSR จาก UID เกมจริง (Enka) + enka.network +
+

+ ดึงตัวละคร กรวยแสง และรีลิกส์จากโปรไฟล์ Enka แล้วสร้างเป็นไฟล์ freesr-data.json ใช้ได้ทุกโหมดการเชื่อมต่อ +

+
+
+ + +
+ + +
+
+
+
+ + + +
+
+
+
@@ -824,7 +860,7 @@
@@ -832,6 +868,7 @@
+
@@ -893,6 +930,128 @@
+ + +
@@ -1888,6 +2047,6 @@
- + diff --git a/js/app.js b/js/app.js index e2000e6..89d45ec 100644 --- a/js/app.js +++ b/js/app.js @@ -4,7 +4,10 @@ import { LIGHTCONE_METADATA, RELIC_SET_METADATA, getLightconeInfo, getRelicSetIn import { initializeItemCatalog } from './item-catalog.js?v=5'; import { initializeUnstuck } from './unstuck.js?v=4'; import { PRYDWEN_BUILD_GUIDES } from './build-guides-data.js'; -import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js?v=2.22'; +import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js?v=2.23'; +import { enkaToFreeSR, mergeFreeSR, countFreeSRContent, normalizeEnkaUid } from './enka-import.js?v=1'; +import { buildSyncPayload, normalizeExtraData, fireflySyncUrl, FIREFLY_EXTRA_FALLBACK } from './firefly.js?v=1'; +import { MODE_LABELS, normalizeMode, modeCapabilities, modeSupports } from './modes.js?v=1'; import { getAvatarIconUrl, getLightconeArtUrl, @@ -28,10 +31,12 @@ window.KNOWN_GACHABASE_ASSETS = KNOWN_GACHABASE_ASSETS; // Application State const state = { - serverMode: localStorage.getItem('gac_srtool_mode') || 'gac', // 'gac' | 'firefly' | 'robin' + serverMode: normalizeMode(localStorage.getItem('gac_srtool_mode') || 'gac'), // 'gac' | 'firefly' | 'robin' apiUrl: localStorage.getItem('gac_srtool_api_url') || 'http://localhost:21000', pingIntervalSec: 10, isConnected: false, + fireflyConnected: false, + fireflyExtraData: readStoredFireflyExtra(), statusData: null, avatarsList: [], fullFreesrData: null, @@ -84,6 +89,8 @@ const el = { badgeCreativeMode: document.getElementById('badge-creative-mode'), // Mini Battle Summary & Team Stats cardBattleSummary: document.getElementById('card-battle-summary'), + cardLineup: document.getElementById('card-lineup'), + cardServerStatus: document.getElementById('card-server-status'), badgeMiniBattleStatus: document.getElementById('badge-mini-battle-status'), miniTotalDmg: document.getElementById('mini-total-dmg'), miniTeamDps: document.getElementById('mini-team-dps'), @@ -198,6 +205,13 @@ const el = { toggleAhaGlobal: document.getElementById('toggle-aha-global'), btnSavePersistentToggles: document.getElementById('btn-save-persistent-toggles'), + // Mode-gated tabs & cards + tabBtnInventory: document.getElementById('tab-btn-inventory'), + tabBtnMonsterSettings: document.getElementById('tab-btn-monster-settings'), + cardFireflyExtras: document.getElementById('card-firefly-extras'), + apiUrlModeHint: document.getElementById('api-url-mode-hint'), + badgePersistenceMode: document.getElementById('badge-persistence-mode'), + // Material Inventory inventorySearch: document.getElementById('inventory-search'), inventoryBody: document.getElementById('inventory-materials-body'), @@ -449,11 +463,78 @@ async function apiCall(endpoint, method = 'GET', data = null) { } } +// FireflyGo transport. Firefly_Srtools exposes a single POST /sync endpoint and +// has no CORS preflight handling, so no custom headers are sent here. +async function fireflyRequest(body, { timeoutMs = 8000 } = {}) { + const url = fireflySyncUrl(state.apiUrl); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: 'POST', + cache: 'no-store', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body ?? {}), + signal: controller.signal + }); + const text = await res.text(); + let parsed = null; + if (text) { + try { parsed = JSON.parse(text); } catch (e) { parsed = null; } + } + if (!res.ok) { + const serverMessage = parsed && typeof parsed.message === 'string' ? parsed.message : ''; + throw new Error(serverMessage || `HTTP ${res.status}: ${res.statusText}`); + } + if (parsed && (parsed.ok === false || parsed.success === false)) { + throw new Error(typeof parsed.message === 'string' && parsed.message ? parsed.message : t('fireflyRequestFailed')); + } + return parsed || {}; + } catch (err) { + if (err.name === 'AbortError') throw new Error(t('fireflyTimeout')); + throw err; + } finally { + clearTimeout(timeoutId); + } +} + +// The connect probe: no `data` key, so FireflyGo only reports its state and +// returns the current extra_data without overwriting the save file. +async function probeFirefly({ silent = false } = {}) { + const result = await fireflyRequest({ username: '', password: '' }); + state.fireflyConnected = true; + setConnectionStatus(true); + + const extra = normalizeExtraData(result?.extra_data); + if (extra) { + state.fireflyExtraData = extra; + persistFireflyExtra(extra); + renderFireflyExtras({ force: true }); + } + + const warnings = Array.isArray(result?.warnings) + ? result.warnings.filter(w => typeof w === 'string' && w.trim()) + : []; + if (!silent) { + if (warnings.length) { + showToast(warnings.join(' • '), 'warning'); + } else { + showToast(t('fireflyProbeSuccess'), 'success'); + } + } + return result; +} + // Explicit, expiring custom-battle connection. The backend returns its TTL. let toolSessionTimer = null; let toolSessionConnected = false; let toolSessionGeneration = 0; async function syncToolSession(connected) { + // Only the GaCy GO gateway implements /api/tool-session. + if (!modeSupports('toolSession')) { + toolSessionConnected = connected; + return; + } const generation = ++toolSessionGeneration; if (toolSessionTimer) { clearTimeout(toolSessionTimer); toolSessionTimer = null; } toolSessionConnected = connected; @@ -469,7 +550,7 @@ async function syncToolSession(connected) { } } window.addEventListener('pagehide', () => { - if (!toolSessionConnected) return; + if (!toolSessionConnected || !modeSupports('toolSession')) return; const base = state.apiUrl.replace(/\/+$/, ''); navigator.sendBeacon(`${base}/api/tool-session`, JSON.stringify({ connected: false, client: 'GaC-Srtool' })); }); @@ -1181,100 +1262,112 @@ function setupLanguageSelector() { } // Server Mode Setup -function setupServerModes() { - const updateModeDisplay = (mode) => { - if (mode !== 'gac') { - mode = 'gac'; - showToast(t('serverModesPausedNotice'), 'warning'); - } - state.serverMode = mode; - localStorage.setItem('gac_srtool_mode', mode); - if (state.activeTab === 'tab-monster-settings') loadMonsterSettings(); - if (el.serverModeSelect) el.serverModeSelect.value = mode; - if (el.settingServerMode) el.settingServerMode.value = mode; +let serverModesBound = false; - if (mode === 'gac') { - if (el.lblActiveMode) el.lblActiveMode.textContent = 'GaCy GO'; - if (el.tabBtnPersistent) { - el.tabBtnPersistent.style.display = ''; - el.tabBtnPersistent.textContent = t('tabPersistentGaC'); - } - if (el.cardPersistentChars) el.cardPersistentChars.style.display = ''; - if (el.cardPersistentGlobal) el.cardPersistentGlobal.style.display = ''; - if (el.tabBtnExrank) el.tabBtnExrank.style.display = ''; - if (el.btnQuickSyncPersistent) el.btnQuickSyncPersistent.style.display = ''; - if (el.btnSelectPersistent) el.btnSelectPersistent.style.display = ''; - if (el.btnSelectDatagame) el.btnSelectDatagame.style.display = 'none'; - if (el.badgePersistenceMode) el.badgePersistenceMode.textContent = t('badgePersistenceGaC'); - } else if (mode === 'firefly') { - if (el.lblActiveMode) el.lblActiveMode.textContent = 'FireflyGo'; - if (el.tabBtnPersistent) { - el.tabBtnPersistent.style.display = ''; - el.tabBtnPersistent.textContent = t('tabPersistentFirefly'); - } - if (el.cardPersistentChars) el.cardPersistentChars.style.display = ''; - if (el.cardPersistentGlobal) el.cardPersistentGlobal.style.display = 'none'; // Hide unsupported GaC switches - if (el.tabBtnExrank) el.tabBtnExrank.style.display = 'none'; // Hide unsupported Ex-ranks - if (el.btnQuickSyncPersistent) el.btnQuickSyncPersistent.style.display = 'none'; - if (el.btnSelectPersistent) el.btnSelectPersistent.style.display = 'none'; - if (el.btnSelectDatagame) el.btnSelectDatagame.style.display = ''; // Show data-in-game for Firefly - if (el.badgePersistenceMode) el.badgePersistenceMode.textContent = t('badgePersistenceFirefly'); +// Re-applies every mode-dependent piece of UI. Safe to call repeatedly (it is +// also called from applyTranslations so labels follow a language switch). +function applyModeDisplay(mode) { + mode = normalizeMode(mode); + const caps = modeCapabilities(mode); + state.serverMode = mode; + localStorage.setItem('gac_srtool_mode', mode); - if (state.activeTab === 'tab-exrank') { - const dashboardBtn = document.querySelector('.tab-btn[data-tab="tab-dashboard"]'); - if (dashboardBtn) dashboardBtn.click(); - } + if (el.serverModeSelect) el.serverModeSelect.value = mode; + if (el.settingServerMode) el.settingServerMode.value = mode; + if (el.lblActiveMode) el.lblActiveMode.textContent = MODE_LABELS[mode]; - if (state.currentJsonMode === 'persistent') { - loadJsonData('datagame'); - } - } else { // robin - if (el.lblActiveMode) el.lblActiveMode.textContent = 'RobinSR'; - if (el.tabBtnPersistent) el.tabBtnPersistent.style.display = 'none'; - if (el.cardPersistentGlobal) el.cardPersistentGlobal.style.display = 'none'; - if (el.tabBtnExrank) el.tabBtnExrank.style.display = 'none'; - if (el.btnQuickSyncPersistent) el.btnQuickSyncPersistent.style.display = 'none'; - if (el.btnSelectPersistent) el.btnSelectPersistent.style.display = 'none'; - if (el.btnSelectDatagame) el.btnSelectDatagame.style.display = 'none'; - if (el.badgePersistenceMode) el.badgePersistenceMode.textContent = t('badgePersistenceRobin'); - - if (state.activeTab === 'tab-persistent' || state.activeTab === 'tab-exrank') { - const dashboardBtn = document.querySelector('.tab-btn[data-tab="tab-dashboard"]'); - if (dashboardBtn) dashboardBtn.click(); - } - - if (state.currentJsonMode === 'persistent' || state.currentJsonMode === 'datagame') { - loadJsonData('freesr'); - } - } - - // Creative Mode is OPEN for all server modes! - if (el.toggleCreativeMode) { - el.toggleCreativeMode.disabled = false; - el.toggleCreativeMode.checked = state.creativeMode; - } - if (el.badgeCreativeMode) { - el.badgeCreativeMode.style.display = state.creativeMode ? 'inline-flex' : 'none'; - } + const setVisible = (node, visible) => { + if (node) node.style.display = visible ? '' : 'none'; }; - if (el.serverModeSelect) { - el.serverModeSelect.addEventListener('change', (e) => { - updateModeDisplay(e.target.value); - showToast(t('toastServerSwitched', { mode: el.lblActiveMode ? el.lblActiveMode.textContent : e.target.value }), 'info'); - refreshStatus(); - }); + // Tabs that need an endpoint the selected backend does not implement. + setVisible(el.tabBtnInventory, caps.inventory); + setVisible(el.tabBtnMonsterSettings, caps.monsterSettings); + setVisible(el.tabBtnDamageAnalyst, caps.damageAnalyst); + setVisible(el.tabBtnPersistent, caps.saveManager); + setVisible(el.tabBtnExrank, caps.exranks); + + // Cards/panels driven by GaCy-only endpoints. + setVisible(el.cardServerStatus, caps.sceneCard); + setVisible(el.cardGmConsole, caps.gmConsole); + setVisible(el.cardBattleSummary, caps.battleSummary); + setVisible(el.cardPersistentChars, caps.saveManager); + setVisible(el.cardPersistentGlobal, caps.saveManager); + setVisible(el.cardFireflyExtras, caps.extraData); + setVisible(el.btnQuickSyncPersistent, caps.saveManager); + setVisible(el.btnSelectPersistent, caps.saveManager); + setVisible(el.btnSelectDatagame, caps.jsonModes.includes('datagame')); + + if (el.tabBtnPersistent && caps.saveManager) { + el.tabBtnPersistent.textContent = t('tabPersistentGaC'); + } + if (el.badgePersistenceMode) { + el.badgePersistenceMode.textContent = t(caps.extraData ? 'badgePersistenceFirefly' + : caps.saveManager ? 'badgePersistenceGaC' : 'badgePersistenceRobin'); + el.badgePersistenceMode.className = caps.saveManager ? 'badge-creative' : 'badge-super-tag'; + } + if (el.apiUrlModeHint) { + el.apiUrlModeHint.textContent = t(caps.extraData ? 'apiUrlHintFirefly' + : mode === 'robin' ? 'apiUrlHintRobin' : 'apiUrlHintGaC'); } - if (el.settingServerMode) { - el.settingServerMode.addEventListener('change', (e) => { - updateModeDisplay(e.target.value); - showToast(t('toastServerSwitched', { mode: el.lblActiveMode ? el.lblActiveMode.textContent : e.target.value }), 'info'); - refreshStatus(); - }); + // Never leave a hidden tab selected. + const hiddenTabs = []; + if (!caps.inventory) hiddenTabs.push('tab-inventory'); + if (!caps.monsterSettings) hiddenTabs.push('tab-monster-settings'); + if (!caps.damageAnalyst) hiddenTabs.push('tab-damage-analyst'); + if (!caps.saveManager) hiddenTabs.push('tab-persistent'); + if (!caps.exranks) hiddenTabs.push('tab-exrank'); + const activeTabId = document.querySelector('.tab-btn.active')?.getAttribute('data-tab') || state.activeTab; + if (hiddenTabs.includes(activeTabId)) { + const dashboardBtn = document.querySelector('.tab-btn[data-tab="tab-dashboard"]'); + if (dashboardBtn) dashboardBtn.click(); } - updateModeDisplay(state.serverMode); + // JSON editor: only offer file modes the backend can round-trip. + if (!caps.jsonModes.includes(state.currentJsonMode)) { + loadJsonData(caps.jsonModes[0] || 'freesr'); + } + + if (caps.extraData) renderFireflyExtras(); + if (caps.monsterSettings && state.activeTab === 'tab-monster-settings') loadMonsterSettings(); + + // Creative Mode is OPEN for all server modes. + if (el.toggleCreativeMode) { + el.toggleCreativeMode.disabled = false; + el.toggleCreativeMode.checked = state.creativeMode; + } + if (el.badgeCreativeMode) { + el.badgeCreativeMode.style.display = state.creativeMode ? 'inline-flex' : 'none'; + } +} + +function setupServerModes() { + const handleModeChange = (mode) => { + const next = normalizeMode(mode); + const changed = next !== state.serverMode; + applyModeDisplay(next); + if (!changed) { + refreshStatus(); + return; + } + // FireflyGo is push-only, so its connection state must never carry over. + state.fireflyConnected = false; + showToast(t('toastServerSwitched', { mode: MODE_LABELS[next] }), 'info'); + refreshStatus(); + }; + + if (!serverModesBound) { + serverModesBound = true; + if (el.serverModeSelect) { + el.serverModeSelect.addEventListener('change', (e) => handleModeChange(e.target.value)); + } + if (el.settingServerMode) { + el.settingServerMode.addEventListener('change', (e) => handleModeChange(e.target.value)); + } + } + + applyModeDisplay(state.serverMode); } // Initialize Avatars Database: Populates all known avatars from metadata @@ -1336,38 +1429,29 @@ async function refreshStatus() { await saveFreesrCache(freesr.data); } } else if (state.serverMode === 'firefly') { - // FireflyGo uses data-in-game.json for live state and freesr-data.json for inventory - const datagame = await apiCall('/api/data-in-game').catch(() => null); - if (datagame && (datagame.data || datagame.lineups)) { - state.dataGameData = datagame.data || datagame; - await saveDataGameCache(state.dataGameData); - } - const freesr = await apiCall('/api/freesr').catch(() => null); - if (freesr && freesr.data) { - state.fullFreesrData = freesr.data; + // FireflyGo is push-only (POST /sync) and has no read endpoints, so the + // dashboard is rebuilt from the local FreeSR snapshot and the ping loop + // never touches the network. `state.fireflyConnected` tracks the last + // explicit /sync result instead. + const freesr = state.fullFreesrData; + if (freesr) { data = { status: 200, - avatar_count: Object.keys(freesr.data.avatars || {}).length, - lightcone_count: (freesr.data.lightcones || []).length, - relic_count: (freesr.data.relics || []).length, - lineups: state.dataGameData?.lineups || freesr.data.lineups || freesr.data.battle_config?.custom_battle_lineup || DEFAULT_LINEUP, - scene: state.dataGameData?.scene || freesr.data.scene, - position: state.dataGameData?.position || freesr.data.position, - ex_ranks: [] - }; - } else if (state.dataGameData) { - data = { - status: 200, - avatar_count: state.avatarsList.length, - lightcone_count: 0, - relic_count: 0, - lineups: state.dataGameData.lineups || DEFAULT_LINEUP, - scene: state.dataGameData.scene, - position: state.dataGameData.position, + avatar_count: Object.keys(freesr.avatars || {}).length, + lightcone_count: (freesr.lightcones || []).length, + relic_count: (freesr.relics || []).length, + lineups: freesr.battle_config?.custom_battle_lineup || DEFAULT_LINEUP, ex_ranks: [] }; } else { - throw new Error('Firefly server not reachable'); + data = { + status: 200, + avatar_count: 0, + lightcone_count: 0, + relic_count: 0, + lineups: DEFAULT_LINEUP, + ex_ranks: [] + }; } } else { // robin const freesr = await apiCall('/api/freesr').catch(() => null); @@ -1389,7 +1473,7 @@ async function refreshStatus() { } state.statusData = data; - setConnectionStatus(true); + setConnectionStatus(state.serverMode === 'firefly' ? state.fireflyConnected : true); el.lblApiUrl.textContent = state.apiUrl; el.statAvatars.textContent = data.avatar_count ?? state.avatarsList.length; @@ -1436,7 +1520,11 @@ async function refreshStatus() { updateDashboardBattleBanner(); } } catch (err) { - setConnectionStatus(false, t('offline')); + if (state.serverMode === 'firefly') { + setConnectionStatus(state.fireflyConnected); + } else { + setConnectionStatus(false, t('offline')); + } el.lblApiUrl.textContent = state.apiUrl; if (state.fullFreesrData) { @@ -2585,7 +2673,7 @@ async function loadAvatars() { } } - if (state.isConnected) { + if (state.isConnected && state.serverMode === 'gac') { try { const res = await apiCall('/api/freesr/avatars'); if (res && res.avatars && res.avatars.length > 0) { @@ -2612,7 +2700,15 @@ function renderAvatarsGrid() { const elementFilter = el.avatarFilterElement.value; const pathFilter = el.avatarFilterPath ? el.avatarFilterPath.value : 'all'; + // FireflyGo only ever loads the characters present in freesr-data.json, so + // the roster must not advertise characters the server cannot spawn. + const ownedOnly = modeCapabilities().ownedAvatarsOnly; + const ownedIds = ownedOnly + ? new Set(Object.keys(state.fullFreesrData?.avatars || {}).map(id => parseInt(id, 10))) + : null; + const filtered = state.avatarsList.filter(item => { + if (ownedIds && !ownedIds.has(item.avatar_id)) return false; const meta = getAvatarInfo(item.avatar_id); const matchesSearch = meta.name.toLowerCase().includes(searchTerm) || String(item.avatar_id).includes(searchTerm); const matchesRarity = rarityFilter === 'all' || String(meta.rarity) === rarityFilter; @@ -6545,17 +6641,54 @@ async function maxAllAvatars({ englishOnly = false } = {}) { } } +// Push the local FreeSR snapshot to FireflyGo (POST /sync). +async function pushFireflySync({ silent = false } = {}) { + if (!state.fullFreesrData) throw new Error(t('fireflyNoData')); + + const payload = { + username: '', + password: '', + data: buildSyncPayload(state.fullFreesrData) + }; + const extra = normalizeExtraData(state.fireflyExtraData); + if (extra) payload.extra_data = extra; + + const result = await fireflyRequest(payload); + state.fireflyConnected = true; + setConnectionStatus(true); + + if (extra) { + state.fireflyExtraData = extra; + persistFireflyExtra(extra); + } + + const warnings = Array.isArray(result?.warnings) + ? result.warnings.filter(w => typeof w === 'string' && w.trim()) + : []; + if (!silent) { + if (warnings.length) showToast(warnings.join(' • '), 'warning'); + else showToast(t('fireflySyncSuccess'), 'success'); + } + return result; +} + // Push local freesr changes to server and save to local browser cache async function saveFreesrChangesToServer() { if (!state.fullFreesrData) return; await saveFreesrCache(state.fullFreesrData); + // FireflyGo only understands POST /sync; there is no freesr/persistent API. + if (state.serverMode === 'firefly') { + await pushFireflySync(); + return; + } + // Attempt to push to server let lastErr = null; const payloadWrapped = { data: state.fullFreesrData }; const payloadDirect = state.fullFreesrData; - if (state.serverMode === 'firefly' || state.serverMode === 'robin') { + if (state.serverMode === 'robin') { try { await apiCall('/srtools', 'POST', payloadWrapped); state.isConnected = true; @@ -8484,7 +8617,7 @@ async function loadJsonData(mode) { if (el.btnSelectDatagame) el.btnSelectDatagame.className = 'btn btn-sm btn-secondary'; el.jsonStatusInfo.textContent = t('jsonStatusLoading', { file: 'freesr-data.json' }); try { - if (state.fullFreesrData && !state.isConnected) { + if (state.fullFreesrData && (!state.isConnected || state.serverMode === 'firefly')) { el.jsonEditor.value = JSON.stringify(state.fullFreesrData, null, 2); el.jsonStatusInfo.textContent = t('jsonStatusMemory', { file: 'freesr-data.json' }); } else if (state.isConnected) { @@ -8906,6 +9039,357 @@ function setupInventoryEvents() { el.btnInventoryNext?.addEventListener('click', () => { state.inventoryPage++; renderMaterialsInventory(); }); } +/* ------------------------------------------------------------------ * + * FireflyGo extra_data controls + * ------------------------------------------------------------------ */ + +let fireflyExtrasDirty = false; + +function readStoredFireflyExtra() { + try { + const raw = localStorage.getItem('gac_srtool_firefly_extra'); + return raw ? JSON.parse(raw) : null; + } catch (e) { + return null; + } +} + +function persistFireflyExtra(extra) { + try { + localStorage.setItem('gac_srtool_firefly_extra', JSON.stringify(extra)); + } catch (e) { + console.warn('Could not persist Firefly extra_data:', e); + } +} + +function setFireflyExtraStatus(messageKey, vars = {}, tone = 'info') { + const node = document.getElementById('firefly-extra-status'); + if (!node) return; + if (!messageKey) { + node.textContent = ''; + node.style.color = ''; + return; + } + node.textContent = t(messageKey, vars); + node.style.color = tone === 'error' ? 'var(--accent-red)' + : tone === 'success' ? 'var(--accent-green)' + : tone === 'warning' ? 'var(--accent-gold)' : 'var(--text-muted)'; +} + +// Fills the extra_data form from the last known server/local values. +function renderFireflyExtras({ force = false } = {}) { + const card = el.cardFireflyExtras; + if (!card) return; + if (fireflyExtrasDirty && !force) return; + + const extra = normalizeExtraData(state.fireflyExtraData) || FIREFLY_EXTRA_FALLBACK; + const multiPath = extra.multi_path || {}; + const setting = extra.setting || {}; + const challenge = extra.challenge || {}; + const theory = extra.theory_craft || {}; + + const setVal = (id, value) => { + const node = document.getElementById(id); + if (node) node.value = value ?? ''; + }; + const setChk = (id, value) => { + const node = document.getElementById(id); + if (node) node.checked = value === true; + }; + const listText = (value) => (Array.isArray(value) ? value.join(', ') : ''); + + setVal('ff-multi-path-main', multiPath.main ?? 0); + setVal('ff-multi-path-march7', multiPath.march_7 ?? 0); + setVal('ff-multi-path-main-list', listText(multiPath.multi_path_main)); + setVal('ff-multi-path-march7-list', listText(multiPath.multi_path_march_7)); + + setChk('ff-setting-censorship', setting.censorship); + setChk('ff-setting-cm', setting.cm); + setChk('ff-setting-first-person', setting.first_person); + setChk('ff-setting-hide-ui', setting.hide_ui); + setVal('ff-setting-invasion-percent', setting.invasion_percent ?? 0); + + setVal('ff-challenge-skip-node', challenge.skip_node ?? 0); + setVal('ff-challenge-peak-group', challenge.challenge_peak_group_id ?? 0); + setVal('ff-challenge-peak-list', listText(challenge.challenge_peak_group_id_list)); + + setChk('ff-theory-mode', theory.mode); + setVal('ff-theory-cycle-count', theory.cycle_count ?? 0); + setVal('ff-theory-stage-id', theory.stage_id ?? 0); + setVal('ff-theory-lineup', listText(theory.custom_lineup)); + const hp = theory.hp; + setVal('ff-theory-hp', hp && Object.keys(hp).length > 0 ? JSON.stringify(hp) : ''); + setVal('ff-lua', extra.lua ?? ''); + + fireflyExtrasDirty = false; + setFireflyExtraStatus(''); +} + +// Reads the form back into the FireflyGo ExtraData shape. +function readFireflyExtrasForm() { + const val = (id) => document.getElementById(id)?.value ?? ''; + const chk = (id) => document.getElementById(id)?.checked === true; + const nums = (id) => val(id) + .split(/[\s,;]+/) + .map(entry => entry.trim()) + .filter(Boolean) + .map(entry => Number(entry)) + .filter(Number.isFinite); + + let hp = {}; + const hpRaw = val('ff-theory-hp').trim(); + if (hpRaw) { + let parsed; + try { + parsed = JSON.parse(hpRaw); + } catch (e) { + throw new Error(t('fireflyHpInvalid')); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(t('fireflyHpInvalid')); + } + hp = parsed; + } + + const luaRaw = val('ff-lua'); + return normalizeExtraData({ + multi_path: { + main: Number(val('ff-multi-path-main')) || 0, + march_7: Number(val('ff-multi-path-march7')) || 0, + multi_path_main: nums('ff-multi-path-main-list'), + multi_path_march_7: nums('ff-multi-path-march7-list') + }, + setting: { + censorship: chk('ff-setting-censorship'), + cm: chk('ff-setting-cm'), + first_person: chk('ff-setting-first-person'), + hide_ui: chk('ff-setting-hide-ui'), + invasion_percent: Number(val('ff-setting-invasion-percent')) || 0 + }, + challenge: { + skip_node: Number(val('ff-challenge-skip-node')) || 0, + challenge_peak_group_id: Number(val('ff-challenge-peak-group')) || 0, + challenge_peak_group_id_list: nums('ff-challenge-peak-list') + }, + theory_craft: { + hp, + cycle_count: Number(val('ff-theory-cycle-count')) || 0, + mode: chk('ff-theory-mode'), + stage_id: Number(val('ff-theory-stage-id')) || 0, + custom_lineup: nums('ff-theory-lineup') + }, + lua: luaRaw.trim() ? luaRaw : null + }); +} + +function saveFireflyExtrasFromForm() { + const extra = readFireflyExtrasForm(); + state.fireflyExtraData = extra; + persistFireflyExtra(extra); + fireflyExtrasDirty = false; + return extra; +} + +function setupFireflyExtrasEvents() { + const card = el.cardFireflyExtras; + if (!card) return; + + const markDirty = () => { fireflyExtrasDirty = true; }; + card.addEventListener('input', markDirty); + card.addEventListener('change', markDirty); + + const btnProbe = document.getElementById('btn-firefly-probe'); + if (btnProbe) { + btnProbe.addEventListener('click', async () => { + setFireflyExtraStatus('fireflyProbing', {}, 'info'); + btnProbe.disabled = true; + try { + await probeFirefly(); + setFireflyExtraStatus('fireflyProbeSuccess', {}, 'success'); + } catch (err) { + setFireflyExtraStatus('fireflyRequestFailedDetail', { message: err.message }, 'error'); + } finally { + btnProbe.disabled = false; + } + }); + } + + const btnSave = document.getElementById('btn-firefly-save-extra'); + if (btnSave) { + btnSave.addEventListener('click', () => { + try { + saveFireflyExtrasFromForm(); + renderFireflyExtras({ force: true }); + setFireflyExtraStatus('fireflyExtraSaved', {}, 'success'); + showToast(t('fireflyExtraSaved'), 'success'); + } catch (err) { + setFireflyExtraStatus('fireflyRequestFailedDetail', { message: err.message }, 'error'); + } + }); + } + + const btnSync = document.getElementById('btn-firefly-sync'); + if (btnSync) { + btnSync.addEventListener('click', async () => { + btnSync.disabled = true; + try { + saveFireflyExtrasFromForm(); + setFireflyExtraStatus('fireflySyncing', {}, 'info'); + await pushFireflySync({ silent: true }); + setFireflyExtraStatus('fireflySyncSuccess', {}, 'success'); + showToast(t('fireflySyncSuccess'), 'success'); + refreshStatus(); + } catch (err) { + setFireflyExtraStatus('fireflyRequestFailedDetail', { message: err.message }, 'error'); + } finally { + btnSync.disabled = false; + } + }); + } +} + +/* ------------------------------------------------------------------ * + * Enka.network import (leftover characters -> freesr-data.json) + * ------------------------------------------------------------------ */ + +function setEnkaStatus(messageKey, vars = {}, tone = 'info') { + const node = document.getElementById('enka-import-status'); + if (!node) return; + if (!messageKey) { + node.textContent = ''; + node.style.color = ''; + return; + } + node.textContent = t(messageKey, vars); + node.style.color = tone === 'error' ? 'var(--accent-red)' + : tone === 'success' ? 'var(--accent-green)' + : tone === 'warning' ? 'var(--accent-gold)' : 'var(--text-muted)'; +} + +// enka.network does not send CORS headers, so the browser goes through the +// /api/enka/:uid rewrite defined in vercel.json. +async function fetchEnkaProfile(uid) { + let res; + try { + res = await fetch(`/api/enka/${encodeURIComponent(uid)}`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(20000) + }); + } catch (err) { + throw new Error(t('enkaProxyUnavailable')); + } + if (res.status === 404) throw new Error(t('enkaUidNotFound')); + if (res.status === 429) throw new Error(t('enkaRateLimited')); + if (!res.ok) throw new Error(t('enkaFetchFailed', { status: res.status })); + return await res.json(); +} + +async function applyEnkaImport(body, { uid = 0 } = {}) { + let incoming; + try { + incoming = enkaToFreeSR(body, { ownerUid: uid }); + } catch (err) { + throw new Error(err.message === 'ENKA_NO_AVATARS' ? t('enkaNoAvatars') : err.message); + } + + const mode = document.getElementById('enka-import-mode')?.value === 'replace' ? 'replace' : 'merge'; + const base = state.fullFreesrData && typeof state.fullFreesrData === 'object' ? state.fullFreesrData : null; + const merged = mergeFreeSR(base, incoming, mode); + state.fullFreesrData = merged; + await saveFreesrCache(merged); + + const counts = countFreeSRContent(merged); + loadAvatars(); + loadEquipment(); + renderAvatarsGrid(); + updateCacheUsageDisplay(); + + if (state.currentJsonMode === 'freesr' && el.jsonEditor) { + el.jsonEditor.value = JSON.stringify(merged, null, 2); + if (el.jsonStatusInfo) el.jsonStatusInfo.textContent = t('jsonStatusMemory', { file: 'freesr-data.json' }); + } + + if (state.autoSync) { + try { + await saveFreesrChangesToServer(); + await refreshStatus(); + } catch (err) { + setEnkaStatus('enkaSyncFailed', { message: err.message }, 'warning'); + return counts; + } + } else { + el.statAvatars.textContent = counts.avatars; + el.statLightcones.textContent = counts.lightcones; + el.statRelics.textContent = counts.relics; + } + + setEnkaStatus('enkaImportDone', { + avatars: counts.avatars, + lightcones: counts.lightcones, + relics: counts.relics + }, 'success'); + return counts; +} + +async function runEnkaFetch() { + const uid = normalizeEnkaUid(document.getElementById('enka-uid-input')?.value); + if (!uid) { + setEnkaStatus('enkaUidInvalid', {}, 'error'); + return; + } + + const btn = document.getElementById('btn-enka-fetch'); + if (btn) btn.disabled = true; + setEnkaStatus('enkaFetching', { uid }, 'info'); + try { + const body = await fetchEnkaProfile(uid); + await applyEnkaImport(body, { uid: Number(uid) }); + } catch (err) { + setEnkaStatus('enkaFetchFailedDetail', { message: err.message }, 'error'); + } finally { + if (btn) btn.disabled = false; + } +} + +async function runEnkaPaste() { + const raw = document.getElementById('enka-paste-input')?.value?.trim(); + if (!raw) { + setEnkaStatus('enkaPasteEmpty', {}, 'warning'); + return; + } + + let body; + try { + body = JSON.parse(raw); + } catch (e) { + setEnkaStatus('enkaPasteInvalid', {}, 'error'); + return; + } + + const uid = normalizeEnkaUid(document.getElementById('enka-uid-input')?.value); + setEnkaStatus('enkaFetching', { uid: uid || '—' }, 'info'); + try { + await applyEnkaImport(body, { uid: Number(uid) || 0 }); + } catch (err) { + setEnkaStatus('enkaFetchFailedDetail', { message: err.message }, 'error'); + } +} + +function setupEnkaImportEvents() { + document.getElementById('btn-enka-fetch')?.addEventListener('click', () => { + runEnkaFetch(); + }); + document.getElementById('btn-enka-paste')?.addEventListener('click', () => { + runEnkaPaste(); + }); + document.getElementById('enka-uid-input')?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + runEnkaFetch(); + } + }); +} + // Setup Event Listeners function setupEvents() { // Quick Ping & Refresh @@ -9585,16 +10069,27 @@ function setupEvents() { if (el.btnTestConnection) { el.btnTestConnection.addEventListener('click', async () => { const targetUrl = (el.settingApiUrl?.value || state.apiUrl).trim().replace(/\/+$/, ''); + const isFirefly = state.serverMode === 'firefly'; if (el.pingLatencyBadge) { el.pingLatencyBadge.style.display = 'inline-block'; el.pingLatencyBadge.textContent = '⏳ ...'; el.pingLatencyBadge.style.backgroundColor = 'var(--bg-card)'; el.pingLatencyBadge.style.color = 'var(--text-muted)'; } + + // Adopt the URL typed in the field so the probe and later syncs agree. + const previousUrl = state.apiUrl; + state.apiUrl = targetUrl || state.apiUrl; + const t0 = performance.now(); try { - const res = await fetch(`${targetUrl}/api/status`, { signal: AbortSignal.timeout(3500) }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (isFirefly) { + await probeFirefly({ silent: true }); + } else { + const res = await fetch(`${state.apiUrl}/api/status`, { signal: AbortSignal.timeout(3500) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setConnectionStatus(true); + } const latency = Math.round(performance.now() - t0); if (el.pingLatencyBadge) { el.pingLatencyBadge.textContent = `${latency} ms`; @@ -9602,14 +10097,18 @@ function setupEvents() { el.pingLatencyBadge.style.color = latency < 80 ? 'var(--accent-green)' : 'var(--accent-gold)'; } showToast(t('toastPingSuccess', { ms: latency }), 'success'); - setConnectionStatus(true); } catch (err) { + state.apiUrl = previousUrl; if (el.pingLatencyBadge) { el.pingLatencyBadge.textContent = 'ERR'; el.pingLatencyBadge.style.backgroundColor = 'rgba(239, 68, 68, 0.2)'; el.pingLatencyBadge.style.color = '#ef4444'; } showToast(t('toastPingFailed'), 'error'); + if (isFirefly) { + state.fireflyConnected = false; + setFireflyExtraStatus('fireflyRequestFailedDetail', { message: err.message }, 'error'); + } setConnectionStatus(false); } }); @@ -9988,6 +10487,8 @@ function parseFireflySranalysisJson(json) { } async function fetchLatestBattleReport(silent = false) { + // Damage Analyst reads /api/battle_report, which only GaCy GO provides. + if (!modeSupports('damageAnalyst')) return; if (analystState.isFetching) return; analystState.isFetching = true; try { @@ -10615,6 +11116,8 @@ async function init() { setupTabs(); setupModalSubtabs(); setupEvents(); + setupFireflyExtrasEvents(); + setupEnkaImportEvents(); initCameraSettings(); setupInventoryEvents(); setupDamageAnalystEvents(); diff --git a/js/avatars-data.js b/js/avatars-data.js index b0f9b61..14b7112 100644 --- a/js/avatars-data.js +++ b/js/avatars-data.js @@ -1,4 +1,4 @@ -import { getCurrentLanguage, translateElement, translatePath } from "./i18n.js?v=2.22"; +import { getCurrentLanguage, translateElement, translatePath } from "./i18n.js?v=2.23"; export const AVATAR_METADATA = { "1001": { diff --git a/js/enka-import.js b/js/enka-import.js new file mode 100644 index 0000000..ee1b7db --- /dev/null +++ b/js/enka-import.js @@ -0,0 +1,245 @@ +// Enka.network (Honkai: Star Rail) -> FreeSR JSON conversion. +// +// The field mapping mirrors Firefly_Srtools' converter so the resulting file is +// accepted verbatim by FireflyGo (POST /sync) as well as by GaCy's /api/freesr. + +export const ENKA_UID_PATTERN = /^\d{8,10}$/; + +export const FREE_SR_DEFAULT_BATTLE_CONFIG = { + battle_type: 'DEFAULT', + blessings: [], + custom_stats: [], + cycle_count: 0, + stage_id: 0, + path_resonance_id: 0, + monsters: [] +}; + +// Meta-game defaults used when Enka does not expose the value. +const DEFAULT_SP_MAX = 140; +const DEFAULT_SP_VALUE = 70; + +const ANCHOR_BY_SUFFIX = { + '001': '1', // basic attack + '002': '2', // skill + '003': '3', // ultimate + '004': '4', // talent + '007': '5', // technique + '101': '6', // bonus ability A2 + '102': '7', // bonus ability A4 + '103': '8' // bonus ability A6 +}; + +export function normalizeEnkaUid(value) { + const uid = String(value ?? '').trim(); + return ENKA_UID_PATTERN.test(uid) ? uid : ''; +} + +// Enka trace ids look like , e.g. 1506001 / 1506101 / 1506203. +export function anchorTypeForPointId(pointId) { + const suffix = String(pointId).slice(-3); + if (ANCHOR_BY_SUFFIX[suffix]) return ANCHOR_BY_SUFFIX[suffix]; + // Stat traces (2xx) all collapse into the single "stats" anchor. + if (/^2\d{2}$/.test(suffix)) return '9'; + return ''; +} + +export function skillMapsFromEnka(skillTreeList) { + const skills = {}; + const anchor = {}; + (Array.isArray(skillTreeList) ? skillTreeList : []).forEach((entry) => { + const pointId = Number(entry?.pointId); + if (!pointId) return; + const level = Number(entry?.level) || 0; + skills[String(pointId)] = level; + const anchorType = anchorTypeForPointId(pointId); + if (!anchorType) return; + // Keep the highest level seen for an anchor slot (stat traces repeat). + if (anchor[anchorType] === undefined || level > anchor[anchorType]) { + anchor[anchorType] = level; + } + }); + return { skills, skillsByAnchorType: anchor }; +} + +// FreeSR relic set ids are the relic id without its leading rarity digit and +// trailing slot digit (61011 -> 6101 style, same as Firefly_Srtools). +export function relicSetIdFromTid(tid) { + const digits = String(Math.trunc(Number(tid) || 0)); + if (digits.length < 3) return 0; + return Number(digits.slice(1, -1)) || 0; +} + +export function relicSlotFromTid(tid) { + const digits = String(Math.trunc(Number(tid) || 0)); + return digits ? Number(digits.slice(-1)) || 0 : 0; +} + +function toNumber(value, fallback = 0) { + const num = Number(value); + return Number.isFinite(num) ? num : fallback; +} + +export function enkaAvatarToFreeSR(avatar, ownerUid = 0) { + const avatarId = toNumber(avatar?.avatarId); + if (!avatarId) return null; + + const { skills, skillsByAnchorType } = skillMapsFromEnka(avatar.skillTreeList); + return { + avatar: { + owner_uid: toNumber(ownerUid), + avatar_id: avatarId, + data: { + rank: toNumber(avatar.rank), + skills, + skills_by_anchor_type: skillsByAnchorType + }, + level: toNumber(avatar.level, 1), + promotion: toNumber(avatar.promotion), + techniques: [], + sp_value: DEFAULT_SP_VALUE, + sp_max: DEFAULT_SP_MAX + } + }; +} + +export function enkaLightconeToFreeSR(avatar, internalUid = 0) { + const equipment = avatar?.equipment; + const itemId = toNumber(equipment?.tid); + if (!itemId) return null; + return { + level: toNumber(equipment.level), + item_id: itemId, + equip_avatar: toNumber(avatar.avatarId), + rank: toNumber(equipment.rank, 1), + promotion: toNumber(equipment.promotion), + internal_uid: internalUid + }; +} + +export function enkaRelicToFreeSR(avatar, relic, internalUid = 0) { + const relicId = toNumber(relic?.tid); + if (!relicId) return null; + const subAffixes = (Array.isArray(relic.subAffixList) ? relic.subAffixList : []) + .map((sub) => ({ + sub_affix_id: toNumber(sub?.affixId), + count: toNumber(sub?.cnt), + step: toNumber(sub?.step) + })) + .filter((sub) => sub.sub_affix_id > 0); + return { + level: toNumber(relic.level), + relic_id: relicId, + relic_set_id: relicSetIdFromTid(relicId), + main_affix_id: toNumber(relic.mainAffixId), + sub_affixes: subAffixes, + internal_uid: internalUid, + equip_avatar: toNumber(avatar.avatarId) + }; +} + +/** + * Accepts either the raw enka.network payload, a Firefly `/api/enka` envelope + * (`{ok:true, data}`), or an already-exported FreeSR file. + */ +export function unwrapEnkaPayload(body) { + if (!body || typeof body !== 'object') return null; + if (body.ok === true && body.data && typeof body.data === 'object') return body.data; + if (body.detailInfo && typeof body.detailInfo === 'object') return body; + if (body.data && typeof body.data === 'object' && body.data.detailInfo) return body.data; + return null; +} + +export function enkaToFreeSR(body, { ownerUid = 0 } = {}) { + const payload = unwrapEnkaPayload(body); + const detail = payload?.detailInfo; + if (!detail || !Array.isArray(detail.avatarDetailList)) { + throw new Error('ENKA_NO_AVATARS'); + } + + const avatars = {}; + const lightcones = []; + const relics = []; + let lightconeUid = 0; + let relicUid = 0; + + detail.avatarDetailList.forEach((entry) => { + const converted = enkaAvatarToFreeSR(entry, ownerUid); + if (!converted) return; + const { avatar } = converted; + avatars[String(avatar.avatar_id)] = avatar; + + const lightcone = enkaLightconeToFreeSR(entry, lightconeUid); + if (lightcone) { + lightcones.push(lightcone); + lightconeUid += 1; + } + + (Array.isArray(entry.relicList) ? entry.relicList : []).forEach((relic) => { + const parsed = enkaRelicToFreeSR(entry, relic, relicUid); + if (!parsed) return; + relics.push(parsed); + relicUid += 1; + }); + }); + + if (Object.keys(avatars).length === 0) { + throw new Error('ENKA_NO_AVATARS'); + } + + return { + avatars, + lightcones, + relics, + battle_config: { ...FREE_SR_DEFAULT_BATTLE_CONFIG, blessings: [], custom_stats: [], monsters: [] } + }; +} + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +/** + * Merge freshly imported characters into an existing FreeSR document. + * `replace` throws the previous character/gear list away but keeps the battle + * configuration so a sync never silently resets the active challenge. + */ +export function mergeFreeSR(base, incoming, mode = 'merge') { + const next = mode === 'replace' + ? { avatars: {}, lightcones: [], relics: [] } + : { + ...(base && typeof base === 'object' ? cloneJson(base) : {}), + avatars: { ...(base?.avatars || {}) }, + lightcones: [...(base?.lightcones || [])], + relics: [...(base?.relics || [])] + }; + + const incomingAvatars = incoming?.avatars || {}; + const incomingIds = new Set(Object.keys(incomingAvatars).map(Number)); + + // Drop gear equipped by characters we are about to overwrite. + next.lightcones = next.lightcones.filter((lc) => !incomingIds.has(Number(lc?.equip_avatar))); + next.relics = next.relics.filter((relic) => !incomingIds.has(Number(relic?.equip_avatar))); + + Object.assign(next.avatars, incomingAvatars); + next.lightcones = next.lightcones.concat(incoming?.lightcones || []); + next.relics = next.relics.concat(incoming?.relics || []); + + if (!next.battle_config) { + next.battle_config = base?.battle_config + ? cloneJson(base.battle_config) + : { ...FREE_SR_DEFAULT_BATTLE_CONFIG }; + } + if (!Array.isArray(next.lightcones)) next.lightcones = []; + if (!Array.isArray(next.relics)) next.relics = []; + + return next; +} + +export function countFreeSRContent(freesr) { + return { + avatars: Object.keys(freesr?.avatars || {}).length, + lightcones: (freesr?.lightcones || []).length, + relics: (freesr?.relics || []).length + }; +} diff --git a/js/equipment-data.js b/js/equipment-data.js index 242dd00..9733b62 100644 --- a/js/equipment-data.js +++ b/js/equipment-data.js @@ -1,4 +1,4 @@ -import { getCurrentLanguage, translatePath } from "./i18n.js?v=2.22"; +import { getCurrentLanguage, translatePath } from "./i18n.js?v=2.23"; export const LIGHTCONE_METADATA = { "20000": { diff --git a/js/firefly.js b/js/firefly.js new file mode 100644 index 0000000..99ee09c --- /dev/null +++ b/js/firefly.js @@ -0,0 +1,238 @@ +// FireflyGo (Firefly_Srtools) transport helpers. +// +// FireflyGo exposes a single push endpoint, POST /sync, that accepts +// { username, password, data?: FreeSRJson, extra_data?: ExtraData }. A request +// without `data` is the "connect" probe and only returns the current extra data. +// Reference: https://git.kain.io.vn/Firefly-Shelter/Firefly_Srtools + +export const FIREFLY_SYNC_PATH = '/sync'; +export const FIREFLY_DEFAULT_BASE_URL = 'http://localhost:21000'; + +/** + * FireflyGo only listens on POST /sync and ignores the GaCy-style port + * paths, so the UI's base URL is turned into a full sync endpoint here. + */ +export function fireflySyncUrl(apiUrl) { + const base = String(apiUrl ?? '').trim().replace(/\/+$/, '') || FIREFLY_DEFAULT_BASE_URL; + if (/\/sync$/i.test(base)) return base; + return `${base}${FIREFLY_SYNC_PATH}`; +} + +export const FIREFLY_EXTRA_FALLBACK = { + multi_path: { + main: 0, + march_7: 0, + multi_path_main: [], + multi_path_march_7: [] + }, + setting: { + censorship: false, + cm: false, + first_person: false, + hide_ui: false, + invasion_percent: 0 + }, + challenge: { + skip_node: 0, + challenge_peak_group_id: 0, + challenge_peak_group_id_list: [] + }, + theory_craft: { + hp: {}, + cycle_count: 0, + mode: false, + stage_id: 0, + custom_lineup: [] + }, + lua: null +}; + +function num(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function bool(value) { + return value === true || value === 1 || value === 'true' || value === '1'; +} + +function numberList(value) { + if (Array.isArray(value)) { + return value.map((entry) => num(entry)).filter((entry) => Number.isFinite(entry)); + } + if (typeof value === 'string') { + return value + .split(/[\s,;]+/) + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => num(entry)) + .filter((entry) => Number.isFinite(entry)); + } + return []; +} + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +/** + * Normalises whatever the server returned (or the user typed) into the shape + * FireflyGo expects. Returns null when there is nothing usable so a sync never + * sends a bogus `extra_data` block. + */ +export function normalizeExtraData(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + + const extra = cloneJson(raw); + const multiPath = extra.multi_path && typeof extra.multi_path === 'object' ? extra.multi_path : {}; + extra.multi_path = { + main: num(multiPath.main), + march_7: num(multiPath.march_7), + multi_path_main: numberList(multiPath.multi_path_main), + multi_path_march_7: numberList(multiPath.multi_path_march_7) + }; + + const setting = extra.setting && typeof extra.setting === 'object' ? extra.setting : {}; + extra.setting = { + ...setting, + censorship: bool(setting.censorship), + cm: bool(setting.cm), + first_person: bool(setting.first_person), + hide_ui: bool(setting.hide_ui), + invasion_percent: num(setting.invasion_percent) + }; + + const challenge = extra.challenge && typeof extra.challenge === 'object' ? extra.challenge : {}; + extra.challenge = { + skip_node: num(challenge.skip_node), + challenge_peak_group_id: num(challenge.challenge_peak_group_id), + challenge_peak_group_id_list: numberList(challenge.challenge_peak_group_id_list) + }; + + if (extra.theory_craft && typeof extra.theory_craft === 'object') { + const theory = extra.theory_craft; + extra.theory_craft = { + hp: theory.hp && typeof theory.hp === 'object' && !Array.isArray(theory.hp) ? theory.hp : {}, + cycle_count: num(theory.cycle_count), + mode: bool(theory.mode), + stage_id: num(theory.stage_id), + custom_lineup: numberList(theory.custom_lineup) + }; + } + + if (extra.lua !== null && extra.lua !== undefined && typeof extra.lua !== 'string') { + extra.lua = String(extra.lua); + } + if (extra.lua === undefined) extra.lua = null; + + return extra; +} + +function normalizeAnchorMap(skillsByAnchorType, skills) { + if (skillsByAnchorType && typeof skillsByAnchorType === 'object' && !Array.isArray(skillsByAnchorType)) { + const entries = Object.entries(skillsByAnchorType) + .map(([key, value]) => [String(key), num(value)]) + .filter(([key]) => key); + if (entries.length > 0) return Object.fromEntries(entries); + } + // Derive the anchor map from point ids when the save does not carry one. + const derived = {}; + Object.entries(skills || {}).forEach(([pointId, level]) => { + const suffix = String(pointId).slice(-3); + const anchor = /^2\d{2}$/.test(suffix) + ? '9' + : ({ '001': '1', '002': '2', '003': '3', '004': '4', '007': '5', '101': '6', '102': '7', '103': '8' }[suffix] || ''); + if (!anchor) return; + if (derived[anchor] === undefined || num(level) > derived[anchor]) derived[anchor] = num(level); + }); + return Object.keys(derived).length > 0 ? derived : undefined; +} + +function normalizeBattleConfig(battleConfig) { + const source = battleConfig && typeof battleConfig === 'object' ? battleConfig : {}; + return { + battle_type: typeof source.battle_type === 'string' && source.battle_type ? source.battle_type : 'DEFAULT', + blessings: Array.isArray(source.blessings) ? source.blessings : [], + custom_stats: Array.isArray(source.custom_stats) ? source.custom_stats : [], + cycle_count: num(source.cycle_count), + stage_id: num(source.stage_id), + path_resonance_id: num(source.path_resonance_id), + monsters: Array.isArray(source.monsters) ? source.monsters : [] + }; +} + +/** + * Converts the app's FreeSR snapshot into the exact payload FireflyGo's + * converter produces: avatars keyed by id, flat lightcone/relic arrays with + * sequential internal uids, and a stripped battle_config. + */ +export function buildSyncPayload(freesr) { + const source = freesr && typeof freesr === 'object' ? freesr : {}; + const avatars = {}; + + Object.entries(source.avatars || {}).forEach(([key, avatar]) => { + if (!avatar || typeof avatar !== 'object') return; + const avatarId = num(avatar.avatar_id, num(key)); + if (!avatarId) return; + const skills = { ...(avatar.data?.skills || {}) }; + const skillsByAnchorType = normalizeAnchorMap(avatar.data?.skills_by_anchor_type, skills); + const exRanks = Array.isArray(avatar.data?.ex_rank_id_list) ? avatar.data.ex_rank_id_list.filter((id) => num(id) > 0) : []; + + avatars[String(avatarId)] = { + owner_uid: num(avatar.owner_uid), + avatar_id: avatarId, + data: { + rank: num(avatar.data?.rank), + skills, + ...(skillsByAnchorType ? { skills_by_anchor_type: skillsByAnchorType } : {}), + ...(exRanks.length > 0 ? { ex_rank_id_list: exRanks } : {}) + }, + level: num(avatar.level), + promotion: num(avatar.promotion), + techniques: Array.isArray(avatar.techniques) ? avatar.techniques : [], + sp_value: num(avatar.sp_value), + sp_max: num(avatar.sp_max), + ...(num(avatar.enhanced_id) > 0 ? { enhanced_id: num(avatar.enhanced_id) } : {}) + }; + }); + + const lightcones = (Array.isArray(source.lightcones) ? source.lightcones : []) + .filter((lc) => lc && num(lc.item_id) > 0) + .map((lc, index) => ({ + level: num(lc.level), + item_id: num(lc.item_id), + equip_avatar: num(lc.equip_avatar), + rank: num(lc.rank), + promotion: num(lc.promotion), + internal_uid: Number.isFinite(Number(lc.internal_uid)) ? num(lc.internal_uid) : index + })); + + const relics = (Array.isArray(source.relics) ? source.relics : []) + .filter((relic) => relic && num(relic.relic_id) > 0) + .map((relic, index) => ({ + level: num(relic.level), + relic_id: num(relic.relic_id), + relic_set_id: num(relic.relic_set_id), + main_affix_id: num(relic.main_affix_id), + sub_affixes: (Array.isArray(relic.sub_affixes) ? relic.sub_affixes : []) + .map((sub) => ({ + sub_affix_id: num(sub?.sub_affix_id), + count: num(sub?.count), + step: num(sub?.step) + })) + .filter((sub) => sub.sub_affix_id > 0), + internal_uid: Number.isFinite(Number(relic.internal_uid)) ? num(relic.internal_uid) : index, + equip_avatar: num(relic.equip_avatar) + })); + + const payload = { + lightcones, + relics, + avatars, + battle_config: normalizeBattleConfig(source.battle_config) + }; + if (Array.isArray(source.loadout) && source.loadout.length > 0) { + payload.loadout = source.loadout; + } + return payload; +} diff --git a/js/i18n.js b/js/i18n.js index ff840a2..5bd2077 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -475,7 +475,7 @@ export const TRANSLATIONS = { "tabPersistentGaC": "💾 จัดการเซฟ GaCy", "tabPersistentFirefly": "👤 ผู้บุกเบิก & March 7th", "badgePersistenceGaC": "GaCy: persistent.json (/api/persistent)", - "badgePersistenceFirefly": "FireflyGo: data-in-game.json & freesr-data.json", + "badgePersistenceFirefly": "FireflyGo: freesr-data.json (POST /sync)", "badgePersistenceRobin": "RobinSR: freesr-data.json (/api/freesr)", "lblLcSuperimpose": "Superimpose (ขัดเกลา)", "lcPickerTitle": "🗡️ เลือกสวมใส่กรวยแสง (Light Cone)", @@ -969,7 +969,7 @@ export const TRANSLATIONS = { "tabPersistentGaC": "💾 GaCy Save Manager", "tabPersistentFirefly": "👤 Trailblazer & March 7th", "badgePersistenceGaC": "GaCy: persistent.json (/api/persistent)", - "badgePersistenceFirefly": "FireflyGo: data-in-game.json & freesr-data.json", + "badgePersistenceFirefly": "FireflyGo: freesr-data.json (POST /sync)", "badgePersistenceRobin": "RobinSR: freesr-data.json (/api/freesr)", "lblLcSuperimpose": "Superimpose Rank", "lcPickerTitle": "🗡️ Select Light Cone to Equip", @@ -1320,7 +1320,7 @@ export const TRANSLATIONS = { "tabPersistentGaC": "💾 GaCy 存档管理", "tabPersistentFirefly": "👤 开拓者与三月七", "badgePersistenceGaC": "GaCy: persistent.json (/api/persistent)", - "badgePersistenceFirefly": "FireflyGo: data-in-game.json & freesr-data.json", + "badgePersistenceFirefly": "FireflyGo: freesr-data.json (POST /sync)", "badgePersistenceRobin": "RobinSR: freesr-data.json (/api/freesr)", "lblLcSuperimpose": "叠影等阶", "lcPickerTitle": "🗡️ 选择要装备的光锥", @@ -1670,7 +1670,7 @@ export const TRANSLATIONS = { "tabPersistentGaC": "💾 GaCy 存檔管理", "tabPersistentFirefly": "👤 開拓者與三月七", "badgePersistenceGaC": "GaCy: persistent.json (/api/persistent)", - "badgePersistenceFirefly": "FireflyGo: data-in-game.json & freesr-data.json", + "badgePersistenceFirefly": "FireflyGo: freesr-data.json (POST /sync)", "badgePersistenceRobin": "RobinSR: freesr-data.json (/api/freesr)", "lblLcSuperimpose": "疊影等階", "lcPickerTitle": "🗡️ 選擇要裝備的光錐", @@ -2020,7 +2020,7 @@ export const TRANSLATIONS = { "tabPersistentGaC": "💾 GaCyセーブデータ管理", "tabPersistentFirefly": "👤 開拓者&三月なのか", "badgePersistenceGaC": "GaCy: persistent.json (/api/persistent)", - "badgePersistenceFirefly": "FireflyGo: data-in-game.json & freesr-data.json", + "badgePersistenceFirefly": "FireflyGo: freesr-data.json (POST /sync)", "badgePersistenceRobin": "RobinSR: freesr-data.json (/api/freesr)", "lblLcSuperimpose": "重畳ランク", "lcPickerTitle": "🗡️ 装備する光円錐を選択", @@ -2370,7 +2370,7 @@ export const TRANSLATIONS = { "tabPersistentGaC": "💾 GaCy 세이브 관리", "tabPersistentFirefly": "👤 개척자 & March 7th", "badgePersistenceGaC": "GaCy: persistent.json (/api/persistent)", - "badgePersistenceFirefly": "FireflyGo: data-in-game.json & freesr-data.json", + "badgePersistenceFirefly": "FireflyGo: freesr-data.json (POST /sync)", "badgePersistenceRobin": "RobinSR: freesr-data.json (/api/freesr)", "lblLcSuperimpose": "중첩 등급", "lcPickerTitle": "🗡️ 장착할 광추 선택", @@ -2720,7 +2720,7 @@ export const TRANSLATIONS = { "tabPersistentGaC": "💾 Quản lý lưu GaCy", "tabPersistentFirefly": "👤 Nhà Khai Phá & March 7th", "badgePersistenceGaC": "GaCy: persistent.json (/api/persistent)", - "badgePersistenceFirefly": "FireflyGo: data-in-game.json & freesr-data.json", + "badgePersistenceFirefly": "FireflyGo: freesr-data.json (POST /sync)", "badgePersistenceRobin": "RobinSR: freesr-data.json (/api/freesr)", "lblLcSuperimpose": "Bậc Tích Lũy", "lcPickerTitle": "🗡️ Chọn Nón Ánh Sáng để trang bị", @@ -3070,7 +3070,7 @@ export const TRANSLATIONS = { "tabPersistentGaC": "💾 Pengelola Simpan GaCy", "tabPersistentFirefly": "👤 Trailblazer & March 7th", "badgePersistenceGaC": "GaCy: persistent.json (/api/persistent)", - "badgePersistenceFirefly": "FireflyGo: data-in-game.json & freesr-data.json", + "badgePersistenceFirefly": "FireflyGo: freesr-data.json (POST /sync)", "badgePersistenceRobin": "RobinSR: freesr-data.json (/api/freesr)", "lblLcSuperimpose": "Tingkat Superimpose", "lcPickerTitle": "🗡️ Pilih Light Cone untuk Dipasang", @@ -3420,7 +3420,7 @@ export const TRANSLATIONS = { "tabPersistentGaC": "💾 Gestor de guardado GaCy", "tabPersistentFirefly": "👤 Trazacaminos y Siete de Marzo", "badgePersistenceGaC": "GaCy: persistent.json (/api/persistent)", - "badgePersistenceFirefly": "FireflyGo: data-in-game.json & freesr-data.json", + "badgePersistenceFirefly": "FireflyGo: freesr-data.json (POST /sync)", "badgePersistenceRobin": "RobinSR: freesr-data.json (/api/freesr)", "lblLcSuperimpose": "Rango de superposición", "lcPickerTitle": "🗡️ Seleccionar Cono de Luz para equipar", @@ -3770,7 +3770,7 @@ export const TRANSLATIONS = { "tabPersistentGaC": "💾 Gestionnaire de sauvegarde GaCy", "tabPersistentFirefly": "👤 Pionnier & March 7th", "badgePersistenceGaC": "GaCy: persistent.json (/api/persistent)", - "badgePersistenceFirefly": "FireflyGo: data-in-game.json & freesr-data.json", + "badgePersistenceFirefly": "FireflyGo: freesr-data.json (POST /sync)", "badgePersistenceRobin": "RobinSR: freesr-data.json (/api/freesr)", "lblLcSuperimpose": "Rang de superposition", "lcPickerTitle": "🗡️ Sélectionner un Cône de Lumière à équiper", @@ -4120,7 +4120,7 @@ export const TRANSLATIONS = { "tabPersistentGaC": "💾 GaCy-Speicherverwaltung", "tabPersistentFirefly": "👤 Trailblazer & March 7th", "badgePersistenceGaC": "GaCy: persistent.json (/api/persistent)", - "badgePersistenceFirefly": "FireflyGo: data-in-game.json & freesr-data.json", + "badgePersistenceFirefly": "FireflyGo: freesr-data.json (POST /sync)", "badgePersistenceRobin": "RobinSR: freesr-data.json (/api/freesr)", "lblLcSuperimpose": "Überlagerungsrang", "lcPickerTitle": "🗡️ Lichtkegel zum Ausrüsten wählen", @@ -4470,7 +4470,7 @@ export const TRANSLATIONS = { "tabPersistentGaC": "💾 Управление сохранениями GaCy", "tabPersistentFirefly": "👤 Первопроходец и Март 7", "badgePersistenceGaC": "GaCy: persistent.json (/api/persistent)", - "badgePersistenceFirefly": "FireflyGo: data-in-game.json & freesr-data.json", + "badgePersistenceFirefly": "FireflyGo: freesr-data.json (POST /sync)", "badgePersistenceRobin": "RobinSR: freesr-data.json (/api/freesr)", "lblLcSuperimpose": "Ранг наложения", "lcPickerTitle": "🗡️ Выберите Световой конус для экипировки", @@ -4820,7 +4820,7 @@ export const TRANSLATIONS = { "tabPersistentGaC": "💾 Gerenciador de Saves GaCy", "tabPersistentFirefly": "👤 Desbravador e March 7th", "badgePersistenceGaC": "GaCy: persistent.json (/api/persistent)", - "badgePersistenceFirefly": "FireflyGo: data-in-game.json & freesr-data.json", + "badgePersistenceFirefly": "FireflyGo: freesr-data.json (POST /sync)", "badgePersistenceRobin": "RobinSR: freesr-data.json (/api/freesr)", "lblLcSuperimpose": "Classificação de Sobreposição", "lcPickerTitle": "🗡️ Selecionar Cone de Luz para Equipar", @@ -6826,19 +6826,19 @@ for (const [language, label] of Object.entries(MONSTER_PICKER_TRANSLATIONS)) { } const SERVER_MODE_AVAILABILITY_TRANSLATIONS = { - th: { serverModeFireflyPaused: 'FireflyGo — กำลังพัฒนา', serverModeRobinPaused: 'RobinSR — กำลังพัฒนา', serverModesPausedNotice: 'FireflyGo และ RobinSR ปิดใช้งานชั่วคราวระหว่างพัฒนา จึงกลับไปใช้ GaC' }, - en: { serverModeFireflyPaused: 'FireflyGo — Under development', serverModeRobinPaused: 'RobinSR — Under development', serverModesPausedNotice: 'FireflyGo and RobinSR are temporarily disabled during development. Switched back to GaC.' }, - 'zh-CN': { serverModeFireflyPaused: 'FireflyGo — 开发中', serverModeRobinPaused: 'RobinSR — 开发中', serverModesPausedNotice: 'FireflyGo 和 RobinSR 正在开发中,暂时停用。已切回 GaCy。' }, - 'zh-TW': { serverModeFireflyPaused: 'FireflyGo — 開發中', serverModeRobinPaused: 'RobinSR — 開發中', serverModesPausedNotice: 'FireflyGo 和 RobinSR 正在開發中,暫時停用。已切回 GaCy。' }, - ja: { serverModeFireflyPaused: 'FireflyGo — 開発中', serverModeRobinPaused: 'RobinSR — 開発中', serverModesPausedNotice: 'FireflyGo と RobinSR は開発中のため一時停止しています。GaCy に切り替えました。' }, - ko: { serverModeFireflyPaused: 'FireflyGo — 개발 중', serverModeRobinPaused: 'RobinSR — 개발 중', serverModesPausedNotice: 'FireflyGo와 RobinSR은 개발 중이라 일시적으로 비활성화되었습니다. GaCy로 전환했습니다.' }, - vi: { serverModeFireflyPaused: 'FireflyGo — Đang phát triển', serverModeRobinPaused: 'RobinSR — Đang phát triển', serverModesPausedNotice: 'FireflyGo và RobinSR đang được phát triển nên tạm thời bị tắt. Đã chuyển về GaCy.' }, - id: { serverModeFireflyPaused: 'FireflyGo — Sedang dikembangkan', serverModeRobinPaused: 'RobinSR — Sedang dikembangkan', serverModesPausedNotice: 'FireflyGo dan RobinSR dinonaktifkan sementara selama pengembangan. Kembali ke GaCy.' }, - es: { serverModeFireflyPaused: 'FireflyGo — En desarrollo', serverModeRobinPaused: 'RobinSR — En desarrollo', serverModesPausedNotice: 'FireflyGo y RobinSR están desactivados temporalmente durante su desarrollo. Se cambió a GaCy.' }, - fr: { serverModeFireflyPaused: 'FireflyGo — En développement', serverModeRobinPaused: 'RobinSR — En développement', serverModesPausedNotice: 'FireflyGo et RobinSR sont temporairement désactivés pendant leur développement. Retour à GaCy.' }, - de: { serverModeFireflyPaused: 'FireflyGo — In Entwicklung', serverModeRobinPaused: 'RobinSR — In Entwicklung', serverModesPausedNotice: 'FireflyGo und RobinSR sind während der Entwicklung vorübergehend deaktiviert. Zu GaCy gewechselt.' }, - ru: { serverModeFireflyPaused: 'FireflyGo — В разработке', serverModeRobinPaused: 'RobinSR — В разработке', serverModesPausedNotice: 'FireflyGo и RobinSR временно отключены на время разработки. Выполнен переход на GaCy.' }, - pt: { serverModeFireflyPaused: 'FireflyGo — Em desenvolvimento', serverModeRobinPaused: 'RobinSR — Em desenvolvimento', serverModesPausedNotice: 'FireflyGo e RobinSR estão temporariamente desativados durante o desenvolvimento. Voltou para GaCy.' } + th: { serverModeFireflyPaused: 'FireflyGo — กำลังพัฒนา', serverModeRobinPaused: 'RobinSR — กำลังพัฒนา' }, + en: { serverModeFireflyPaused: 'FireflyGo — Under development', serverModeRobinPaused: 'RobinSR — Under development' }, + 'zh-CN': { serverModeFireflyPaused: 'FireflyGo — 开发中', serverModeRobinPaused: 'RobinSR — 开发中' }, + 'zh-TW': { serverModeFireflyPaused: 'FireflyGo — 開發中', serverModeRobinPaused: 'RobinSR — 開發中' }, + ja: { serverModeFireflyPaused: 'FireflyGo — 開発中', serverModeRobinPaused: 'RobinSR — 開発中' }, + ko: { serverModeFireflyPaused: 'FireflyGo — 개발 중', serverModeRobinPaused: 'RobinSR — 개발 중' }, + vi: { serverModeFireflyPaused: 'FireflyGo — Đang phát triển', serverModeRobinPaused: 'RobinSR — Đang phát triển' }, + id: { serverModeFireflyPaused: 'FireflyGo — Sedang dikembangkan', serverModeRobinPaused: 'RobinSR — Sedang dikembangkan' }, + es: { serverModeFireflyPaused: 'FireflyGo — En desarrollo', serverModeRobinPaused: 'RobinSR — En desarrollo' }, + fr: { serverModeFireflyPaused: 'FireflyGo — En développement', serverModeRobinPaused: 'RobinSR — En développement' }, + de: { serverModeFireflyPaused: 'FireflyGo — In Entwicklung', serverModeRobinPaused: 'RobinSR — In Entwicklung' }, + ru: { serverModeFireflyPaused: 'FireflyGo — В разработке', serverModeRobinPaused: 'RobinSR — В разработке' }, + pt: { serverModeFireflyPaused: 'FireflyGo — Em desenvolvimento', serverModeRobinPaused: 'RobinSR — Em desenvolvimento' } }; for (const [language, additions] of Object.entries(ADDITIONAL_TRANSLATIONS)) { @@ -7049,6 +7049,78 @@ for (const [language, description] of Object.entries(ITEM_CATALOG_INTERACTION_DE // language falls back to en through t(), so no Thai leaks into other locales. const MODAL_AND_TOOL_TRANSLATIONS = { th: { + // Server modes + serverModeFirefly: 'FireflyGo', + apiUrlHintGaC: 'GaCy GO ใช้ REST API ใต้ URL นี้ (เช่น /api/status, /api/freesr, /api/persistent)', + apiUrlHintFirefly: 'FireflyGo รับข้อมูลทาง POST /sync เท่านั้น — ต้องเปิดเซิร์ฟเวอร์ Firefly_Srtools ไว้ที่เครื่องเดียวกัน', + apiUrlHintRobin: 'RobinSR ยังไม่รองรับในเวอร์ชันนี้', + + // Enka import + enkaImportTitle: '📡 สร้างไฟล์ FreeSR จาก UID เกมจริง (Enka)', + enkaImportSubtitle: 'ดึงตัวละคร กรวยแสง และรีลิกส์จากโปรไฟล์ Enka แล้วสร้างเป็นไฟล์ freesr-data.json ใช้ได้ทุกโหมดการเชื่อมต่อ', + enkaUidLabel: 'UID ของเกม', + enkaUidPlaceholder: '800000000', + enkaUidInvalid: 'UID ไม่ถูกต้อง ต้องเป็นตัวเลข 8-10 หลัก', + enkaModeMerge: 'รวมกับข้อมูลเดิม', + enkaModeReplace: 'แทนที่ตัวละครทั้งหมด', + btnEnkaFetch: 'ดึงข้อมูลจาก Enka', + enkaPasteLabel: 'หรือวาง JSON จาก Enka', + enkaPastePlaceholder: 'https://enka.network/api/hsr/uid/...', + btnEnkaParsePaste: 'ใช้ JSON ที่วาง', + enkaFetching: 'กำลังดึงข้อมูล UID {uid}...', + enkaImportDone: 'นำเข้าสำเร็จ: ตัวละคร {avatars} • กรวยแสง {lightcones} • รีลิกส์ {relics}', + enkaUidNotFound: 'ไม่พบ UID นี้ใน Enka (หรือโปรไฟล์ถูกตั้งเป็นส่วนตัว)', + enkaRateLimited: 'Enka จำกัดจำนวนการเรียก กรุณารอสักครู่แล้วลองใหม่', + enkaFetchFailed: 'ดึงข้อมูลจาก Enka ไม่สำเร็จ (HTTP {status})', + enkaFetchFailedDetail: 'นำเข้าไม่สำเร็จ: {message}', + enkaNoAvatars: 'ข้อมูลนี้ไม่มีตัวละครที่แสดงอยู่ (โปรไฟล์อาจถูกซ่อน)', + enkaPasteEmpty: 'ยังไม่มี JSON ที่วางไว้', + enkaPasteInvalid: 'JSON ที่วางไม่ถูกต้อง', + enkaProxyUnavailable: 'เรียก Enka ไม่ได้ — ตรวจสอบว่าเว็บนี้มี proxy /api/enka หรือใช้ช่องวาง JSON แทน', + enkaSyncFailed: 'นำเข้าในเครื่องแล้ว แต่ส่งไปเซิร์ฟเวอร์ไม่สำเร็จ: {message}', + + // FireflyGo extra_data + fireflyExtrasTitle: '🦋 ตัวควบคุม FireflyGo (extra_data)', + fireflyExtrasBadge: 'POST /sync', + fireflyExtrasSubtitle: 'ค่าเหล่านี้ถูกส่งไปพร้อมข้อมูลตัวละครทุกครั้งที่ซิงค์ เพื่อสั่งงานสคริปต์และเซิร์ฟเวอร์ Firefly', + fireflyMultiPathTitle: '🧭 Multi-Path (Trailblazer)', + fireflyMultiPathMain: 'Path id หลัก', + fireflyMultiPathMarch7: 'Path id ของ March 7th', + fireflyMultiPathMainList: 'รายการ Path หลัก', + fireflyMultiPathMarch7List: 'รายการ Path ของ March 7th', + fireflySettingTitle: '🎛️ ตั้งค่าในเกม', + fireflySettingCensorship: 'Censorship', + fireflySettingCm: 'Cinematic mode', + fireflySettingFirstPerson: 'มุมมองบุคคลที่หนึ่ง', + fireflySettingHideUi: 'ซ่อน UI', + fireflySettingInvasion: 'เปอร์เซ็นต์การบุกรุก', + fireflyChallengeTitle: '⚔️ Challenge Peak', + fireflyChallengeSkipNode: 'ข้ามโหนด', + fireflyChallengeGroupId: 'Peak group id', + fireflyChallengeGroupList: 'รายการ Peak group id', + fireflyTheoryTitle: '🧪 Theory Craft', + fireflyTheoryMode: 'เปิดโหมด Theory Craft', + fireflyTheoryCycleCount: 'จำนวนรอบ', + fireflyTheoryStageId: 'Stage id', + fireflyTheoryLineup: 'ทีมที่กำหนดเอง', + fireflyTheoryHp: 'ค่า HP ทับ (JSON)', + fireflyLuaLabel: 'สคริปต์ Lua (ไม่บังคับ)', + fireflyLuaPlaceholder: '-- lua ที่กำหนดเอง', + fireflyListPlaceholder: '1, 2, 3', + btnFireflyProbe: '🔄 ดึงค่าจากเซิร์ฟเวอร์', + btnFireflySaveExtra: '💾 บันทึกตัวควบคุม', + btnFireflySync: '🚀 ซิงค์ลง FireflyGo', + fireflyProbing: 'กำลังเชื่อมต่อ FireflyGo...', + fireflySyncing: 'กำลังส่งข้อมูลไปยัง FireflyGo...', + fireflyProbeSuccess: 'เชื่อมต่อ FireflyGo สำเร็จ', + fireflySyncSuccess: 'ซิงค์ข้อมูลลง FireflyGo สำเร็จ', + fireflyRequestFailed: 'FireflyGo ตอบกลับด้วยข้อผิดพลาด', + fireflyRequestFailedDetail: 'ไม่สำเร็จ: {message}', + fireflyTimeout: 'FireflyGo ไม่ตอบสนอง (หมดเวลา)', + fireflyNoData: 'ยังไม่มีข้อมูลตัวละครให้ซิงค์', + fireflyExtraSaved: 'บันทึกตัวควบคุม FireflyGo แล้ว', + fireflyHpInvalid: 'ค่า HP ต้องเป็น JSON object เช่น {"1001": [12000, 9000]}', + assignPickerTitleRelic: 'สวมใส่รีลิกส์ให้ตัวละคร', assignPickerTitleLc: 'สวมใส่กรวยแสงให้ตัวละคร', assignPickerSubtitle: 'เลือกตัวละครที่จะสวมใส่ {item} — หรือเก็บไว้ในคลัง', @@ -7184,6 +7256,78 @@ const MODAL_AND_TOOL_TRANSLATIONS = { analystDamageAvPlaceholder: "เฉลี่ยต่อรอบ: -", analystCyclesPlaceholder: "0 รอบ", analystTurnsPlaceholder: "0 เทิร์น", analystAutoTurnsPlaceholder: "เทิร์นอัตโนมัติ: 0", analystDamageAvPlaceholder: "Avg per cycle: -", analystCyclesPlaceholder: "0 Cycles", analystTurnsPlaceholder: "0 Turns", analystAutoTurnsPlaceholder: "Auto Turns: 0", analystDamageAvPlaceholder: "ラウンド平均: -", analystCyclesPlaceholder: "0 ラウンド", analystTurnsPlaceholder: "0 ターン", analystAutoTurnsPlaceholder: "オートターン: 0", analystDamageAvPlaceholder: "라운드당 평균: -", analystCyclesPlaceholder: "0 라운드", analystTurnsPlaceholder: "0 턴", analystAutoTurnsPlaceholder: "자동 턴: 0", analystDamageAvPlaceholder: "Trung bình mỗi vòng: -", analystCyclesPlaceholder: "0 vòng", analystTurnsPlaceholder: "0 lượt", analystAutoTurnsPlaceholder: "Lượt tự động: 0", analystDamageAvPlaceholder: "Rata-rata per siklus: -", analystCyclesPlaceholder: "0 Siklus", analystTurnsPlaceholder: "0 Giliran", analystAutoTurnsPlaceholder: "Giliran otomatis: 0", analystDamageAvPlaceholder: "Promedio por ciclo: -", analystCyclesPlaceholder: "0 ciclos", analystTurnsPlaceholder: "0 turnos", analystAutoTurnsPlaceholder: "Turnos automáticos: 0", analystDamageAvPlaceholder: "Moyenne par cycle : -", analystCyclesPlaceholder: "0 cycle", analystTurnsPlaceholder: "0 tour", analystAutoTurnsPlaceholder: "Tours auto : 0", analystDamageAvPlaceholder: "Durchschnitt pro Zyklus: -", analystCyclesPlaceholder: "0 Zyklen", analystTurnsPlaceholder: "0 Züge", analystAutoTurnsPlaceholder: "Auto-Züge: 0", analystDamageAvPlaceholder: "В среднем за цикл: -", analystCyclesPlaceholder: "0 циклов", analystTurnsPlaceholder: "0 ходов", analystAutoTurnsPlaceholder: "Авто-ходы: 0", analystDamageAvPlaceholder: "Média por ciclo: -", analystCyclesPlaceholder: "0 ciclos", analystTurnsPlaceholder: "0 turnos", analystAutoTurnsPlaceholder: "Turnos automáticos: 0", miniViewFullAnalystTooltip: "เปิดหน้า Damage Analyst ตัวเต็ม", miniViewFullAnalystTooltip: "Open full Damage Analyst", miniViewFullAnalystTooltip: "完全なダメージ分析を開く", miniViewFullAnalystTooltip: "전체 데미지 분석 열기", miniViewFullAnalystTooltip: "Mở Trình phân tích sát thương đầy đủ", miniViewFullAnalystTooltip: "Buka Analis Kerusakan Lengkap", miniViewFullAnalystTooltip: "Abrir analizador de daño completo", miniViewFullAnalystTooltip: "Ouvrir l'analyseur de dégâts complet", miniViewFullAnalystTooltip: "Vollständigen Schadensanalysten öffnen", miniViewFullAnalystTooltip: "Открыть полный анализ урона", miniViewFullAnalystTooltip: "Abrir analisador de dano completo", copyCardGenerated: 'สร้างโดย GaCy-Srtool' }, en: { + // Server modes + serverModeFirefly: 'FireflyGo', + apiUrlHintGaC: 'GaCy GO serves its REST API under this URL (e.g. /api/status, /api/freesr, /api/persistent)', + apiUrlHintFirefly: 'FireflyGo only accepts POST /sync — it must be the machine running Firefly_Srtools', + apiUrlHintRobin: 'RobinSR is not supported in this build yet', + + // Enka import + enkaImportTitle: '📡 Build a FreeSR file from a real game UID (Enka)', + enkaImportSubtitle: 'Pull characters, light cones and relics from an Enka profile and turn them into freesr-data.json. Works in every connection mode.', + enkaUidLabel: 'Game UID', + enkaUidPlaceholder: '800000000', + enkaUidInvalid: 'Invalid UID — it must be 8-10 digits', + enkaModeMerge: 'Merge with existing data', + enkaModeReplace: 'Replace every character', + btnEnkaFetch: 'Fetch from Enka', + enkaPasteLabel: 'Or paste the Enka JSON', + enkaPastePlaceholder: 'https://enka.network/api/hsr/uid/...', + btnEnkaParsePaste: 'Use the pasted JSON', + enkaFetching: 'Fetching UID {uid}...', + enkaImportDone: 'Imported: {avatars} characters • {lightcones} light cones • {relics} relics', + enkaUidNotFound: 'That UID was not found on Enka (or the profile is private)', + enkaRateLimited: 'Enka is rate limiting requests. Please wait a moment and try again.', + enkaFetchFailed: 'Could not fetch from Enka (HTTP {status})', + enkaFetchFailedDetail: 'Import failed: {message}', + enkaNoAvatars: 'This payload has no visible characters (the profile may be hidden)', + enkaPasteEmpty: 'There is no pasted JSON yet', + enkaPasteInvalid: 'The pasted JSON is not valid', + enkaProxyUnavailable: 'Cannot reach Enka — check that this site exposes the /api/enka proxy, or use the paste field instead', + enkaSyncFailed: 'Imported locally, but sending it to the server failed: {message}', + + // FireflyGo extra_data + fireflyExtrasTitle: '🦋 FireflyGo controls (extra_data)', + fireflyExtrasBadge: 'POST /sync', + fireflyExtrasSubtitle: 'These values are sent with the character data on every sync to drive the Firefly script and server.', + fireflyMultiPathTitle: '🧭 Multi-Path (Trailblazer)', + fireflyMultiPathMain: 'Main path id', + fireflyMultiPathMarch7: 'March 7th path id', + fireflyMultiPathMainList: 'Main path list', + fireflyMultiPathMarch7List: 'March 7th path list', + fireflySettingTitle: '🎛️ In-game settings', + fireflySettingCensorship: 'Censorship', + fireflySettingCm: 'Cinematic mode', + fireflySettingFirstPerson: 'First person', + fireflySettingHideUi: 'Hide UI', + fireflySettingInvasion: 'Invasion percent', + fireflyChallengeTitle: '⚔️ Challenge Peak', + fireflyChallengeSkipNode: 'Skip node', + fireflyChallengeGroupId: 'Peak group id', + fireflyChallengeGroupList: 'Peak group id list', + fireflyTheoryTitle: '🧪 Theory Craft', + fireflyTheoryMode: 'Enable theory craft', + fireflyTheoryCycleCount: 'Cycle count', + fireflyTheoryStageId: 'Stage id', + fireflyTheoryLineup: 'Custom lineup', + fireflyTheoryHp: 'HP overrides (JSON)', + fireflyLuaLabel: 'Lua script (optional)', + fireflyLuaPlaceholder: '-- custom lua', + fireflyListPlaceholder: '1, 2, 3', + btnFireflyProbe: '🔄 Pull values from server', + btnFireflySaveExtra: '💾 Save controls', + btnFireflySync: '🚀 Sync to FireflyGo', + fireflyProbing: 'Connecting to FireflyGo...', + fireflySyncing: 'Sending data to FireflyGo...', + fireflyProbeSuccess: 'Connected to FireflyGo', + fireflySyncSuccess: 'Synced to FireflyGo', + fireflyRequestFailed: 'FireflyGo returned an error', + fireflyRequestFailedDetail: 'Failed: {message}', + fireflyTimeout: 'FireflyGo did not respond (timed out)', + fireflyNoData: 'There is no character data to sync yet', + fireflyExtraSaved: 'FireflyGo controls saved', + fireflyHpInvalid: 'HP overrides must be a JSON object such as {"1001": [12000, 9000]}', + assignPickerTitleRelic: 'Equip Relic to a Character', assignPickerTitleLc: 'Equip Light Cone to a Character', assignPickerSubtitle: 'Choose who wears {item} — or keep it in your inventory', diff --git a/js/item-catalog.js b/js/item-catalog.js index ad919ad..8c1d3b9 100644 --- a/js/item-catalog.js +++ b/js/item-catalog.js @@ -1,4 +1,4 @@ -import { getCurrentLanguage, t, translatePath } from './i18n.js?v=2.22'; +import { getCurrentLanguage, t, translatePath } from './i18n.js?v=2.23'; const CATALOG_URL = new URL('../assets/item-catalog.json?v=2', import.meta.url); const PAGE_SIZE = 48; diff --git a/js/modes.js b/js/modes.js new file mode 100644 index 0000000..fc871df --- /dev/null +++ b/js/modes.js @@ -0,0 +1,74 @@ +// Server-mode capability matrix. +// +// GaCy GO is the only backend that implements the full tool API +// (/api/status, /api/persistent, /api/inventory, /api/monster-settings, +// /api/battle_report, /api/tool-session ...). FireflyGo is push-only and only +// understands POST /sync, so every feature that needs a read endpoint has to +// disappear from the UI instead of failing at runtime. + +export const SERVER_MODES = ['gac', 'firefly', 'robin']; + +export const MODE_LABELS = { + gac: 'GaCy GO', + firefly: 'FireflyGo', + robin: 'RobinSR' +}; + +export const MODE_CAPABILITIES = { + gac: { + saveManager: true, + exranks: true, + inventory: true, + monsterSettings: true, + damageAnalyst: true, + sceneCard: true, + gmConsole: true, + battleSummary: true, + toolSession: true, + extraData: false, + ownedAvatarsOnly: false, + // data-in-game.json is a FireflyGo artifact, so GaCy keeps only its own + // freesr/persistent documents visible in the JSON tab. + jsonModes: ['freesr', 'persistent'] + }, + firefly: { + saveManager: false, + exranks: false, + inventory: false, + monsterSettings: false, + damageAnalyst: false, + sceneCard: false, + gmConsole: false, + battleSummary: false, + toolSession: false, + extraData: true, + ownedAvatarsOnly: true, + jsonModes: ['freesr'] + }, + robin: { + saveManager: false, + exranks: false, + inventory: false, + monsterSettings: false, + damageAnalyst: false, + sceneCard: false, + gmConsole: false, + battleSummary: false, + toolSession: false, + extraData: false, + ownedAvatarsOnly: false, + jsonModes: ['freesr'] + } +}; + +export function normalizeMode(mode) { + return MODE_CAPABILITIES[mode] ? mode : 'gac'; +} + +export function modeCapabilities(mode) { + return MODE_CAPABILITIES[normalizeMode(mode)]; +} + +export function modeSupports(feature, mode) { + return modeCapabilities(mode)[feature] === true; +} diff --git a/js/unstuck.js b/js/unstuck.js index 715b4c3..97d72be 100644 --- a/js/unstuck.js +++ b/js/unstuck.js @@ -1,4 +1,4 @@ -import { getCurrentLanguage } from './i18n.js?v=2.22'; +import { getCurrentLanguage } from './i18n.js?v=2.23'; const strings = { en: { diff --git a/package.json b/package.json index 18d92dc..d13867e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "sync-build-guides": "node scripts/sync-build-guides.mjs", "dev": "node scripts/sync-branch.js && npx serve . -l 5173", "start": "node scripts/sync-branch.js && npx serve . -l 5173", - "serve": "npx serve . -l 5173" + "serve": "npx serve . -l 5173", + "dev:proxy": "node scripts/sync-branch.js && node scripts/dev-server.cjs" }, "keywords": [ "hkrpg", diff --git a/scripts/dev-server.cjs b/scripts/dev-server.cjs new file mode 100644 index 0000000..58e76dd --- /dev/null +++ b/scripts/dev-server.cjs @@ -0,0 +1,99 @@ +// Local development server that mirrors the Vercel deployment. +// +// `npx serve` cannot proxy, and enka.network sends no CORS headers, so the +// Enka import button would always fail locally without this. Production uses +// the /api/enka/:uid rewrite in vercel.json instead. +// +// Usage: npm run dev:proxy (or node scripts/dev-server.cjs) +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); +const port = Number(process.env.PORT) || 5173; +const ENKA_ORIGIN = 'https://enka.network'; +const ENKA_PATH = /^\/api\/enka\/(\d{1,12})\/?$/; + +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.cjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.txt': 'text/plain; charset=utf-8', + '.md': 'text/markdown; charset=utf-8' +}; + +function sendJson(res, status, payload) { + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store' + }); + res.end(JSON.stringify(payload)); +} + +async function proxyEnka(uid, res) { + try { + const upstream = await fetch(`${ENKA_ORIGIN}/api/hsr/uid/${uid}`, { + headers: { 'User-Agent': 'GaCy-Srtool-dev' } + }); + const body = await upstream.text(); + res.writeHead(upstream.status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store' + }); + res.end(body); + } catch (err) { + sendJson(res, 502, { error: 'enka_proxy_failed', message: err.message }); + } +} + +function serveStatic(pathname, res) { + const decoded = decodeURIComponent(pathname); + const relative = decoded === '/' ? 'index.html' : decoded.replace(/^\/+/, ''); + const target = path.resolve(root, relative); + if (!target.startsWith(root + path.sep) && target !== path.join(root, 'index.html')) { + sendJson(res, 403, { error: 'forbidden' }); + return; + } + + fs.stat(target, (err, stats) => { + if (err || !stats.isFile()) { + sendJson(res, 404, { error: 'not_found', path: relative }); + return; + } + const type = MIME_TYPES[path.extname(target).toLowerCase()] || 'application/octet-stream'; + res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store' }); + fs.createReadStream(target).pipe(res); + }); +} + +const server = http.createServer((req, res) => { + const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + const enkaMatch = requestUrl.pathname.match(ENKA_PATH); + if (enkaMatch) { + proxyEnka(enkaMatch[1], res); + return; + } + if (requestUrl.pathname.startsWith('/api/')) { + sendJson(res, 404, { error: 'not_found', path: requestUrl.pathname }); + return; + } + serveStatic(requestUrl.pathname, res); +}); + +server.listen(port, () => { + console.log(`GaCy-Srtool dev server: http://localhost:${port}`); + console.log(`Enka proxy: http://localhost:${port}/api/enka/`); +}); diff --git a/tests/enka-import.test.cjs b/tests/enka-import.test.cjs new file mode 100644 index 0000000..7e285f5 --- /dev/null +++ b/tests/enka-import.test.cjs @@ -0,0 +1,182 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const moduleUrl = pathToFileURL(path.join(__dirname, '../js/enka-import.js')); + +async function load() { + return import(moduleUrl.href); +} + +function enkaPayload(overrides = {}) { + return { + detailInfo: { + avatarDetailList: [ + { + avatarId: 1506, + level: 80, + promotion: 6, + rank: 2, + equipment: { tid: 23001, level: 80, promotion: 6, rank: 1 }, + relicList: [ + { + tid: 61011, + level: 15, + mainAffixId: 1, + subAffixList: [ + { affixId: 2, cnt: 3, step: 0 }, + { affixId: 0, cnt: 0, step: 0 } + ] + } + ], + skillTreeList: [ + { pointId: 1506001, level: 6 }, + { pointId: 1506002, level: 10 }, + { pointId: 1506201, level: 1 }, + { pointId: 1506202, level: 1 } + ], + ...overrides + } + ] + } + }; +} + +test('normalizeEnkaUid only accepts 8-10 digit UIDs', async () => { + const { normalizeEnkaUid } = await load(); + assert.equal(normalizeEnkaUid('800000000'), '800000000'); + assert.equal(normalizeEnkaUid(' 12345678 '), '12345678'); + assert.equal(normalizeEnkaUid('1234567'), ''); + assert.equal(normalizeEnkaUid('12345678901'), ''); + assert.equal(normalizeEnkaUid('80000abc'), ''); + assert.equal(normalizeEnkaUid(null), ''); +}); + +test('trace anchors and relic ids follow the Firefly/SR formulas', async () => { + const { anchorTypeForPointId, relicSetIdFromTid, relicSlotFromTid } = await load(); + assert.equal(anchorTypeForPointId(1506001), '1'); + assert.equal(anchorTypeForPointId(1506004), '4'); + assert.equal(anchorTypeForPointId(1506007), '5'); + assert.equal(anchorTypeForPointId(1506101), '6'); + assert.equal(anchorTypeForPointId(1506103), '8'); + assert.equal(anchorTypeForPointId(1506201), '9'); + assert.equal(anchorTypeForPointId(1506999), ''); + + assert.equal(relicSetIdFromTid(61011), 101); + assert.equal(relicSlotFromTid(61011), 1); + assert.equal(relicSetIdFromTid('31143'), 114); + assert.equal(relicSlotFromTid('31143'), 3); +}); + +test('enkaToFreeSR converts avatars, light cones and relics', async () => { + const { enkaToFreeSR, countFreeSRContent } = await load(); + const freesr = enkaToFreeSR(enkaPayload(), { ownerUid: 800000000 }); + + const avatar = freesr.avatars['1506']; + assert.equal(avatar.avatar_id, 1506); + assert.equal(avatar.owner_uid, 800000000); + assert.equal(avatar.level, 80); + assert.equal(avatar.promotion, 6); + assert.equal(avatar.data.rank, 2); + assert.equal(avatar.data.skills['1506002'], 10); + // 201/202 are stat traces, so they collapse into anchor 9. + assert.deepEqual(avatar.data.skills_by_anchor_type, { 1: 6, 2: 10, 9: 1 }); + + assert.equal(freesr.lightcones.length, 1); + assert.deepEqual(freesr.lightcones[0], { + level: 80, + item_id: 23001, + equip_avatar: 1506, + rank: 1, + promotion: 6, + internal_uid: 0 + }); + + assert.equal(freesr.relics.length, 1); + assert.equal(freesr.relics[0].relic_set_id, 101); + assert.equal(freesr.relics[0].main_affix_id, 1); + assert.equal(freesr.relics[0].equip_avatar, 1506); + assert.deepEqual(freesr.relics[0].sub_affixes, [{ sub_affix_id: 2, count: 3, step: 0 }]); + + assert.deepEqual(countFreeSRContent(freesr), { avatars: 1, lightcones: 1, relics: 1 }); + assert.equal(freesr.battle_config.battle_type, 'DEFAULT'); + assert.deepEqual(freesr.battle_config.monsters, []); +}); + +test('unwrapEnkaPayload accepts raw, enveloped and Firefly responses', async () => { + const { unwrapEnkaPayload } = await load(); + const raw = enkaPayload(); + assert.equal(unwrapEnkaPayload(raw), raw); + assert.deepEqual(unwrapEnkaPayload({ ok: true, data: raw }), raw); + assert.deepEqual(unwrapEnkaPayload({ data: raw }), raw); + assert.equal(unwrapEnkaPayload({ detailInfo: null }), null); + assert.equal(unwrapEnkaPayload(null), null); + assert.equal(unwrapEnkaPayload({ message: 'nope' }), null); +}); + +test('enkaToFreeSR rejects payloads without visible characters', async () => { + const { enkaToFreeSR } = await load(); + assert.throws(() => enkaToFreeSR({ detailInfo: { avatarDetailList: [] } }), /ENKA_NO_AVATARS/); + assert.throws(() => enkaToFreeSR({ detailInfo: {} }), /ENKA_NO_AVATARS/); +}); + +test('mergeFreeSR replaces gear of re-imported characters only', async () => { + const { mergeFreeSR } = await load(); + const base = { + avatars: { + 1506: { avatar_id: 1506, level: 1, data: { rank: 0 } }, + 1001: { avatar_id: 1001, level: 80, data: { rank: 6 } } + }, + lightcones: [ + { item_id: 23001, equip_avatar: 1506, internal_uid: 0 }, + { item_id: 23002, equip_avatar: 1001, internal_uid: 1 } + ], + relics: [{ relic_id: 61011, equip_avatar: 1506, internal_uid: 0 }], + battle_config: { battle_type: 'MOC', stage_id: 12, monsters: [[1, 2]] } + }; + const incoming = { + avatars: { 1506: { avatar_id: 1506, level: 80, data: { rank: 2 } } }, + lightcones: [{ item_id: 23009, equip_avatar: 1506, internal_uid: 0 }], + relics: [] + }; + + const merged = mergeFreeSR(base, incoming, 'merge'); + assert.equal(merged.avatars[1506].level, 80); + assert.equal(merged.avatars[1001].level, 80); + assert.deepEqual(merged.lightcones.map(lc => lc.item_id), [23002, 23009]); + assert.equal(merged.relics.length, 0); + // The active challenge must survive an import. + assert.equal(merged.battle_config.stage_id, 12); + // The source document is not mutated. + assert.equal(base.avatars[1506].level, 1); +}); + +test('mergeFreeSR replace mode clears the roster but keeps battle config', async () => { + const { mergeFreeSR } = await load(); + const base = { + avatars: { 1001: { avatar_id: 1001 } }, + lightcones: [{ item_id: 23002, equip_avatar: 1001 }], + relics: [{ relic_id: 61011, equip_avatar: 1001 }], + battle_config: { battle_type: 'MOC', stage_id: 12 } + }; + const incoming = { avatars: { 1506: { avatar_id: 1506 } }, lightcones: [], relics: [] }; + const replaced = mergeFreeSR(base, incoming, 'replace'); + + assert.deepEqual(Object.keys(replaced.avatars), ['1506']); + assert.deepEqual(replaced.lightcones, []); + assert.deepEqual(replaced.relics, []); + assert.equal(replaced.battle_config.stage_id, 12); +}); + +test('mergeFreeSR works without an existing document', async () => { + const { mergeFreeSR, FREE_SR_DEFAULT_BATTLE_CONFIG } = await load(); + const merged = mergeFreeSR(null, { avatars: { 1506: { avatar_id: 1506 } }, lightcones: [], relics: [] }); + assert.deepEqual(Object.keys(merged.avatars), ['1506']); + assert.deepEqual(merged.battle_config, { + ...FREE_SR_DEFAULT_BATTLE_CONFIG, + blessings: [], + custom_stats: [], + monsters: [] + }); +}); diff --git a/tests/firefly-payload.test.cjs b/tests/firefly-payload.test.cjs new file mode 100644 index 0000000..3ab7852 --- /dev/null +++ b/tests/firefly-payload.test.cjs @@ -0,0 +1,194 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const moduleUrl = pathToFileURL(path.join(__dirname, '../js/firefly.js')); + +async function load() { + return import(moduleUrl.href); +} + +function sampleFreesr() { + return { + // Unknown top-level keys must not leak into the /sync payload. + lineups: { 0: 1506 }, + scene: { plane_id: 1 }, + avatars: { + 1506: { + avatar_id: 1506, + owner_uid: 800000000, + level: 80, + promotion: 6, + sp_value: 70, + sp_max: 140, + techniques: [1, 2], + enhanced_id: 0, + data: { + rank: 2, + skills: { 1506001: 6, 1506002: 10, 1506201: 1 }, + skills_by_anchor_type: { 1: 6, 2: 10 }, + ex_rank_id_list: [5501, 0] + } + } + }, + lightcones: [ + { item_id: 23001, level: 80, promotion: 6, rank: 1, equip_avatar: 1506, internal_uid: 0 }, + { item_id: 0, equip_avatar: 0 } + ], + relics: [ + { + relic_id: 61011, + relic_set_id: 101, + level: 15, + main_affix_id: 1, + equip_avatar: 1506, + internal_uid: 0, + sub_affixes: [ + { sub_affix_id: 2, count: 3, step: 0 }, + { sub_affix_id: 0, count: 1, step: 0 } + ] + } + ], + battle_config: { + battle_type: 'MOC', + blessings: [1, 2], + custom_stats: [], + cycle_count: 3, + stage_id: 12, + path_resonance_id: 0, + monsters: [[1002010, 1]], + custom_battle_lineup: { 0: 1506 }, + unknown_key: 'drop me' + }, + loadout: [{ avatar_id: 1506 }] + }; +} + +test('fireflySyncUrl always resolves to a /sync endpoint', async () => { + const { fireflySyncUrl, FIREFLY_DEFAULT_BASE_URL } = await load(); + assert.equal(fireflySyncUrl('http://localhost:21000'), 'http://localhost:21000/sync'); + assert.equal(fireflySyncUrl('http://localhost:21000/'), 'http://localhost:21000/sync'); + assert.equal(fireflySyncUrl('http://localhost:21000/sync'), 'http://localhost:21000/sync'); + assert.equal(fireflySyncUrl('http://localhost:21000/SYNC'), 'http://localhost:21000/SYNC'); + assert.equal(fireflySyncUrl(''), `${FIREFLY_DEFAULT_BASE_URL}/sync`); + assert.equal(fireflySyncUrl(null), `${FIREFLY_DEFAULT_BASE_URL}/sync`); + assert.equal(fireflySyncUrl(' '), `${FIREFLY_DEFAULT_BASE_URL}/sync`); +}); + +test('normalizeExtraData coerces the server payload into the FireflyGo shape', async () => { + const { normalizeExtraData } = await load(); + const extra = normalizeExtraData({ + multi_path: { main: '3', march_7: 1, multi_path_main: '1, 2 ,3', multi_path_march_7: [4] }, + setting: { censorship: 1, cm: 'true', first_person: 0, hide_ui: false, invasion_percent: '25' }, + challenge: { skip_node: '2', challenge_peak_group_id: 7, challenge_peak_group_id_list: '8,9' }, + theory_craft: { hp: { 1001: [12000] }, cycle_count: '2', mode: 'true', stage_id: '5', custom_lineup: [1001, 1002] }, + lua: 42 + }); + + assert.deepEqual(extra.multi_path, { + main: 3, + march_7: 1, + multi_path_main: [1, 2, 3], + multi_path_march_7: [4] + }); + assert.deepEqual(extra.setting, { + censorship: true, + cm: true, + first_person: false, + hide_ui: false, + invasion_percent: 25 + }); + assert.deepEqual(extra.challenge, { + skip_node: 2, + challenge_peak_group_id: 7, + challenge_peak_group_id_list: [8, 9] + }); + assert.deepEqual(extra.theory_craft, { + hp: { 1001: [12000] }, + cycle_count: 2, + mode: true, + stage_id: 5, + custom_lineup: [1001, 1002] + }); + assert.equal(extra.lua, '42'); +}); + +test('normalizeExtraData fills missing blocks and rejects empty input', async () => { + const { normalizeExtraData } = await load(); + assert.equal(normalizeExtraData(null), null); + assert.equal(normalizeExtraData('nope'), null); + assert.equal(normalizeExtraData([]), null); + + const extra = normalizeExtraData({}); + assert.deepEqual(extra.multi_path, { main: 0, march_7: 0, multi_path_main: [], multi_path_march_7: [] }); + assert.deepEqual(extra.challenge, { skip_node: 0, challenge_peak_group_id: 0, challenge_peak_group_id_list: [] }); + assert.equal(extra.lua, null); + assert.equal(extra.theory_craft, undefined); +}); + +test('buildSyncPayload emits the FreeSR shape FireflyGo expects', async () => { + const { buildSyncPayload } = await load(); + const payload = buildSyncPayload(sampleFreesr()); + + assert.deepEqual(Object.keys(payload).sort(), ['avatars', 'battle_config', 'lightcones', 'loadout', 'relics']); + assert.equal(payload.extra_data, undefined); + + const avatar = payload.avatars['1506']; + assert.equal(avatar.avatar_id, 1506); + assert.equal(avatar.level, 80); + assert.equal(avatar.sp_max, 140); + assert.deepEqual(avatar.techniques, [1, 2]); + // enhanced_id 0 must not be sent. + assert.equal('enhanced_id' in avatar, false); + // Zero ex-ranks are filtered out. + assert.deepEqual(avatar.data.ex_rank_id_list, [5501]); + assert.deepEqual(avatar.data.skills_by_anchor_type, { 1: 6, 2: 10 }); + + // Gear without an item id is dropped. + assert.equal(payload.lightcones.length, 1); + assert.equal(payload.lightcones[0].item_id, 23001); + + assert.equal(payload.relics.length, 1); + assert.deepEqual(payload.relics[0].sub_affixes, [{ sub_affix_id: 2, count: 3, step: 0 }]); + + // Unknown battle_config keys such as custom_battle_lineup are stripped. + assert.deepEqual(payload.battle_config, { + battle_type: 'MOC', + blessings: [1, 2], + custom_stats: [], + cycle_count: 3, + stage_id: 12, + path_resonance_id: 0, + monsters: [[1002010, 1]] + }); +}); + +test('buildSyncPayload derives the anchor map when the save has none', async () => { + const { buildSyncPayload } = await load(); + const payload = buildSyncPayload({ + avatars: { + 1001: { + avatar_id: 1001, + data: { rank: 6, skills: { 1001001: 6, 1001002: 10, 1001103: 1, 1001201: 1, 1001202: 1 } } + } + }, + lightcones: [], + relics: [] + }); + assert.deepEqual(payload.avatars['1001'].data.skills_by_anchor_type, { + 1: 6, 2: 10, 9: 1, 8: 1 + }); +}); + +test('buildSyncPayload survives empty or malformed documents', async () => { + const { buildSyncPayload } = await load(); + const empty = buildSyncPayload(null); + assert.deepEqual(empty.avatars, {}); + assert.deepEqual(empty.lightcones, []); + assert.deepEqual(empty.relics, []); + assert.equal(empty.battle_config.battle_type, 'DEFAULT'); + + const malformed = buildSyncPayload({ avatars: { 1: null, 2: { avatar_id: 0 } } }); + assert.deepEqual(malformed.avatars, {}); +}); diff --git a/tests/mode-capabilities.test.cjs b/tests/mode-capabilities.test.cjs new file mode 100644 index 0000000..cddaf1a --- /dev/null +++ b/tests/mode-capabilities.test.cjs @@ -0,0 +1,110 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const modesUrl = pathToFileURL(path.join(__dirname, '../js/modes.js')); + +async function load() { + return import(modesUrl.href); +} + +test('GaCy GO keeps every tool feature', async () => { + const { modeCapabilities } = await load(); + const caps = modeCapabilities('gac'); + for (const feature of [ + 'saveManager', 'exranks', 'inventory', 'monsterSettings', 'damageAnalyst', + 'sceneCard', 'gmConsole', 'battleSummary', 'toolSession' + ]) { + assert.equal(caps[feature], true, `${feature} must be available on GaCy GO`); + } + assert.equal(caps.extraData, false); + assert.equal(caps.ownedAvatarsOnly, false); + assert.deepEqual(caps.jsonModes, ['freesr', 'persistent']); +}); + +test('FireflyGo hides every GaCy-only feature and exposes extra_data', async () => { + const { modeCapabilities, modeSupports } = await load(); + const caps = modeCapabilities('firefly'); + for (const feature of [ + 'saveManager', 'exranks', 'inventory', 'monsterSettings', 'damageAnalyst', + 'sceneCard', 'gmConsole', 'battleSummary', 'toolSession' + ]) { + assert.equal(caps[feature], false, `${feature} must be hidden on FireflyGo`); + } + assert.equal(caps.extraData, true); + assert.equal(caps.ownedAvatarsOnly, true); + assert.deepEqual(caps.jsonModes, ['freesr']); + assert.equal(modeSupports('extraData', 'firefly'), true); + assert.equal(modeSupports('saveManager', 'firefly'), false); +}); + +test('unknown modes fall back to GaCy GO', async () => { + const { normalizeMode, modeCapabilities, modeSupports, MODE_LABELS } = await load(); + assert.equal(normalizeMode('nope'), 'gac'); + assert.equal(normalizeMode(undefined), 'gac'); + assert.equal(normalizeMode('firefly'), 'firefly'); + assert.deepEqual(modeCapabilities('legacy'), modeCapabilities('gac')); + assert.equal(modeSupports('inventory', 'legacy'), true); + assert.equal(MODE_LABELS.firefly, 'FireflyGo'); +}); + +test('mode UI strings exist in Thai and English', async () => { + const { TRANSLATIONS } = await import('../js/i18n.js'); + const keys = [ + 'serverModeFirefly', + 'apiUrlHintGaC', 'apiUrlHintFirefly', 'apiUrlHintRobin', + 'enkaImportTitle', 'enkaImportSubtitle', 'enkaUidLabel', 'enkaUidPlaceholder', + 'enkaUidInvalid', 'enkaModeMerge', 'enkaModeReplace', 'btnEnkaFetch', + 'enkaPasteLabel', 'enkaPastePlaceholder', 'btnEnkaParsePaste', + 'enkaFetching', 'enkaImportDone', 'enkaUidNotFound', 'enkaRateLimited', + 'enkaFetchFailed', 'enkaFetchFailedDetail', 'enkaNoAvatars', 'enkaPasteEmpty', + 'enkaPasteInvalid', 'enkaProxyUnavailable', 'enkaSyncFailed', + 'fireflyExtrasTitle', 'fireflyExtrasBadge', 'fireflyExtrasSubtitle', + 'fireflyMultiPathTitle', 'fireflyMultiPathMain', 'fireflyMultiPathMarch7', + 'fireflyMultiPathMainList', 'fireflyMultiPathMarch7List', + 'fireflySettingTitle', 'fireflySettingCensorship', 'fireflySettingCm', + 'fireflySettingFirstPerson', 'fireflySettingHideUi', 'fireflySettingInvasion', + 'fireflyChallengeTitle', 'fireflyChallengeSkipNode', 'fireflyChallengeGroupId', + 'fireflyChallengeGroupList', 'fireflyTheoryTitle', 'fireflyTheoryMode', + 'fireflyTheoryCycleCount', 'fireflyTheoryStageId', 'fireflyTheoryLineup', + 'fireflyTheoryHp', 'fireflyLuaLabel', 'fireflyLuaPlaceholder', 'fireflyListPlaceholder', + 'btnFireflyProbe', 'btnFireflySaveExtra', 'btnFireflySync', + 'fireflyProbing', 'fireflySyncing', 'fireflyProbeSuccess', 'fireflySyncSuccess', + 'fireflyRequestFailed', 'fireflyRequestFailedDetail', 'fireflyTimeout', + 'fireflyNoData', 'fireflyExtraSaved', 'fireflyHpInvalid' + ]; + for (const lang of ['th', 'en']) { + for (const key of keys) { + const value = TRANSLATIONS[lang]?.[key]; + assert.equal(typeof value, 'string', `${lang}.${key} must be a string`); + assert.ok(value.trim().length > 0, `${lang}.${key} must not be empty`); + assert.notEqual(value, key, `${lang}.${key} must be translated`); + } + } +}); + +test('the removed paused notice is no longer referenced', () => { + const app = fs.readFileSync(path.join(__dirname, '../js/app.js'), 'utf8'); + assert.equal(app.includes('serverModesPausedNotice'), false); +}); + +test('mode-gated DOM hooks exist in index.html', () => { + const html = fs.readFileSync(path.join(__dirname, '../index.html'), 'utf8'); + for (const id of [ + 'tab-btn-inventory', 'tab-btn-monster-settings', 'tab-btn-persistent', 'tab-btn-exrank', + 'card-lineup', 'card-server-status', 'card-firefly-extras', 'api-url-mode-hint', + 'enka-uid-input', 'enka-import-mode', 'btn-enka-fetch', 'enka-import-status', + 'enka-paste-input', 'btn-enka-paste', + 'ff-multi-path-main', 'ff-multi-path-march7', 'ff-multi-path-main-list', + 'ff-multi-path-march7-list', 'ff-setting-censorship', 'ff-setting-cm', + 'ff-setting-first-person', 'ff-setting-hide-ui', 'ff-setting-invasion-percent', + 'ff-challenge-skip-node', 'ff-challenge-peak-group', 'ff-challenge-peak-list', + 'ff-theory-mode', 'ff-theory-cycle-count', 'ff-theory-stage-id', 'ff-theory-lineup', + 'ff-theory-hp', 'ff-lua', 'btn-firefly-probe', 'btn-firefly-save-extra', + 'btn-firefly-sync', 'firefly-extra-status' + ]) { + assert.ok(html.includes(`id="${id}"`), `index.html must contain #${id}`); + } +}); diff --git a/vercel.json b/vercel.json index 828259f..7c8e5e8 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,10 @@ { "buildCommand": "node scripts/build-vercel.cjs", - "outputDirectory": "dist" + "outputDirectory": "dist", + "rewrites": [ + { + "source": "/api/enka/:uid", + "destination": "https://enka.network/api/hsr/uid/:uid" + } + ] }