import { COMMANDS, usage, inspectCommand, completeCommand, setCommandLanguage, describeCommand } from './command-assistant.mjs';
import { AVATAR_METADATA, getAvatarInfo } from './avatars-data.js?v=2.21';
import { LIGHTCONE_METADATA, RELIC_SET_METADATA, getLightconeInfo, getRelicSetInfo } from './equipment-data.js?v=2.27';
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.21';
import {
getAvatarIconUrl,
getLightconeArtUrl,
getLightconeThumbUrl,
handleAvatarImgError,
handleAvatarPortraitError,
handleLcImgError,
GACHABASE_IMAGE_CACHE,
KNOWN_GACHABASE_ASSETS
} from './gachabase.js';
// Expose image resolvers & error handlers to window for inline onclick/onerror attributes
window.handleAvatarImgError = handleAvatarImgError;
window.handleAvatarPortraitError = handleAvatarPortraitError;
window.handleLcImgError = handleLcImgError;
window.getAvatarIconUrl = getAvatarIconUrl;
window.getLightconeArtUrl = getLightconeArtUrl;
window.getLightconeThumbUrl = getLightconeThumbUrl;
window.GACHABASE_IMAGE_CACHE = GACHABASE_IMAGE_CACHE;
window.KNOWN_GACHABASE_ASSETS = KNOWN_GACHABASE_ASSETS;
// Application State
const state = {
serverMode: localStorage.getItem('gac_srtool_mode') || 'gac', // 'gac' | 'firefly' | 'robin'
apiUrl: localStorage.getItem('gac_srtool_api_url') || 'http://localhost:21000',
pingIntervalSec: 10,
isConnected: false,
statusData: null,
avatarsList: [],
fullFreesrData: null,
persistentData: null,
dataGameData: null,
currentJsonMode: 'freesr',
activeTab: 'tab-dashboard',
selectedLineupSlot: null,
selectedAvatarDetail: null,
creativeMode: localStorage.getItem('gac_srtool_creative_mode') === 'true' || localStorage.getItem('gac_srtool_default_creative') === 'true',
defaultEnergy: parseInt(localStorage.getItem('gac_srtool_default_energy') || '50', 10),
autoSync: localStorage.getItem('gac_srtool_auto_sync') !== 'false',
defaultCreative: localStorage.getItem('gac_srtool_default_creative') === 'true',
showcaseRolls: localStorage.getItem('gac_srtool_showcase_rolls') !== 'false',
showcaseAura: localStorage.getItem('gac_srtool_showcase_aura') !== 'false',
eqActiveSubtab: 'lc',
inventoryItems: [],
inventorySaved: {},
inventoryDraft: {},
inventoryPage: 0,
monsterSettings: { activeMode: 'moc', catalog: null, groupId: '', stageId: '', waves: [], savedFingerprint: '', ready: false, canEdit: false, aaRoundSelection: null, aaRoundSelectionLoaded: false, aaRoundSelectionBusy: false, aaRoundSelectionError: '', aaRoundSelectionErrorKey: '' },
};
// UI Elements Map
const el = {
serverModeSelect: document.getElementById('server-mode-select'),
settingServerMode: document.getElementById('setting-server-mode'),
lblActiveMode: document.getElementById('lbl-active-mode'),
langSelect: document.getElementById('lang-select'),
statusDot: document.getElementById('status-dot'),
connectionStatusText: document.getElementById('connection-status-text'),
btnQuickPing: document.getElementById('btn-quick-ping'),
btnOpenSettings: document.getElementById('btn-open-settings'),
modalSettings: document.getElementById('modal-settings'),
btnCloseModal: document.getElementById('btn-close-modal'),
btnModalCancel: document.getElementById('btn-modal-cancel'),
btnModalSave: document.getElementById('btn-modal-save'),
modalApiUrl: document.getElementById('modal-api-url'),
// Dashboard
statAvatars: document.getElementById('stat-avatars'),
statLightcones: document.getElementById('stat-lightcones'),
statRelics: document.getElementById('stat-relics'),
lblApiUrl: document.getElementById('lbl-api-url'),
lblPlaneId: document.getElementById('lbl-plane-id'),
lblFloorId: document.getElementById('lbl-floor-id'),
lblCoordinates: document.getElementById('lbl-coordinates'),
lineupContainer: document.getElementById('lineup-container'),
toggleCreativeMode: document.getElementById('toggle-creative-mode'),
badgeCreativeMode: document.getElementById('badge-creative-mode'),
// Mini Battle Summary & Team Stats
cardBattleSummary: document.getElementById('card-battle-summary'),
badgeMiniBattleStatus: document.getElementById('badge-mini-battle-status'),
miniTotalDmg: document.getElementById('mini-total-dmg'),
miniTeamDps: document.getElementById('mini-team-dps'),
miniBreakDmg: document.getElementById('mini-break-dmg'),
miniBattleTurns: document.getElementById('mini-battle-turns'),
miniBattleAvatarsList: document.getElementById('mini-battle-avatars-list'),
btnMiniViewFullAnalyst: document.getElementById('btn-mini-view-full-analyst'),
synergyAvgLevel: document.getElementById('synergy-avg-level'),
synergyAvgEidolon: document.getElementById('synergy-avg-eidolon'),
synergyLcReady: document.getElementById('synergy-lc-ready'),
synergyRelicReady: document.getElementById('synergy-relic-ready'),
// Damage Analyst
tabDamageAnalyst: document.getElementById('tab-damage-analyst'),
tabBtnDamageAnalyst: document.getElementById('tab-btn-damage-analyst'),
badgeAnalystStatus: document.getElementById('badge-analyst-status'),
chkAnalystAutosync: document.getElementById('chk-analyst-autosync'),
btnAnalystFetch: document.getElementById('btn-analyst-fetch'),
inputAnalystFile: document.getElementById('input-analyst-file'),
btnAnalystImport: document.getElementById('btn-analyst-import'),
btnAnalystExport: document.getElementById('btn-analyst-export'),
btnAnalystClear: document.getElementById('btn-analyst-clear'),
analystTeamDamage: document.getElementById('analyst-team-damage'),
analystDamageAv: document.getElementById('analyst-damage-av'),
analystCycles: document.getElementById('analyst-cycles'),
analystCyclesNote: document.getElementById('analyst-cycles-note'),
analystTotalTurns: document.getElementById('analyst-total-turns'),
analystAutoTurns: document.getElementById('analyst-auto-turns'),
analystDuration: document.getElementById('analyst-duration'),
analystBattleMeta: document.getElementById('analyst-battle-meta'),
analystShareBarContainer: document.getElementById('analyst-share-bar-container'),
analystTotalShareLabel: document.getElementById('analyst-total-share-label'),
analystShareLegend: document.getElementById('analyst-share-legend'),
analystCharactersContainer: document.getElementById('analyst-characters-container'),
analystEnemiesContainer: document.getElementById('analyst-enemies-container'),
analystEnemiesCard: document.getElementById('analyst-enemies-card'),
// GM Command Dispatcher & Live Log Console
cardGmConsole: document.getElementById('card-gm-console'),
btnConsoleCopyLog: document.getElementById('btn-console-copy-log'),
btnConsoleClearLog: document.getElementById('btn-console-clear-log'),
consoleTerminalLog: document.getElementById('console-terminal-log'),
formConsoleCmd: document.getElementById('form-console-cmd'),
consoleCmdInput: document.getElementById('console-cmd-input'),
consoleSuggestions: document.getElementById('console-command-suggestions'),
consoleSuggestionCount: document.getElementById('console-suggestion-count'),
consoleSuggestionOptions: document.getElementById('console-suggestion-options'),
btnConsoleCmdSend: document.getElementById('btn-console-cmd-send'),
lblConsoleConnStatus: document.getElementById('lbl-console-conn-status'),
// Avatars
avatarsGrid: document.getElementById('avatars-grid'),
avatarSearch: document.getElementById('avatar-search'),
avatarFilterRarity: document.getElementById('avatar-filter-rarity'),
avatarFilterElement: document.getElementById('avatar-filter-element'),
avatarFilterPath: document.getElementById('avatar-filter-path'),
btnMaxAllAvatars: document.getElementById('btn-max-all-avatars'),
btnReloadAvatars: document.getElementById('btn-reload-avatars'),
// Equipment
lightconesGrid: document.getElementById('lightcones-grid'),
relicsGrid: document.getElementById('relics-grid'),
badgeTotalLc: document.getElementById('badge-total-lc'),
badgeTotalRelics: document.getElementById('badge-total-relics'),
eqSearch: document.getElementById('eq-search'),
eqFilterRarity: document.getElementById('eq-filter-rarity'),
eqFilterEquipped: document.getElementById('eq-filter-equipped'),
eqFilterExtra: document.getElementById('eq-filter-extra'),
btnEqSubtabLc: document.getElementById('btn-eq-subtab-lc'),
btnEqSubtabRelic: document.getElementById('btn-eq-subtab-relic'),
eqInspectorCard: document.getElementById('eq-inspector-card'),
btnOpenAddLc: document.getElementById('btn-open-add-lc'),
modalAddLc: document.getElementById('modal-add-lc'),
btnCloseAddLcModal: document.getElementById('btn-close-add-lc-modal'),
btnCancelAddLc: document.getElementById('btn-cancel-add-lc'),
formAddLc: document.getElementById('form-add-lc'),
addLcSelect: document.getElementById('add-lc-select'),
addLcSearch: document.getElementById('add-lc-search'),
addLcFilterPath: document.getElementById('add-lc-filter-path'),
addLcFilterRarity: document.getElementById('add-lc-filter-rarity'),
addLcVisualGrid: document.getElementById('add-lc-visual-grid'),
addLcSelectedId: document.getElementById('add-lc-selected-id'),
addLcPreviewThumb: document.getElementById('add-lc-preview-thumb'),
addLcPreviewName: document.getElementById('add-lc-preview-name'),
addLcPreviewMeta: document.getElementById('add-lc-preview-meta'),
addLcLevel: document.getElementById('add-lc-level'),
addLcPromotion: document.getElementById('add-lc-promotion'),
addLcRank: document.getElementById('add-lc-rank'),
addLcAvatar: document.getElementById('add-lc-avatar'),
addLcAvatarBtn: document.getElementById('add-lc-avatar-btn'),
btnOpenAddRelic: document.getElementById('btn-open-add-relic'),
modalAddRelic: document.getElementById('modal-add-relic'),
btnCloseAddRelicModal: document.getElementById('btn-close-add-relic-modal'),
btnCancelAddRelic: document.getElementById('btn-cancel-add-relic'),
formAddRelic: document.getElementById('form-add-relic'),
addRelicSet: document.getElementById('add-relic-set'),
addRelicSlot: document.getElementById('add-relic-slot'),
addRelicLevel: document.getElementById('add-relic-level'),
addRelicMainAffix: document.getElementById('add-relic-main-affix'),
addRelicAvatar: document.getElementById('add-relic-avatar'),
addRelicAvatarBtn: document.getElementById('add-relic-avatar-btn'),
// Persistent Tab
tabBtnPersistent: document.getElementById('tab-btn-persistent'),
cardPersistentChars: document.getElementById('card-persistent-chars'),
cardPersistentGlobal: document.getElementById('card-persistent-global'),
formPersistentChars: document.getElementById('form-persistent-chars'),
persistentMcSelect: document.getElementById('persistent-mc-select'),
persistentMarchSelect: document.getElementById('persistent-march-select'),
toggleSwGlobal: document.getElementById('toggle-sw-global'),
toggleCastoriceGlobal: document.getElementById('toggle-castorice-global'),
toggleAhaGlobal: document.getElementById('toggle-aha-global'),
btnSavePersistentToggles: document.getElementById('btn-save-persistent-toggles'),
// Material Inventory
inventorySearch: document.getElementById('inventory-search'),
inventoryBody: document.getElementById('inventory-materials-body'),
inventoryCount: document.getElementById('inventory-count'),
inventoryDirtyCount: document.getElementById('inventory-dirty-count'),
inventoryStatus: document.getElementById('inventory-status'),
inventoryPageLabel: document.getElementById('inventory-page-label'),
btnInventorySave: document.getElementById('btn-inventory-save'),
btnInventoryDiscard: document.getElementById('btn-inventory-discard'),
btnInventoryPrev: document.getElementById('btn-inventory-prev'),
btnInventoryNext: document.getElementById('btn-inventory-next'),
// Ex-Rank
tabBtnExrank: document.getElementById('tab-btn-exrank'),
exrankList: document.getElementById('exrank-list'),
btnExrankEnableAll: document.getElementById('btn-exrank-enable-all'),
btnExrankDisableAll: document.getElementById('btn-exrank-disable-all'),
// JSON Tab
btnSelectFreesr: document.getElementById('btn-select-freesr'),
btnSelectPersistent: document.getElementById('btn-select-persistent'),
btnSelectDatagame: document.getElementById('btn-select-datagame'),
btnJsonBeautify: document.getElementById('btn-json-beautify'),
btnJsonDownload: document.getElementById('btn-json-download'),
btnJsonSave: document.getElementById('btn-json-save'),
jsonFileInput: document.getElementById('json-file-input'),
jsonEditor: document.getElementById('json-editor'),
jsonStatusInfo: document.getElementById('json-status-info'),
// Settings
formSettings: document.getElementById('form-settings'),
settingApiUrl: document.getElementById('setting-api-url'),
btnTestConnection: document.getElementById('btn-test-connection'),
pingLatencyBadge: document.getElementById('ping-latency-badge'),
settingAutoSync: document.getElementById('setting-auto-sync'),
settingDefaultCreative: document.getElementById('setting-default-creative'),
settingShowcaseRolls: document.getElementById('setting-showcase-rolls'),
settingShowcaseAura: document.getElementById('setting-showcase-aura'),
lblCacheUsage: document.getElementById('lbl-cache-usage'),
btnClearBrowserCache: document.getElementById('btn-clear-browser-cache'),
btnResetSettings: document.getElementById('btn-reset-settings'),
// Character Detail Modal
modalAvatarDetail: document.getElementById('modal-avatar-detail'),
btnCloseAvatarModal: document.getElementById('btn-close-avatar-modal'),
modalCharTitle: document.getElementById('modal-char-title'),
modalCharSwitcher: document.getElementById('modal-char-switcher'),
modalCharImg: document.getElementById('modal-char-img'),
modalCharName: document.getElementById('modal-char-name'),
modalCharMeta: document.getElementById('modal-char-meta'),
modalCharLevel: document.getElementById('modal-char-level'),
modalCharPromotion: document.getElementById('modal-char-promotion'),
modalCharEidolonBtns: document.getElementById('modal-char-eidolon-btns'),
btnModalQuickE0: document.getElementById('btn-modal-quick-e0'),
btnModalQuickE6: document.getElementById('btn-modal-quick-e6'),
btnModalMaxSkills: document.getElementById('btn-modal-max-skills'),
btnModalSaveAvatar: document.getElementById('btn-modal-save-avatar'),
btnModalE6S5: document.getElementById('btn-modal-e6s5'),
btnModalE0S1: document.getElementById('btn-modal-e0s1'),
// Overview Tab in Modal (Neonteam style)
overviewPortraitImg: document.getElementById('overview-portrait-img'),
overviewCharName: document.getElementById('overview-char-name'),
overviewCharMeta: document.getElementById('overview-char-meta'),
overviewBadgeLvl: document.getElementById('overview-badge-lvl'),
overviewBadgeEidolon: document.getElementById('overview-badge-eidolon'),
overviewStatsGrid: document.getElementById('overview-stats-grid'),
overviewLcCard: document.getElementById('overview-lc-card'),
overviewLcThumb: document.getElementById('overview-lc-thumb'),
overviewLcName: document.getElementById('overview-lc-name'),
overviewLcRank: document.getElementById('overview-lc-rank'),
overviewRelicsCard: document.getElementById('overview-relics-card'),
overviewRelicsSummary: document.getElementById('overview-relics-summary'),
// Relics Tab in Modal
modalRelicsSlotsGrid: document.getElementById('modal-relics-slots-grid'),
modalRelicsActiveSets: document.getElementById('modal-relics-active-sets'),
btnModalRelicsAuto: document.getElementById('btn-modal-relics-auto'),
btnModalRelicsClear: document.getElementById('btn-modal-relics-clear'),
modalSelectRelicSlot: document.getElementById('modal-select-relic-slot'),
modalSlotPickerTitle: document.getElementById('modal-slot-picker-title'),
btnCloseSlotPicker: document.getElementById('btn-close-slot-picker'),
btnSlotPickerQuickGen: document.getElementById('btn-slot-picker-quick-gen'),
slotPickerInventoryList: document.getElementById('slot-picker-inventory-list'),
// Skills Info Steppers & Rich Descs (Inspired by srtools.punklorde.org/skills-info)
valSkillBasic: document.getElementById('val-skill-basic'),
rangeSkillBasic: document.getElementById('range-skill-basic'),
btnStepDecBasic: document.getElementById('btn-step-dec-basic'),
btnStepIncBasic: document.getElementById('btn-step-inc-basic'),
btnSkillMaxBasic: document.getElementById('btn-skill-max-basic'),
skillIconBasic: document.getElementById('skill-icon-basic'),
skillRealNameBasic: document.getElementById('skill-real-name-basic'),
skillDescBasic: document.getElementById('skill-desc-basic'),
valSkillSkill: document.getElementById('val-skill-skill'),
rangeSkillSkill: document.getElementById('range-skill-skill'),
btnStepDecSkill: document.getElementById('btn-step-dec-skill'),
btnStepIncSkill: document.getElementById('btn-step-inc-skill'),
btnSkillMaxSkill: document.getElementById('btn-skill-max-skill'),
skillIconSkill: document.getElementById('skill-icon-skill'),
skillRealNameSkill: document.getElementById('skill-real-name-skill'),
skillDescSkill: document.getElementById('skill-desc-skill'),
valSkillUltra: document.getElementById('val-skill-ultra'),
rangeSkillUltra: document.getElementById('range-skill-ultra'),
btnStepDecUltra: document.getElementById('btn-step-dec-ultra'),
btnStepIncUltra: document.getElementById('btn-step-inc-ultra'),
btnSkillMaxUltra: document.getElementById('btn-skill-max-ultra'),
skillIconUltra: document.getElementById('skill-icon-ultra'),
skillRealNameUltra: document.getElementById('skill-real-name-ultra'),
skillDescUltra: document.getElementById('skill-desc-ultra'),
valSkillTalent: document.getElementById('val-skill-talent'),
rangeSkillTalent: document.getElementById('range-skill-talent'),
btnStepDecTalent: document.getElementById('btn-step-dec-talent'),
btnStepIncTalent: document.getElementById('btn-step-inc-talent'),
btnSkillMaxTalent: document.getElementById('btn-skill-max-talent'),
skillIconTalent: document.getElementById('skill-icon-talent'),
skillRealNameTalent: document.getElementById('skill-real-name-talent'),
skillDescTalent: document.getElementById('skill-desc-talent'),
checkTraceTechnique: document.getElementById('check-trace-technique'),
skillIconTechnique: document.getElementById('skill-icon-technique'),
skillRealNameTechnique: document.getElementById('skill-real-name-technique'),
skillDescTechnique: document.getElementById('skill-desc-technique'),
checkTraceA2: document.getElementById('check-trace-a2'),
traceIconA2: document.getElementById('trace-icon-a2'),
traceNameA2: document.getElementById('trace-name-a2'),
traceDescA2: document.getElementById('trace-desc-a2'),
checkTraceA4: document.getElementById('check-trace-a4'),
traceIconA4: document.getElementById('trace-icon-a4'),
traceNameA4: document.getElementById('trace-name-a4'),
traceDescA4: document.getElementById('trace-desc-a4'),
checkTraceA6: document.getElementById('check-trace-a6'),
traceIconA6: document.getElementById('trace-icon-a6'),
traceNameA6: document.getElementById('trace-name-a6'),
traceDescA6: document.getElementById('trace-desc-a6'),
checkTraceStats: document.getElementById('check-trace-stats'),
// Light Cone View in Modal
modalLcArtWrapper: document.getElementById('modal-lc-art-wrapper'),
modalLcFullImg: document.getElementById('modal-lc-full-img'),
modalLcNoArt: document.getElementById('modal-lc-no-art'),
modalLcNameDisplay: document.getElementById('modal-lc-name-display'),
modalLcMetaDisplay: document.getElementById('modal-lc-meta-display'),
modalLcRankBadge: document.getElementById('modal-lc-rank-badge'),
rangeLcLevel: document.getElementById('range-lc-level'),
modalLcLvlVal: document.getElementById('modal-lc-lvl-val'),
modalLcLevel: document.getElementById('modal-lc-level'),
modalLcRankBtns: document.getElementById('modal-lc-rank-btns'),
modalLcPassiveDesc: document.getElementById('modal-lc-passive-desc'),
selectModalChangeLc: document.getElementById('select-modal-change-lc'),
btnModalApplyChangeLc: document.getElementById('btn-modal-apply-change-lc'),
modalLcId: document.getElementById('modal-lc-id'),
// Showcase View in Modal
showcaseCardContainer: document.getElementById('showcase-card-container'),
btnShowcaseCopyText: document.getElementById('btn-showcase-copy-text'),
btnShowcaseExportImg: document.getElementById('btn-showcase-export-img'),
btnShowcaseAutoRelics: document.getElementById('btn-showcase-auto-relics'),
// Lineup Picker Modal
modalLineupPicker: document.getElementById('modal-lineup-picker'),
btnClosePickerModal: document.getElementById('btn-close-picker-modal'),
modalPickerTitle: document.getElementById('modal-picker-title'),
pickerSearch: document.getElementById('picker-search'),
pickerAvatarsGrid: document.getElementById('picker-avatars-grid'),
// Avatar Assignment Picker (Add Relic / Add Light Cone)
modalAvatarAssign: document.getElementById('modal-avatar-assign'),
modalAssignTitle: document.getElementById('modal-assign-title'),
modalAssignSubtitle: document.getElementById('modal-assign-subtitle'),
assignPickerSearch: document.getElementById('assign-picker-search'),
assignPickerGrid: document.getElementById('assign-picker-grid'),
assignPickerCurrentValue: document.getElementById('assign-picker-current-value'),
btnCloseAssignPicker: document.getElementById('btn-close-assign-picker'),
btnCancelAssignPicker: document.getElementById('btn-cancel-assign-picker'),
btnConfirmAssignPicker: document.getElementById('btn-confirm-assign-picker'),
toastContainer: document.getElementById('toast-container')
};
// Toast notification helper
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = {
info: 'ℹ️',
success: '✅',
warning: '⚠️',
error: '❌'
};
toast.innerHTML = `
${icons[type] || 'ℹ️'}
${message}
`;
el.toastContainer.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
// Low-level API caller
async function apiCall(endpoint, method = 'GET', data = null) {
const cleanBase = state.apiUrl.replace(/\/+$/, '');
const url = `${cleanBase}${endpoint}`;
const options = {
method,
cache: method === 'GET' ? 'no-store' : 'default',
headers: {
'Content-Type': 'application/json',
'X-GaC-Srtool': '1'
}
};
if (data && (method === 'POST' || method === 'PUT')) {
options.body = JSON.stringify(data);
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000);
options.signal = controller.signal;
const res = await fetch(url, options);
clearTimeout(timeoutId);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
} catch (err) {
if (err.name === 'AbortError') {
throw new Error('Connection timed out');
}
throw err;
}
}
// Explicit, expiring custom-battle connection. The backend returns its TTL.
let toolSessionTimer = null;
let toolSessionConnected = false;
let toolSessionGeneration = 0;
async function syncToolSession(connected) {
const generation = ++toolSessionGeneration;
if (toolSessionTimer) { clearTimeout(toolSessionTimer); toolSessionTimer = null; }
toolSessionConnected = connected;
try {
const result = await apiCall('/api/tool-session', 'POST', { connected, client: 'GaC-Srtool' });
if (generation === toolSessionGeneration && connected && toolSessionConnected && Number(result.lease_seconds) > 0) {
toolSessionTimer = setTimeout(() => syncToolSession(true), Number(result.lease_seconds) * 1000 / 3);
}
} catch (error) {
if (generation === toolSessionGeneration) toolSessionConnected = false;
// Older backends remain usable, but cannot enable the custom encounter lease.
console.warn('Tool session unavailable:', error.message);
}
}
window.addEventListener('pagehide', () => {
if (!toolSessionConnected) return;
const base = state.apiUrl.replace(/\/+$/, '');
navigator.sendBeacon(`${base}/api/tool-session`, JSON.stringify({ connected: false, client: 'GaC-Srtool' }));
});
// Update connection status UI
function setConnectionStatus(connected, extraText = '') {
const connectionChanged = state.isConnected !== connected;
if (connected !== toolSessionConnected) syncToolSession(connected);
state.isConnected = connected;
if (connectionChanged && state.activeTab === 'tab-monster-settings') loadMonsterSettings();
updateCameraSaveStatus();
if (connected) {
el.statusDot.classList.add('connected');
el.connectionStatusText.textContent = extraText || t('online');
el.connectionStatusText.style.color = 'var(--accent-green)';
if (el.lblConsoleConnStatus) {
el.lblConsoleConnStatus.textContent = '● Server Online';
el.lblConsoleConnStatus.style.color = 'var(--accent-green)';
}
} else {
el.statusDot.classList.remove('connected');
el.connectionStatusText.textContent = extraText || t('offline');
el.connectionStatusText.style.color = 'var(--accent-red)';
if (el.lblConsoleConnStatus) {
el.lblConsoleConnStatus.textContent = '○ Offline (Local Mode)';
el.lblConsoleConnStatus.style.color = 'var(--text-muted)';
}
}
}
// Helper to get element CSS class
// Canonical element & path maps (handles English and all 13 supported languages)
const CANONICAL_ELEMENTS = {
physical: 'Physical', fire: 'Fire', ice: 'Ice', lightning: 'Lightning',
wind: 'Wind', quantum: 'Quantum', imaginary: 'Imaginary', thunder: 'Lightning'
};
const CANONICAL_PATHS = {
destruction: 'Destruction', hunt: 'Hunt', 'the hunt': 'Hunt', erudition: 'Erudition',
harmony: 'Harmony', nihility: 'Nihility', preservation: 'Preservation',
abundance: 'Abundance', remembrance: 'Remembrance', elation: 'Elation'
};
// Dynamically index all translated element and path strings across languages
for (const langKey in TRANSLATIONS) {
const tr = TRANSLATIONS[langKey];
if (tr.elements) {
for (const [enKey, locVal] of Object.entries(tr.elements)) {
if (locVal) CANONICAL_ELEMENTS[String(locVal).trim().toLowerCase()] = enKey;
}
}
if (tr.paths) {
for (const [enKey, locVal] of Object.entries(tr.paths)) {
if (locVal) CANONICAL_PATHS[String(locVal).trim().toLowerCase()] = (enKey === 'The Hunt' ? 'Hunt' : enKey);
}
}
}
function normalizeElement(elem) {
if (!elem) return 'Physical';
return CANONICAL_ELEMENTS[String(elem).trim().toLowerCase()] || elem;
}
function normalizePath(p) {
if (!p) return 'Destruction';
return CANONICAL_PATHS[String(p).trim().toLowerCase()] || (p === 'The Hunt' ? 'Hunt' : p);
}
function getElementClass(elem) {
const canonical = normalizeElement(elem);
const map = {
Fire: 'elem-fire',
Ice: 'elem-ice',
Lightning: 'elem-lightning',
Wind: 'elem-wind',
Physical: 'elem-physical',
Quantum: 'elem-quantum',
Imaginary: 'elem-imaginary'
};
return map[canonical] || 'elem-physical';
}
// Render Element Icon HTML (Official Star Rail Game Icon)
function getElementIconHtml(element, size = 20) {
if (!element) return '';
const canonical = normalizeElement(element);
const elemClass = getElementClass(canonical);
return `
`;
}
// A set without captured affix groups remains browse-only.
let relicClientCapture = null;
const relicCaptureReady = fetch('./assets/relic-client-capture.json')
.then(response => response.ok ? response.json() : null)
.then(data => { relicClientCapture = data; })
.catch(error => console.warn('Client relic capture unavailable', error));
function canCreateRelicSet(setId) {
const meta = RELIC_SET_METADATA[setId];
if (meta?.clientCaptureRequired) {
const rows = relicClientCapture?.relics?.filter(row => Number(row.SetID) === Number(setId)) || [];
return [5, 6].every(slot => rows.some(row => Number(row.Type) === slot &&
relicClientCapture.main_affixes?.some(affix => affix.GroupID === row.MainAffixGroup) &&
relicClientCapture.sub_affixes?.some(affix => affix.GroupID === row.SubAffixGroup)));
}
return meta?.creationAvailable !== false;
}
function isCapturedMainAffixValid(setId, slot, mainAffixId) {
if (!RELIC_SET_METADATA[setId]?.clientCaptureRequired) return true;
const relic = relicClientCapture?.relics?.find(row => Number(row.SetID) === Number(setId) && Number(row.Type) === Number(slot));
return !!relic && relicClientCapture.main_affixes.some(row => row.GroupID === relic.MainAffixGroup && row.AffixID === Number(mainAffixId));
}
function areCapturedSubAffixesValid(setId, slot, mainAffixId, subAffixes) {
if (!RELIC_SET_METADATA[setId]?.clientCaptureRequired) return true;
const relic = relicClientCapture?.relics?.find(row => Number(row.SetID) === Number(setId) && Number(row.Type) === Number(slot));
if (!relic) return false;
const main = relicClientCapture.main_affixes.find(row => row.GroupID === relic.MainAffixGroup && row.AffixID === Number(mainAffixId));
if (!main) return false;
const ids = new Set();
return subAffixes.every(sub => {
const id = Number(sub.sub_affix_id);
const row = relicClientCapture.sub_affixes.find(entry => entry.GroupID === relic.SubAffixGroup && entry.AffixID === id);
if (!row || row.Property === main.Property || ids.has(id)) return false;
ids.add(id);
return true;
});
}
// Official in-game property icons (same set used by the overview tab)
function getRelicImage(setId, slot = 5) {
return RELIC_SET_METADATA[setId]?.setIcon || RELIC_SET_METADATA[setId]?.images?.[slot] || RELIC_SET_METADATA[setId]?.images?.[5] || `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/relic/${setId}.png`;
}
function getLightconeIconSrc(id) {
return LIGHTCONE_METADATA[id]?.images?.thumbnail || `https://static.nanoka.cc/assets/hsr/lightconemediumicon/${id}.webp`;
}
const STAT_PROPERTY_ICON_CDN = 'https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/property';
function getStatIconHtml(iconName, size = 15, alt = '') {
if (!iconName) return '';
return `
`;
}
// Render Path Icon HTML (Official Star Rail Game Icon)
function getPathIconHtml(path, size = 20) {
if (!path) return '';
const canonical = normalizePath(path);
return `
`;
}
function updateEquipmentFilterExtraOptions() {
if (!el.eqFilterExtra) return;
const selected = el.eqFilterExtra.value || 'all';
if (state.eqActiveSubtab === 'relic') {
const slots = [
['head', 1], ['hands', 2], ['body', 3],
['feet', 4], ['sphere', 5], ['rope', 6]
];
el.eqFilterExtra.innerHTML = `${slots.map(([slot, id]) => {
const key = `relicSlot${slot.charAt(0).toUpperCase()}${slot.slice(1)}`;
return ``;
}).join('')}`;
} else {
const paths = ['Destruction', 'Hunt', 'Erudition', 'Harmony', 'Nihility', 'Preservation', 'Abundance', 'Remembrance', 'Elation'];
el.eqFilterExtra.innerHTML = `${paths.map(path => ``).join('')}`;
}
if (Array.from(el.eqFilterExtra.options).some(option => option.value === selected)) {
el.eqFilterExtra.value = selected;
}
}
// Update filter dropdown options to current language
function updateFilterDropdownTranslations() {
if (el.avatarFilterElement) {
const selected = el.avatarFilterElement.value;
const elements = ['Fire', 'Ice', 'Lightning', 'Wind', 'Physical', 'Quantum', 'Imaginary'];
if (el.avatarFilterElement.options[0]) {
el.avatarFilterElement.options[0].textContent = t('filterAllElements') || 'ทุกธาตุ';
}
elements.forEach((elemKey, idx) => {
if (el.avatarFilterElement.options[idx + 1]) {
el.avatarFilterElement.options[idx + 1].textContent = translateElement(elemKey);
}
});
el.avatarFilterElement.value = selected;
}
if (el.avatarFilterPath) {
const selected = el.avatarFilterPath.value;
const paths = ['Destruction', 'Hunt', 'Erudition', 'Harmony', 'Nihility', 'Preservation', 'Abundance', 'Remembrance', 'Elation'];
if (el.avatarFilterPath.options[0]) {
el.avatarFilterPath.options[0].textContent = t('filterAllPaths') || 'ทุกประเภท';
}
paths.forEach((pathKey, idx) => {
if (el.avatarFilterPath.options[idx + 1]) {
el.avatarFilterPath.options[idx + 1].textContent = translatePath(pathKey);
}
});
el.avatarFilterPath.value = selected;
}
const addLcFilter = document.getElementById('add-lc-filter-path');
if (addLcFilter) {
const selected = addLcFilter.value;
const paths = ['Destruction', 'Hunt', 'Erudition', 'Harmony', 'Nihility', 'Preservation', 'Abundance', 'Remembrance', 'Elation'];
if (addLcFilter.options[0]) addLcFilter.options[0].textContent = t('filterAllPaths') || 'ทุกสาย';
paths.forEach((p, idx) => {
if (addLcFilter.options[idx + 1]) addLcFilter.options[idx + 1].textContent = translatePath(p);
});
addLcFilter.value = selected;
}
const lcPickerFilter = document.getElementById('lc-picker-filter-path');
if (lcPickerFilter) {
const selected = lcPickerFilter.value;
const paths = ['Destruction', 'Hunt', 'Erudition', 'Harmony', 'Nihility', 'Preservation', 'Abundance', 'Remembrance'];
if (lcPickerFilter.options[0]) lcPickerFilter.options[0].textContent = t('filterAllPaths') || 'ทุกสาย';
if (lcPickerFilter.options[1]) lcPickerFilter.options[1].textContent = t('filterSamePath') || 'สายเดียวกัน';
paths.forEach((p, idx) => {
if (lcPickerFilter.options[idx + 2]) lcPickerFilter.options[idx + 2].textContent = translatePath(p);
});
lcPickerFilter.value = selected;
}
document.querySelectorAll('[data-superimposition-rank]').forEach((option) => {
option.textContent = t('superimpositionRank', { rank: option.value });
});
document.querySelectorAll('#lc-picker-rank option').forEach((option) => {
option.textContent = t('superimpositionRank', { rank: option.value });
});
document.querySelectorAll('[data-relic-slot]').forEach((option) => {
const slotKey = option.dataset.relicSlot;
const slotLabel = t(`relicSlot${slotKey.charAt(0).toUpperCase()}${slotKey.slice(1)}`);
option.textContent = `${option.value}: ${slotLabel}`;
});
updateEquipmentFilterExtraOptions();
const dict = TRANSLATIONS[getCurrentLanguage()] || TRANSLATIONS.en || TRANSLATIONS.th;
if (el.persistentMcSelect && dict.tbOptions) {
const curVal = el.persistentMcSelect.value;
Array.from(el.persistentMcSelect.options).forEach(opt => {
if (dict.tbOptions[opt.value]) {
opt.textContent = dict.tbOptions[opt.value];
}
});
el.persistentMcSelect.value = curVal;
}
if (el.persistentMarchSelect && dict.marchOptions) {
const curVal = el.persistentMarchSelect.value;
Array.from(el.persistentMarchSelect.options).forEach(opt => {
if (dict.marchOptions[opt.value]) {
opt.textContent = dict.marchOptions[opt.value];
}
});
el.persistentMarchSelect.value = curVal;
}
}
// Robust Dual Storage (IndexedDB + localStorage fallback)
const DB_NAME = 'GaCSrtoolDB';
const DB_STORE = 'cache_store';
function openIDB() {
return new Promise((resolve) => {
if (!window.indexedDB) {
resolve(null);
return;
}
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(DB_STORE)) {
db.createObjectStore(DB_STORE);
}
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = () => resolve(null);
});
}
async function idbSet(key, value) {
try {
const db = await openIDB();
if (db) {
return new Promise((resolve) => {
const tx = db.transaction(DB_STORE, 'readwrite');
tx.objectStore(DB_STORE).put(value, key);
tx.oncomplete = () => resolve(true);
tx.onerror = () => resolve(false);
});
}
} catch (e) {}
return false;
}
async function idbGet(key) {
try {
const db = await openIDB();
if (db) {
return new Promise((resolve) => {
const tx = db.transaction(DB_STORE, 'readonly');
const req = tx.objectStore(DB_STORE).get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
});
}
} catch (e) {}
return null;
}
async function idbDelete(key) {
try {
const db = await openIDB();
if (db) {
return new Promise((resolve) => {
const tx = db.transaction(DB_STORE, 'readwrite');
tx.objectStore(DB_STORE).delete(key);
tx.oncomplete = () => resolve(true);
tx.onerror = () => resolve(false);
});
}
} catch (e) {}
return false;
}
async function idbClear() {
try {
const db = await openIDB();
if (db) {
return new Promise((resolve) => {
const tx = db.transaction(DB_STORE, 'readwrite');
tx.objectStore(DB_STORE).clear();
tx.oncomplete = () => resolve(true);
tx.onerror = () => resolve(false);
});
}
} catch (e) {}
return false;
}
// Storage Cache Handlers for Offline & Upload Persistence
async function saveFreesrCache(data) {
if (!data) return;
try {
// 1. Primary: IndexedDB (handles large multi-MB FreeSR files easily)
await idbSet('gac_srtool_freesr_cache', data);
} catch (e) {
console.warn('Failed to save freesr cache to IndexedDB:', e);
}
// 2. Secondary: localStorage
try {
localStorage.setItem('gac_srtool_freesr_cache', JSON.stringify(data));
} catch (e) {
console.warn('localStorage quota exceeded for freesr cache, safely persisted in IndexedDB:', e);
}
updateCacheUsageDisplay();
}
async function savePersistentCache(data) {
if (!data) return;
try {
await idbSet('gac_srtool_persistent_cache', data);
} catch (e) {
console.warn('Failed to save persistent cache to IndexedDB:', e);
}
try {
localStorage.setItem('gac_srtool_persistent_cache', JSON.stringify(data));
} catch (e) {
console.warn('localStorage quota exceeded for persistent cache, safely persisted in IndexedDB:', e);
}
updateCacheUsageDisplay();
}
// FireflyGo data-in-game.json structure and path mappings (from FFGO dumps)
const DEFAULT_DATA_GAME = {
leader: 0,
lineups: {
"0": 1506,
"1": 1502,
"2": 1511,
"3": 1503
},
position: {
x: 8000,
z: 1550,
y: 715,
rot_y: 131000
},
scene: {
plane_id: 80112,
floor_id: 80112001,
entry_id: 801120102,
map_rotation_group_id: 0
},
player_outfit: [ 1003 ],
char_path: {
main: 8010,
march_7: 1224
},
char_enhanced: {
"1004": 1,
"1005": 0,
"1006": 1,
"1102": 1,
"1205": 1,
"1212": 1,
"1217": 1,
"1306": 1,
"1307": 1,
"1310": 1
}
};
const FIREFLY_MC_MAP = {
'MalePhysical': 8001,
'FemalePhysical': 8002,
'MalePreservation': 8003,
'FemalePreservation': 8004,
'MaleHarmony': 8005,
'FemaleHarmony': 8010,
'MaleRemembrance': 8011,
'FemaleRemembrance': 8012
};
const FIREFLY_MC_REVERSE_MAP = {
8001: 'MalePhysical',
8002: 'FemalePhysical',
8003: 'MalePreservation',
8004: 'FemalePreservation',
8005: 'MaleHarmony',
8006: 'FemaleHarmony',
8010: 'FemaleHarmony',
8011: 'MaleRemembrance',
8012: 'FemaleRemembrance'
};
const FIREFLY_MARCH_MAP = {
'MarchPreservation': 1001,
'MarchHunt': 1224
};
const FIREFLY_MARCH_REVERSE_MAP = {
1001: 'MarchPreservation',
1224: 'MarchHunt'
};
async function saveDataGameCache(data) {
if (!data) return;
try {
await idbSet('gac_srtool_datagame_cache', data);
} catch (e) {
console.warn('Failed to save data-in-game cache to IndexedDB:', e);
}
try {
localStorage.setItem('gac_srtool_datagame_cache', JSON.stringify(data));
} catch (e) {
console.warn('localStorage quota exceeded for data-in-game cache:', e);
}
updateCacheUsageDisplay();
}
async function loadCachedStorageData() {
try {
let cachedFreesr = await idbGet('gac_srtool_freesr_cache');
if (!cachedFreesr) {
const raw = localStorage.getItem('gac_srtool_freesr_cache');
if (raw) cachedFreesr = JSON.parse(raw);
}
if (cachedFreesr) {
state.fullFreesrData = cachedFreesr;
if (cachedFreesr.avatars) {
for (const aid in cachedFreesr.avatars) {
const av = cachedFreesr.avatars[aid];
const found = state.avatarsList.find(a => a.avatar_id === parseInt(aid, 10));
if (found) {
found.level = av.level || 80;
found.promotion = av.promotion || 6;
found.rank = av.data?.rank ?? 6;
}
}
}
if (cachedFreesr.lineups && (!state.statusData || !state.statusData.lineups)) {
state.statusData = state.statusData || {};
state.statusData.lineups = cachedFreesr.lineups;
}
}
} catch (e) {
console.warn('Failed to load cached freesr data:', e);
}
try {
let cachedPersistent = await idbGet('gac_srtool_persistent_cache');
if (!cachedPersistent) {
const raw = localStorage.getItem('gac_srtool_persistent_cache');
if (raw) cachedPersistent = JSON.parse(raw);
}
if (cachedPersistent) {
state.persistentData = cachedPersistent;
if (cachedPersistent.lineups && (!state.statusData || !state.statusData.lineups)) {
state.statusData = state.statusData || {};
state.statusData.lineups = cachedPersistent.lineups;
}
}
} catch (e) {
console.warn('Failed to load cached persistent data:', e);
}
try {
let cachedDataGame = await idbGet('gac_srtool_datagame_cache');
if (!cachedDataGame) {
const raw = localStorage.getItem('gac_srtool_datagame_cache');
if (raw) cachedDataGame = JSON.parse(raw);
}
if (cachedDataGame) {
state.dataGameData = cachedDataGame;
if (state.serverMode === 'firefly' && cachedDataGame.lineups && (!state.statusData || !state.statusData.lineups)) {
state.statusData = state.statusData || {};
state.statusData.lineups = cachedDataGame.lineups;
}
}
} catch (e) {
console.warn('Failed to load cached data-in-game data:', e);
}
}
// Apply multi-language translations across UI
function applyTranslations() {
setCommandLanguage(getCurrentLanguage());
document.querySelectorAll('[data-i18n]').forEach(elem => {
const key = elem.getAttribute('data-i18n');
const val = t(key);
if (val && val !== key) {
const icons = { substatPresetCrit: ['IconCriticalChance', 'IconCriticalDamage'], substatPresetSpeed: ['IconSpeed'], substatPresetBreak: ['IconBreakUp'], substatPresetTank: ['IconMaxHP', 'IconDefence'] }[key];
if (icons) {
elem.textContent = val.replace(/^[🎯⚡🔨🛡️\s]+/u, '');
icons.slice().reverse().forEach(icon => elem.insertAdjacentHTML('afterbegin', getStatIconHtml(icon, 16, '')));
} else {
elem.textContent = val;
const equipmentIcons = {
tabEquipment: './assets/lightcones/22009-icon.webp',
modalAddLcTitle: './assets/lightcones/22009-icon.webp',
eqLcTitle: './assets/lightcones/22009-icon.webp',
overviewEquippedLc: './assets/lightcones/22009-icon.webp',
modalAddRelicTitle: './assets/relic-slots/1.png',
eqRelicTitle: './assets/relic-slots/1.png',
overviewEquippedRelics: './assets/relic-slots/1.png',
activeSetEffects: './assets/relic-slots/1.png'
};
if (equipmentIcons[key]) {
const icon = document.createElement('img');
icon.src = equipmentIcons[key];
icon.alt = '';
icon.className = 'game-inline-icon';
elem.prepend(icon, ' ');
}
}
}
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(elem => {
const key = elem.getAttribute('data-i18n-placeholder');
const val = t(key);
if (val) elem.placeholder = val;
});
document.querySelectorAll('[data-i18n-title]').forEach(elem => {
const key = elem.getAttribute('data-i18n-title');
const val = t(key);
if (val) elem.title = val;
});
document.querySelectorAll('[data-i18n-alt]').forEach(elem => {
const key = elem.getAttribute('data-i18n-alt');
const val = t(key);
if (val) elem.alt = val;
});
document.querySelectorAll('[data-i18n-aria-label]').forEach(elem => {
const key = elem.getAttribute('data-i18n-aria-label');
const val = t(key);
if (val) elem.setAttribute('aria-label', val);
});
// Update dynamic filter dropdown options
updateFilterDropdownTranslations();
// Update connection status text with new language
setConnectionStatus(state.isConnected);
document.getElementById('aha-help-options')?.setAttribute('aria-label', t('ahaHelpTitle'));
// Update server mode badges & labels for current language
setupServerModes();
// Re-render team synergy in new language
renderTeamSynergy(state.statusData?.lineups);
renderLineup(state.statusData?.lineups);
if (state.statusData?.ex_ranks) {
renderExRanks(state.statusData.ex_ranks);
}
if (state.avatarsList.length > 0) {
renderAvatarsGrid();
}
// Keep the equip-target chips and the avatar picker in the active language.
renderEquipTargetDisplay('relic');
renderEquipTargetDisplay('lc');
if (el.modalAvatarAssign?.classList.contains('active')) {
updateAvatarAssignCurrent();
renderAvatarAssignGrid();
}
// Keep the camera panel (mode cards, distance readout, save state) translated.
renderCameraModeOptions();
syncCameraUi();
loadEquipment();
if (analystState.currentReport) {
renderDamageAnalyst();
} else {
renderEmptyDamageAnalyst();
}
updateDashboardBattleBanner();
updateDocumentTitle();
renderGMCommandPreview();
if (state.activeTab === 'tab-monster-settings') {
renderMonsterSettings();
if (state.monsterSettings.activeMode === 'aha') loadAhaHelpSettings();
}
refreshOpenAvatarDetailModal();
}
// Re-localize the character detail modal while it is open so that names and
// dynamic content follow a language switch instead of keeping the old strings.
function refreshOpenAvatarDetailModal() {
const modal = document.getElementById('modal-avatar-detail');
if (!modal || !modal.classList.contains('active') || !state.selectedAvatarDetail) return;
const aid = state.selectedAvatarDetail.avatar_id;
const meta = getAvatarInfo(aid);
if (el.modalCharTitle) el.modalCharTitle.textContent = `${t('modalTitleCharEdit')}: ${meta.name} (ID: ${aid})`;
if (el.modalCharName) el.modalCharName.textContent = meta.name;
if (el.modalCharMeta) {
el.modalCharMeta.innerHTML = `
${getElementIconHtml(meta.element, 24)}
${getPathIconHtml(meta.path, 24)}
★ ${meta.rarity}
`;
}
renderModalSwitcher(aid);
const activeBtn = document.querySelector('.modal-subtab-btn.active');
const activeTab = activeBtn?.getAttribute('data-modaltab') || 'modaltab-overview';
const activeContent = document.getElementById(activeTab);
const contentScroll = modal.querySelector('.modal-content')?.scrollTop ?? 0;
const tabScroll = activeContent?.scrollTop ?? 0;
// Lightcone section lives outside the sub-tabs, so refresh it explicitly.
const equippedLc = (state.fullFreesrData?.lightcones || []).find(lc => lc.equip_avatar === aid);
const activeLcId = equippedLc?.item_id || SIGNATURE_LIGHTCONES[aid] || 21001;
populateModalChangeLcDropdown(meta.path);
updateModalLcFullView(activeLcId, selectedLcRank || equippedLc?.rank || 5, equippedLc?.level || 80);
// Re-render every sub-tab so hidden tabs never keep strings from the old language.
renderModalOverviewTab(aid);
renderModalRelicsTab(aid);
renderModalSkillsTab(aid);
renderShowcaseCard(aid);
renderCharacterBuildGuide(aid);
switchModalSubtab(activeTab);
const modalContent = modal.querySelector('.modal-content');
if (modalContent) modalContent.scrollTop = contentScroll;
if (activeContent) activeContent.scrollTop = tabScroll;
}
// Setup Language Selector
function setupLanguageSelector() {
el.langSelect.innerHTML = '';
SUPPORTED_LANGUAGES.forEach(lang => {
const opt = document.createElement('option');
opt.value = lang.code;
opt.textContent = `${lang.flag} ${lang.name}`;
if (lang.code === getCurrentLanguage()) {
opt.selected = true;
}
el.langSelect.appendChild(opt);
});
el.langSelect.addEventListener('change', (e) => {
const newLang = e.target.value;
setLanguage(newLang);
applyTranslations();
showToast(t('languageChanged', { language: SUPPORTED_LANGUAGES.find(l => l.code === newLang)?.name || newLang }), 'info');
});
}
// 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;
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');
if (state.activeTab === 'tab-exrank') {
const dashboardBtn = document.querySelector('.tab-btn[data-tab="tab-dashboard"]');
if (dashboardBtn) dashboardBtn.click();
}
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';
}
};
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();
});
}
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();
});
}
updateModeDisplay(state.serverMode);
}
// Initialize Avatars Database: Populates all known avatars from metadata
function initAvatarsList(force = false) {
// Prune any non-playable or CW-only avatars that might have been loaded
state.avatarsList = state.avatarsList.filter(a => AVATAR_METADATA[a.avatar_id]);
if (state.avatarsList.length > 0 && !force) {
let missing = false;
for (const aidStr in AVATAR_METADATA) {
const aid = parseInt(aidStr, 10);
if (!state.avatarsList.some(a => a.avatar_id === aid)) {
state.avatarsList.push({
avatar_id: aid,
level: 80,
promotion: 6,
rank: 6
});
missing = true;
}
}
if (missing) {
state.avatarsList.sort((a, b) => b.avatar_id - a.avatar_id);
}
return;
}
const list = [];
for (const aidStr in AVATAR_METADATA) {
const aid = parseInt(aidStr, 10);
list.push({
avatar_id: aid,
level: 80,
promotion: 6,
rank: 6
});
}
list.sort((a, b) => b.avatar_id - a.avatar_id);
state.avatarsList = list;
}
// Default lineup matching our persistent server data
const DEFAULT_LINEUP = { 0: 1511, 1: 1506, 2: 1502, 3: 1503 };
// Refresh status from server
async function refreshStatus() {
try {
let data;
if (state.serverMode === 'gac') {
const [status, freesr] = await Promise.all([
apiCall('/api/status'),
apiCall('/api/freesr').catch(() => null)
]);
data = status;
// GAC status contains persistent settings and lineup only. Refresh the
// FreeSR snapshot separately so the avatar editor shows the server's
// latest skills/Trace values instead of a stale browser cache.
if (freesr?.data) {
state.fullFreesrData = freesr.data;
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;
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,
ex_ranks: []
};
} else {
throw new Error('Firefly server not reachable');
}
} else { // robin
const freesr = await apiCall('/api/freesr').catch(() => null);
if (freesr && freesr.data) {
state.fullFreesrData = freesr.data;
data = {
status: 200,
avatar_count: Object.keys(freesr.data.avatars || {}).length,
lightcone_count: (freesr.data.lightcones || []).length,
relic_count: (freesr.data.relics || []).length,
lineups: freesr.data.lineups || freesr.data.battle_config?.custom_battle_lineup || DEFAULT_LINEUP,
scene: freesr.data.scene,
position: freesr.data.position,
ex_ranks: []
};
} else {
throw new Error('Server not reachable');
}
}
state.statusData = data;
setConnectionStatus(true);
el.lblApiUrl.textContent = state.apiUrl;
el.statAvatars.textContent = data.avatar_count ?? state.avatarsList.length;
el.statLightcones.textContent = data.lightcone_count ?? 0;
el.statRelics.textContent = data.relic_count ?? 0;
if (data.scene) {
el.lblPlaneId.textContent = data.scene.plane_id || '-';
el.lblFloorId.textContent = data.scene.floor_id || '-';
}
if (data.position) {
const { x = 0, y = 0, z = 0 } = data.position;
el.lblCoordinates.textContent = `X: ${x}, Y: ${y}, Z: ${z}`;
}
if (el.badgePersistenceMode) {
if (state.serverMode === 'gac') {
el.badgePersistenceMode.textContent = t('badgePersistenceGaC');
el.badgePersistenceMode.className = 'badge-creative';
} else if (state.serverMode === 'firefly') {
el.badgePersistenceMode.textContent = t('badgePersistenceFirefly');
el.badgePersistenceMode.className = 'badge-super-tag';
} else {
el.badgePersistenceMode.textContent = t('badgePersistenceRobin');
el.badgePersistenceMode.className = 'badge-super-tag';
}
}
// Creative Mode is OPEN for all servers
if (el.toggleCreativeMode) {
el.toggleCreativeMode.disabled = false;
el.toggleCreativeMode.checked = state.creativeMode;
if (el.badgeCreativeMode) {
el.badgeCreativeMode.style.display = state.creativeMode ? 'inline-flex' : 'none';
}
}
renderLineup(data.lineups || DEFAULT_LINEUP);
renderExRanks(data.ex_ranks || []);
loadAvatars();
if (analystState.currentReport) {
renderDamageAnalyst();
updateDashboardBattleBanner();
}
} catch (err) {
setConnectionStatus(false, t('offline'));
el.lblApiUrl.textContent = state.apiUrl;
if (state.fullFreesrData) {
const avCount = Object.keys(state.fullFreesrData.avatars || {}).length;
const lcCount = (state.fullFreesrData.lightcones || []).length;
const relicCount = (state.fullFreesrData.relics || []).length;
el.statAvatars.textContent = avCount || state.avatarsList.length;
el.statLightcones.textContent = lcCount;
el.statRelics.textContent = relicCount;
} else {
el.statAvatars.textContent = state.avatarsList.length;
}
if (el.badgePersistenceMode) {
if (state.serverMode === 'gac') {
el.badgePersistenceMode.textContent = t('badgePersistenceGaC');
el.badgePersistenceMode.className = 'badge-creative';
} else if (state.serverMode === 'firefly') {
el.badgePersistenceMode.textContent = t('badgePersistenceFirefly');
el.badgePersistenceMode.className = 'badge-super-tag';
} else {
el.badgePersistenceMode.textContent = t('badgePersistenceRobin');
el.badgePersistenceMode.className = 'badge-super-tag';
}
}
// Keep rendering with current or default lineup so UI is always functional
renderLineup(state.statusData?.lineups || DEFAULT_LINEUP);
loadAvatars();
}
}
// Render party lineup cards
function renderLineup(lineups) {
if (el.toggleCreativeMode) {
el.toggleCreativeMode.checked = state.creativeMode;
el.toggleCreativeMode.disabled = false;
}
if (el.badgeCreativeMode) {
el.badgeCreativeMode.style.display = state.creativeMode ? 'inline-flex' : 'none';
}
el.lineupContainer.innerHTML = '';
let currentLineup = lineups || state.statusData?.lineups || DEFAULT_LINEUP;
// Handle array format if provided
if (Array.isArray(currentLineup)) {
const mapped = {};
currentLineup.forEach((id, idx) => { mapped[idx] = id; });
currentLineup = mapped;
}
// Determine slots to render
let slotKeys = [];
if (state.creativeMode) {
slotKeys = Object.keys(currentLineup).map(k => parseInt(k, 10)).sort((a, b) => a - b);
if (slotKeys.length === 0) slotKeys = [0];
} else {
slotKeys = [0, 1, 2, 3];
}
slotKeys.forEach(slot => {
const avatarId = currentLineup[slot] || currentLineup[String(slot)] || 0;
const slotCard = document.createElement('div');
slotCard.className = `lineup-slot-card ${avatarId ? 'filled' : ''}`;
slotCard.title = t('slotTooltip');
const removeSlotBtnHtml = state.creativeMode ? `
` : '';
if (avatarId === 0) {
slotCard.innerHTML = `
${t('slot')} ${slot + 1}
${removeSlotBtnHtml}
➕
${t('slotEmpty')}
`;
} else {
const info = getAvatarInfo(avatarId);
slotCard.innerHTML = `
${t('slot')} ${slot + 1}
${removeSlotBtnHtml}
${info.name}
${getElementIconHtml(info.element, 20)}
${getPathIconHtml(info.path, 20)}
`;
}
slotCard.addEventListener('click', (e) => {
if (e.target.closest('.btn-lineup-remove-slot')) {
e.stopPropagation();
removeLineupSlot(slot);
return;
}
openLineupPicker(slot);
});
el.lineupContainer.appendChild(slotCard);
});
// In Creative Mode: render "+ เพิ่ม Slot" button card
if (state.creativeMode) {
const addSlotCard = document.createElement('div');
addSlotCard.className = 'lineup-slot-card lineup-add-slot-card';
addSlotCard.title = t('btnAddSlot') || '+ เพิ่ม Slot';
addSlotCard.innerHTML = `
➕
${t('btnAddSlot') || '+ เพิ่ม Slot'}
`;
addSlotCard.addEventListener('click', () => {
addLineupSlot();
});
el.lineupContainer.appendChild(addSlotCard);
}
// Update Team Combat Synergy Analysis
renderTeamSynergy(currentLineup);
}
// ==========================================
// Mini Battle Summary & Team Stats
// ==========================================
const ELEMENT_COLORS = {
Physical: '#cbd5e1',
Fire: '#ef4444',
Ice: '#38bdf8',
Lightning: '#c084fc',
Thunder: '#c084fc',
Wind: '#34d399',
Quantum: '#818cf8',
Imaginary: '#fbbf24'
};
function renderMiniBattleSummary() {
if (!el.miniBattleAvatarsList) return;
const report = analystState.currentReport;
if (!report || !Array.isArray(report.avatars) || report.avatars.length === 0) {
if (el.badgeMiniBattleStatus) {
el.badgeMiniBattleStatus.textContent = t('miniNoBattleTitle');
el.badgeMiniBattleStatus.style.background = 'rgba(156, 163, 175, 0.12)';
el.badgeMiniBattleStatus.style.color = 'var(--text-muted)';
el.badgeMiniBattleStatus.style.borderColor = 'rgba(156, 163, 175, 0.25)';
}
if (el.miniTotalDmg) el.miniTotalDmg.textContent = '-';
if (el.miniTeamDps) el.miniTeamDps.textContent = '-';
if (el.miniBreakDmg) el.miniBreakDmg.textContent = '-';
if (el.miniBattleTurns) el.miniBattleTurns.textContent = '-';
el.miniBattleAvatarsList.innerHTML = `
⚔️
${t('miniNoBattleTitle')}
${t('miniNoBattleDesc')}
`;
return;
}
// Active Report
const status = String(report.end_status || 'WIN').toUpperCase();
const isLive = analystState.isLive || status === 'LIVE';
const isWin = status === 'WIN';
if (el.badgeMiniBattleStatus) {
const statusKey = isLive ? 'analystStatusLive'
: isWin ? 'analystStatusWin'
: status === 'QUIT' ? 'analystStatusQuit' : 'analystStatusLose';
const color = isLive || isWin ? 'var(--accent-green)' : status === 'QUIT' ? '#fbbf24' : '#f87171';
el.badgeMiniBattleStatus.textContent = t(statusKey);
el.badgeMiniBattleStatus.style.background = isLive || isWin ? 'rgba(52, 211, 153, 0.15)' : status === 'QUIT' ? 'rgba(245, 158, 11, 0.15)' : 'rgba(239, 68, 68, 0.15)';
el.badgeMiniBattleStatus.style.color = color;
el.badgeMiniBattleStatus.style.borderColor = isLive || isWin ? 'rgba(52, 211, 153, 0.4)' : status === 'QUIT' ? 'rgba(245, 158, 11, 0.4)' : 'rgba(239, 68, 68, 0.4)';
}
const durationSec = report.cost_time || 0;
const teamDps = durationSec > 0 ? (report.team_total_damage / durationSec) : 0;
const orderedAvatars = orderBattleReportAvatars(report.avatars);
const hasAvatarDamage = hasAvatarDamageData(orderedAvatars);
const hasAvatarStats = hasAvatarBattleStatsData(orderedAvatars);
let totalBreakDmg = 0;
orderedAvatars.forEach(a => {
totalBreakDmg += (a.total_break_damage || 0);
});
if (el.miniTotalDmg) el.miniTotalDmg.textContent = formatNumber(report.team_total_damage);
if (el.miniTeamDps) el.miniTeamDps.textContent = `${formatNumber(Math.round(teamDps))}/s`;
if (el.miniBreakDmg) el.miniBreakDmg.textContent = hasAvatarStats ? formatNumber(totalBreakDmg) : '—';
if (el.miniBattleTurns) el.miniBattleTurns.textContent = `${t('analystTurnValue', { count: report.total_battle_turns || 0 })} (${formatDuration(durationSec)})`;
el.miniBattleAvatarsList.innerHTML = `${hasAvatarDamage ? '' : `${t('analystShareUnavailable')}
`}${orderedAvatars.map(av => {
const meta = getAvatarInfo(av.avatar_id);
const elemColor = ELEMENT_COLORS[meta.element] || '#38bdf8';
const pct = getAvatarDamagePercent(av, report.team_total_damage || 0, hasAvatarDamage);
const iconUrl = getAvatarIconUrl(av.avatar_id);
const rankStr = report.is_currency_wars ? (av.rank ? `${av.rank}★` : '1★') : (av.rank !== undefined ? `E${av.rank}` : '');
const lvlStr = av.level ? `Lv.${av.level}` : '';
const metaSub = [lvlStr, rankStr, translatePath(meta.path)].filter(Boolean).join(' • ');
return `
${hasAvatarDamage ? formatNumber(av.total_damage) : '—'}
${pct === null ? '—' : `${pct.toFixed(1)}%`}
`;
}).join('')}`;
}
function hasAvatarDamageData(avatars) {
return Array.isArray(avatars) && avatars.some(avatar =>
Number(avatar?.total_damage) > 0 || Number(avatar?.damage_percent) > 0
);
}
function hasAvatarBattleStatsData(avatars) {
return Array.isArray(avatars) && avatars.some(avatar => {
const scalarStats = [
avatar?.total_damage, avatar?.total_damage_taken, avatar?.total_heal,
avatar?.total_hp_recover, avatar?.total_shield, avatar?.total_shield_taken,
avatar?.total_break_damage, avatar?.total_turns, avatar?.extra_turns,
avatar?.die_times, avatar?.revive_times, avatar?.break_times
];
const damageProperties = [
avatar?.attack_type_damage, avatar?.attack_type_break_damage,
avatar?.attack_type_max_damage
];
return scalarStats.some(value => Number(value) > 0)
|| damageProperties.some(properties => properties && Object.values(properties).some(value => Number(value) > 0))
|| (Array.isArray(avatar?.skill_times) && avatar.skill_times.length > 0);
});
}
function hasAvatarDamageBreakdownData(avatar) {
const damageTypes = avatar?.attack_type_damage;
return Number(avatar?.total_break_damage) > 0
|| (damageTypes && Object.keys(damageTypes).length > 0);
}
function hasAvatarMaxHitData(avatar) {
const maxDamage = avatar?.attack_type_max_damage;
return Boolean(maxDamage && Object.values(maxDamage).some(value => Number(value) > 0));
}
function getAvatarDamagePercent(avatar, teamDamage, available) {
if (!available) return null;
const reportedPercent = Number(avatar?.damage_percent);
const damage = Math.max(0, Number(avatar?.total_damage) || 0);
const percent = Number.isFinite(reportedPercent) && (reportedPercent > 0 || damage === 0)
? reportedPercent
: teamDamage > 0 ? (damage / teamDamage) * 100 : 0;
return Math.max(0, Math.min(100, percent));
}
function renderTeamSynergy(lineups) {
const currentLineup = lineups || state.statusData?.lineups || DEFAULT_LINEUP;
// Extract active character IDs
const activeIds = [];
for (const slot in currentLineup) {
const aid = parseInt(currentLineup[slot], 10);
if (aid && aid > 0 && !activeIds.includes(aid)) {
activeIds.push(aid);
}
}
let totalLevel = 0;
let totalEidolon = 0;
let lcCount = 0;
let fullRelicCount = 0;
activeIds.forEach(aid => {
const av = state.fullFreesrData?.avatars?.[aid];
const lvl = av?.level || 80;
const rank = av?.data?.rank ?? (state.avatarsList.find(a => a.avatar_id === aid)?.rank ?? 0);
totalLevel += lvl;
totalEidolon += rank;
// LC check
const hasLc = (state.fullFreesrData?.lightcones || []).some(l => l.equip_avatar === aid);
if (hasLc) lcCount++;
// Relics check (count how many relics equipped)
const relCount = (state.fullFreesrData?.relics || []).filter(r => r.equip_avatar === aid).length;
if (relCount >= 6) fullRelicCount++;
});
const count = activeIds.length || 1;
const avgLevel = activeIds.length ? (totalLevel / count).toFixed(1) : '-';
const avgEidolon = activeIds.length ? `E${(totalEidolon / count).toFixed(1)}` : '-';
// Metrics
if (el.synergyAvgLevel) el.synergyAvgLevel.textContent = activeIds.length ? `Lv.${avgLevel}` : '-';
if (el.synergyAvgEidolon) el.synergyAvgEidolon.textContent = avgEidolon;
if (el.synergyLcReady) el.synergyLcReady.textContent = activeIds.length ? `${lcCount}/${activeIds.length}` : '-';
if (el.synergyRelicReady) el.synergyRelicReady.textContent = activeIds.length ? `${fullRelicCount}/${activeIds.length}` : '-';
renderMiniBattleSummary();
}
// ==========================================
// GM Command Dispatcher & Live Log Console
// ==========================================
const consoleState = {
history: [],
historyIndex: -1,
maxLogs: 200,
suggestions: [],
suggestionIndex: -1
};
let consoleCatalogs = {};
let consoleCatalogRequest = null;
async function loadConsoleCommandCatalogs() {
if (!state.isConnected || consoleCatalogRequest) return consoleCatalogRequest;
consoleCatalogRequest = (async () => {
try {
const result = await apiCall('/api/command-completions');
consoleCatalogs = result?.choices || {};
} catch (err) { consoleCatalogRequest = null; }
if (document.activeElement === el.consoleCmdInput) renderGMCommandSuggestions();
})();
return consoleCatalogRequest;
}
function consoleCommandModel() {
const catalogs = { ...consoleCatalogs, avatars: Object.keys(AVATAR_METADATA).map(id => ({ value:id, label:getAvatarInfo(id).name })) };
if (catalogs.cw_roles) catalogs.cw_roles = catalogs.cw_roles.map(c => ({ ...c, label:`${getAvatarInfo(c.avatar).name} · Role ${c.value} · ${c.star}★` }));
return inspectCommand(el.consoleCmdInput?.value || '', el.consoleCmdInput?.selectionStart ?? 0, catalogs);
}
function renderGMCommandPreview(model = consoleCommandModel()) {
const preview = document.getElementById('console-command-preview');
const hint = document.getElementById('console-command-argument');
if (preview) preview.textContent = model.preview;
if (hint) hint.textContent = model.hint;
const description = document.getElementById('console-command-description');
if (description) description.textContent = model.command?.description || t('consoleDescriptionHint');
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function hideGMCommandSuggestions() {
consoleState.suggestions = [];
consoleState.suggestionIndex = -1;
if (el.consoleSuggestions) el.consoleSuggestions.hidden = true;
if (el.consoleCmdInput) {
el.consoleCmdInput.setAttribute('aria-expanded', 'false');
el.consoleCmdInput.removeAttribute('aria-activedescendant');
}
}
function renderGMCommandSuggestions() {
if (!el.consoleSuggestions || !el.consoleSuggestionOptions) return;
consoleState.model = consoleCommandModel();
renderGMCommandPreview(consoleState.model);
consoleState.suggestions = consoleState.model.suggestions;
consoleState.suggestionIndex = -1;
if (consoleState.suggestions.length === 0) {
hideGMCommandSuggestions();
return;
}
el.consoleSuggestionOptions.replaceChildren();
consoleState.suggestions.forEach((suggestion, index) => {
const option = document.createElement('button');
option.type = 'button';
option.className = 'console-suggestion-option';
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', 'false');
option.dataset.suggestionIndex = String(index);
const command = document.createElement('span');
command.className = 'console-suggestion-command';
command.textContent = `${suggestion.value}${suggestion.label && suggestion.label !== suggestion.value ? " · " + suggestion.label : ""}`;
option.appendChild(command);
const description = document.createElement('span');
description.className = 'console-suggestion-description';
description.textContent = suggestion.description;
option.appendChild(description);
el.consoleSuggestionOptions.appendChild(option);
});
if (el.consoleSuggestionCount) {
el.consoleSuggestionCount.textContent = `‹ 0/${consoleState.suggestions.length} ›`;
}
el.consoleSuggestions.hidden = false;
el.consoleCmdInput?.setAttribute('aria-expanded', 'true');
}
function selectGMCommandSuggestion(index) {
const suggestion = consoleState.suggestions[index];
if (!suggestion || !el.consoleCmdInput) return false;
const completed = completeCommand(el.consoleCmdInput.value, consoleState.model, suggestion);
el.consoleCmdInput.value = completed.value;
consoleState.historyIndex = -1;
el.consoleCmdInput.focus();
el.consoleCmdInput.setSelectionRange(completed.caret, completed.caret);
renderGMCommandSuggestions();
return true;
}
function moveGMCommandSuggestion(direction) {
if (consoleState.suggestions.length === 0) return false;
const count = consoleState.suggestions.length;
if (consoleState.suggestionIndex < 0) {
consoleState.suggestionIndex = direction > 0 ? 0 : count - 1;
} else {
consoleState.suggestionIndex = (consoleState.suggestionIndex + direction + count) % count;
}
const options = el.consoleSuggestionOptions?.querySelectorAll('[role="option"]') || [];
options.forEach((option, index) => {
const selected = index === consoleState.suggestionIndex;
option.setAttribute('aria-selected', String(selected));
if (selected) { el.consoleCmdInput?.setAttribute('aria-activedescendant', `console-command-option-${index}`); option.scrollIntoView({block:'nearest'}); }
option.id = `console-command-option-${index}`;
});
if (el.consoleSuggestionCount) {
el.consoleSuggestionCount.textContent = `‹ ${consoleState.suggestionIndex + 1}/${count} ›`;
}
const candidate = completeCommand(el.consoleCmdInput.value, consoleState.model, consoleState.suggestions[consoleState.suggestionIndex]);
renderGMCommandPreview(inspectCommand(candidate.value, candidate.caret, consoleCatalogs));
return true;
}
function logToConsole(message, tag = 'SYS') {
if (!el.consoleTerminalLog) return;
const now = new Date();
const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
let tagClass = 'tag-sys';
const upperTag = String(tag).toUpperCase();
if (upperTag === 'CMD' || upperTag === 'GM') tagClass = 'tag-cmd';
else if (upperTag === 'OK' || upperTag === 'SUCCESS') tagClass = 'tag-ok';
else if (upperTag === 'ERR' || upperTag === 'ERROR') tagClass = 'tag-err';
else if (upperTag === 'NET') tagClass = 'tag-net';
const lineEl = document.createElement('div');
lineEl.className = 'log-line';
lineEl.innerHTML = `
${timeStr}
[${escapeHtml(upperTag)}]
${escapeHtml(message)}
`;
el.consoleTerminalLog.appendChild(lineEl);
// Trim excess logs if above maxLogs
while (el.consoleTerminalLog.children.length > consoleState.maxLogs) {
el.consoleTerminalLog.removeChild(el.consoleTerminalLog.firstChild);
}
// Auto scroll to bottom
el.consoleTerminalLog.scrollTop = el.consoleTerminalLog.scrollHeight;
}
async function dispatchGMCommand(rawCmd) {
const cmd = (rawCmd || '').trim();
if (!cmd) return;
// Save to history
if (consoleState.history[consoleState.history.length - 1] !== cmd) {
consoleState.history.push(cmd);
}
consoleState.historyIndex = -1;
// Log user command
logToConsole(cmd, 'CMD');
// Handle local /help command immediately
if (cmd.toLowerCase() === '/help') {
logToConsole('Available GM commands:', 'SYS');
COMMANDS.forEach(c => logToConsole(`${usage(c)} - ${describeCommand(c)}`, 'SYS'));
return;
}
// If server is online, try dispatching to backend
if (state.isConnected) {
try {
const res = await apiCall('/api/command', 'POST', { command: cmd });
if (res) {
const succeeded = res.success !== false;
const msg = res.output || res.message || res.msg || (succeeded ? 'Command completed.' : 'Command failed.');
logToConsole(msg, succeeded ? 'OK' : 'ERR');
if (!succeeded) return;
// Command specific post-processing
const lowerCmd = cmd.toLowerCase();
if (lowerCmd.startsWith('/heal')) {
showToast('Team HP and Energy restored.', 'success');
refreshStatus();
} else if (lowerCmd.startsWith('/teleport') || lowerCmd.startsWith('/scene')) {
showToast('Scene change command sent.', 'success');
refreshStatus();
} else if (lowerCmd.startsWith('/clear lineup')) {
showToast('Active lineup cleared.', 'info');
refreshStatus();
} else if (lowerCmd.startsWith('/max')) {
showToast('Characters updated to max level and Eidolon rank.', 'success');
refreshStatus();
} else if (lowerCmd.startsWith('/give') || lowerCmd.startsWith('/item')) {
showToast('Item command completed.', 'success');
} else if (lowerCmd.startsWith('/avatar')) {
showToast('Character updated.', 'success');
loadAvatars();
}
return;
}
} catch (err) {
logToConsole(`Server dispatcher: ${err.message}. Trying local fallback.`, 'NET');
}
}
// Fallback to client-side local executions if server is offline or doesn't support the command
const lowerCmd = cmd.toLowerCase();
if (lowerCmd.startsWith('/heal')) {
logToConsole('Local fallback: restoring team Energy to 100%.', 'OK');
if (state.fullFreesrData?.avatars) {
for (const aid in state.fullFreesrData.avatars) {
state.fullFreesrData.avatars[aid].sp_value = 140;
}
saveFreesrChangesToServer();
}
showToast('Team Energy restored.', 'success');
} else if (lowerCmd.startsWith('/max')) {
logToConsole('Local fallback: setting all characters to maximum values...', 'SYS');
await maxAllAvatars({ englishOnly: true });
logToConsole('Local: all characters updated to Level 80 and E6.', 'OK');
} else if (lowerCmd.startsWith('/clear lineup')) {
logToConsole('Local fallback: clearing the active lineup...', 'SYS');
await quickClearLineup({ englishOnly: true });
logToConsole('Local: active lineup cleared.', 'OK');
} else if (lowerCmd.startsWith('/teleport') || lowerCmd.startsWith('/scene')) {
const parts = cmd.split(/\s+/);
const planeId = parseInt(parts[1], 10) || 20001;
const floorId = parseInt(parts[2], 10) || 1;
logToConsole(`Local fallback: setting scene to Plane ID ${planeId}, Floor ${floorId}.`, 'OK');
if (state.fullFreesrData) {
state.fullFreesrData.scene = state.fullFreesrData.scene || {};
state.fullFreesrData.scene.plane_id = planeId;
state.fullFreesrData.scene.floor_id = floorId;
saveFreesrChangesToServer();
refreshStatus();
}
showToast(`Scene changed to Plane ${planeId}.`, 'success');
} else if (lowerCmd.startsWith('/avatar')) {
const parts = cmd.split(/\s+/);
const aid = parseInt(parts[1], 10);
const lvl = parseInt(parts[2], 10) || 80;
const rank = parseInt(parts[3], 10) || 6;
if (aid) {
if (!state.fullFreesrData) state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
state.fullFreesrData.avatars = state.fullFreesrData.avatars || {};
state.fullFreesrData.avatars[aid] = {
owner_uid: 0,
avatar_id: aid,
level: lvl,
promotion: lvl > 70 ? 6 : (lvl > 60 ? 5 : 4),
data: { rank, skills: { 1: 10, 2: 10, 3: 10, 4: 10 } }
};
saveFreesrChangesToServer();
loadAvatars();
logToConsole(`Local: Avatar ${aid} added or updated (Lv.${lvl}, E${rank}).`, 'OK');
showToast(`Character ${aid} updated.`, 'success');
} else {
logToConsole('Invalid syntax. Usage: /avatar [level] [rank]', 'ERR');
}
} else if (lowerCmd.startsWith('/give') || lowerCmd.startsWith('/item')) {
const parts = cmd.split(/\s+/);
const itemId = parseInt(parts[1], 10);
const count = parseInt(parts[2], 10) || 1;
if (itemId) {
logToConsole(`Local: Item ID ${itemId} x${count.toLocaleString()} added.`, 'OK');
showToast(`Item ${itemId} x${count} added.`, 'success');
} else {
logToConsole('Invalid syntax. Usage: /give [count]', 'ERR');
}
} else {
logToConsole(`Unknown or unsupported command in offline mode: "${cmd}". Type /help for available commands.`, 'ERR');
}
}
function setupGMConsoleEvents() {
if (el.formConsoleCmd) {
el.formConsoleCmd.addEventListener('submit', (e) => {
e.preventDefault();
if (!el.consoleCmdInput) return;
const cmd = el.consoleCmdInput.value.trim();
if (!cmd) return;
hideGMCommandSuggestions();
el.consoleCmdInput.value = '';
renderGMCommandPreview();
dispatchGMCommand(cmd);
});
}
if (el.consoleCmdInput) {
el.consoleCmdInput.addEventListener('input', () => {
consoleState.historyIndex = -1;
renderGMCommandSuggestions();
});
el.consoleCmdInput.addEventListener('focus', () => { renderGMCommandSuggestions(); loadConsoleCommandCatalogs(); });
el.consoleCmdInput.addEventListener('click', renderGMCommandSuggestions);
el.consoleCmdInput.addEventListener('keyup', e => { if (['ArrowLeft','ArrowRight','Home','End'].includes(e.key)) renderGMCommandSuggestions(); });
el.consoleCmdInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && consoleState.suggestionIndex >= 0) { e.preventDefault(); selectGMCommandSuggestion(consoleState.suggestionIndex); return; }
if (e.key === 'Escape' && consoleState.suggestions.length > 0) {
e.preventDefault();
hideGMCommandSuggestions();
return;
}
if (e.key === 'Tab' && consoleState.suggestions.length > 0) {
e.preventDefault();
if (e.shiftKey) moveGMCommandSuggestion(-1);
else selectGMCommandSuggestion(consoleState.suggestionIndex >= 0 ? consoleState.suggestionIndex : 0);
return;
}
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && consoleState.suggestions.length > 0) {
e.preventDefault();
moveGMCommandSuggestion(e.key === 'ArrowDown' ? 1 : -1);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (consoleState.history.length === 0) return;
if (consoleState.historyIndex === -1) {
consoleState.historyIndex = consoleState.history.length - 1;
} else if (consoleState.historyIndex > 0) {
consoleState.historyIndex--;
}
el.consoleCmdInput.value = consoleState.history[consoleState.historyIndex] || '';
hideGMCommandSuggestions();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (consoleState.history.length === 0 || consoleState.historyIndex === -1) return;
if (consoleState.historyIndex < consoleState.history.length - 1) {
consoleState.historyIndex++;
el.consoleCmdInput.value = consoleState.history[consoleState.historyIndex];
} else {
consoleState.historyIndex = -1;
el.consoleCmdInput.value = '';
}
hideGMCommandSuggestions();
}
});
}
if (el.consoleSuggestionOptions) {
el.consoleSuggestionOptions.addEventListener('mousedown', e => e.preventDefault());
el.consoleSuggestionOptions.addEventListener('click', (e) => {
const option = e.target.closest('[data-suggestion-index]');
if (!option) return;
selectGMCommandSuggestion(Number(option.dataset.suggestionIndex));
});
}
if (el.btnConsoleCopyLog) {
el.btnConsoleCopyLog.addEventListener('click', () => {
if (!el.consoleTerminalLog) return;
const text = el.consoleTerminalLog.innerText || el.consoleTerminalLog.textContent;
navigator.clipboard?.writeText(text).then(() => {
showToast('Console log copied.', 'success');
}).catch(() => {
showToast('Could not copy the console log.', 'error');
});
});
}
if (el.btnConsoleClearLog) {
el.btnConsoleClearLog.addEventListener('click', () => {
if (el.consoleTerminalLog) {
el.consoleTerminalLog.innerHTML = '';
logToConsole('Console log cleared.', 'SYS');
}
});
}
renderGMCommandPreview();
logToConsole('GaC server console ready. Type /help for available commands.', 'SYS');
}
// Add extra slot in Creative Mode
async function addLineupSlot() {
state.statusData = state.statusData || {};
let currentLineup = { ...(state.statusData.lineups || DEFAULT_LINEUP) };
if (Array.isArray(currentLineup)) {
const mapped = {};
currentLineup.forEach((id, idx) => { mapped[idx] = id; });
currentLineup = mapped;
}
const keys = Object.keys(currentLineup).map(k => parseInt(k, 10)).sort((a, b) => a - b);
const nextSlot = keys.length > 0 ? Math.max(...keys) + 1 : 0;
currentLineup[nextSlot] = 0;
state.statusData.lineups = currentLineup;
if (state.fullFreesrData) {
state.fullFreesrData.lineups = currentLineup;
if (state.fullFreesrData.battle_config) {
state.fullFreesrData.battle_config.custom_battle_lineup = currentLineup;
}
}
if (state.isConnected) {
if (state.serverMode === 'gac') {
await apiCall('/api/persistent', 'POST', { lineups: currentLineup }).catch(() => {});
} else {
await saveFreesrChangesToServer().catch(() => {});
}
}
renderLineup(currentLineup);
openLineupPicker(nextSlot);
}
// Remove slot in Creative Mode
async function removeLineupSlot(slotIndex) {
state.statusData = state.statusData || {};
let currentLineup = { ...(state.statusData.lineups || DEFAULT_LINEUP) };
if (Array.isArray(currentLineup)) {
const mapped = {};
currentLineup.forEach((id, idx) => { mapped[idx] = id; });
currentLineup = mapped;
}
const keys = Object.keys(currentLineup).map(k => parseInt(k, 10)).sort((a, b) => a - b);
const newLineup = {};
let newIdx = 0;
for (const k of keys) {
if (k !== slotIndex) {
newLineup[newIdx] = currentLineup[k];
newIdx++;
}
}
if (Object.keys(newLineup).length === 0) {
newLineup[0] = 0;
}
state.statusData.lineups = newLineup;
if (state.fullFreesrData) {
state.fullFreesrData.lineups = newLineup;
if (state.fullFreesrData.battle_config) {
state.fullFreesrData.battle_config.custom_battle_lineup = newLineup;
}
}
if (state.isConnected) {
if (state.serverMode === 'gac') {
await apiCall('/api/persistent', 'POST', { lineups: newLineup }).catch(() => {});
} else {
await saveFreesrChangesToServer().catch(() => {});
}
}
renderLineup(newLineup);
const toastMsg = t('toastSlotRemoved', { slot: slotIndex + 1 }) || `ลบ Slot ${slotIndex + 1} เรียบร้อยแล้ว`;
showToast(toastMsg, 'info');
}
// Lineup Picker Modal
function openLineupPicker(slotIndex) {
state.selectedLineupSlot = slotIndex;
el.modalPickerTitle.textContent = `${t('pickerTitle')} - Slot ${slotIndex + 1}`;
el.pickerSearch.value = '';
renderPickerGrid();
el.modalLineupPicker.classList.add('active');
}
function renderPickerGrid() {
const search = el.pickerSearch.value.trim().toLowerCase();
el.pickerAvatarsGrid.innerHTML = '';
// Option to remove character from slot
const removeCard = document.createElement('div');
removeCard.className = 'picker-remove-card';
removeCard.innerHTML = `
❌
${t('removeFromTeam')}
`;
removeCard.addEventListener('click', async () => {
await assignAvatarToLineup(state.selectedLineupSlot, 0);
el.modalLineupPicker.classList.remove('active');
});
el.pickerAvatarsGrid.appendChild(removeCard);
// Available avatars (sorted descending by avatar_id)
const sortedAvatars = [...state.avatarsList].sort((a, b) => b.avatar_id - a.avatar_id);
sortedAvatars.forEach(item => {
const meta = getAvatarInfo(item.avatar_id);
if (search && !meta.name.toLowerCase().includes(search) && !String(item.avatar_id).includes(search)) {
return;
}
const card = document.createElement('div');
card.className = `picker-card-item card-rarity-${meta.rarity}`;
card.innerHTML = `
${meta.name}
${getElementIconHtml(meta.element, 18)}
${getPathIconHtml(meta.path, 18)}
Lv. ${item.level} • E${item.rank}
`;
card.addEventListener('click', async () => {
await assignAvatarToLineup(state.selectedLineupSlot, item.avatar_id);
el.modalLineupPicker.classList.remove('active');
});
el.pickerAvatarsGrid.appendChild(card);
});
}
/* ==========================================================================
Avatar Assignment Picker (Add Relic / Add Light Cone)
========================================================================== */
const avatarAssignState = { kind: 'relic', pending: 0 };
function avatarAssignSelectEl(kind) {
return kind === 'lc' ? el.addLcAvatar : el.addRelicAvatar;
}
function avatarAssignButtonEl(kind) {
return kind === 'lc' ? el.addLcAvatarBtn : el.addRelicAvatarBtn;
}
function getRelicSlotNumber() {
return parseInt(el.addRelicSlot?.value || '1', 10) || 1;
}
// What (if anything) the given avatar currently wears in this slot.
function findAvatarEquippedItem(kind, avatarId, relicSlot) {
if (kind === 'lc') {
const lc = (state.fullFreesrData?.lightcones || []).find(l => l.equip_avatar === avatarId);
return lc ? { name: getLightconeInfo(lc.item_id).name } : null;
}
const relic = (state.fullFreesrData?.relics || [])
.find(r => r.equip_avatar === avatarId && (r.relic_id % 10) === relicSlot);
if (!relic) return null;
const setId = Math.floor((relic.relic_id % 10000) / 10);
return { name: getRelicSetInfo(setId).name };
}
// Keep the hidden