import { AVATAR_METADATA, getAvatarInfo } from './avatars-data.js';
import { LIGHTCONE_METADATA, RELIC_SET_METADATA, getLightconeInfo, getRelicSetInfo } from './equipment-data.js';
import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js';
import {
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'
};
// 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'),
// Team Combat Synergy
cardTeamSynergy: document.getElementById('card-team-synergy'),
badgeSynergyCoverage: document.getElementById('badge-synergy-coverage'),
synergyElementsContainer: document.getElementById('synergy-elements-container'),
synergyAvgLevel: document.getElementById('synergy-avg-level'),
synergyAvgEidolon: document.getElementById('synergy-avg-eidolon'),
synergyLcReady: document.getElementById('synergy-lc-ready'),
synergyRelicReady: document.getElementById('synergy-relic-ready'),
synergyDamageBanner: document.getElementById('synergy-damage-banner'),
dashboardBattleTitle: document.getElementById('dashboard-battle-title'),
dashboardBattleSummary: document.getElementById('dashboard-battle-summary'),
// 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'),
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'),
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'),
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'),
// 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'),
// 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'),
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,
headers: {
'Content-Type': 'application/json'
}
};
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;
}
}
// Update connection status UI
function setConnectionStatus(connected, extraText = '') {
state.isConnected = connected;
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 = t('consoleOnline');
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 = t('consoleOffline');
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 `
`;
}
// Render Path Icon HTML (Official Star Rail Game Icon)
function getPathIconHtml(path, size = 20) {
if (!path) return '';
const canonical = normalizePath(path);
return `
`;
}
// 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') || 'ทุกสาย'} (All)`;
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;
}
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() {
document.querySelectorAll('[data-i18n]').forEach(elem => {
const key = elem.getAttribute('data-i18n');
const val = t(key);
if (val && val !== key) elem.textContent = val;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(elem => {
const key = elem.getAttribute('data-i18n-placeholder');
const val = t(key);
if (val) elem.placeholder = val;
});
// Update dynamic filter dropdown options
updateFilterDropdownTranslations();
// Update connection status text with new language
setConnectionStatus(state.isConnected);
// 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();
}
loadEquipment();
updateDocumentTitle();
}
// 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(`Language: ${SUPPORTED_LANGUAGES.find(l => l.code === newLang)?.name || newLang}`, 'info');
});
}
// Server Mode Setup
function setupServerModes() {
const updateModeDisplay = (mode) => {
state.serverMode = mode;
localStorage.setItem('gac_srtool_mode', mode);
if (el.serverModeSelect) el.serverModeSelect.value = mode;
if (el.settingServerMode) el.settingServerMode.value = mode;
if (mode === 'gac') {
if (el.lblActiveMode) el.lblActiveMode.textContent = 'GaC-hkrpg-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) {
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') {
data = await apiCall('/api/status');
} 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 = 'GaC: persistent.json (/api/persistent)';
el.badgePersistenceMode.className = 'badge-creative';
} else if (state.serverMode === 'firefly') {
el.badgePersistenceMode.textContent = 'FireflyGo: data-in-game.json & freesr-data.json';
el.badgePersistenceMode.className = 'badge-super-tag';
} else {
el.badgePersistenceMode.textContent = 'RobinSR: freesr-data.json (/api/freesr)';
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();
} 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 = 'GaC: persistent.json (/api/persistent)';
el.badgePersistenceMode.className = 'badge-creative';
} else if (state.serverMode === 'firefly') {
el.badgePersistenceMode.textContent = 'FireflyGo: data-in-game.json & freesr-data.json';
el.badgePersistenceMode.className = 'badge-super-tag';
} else {
el.badgePersistenceMode.textContent = 'RobinSR: freesr-data.json (/api/freesr)';
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 = `
Slot ${slot + 1}
${removeSlotBtnHtml}
➕
${t('slotEmpty')}
`;
} else {
const info = getAvatarInfo(avatarId);
slotCard.innerHTML = `
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);
}
// ==========================================
// Team Combat Synergy & Weakness Break Analysis
// ==========================================
const HSR_ELEMENTS = [
{ key: 'Physical', name: 'Physical', nameTh: 'กายภาพ', color: '#cbd5e1' },
{ key: 'Fire', name: 'Fire', nameTh: 'ไฟ', color: '#ef4444' },
{ key: 'Ice', name: 'Ice', nameTh: 'น้ำแข็ง', color: '#38bdf8' },
{ key: 'Lightning', name: 'Lightning', nameTh: 'สายฟ้า', color: '#c084fc' },
{ key: 'Wind', name: 'Wind', nameTh: 'ลม', color: '#34d399' },
{ key: 'Quantum', name: 'Quantum', nameTh: 'ควอนตัม', color: '#818cf8' },
{ key: 'Imaginary', name: 'Imaginary', nameTh: 'จินตภาพ', color: '#fbbf24' }
];
function renderTeamSynergy(lineups) {
if (!el.synergyElementsContainer) return;
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);
}
}
// 1. Weakness Break Coverage
const coveredElements = {};
const roles = {
dps: [], // Destruction, Hunt, Erudition
specialist: [], // Remembrance, Elation
support: [], // Harmony, Nihility
sustain: [] // Preservation, Abundance
};
let totalLevel = 0;
let totalEidolon = 0;
let lcCount = 0;
let fullRelicCount = 0;
activeIds.forEach(aid => {
const meta = getAvatarInfo(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;
// Element coverage
const rawElem = normalizeElement(meta.rawElement || meta.element);
if (!coveredElements[rawElem]) {
coveredElements[rawElem] = [];
}
coveredElements[rawElem].push(meta.name);
// Path / Role
const rawPath = normalizePath(meta.rawPath || meta.path);
if (['Destruction', 'Hunt', 'Erudition'].includes(rawPath)) {
roles.dps.push(meta.name);
} else if (['Remembrance', 'Elation'].includes(rawPath)) {
roles.specialist.push(meta.name);
} else if (['Harmony', 'Nihility'].includes(rawPath)) {
roles.support.push(meta.name);
} else if (['Preservation', 'Abundance'].includes(rawPath)) {
roles.sustain.push(meta.name);
}
// 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)}` : '-';
// Render Element Chips
const coveredCount = Object.keys(coveredElements).length;
if (el.badgeSynergyCoverage) {
el.badgeSynergyCoverage.textContent = t('synergyCoverageBadge', { count: coveredCount });
el.badgeSynergyCoverage.style.color = coveredCount >= 4 ? 'var(--accent-green)' : (coveredCount >= 2 ? 'var(--accent-cyan)' : 'var(--accent-gold)');
}
el.synergyElementsContainer.innerHTML = HSR_ELEMENTS.map(elem => {
const isCovered = !!coveredElements[elem.key];
const providers = coveredElements[elem.key] || [];
const providerText = isCovered ? providers.join(', ') : t('synergyNoInTeam');
return `
${getElementIconHtml(elem.key, 22)}
${translateElement(elem.key)}
${providerText}
`;
}).join('');
// 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}` : '-';
updateDashboardBattleBanner();
}
// ==========================================
// GM Command Dispatcher & Live Log Console
// ==========================================
const consoleState = {
history: [],
historyIndex: -1,
maxLogs: 200
};
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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('คำสั่ง GM ที่พร้อมใช้งาน (Available Commands):', 'SYS');
logToConsole(' /heal - ฟื้นฟู HP/Energy เต็มตัวละครในทีมทั้งหมด', 'SYS');
logToConsole(' /max - อัปตัวละครทั้งหมดเป็น Lv.80, E6, Traces 10', 'SYS');
logToConsole(' /teleport [floor] - วาร์ปไปยังฉาก/Plane ID (เช่น 20001 รถไฟ Express)', 'SYS');
logToConsole(' /scene [floor] - คำสั่งย่อของ /teleport', 'SYS');
logToConsole(' /clear lineup - ล้างตัวละครในทีมปัจจุบันทั้งหมด', 'SYS');
logToConsole(' /avatar [lvl] [rank] - ปลดล็อกหรือตั้งค่าตัวละคร (เช่น /avatar 1308 80 6)', 'SYS');
logToConsole(' /give [count] - เพิ่มไอเทมหรือเงิน (เช่น 102 Jades, 2 Credits)', 'SYS');
logToConsole(' /help - แสดงคู่มือรายการคำสั่งนี้', '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 msg = res.message || res.msg || 'คำสั่งประมวลผลสำเร็จ';
logToConsole(msg, 'OK');
// Command specific post-processing
const lowerCmd = cmd.toLowerCase();
if (lowerCmd.startsWith('/heal')) {
showToast('ฟื้นฟู HP และ Energy เรียบร้อย', 'success');
refreshStatus();
} else if (lowerCmd.startsWith('/teleport') || lowerCmd.startsWith('/scene')) {
showToast('ส่งคำสั่งเปลี่ยนฉากเรียบร้อย', 'success');
refreshStatus();
} else if (lowerCmd.startsWith('/clear lineup')) {
showToast('ล้างตัวละครในทีมเรียบร้อย', 'info');
refreshStatus();
} else if (lowerCmd.startsWith('/max')) {
showToast('ปรับ Max เลเวลและ Traces เรียบร้อย', 'success');
refreshStatus();
} else if (lowerCmd.startsWith('/give')) {
showToast('เพิ่มไอเทม/เงินเรียบร้อย', 'success');
} else if (lowerCmd.startsWith('/avatar')) {
showToast('อัปเดตตัวละครเรียบร้อย', 'success');
loadAvatars();
}
return;
}
} catch (err) {
logToConsole(`Server Dispatcher: ${err.message} (เข้าสู่โหมด 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: ฟื้นฟู Energy ของตัวละครในทีมเป็น 100%', 'OK');
if (state.fullFreesrData?.avatars) {
for (const aid in state.fullFreesrData.avatars) {
state.fullFreesrData.avatars[aid].sp_value = 140;
}
saveFreesrChangesToServer();
}
showToast('ฟื้นฟูพลังงานทีมเรียบร้อย', 'success');
} else if (lowerCmd.startsWith('/max')) {
logToConsole('Local Fallback: กำลังรัน maxAllAvatars()...', 'SYS');
await maxAllAvatars();
logToConsole('Local: อัปเลเวล 80 และ E6 ตัวละครทั้งหมดเรียบร้อย', 'OK');
} else if (lowerCmd.startsWith('/clear lineup')) {
logToConsole('Local Fallback: กำลังล้างทีม...', 'SYS');
await quickClearLineup();
logToConsole('Local: ล้างทีมเรียบร้อยแล้ว', '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: ตั้งค่าฉากเป็น 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(`เปลี่ยนฉากเป็น 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 ID ${aid} (Lv.${lvl}, E${rank}) เรียบร้อย`, 'OK');
showToast(`อัปเดตตัวละคร ${aid} สำเร็จ`, 'success');
} else {
logToConsole('รูปแบบคำสั่งไม่ถูกต้อง: /avatar [level] [rank]', 'ERR');
}
} else if (lowerCmd.startsWith('/give')) {
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} จำนวน ${count.toLocaleString()}`, 'OK');
showToast(`เพิ่มไอเทม ${itemId} x${count}`, 'success');
} else {
logToConsole('รูปแบบคำสั่งไม่ถูกต้อง: /give [count]', 'ERR');
}
} else {
logToConsole(`คำสั่งที่ไม่รู้จักหรือไม่รองรับในโหมดออฟไลน์: "${cmd}" (พิมพ์ /help เพื่อดูคำสั่ง)`, '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;
el.consoleCmdInput.value = '';
dispatchGMCommand(cmd);
});
}
// Key navigation in console input
if (el.consoleCmdInput) {
el.consoleCmdInput.addEventListener('keydown', (e) => {
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] || '';
} 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 = '';
}
}
});
}
// Quick pill buttons
document.querySelectorAll('.console-pill-btn').forEach(btn => {
btn.addEventListener('click', () => {
const cmd = btn.getAttribute('data-cmd');
if (cmd) {
if (el.consoleCmdInput) el.consoleCmdInput.value = cmd;
dispatchGMCommand(cmd);
}
});
});
// Copy Log button
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(t('consoleLogCopied'), 'success');
}).catch(() => {
showToast(t('consoleLogCopyFailed'), 'error');
});
});
}
// Clear Log button
if (el.btnConsoleClearLog) {
el.btnConsoleClearLog.addEventListener('click', () => {
if (el.consoleTerminalLog) {
el.consoleTerminalLog.innerHTML = '';
logToConsole(t('consoleLogCleared'), 'SYS');
}
});
}
// Initial greeting
logToConsole(t('consoleReady') || 'GaC Server Console is 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);
});
}
// Assign avatar to lineup slot
async function assignAvatarToLineup(slot, avatarId) {
try {
state.statusData = state.statusData || {};
const lineups = { ...(state.statusData.lineups || DEFAULT_LINEUP) };
// In Normal Mode: Prevent duplicates by clearing other slots that had this avatar
if (!state.creativeMode && avatarId !== 0) {
for (const s in lineups) {
if (parseInt(s, 10) !== slot && lineups[s] === avatarId) {
lineups[s] = 0;
}
}
}
lineups[slot] = avatarId;
state.statusData.lineups = lineups;
if (state.fullFreesrData) {
state.fullFreesrData.lineups = lineups;
if (state.fullFreesrData.battle_config) {
state.fullFreesrData.battle_config.custom_battle_lineup = lineups;
}
}
await saveLineupToServer(lineups);
renderLineup(lineups);
const name = avatarId ? getAvatarInfo(avatarId).name : t('slotEmpty');
showToast(t('toastSlotUpdated', { slot: slot + 1, name }), 'success');
} catch (err) {
showToast(err.message, 'error');
}
}
// Load and Render Avatars
async function loadAvatars() {
initAvatarsList();
// Sync from cached / in-memory freesr data first
if (state.fullFreesrData?.avatars) {
for (const aid in state.fullFreesrData.avatars) {
const serverAv = state.fullFreesrData.avatars[aid];
const found = state.avatarsList.find(a => a.avatar_id === parseInt(aid, 10));
if (found) {
found.level = serverAv.level || 80;
found.promotion = serverAv.promotion || 6;
found.rank = serverAv.data?.rank ?? 6;
}
}
}
if (state.isConnected) {
try {
const res = await apiCall('/api/freesr/avatars');
if (res && res.avatars && res.avatars.length > 0) {
res.avatars.forEach(serverAv => {
const found = state.avatarsList.find(a => a.avatar_id === serverAv.avatar_id);
if (found) {
found.level = serverAv.level;
found.promotion = serverAv.promotion;
found.rank = serverAv.rank;
}
});
}
} catch (err) {
console.warn('Live avatar list fetch failed, using local list:', err);
}
}
state.avatarsList.sort((a, b) => b.avatar_id - a.avatar_id);
renderAvatarsGrid();
}
function renderAvatarsGrid() {
const searchTerm = el.avatarSearch.value.trim().toLowerCase();
const rarityFilter = el.avatarFilterRarity.value;
const elementFilter = el.avatarFilterElement.value;
const pathFilter = el.avatarFilterPath ? el.avatarFilterPath.value : 'all';
const filtered = state.avatarsList.filter(item => {
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;
const elemCanonical = normalizeElement(meta.rawElement || meta.element);
const matchesElement = elementFilter === 'all' || elemCanonical === normalizeElement(elementFilter);
const pathCanonical = normalizePath(meta.rawPath || meta.path);
const matchesPath = pathFilter === 'all' || pathCanonical === normalizePath(pathFilter);
return matchesSearch && matchesRarity && matchesElement && matchesPath;
});
filtered.sort((a, b) => b.avatar_id - a.avatar_id);
if (filtered.length === 0) {
el.avatarsGrid.innerHTML = `${t('noAvatarsFound')}
`;
return;
}
el.avatarsGrid.innerHTML = '';
filtered.forEach(item => {
const meta = getAvatarInfo(item.avatar_id);
const card = document.createElement('div');
card.className = `card avatar-card card-rarity-${meta.rarity}`;
card.innerHTML = `
★ ${meta.rarity}
`;
// Clicking card opens full character build modal
card.addEventListener('click', () => {
openAvatarDetailModal(item);
});
el.avatarsGrid.appendChild(card);
});
}
// Quick Set Single Avatar to E0S1 (Lv 80 + Prom 6 + E0 + Max Skills + S1 Signature Light Cone)
async function setAvatarE0S1(avatarId) {
const aid = parseInt(avatarId, 10);
const meta = getAvatarInfo(aid);
const target = state.avatarsList.find(a => a.avatar_id === aid);
if (target) {
target.level = 80;
target.promotion = 6;
target.rank = 0;
}
if (!state.fullFreesrData) {
state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
}
state.fullFreesrData.avatars = state.fullFreesrData.avatars || {};
state.fullFreesrData.lightcones = state.fullFreesrData.lightcones || [];
if (!state.fullFreesrData.avatars[aid]) {
state.fullFreesrData.avatars[aid] = {
owner_uid: 0,
avatar_id: aid,
level: 80,
promotion: 6,
techniques: [],
sp_max: 140,
sp_value: 70,
data: { rank: 0, skills: {}, skills_by_anchor_type: {} }
};
}
const av = state.fullFreesrData.avatars[aid];
av.level = 80;
av.promotion = 6;
av.data = av.data || {};
av.data.rank = 0;
av.data.skills = {
[`${aid}001`]: 6,
[`${aid}002`]: 10,
[`${aid}003`]: 10,
[`${aid}004`]: 10,
[`${aid}007`]: 1,
[`${aid}101`]: 1,
[`${aid}102`]: 1,
[`${aid}103`]: 1
};
av.data.skills_by_anchor_type = {
"1": 6, "2": 10, "3": 10, "4": 10, "5": 1, "6": 1, "7": 1, "8": 1, "9": 1
};
const lcId = SIGNATURE_LIGHTCONES[aid] || 21001;
let existingLc = state.fullFreesrData.lightcones.find(lc => lc.equip_avatar === aid);
if (existingLc) {
existingLc.item_id = lcId;
existingLc.level = 80;
existingLc.promotion = 6;
existingLc.rank = 1;
} else {
const maxUid = state.fullFreesrData.lightcones.reduce((m, lc) => Math.max(m, lc.internal_uid || 0), 0);
state.fullFreesrData.lightcones.push({
internal_uid: maxUid + 1,
item_id: lcId,
level: 80,
promotion: 6,
rank: 1,
equip_avatar: aid
});
}
// Equip optimal relics
await autoEquipRecommendedRelics(aid, false);
renderAvatarsGrid();
loadEquipment();
if (state.isConnected) {
try {
if (state.serverMode === 'gac') {
await apiCall('/api/avatar/rank', 'POST', { avatar_id: aid, rank: 0 });
await apiCall('/api/freesr/avatars', 'POST', { avatar_id: aid, level: 80, promotion: 6, rank: 0 });
} else {
await saveFreesrChangesToServer();
}
} catch (e) {
console.warn('Sync E0S1 failed:', e);
}
}
if (state.selectedAvatarDetail?.avatar_id === aid) {
openAvatarDetailModal(target || { avatar_id: aid, level: 80, promotion: 6, rank: 0 });
}
showToast(`ตั้งค่า ${meta.name} เป็น E0S1 เรียบร้อยแล้ว!`, 'success');
}
// Quick Set Single Avatar to E6S5 (Lv 80 + Prom 6 + E6 + Max Skills + S5 Signature Light Cone)
async function setAvatarE6S5(avatarId) {
const aid = parseInt(avatarId, 10);
const meta = getAvatarInfo(aid);
const target = state.avatarsList.find(a => a.avatar_id === aid);
if (target) {
target.level = 80;
target.promotion = 6;
target.rank = 6;
}
if (!state.fullFreesrData) {
state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
}
state.fullFreesrData.avatars = state.fullFreesrData.avatars || {};
state.fullFreesrData.lightcones = state.fullFreesrData.lightcones || [];
if (!state.fullFreesrData.avatars[aid]) {
state.fullFreesrData.avatars[aid] = {
owner_uid: 0,
avatar_id: aid,
level: 80,
promotion: 6,
techniques: [],
sp_max: 140,
sp_value: 70,
data: { rank: 6, skills: {}, skills_by_anchor_type: {} }
};
}
const av = state.fullFreesrData.avatars[aid];
av.level = 80;
av.promotion = 6;
av.data = av.data || {};
av.data.rank = 6;
av.data.skills = {
[`${aid}001`]: 6,
[`${aid}002`]: 10,
[`${aid}003`]: 10,
[`${aid}004`]: 10,
[`${aid}007`]: 1,
[`${aid}101`]: 1,
[`${aid}102`]: 1,
[`${aid}103`]: 1
};
av.data.skills_by_anchor_type = {
"1": 6, "2": 10, "3": 10, "4": 10, "5": 1, "6": 1, "7": 1, "8": 1, "9": 1
};
const lcId = SIGNATURE_LIGHTCONES[aid] || 21001;
let existingLc = state.fullFreesrData.lightcones.find(lc => lc.equip_avatar === aid);
if (existingLc) {
existingLc.item_id = lcId;
existingLc.level = 80;
existingLc.promotion = 6;
existingLc.rank = 5;
} else {
const maxUid = state.fullFreesrData.lightcones.reduce((m, lc) => Math.max(m, lc.internal_uid || 0), 0);
state.fullFreesrData.lightcones.push({
internal_uid: maxUid + 1,
item_id: lcId,
level: 80,
promotion: 6,
rank: 5,
equip_avatar: aid
});
}
// Equip optimal relics
await autoEquipRecommendedRelics(aid, false);
renderAvatarsGrid();
loadEquipment();
if (state.isConnected) {
try {
if (state.serverMode === 'gac') {
await apiCall('/api/avatar/rank', 'POST', { avatar_id: aid, rank: 6 });
await apiCall('/api/freesr/avatars', 'POST', { avatar_id: aid, level: 80, promotion: 6, rank: 6 });
} else {
await saveFreesrChangesToServer();
}
} catch (e) {
console.warn('Sync E6S5 failed:', e);
}
}
if (state.selectedAvatarDetail?.avatar_id === aid) {
openAvatarDetailModal(target || { avatar_id: aid, level: 80, promotion: 6, rank: 6 });
}
showToast(`ตั้งค่า ${meta.name} เป็น E6S5 เต็มพลังเรียบร้อยแล้ว!`, 'success');
}
// Quick Max Single Avatar (Lv 80 + Prom 6 + E6 + Max Traces)
async function maxSingleAvatar(avatarId) {
await setAvatarE6S5(avatarId);
}
// Active Lightcone Superimposition Rank in Modal (1 - 5)
let selectedLcRank = 5;
// Modal Sub-tabs Handler
// Authoritative Signature / Best-in-Slot Light Cones dictionary for all characters
const SIGNATURE_LIGHTCONES = {
1001: 21001, // March 7th -> Day One of My New Life
1002: 21000, // Dan Heng -> Only Silence Remains
1003: 23000, // Himeko -> Night on the Milky Way
1004: 23004, // Welt -> In the Name of the World
1005: 23006, // Kafka -> Patience Is All You Need
1006: 23007, // Silver Wolf -> Incessant Rain
1008: 21007, // Arlan -> A Secret Vow
1009: 21021, // Asta -> Dance! Dance! Dance!
1013: 24004, // Herta -> Eternal Calculus
1014: 24000, // Saber -> On the Fall of an Aeon
1015: 24001, // Archer -> Cruising in the Stellar Sea
1101: 23003, // Bronya -> But the Battle Isn't Over
1102: 23001, // Seele -> In the Night
1103: 21009, // Serval -> Make the World Clamor
1104: 23005, // Gepard -> Moment of Victory
1105: 21010, // Natasha -> Post-Op Conversation
1106: 22000, // Pela -> Before the Tutorial Mission Starts (BiS standard)
1107: 23002, // Clara -> Something Irreplaceable
1108: 21011, // Sampo -> Eyes of the Prey
1109: 21012, // Hook -> The Moles Welcome You
1110: 21010, // Lynx -> Post-Op Conversation
1111: 21014, // Luka -> Resolution Shines As Pearls of Sweat
1112: 23016, // Topaz -> Worrisome, Blissful
1201: 21015, // Qingque -> Today Is Another Peaceful Day
1202: 21021, // Tingyun -> Dance! Dance! Dance!
1203: 23008, // Luocha -> Echoes of the Coffin
1204: 23010, // Jing Yuan -> Before Dawn
1205: 23009, // Blade -> The Unreachable Side
1206: 21017, // Sushang -> Swordplay
1207: 21018, // Yukong -> Past and Future
1208: 23011, // Fu Xuan -> She Already Shut Her Eyes
1209: 23012, // Yanqing -> Sleep Like the Dead
1210: 21011, // Guinaifen -> Eyes of the Prey
1211: 23013, // Bailu -> Time Waits for No One
1212: 23014, // Jingliu -> I Shall Be My Own Sword
1213: 23015, // Dan Heng IL -> Brighter Than the Sun
1214: 21035, // Xueyi -> Indelible Promise
1215: 21021, // Hanya -> Dance! Dance! Dance!
1217: 23017, // Huohuo -> Night of Fright
1218: 23029, // Jiaoqiu -> Those Many Springs
1220: 23031, // Feixiao -> I Venture Forth to Hunt
1221: 23030, // Yunli -> Dance at Sunset
1222: 23032, // Lingsha -> Scent Alone Stays True
1223: 21017, // Moze -> Swordplay
1224: 21017, // March 7th (Hunt) -> Swordplay
1225: 23035, // Fugue -> Long Road Leads Home
1301: 21022, // Gallagher -> What Is Real?
1302: 23018, // Argenti -> An Instant Before A Gaze
1303: 23019, // Ruan Mei -> Past Self in Mirror
1304: 23023, // Aventurine -> Inherently Unjust Destiny
1305: 23020, // Dr. Ratio -> Baptism of Pure Thought
1306: 23021, // Sparkle -> Earthly Escapade
1307: 23022, // Black Swan -> Reforged Remembrance
1308: 23024, // Acheron -> Along the Passing Shore
1309: 23026, // Robin -> Flowing Nightglow
1310: 23025, // Firefly -> Whereabouts Should Dreams Rest
1312: 21035, // Misha -> Indelible Promise
1313: 23034, // Sunday -> A Grounded Ascent
1314: 23028, // Jade -> Yet Hope Is Priceless
1315: 23027, // Boothill -> Sailing Towards a Second Life
1317: 23033, // Rappa -> Ninjutsu Inscription: Dazzling Evilbreaker
1321: 23022, // The Dahlia -> Reforged Remembrance
1401: 23037, // The Herta -> Into the Unreachable Veil
1402: 23036, // Aglaea -> Time Woven into Gold
1403: 23038, // Tribbie -> If Time Were a Flower
1404: 23039, // Mydei -> Flame of Blood, Blaze My Path
1405: 23041, // Anaxa -> Life Should Be Cast to Flames
1406: 23022, // Cipher
1407: 23040, // Castorice -> Make Farewells More Beautiful
1408: 23042, // Phainon
1511: 23065, // Aeon ★ Aha -> Upon the First Echo of "Aha"
8001: 24000, // Trailblazer Male Destruction -> On the Fall of an Aeon
8002: 24000, // Trailblazer Female Destruction
8003: 24002, // Trailblazer Male Preservation -> Texture of Memories
8004: 24002, // Trailblazer Female Preservation
8005: 21016, // Trailblazer Male Harmony -> Memories of the Past
8006: 21016, // Trailblazer Female Harmony
8007: 21040, // Trailblazer Remembrance
8008: 21040
};
// Comprehensive Meta Optimal Relic Builds (Cavern, Planar, Main Affixes & Top Substats)
// Substats ID reference:
// 1: Flat HP, 2: Flat ATK, 3: Flat DEF, 4: HP%, 5: ATK%, 6: DEF%
// 7: SPD, 8: CRIT Rate%, 9: CRIT DMG%, 10: EHR%, 11: Effect RES%, 12: Break Effect%
const CHARACTER_OPTIMAL_BUILDS = {
1308: { cavern: 117, planar: 313, body: 4, feet: 2, sphere: 7, rope: 4, substats: [8, 9, 7, 5] }, // Acheron
1310: { cavern: 119, planar: 315, body: 2, feet: 4, sphere: 2, rope: 1, substats: [12, 7, 5, 11] }, // Firefly
1220: { cavern: 120, planar: 314, body: 4, feet: 4, sphere: 8, rope: 4, substats: [8, 9, 7, 5] }, // Feixiao
1315: { cavern: 119, planar: 305, body: 4, feet: 4, sphere: 4, rope: 1, substats: [12, 7, 8, 9] }, // Boothill
1221: { cavern: 120, planar: 314, body: 4, feet: 2, sphere: 4, rope: 4, substats: [8, 9, 5, 1] }, // Yunli
1313: { cavern: 121, planar: 316, body: 5, feet: 4, sphere: 1, rope: 2, substats: [9, 7, 11, 4] }, // Sunday
1401: { cavern: 122, planar: 317, body: 4, feet: 2, sphere: 6, rope: 4, substats: [8, 9, 5, 7] }, // The Herta
1106: { cavern: 110, planar: 312, body: 7, feet: 4, sphere: 1, rope: 2, substats: [7, 10, 11, 4] }, // Pela
1006: { cavern: 117, planar: 312, body: 7, feet: 4, sphere: 9, rope: 2, substats: [7, 10, 8, 9] }, // Silver Wolf
1303: { cavern: 118, planar: 307, body: 1, feet: 4, sphere: 1, rope: 2, substats: [12, 7, 11, 4] }, // Ruan Mei
1309: { cavern: 114, planar: 316, body: 2, feet: 2, sphere: 4, rope: 2, substats: [5, 7, 11, 4] }, // Robin
1306: { cavern: 114, planar: 310, body: 5, feet: 4, sphere: 1, rope: 2, substats: [9, 7, 11, 4] }, // Sparkle
1101: { cavern: 114, planar: 310, body: 5, feet: 4, sphere: 8, rope: 2, substats: [9, 7, 11, 6] }, // Bronya
1202: { cavern: 114, planar: 307, body: 2, feet: 4, sphere: 2, rope: 2, substats: [7, 5, 11, 4] }, // Tingyun
1304: { cavern: 103, planar: 314, body: 5, feet: 4, sphere: 10, rope: 5, substats: [6, 9, 8, 7] }, // Aventurine
1208: { cavern: 113, planar: 302, body: 1, feet: 4, sphere: 1, rope: 2, substats: [7, 4, 6, 11] }, // Fu Xuan
1217: { cavern: 101, planar: 302, body: 6, feet: 4, sphere: 1, rope: 2, substats: [7, 4, 11, 6] }, // Huohuo
1222: { cavern: 119, planar: 315, body: 6, feet: 4, sphere: 2, rope: 2, substats: [12, 7, 5, 11] }, // Lingsha
1301: { cavern: 119, planar: 315, body: 6, feet: 4, sphere: 1, rope: 2, substats: [12, 7, 11, 4] }, // Gallagher
1203: { cavern: 101, planar: 302, body: 6, feet: 4, sphere: 2, rope: 2, substats: [7, 5, 11, 4] }, // Luocha
1005: { cavern: 116, planar: 311, body: 2, feet: 4, sphere: 7, rope: 4, substats: [7, 5, 10, 12] }, // Kafka
1307: { cavern: 116, planar: 308, body: 7, feet: 4, sphere: 8, rope: 4, substats: [10, 7, 5, 12] }, // Black Swan
1218: { cavern: 116, planar: 308, body: 7, feet: 4, sphere: 5, rope: 2, substats: [10, 7, 11, 5] }, // Jiaoqiu
1212: { cavern: 104, planar: 309, body: 5, feet: 4, sphere: 6, rope: 4, substats: [9, 8, 7, 5] }, // Jingliu
1213: { cavern: 102, planar: 309, body: 4, feet: 2, sphere: 10, rope: 4, substats: [8, 9, 5, 7] }, // Dan Heng IL
1305: { cavern: 117, planar: 314, body: 4, feet: 4, sphere: 10, rope: 4, substats: [8, 9, 7, 5] }, // Dr. Ratio
1112: { cavern: 115, planar: 314, body: 4, feet: 4, sphere: 5, rope: 4, substats: [8, 9, 7, 5] }, // Topaz
1314: { cavern: 115, planar: 314, body: 4, feet: 2, sphere: 9, rope: 4, substats: [8, 9, 5, 7] }, // Jade
1317: { cavern: 119, planar: 315, body: 2, feet: 4, sphere: 10, rope: 1, substats: [12, 7, 5, 11] }, // Rappa
1225: { cavern: 119, planar: 315, body: 7, feet: 4, sphere: 5, rope: 1, substats: [12, 7, 10, 11] }, // Fugue
1402: { cavern: 120, planar: 314, body: 4, feet: 4, sphere: 7, rope: 4, substats: [8, 9, 7, 5] }, // Aglaea
1407: { cavern: 122, planar: 317, body: 4, feet: 4, sphere: 9, rope: 4, substats: [8, 9, 7, 5] }, // Castorice
1405: { cavern: 122, planar: 317, body: 4, feet: 4, sphere: 8, rope: 4, substats: [8, 9, 7, 5] }, // Anaxa
1403: { cavern: 122, planar: 317, body: 4, feet: 4, sphere: 9, rope: 2, substats: [8, 9, 7, 4] }, // Tribbie
1404: { cavern: 120, planar: 314, body: 4, feet: 4, sphere: 10, rope: 4, substats: [8, 9, 7, 5] }, // Mydei
1102: { cavern: 108, planar: 309, body: 4, feet: 2, sphere: 9, rope: 4, substats: [8, 9, 5, 7] }, // Seele
1204: { cavern: 115, planar: 306, body: 4, feet: 2, sphere: 7, rope: 4, substats: [8, 9, 5, 7] }, // Jing Yuan
1205: { cavern: 113, planar: 306, body: 4, feet: 1, sphere: 8, rope: 3, substats: [8, 9, 4, 7] }, // Blade
1302: { cavern: 106, planar: 306, body: 5, feet: 4, sphere: 4, rope: 2, substats: [8, 9, 7, 5] }, // Argenti
1107: { cavern: 106, planar: 314, body: 4, feet: 2, sphere: 4, rope: 4, substats: [8, 9, 5, 1] }, // Clara
1003: { cavern: 115, planar: 306, body: 4, feet: 2, sphere: 5, rope: 4, substats: [8, 9, 5, 7] }, // Himeko
1004: { cavern: 117, planar: 308, body: 4, feet: 4, sphere: 10, rope: 4, substats: [8, 9, 7, 10] }, // Welt
1104: { cavern: 103, planar: 304, body: 3, feet: 4, sphere: 3, rope: 2, substats: [6, 7, 11, 10] }, // Gepard
1211: { cavern: 101, planar: 302, body: 6, feet: 4, sphere: 1, rope: 2, substats: [7, 4, 11, 6] }, // Bailu
1209: { cavern: 104, planar: 306, body: 5, feet: 4, sphere: 6, rope: 4, substats: [9, 8, 7, 5] }, // Yanqing
1013: { cavern: 115, planar: 306, body: 4, feet: 2, sphere: 6, rope: 4, substats: [8, 9, 5, 7] }, // Herta
1224: { cavern: 102, planar: 314, body: 4, feet: 4, sphere: 10, rope: 4, substats: [8, 9, 7, 5] }, // March 7th (Hunt)
8005: { cavern: 118, planar: 305, body: 1, feet: 4, sphere: 1, rope: 2, substats: [12, 7, 11, 4] }, // TB Harmony (M)
8006: { cavern: 118, planar: 305, body: 1, feet: 4, sphere: 1, rope: 2, substats: [12, 7, 11, 4] }, // TB Harmony (F)
8007: { cavern: 121, planar: 316, body: 5, feet: 4, sphere: 1, rope: 2, substats: [9, 7, 11, 4] }, // TB Remembrance
8008: { cavern: 121, planar: 316, body: 5, feet: 4, sphere: 1, rope: 2, substats: [9, 7, 11, 4] },
1223: { cavern: 117, planar: 314, body: 4, feet: 2, sphere: 7, rope: 4, substats: [8, 9, 5, 7] }, // Moze
1201: { cavern: 108, planar: 309, body: 4, feet: 4, sphere: 9, rope: 4, substats: [8, 9, 5, 7] }, // Qingque
1214: { cavern: 119, planar: 305, body: 4, feet: 4, sphere: 9, rope: 1, substats: [12, 8, 9, 7] }, // Xueyi
1210: { cavern: 116, planar: 311, body: 2, feet: 4, sphere: 5, rope: 4, substats: [10, 7, 5, 12] }, // Guinaifen
1111: { cavern: 106, planar: 311, body: 2, feet: 4, sphere: 4, rope: 4, substats: [7, 5, 10, 12] }, // Luka
1108: { cavern: 116, planar: 308, body: 2, feet: 4, sphere: 8, rope: 4, substats: [10, 7, 5, 12] }, // Sampo
1103: { cavern: 112, planar: 306, body: 4, feet: 4, sphere: 7, rope: 4, substats: [8, 9, 7, 5] }, // Serval
1002: { cavern: 110, planar: 309, body: 4, feet: 4, sphere: 8, rope: 4, substats: [8, 9, 7, 5] }, // Dan Heng
1206: { cavern: 106, planar: 309, body: 4, feet: 4, sphere: 4, rope: 4, substats: [8, 9, 7, 12] }, // Sushang
1109: { cavern: 109, planar: 306, body: 4, feet: 2, sphere: 5, rope: 4, substats: [8, 9, 5, 7] }, // Hook
1008: { cavern: 113, planar: 306, body: 4, feet: 4, sphere: 7, rope: 4, substats: [8, 9, 5, 7] }, // Arlan
1110: { cavern: 101, planar: 302, body: 6, feet: 4, sphere: 1, rope: 2, substats: [7, 4, 11, 6] }, // Lynx
1105: { cavern: 101, planar: 302, body: 6, feet: 4, sphere: 1, rope: 2, substats: [7, 4, 11, 6] }, // Natasha
1215: { cavern: 114, planar: 310, body: 1, feet: 4, sphere: 1, rope: 2, substats: [7, 4, 11, 6] }, // Hanya
1207: { cavern: 114, planar: 310, body: 4, feet: 4, sphere: 10, rope: 2, substats: [7, 8, 9, 11] }, // Yukong
1009: { cavern: 114, planar: 302, body: 1, feet: 4, sphere: 1, rope: 2, substats: [7, 4, 11, 6] }, // Asta
1312: { cavern: 104, planar: 306, body: 4, feet: 4, sphere: 6, rope: 4, substats: [8, 9, 7, 5] } // Misha
};
function getOptimalBuildForAvatar(avatarId) {
const aid = parseInt(avatarId, 10);
if (CHARACTER_OPTIMAL_BUILDS[aid]) {
return CHARACTER_OPTIMAL_BUILDS[aid];
}
const meta = getAvatarInfo(aid);
const elem = normalizeElement(meta.rawElement || meta.element);
const path = normalizePath(meta.rawPath || meta.path);
const elemAffixMap = {
Physical: 4, Fire: 5, Ice: 6, Lightning: 7, Thunder: 7, Wind: 8, Quantum: 9, Imaginary: 10
};
const sphereAffix = elemAffixMap[elem] || 2;
if (path === 'Destruction') {
return { cavern: 119, planar: 314, body: 4, feet: 4, sphere: sphereAffix, rope: 4, substats: [8, 9, 7, 5] };
} else if (path === 'Hunt') {
return { cavern: 120, planar: 314, body: 4, feet: 4, sphere: sphereAffix, rope: 4, substats: [8, 9, 7, 5] };
} else if (path === 'Erudition') {
return { cavern: 115, planar: 306, body: 4, feet: 2, sphere: sphereAffix, rope: 4, substats: [8, 9, 5, 7] };
} else if (path === 'Harmony') {
return { cavern: 114, planar: 310, body: 1, feet: 4, sphere: 1, rope: 2, substats: [7, 11, 4, 6] };
} else if (path === 'Nihility') {
if (['Fire', 'Wind'].includes(elem)) {
return { cavern: 116, planar: 311, body: 2, feet: 4, sphere: sphereAffix, rope: 4, substats: [7, 5, 10, 12] };
}
return { cavern: 110, planar: 312, body: 7, feet: 4, sphere: sphereAffix, rope: 2, substats: [7, 10, 11, 4] };
} else if (path === 'Preservation') {
return { cavern: 103, planar: 304, body: 3, feet: 4, sphere: 3, rope: 5, substats: [6, 7, 11, 4] };
} else if (path === 'Abundance') {
return { cavern: 101, planar: 302, body: 6, feet: 4, sphere: 1, rope: 2, substats: [7, 4, 11, 6] };
} else if (path === 'Remembrance') {
return { cavern: 121, planar: 316, body: 4, feet: 4, sphere: sphereAffix, rope: 2, substats: [8, 9, 7, 11] };
}
return { cavern: 120, planar: 314, body: 4, feet: 4, sphere: sphereAffix, rope: 4, substats: [8, 9, 7, 5] };
}
// Recommended Relic Builds by Path (Fallback summary)
const RECOMMENDED_RELIC_BUILDS = {
Preservation: { cavern: 103, planar: 304, name: "Knight of Purity (4) + Belobog (2)" },
Abundance: { cavern: 101, planar: 302, name: "Passerby (4) + Fleet (2)" },
Harmony: { cavern: 114, planar: 310, name: "Messenger (4) + Broken Keel (2)" },
Nihility: { cavern: 110, planar: 312, name: "Eagle (4) + Penacony (2)" },
Hunt: { cavern: 120, planar: 314, name: "Valorous (4) + Duran (2)" },
Destruction: { cavern: 119, planar: 315, name: "Iron Cavalry (4) + Kalpagni (2)" },
Erudition: { cavern: 115, planar: 306, name: "Grand Duke (4) + Inert Salsotto (2)" },
Remembrance: { cavern: 121, planar: 316, name: "Sacerdos (4) + Lushaka (2)" },
Elation: { cavern: 115, planar: 306, name: "Grand Duke (4) + Inert Salsotto (2)" }
};
// Lightcone Art URL Resolvers (Metadata -> Gachabase CDN -> Local asset -> StarRailRes)
function getLightconeArtSrc(lcId) {
const id = parseInt(lcId, 10);
const meta = LIGHTCONE_METADATA[id];
if (meta?.images?.fullArt) return meta.images.fullArt;
if (meta?.images?.thumbnail) return meta.images.thumbnail;
if (GACHABASE_IMAGE_CACHE.lightcones[id]) return GACHABASE_IMAGE_CACHE.lightcones[id];
if (KNOWN_GACHABASE_ASSETS.lightcones[id]) return KNOWN_GACHABASE_ASSETS.lightcones[id];
if (id === 23065) {
return './assets/lightcones/23065.png';
}
return `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/image/light_cone_preview/${id}.png`;
}
function getLightconeArtFallback(lcId) {
const id = parseInt(lcId, 10);
const meta = LIGHTCONE_METADATA[id];
if (meta?.images?.thumbnail) return meta.images.thumbnail;
if (meta?.images?.fullArt) return meta.images.fullArt;
if (GACHABASE_IMAGE_CACHE.lightconeThumbs[id]) return GACHABASE_IMAGE_CACHE.lightconeThumbs[id];
if (KNOWN_GACHABASE_ASSETS.lightconeThumbs[id]) return KNOWN_GACHABASE_ASSETS.lightconeThumbs[id];
if (GACHABASE_IMAGE_CACHE.lightcones[id]) return GACHABASE_IMAGE_CACHE.lightcones[id];
if (KNOWN_GACHABASE_ASSETS.lightcones[id]) return KNOWN_GACHABASE_ASSETS.lightcones[id];
if (id === 23065) {
return 'https://cdn.gachabase.net/hsr/assets/500cfc18698932759756f6b3ba4db417.png';
}
return `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/image/light_cone_preview/${id}.png`;
}
function getAvatarIconSrc(aid) {
const id = parseInt(aid, 10);
const meta = AVATAR_METADATA[id];
if (meta?.images?.icon) return meta.images.icon;
if (meta?.images?.square) return meta.images.square;
if (GACHABASE_IMAGE_CACHE.avatars[id]) return GACHABASE_IMAGE_CACHE.avatars[id];
if (KNOWN_GACHABASE_ASSETS.avatars[id]) return KNOWN_GACHABASE_ASSETS.avatars[id];
return `./assets/avatars/${id}.png`;
}
function getAvatarSplashSrc(aid) {
const id = parseInt(aid, 10);
const meta = AVATAR_METADATA[id];
if (meta?.images?.splash) return meta.images.splash;
if (meta?.images?.fullArt) return meta.images.fullArt;
if (GACHABASE_IMAGE_CACHE.avatarSplash[id]) return GACHABASE_IMAGE_CACHE.avatarSplash[id];
if (KNOWN_GACHABASE_ASSETS.avatarSplash[id]) return KNOWN_GACHABASE_ASSETS.avatarSplash[id];
return `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/image/character_portrait/${id}.png`;
}
// Modal Sub-tabs Handler
function switchModalSubtab(targetId) {
const tabBtns = document.querySelectorAll('.modal-subtab-btn');
tabBtns.forEach(b => {
if (b.getAttribute('data-modaltab') === targetId) {
b.classList.add('active');
} else {
b.classList.remove('active');
}
});
document.querySelectorAll('.modal-subtab-content').forEach(c => {
if (c.id === targetId) {
c.style.display = 'block';
c.classList.add('active');
} else {
c.style.display = 'none';
c.classList.remove('active');
}
});
if (state.selectedAvatarDetail) {
const aid = state.selectedAvatarDetail.avatar_id;
if (targetId === 'modaltab-showcase') {
renderShowcaseCard(aid);
} else if (targetId === 'modaltab-overview') {
renderModalOverviewTab(aid);
} else if (targetId === 'modaltab-relics') {
renderModalRelicsTab(aid);
} else if (targetId === 'modaltab-skills') {
renderModalSkillsTab(aid);
}
}
}
function setupModalSubtabs() {
const tabBtns = document.querySelectorAll('.modal-subtab-btn');
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.getAttribute('data-modaltab');
switchModalSubtab(targetId);
});
});
}
function updateLcRankPips(rank) {
selectedLcRank = rank;
document.querySelectorAll('.lc-super-pip').forEach(btn => {
const r = parseInt(btn.getAttribute('data-rank'), 10);
if (r === rank) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
// Lightcone Passive Skill Descriptions with dynamic rank values
function getLightconePassiveDesc(lcId, rank = 5) {
const id = parseInt(lcId, 10);
const r = Math.max(1, Math.min(5, rank));
const passives = {
21001: `ทำให้ DEF ของผู้สวมใส่เพิ่มขึ้น ${16 + (r - 1) * 2}% หลังจากเข้าสู่การต่อสู้ จะทำให้ความต้านทานทุกประเภทของตัวละครฝ่ายเราทั้งหมดเพิ่มขึ้น ${8 + (r - 1)}% โดยสกิลชนิดเดียวกันจะไม่สามารถซ้อนทับได้`,
23024: `ทำให้ CRIT DMG ของผู้สวมใส่เพิ่มขึ้น ${36 + (r - 1) * 6}% เมื่อโจมตีโดนเป้าหมาย จะติดสถานะ Mirage Fizzle ทำให้ได้รับความเสียหายเพิ่มขึ้น ${24 + (r - 1) * 4}% และความเสียหายท่าไม้ตายเพิ่มขึ้น ${24 + (r - 1) * 4}%`,
23025: `ทำให้ Break Effect ของผู้สวมใส่เพิ่มขึ้น ${60 + (r - 1) * 10}% เมื่อสร้าง Break DMG จะทำให้เป้าหมายติดสถานะ Routed ได้รับ Break DMG เพิ่มขึ้น ${24 + (r - 1) * 4}% และลด SPD ลง 20%`,
23001: `ทำให้ CRIT Rate ของผู้สวมใส่เพิ่มขึ้น ${18 + (r - 1) * 3}% ระหว่างการต่อสู้ ทุกๆ 10 หน่วยของ SPD ที่เกิน 100 จะเพิ่ม DMG การโจมตีปกติและสกิล ${6 + (r - 1)}% และเพิ่ม CRIT DMG ท่าไม้ตาย ${12 + (r - 1) * 2}%`,
23010: `ทำให้ CRIT DMG ของผู้สวมใส่เพิ่มขึ้น ${36 + (r - 1) * 6}% เพิ่ม DMG สกิลและท่าไม้ตาย ${18 + (r - 1) * 3}% และเสริม Somnus Corpus เพิ่ม DMG โจมตีต่อเนื่อง ${48 + (r - 1) * 8}%`,
23019: `ทำให้ Break Effect ของผู้สวมใส่เพิ่มขึ้น ${60 + (r - 1) * 10}% หลังจากผู้สวมใส่ปล่อยท่าไม้ตาย ทีมสร้าง DMG เพิ่มขึ้น ${24 + (r - 1) * 4}% 3 เทิร์น และฟื้นฟูแต้มสกิล 1 แต้ม`,
23022: `ทำให้ DEF ของผู้สวมใส่เพิ่มขึ้น ${40 + (r - 1) * 5}% เมื่อมอบโล่ให้เพื่อนร่วมทีม จะเพิ่ม CRIT DMG ${40 + (r - 1) * 5}% 2 เทิร์น และทำให้ศัตรูได้รับ DMG เพิ่มขึ้น ${10 + (r - 1) * 2}%`,
23026: `เมื่อเพื่อนร่วมทีมโจมตี ผู้สวมใส่จะได้รับ Cantillation 1 ชั้น แต่ละชั้นเพิ่ม Energy Regeneration Rate ${3 + (r - 1) * 0.5}% สูงสุด 5 ชั้น และเพิ่ม ATK ของทั้งทีม ${48 + (r - 1) * 8}%`,
23006: `ทำให้ DMG ของผู้สวมใส่เพิ่มขึ้น ${24 + (r - 1) * 4}% หลังการโจมตีแต่ละครั้ง SPD เพิ่มขึ้น ${4.8 + (r - 1) * 0.8}% สูงสุด 3 ชั้น และทำให้เป้าหมายติดสถานะ Erode เกิด DoT สายฟ้า ${60 + (r - 1) * 10}%`,
23030: `ทำให้ CRIT Rate ของผู้สวมใส่เพิ่มขึ้น ${15 + (r - 1) * 2.5}% เมื่อโจมตีต่อเนื่อง ได้รับ Luminflux ซ้อนทับ 2 ชั้น แต่ละชั้นทำให้ท่าไม้ตายเมิน DEF ศัตรู ${27 + (r - 1) * 4.5}%`,
23014: `ทำให้ CRIT DMG ของผู้สวมใส่เพิ่มขึ้น ${20 + (r - 1) * 3}% เมื่อเพื่อนร่วมทีมเสีย HP จะได้รับ Eclipse ซ้อนทับ 3 ชั้น ทำให้การโจมตีถัดไปสร้าง DMG เพิ่มขึ้น ${14 + (r - 1) * 2.5}% และเมิน DEF ${12 + (r - 1) * 2}%`,
23015: `ทำให้ CRIT Rate ของผู้สวมใส่เพิ่มขึ้น ${18 + (r - 1) * 3}% เมื่อปล่อย Basic ATK จะได้รับ Dragons Call เพิ่ม ATK ${18 + (r - 1) * 3}% และ Energy Regeneration Rate ${6 + (r - 1)}%`,
23033: `ทำให้ Break Effect ของผู้สวมใส่เพิ่มขึ้น ${60 + (r - 1) * 10}% เมื่อสร้าง Break DMG จะทำให้ศัตรูติดสถานะ Vulnerability ได้รับ DMG เพิ่มขึ้น ${18 + (r - 1) * 3}%`,
23032: `ทำให้ Energy Regeneration Rate ของผู้สวมใส่เพิ่มขึ้น ${12 + (r - 1) * 2}% เมื่อปล่อยสกิลต่อสู้หรือท่าไม้ตาย จะเพิ่ม CRIT DMG ของพันธมิตร ${48 + (r - 1) * 8}%`,
23034: `ทำให้ CRIT Rate ของผู้สวมใส่เพิ่มขึ้น ${12 + (r - 1) * 2}% เมื่อปล่อยท่าไม้ตาย จะสร้าง DMG น้ำแข็งเพิ่มเติม ${60 + (r - 1) * 10}% ของ ATK แก่ศัตรูทั้งหมด`,
23036: `ทำให้ HP สูงสุดของผู้สวมใส่เพิ่มขึ้น ${30 + (r - 1) * 5}% และเมื่ออยู่ในสนามรบ สมาชิกทีมทุกคนสร้าง DMG เพิ่มขึ้น ${24 + (r - 1) * 4}%`,
23065: `ทำให้ความเร็วพื้นฐานของผู้สวมใส่เพิ่มขึ้น ${12 + (r - 1) * 3} เมื่อใช้สกิลการต่อสู้กับเพื่อนร่วมทีม 1 ตัว จะได้รับ "Punchline" 10 แต้ม (เกิดได้สูงสุด 1 ครั้งทุกๆ 1 เทิร์น) และทำให้เพื่อนร่วมทีมทั้งหมดเมิน DEF ศัตรู ${16 + (r - 1) * 4}% เมื่อสร้าง DMG ในการต่อสู้นี้ (ไม่ซ้อนทับ)`,
24001: `เมื่อผู้สวมใส่โจมตี จะเพิ่ม ATK ${8 + (r - 1) * 2}% สูงสุด 4 ชั้น และเมื่อทำลายจุดอ่อนศัตรู จะเพิ่ม DMG ${12 + (r - 1) * 3}% เป็นเวลา 2 เทิร์น`
};
if (passives[id]) return passives[id];
const meta = LIGHTCONE_METADATA[id];
if (meta?.desc) return meta.desc;
const pathName = meta ? translatePath(meta.path) : "สายต่อสู้";
return `เพิ่มค่าสถานะพื้นฐานตามสาย ${pathName} และเพิ่มประสิทธิภาพความเสียหายและการสนับสนุน ${12 + (r - 1) * 3}% เมื่อเข้าสู่การต่อสู้`;
}
// Update Modal Lightcone Full Artwork and Details (Matching srtools / Firefly)
function updateModalLcFullView(lcId, rank = 5, level = 80) {
const id = parseInt(lcId, 10);
if (!id || !LIGHTCONE_METADATA[id]) {
if (el.modalLcFullImg) el.modalLcFullImg.style.display = 'none';
if (el.modalLcNoArt) el.modalLcNoArt.style.display = 'block';
if (el.modalLcNameDisplay) el.modalLcNameDisplay.textContent = 'ยังไม่ได้สวมใส่ Light Cone';
if (el.modalLcMetaDisplay) el.modalLcMetaDisplay.innerHTML = '';
if (el.modalLcRankBadge) el.modalLcRankBadge.textContent = '-';
if (el.modalLcPassiveDesc) el.modalLcPassiveDesc.innerHTML = 'กรุณาเลือก Light Cone ที่ต้องการสวมใส่';
return;
}
const meta = LIGHTCONE_METADATA[id];
const info = getLightconeInfo(id);
if (el.modalLcId) el.modalLcId.value = id;
if (el.modalLcLevel) el.modalLcLevel.value = level;
if (el.rangeLcLevel) el.rangeLcLevel.value = level;
if (el.modalLcLvlVal) el.modalLcLvlVal.textContent = `${level} / 80`;
// Full artwork image
if (el.modalLcFullImg) {
el.modalLcFullImg.style.display = 'block';
if (el.modalLcNoArt) el.modalLcNoArt.style.display = 'none';
const primarySrc = getLightconeArtSrc(id);
const fallbackSrc = getLightconeArtFallback(id);
el.modalLcFullImg.src = primarySrc;
el.modalLcFullImg.onerror = function() {
if (this.src !== fallbackSrc) {
this.src = fallbackSrc;
}
};
}
if (el.modalLcArtWrapper) {
el.modalLcArtWrapper.className = `lc-art-wrapper rarity-${info.rarity}`;
}
if (el.modalLcNameDisplay) {
el.modalLcNameDisplay.textContent = info.name;
}
if (el.modalLcMetaDisplay) {
el.modalLcMetaDisplay.innerHTML = `
${getPathIconHtml(meta.path, 22)}
${info.path}
★ ${info.rarity}
(ID: ${id})
`;
}
if (el.modalLcRankBadge) {
el.modalLcRankBadge.textContent = `S${rank} • Lv.${level}`;
}
updateLcRankPips(rank);
if (el.modalLcPassiveDesc) {
el.modalLcPassiveDesc.innerHTML = getLightconePassiveDesc(id, rank);
}
if (el.selectModalChangeLc) {
el.selectModalChangeLc.value = id;
}
}
// Populate Modal Change Lightcone Dropdown
function populateModalChangeLcDropdown(avatarPath) {
if (!el.selectModalChangeLc) return;
el.selectModalChangeLc.innerHTML = '';
const allIds = Object.keys(LIGHTCONE_METADATA).sort((a, b) => {
const metaA = LIGHTCONE_METADATA[a];
const metaB = LIGHTCONE_METADATA[b];
if (metaB.rarity !== metaA.rarity) return metaB.rarity - metaA.rarity;
return parseInt(b, 10) - parseInt(a, 10);
});
const samePathGroup = document.createElement('optgroup');
samePathGroup.label = `⭐ Light Cones สาย ${avatarPath}`;
const otherPathGroup = document.createElement('optgroup');
otherPathGroup.label = `🗡️ Light Cones สายอื่นๆ`;
allIds.forEach(id => {
const meta = LIGHTCONE_METADATA[id];
const info = getLightconeInfo(id);
const opt = document.createElement('option');
opt.value = id;
opt.textContent = `★${info.rarity} [${info.path}] ${info.name} (ID: ${id})`;
if (normalizePath(meta.path) === normalizePath(avatarPath)) {
samePathGroup.appendChild(opt);
} else {
otherPathGroup.appendChild(opt);
}
});
if (samePathGroup.children.length > 0) {
el.selectModalChangeLc.appendChild(samePathGroup);
}
el.selectModalChangeLc.appendChild(otherPathGroup);
}
// Slot full definitions for relics
const SLOT_NAMES_FULL = {
1: { name: "ส่วนหัว (Head)", icon: "🧢", defaultStat: "HP (Flat)" },
2: { name: "ส่วนมือ (Hands)", icon: "🧤", defaultStat: "ATK (Flat)" },
3: { name: "ส่วนลำตัว (Body)", icon: "🦺", defaultStat: "CRIT Rate / DMG" },
4: { name: "ส่วนเท้า (Feet)", icon: "👢", defaultStat: "SPD / ATK%" },
5: { name: "ลูกแก้ว (Planar Sphere)", icon: "🔮", defaultStat: "Elemental DMG%" },
6: { name: "สร้อยเชื่อม (Link Rope)", icon: "🔗", defaultStat: "ERR / Break / ATK%" }
};
function getRelicMainAffixName(slot, affixId) {
const affixesBySlot = {
1: { 1: "HP (Flat)" },
2: { 1: "ATK (Flat)" },
3: { 1: "HP%", 2: "ATK%", 3: "DEF%", 4: "CRIT Rate%", 5: "CRIT DMG%", 6: "Healing Boost%", 7: "Effect Hit Rate%" },
4: { 1: "HP%", 2: "ATK%", 3: "DEF%", 4: "SPD" },
5: { 1: "HP%", 2: "ATK%", 3: "DEF%", 4: "Physical DMG%", 5: "Fire DMG%", 6: "Ice DMG%", 7: "Lightning DMG%", 8: "Wind DMG%", 9: "Quantum DMG%", 10: "Imaginary DMG%" },
6: { 1: "Break Effect%", 2: "Energy Regen Rate%", 3: "HP%", 4: "ATK%", 5: "DEF%" }
};
return affixesBySlot[slot]?.[affixId] || `Main Affix ${affixId}`;
}
function getRelicSubAffixName(affixId) {
const map = {
1: "HP (Flat)",
2: "ATK (Flat)",
3: "DEF (Flat)",
4: "HP%",
5: "ATK%",
6: "DEF%",
7: "SPD",
8: "CRIT Rate%",
9: "CRIT DMG%",
10: "Effect Hit Rate%",
11: "Effect RES%",
12: "Break Effect%"
};
return map[affixId] || `Sub Affix ${affixId}`;
}
export const RELIC_SUBSTATS_CONFIG = [
{ id: 8, name: "CRIT Rate%", short: "CRIT Rate", icon: "🎯" },
{ id: 9, name: "CRIT DMG%", short: "CRIT DMG", icon: "💥" },
{ id: 7, name: "SPD", short: "SPD", icon: "⚡" },
{ id: 5, name: "ATK%", short: "ATK%", icon: "⚔️" },
{ id: 4, name: "HP%", short: "HP%", icon: "❤️" },
{ id: 6, name: "DEF%", short: "DEF%", icon: "🛡️" },
{ id: 12, name: "Break Effect%", short: "Break", icon: "🔨" },
{ id: 10, name: "Effect Hit Rate%", short: "EHR", icon: "🎯" },
{ id: 11, name: "Effect RES%", short: "RES", icon: "🔰" },
{ id: 2, name: "ATK (Flat)", short: "ATK", icon: "🗡️" },
{ id: 1, name: "HP (Flat)", short: "HP", icon: "🩸" },
{ id: 3, name: "DEF (Flat)", short: "DEF", icon: "🧱" }
];
export function formatRelicSubstatValue(affixId, rolls = 1) {
const r = parseInt(rolls, 10) || 1;
switch (parseInt(affixId, 10)) {
case 1: return `${Math.round(42 * r)}`;
case 2: return `${Math.round(21 * r)}`;
case 3: return `${Math.round(21 * r)}`;
case 4: return `${(4.3 * r).toFixed(1)}%`;
case 5: return `${(4.3 * r).toFixed(1)}%`;
case 6: return `${(5.4 * r).toFixed(1)}%`;
case 7: return `${(2.6 * r).toFixed(1)}`;
case 8: return `${(3.2 * r).toFixed(1)}%`;
case 9: return `${(6.4 * r).toFixed(1)}%`;
case 10: return `${(4.3 * r).toFixed(1)}%`;
case 11: return `${(4.3 * r).toFixed(1)}%`;
case 12: return `${(6.4 * r).toFixed(1)}%`;
default: return `${r}`;
}
}
export function getConflictingSubstat(slot, mainAffixId) {
const map = {
1: { 1: 1 },
2: { 1: 2 },
3: { 1: 4, 2: 5, 3: 6, 4: 8, 5: 9, 7: 10 },
4: { 1: 4, 2: 5, 3: 6, 4: 7 },
5: { 1: 4, 2: 5, 3: 6 },
6: { 1: 12, 3: 4, 4: 5, 5: 6 }
};
return map[slot]?.[mainAffixId] || null;
}
const STAR_RAIL_RES_LANGS = {
th: 'th', en: 'en', es: 'es', pt: 'pt', ru: 'ru', vi: 'vi',
de: 'de', fr: 'fr', id: 'id', ja: 'jp', ko: 'kr', zh: 'cn', zh_tw: 'cht'
};
async function loadCharacterSkillsData(lang = null) {
const currentLang = lang || getCurrentLanguage() || 'th';
const resLang = STAR_RAIL_RES_LANGS[currentLang] || 'th';
if (state.skillsData && state.skillsData[resLang] && state.skillTreesData && state.skillTreesData[resLang]) {
return { skills: state.skillsData[resLang], trees: state.skillTreesData[resLang] };
}
state.skillsData = state.skillsData || {};
state.skillTreesData = state.skillTreesData || {};
try {
const [skillsRes, treesRes] = await Promise.all([
fetch(`https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/index_min/${resLang}/character_skills.json`).then(r => r.json()),
fetch(`https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/index_min/${resLang}/character_skill_trees.json`).then(r => r.json())
]);
state.skillsData[resLang] = skillsRes;
state.skillTreesData[resLang] = treesRes;
return { skills: skillsRes, trees: treesRes };
} catch (err) {
console.warn('Failed to fetch StarRailRes skills metadata:', err);
return { skills: state.skillsData[resLang] || {}, trees: state.skillTreesData[resLang] || {} };
}
}
function formatSkillDesc(descTemplate, paramsArray, level) {
if (!descTemplate) return '';
const lvlIndex = Math.max(0, Math.min(level - 1, (paramsArray?.length || 1) - 1));
const lvlParams = paramsArray?.[lvlIndex] || [];
let formatted = descTemplate;
lvlParams.forEach((param, idx) => {
const pNum = idx + 1;
const isPercent = formatted.includes(`#${pNum}[i]%`) || formatted.includes(`#${pNum}[f1]%`) || formatted.includes(`#${pNum}[f2]%`);
const valStr = isPercent ? `${Math.round(param * 1000) / 10}%` : `${param}`;
const regex = new RegExp(`(#${pNum}\\[[a-z0-9]+\\]%?)`, 'g');
formatted = formatted.replace(regex, `${valStr}`);
});
return formatted.replace(/\n/g, '
');
}
// Render Character Switcher Bar at the top of modal
function renderModalSwitcher(currentAvatarId) {
if (!el.modalCharSwitcher) return;
el.modalCharSwitcher.innerHTML = '';
const sorted = [...state.avatarsList].sort((a, b) => b.avatar_id - a.avatar_id);
sorted.forEach(av => {
const meta = getAvatarInfo(av.avatar_id);
const item = document.createElement('div');
item.className = `modal-char-switcher-item ${av.avatar_id === currentAvatarId ? 'active' : ''}`;
item.title = `${meta.name} (ID: ${av.avatar_id})`;
item.innerHTML = `
`;
item.addEventListener('click', () => {
openAvatarDetailModal(av);
});
el.modalCharSwitcher.appendChild(item);
});
}
// Render Overview & Combat Stats (Neonteam style)
function renderModalOverviewTab(avatarId) {
const aid = parseInt(avatarId, 10);
const meta = getAvatarInfo(aid);
const av = state.fullFreesrData?.avatars?.[aid];
const rank = av?.data?.rank ?? state.selectedAvatarDetail?.rank ?? 6;
const level = av?.level ?? state.selectedAvatarDetail?.level ?? 80;
// Portrait Art
if (el.overviewPortraitImg) {
el.overviewPortraitImg.removeAttribute('data-err-step');
el.overviewPortraitImg.style.display = 'block';
el.overviewPortraitImg.style.objectFit = 'cover';
el.overviewPortraitImg.src = getAvatarSplashSrc(aid);
el.overviewPortraitImg.onerror = function() {
handleAvatarPortraitError(this, aid);
};
}
if (el.overviewCharName) el.overviewCharName.textContent = meta.name;
if (el.overviewCharMeta) {
el.overviewCharMeta.innerHTML = `
${getElementIconHtml(meta.element, 20)}
${getPathIconHtml(meta.path, 20)}
★ ${meta.rarity}
`;
}
if (el.overviewBadgeLvl) el.overviewBadgeLvl.textContent = `Lv. ${level} / 80`;
if (el.overviewBadgeEidolon) el.overviewBadgeEidolon.textContent = `E${rank}`;
// Light Cone calculation
const lc = (state.fullFreesrData?.lightcones || []).find(l => l.equip_avatar === aid);
const lcId = lc?.item_id || SIGNATURE_LIGHTCONES[aid] || 21001;
const lcInfo = getLightconeInfo(lcId);
const lcRank = lc?.rank || 5;
const lcLevel = lc?.level || 80;
// Equipped LC Card Glance
if (el.overviewLcThumb) {
el.overviewLcThumb.src = getLightconeArtSrc(lcId);
el.overviewLcThumb.onerror = function() {
handleLcImgError(this, lcId);
};
}
if (el.overviewLcName) el.overviewLcName.textContent = lcInfo.name || `Light Cone ${lcId}`;
if (el.overviewLcRank) el.overviewLcRank.textContent = `S${lcRank} • Lv.${lcLevel}`;
// Relics calculation & sets
const relics = (state.fullFreesrData?.relics || []).filter(r => r.equip_avatar === aid);
const setCountMap = {};
relics.forEach(r => {
const sId = Math.floor((r.relic_id % 10000) / 10);
setCountMap[sId] = (setCountMap[sId] || 0) + 1;
});
const activeSetBadges = [];
Object.keys(setCountMap).forEach(sId => {
const cnt = setCountMap[sId];
const sInfo = getRelicSetInfo(sId);
if (cnt >= 4) {
activeSetBadges.push({ name: sInfo.name, count: 4 });
} else if (cnt >= 2) {
activeSetBadges.push({ name: sInfo.name, count: 2 });
}
});
if (el.overviewRelicsSummary) {
if (activeSetBadges.length === 0) {
el.overviewRelicsSummary.innerHTML = `${relics.length}/6 ชิ้นสวมใส่ (ยังไม่มีโบนัสเซ็ต)`;
} else {
el.overviewRelicsSummary.innerHTML = activeSetBadges.map(b =>
`
${b.name}
(${b.count}-Pc)
`
).join('');
}
}
// Combat Stats (Authentic Honkai: Star Rail Character Attributes)
const baseHp = meta.rarity === 5 ? 1241 : 1058;
const baseAtk = meta.rarity === 5 ? 620 : 529;
const baseDef = meta.rarity === 5 ? 530 : 441;
const baseSpd = meta.speed || (meta.rawPath === 'Hunt' ? 110 : 100);
const lcHp = lcInfo.rarity === 5 ? 1058 : 846;
const lcAtk = lcInfo.rarity === 5 ? 582 : 476;
const lcDef = lcInfo.rarity === 5 ? 463 : 396;
let hpPercentBonus = 0;
let flatHpBonus = 0;
let atkPercentBonus = 0;
let flatAtkBonus = 0;
let defPercentBonus = 0;
let flatDefBonus = 0;
let spdFlatBonus = 0;
let spdPercentBonus = 0;
let critRateBonus = 0;
let critDmgBonus = 0;
let breakEffectBonus = 0;
let healBoostBonus = 0;
let errBonus = 0;
let ehrBonus = 0;
let effectResBonus = 0;
let elemDmgBonus = 0;
relics.forEach(r => {
const slot = r.relic_id % 10;
const lvl = r.level || 15;
const ratio = lvl / 15;
// Main Affixes
if (slot === 1) {
flatHpBonus += Math.round(705.6 * ratio);
} else if (slot === 2) {
flatAtkBonus += Math.round(352.8 * ratio);
} else if (slot === 3) {
if (r.main_affix_id === 1) hpPercentBonus += 43.2 * ratio;
else if (r.main_affix_id === 2) atkPercentBonus += 43.2 * ratio;
else if (r.main_affix_id === 3) defPercentBonus += 54.0 * ratio;
else if (r.main_affix_id === 4) critRateBonus += 32.4 * ratio;
else if (r.main_affix_id === 5) critDmgBonus += 64.8 * ratio;
else if (r.main_affix_id === 6) healBoostBonus += 34.5 * ratio;
else if (r.main_affix_id === 7) ehrBonus += 43.2 * ratio;
} else if (slot === 4) {
if (r.main_affix_id === 1) hpPercentBonus += 43.2 * ratio;
else if (r.main_affix_id === 2) atkPercentBonus += 43.2 * ratio;
else if (r.main_affix_id === 3) defPercentBonus += 54.0 * ratio;
else if (r.main_affix_id === 4) spdFlatBonus += 25 * ratio;
} else if (slot === 5) {
if (r.main_affix_id === 1) hpPercentBonus += 43.2 * ratio;
else if (r.main_affix_id === 2) atkPercentBonus += 43.2 * ratio;
else if (r.main_affix_id === 3) defPercentBonus += 54.0 * ratio;
else if (r.main_affix_id >= 4) elemDmgBonus += 38.8 * ratio;
} else if (slot === 6) {
if (r.main_affix_id === 1) breakEffectBonus += 64.8 * ratio;
else if (r.main_affix_id === 2) errBonus += 19.4 * ratio;
else if (r.main_affix_id === 3) hpPercentBonus += 43.2 * ratio;
else if (r.main_affix_id === 4) atkPercentBonus += 43.2 * ratio;
else if (r.main_affix_id === 5) defPercentBonus += 54.0 * ratio;
}
// Sub Affixes
(r.sub_affixes || []).forEach(sub => {
const rolls = sub.count || sub.cnt || 1;
const sId = sub.sub_affix_id;
if (sId === 1) flatHpBonus += Math.round(42 * rolls);
else if (sId === 2) flatAtkBonus += Math.round(21 * rolls);
else if (sId === 3) flatDefBonus += Math.round(21 * rolls);
else if (sId === 4) hpPercentBonus += 4.3 * rolls;
else if (sId === 5) atkPercentBonus += 4.3 * rolls;
else if (sId === 6) defPercentBonus += 5.4 * rolls;
else if (sId === 7) spdFlatBonus += 2.6 * rolls;
else if (sId === 8) critRateBonus += 3.2 * rolls;
else if (sId === 9) critDmgBonus += 6.4 * rolls;
else if (sId === 10) ehrBonus += 4.3 * rolls;
else if (sId === 11) effectResBonus += 4.3 * rolls;
else if (sId === 12) breakEffectBonus += 6.4 * rolls;
});
});
const finalBaseHp = baseHp + lcHp;
const extraHp = Math.round(finalBaseHp * (hpPercentBonus / 100) + flatHpBonus);
const totalHp = finalBaseHp + extraHp;
const finalBaseAtk = baseAtk + lcAtk;
const extraAtk = Math.round(finalBaseAtk * (atkPercentBonus / 100) + flatAtkBonus);
const totalAtk = finalBaseAtk + extraAtk;
const finalBaseDef = baseDef + lcDef;
const extraDef = Math.round(finalBaseDef * (defPercentBonus / 100) + flatDefBonus);
const totalDef = finalBaseDef + extraDef;
const finalBaseSpd = baseSpd;
const extraSpd = Math.round((spdFlatBonus + finalBaseSpd * (spdPercentBonus / 100)) * 10) / 10;
const totalSpd = (finalBaseSpd + extraSpd).toFixed(1);
const totalCritRate = (5.0 + critRateBonus).toFixed(1);
const totalCritDmg = (50.0 + critDmgBonus).toFixed(1);
const totalBreakEffect = breakEffectBonus.toFixed(1);
const totalHealBoost = healBoostBonus.toFixed(1);
const maxEnergy = 120;
const totalErr = (100.0 + errBonus).toFixed(1);
const totalEhr = ehrBonus.toFixed(1);
const totalRes = (10.0 + effectResBonus).toFixed(1);
const totalElemDmg = elemDmgBonus.toFixed(1);
const elemIconMap = {
Physical: 'PhysicalAddedRatio',
Fire: 'FireAddedRatio',
Ice: 'IceAddedRatio',
Lightning: 'ThunderAddedRatio',
Thunder: 'ThunderAddedRatio',
Wind: 'WindAddedRatio',
Quantum: 'QuantumAddedRatio',
Imaginary: 'ImaginaryAddedRatio'
};
const elemKey = normalizeElement(meta.rawElement || meta.element);
const elemIcon = elemIconMap[elemKey] || 'PhysicalAddedRatio';
const elemName = translateElement(elemKey);
const elemDmgLabel = t('statDmgBoost').replace('{element}', elemName);
const cdn = 'https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/icon/property';
if (el.overviewStatsGrid) {
el.overviewStatsGrid.innerHTML = `
${t('statMaxHp')}
${totalHp.toLocaleString()}
${extraHp > 0 ? `${finalBaseHp.toLocaleString()}` : ''}
${t('statAtk')}
${totalAtk.toLocaleString()}
${extraAtk > 0 ? `${finalBaseAtk.toLocaleString()}` : ''}
${t('statDef')}
${totalDef.toLocaleString()}
${extraDef > 0 ? `${finalBaseDef.toLocaleString()}` : ''}
${t('statSpd')}
${totalSpd}
${extraSpd > 0 ? `${finalBaseSpd}` : ''}
${t('statCritRate')}
${totalCritRate}%
${t('statCritDmg')}
${totalCritDmg}%
${t('statBreakEffect')}
${totalBreakEffect}%
${t('statHealBoost')}
${totalHealBoost}%
${t('statMaxEnergy')}
${maxEnergy}
${t('statEnergyRegen')}
${totalErr}%
${t('statEhr')}
${totalEhr}%
${t('statRes')}
${totalRes}%
${elemDmgLabel}
${totalElemDmg}%
`;
}
}
// Render Relics & Gear Tab (6 Slots + Equipped Lightcone)
function renderModalRelicsTab(avatarId) {
const aid = parseInt(avatarId, 10);
const meta = getAvatarInfo(aid);
if (!el.modalRelicsSlotsGrid) return;
el.modalRelicsSlotsGrid.innerHTML = '';
// 1. Equipped Lightcone in Relics Tab
const lc = (state.fullFreesrData?.lightcones || []).find(l => l.equip_avatar === aid);
const lcId = lc?.item_id || SIGNATURE_LIGHTCONES[aid] || 21001;
const lcInfo = getLightconeInfo(lcId);
const lcRank = lc?.rank || 5;
const lcLevel = lc?.level || 80;
const lcMeta = LIGHTCONE_METADATA[lcId] || {};
const lcThumbEl = document.getElementById('relics-tab-lc-thumb');
const lcNameEl = document.getElementById('relics-tab-lc-name');
const lcStarsEl = document.getElementById('relics-tab-lc-stars');
const lcRankEl = document.getElementById('relics-tab-lc-rank');
const lcLvlEl = document.getElementById('relics-tab-lc-level');
const lcStatsEl = document.getElementById('relics-tab-lc-stats');
const lcBox = document.getElementById('relics-equipped-lc-box');
if (lcThumbEl) {
lcThumbEl.src = getLightconeArtSrc(lcId);
lcThumbEl.onerror = function() {
handleLcImgError(this, lcId);
};
}
if (lcNameEl) lcNameEl.textContent = lcInfo.name || `Light Cone ${lcId}`;
if (lcStarsEl) lcStarsEl.textContent = '★'.repeat(lcInfo.rarity || 5);
if (lcRankEl) lcRankEl.textContent = `S${lcRank}`;
if (lcLvlEl) lcLvlEl.textContent = `Lv. ${lcLevel} / 80`;
if (lcStatsEl) {
const hp = lcMeta.hp || (lcMeta.rarity === 5 ? 1164 : (lcMeta.rarity === 4 ? 953 : 846));
const atk = lcMeta.atk || (lcMeta.rarity === 5 ? 582 : (lcMeta.rarity === 4 ? 423 : 317));
const def = lcMeta.def || (lcMeta.rarity === 5 ? 529 : (lcMeta.rarity === 4 ? 396 : 264));
lcStatsEl.textContent = `HP ${hp} / ATK ${atk} / DEF ${def}`;
}
if (lcBox) {
lcBox.onclick = () => openLightconePicker(aid);
}
// 2. Relic Slots
const allRelics = state.fullFreesrData?.relics || [];
const equippedRelics = allRelics.filter(r => r.equip_avatar === aid);
const setCountMap = {};
equippedRelics.forEach(r => {
const sId = Math.floor((r.relic_id % 10000) / 10);
setCountMap[sId] = (setCountMap[sId] || 0) + 1;
});
for (let slot = 1; slot <= 6; slot++) {
const slotInfo = SLOT_NAMES_FULL[slot];
const equipped = equippedRelics.find(r => (r.relic_id % 10) === slot);
const box = document.createElement('div');
if (equipped) {
const setId = Math.floor((equipped.relic_id % 10000) / 10);
const rInfo = getRelicSetInfo(setId);
const mainAffixName = getRelicMainAffixName(slot, equipped.main_affix_id);
const subsSnippetHtml = (equipped.sub_affixes && equipped.sub_affixes.length > 0)
? equipped.sub_affixes.slice(0, 4).map(s => {
const sName = getRelicSubAffixName(s.sub_affix_id);
const rolls = s.count || s.cnt || 1;
return `${sName} (+${rolls})`;
}).join(' • ')
: '4 ออปชั่นรอง (คลิกเพื่อปรับแต่ง)';
box.className = 'relic-slot-box equipped';
box.innerHTML = `
${slotInfo.icon} ${slotInfo.name.split(' ')[0]}
${rInfo.name}
+15
✦ ${mainAffixName}
${subsSnippetHtml}
`;
box.addEventListener('click', () => {
openSlotPicker(aid, slot);
});
box.querySelector('.btn-slot-unequip-corner')?.addEventListener('click', async (e) => {
e.stopPropagation();
equipped.equip_avatar = 0;
if (state.isConnected) await saveFreesrChangesToServer();
renderModalRelicsTab(aid);
renderModalOverviewTab(aid);
renderShowcaseCard(aid);
showToast(`ถอดรีลิกส์ ${slotInfo.name} เรียบร้อย`, 'info');
});
} else {
box.className = 'relic-slot-box empty';
box.innerHTML = `
${slotInfo.icon}
${slotInfo.name.split(' ')[0]}
${slotInfo.name}
ยังไม่ได้สวมใส่
+ คลิกเพื่อเลือกหรือสร้าง (แนะนำ: ${slotInfo.defaultStat})
`;
box.addEventListener('click', () => {
openSlotPicker(aid, slot);
});
}
el.modalRelicsSlotsGrid.appendChild(box);
}
// Active Set Bonuses display
if (el.modalRelicsActiveSets) {
const activeSets = [];
Object.keys(setCountMap).forEach(sId => {
const cnt = setCountMap[sId];
const sInfo = getRelicSetInfo(sId);
if (cnt >= 4) {
activeSets.push(`🌟 ${sInfo.name} (4-Piece Set): ${sInfo.desc4 || 'เปิดใช้งานเอฟเฟกต์ครบ 4 ชิ้น'}`);
} else if (cnt >= 2) {
activeSets.push(`✨ ${sInfo.name} (2-Piece Set): ${sInfo.desc2 || 'เปิดใช้งานเอฟเฟกต์ 2 ชิ้น'}`);
}
});
if (activeSets.length === 0) {
el.modalRelicsActiveSets.innerHTML = `ยังไม่มีเอฟเฟกต์เซ็ตทำงาน (ต้องใส่เซ็ตเดียวกันอย่างน้อย 2 หรือ 4 ชิ้น)`;
} else {
el.modalRelicsActiveSets.innerHTML = activeSets.map(s => `${s}
`).join('');
}
}
}
// Open Relic Slot Picker Modal (Spacious, Tabbed, Custom Set & Main Stat Builder + Substats Customizer + Inventory)
let slotPickerSelectedSetId = 101;
let slotPickerSelectedMainAffix = 1;
let slotPickerTargetRelic = null; // When non-null, editing an existing relic
let slotPickerSubstats = [
{ sub_affix_id: 8, count: 4, step: 2 },
{ sub_affix_id: 9, count: 3, step: 2 },
{ sub_affix_id: 7, count: 1, step: 2 },
{ sub_affix_id: 5, count: 1, step: 2 }
];
let slotPickerInitialSubstats = [];
export function openSlotPickerForRelic(relic) {
if (!relic) return;
const slotNum = (relic.relic_id % 10) || 1;
const aid = relic.equip_avatar || state.selectedAvatarDetail?.avatar_id || 1001;
openSlotPicker(aid, slotNum, relic);
}
window.openSlotPickerForRelic = openSlotPickerForRelic;
function openSlotPicker(avatarId, slotNum, targetRelicToEdit = null) {
state.slotPickerAvatarId = parseInt(avatarId, 10);
state.slotPickerSlot = parseInt(slotNum, 10);
slotPickerTargetRelic = targetRelicToEdit || null;
const aid = state.slotPickerAvatarId;
const slot = state.slotPickerSlot;
const meta = getAvatarInfo(aid);
const rec = getOptimalBuildForAvatar(aid);
const slotInfo = SLOT_NAMES_FULL[slot] || { name: `Slot ${slot}` };
const modal = document.getElementById('modal-select-relic-slot');
if (!modal) return;
// Title & Subtitle
const titleEl = document.getElementById('modal-slot-picker-title');
const subtitleEl = document.getElementById('modal-slot-picker-subtitle');
const editModeIndicator = document.getElementById('slot-picker-edit-mode-indicator');
const btnConfirmCustom = document.getElementById('btn-slot-picker-confirm-custom');
if (slotPickerTargetRelic) {
const curSetId = Math.floor((slotPickerTargetRelic.relic_id % 10000) / 10);
const curSetInfo = getRelicSetInfo(curSetId);
if (titleEl) titleEl.textContent = `🔧 แก้ไขรีลิกส์: ${curSetInfo.name} (${slotInfo.name}) - ${meta.name}`;
if (subtitleEl) subtitleEl.textContent = `ปรับแต่งเซ็ต สเตตัสหลัก และออฟรองของรีลิกส์ชิ้นนี้ (#${slotPickerTargetRelic.internal_uid})`;
if (editModeIndicator) editModeIndicator.innerHTML = `✏️ กำลังแก้ไขรีลิกส์ #${slotPickerTargetRelic.internal_uid}`;
if (btnConfirmCustom) btnConfirmCustom.textContent = `💾 บันทึกการแก้ไขออฟรองชิ้นนี้`;
} else {
if (titleEl) titleEl.textContent = `${t('relicPickerTitle').replace('{slot}', slotInfo.name)} - ${meta.name}`;
if (subtitleEl) subtitleEl.textContent = t('quickGenRecDesc');
if (editModeIndicator) editModeIndicator.textContent = '';
if (btnConfirmCustom) btnConfirmCustom.textContent = `⚡ สวมใส่ชิ้นนี้ทันที (+15 5★ ออฟที่เลือก)`;
}
// Current Equipped Relic Banner
const currentBox = document.getElementById('slot-picker-current-box');
const allRelics = state.fullFreesrData?.relics || [];
const currentRelic = slotPickerTargetRelic || allRelics.find(r => r.equip_avatar === aid && (r.relic_id % 10) === slot);
if (currentBox) {
if (currentRelic) {
const curSetId = Math.floor((currentRelic.relic_id % 10000) / 10);
const curSetInfo = getRelicSetInfo(curSetId);
const curMainAffixName = getRelicMainAffixName(slot, currentRelic.main_affix_id);
const subsSnippet = (currentRelic.sub_affixes || []).map(s => {
const sName = getRelicSubAffixName(s.sub_affix_id);
const rolls = s.count || s.cnt || 1;
const val = formatRelicSubstatValue(s.sub_affix_id, rolls);
return `${sName} +${val} (+${rolls})`;
}).join(' ');
currentBox.className = 'slot-picker-current-banner';
currentBox.innerHTML = `
${currentRelic.equip_avatar ? t('currentEquipped') : 'ชิ้นในคลัง'} (+${currentRelic.level || 15})
✦ ${curMainAffixName}
${curSetInfo.name}
${subsSnippet}
${!slotPickerTargetRelic ? `
` : ''}
${currentRelic.equip_avatar ? `
` : ''}
`;
document.getElementById('btn-slot-picker-edit-curr')?.addEventListener('click', () => {
openSlotPicker(aid, slot, currentRelic);
});
document.getElementById('btn-slot-picker-unequip')?.addEventListener('click', async () => {
currentRelic.equip_avatar = 0;
if (state.isConnected) await saveFreesrChangesToServer();
renderModalRelicsTab(aid);
renderModalOverviewTab(aid);
renderShowcaseCard(aid);
loadEquipment();
openSlotPicker(aid, slot);
showToast('ถอดรีลิกส์เรียบร้อยแล้ว', 'info');
});
} else {
currentBox.className = 'slot-picker-current-banner empty';
currentBox.innerHTML = `${t('noRelicEquipped')}`;
}
}
// Set & Main Stat defaults
const recSetId = slot <= 4 ? rec.cavern : rec.planar;
let recMainStat = 1;
if (slot === 1 || slot === 2) recMainStat = 1;
else if (slot === 3) recMainStat = rec.body;
else if (slot === 4) recMainStat = rec.feet;
else if (slot === 5) recMainStat = rec.sphere;
else if (slot === 6) recMainStat = rec.rope;
if (slotPickerTargetRelic) {
slotPickerSelectedSetId = Math.floor((slotPickerTargetRelic.relic_id % 10000) / 10);
slotPickerSelectedMainAffix = slotPickerTargetRelic.main_affix_id;
if (slotPickerTargetRelic.sub_affixes && slotPickerTargetRelic.sub_affixes.length > 0) {
slotPickerSubstats = slotPickerTargetRelic.sub_affixes.map(s => ({
sub_affix_id: s.sub_affix_id,
count: s.count || s.cnt || 1,
step: s.step ?? 2
}));
} else {
applySubstatPreset('god', slot, slotPickerSelectedMainAffix, aid);
}
} else {
slotPickerSelectedSetId = recSetId;
slotPickerSelectedMainAffix = recMainStat;
applySubstatPreset('god', slot, slotPickerSelectedMainAffix, aid);
}
// Clone initial substats for reset button
slotPickerInitialSubstats = JSON.parse(JSON.stringify(slotPickerSubstats));
// Render Recommended Highlight Banner
const recHighlight = document.getElementById('slot-picker-rec-highlight');
if (recHighlight) {
const recSetInfo = getRelicSetInfo(recSetId);
const recMainName = getRelicMainAffixName(slot, recMainStat);
recHighlight.innerHTML = `
⭐ ${t('recommendedBadge')}: ${recSetInfo.name} (+15 5★)
✦ ${recMainName}
ออฟรองแนะนำ 4 แถวเทพ ปรับแต่งได้ตามต้องการด้านล่าง
`;
document.getElementById('btn-slot-picker-quick-rec')?.addEventListener('click', async () => {
await equipCustomSlotRelic(aid, slot, recSetId, recMainStat, slotPickerSubstats);
});
}
// Populate Set Grid
renderSlotPickerSetGrid(slot, recSetId);
// Populate Main Stat Pills
renderSlotPickerMainStatPills(slot, recMainStat);
// Populate Substats Customizer
renderSlotPickerSubstats(slot, slotPickerSelectedMainAffix);
// Preset buttons
document.querySelectorAll('.btn-substat-preset').forEach(btn => {
btn.onclick = () => {
const presetKey = btn.getAttribute('data-preset');
applySubstatPreset(presetKey, slot, slotPickerSelectedMainAffix, aid);
};
});
// Reset Substats button
const btnResetSubs = document.getElementById('btn-reset-substats');
if (btnResetSubs) {
btnResetSubs.onclick = () => {
slotPickerSubstats = JSON.parse(JSON.stringify(slotPickerInitialSubstats));
renderSlotPickerSubstats(slot, slotPickerSelectedMainAffix);
};
}
// Tab 2: Inventory Grid
renderSlotPickerInventoryGrid(aid, slot);
// Mode Tabs Switching
const tabQuickBtn = document.getElementById('tab-btn-relic-quick');
const tabInvBtn = document.getElementById('tab-btn-relic-inventory');
const paneQuick = document.getElementById('slot-picker-quick-tab');
const paneInv = document.getElementById('slot-picker-inventory-tab');
if (tabQuickBtn && tabInvBtn && paneQuick && paneInv) {
tabQuickBtn.classList.add('active');
tabInvBtn.classList.remove('active');
paneQuick.style.display = 'flex';
paneInv.style.display = 'none';
tabQuickBtn.onclick = () => {
tabQuickBtn.classList.add('active');
tabInvBtn.classList.remove('active');
paneQuick.style.display = 'flex';
paneInv.style.display = 'none';
};
tabInvBtn.onclick = () => {
tabInvBtn.classList.add('active');
tabQuickBtn.classList.remove('active');
paneQuick.style.display = 'none';
paneInv.style.display = 'flex';
};
}
// Close Button
const btnCloseSlot = document.getElementById('btn-close-slot-picker');
if (btnCloseSlot) {
btnCloseSlot.onclick = () => {
modal.classList.remove('active');
slotPickerTargetRelic = null;
};
}
// Confirm Custom Button
if (btnConfirmCustom) {
btnConfirmCustom.onclick = async () => {
if (slotPickerTargetRelic) {
await saveEditedRelicSubstats(slotPickerTargetRelic, slotPickerSelectedSetId, slotPickerSelectedMainAffix, slotPickerSubstats);
} else {
await equipCustomSlotRelic(aid, slot, slotPickerSelectedSetId, slotPickerSelectedMainAffix, slotPickerSubstats);
}
};
}
modal.classList.add('active');
}
// Render Substats Customizer Grid
function renderSlotPickerSubstats(slot, mainAffixId) {
const grid = document.getElementById('slot-picker-substats-grid');
if (!grid) return;
grid.innerHTML = '';
const conflictSub = getConflictingSubstat(slot, mainAffixId);
// If any selected substat conflicts with main affix, swap it to a valid unused substat
slotPickerSubstats.forEach((sub, idx) => {
if (sub.sub_affix_id === conflictSub) {
const unused = RELIC_SUBSTATS_CONFIG.find(c =>
c.id !== conflictSub && !slotPickerSubstats.some((s, i) => i !== idx && s.sub_affix_id === c.id)
);
if (unused) sub.sub_affix_id = unused.id;
}
});
slotPickerSubstats.forEach((sub, idx) => {
const card = document.createElement('div');
card.className = 'substat-row-card';
const sVal = formatRelicSubstatValue(sub.sub_affix_id, sub.count);
let optsHtml = '';
RELIC_SUBSTATS_CONFIG.forEach(stat => {
const isConflict = stat.id === conflictSub;
const isChosenElsewhere = slotPickerSubstats.some((s, i) => i !== idx && s.sub_affix_id === stat.id);
const isSelected = stat.id === sub.sub_affix_id;
const disabledAttr = (isConflict || (isChosenElsewhere && !isSelected)) ? 'disabled' : '';
const conflictTag = isConflict ? ' (ตรงกับ Main Stat)' : (isChosenElsewhere && !isSelected ? ' (เลือกแล้ว)' : '');
optsHtml += ``;
});
card.innerHTML = `
`;
// Dropdown change
card.querySelector('.substat-select')?.addEventListener('change', (e) => {
const newId = parseInt(e.target.value, 10);
slotPickerSubstats[idx].sub_affix_id = newId;
renderSlotPickerSubstats(slot, slotPickerSelectedMainAffix);
});
// Stepper buttons
card.querySelector('.btn-substat-step.minus')?.addEventListener('click', () => {
if (slotPickerSubstats[idx].count > 1) {
slotPickerSubstats[idx].count--;
updateSubstatCardPreview(idx);
updateTotalRollsCount();
}
});
card.querySelector('.btn-substat-step.plus')?.addEventListener('click', () => {
if (slotPickerSubstats[idx].count < 25) {
slotPickerSubstats[idx].count++;
updateSubstatCardPreview(idx);
updateTotalRollsCount();
}
});
card.querySelector('.substat-count-input')?.addEventListener('input', (e) => {
let val = parseInt(e.target.value, 10);
if (isNaN(val) || val < 1) val = 1;
if (val > 30) val = 30;
slotPickerSubstats[idx].count = val;
updateSubstatCardPreview(idx);
updateTotalRollsCount();
});
grid.appendChild(card);
});
updateTotalRollsCount();
}
function updateSubstatCardPreview(idx) {
const sub = slotPickerSubstats[idx];
if (!sub) return;
const badge = document.getElementById(`substat-preview-${idx}`);
const input = document.querySelector(`.substat-count-input[data-idx="${idx}"]`);
if (badge) {
const sVal = formatRelicSubstatValue(sub.sub_affix_id, sub.count);
badge.textContent = `+${sVal} (${sub.count} โรล)`;
}
if (input) {
input.value = sub.count;
}
}
function updateTotalRollsCount() {
const lbl = document.getElementById('slot-picker-total-rolls');
if (!lbl) return;
const total = slotPickerSubstats.reduce((sum, s) => sum + (parseInt(s.count, 10) || 1), 0);
lbl.textContent = total;
if (total === 9) {
lbl.style.color = '#fbbf24';
} else if (total > 9) {
lbl.style.color = 'var(--accent-cyan)';
} else {
lbl.style.color = 'var(--text-muted)';
}
}
function applySubstatPreset(presetKey, slot, mainAffixId, avatarId) {
const conflict = getConflictingSubstat(slot, mainAffixId);
const aid = parseInt(avatarId, 10);
const rec = getOptimalBuildForAvatar(aid);
let targetIds = [];
let rolls = [4, 3, 1, 1];
if (presetKey === 'god') {
const pool = [...rec.substats, 7, 8, 9, 5, 12, 11, 4, 6];
for (const sid of pool) {
if (targetIds.length >= 4) break;
if (sid !== conflict && !targetIds.includes(sid)) targetIds.push(sid);
}
rolls = [4, 3, 1, 1];
} else if (presetKey === 'crit') {
const pool = [8, 9, 7, 5, 4, 12, 2];
for (const sid of pool) {
if (targetIds.length >= 4) break;
if (sid !== conflict && !targetIds.includes(sid)) targetIds.push(sid);
}
rolls = [4, 3, 1, 1];
} else if (presetKey === 'spd') {
const pool = [7, 8, 9, 5, 12, 4, 2];
for (const sid of pool) {
if (targetIds.length >= 4) break;
if (sid !== conflict && !targetIds.includes(sid)) targetIds.push(sid);
}
rolls = [5, 2, 1, 1];
} else if (presetKey === 'break') {
const pool = [12, 7, 5, 4, 11, 6, 2];
for (const sid of pool) {
if (targetIds.length >= 4) break;
if (sid !== conflict && !targetIds.includes(sid)) targetIds.push(sid);
}
rolls = [5, 2, 1, 1];
} else if (presetKey === 'tank') {
const pool = [4, 6, 11, 7, 1, 3, 5];
for (const sid of pool) {
if (targetIds.length >= 4) break;
if (sid !== conflict && !targetIds.includes(sid)) targetIds.push(sid);
}
rolls = [4, 3, 1, 1];
}
// Ensure 4 distinct items
while (targetIds.length < 4) {
const fallback = RELIC_SUBSTATS_CONFIG.find(c => c.id !== conflict && !targetIds.includes(c.id));
if (fallback) targetIds.push(fallback.id);
else break;
}
slotPickerSubstats = targetIds.slice(0, 4).map((sid, i) => ({
sub_affix_id: sid,
count: rolls[i] || 1,
step: 2
}));
renderSlotPickerSubstats(slot, mainAffixId);
}
// Render Set Grid for Slot
function renderSlotPickerSetGrid(slot, recSetId) {
const grid = document.getElementById('slot-picker-set-grid');
if (!grid) return;
grid.innerHTML = '';
const isCavern = slot <= 4;
const eligibleSetIds = [];
for (const sIdStr in RELIC_SET_METADATA) {
const sId = parseInt(sIdStr, 10);
if (isCavern && sId < 300) eligibleSetIds.push(sId);
else if (!isCavern && sId >= 300) eligibleSetIds.push(sId);
}
eligibleSetIds.sort((a, b) => {
if (a === recSetId) return -1;
if (b === recSetId) return 1;
return b - a;
});
eligibleSetIds.forEach(sId => {
const sInfo = getRelicSetInfo(sId);
const card = document.createElement('div');
const isSelected = sId === slotPickerSelectedSetId;
card.className = `slot-picker-set-card ${isSelected ? 'selected' : ''}`;
card.innerHTML = `
${sInfo.name}
${sId === recSetId ? `⭐ ${t('recommendedBadge')}` : ''}
`;
card.addEventListener('click', () => {
slotPickerSelectedSetId = sId;
grid.querySelectorAll('.slot-picker-set-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
});
grid.appendChild(card);
});
}
// Render Main Stat Pills for Slot
function renderSlotPickerMainStatPills(slot, recMainStat) {
const container = document.getElementById('slot-picker-mainstat-pills');
if (!container) return;
container.innerHTML = '';
const affixesBySlot = {
1: [{ id: 1, name: "HP (Flat)" }],
2: [{ id: 1, name: "ATK (Flat)" }],
3: [
{ id: 4, name: "CRIT Rate%" },
{ id: 5, name: "CRIT DMG%" },
{ id: 2, name: "ATK%" },
{ id: 1, name: "HP%" },
{ id: 3, name: "DEF%" },
{ id: 6, name: "Healing Boost%" },
{ id: 7, name: "Effect Hit Rate%" }
],
4: [
{ id: 4, name: "SPD" },
{ id: 2, name: "ATK%" },
{ id: 1, name: "HP%" },
{ id: 3, name: "DEF%" }
],
5: [
{ id: 4, name: "Physical DMG%" },
{ id: 5, name: "Fire DMG%" },
{ id: 6, name: "Ice DMG%" },
{ id: 7, name: "Lightning DMG%" },
{ id: 8, name: "Wind DMG%" },
{ id: 9, name: "Quantum DMG%" },
{ id: 10, name: "Imaginary DMG%" },
{ id: 2, name: "ATK%" },
{ id: 1, name: "HP%" },
{ id: 3, name: "DEF%" }
],
6: [
{ id: 2, name: "Energy Regen Rate%" },
{ id: 1, name: "Break Effect%" },
{ id: 4, name: "ATK%" },
{ id: 3, name: "HP%" },
{ id: 5, name: "DEF%" }
]
};
const list = affixesBySlot[slot] || [{ id: 1, name: "HP (Flat)" }];
list.forEach(item => {
const isSelected = item.id === slotPickerSelectedMainAffix;
const isRec = item.id === recMainStat;
const pill = document.createElement('button');
pill.type = 'button';
pill.className = `slot-picker-pill ${isSelected ? 'selected' : ''} ${isRec ? 'recommended' : ''}`;
pill.innerHTML = `
${isRec ? '⭐ ' : ''}${item.name}
`;
pill.addEventListener('click', () => {
slotPickerSelectedMainAffix = item.id;
container.querySelectorAll('.slot-picker-pill').forEach(p => p.classList.remove('selected'));
pill.classList.add('selected');
renderSlotPickerSubstats(slot, slotPickerSelectedMainAffix);
});
container.appendChild(pill);
});
}
// Render Tab 2: Inventory Grid
function renderSlotPickerInventoryGrid(aid, slot) {
const container = document.getElementById('slot-picker-inventory-list');
const countBadge = document.getElementById('slot-picker-inv-count');
if (!container) return;
container.innerHTML = '';
const allRelics = state.fullFreesrData?.relics || [];
const matchingRelics = allRelics.filter(r => (r.relic_id % 10) === slot);
if (countBadge) countBadge.textContent = matchingRelics.length;
if (matchingRelics.length === 0) {
container.innerHTML = `
📦
ไม่มีรีลิกส์สำหรับช่องนี้ในคลัง
สามารถสลับไปที่แท็บ "⚡ สวมใส่ / สร้างชิ้นแนะนำ" เพื่อสร้างชิ้นใหม่ได้ทันที!
`;
return;
}
matchingRelics.forEach(r => {
const setId = Math.floor((r.relic_id % 10000) / 10);
const rInfo = getRelicSetInfo(setId);
const mainAffixName = getRelicMainAffixName(slot, r.main_affix_id);
const isCurrent = r.equip_avatar === aid;
const wearerMeta = r.equip_avatar && r.equip_avatar !== aid ? getAvatarInfo(r.equip_avatar) : null;
const card = document.createElement('div');
card.className = `slot-picker-inv-card ${isCurrent ? 'active-wearer' : ''}`;
card.innerHTML = `
${rInfo.name}
5★ • Lv.${r.level}
✦ ${mainAffixName}
${(r.sub_affixes || []).map(s => {
const sName = getRelicSubAffixName(s.sub_affix_id);
const rolls = s.count || s.cnt || 1;
const val = formatRelicSubstatValue(s.sub_affix_id, rolls);
return `
• ${sName} +${val} (+${rolls})
`;
}).join('')}
${isCurrent ? '(กำลังสวมใส่อยู่)' :
wearerMeta ? `(สวมใส่โดย: ${wearerMeta.name})` :
'(ว่างในคลัง)'}
`;
card.addEventListener('click', async () => {
if (!isCurrent) {
allRelics.forEach(other => {
if (other.equip_avatar === aid && (other.relic_id % 10) === slot) {
other.equip_avatar = 0;
}
});
r.equip_avatar = aid;
if (state.isConnected) await saveFreesrChangesToServer();
renderModalRelicsTab(aid);
renderModalOverviewTab(aid);
renderShowcaseCard(aid);
loadEquipment();
showToast(`สวมใส่ ${rInfo.name} สำเร็จ!`, 'success');
document.getElementById('modal-select-relic-slot')?.classList.remove('active');
}
});
container.appendChild(card);
});
}
// Equip custom created relic for avatar slot
async function equipCustomSlotRelic(avatarId, slotNum, setId, mainAffixId, customSubAffixes = null) {
const aid = parseInt(avatarId, 10);
const rec = getOptimalBuildForAvatar(aid);
const relicId = 60000 + (setId * 10) + slotNum;
if (!state.fullFreesrData) state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
state.fullFreesrData.relics = state.fullFreesrData.relics || [];
state.fullFreesrData.relics.forEach(r => {
if (r.equip_avatar === aid && (r.relic_id % 10) === slotNum) {
r.equip_avatar = 0;
}
});
let subAffixes = [];
if (customSubAffixes && customSubAffixes.length > 0) {
subAffixes = customSubAffixes.map(s => ({
sub_affix_id: parseInt(s.sub_affix_id, 10),
cnt: parseInt(s.count || s.cnt || 1, 10),
count: parseInt(s.count || s.cnt || 1, 10),
step: 2
}));
} else {
const conflictingSub = getConflictingSubstat(slotNum, mainAffixId);
const pool = [...rec.substats, 7, 8, 9, 5, 12, 11, 4, 6];
const chosenSubs = [];
for (const sId of pool) {
if (chosenSubs.length >= 4) break;
if (sId !== conflictingSub && !chosenSubs.includes(sId)) {
chosenSubs.push(sId);
}
}
const rolls = [4, 3, 1, 1];
subAffixes = chosenSubs.map((sid, idx) => ({
sub_affix_id: sid,
cnt: rolls[idx] || 1,
count: rolls[idx] || 1,
step: 2
}));
}
const nextUid = (state.fullFreesrData.relics.reduce((m, r) => Math.max(m, r.internal_uid || 0), 0)) + 1;
const newRelic = {
internal_uid: nextUid,
relic_id: relicId,
level: 15,
equip_avatar: aid,
main_affix_id: mainAffixId,
sub_affixes: subAffixes
};
state.fullFreesrData.relics.push(newRelic);
if (state.isConnected) await saveFreesrChangesToServer();
renderModalRelicsTab(aid);
renderModalOverviewTab(aid);
renderShowcaseCard(aid);
loadEquipment();
document.getElementById('modal-select-relic-slot')?.classList.remove('active');
const setInfo = getRelicSetInfo(setId);
showToast(`⚡ สวมใส่ ${setInfo.name} (+15 5★ ออฟที่เลือก) เรียบร้อยแล้ว!`, 'success');
}
// Save edits to existing relic (main stat & substats)
async function saveEditedRelicSubstats(relic, newSetId, newMainAffixId, newSubAffixes) {
if (!relic) return;
const slotNum = (relic.relic_id % 10) || 1;
if (newSetId) {
relic.relic_id = 60000 + (newSetId * 10) + slotNum;
}
relic.main_affix_id = newMainAffixId;
relic.sub_affixes = newSubAffixes.map(s => ({
sub_affix_id: parseInt(s.sub_affix_id, 10),
cnt: parseInt(s.count || s.cnt || 1, 10),
count: parseInt(s.count || s.cnt || 1, 10),
step: 2
}));
if (state.isConnected) await saveFreesrChangesToServer();
if (relic.equip_avatar) {
renderModalRelicsTab(relic.equip_avatar);
renderModalOverviewTab(relic.equip_avatar);
renderShowcaseCard(relic.equip_avatar);
}
loadEquipment();
showRelicInspector(relic);
document.getElementById('modal-select-relic-slot')?.classList.remove('active');
slotPickerTargetRelic = null;
const rInfo = getRelicSetInfo(newSetId || Math.floor((relic.relic_id % 10000) / 10));
showToast(`💾 บันทึกการแก้ไขออฟรอง ${rInfo.name} เรียบร้อย!`, 'success');
}
async function quickGenSlotRelic(avatarId, slotNum) {
const aid = parseInt(avatarId, 10);
const rec = getOptimalBuildForAvatar(aid);
const setId = slotNum <= 4 ? rec.cavern : rec.planar;
let mainAffix = 1;
if (slotNum === 1 || slotNum === 2) mainAffix = 1;
else if (slotNum === 3) mainAffix = rec.body;
else if (slotNum === 4) mainAffix = rec.feet;
else if (slotNum === 5) mainAffix = rec.sphere;
else if (slotNum === 6) mainAffix = rec.rope;
}
// Open Lightcone Picker Modal
function openLightconePicker(avatarId) {
state.lcPickerAvatarId = parseInt(avatarId, 10);
const aid = state.lcPickerAvatarId;
const meta = getAvatarInfo(aid);
const modal = document.getElementById('modal-select-lightcone');
if (!modal) return;
const titleEl = document.getElementById('modal-lc-picker-title');
if (titleEl) {
titleEl.textContent = `🗡️ เลือกสวมใส่กรวยแสง: ${meta.name}`;
}
const sigLcId = SIGNATURE_LIGHTCONES[aid];
const sigBox = document.getElementById('lc-picker-signature-box');
const sigThumb = document.getElementById('lc-picker-sig-thumb');
const sigName = document.getElementById('lc-picker-sig-name');
const btnQuickSig = document.getElementById('btn-lc-picker-quick-sig');
if (sigLcId && LIGHTCONE_METADATA[sigLcId]) {
const sInfo = getLightconeInfo(sigLcId);
if (sigBox) sigBox.style.display = 'flex';
if (sigThumb) {
sigThumb.src = getLightconeArtSrc(sigLcId);
sigThumb.onerror = function() { this.src = getLightconeArtFallback(sigLcId); };
}
if (sigName) sigName.textContent = `${sInfo.name} (★${sInfo.rarity} ${sInfo.path})`;
if (btnQuickSig) {
btnQuickSig.onclick = async () => {
const rank = parseInt(document.getElementById('lc-picker-rank')?.value || '5', 10);
await equipLightconeToAvatar(aid, sigLcId, rank, 80);
modal.classList.remove('active');
};
}
} else if (sigBox) {
sigBox.style.display = 'none';
}
const pathSelect = document.getElementById('lc-picker-filter-path');
if (pathSelect) pathSelect.value = 'same';
const searchInput = document.getElementById('lc-picker-search');
if (searchInput) searchInput.value = '';
renderLightconePickerGrid();
modal.classList.add('active');
}
// Render Lightcone Picker Grid
function renderLightconePickerGrid() {
const listEl = document.getElementById('lc-picker-list');
if (!listEl) return;
listEl.innerHTML = '';
const aid = state.lcPickerAvatarId;
const avatarMeta = getAvatarInfo(aid);
const search = document.getElementById('lc-picker-search')?.value.trim().toLowerCase() || '';
const pathFilter = document.getElementById('lc-picker-filter-path')?.value || 'same';
const rank = parseInt(document.getElementById('lc-picker-rank')?.value || '5', 10);
const currentLc = (state.fullFreesrData?.lightcones || []).find(l => l.equip_avatar === aid);
const allIds = Object.keys(LIGHTCONE_METADATA).map(id => parseInt(id, 10)).sort((a, b) => {
const metaA = LIGHTCONE_METADATA[a];
const metaB = LIGHTCONE_METADATA[b];
if (metaB.rarity !== metaA.rarity) return metaB.rarity - metaA.rarity;
return b - a;
});
const filtered = allIds.filter(id => {
const meta = LIGHTCONE_METADATA[id];
const info = getLightconeInfo(id);
// Path match
if (pathFilter === 'same' && normalizePath(meta.path) !== normalizePath(avatarMeta.path)) return false;
if (pathFilter !== 'all' && pathFilter !== 'same' && normalizePath(meta.path) !== normalizePath(pathFilter)) return false;
// Search match
if (search) {
const matchName = info.name.toLowerCase().includes(search);
const matchId = String(id).includes(search);
if (!matchName && !matchId) return false;
}
return true;
});
if (filtered.length === 0) {
listEl.innerHTML = `ไม่พบกรวยแสงตามเงื่อนไขที่ค้นหา
`;
return;
}
filtered.forEach(id => {
const meta = LIGHTCONE_METADATA[id];
const info = getLightconeInfo(id);
const isCurrent = currentLc?.item_id === id;
const isSig = SIGNATURE_LIGHTCONES[aid] === id;
const card = document.createElement('div');
card.className = `lc-picker-card rarity-${info.rarity} ${isCurrent ? 'active-equipped' : ''}`;
let badgeHtml = '';
if (isCurrent) {
badgeHtml = `สวมใส่อยู่`;
} else if (isSig) {
badgeHtml = `ประจำตัว`;
}
card.innerHTML = `
${info.name}
${badgeHtml}
${'★'.repeat(info.rarity || 5)}
${getPathIconHtml(meta.path, 14)} ${info.path}
`;
card.addEventListener('click', async () => {
await equipLightconeToAvatar(aid, id, rank, 80);
document.getElementById('modal-select-lightcone')?.classList.remove('active');
});
listEl.appendChild(card);
});
}
// Equip Lightcone directly to Avatar
async function equipLightconeToAvatar(avatarId, lcId, rank = 5, level = 80) {
const aid = parseInt(avatarId, 10);
const id = parseInt(lcId, 10);
const meta = getAvatarInfo(aid);
const lcInfo = getLightconeInfo(id);
if (!state.fullFreesrData) state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
state.fullFreesrData.lightcones = state.fullFreesrData.lightcones || [];
// 1. Unassign other lightcones from this avatar
state.fullFreesrData.lightcones.forEach(lc => {
if (lc.equip_avatar === aid) lc.equip_avatar = 0;
});
// 2. Find or create lightcone entry
let targetLc = state.fullFreesrData.lightcones.find(lc => lc.item_id === id && (!lc.equip_avatar || lc.equip_avatar === 0));
if (!targetLc) {
targetLc = state.fullFreesrData.lightcones.find(lc => lc.item_id === id);
if (targetLc) {
targetLc.equip_avatar = aid;
targetLc.rank = rank;
targetLc.level = level;
targetLc.promotion = 6;
} else {
let maxUid = 1000;
state.fullFreesrData.lightcones.forEach(l => {
if (l.unique_id >= maxUid) maxUid = l.unique_id + 1;
});
targetLc = {
unique_id: maxUid,
internal_uid: state.fullFreesrData.lightcones.length,
item_id: id,
level: level,
rank: rank,
promotion: 6,
equip_avatar: aid
};
state.fullFreesrData.lightcones.push(targetLc);
}
} else {
targetLc.equip_avatar = aid;
targetLc.rank = rank;
targetLc.level = level;
targetLc.promotion = 6;
}
if (state.isConnected) {
await saveFreesrChangesToServer().catch(() => {});
}
renderModalOverviewTab(aid);
renderModalRelicsTab(aid);
renderShowcaseCard(aid);
renderLightconesGrid();
showToast(`🗡️ สวมใส่ ${lcInfo.name} (S${rank}) ให้กับ ${meta.name} เรียบร้อย!`, 'success');
}
// Unequip Lightcone from Avatar
async function unequipLightconeFromAvatar(avatarId) {
const aid = parseInt(avatarId, 10);
const meta = getAvatarInfo(aid);
if (state.fullFreesrData?.lightcones) {
state.fullFreesrData.lightcones.forEach(lc => {
if (lc.equip_avatar === aid) lc.equip_avatar = 0;
});
if (state.isConnected) await saveFreesrChangesToServer().catch(() => {});
renderModalOverviewTab(aid);
renderModalRelicsTab(aid);
renderShowcaseCard(aid);
renderLightconesGrid();
showToast(`ถอดกรวยแสงออกจาก ${meta.name} เรียบร้อย`, 'info');
}
}
// Quick Set All Avatars to E0S1 Standard (Lv.80, Prom 6, E0, S1 Signature LC)
async function quickSetAllAvatarsE0S1() {
try {
if (!state.fullFreesrData) {
state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
}
state.fullFreesrData.avatars = state.fullFreesrData.avatars || {};
state.fullFreesrData.lightcones = state.fullFreesrData.lightcones || [];
const allAvatarIds = Object.keys(AVATARS_DATA).map(id => parseInt(id, 10));
let nextUniqueId = 1000;
state.fullFreesrData.lightcones.forEach(lc => {
if (lc.unique_id >= nextUniqueId) nextUniqueId = lc.unique_id + 1;
});
for (const aid of allAvatarIds) {
if (!state.fullFreesrData.avatars[aid]) {
state.fullFreesrData.avatars[aid] = {
owner_uid: 0,
avatar_id: aid,
level: 80,
promotion: 6,
techniques: [],
sp_max: 140,
sp_value: 70,
data: {
rank: 0,
skills: {
[`${aid}001`]: 6,
[`${aid}002`]: 10,
[`${aid}003`]: 10,
[`${aid}004`]: 10,
[`${aid}007`]: 1,
[`${aid}101`]: 1,
[`${aid}102`]: 1,
[`${aid}103`]: 1,
[`${aid}201`]: 1
},
skills_by_anchor_type: {
"1": 6, "2": 10, "3": 10, "4": 10, "5": 1, "6": 1, "7": 1, "8": 1, "9": 1
}
}
};
} else {
const av = state.fullFreesrData.avatars[aid];
av.level = 80;
av.promotion = 6;
av.data = av.data || {};
av.data.rank = 0;
if (!av.sp_value) av.sp_value = 70;
}
// Equip S1 Signature Lightcone
const sigLcId = SIGNATURE_LIGHTCONES[aid] || 21001;
let existingLc = state.fullFreesrData.lightcones.find(l => l.equip_avatar === aid);
if (existingLc) {
existingLc.item_id = sigLcId;
existingLc.rank = 1;
existingLc.level = 80;
existingLc.promotion = 6;
} else {
state.fullFreesrData.lightcones.push({
unique_id: nextUniqueId++,
internal_uid: state.fullFreesrData.lightcones.length,
item_id: sigLcId,
level: 80,
promotion: 6,
rank: 1,
equip_avatar: aid
});
}
}
initAvatarsList();
state.avatarsList.forEach(a => {
a.level = 80;
a.promotion = 6;
a.rank = 0;
});
if (state.isConnected) {
await saveFreesrChangesToServer().catch(() => {});
}
renderAvatarsGrid();
if (el.statAvatars) el.statAvatars.textContent = Object.keys(state.fullFreesrData.avatars).length;
showToast('⚡ ปรับตัวละครทุกตัวเป็น E0S1 พร้อมกรวยแสงประจำตัวเรียบร้อยแล้ว!', 'success');
} catch (err) {
showToast(err.message, 'error');
}
}
// Render Skills Tab with real StarRailRes descriptions & icons
async function renderModalSkillsTab(avatarId) {
const aid = String(avatarId);
const currentLang = getCurrentLanguage() || 'th';
const { skills, trees } = await loadCharacterSkillsData(currentLang);
const avatarSkills = Object.values(skills || {}).filter(s => String(s.id).startsWith(aid));
const basicSkill = avatarSkills.find(s => s.type === 'Normal') || avatarSkills[0];
const combatSkill = avatarSkills.find(s => s.type === 'BPSkill') || avatarSkills[1];
const ultraSkill = avatarSkills.find(s => s.type === 'Ultra') || avatarSkills[2];
const talentSkill = avatarSkills.find(s => s.type === 'Talent') || avatarSkills[3];
const techSkill = avatarSkills.find(s => s.type === 'Maze' || s.type === 'MazeNormal');
const a2Node = trees?.[`${aid}101`] || Object.values(trees || {}).find(t => String(t.id).startsWith(aid) && t.anchor === 'Point06');
const a4Node = trees?.[`${aid}102`] || Object.values(trees || {}).find(t => String(t.id).startsWith(aid) && t.anchor === 'Point07');
const a6Node = trees?.[`${aid}103`] || Object.values(trees || {}).find(t => String(t.id).startsWith(aid) && t.anchor === 'Point08');
// 1. Basic Attack
if (basicSkill) {
if (el.skillIconBasic) el.skillIconBasic.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/${basicSkill.icon}`;
if (el.skillRealNameBasic) el.skillRealNameBasic.textContent = basicSkill.name;
const curLvl = parseInt(el.rangeSkillBasic?.value || 6, 10);
if (el.skillDescBasic) el.skillDescBasic.innerHTML = formatSkillDesc(basicSkill.desc, basicSkill.params, curLvl);
}
// 2. Skill
if (combatSkill) {
if (el.skillIconSkill) el.skillIconSkill.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/${combatSkill.icon}`;
if (el.skillRealNameSkill) el.skillRealNameSkill.textContent = combatSkill.name;
const curLvl = parseInt(el.rangeSkillSkill?.value || 10, 10);
if (el.skillDescSkill) el.skillDescSkill.innerHTML = formatSkillDesc(combatSkill.desc, combatSkill.params, curLvl);
}
// 3. Ultimate
if (ultraSkill) {
if (el.skillIconUltra) el.skillIconUltra.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/${ultraSkill.icon}`;
if (el.skillRealNameUltra) el.skillRealNameUltra.textContent = ultraSkill.name;
const curLvl = parseInt(el.rangeSkillUltra?.value || 10, 10);
if (el.skillDescUltra) el.skillDescUltra.innerHTML = formatSkillDesc(ultraSkill.desc, ultraSkill.params, curLvl);
}
// 4. Talent
if (talentSkill) {
if (el.skillIconTalent) el.skillIconTalent.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/${talentSkill.icon}`;
if (el.skillRealNameTalent) el.skillRealNameTalent.textContent = talentSkill.name;
const curLvl = parseInt(el.rangeSkillTalent?.value || 10, 10);
if (el.skillDescTalent) el.skillDescTalent.innerHTML = formatSkillDesc(talentSkill.desc, talentSkill.params, curLvl);
}
// Technique
if (techSkill) {
if (el.skillIconTechnique) el.skillIconTechnique.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/${techSkill.icon}`;
if (el.skillRealNameTechnique) el.skillRealNameTechnique.textContent = techSkill.name;
if (el.skillDescTechnique) el.skillDescTechnique.innerHTML = formatSkillDesc(techSkill.desc, techSkill.params, 1);
}
// Traces
if (a2Node) {
if (el.traceIconA2) el.traceIconA2.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/${a2Node.icon}`;
if (el.traceNameA2) el.traceNameA2.textContent = `🌟 ${a2Node.name || 'ร่องรอย A2'}`;
if (el.traceDescA2) el.traceDescA2.innerHTML = formatSkillDesc(a2Node.desc, a2Node.params, 1);
}
if (a4Node) {
if (el.traceIconA4) el.traceIconA4.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/${a4Node.icon}`;
if (el.traceNameA4) el.traceNameA4.textContent = `🌟 ${a4Node.name || 'ร่องรอย A4'}`;
if (el.traceDescA4) el.traceDescA4.innerHTML = formatSkillDesc(a4Node.desc, a4Node.params, 1);
}
if (a6Node) {
if (el.traceIconA6) el.traceIconA6.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/${a6Node.icon}`;
if (el.traceNameA6) el.traceNameA6.textContent = `🌟 ${a6Node.name || 'ร่องรอย A6'}`;
if (el.traceDescA6) el.traceDescA6.innerHTML = formatSkillDesc(a6Node.desc, a6Node.params, 1);
}
}
// Character Detail / Build Modal (srtools & neonteam style)
function openAvatarDetailModal(avatarItem) {
state.selectedAvatarDetail = avatarItem;
const aid = avatarItem.avatar_id;
const meta = getAvatarInfo(aid);
el.modalCharTitle.textContent = `${t('modalTitleCharEdit')}: ${meta.name} (ID: ${aid})`;
el.modalCharImg.src = getAvatarIconSrc(aid);
el.modalCharImg.onerror = function() {
handleAvatarImgError(this, aid);
};
el.modalCharName.textContent = meta.name;
el.modalCharMeta.innerHTML = `
${getElementIconHtml(meta.element, 24)}
${getPathIconHtml(meta.path, 24)}
★ ${meta.rarity}
`;
// Render character switcher strip (Neonteam style)
renderModalSwitcher(aid);
// Default to Overview sub-tab
switchModalSubtab('modaltab-overview');
// Basic Status
el.modalCharLevel.value = avatarItem.level || 80;
el.modalCharPromotion.value = avatarItem.promotion || 6;
renderModalEidolonPips(avatarItem.rank ?? 6);
// Load avatar data from fullFreesrData if available
const av = state.fullFreesrData?.avatars?.[aid];
const skills = av?.data?.skills || {};
const anchorSkills = av?.data?.skills_by_anchor_type || {};
// 1. Basic Attack: anchor 1 or aid + '001' (1-6)
const basicVal = anchorSkills['1'] ?? skills[`${aid}001`] ?? 6;
if (el.rangeSkillBasic && el.valSkillBasic) {
el.rangeSkillBasic.value = Math.min(6, Math.max(1, basicVal));
el.valSkillBasic.textContent = el.rangeSkillBasic.value;
}
// 2. Skill: anchor 2 or aid + '002' (1-10)
const skillVal = anchorSkills['2'] ?? skills[`${aid}002`] ?? 10;
if (el.rangeSkillSkill && el.valSkillSkill) {
el.rangeSkillSkill.value = Math.min(10, Math.max(1, skillVal));
el.valSkillSkill.textContent = el.rangeSkillSkill.value;
}
// 3. Ultimate: anchor 3 or aid + '003' (1-10)
const ultraVal = anchorSkills['3'] ?? skills[`${aid}003`] ?? 10;
if (el.rangeSkillUltra && el.valSkillUltra) {
el.rangeSkillUltra.value = Math.min(10, Math.max(1, ultraVal));
el.valSkillUltra.textContent = el.rangeSkillUltra.value;
}
// 4. Talent: anchor 4 or aid + '004' (1-10)
const talentVal = anchorSkills['4'] ?? skills[`${aid}004`] ?? 10;
if (el.rangeSkillTalent && el.valSkillTalent) {
el.rangeSkillTalent.value = Math.min(10, Math.max(1, talentVal));
el.valSkillTalent.textContent = el.rangeSkillTalent.value;
}
// Traces & Technique
if (el.checkTraceTechnique) {
el.checkTraceTechnique.checked = (anchorSkills['5'] ?? skills[`${aid}007`] ?? 1) > 0;
}
if (el.checkTraceA2) {
el.checkTraceA2.checked = (anchorSkills['6'] ?? skills[`${aid}101`] ?? 1) > 0;
}
if (el.checkTraceA4) {
el.checkTraceA4.checked = (anchorSkills['7'] ?? skills[`${aid}102`] ?? 1) > 0;
}
if (el.checkTraceA6) {
el.checkTraceA6.checked = (anchorSkills['8'] ?? skills[`${aid}103`] ?? 1) > 0;
}
if (el.checkTraceStats) {
el.checkTraceStats.checked = (anchorSkills['9'] ?? skills[`${aid}201`] ?? 1) > 0;
}
// Lightcone initialization & full preview
populateModalChangeLcDropdown(meta.path);
const equippedLc = (state.fullFreesrData?.lightcones || []).find(lc => lc.equip_avatar === aid);
const activeLcId = equippedLc?.item_id || SIGNATURE_LIGHTCONES[aid] || 21001;
const activeLcRank = equippedLc?.rank || 5;
const activeLcLevel = equippedLc?.level || 80;
selectedLcRank = activeLcRank;
updateModalLcFullView(activeLcId, activeLcRank, activeLcLevel);
// Render Subtabs
renderModalOverviewTab(aid);
renderModalRelicsTab(aid);
renderModalSkillsTab(aid);
renderShowcaseCard(aid);
el.modalAvatarDetail.classList.add('active');
}
function renderModalEidolonPips(currentRank) {
el.modalCharEidolonBtns.innerHTML = '';
for (let r = 0; r <= 6; r++) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `eidolon-pip modal-eidolon-pip ${currentRank >= r ? 'active' : ''}`;
btn.textContent = `E${r}`;
btn.title = `Eidolon ${r}`;
btn.addEventListener('click', () => {
if (state.selectedAvatarDetail) {
state.selectedAvatarDetail.rank = r;
}
renderModalEidolonPips(r);
});
el.modalCharEidolonBtns.appendChild(btn);
}
}
// Set avatar rank (Used by both card pips and modal)
async function setAvatarRank(avatarId, rank) {
const target = state.avatarsList.find(a => a.avatar_id === avatarId);
if (target) {
target.rank = rank;
}
if (state.fullFreesrData?.avatars?.[avatarId]) {
state.fullFreesrData.avatars[avatarId].data.rank = rank;
}
renderAvatarsGrid();
if (state.selectedAvatarDetail && state.selectedAvatarDetail.avatar_id === avatarId) {
state.selectedAvatarDetail.rank = rank;
renderModalEidolonPips(rank);
}
if (state.isConnected) {
try {
if (state.serverMode === 'gac') {
await apiCall('/api/avatar/rank', 'POST', { avatar_id: avatarId, rank });
} else {
await saveFreesrChangesToServer();
}
showToast(t('avatarRankSuccess', { id: avatarId, rank }), 'success');
} catch (err) {
console.warn('Rank sync to server failed:', err);
}
} else {
showToast(`อัปเดต ${getAvatarInfo(avatarId).name} เป็น E${rank} (บันทึกในเครื่อง)`, 'info');
}
}
// Max Skills & Traces for character
function maxSkillsForAvatar() {
if (el.rangeSkillBasic && el.valSkillBasic) {
el.rangeSkillBasic.value = 6;
el.valSkillBasic.textContent = 6;
}
if (el.rangeSkillSkill && el.valSkillSkill) {
el.rangeSkillSkill.value = 10;
el.valSkillSkill.textContent = 10;
}
if (el.rangeSkillUltra && el.valSkillUltra) {
el.rangeSkillUltra.value = 10;
el.valSkillUltra.textContent = 10;
}
if (el.rangeSkillTalent && el.valSkillTalent) {
el.rangeSkillTalent.value = 10;
el.valSkillTalent.textContent = 10;
}
if (el.checkTraceTechnique) el.checkTraceTechnique.checked = true;
if (el.checkTraceA2) el.checkTraceA2.checked = true;
if (el.checkTraceA4) el.checkTraceA4.checked = true;
if (el.checkTraceA6) el.checkTraceA6.checked = true;
if (el.checkTraceStats) el.checkTraceStats.checked = true;
showToast(t('toastAllSkillsMaxed'), 'info');
}
// Max All Build For Current Avatar (Lv.80, Prom 6, E6, Max Skills, Max Traces, 100% Energy, LC Lv.80 S5)
async function maxAllBuildForCurrentAvatar() {
if (!state.selectedAvatarDetail) return;
const aid = state.selectedAvatarDetail.avatar_id;
// Level & Eidolon
el.modalCharLevel.value = 80;
el.modalCharPromotion.value = 6;
state.selectedAvatarDetail.level = 80;
state.selectedAvatarDetail.promotion = 6;
state.selectedAvatarDetail.rank = 6;
renderModalEidolonPips(6);
// Skills & Traces
maxSkillsForAvatar();
// Energy (Default 50% standard starting energy)
if (el.rangeCharEnergy && el.valCharEnergy) {
const half = Math.floor(parseInt(el.rangeCharEnergy.max, 10) * 0.5);
el.rangeCharEnergy.value = half;
el.valCharEnergy.textContent = `${half}/${el.rangeCharEnergy.max}`;
}
// Lightcone
if (el.modalLcLevel) el.modalLcLevel.value = 80;
updateLcRankPips(5);
// Save changes
await saveAvatarDetails();
}
// Save avatar details from modal
async function saveAvatarDetails() {
if (!state.selectedAvatarDetail) return;
const aid = state.selectedAvatarDetail.avatar_id;
const level = parseInt(el.modalCharLevel.value, 10) || 80;
const promotion = parseInt(el.modalCharPromotion.value, 10) || 6;
const rank = state.selectedAvatarDetail.rank ?? 6;
state.selectedAvatarDetail.level = level;
state.selectedAvatarDetail.promotion = promotion;
state.selectedAvatarDetail.rank = rank;
const target = state.avatarsList.find(a => a.avatar_id === aid);
if (target) {
target.level = level;
target.promotion = promotion;
target.rank = rank;
}
const maxSp = parseInt(el.rangeCharEnergy?.max, 10) || 140;
const defaultSp = Math.floor(maxSp * 0.5);
const currentSp = el.rangeCharEnergy ? parseInt(el.rangeCharEnergy.value, 10) : defaultSp;
if (!state.fullFreesrData) {
state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
}
if (!state.fullFreesrData.avatars) {
state.fullFreesrData.avatars = {};
}
if (!state.fullFreesrData.avatars[aid]) {
state.fullFreesrData.avatars[aid] = {
owner_uid: 0,
avatar_id: aid,
level,
promotion,
techniques: [],
sp_value: currentSp,
sp_max: maxSp,
data: {
rank,
skills: {},
skills_by_anchor_type: {}
}
};
}
const av = state.fullFreesrData.avatars[aid];
av.level = level;
av.promotion = promotion;
av.data = av.data || {};
av.data.rank = rank;
av.sp_max = maxSp;
av.sp_value = currentSp;
// Extract skills values
const basicVal = parseInt(el.rangeSkillBasic?.value, 10) || 6;
const skillVal = parseInt(el.rangeSkillSkill?.value, 10) || 10;
const ultraVal = parseInt(el.rangeSkillUltra?.value, 10) || 10;
const talentVal = parseInt(el.rangeSkillTalent?.value, 10) || 10;
const techVal = el.checkTraceTechnique?.checked ? 1 : 0;
const a2Val = el.checkTraceA2?.checked ? 1 : 0;
const a4Val = el.checkTraceA4?.checked ? 1 : 0;
const a6Val = el.checkTraceA6?.checked ? 1 : 0;
const statVal = el.checkTraceStats?.checked ? 1 : 0;
av.data.skills = av.data.skills || {};
av.data.skills[`${aid}001`] = basicVal;
av.data.skills[`${aid}002`] = skillVal;
av.data.skills[`${aid}003`] = ultraVal;
av.data.skills[`${aid}004`] = talentVal;
av.data.skills[`${aid}007`] = techVal;
av.data.skills[`${aid}101`] = a2Val;
av.data.skills[`${aid}102`] = a4Val;
av.data.skills[`${aid}103`] = a6Val;
for (let i = 1; i <= 10; i++) {
const pad = i < 10 ? `0${i}` : `${i}`;
av.data.skills[`${aid}2${pad}`] = statVal;
}
av.data.skills_by_anchor_type = {
"1": basicVal,
"2": skillVal,
"3": ultraVal,
"4": talentVal,
"5": techVal,
"6": a2Val,
"7": a4Val,
"8": a6Val,
"9": statVal,
"10": statVal,
"11": statVal,
"12": statVal,
"13": statVal,
"14": statVal,
"15": statVal,
"16": statVal,
"17": statVal,
"18": statVal
};
// Lightcone update
state.fullFreesrData.lightcones = state.fullFreesrData.lightcones || [];
const lcId = parseInt(el.modalLcId?.value, 10);
const lcLevel = parseInt(el.modalLcLevel?.value, 10) || 80;
const lcRank = selectedLcRank || 5;
const lcProm = lcLevel > 70 ? 6 : (lcLevel > 60 ? 5 : (lcLevel > 50 ? 4 : (lcLevel > 40 ? 3 : (lcLevel > 20 ? 1 : 0))));
const existingLcIndex = state.fullFreesrData.lightcones.findIndex(lc => lc.equip_avatar === aid);
if (lcId && lcId > 0) {
if (existingLcIndex !== -1) {
state.fullFreesrData.lightcones[existingLcIndex].item_id = lcId;
state.fullFreesrData.lightcones[existingLcIndex].level = lcLevel;
state.fullFreesrData.lightcones[existingLcIndex].rank = lcRank;
state.fullFreesrData.lightcones[existingLcIndex].promotion = lcProm;
} else {
state.fullFreesrData.lightcones.push({
internal_uid: state.fullFreesrData.lightcones.length,
item_id: lcId,
equip_avatar: aid,
level: lcLevel,
rank: lcRank,
promotion: lcProm
});
}
} else if (existingLcIndex !== -1) {
state.fullFreesrData.lightcones[existingLcIndex].equip_avatar = null;
}
renderAvatarsGrid();
loadEquipment();
if (closeModal) {
el.modalAvatarDetail.classList.remove('active');
}
if (state.isConnected) {
try {
if (state.serverMode === 'gac') {
await apiCall('/api/freesr/avatars', 'POST', { avatar_id: aid, level, promotion, rank });
await saveFreesrChangesToServer();
} else {
await saveFreesrChangesToServer();
}
showToast(t('toastCharSaved', { name: getAvatarInfo(aid).name }), 'success');
} catch (err) {
showToast(t('toastCharSaved', { name: getAvatarInfo(aid).name }), 'info');
}
} else {
showToast(t('toastCharSaved', { name: getAvatarInfo(aid).name }), 'success');
}
}
// Apply E6S5 Build (Lv.80, Prom 6, E6, Max Skills/Traces, 50% Energy, S5 Light Cone)
async function applyE6S5Build() {
if (!state.selectedAvatarDetail) return;
const aid = state.selectedAvatarDetail.avatar_id;
// Level & Eidolon
el.modalCharLevel.value = 80;
el.modalCharPromotion.value = 6;
state.selectedAvatarDetail.level = 80;
state.selectedAvatarDetail.promotion = 6;
state.selectedAvatarDetail.rank = 6;
renderModalEidolonPips(6);
// Skills & Traces
maxSkillsForAvatar();
// Energy: 50% standard
if (el.rangeCharEnergy && el.valCharEnergy) {
const half = Math.floor(parseInt(el.rangeCharEnergy.max, 10) * 0.5);
el.rangeCharEnergy.value = half;
el.valCharEnergy.textContent = `${half}/${el.rangeCharEnergy.max}`;
}
// Light Cone: Equip true Signature LC Lv 80, S5
const sigLcId = SIGNATURE_LIGHTCONES[aid] || 21001;
if (el.modalLcId) el.modalLcId.value = sigLcId;
selectedLcRank = 5;
updateModalLcFullView(sigLcId, 5, 80);
// Equip optimal relics
await autoEquipRecommendedRelics(aid, false);
await saveAvatarDetails(false);
renderShowcaseCard(aid);
showToast(t('toastE6S5Applied'), 'success');
}
// Apply E0S1 Build (Lv.80, Prom 6, E0, Max Skills/Traces, 50% Energy, S1 Signature Light Cone)
async function applyE0S1Build() {
if (!state.selectedAvatarDetail) return;
const aid = state.selectedAvatarDetail.avatar_id;
// Level & Eidolon
el.modalCharLevel.value = 80;
el.modalCharPromotion.value = 6;
state.selectedAvatarDetail.level = 80;
state.selectedAvatarDetail.promotion = 6;
state.selectedAvatarDetail.rank = 0;
renderModalEidolonPips(0);
// Skills & Traces
maxSkillsForAvatar();
// Energy: 50% standard
if (el.rangeCharEnergy && el.valCharEnergy) {
const half = Math.floor(parseInt(el.rangeCharEnergy.max, 10) * 0.5);
el.rangeCharEnergy.value = half;
el.valCharEnergy.textContent = `${half}/${el.rangeCharEnergy.max}`;
}
// Light Cone: Equip true Signature LC Lv 80, S1
const sigLcId = SIGNATURE_LIGHTCONES[aid] || 21001;
if (el.modalLcId) el.modalLcId.value = sigLcId;
selectedLcRank = 1;
updateModalLcFullView(sigLcId, 1, 80);
// Equip optimal relics
await autoEquipRecommendedRelics(aid, false);
await saveAvatarDetails(false);
renderShowcaseCard(aid);
showToast(t('toastE0S1Applied'), 'info');
}
// Render Showcase Card (Matching srtools.punklorde.org/showcase-card)
function renderShowcaseCard(avatarId) {
if (!el.showcaseCardContainer) return;
const aid = parseInt(avatarId, 10);
const meta = getAvatarInfo(aid);
const canonPath = normalizePath(meta.rawPath || meta.path);
const canonElem = normalizeElement(meta.rawElement || meta.element);
const av = state.fullFreesrData?.avatars?.[aid];
const level = av?.level || state.selectedAvatarDetail?.level || 80;
const rank = av?.data?.rank ?? state.selectedAvatarDetail?.rank ?? 6;
// 1. Equipped Light Cone
const equippedLc = (state.fullFreesrData?.lightcones || []).find(lc => lc.equip_avatar === aid);
const lcId = equippedLc?.item_id || SIGNATURE_LIGHTCONES[aid] || 21001;
const lcMeta = LIGHTCONE_METADATA[lcId] || {};
const lcInfo = getLightconeInfo(lcId);
const lcLevel = equippedLc?.level || 80;
const lcRank = equippedLc?.rank || selectedLcRank || 5;
const baseHp = meta.rarity === 5 ? 1200 : 1058;
const baseAtk = meta.rarity === 5 ? 640 : 511;
const baseDef = meta.rarity === 5 ? 600 : 573;
const baseSpd = 100;
const lcHp = lcMeta.hp || (lcMeta.rarity === 5 ? 1164 : (lcMeta.rarity === 4 ? 953 : 846));
const lcAtk = lcMeta.atk || (lcMeta.rarity === 5 ? 582 : (lcMeta.rarity === 4 ? 423 : 317));
const lcDef = lcMeta.def || (lcMeta.rarity === 5 ? 529 : (lcMeta.rarity === 4 ? 396 : 264));
// 2. Relics & Set Bonuses
const avatarRelics = (state.fullFreesrData?.relics || []).filter(r => r.equip_avatar === aid);
const setCounts = {};
avatarRelics.forEach(r => {
const setId = Math.floor((r.relic_id % 10000) / 10);
setCounts[setId] = (setCounts[setId] || 0) + 1;
});
const activeSetBadges = [];
for (const sId in setCounts) {
const count = setCounts[sId];
const sInfo = getRelicSetInfo(sId);
if (count >= 4) {
activeSetBadges.push({ name: sInfo.name, count: 4 });
} else if (count >= 2) {
activeSetBadges.push({ name: sInfo.name, count: 2 });
}
}
if (activeSetBadges.length === 0) {
const rec = RECOMMENDED_RELIC_BUILDS[canonPath] || RECOMMENDED_RELIC_BUILDS.Preservation;
activeSetBadges.push({ name: getRelicSetInfo(rec.cavern).name, count: 4 });
activeSetBadges.push({ name: getRelicSetInfo(rec.planar).name, count: 2 });
}
// Combat Stats
const totalHp = Math.round((baseHp + lcHp) * 1.45 + 706);
const totalAtk = Math.round((baseAtk + lcAtk) * 1.35 + 353);
const totalDef = Math.round((baseDef + lcDef) * 1.85 + (canonPath === 'Preservation' ? 1200 : 0));
const totalSpd = (baseSpd + 25 + 10.5).toFixed(1);
const critRate = (5.0 + (canonPath === 'Hunt' || canonPath === 'Destruction' ? 65.4 : 18.8)).toFixed(1);
const critDmg = (50.0 + (canonPath === 'Hunt' || canonPath === 'Destruction' ? 132.5 : 26.5)).toFixed(1);
const breakEffect = (canonPath === 'Harmony' || canonPath === 'Nihility' ? 142.0 : 10.4).toFixed(1);
const effectRes = (10.0).toFixed(1);
const err = (19.4).toFixed(1);
const ehr = (canonPath === 'Nihility' ? 67.2 : 19.4).toFixed(1);
const healing = (canonPath === 'Abundance' ? 34.5 : 0.0).toFixed(1);
const elemDmg = (38.8).toFixed(1);
// Eidolon strip
let eidolonStripHtml = '';
for (let e = 1; e <= 6; e++) {
const isAct = rank >= e;
eidolonStripHtml += `E${e}
`;
}
// Sets HTML
let setsHtml = '';
activeSetBadges.forEach(sb => {
setsHtml += `
${sb.name}
${sb.count}-PC
`;
});
// Relic cards (Slots 1 to 6)
const slotNames = {
1: { name: "ส่วนหัว", stat: "HP 706" },
2: { name: "ส่วนมือ", stat: "ATK 353" },
3: { name: "ลำตัว", stat: canonPath === 'Preservation' ? "DEF 54.0%" : (canonPath === 'Hunt' ? "CRIT Rate 32.4%" : "HP 43.2%") },
4: { name: "รองเท้า", stat: "SPD 25.0" },
5: { name: "ลูกแก้ว", stat: `${translateElement(canonElem)} 38.8%` },
6: { name: "สร้อย", stat: canonPath === 'Preservation' || canonPath === 'Harmony' ? "ERR 19.4%" : "ATK 43.2%" }
};
const showRolls = localStorage.getItem('gac_srtool_showcase_rolls') !== 'false';
const showAura = localStorage.getItem('gac_srtool_showcase_aura') !== 'false';
const rollBadge = (rolls) => showRolls ? `+${rolls}` : '';
let relicsHtml = '';
for (let slot = 1; slot <= 6; slot++) {
const equipped = avatarRelics.find(r => (r.relic_id % 10 === slot));
const rec = RECOMMENDED_RELIC_BUILDS[canonPath] || RECOMMENDED_RELIC_BUILDS.Preservation;
const setId = equipped ? Math.floor((equipped.relic_id % 10000) / 10) : (slot <= 4 ? rec.cavern : rec.planar);
const rInfo = getRelicSetInfo(setId);
const slotInfo = slotNames[slot];
let mainStatDisplay = slotInfo.stat;
if (equipped && equipped.main_affix_id) {
mainStatDisplay = getRelicMainAffixName(slot, equipped.main_affix_id);
}
let subsDisplayHtml = '';
if (equipped && equipped.sub_affixes && equipped.sub_affixes.length > 0) {
subsDisplayHtml = equipped.sub_affixes.map(sub => {
const sName = getRelicSubAffixName(sub.sub_affix_id);
const rolls = sub.count || sub.cnt || 1;
const val = formatRelicSubstatValue(sub.sub_affix_id, rolls);
return `${sName} +${val}${rollBadge(rolls)}`;
}).join('');
} else {
subsDisplayHtml = `
CRIT Rate +${(3.2 + slot * 1.5).toFixed(1)}%${rollBadge(slot % 2 === 0 ? 3 : 2)}
CRIT DMG +${(5.8 + slot * 2.1).toFixed(1)}%${rollBadge(slot % 3 === 0 ? 4 : 2)}
ATK +${(3.8 + slot * 0.8).toFixed(1)}%${rollBadge(1)}
DEF +${(4.8 + slot * 1.2).toFixed(1)}%${rollBadge(1)}
`;
}
relicsHtml += `
+15
${rInfo.name}
✦ ${mainStatDisplay}
${subsDisplayHtml}
`;
}
const primaryPortrait = getAvatarSplashSrc(aid);
const fallbackPortrait = `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/image/character_portrait/${aid}.png`;
const lcArtThumb = getLightconeArtSrc(lcId);
el.showcaseCardContainer.innerHTML = `
${meta.name}
${getElementIconHtml(meta.element, 22)}
${getPathIconHtml(meta.path, 22)}
Lv. ${level}/80
${eidolonStripHtml}
${lcInfo.name}
S${lcRank} • Lv. ${lcLevel}/80
❤️ ${lcHp}
⚔️ ${lcAtk}
🛡️ ${lcDef}
Active Set Bonuses
${setsHtml}
❤️ HP${totalHp}
⚔️ ATK${totalAtk}
🛡️ DEF${totalDef}
👟 SPD${totalSpd}
🎯 CRIT Rate${critRate}%
💥 CRIT DMG${critDmg}%
⚡ Break Eff${breakEffect}%
🛡️ Eff RES${effectRes}%
🌀 ERR${err}%
🎯 Eff Hit${ehr}%
💚 Healing${healing}%
✨ ${meta.element}${elemDmg}%
Equipped Relics (+15)
${relicsHtml}
`;
// Apply Element Aura Glow
const auraGlows = {
Physical: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%)',
Fire: 'linear-gradient(135deg, rgba(79, 15, 15, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%)',
Ice: 'linear-gradient(135deg, rgba(12, 74, 96, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%)',
Lightning: 'linear-gradient(135deg, rgba(68, 15, 110, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%)',
Wind: 'linear-gradient(135deg, rgba(6, 78, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%)',
Quantum: 'linear-gradient(135deg, rgba(49, 46, 129, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%)',
Imaginary: 'linear-gradient(135deg, rgba(88, 40, 5, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%)'
};
const auraBorders = {
Physical: 'rgba(203, 213, 225, 0.35)',
Fire: 'rgba(239, 68, 68, 0.45)',
Ice: 'rgba(56, 189, 248, 0.45)',
Lightning: 'rgba(168, 85, 247, 0.45)',
Wind: 'rgba(52, 211, 153, 0.45)',
Quantum: 'rgba(129, 140, 248, 0.45)',
Imaginary: 'rgba(251, 191, 36, 0.45)'
};
if (showAura && auraGlows[canonElem]) {
el.showcaseCardContainer.style.background = auraGlows[canonElem];
el.showcaseCardContainer.style.borderColor = auraBorders[canonElem] || 'var(--border-color)';
el.showcaseCardContainer.style.boxShadow = `0 10px 40px -10px ${auraBorders[canonElem] || 'rgba(0,0,0,0.5)'}`;
} else {
el.showcaseCardContainer.style.background = 'linear-gradient(135deg, #0d121f 0%, #171f30 100%)';
el.showcaseCardContainer.style.borderColor = 'var(--border-color)';
el.showcaseCardContainer.style.boxShadow = '0 10px 30px rgba(0,0,0,0.5)';
}
}
// Auto-equip recommended 6-piece relics for avatar
async function autoEquipRecommendedRelics(avatarId, notify = true) {
if (!state.fullFreesrData) {
state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
}
state.fullFreesrData.relics = state.fullFreesrData.relics || [];
const aid = parseInt(avatarId, 10);
const meta = getAvatarInfo(aid);
const rec = getOptimalBuildForAvatar(aid);
// Clear existing relics on this character
state.fullFreesrData.relics = state.fullFreesrData.relics.filter(r => r.equip_avatar !== aid);
let nextUid = (state.fullFreesrData.relics.reduce((m, r) => Math.max(m, r.internal_uid || 0), 0)) + 1;
const mainToSubConflict = {
3: { 1: 4, 2: 5, 3: 6, 4: 8, 5: 9, 7: 10 },
4: { 1: 4, 2: 5, 3: 6, 4: 7 },
5: { 1: 4, 2: 5, 3: 6 },
6: { 1: 12, 3: 4, 4: 5, 5: 6 }
};
for (let slot = 1; slot <= 6; slot++) {
const setId = slot <= 4 ? rec.cavern : rec.planar;
const relicId = 60000 + (setId * 10) + slot;
let mainAffixId = 1;
if (slot === 1 || slot === 2) {
mainAffixId = 1;
} else if (slot === 3) {
mainAffixId = rec.body;
} else if (slot === 4) {
mainAffixId = rec.feet;
} else if (slot === 5) {
mainAffixId = rec.sphere;
} else if (slot === 6) {
mainAffixId = rec.rope;
}
// Select 4 non-conflicting god-tier substats
const conflictingSub = mainToSubConflict[slot]?.[mainAffixId];
const pool = [...rec.substats, 7, 8, 9, 5, 12, 11, 4, 6];
const chosenSubs = [];
for (const sId of pool) {
if (chosenSubs.length >= 4) break;
if (sId !== conflictingSub && !chosenSubs.includes(sId)) {
chosenSubs.push(sId);
}
}
const rolls = [4, 3, 1, 1];
const subAffixes = chosenSubs.map((sid, idx) => ({
sub_affix_id: sid,
cnt: rolls[idx] || 1,
count: rolls[idx] || 1,
step: 2
}));
state.fullFreesrData.relics.push({
internal_uid: nextUid++,
relic_id: relicId,
level: 15,
equip_avatar: aid,
main_affix_id: mainAffixId,
sub_affixes: subAffixes
});
}
if (state.isConnected) {
await saveFreesrChangesToServer();
}
loadEquipment();
renderShowcaseCard(aid);
if (state.selectedAvatarDetail?.avatar_id === aid) {
renderModalRelicsTab(aid);
renderModalOverviewTab(aid);
}
if (notify) {
showToast(t('toastRelicsEquipped', { name: meta.name }), 'success');
}
}
// Copy Showcase Card build text to clipboard
function copyShowcaseText(avatarId) {
const meta = getAvatarInfo(avatarId);
const av = state.fullFreesrData?.avatars?.[avatarId];
const rank = av?.data?.rank ?? state.selectedAvatarDetail?.rank ?? 6;
const lc = (state.fullFreesrData?.lightcones || []).find(l => l.equip_avatar === avatarId);
const lcInfo = lc ? getLightconeInfo(lc.item_id) : { name: "None", rarity: 0 };
const text = `✦ Honkai: Star Rail Character Build ✦
Name: ${meta.name} (ID: ${avatarId})
Path: ${meta.path} | Element: ${meta.element}
Level: 80 | Eidolon: E${rank}
Light Cone: ${lcInfo.name} (Lv. ${lc?.level || 80} / S${lc?.rank || 1})
Generated via GaC-Srtool`;
navigator.clipboard.writeText(text).then(() => {
showToast(t('toastShowcaseCopied'), 'success');
}).catch(() => {
showToast('Failed to copy to clipboard', 'warning');
});
}
// Export Showcase Card as high-res PNG image using html2canvas
async function exportShowcaseImage(avatarId) {
const meta = getAvatarInfo(avatarId);
const showcaseEl = document.getElementById('showcase-card-container');
if (!showcaseEl) return;
showToast('กำลังประมวลผลและสร้างรูปภาพการ์ด PNG...', 'info');
if (typeof html2canvas !== 'undefined') {
try {
const origWidth = showcaseEl.style.width;
const origMinWidth = showcaseEl.style.minWidth;
showcaseEl.style.width = '1050px';
showcaseEl.style.minWidth = '1050px';
const canvas = await html2canvas(showcaseEl, {
useCORS: true,
allowTaint: true,
scale: 2,
backgroundColor: '#0c1222',
windowWidth: 1280
});
showcaseEl.style.width = origWidth;
showcaseEl.style.minWidth = origMinWidth;
const dataUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataUrl;
const cleanName = meta.name.replace(/[^a-zA-Z0-9ก-๙_-]/g, '_');
a.download = `${cleanName}_Build_${Date.now()}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
showToast('ดาวน์โหลดรูปภาพการ์ด PNG เรียบร้อยแล้ว!', 'success');
return;
} catch (err) {
console.warn('html2canvas failed, falling back to print view:', err);
}
}
const win = window.open('', '_blank');
if (!win) {
showToast('กรุณาอนุญาต Pop-up เพื่อพิมพ์หรือบันทึกรูปภาพการ์ด', 'warning');
return;
}
win.document.write(`
${meta.name} - Star Rail Build Showcase
${showcaseEl.outerHTML}
`);
win.document.close();
showToast(t('showcaseExport') || 'เปิดหน้าต่างสำหรับบันทึกการ์ดเรียบร้อย', 'info');
}
// Server Lineup Persistence Manager (GaC Persistent vs FireflyGo data-in-game vs FreeSR)
async function saveLineupToServer(lineupData) {
if (!lineupData) return;
// 1. GaC server handles lineups in persistent
if (state.serverMode === 'gac') {
if (state.persistentData) {
state.persistentData.lineups = lineupData;
savePersistentCache(state.persistentData);
}
if (state.isConnected) {
await apiCall('/api/persistent', 'POST', { lineups: lineupData }).catch(() => {});
}
}
// 2. FireflyGo / FreeSR servers handle lineups in freesr-data.json
if (!state.fullFreesrData) {
state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
}
state.fullFreesrData.lineups = { ...lineupData };
if (!state.fullFreesrData.battle_config) {
state.fullFreesrData.battle_config = {};
}
// Dynamically map all active slots in order - never truncate slots in Creative Mode!
const slotKeys = Object.keys(lineupData).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
const fullArr = slotKeys.map(k => lineupData[k] || 0);
state.fullFreesrData.battle_config.custom_battle_lineup = fullArr;
state.fullFreesrData.custom_battle_lineup = fullArr;
await saveFreesrCache(state.fullFreesrData);
// 3. Firefly live state in data-in-game.json
if (state.dataGameData || state.serverMode === 'firefly') {
state.dataGameData = state.dataGameData || { ...DEFAULT_DATA_GAME };
state.dataGameData.lineups = { ...lineupData };
await saveDataGameCache(state.dataGameData);
if (state.isConnected) {
await apiCall('/api/data-in-game', 'POST', state.dataGameData).catch(() => {});
}
}
if (state.isConnected && state.serverMode !== 'gac') {
await saveFreesrChangesToServer().catch(() => {});
}
}
// ============================================================================
// Quick Action Functions
// ============================================================================
// 1. Quick Set Entire Lineup to E0S1 Standard
async function quickSetLineupE0S1() {
try {
state.statusData = state.statusData || {};
const lineups = state.statusData.lineups || DEFAULT_LINEUP;
const lineupIds = Object.values(lineups).filter(id => id && id > 0);
if (lineupIds.length === 0) {
showToast(t('slotEmpty') || 'ไม่มีตัวละครในทีม', 'warning');
return;
}
for (const aid of lineupIds) {
await setAvatarE0S1(aid);
}
showToast('ปรับตัวละครทุกคนในทีมเป็น E0S1 พร้อมรบเรียบร้อยแล้ว!', 'success');
} catch (err) {
showToast(err.message, 'error');
}
}
// 2. Quick Equip Recommended Relics for Entire Lineup
async function quickEquipLineupRelics() {
try {
state.statusData = state.statusData || {};
const lineups = state.statusData.lineups || DEFAULT_LINEUP;
const lineupIds = Object.values(lineups).filter(id => id && id > 0);
if (lineupIds.length === 0) {
showToast(t('slotEmpty') || 'ไม่มีตัวละครในทีม', 'warning');
return;
}
for (const aid of lineupIds) {
await autoEquipRecommendedRelics(aid, false);
}
showToast('ติดตั้งรีลิกส์และเครื่องประดับแนะนำให้ทั้งทีมเรียบร้อยแล้ว!', 'success');
} catch (err) {
showToast(err.message, 'error');
}
}
// 2. Quick Unlock All 70+ Avatars
async function quickUnlockAllAvatars() {
try {
if (!state.fullFreesrData) {
state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
}
state.fullFreesrData.avatars = state.fullFreesrData.avatars || {};
const allIds = Object.keys(AVATAR_METADATA).map(id => parseInt(id, 10));
allIds.forEach(aid => {
if (!state.fullFreesrData.avatars[aid]) {
state.fullFreesrData.avatars[aid] = {
owner_uid: 0,
avatar_id: aid,
level: 80,
promotion: 6,
techniques: [],
sp_max: 140,
sp_value: 70, // 50% standard starting energy
data: {
rank: 6,
skills: {
[`${aid}001`]: 6,
[`${aid}002`]: 10,
[`${aid}003`]: 10,
[`${aid}004`]: 10,
[`${aid}007`]: 1,
[`${aid}101`]: 1,
[`${aid}102`]: 1,
[`${aid}103`]: 1,
[`${aid}201`]: 1
},
skills_by_anchor_type: {
"1": 6, "2": 10, "3": 10, "4": 10, "5": 1, "6": 1, "7": 1, "8": 1, "9": 1
}
}
};
} else {
const av = state.fullFreesrData.avatars[aid];
av.level = 80;
av.promotion = 6;
av.data = av.data || {};
av.data.rank = 6;
if (!av.sp_value) av.sp_value = 70;
}
});
initAvatarsList();
state.avatarsList.forEach(a => {
a.level = 80;
a.promotion = 6;
a.rank = 6;
});
if (state.isConnected) {
if (state.serverMode === 'gac') {
await apiCall('/api/avatar/max_all', 'POST', {}).catch(() => {});
await saveFreesrChangesToServer().catch(() => {});
} else {
await saveFreesrChangesToServer();
}
}
renderAvatarsGrid();
if (el.statAvatars) el.statAvatars.textContent = Object.keys(state.fullFreesrData.avatars).length;
showToast(t('toastUnlockedAll') || 'ปลดล็อกตัวละครครบทุกตัว (70+ ตัว E6) เรียบร้อยแล้ว', 'success');
} catch (err) {
showToast(err.message, 'error');
}
}
// 3. Quick Add All 5-Star Signature Light Cones
async function quickAddAll5StarLightcones() {
try {
if (!state.fullFreesrData) {
state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
}
state.fullFreesrData.lightcones = state.fullFreesrData.lightcones || [];
const existingLcIds = new Set(state.fullFreesrData.lightcones.map(lc => lc.item_id));
let nextUniqueId = 1000;
state.fullFreesrData.lightcones.forEach(lc => {
if (lc.unique_id >= nextUniqueId) nextUniqueId = lc.unique_id + 1;
});
let addedCount = 0;
for (const idStr in LIGHTCONE_METADATA) {
const meta = LIGHTCONE_METADATA[idStr];
const itemId = parseInt(idStr, 10);
if (meta.rarity === 5 && !existingLcIds.has(itemId)) {
state.fullFreesrData.lightcones.push({
unique_id: nextUniqueId++,
item_id: itemId,
level: 80,
promotion: 6,
rank: 5,
equip_avatar: 0
});
existingLcIds.add(itemId);
addedCount++;
}
}
if (state.isConnected) {
await saveFreesrChangesToServer();
}
loadEquipment();
showToast(t('toastAddedAllLcs') || `เพิ่มกรวยแสง 5 ดาวทั้งหมด (${addedCount} ชิ้น) เข้าคลังแล้ว`, 'success');
} catch (err) {
showToast(err.message, 'error');
}
}
// 4. Quick Teleport to Astral Express Parlor Car
async function quickTeleportAstralExpress() {
const trainLoc = {
plane_id: 20001,
floor_id: 20001001,
entry_id: 2000101,
x: 0,
y: 0,
z: 0,
rot_y: 0
};
try {
if (state.fullFreesrData) {
state.fullFreesrData.scene = {
plane_id: trainLoc.plane_id,
floor_id: trainLoc.floor_id,
entry_id: trainLoc.entry_id
};
state.fullFreesrData.position = {
x: trainLoc.x,
y: trainLoc.y,
z: trainLoc.z,
rot_y: trainLoc.rot_y
};
}
if (state.isConnected) {
if (state.serverMode === 'gac') {
await apiCall('/api/teleport', 'POST', trainLoc).catch(async () => {
await apiCall('/api/persistent', 'POST', {
scene: { plane_id: trainLoc.plane_id, floor_id: trainLoc.floor_id, entry_id: trainLoc.entry_id },
position: { x: trainLoc.x, y: trainLoc.y, z: trainLoc.z, rot_y: trainLoc.rot_y }
});
});
} else {
await saveFreesrChangesToServer();
}
}
if (el.lblPlaneId) el.lblPlaneId.textContent = trainLoc.plane_id;
if (el.lblFloorId) el.lblFloorId.textContent = trainLoc.floor_id;
if (el.lblCoordinates) el.lblCoordinates.textContent = `X: ${trainLoc.x}, Y: ${trainLoc.y}, Z: ${trainLoc.z}`;
showToast(t('toastTeleportedTrain') || 'วาร์ปกลับสู่ Astral Express Parlor Car เรียบร้อยแล้ว', 'success');
} catch (err) {
showToast(err.message, 'error');
}
}
// 5. Quick Clear Lineup Slots
async function quickClearLineup() {
try {
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 cleared = {};
Object.keys(currentLineup).forEach(k => {
cleared[k] = 0;
});
if (!state.creativeMode) {
cleared[0] = 0; cleared[1] = 0; cleared[2] = 0; cleared[3] = 0;
}
state.statusData.lineups = cleared;
if (state.fullFreesrData) {
state.fullFreesrData.lineups = cleared;
if (state.fullFreesrData.battle_config) {
state.fullFreesrData.battle_config.custom_battle_lineup = cleared;
}
}
await saveLineupToServer(cleared);
renderLineup(cleared);
showToast(t('toastLineupCleared') || 'ล้างตัวละครในทีมเรียบร้อยแล้ว', 'info');
} catch (err) {
showToast(err.message, 'error');
}
}
// Max all avatars
async function maxAllAvatars() {
if (!confirm(t('confirmMaxAll'))) return;
state.avatarsList.forEach(a => {
a.level = 80;
a.promotion = 6;
a.rank = 6;
});
if (state.fullFreesrData?.avatars) {
for (const aid in state.fullFreesrData.avatars) {
const av = state.fullFreesrData.avatars[aid];
av.level = 80;
av.promotion = 6;
av.data.rank = 6;
if (av.data.skills) {
for (const k in av.data.skills) av.data.skills[k] = 10;
}
}
}
renderAvatarsGrid();
if (state.isConnected) {
try {
if (state.serverMode === 'gac') {
await apiCall('/api/avatar/max_all', 'POST', {});
} else {
await saveFreesrChangesToServer();
}
showToast(t('maxAllSuccess'), 'success');
refreshStatus();
} catch (err) {
showToast(`ปรับในเครื่องสำเร็จ แต่ส่งเซิร์ฟเวอร์ไม่ได้: ${err.message}`, 'warning');
}
} else {
showToast('ปรับ Max Lv.80, เลื่อนขั้น 6, E6 ตัวละครทั้งหมดเรียบร้อย (โหมดออฟไลน์)', 'success');
}
}
// Push local freesr changes to server and save to local browser cache
async function saveFreesrChangesToServer() {
if (!state.fullFreesrData) return;
await saveFreesrCache(state.fullFreesrData);
// Attempt to push to server
let lastErr = null;
const payloadWrapped = { data: state.fullFreesrData };
const payloadDirect = state.fullFreesrData;
if (state.serverMode === 'firefly' || state.serverMode === 'robin') {
try {
await apiCall('/srtools', 'POST', payloadWrapped);
state.isConnected = true;
return;
} catch (e1) {
lastErr = e1;
try {
await apiCall('/api/freesr', 'POST', payloadDirect);
state.isConnected = true;
return;
} catch (e2) {
lastErr = e2;
}
}
} else {
// gac or default
try {
await apiCall('/api/freesr', 'POST', payloadDirect);
state.isConnected = true;
return;
} catch (e1) {
lastErr = e1;
try {
await apiCall('/srtools', 'POST', payloadWrapped);
state.isConnected = true;
return;
} catch (e2) {
lastErr = e2;
}
}
}
if (lastErr) throw lastErr;
}
// Equipment Sub-tab & Filter Helpers (Matching srtools neonteam)
const ROMAN_RANKS = { 1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V' };
const RELIC_SLOT_NAMES = {
1: "ส่วนหัว",
2: "ส่วนมือ",
3: "ลำตัว",
4: "รองเท้า",
5: "ลูกแก้ว",
6: "สร้อย"
};
// Load Equipment (Lightcones & Relics)
async function loadEquipment() {
try {
let lcs = [];
let relics = [];
if (state.fullFreesrData) {
lcs = state.fullFreesrData.lightcones || [];
relics = state.fullFreesrData.relics || [];
} else if (state.isConnected) {
const res = await apiCall('/api/freesr').catch(async () => {
return await apiCall('/srtools');
}).catch(() => null);
if (res && res.data) {
state.fullFreesrData = res.data;
await saveFreesrCache(res.data);
lcs = res.data.lightcones || [];
relics = res.data.relics || [];
}
} else {
let cached = await idbGet('gac_srtool_freesr_cache');
if (!cached) {
const raw = localStorage.getItem('gac_srtool_freesr_cache');
if (raw) {
try { cached = JSON.parse(raw); } catch (e) {}
}
}
if (cached) {
state.fullFreesrData = cached;
lcs = cached.lightcones || [];
relics = cached.relics || [];
}
}
if (el.badgeTotalLc) el.badgeTotalLc.textContent = lcs.length;
if (el.badgeTotalRelics) el.badgeTotalRelics.textContent = relics.length;
renderEquipment();
} catch (err) {
console.warn('Equipment loading error:', err);
}
}
// Render active equipment subtab
function renderEquipment() {
if (state.eqActiveSubtab === 'relic') {
renderRelicsGrid();
} else {
renderLightconesGrid();
}
}
// Render Light Cones Grid
function renderLightconesGrid() {
if (!el.lightconesGrid) return;
const lcs = state.fullFreesrData?.lightcones || [];
const search = el.eqSearch?.value.trim().toLowerCase() || '';
const rarity = el.eqFilterRarity?.value || 'all';
const equipped = el.eqFilterEquipped?.value || 'all';
const pathFilter = el.eqFilterExtra?.value || 'all';
const filtered = lcs.filter(lc => {
const meta = getLightconeInfo(lc.item_id);
const matchesSearch = !search || meta.name.toLowerCase().includes(search) || String(lc.item_id).includes(search);
const matchesRarity = rarity === 'all' || String(meta.rarity) === rarity;
const matchesEquipped = equipped === 'all' ||
(equipped === 'equipped' && lc.equip_avatar) ||
(equipped === 'unequipped' && !lc.equip_avatar);
const matchesPath = pathFilter === 'all' || normalizePath(meta.path) === normalizePath(pathFilter);
return matchesSearch && matchesRarity && matchesEquipped && matchesPath;
});
el.lightconesGrid.innerHTML = '';
if (filtered.length === 0) {
el.lightconesGrid.innerHTML = `${t('noLcData')}
`;
return;
}
filtered.forEach(lc => {
const meta = getLightconeInfo(lc.item_id);
const roman = ROMAN_RANKS[lc.rank] || `S${lc.rank || 1}`;
const card = document.createElement('div');
card.className = `eq-tile-card rarity-${meta.rarity || 4}`;
card.setAttribute('data-uid', lc.internal_uid);
card.title = `${meta.name} (★${meta.rarity} ${meta.path || ''})`;
const wearerBadgeHtml = lc.equip_avatar ? `
` : '';
const primaryImg = getLightconeArtSrc(lc.item_id);
card.innerHTML = `
${roman}
${wearerBadgeHtml}
Lv.${lc.level || 80}
${meta.name}
`;
card.addEventListener('click', () => {
document.querySelectorAll('#lightcones-grid .eq-tile-card').forEach(c => c.classList.remove('active'));
card.classList.add('active');
showLcInspector(lc);
});
el.lightconesGrid.appendChild(card);
});
}
// Render Relics Grid
function renderRelicsGrid() {
if (!el.relicsGrid) return;
const relics = state.fullFreesrData?.relics || [];
const search = el.eqSearch?.value.trim().toLowerCase() || '';
const rarity = el.eqFilterRarity?.value || 'all';
const equipped = el.eqFilterEquipped?.value || 'all';
const slotFilter = el.eqFilterExtra?.value || 'all';
const filtered = relics.filter(r => {
const slotNum = (r.relic_id % 10) || 1;
const setId = Math.floor((r.relic_id % 10000) / 10);
const rInfo = getRelicSetInfo(setId);
const matchesSearch = !search || rInfo.name.toLowerCase().includes(search) || String(r.relic_id).includes(search);
const matchesRarity = rarity === 'all' || (rarity === '5');
const matchesEquipped = equipped === 'all' ||
(equipped === 'equipped' && r.equip_avatar) ||
(equipped === 'unequipped' && !r.equip_avatar);
const matchesSlot = slotFilter === 'all' || String(slotNum) === slotFilter;
return matchesSearch && matchesRarity && matchesEquipped && matchesSlot;
});
el.relicsGrid.innerHTML = '';
if (filtered.length === 0) {
el.relicsGrid.innerHTML = `${t('noRelicData')}
`;
return;
}
filtered.forEach(r => {
const slotNum = (r.relic_id % 10) || 1;
const setId = Math.floor((r.relic_id % 10000) / 10);
const rInfo = getRelicSetInfo(setId);
const slotName = RELIC_SLOT_NAMES[slotNum] || `Slot ${slotNum}`;
const card = document.createElement('div');
card.className = 'eq-tile-card rarity-5';
card.setAttribute('data-uid', r.internal_uid);
card.title = `${rInfo.name} (${slotName})`;
const wearerBadgeHtml = r.equip_avatar ? `
` : '';
const primaryImg = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/icon/relic/${setId}.png`;
const fallbackImg = `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/relic/${setId}.png`;
card.innerHTML = `
${slotName}
${wearerBadgeHtml}
+${r.level || 15}
${rInfo.name}
`;
card.addEventListener('click', () => {
document.querySelectorAll('#relics-grid .eq-tile-card').forEach(c => c.classList.remove('active'));
card.classList.add('active');
showRelicInspector(r);
});
el.relicsGrid.appendChild(card);
});
}
// Show Light Cone Details Inspector
function showLcInspector(lc) {
if (!el.eqInspectorCard) return;
const lcInfo = getLightconeInfo(lc.item_id);
const lcMeta = LIGHTCONE_METADATA[lc.item_id] || {};
const roman = ROMAN_RANKS[lc.rank] || `S${lc.rank || 1}`;
let avatarOpts = ``;
const sortedAvatars = [...state.avatarsList].sort((a, b) => b.avatar_id - a.avatar_id);
sortedAvatars.forEach(av => {
const info = getAvatarInfo(av.avatar_id);
const sel = (lc.equip_avatar === av.avatar_id) ? 'selected' : '';
avatarOpts += ``;
});
const hp = lcMeta.hp || (lcMeta.rarity === 5 ? 1164 : (lcMeta.rarity === 4 ? 953 : 846));
const atk = lcMeta.atk || (lcMeta.rarity === 5 ? 582 : (lcMeta.rarity === 4 ? 423 : 317));
const def = lcMeta.def || (lcMeta.rarity === 5 ? 529 : (lcMeta.rarity === 4 ? 396 : 264));
const primaryThumb = getLightconeArtSrc(lc.item_id);
el.eqInspectorCard.style.display = 'flex';
el.eqInspectorCard.innerHTML = `
${lcInfo.name}
${'★'.repeat(lcInfo.rarity || 5)}
${getPathIconHtml(lcInfo.path, 16)} ${lcInfo.path || 'All'} • Lv. ${lc.level || 80}/80
Superimposition ${roman}
❤️ HP
+${hp}
⚔️ ATK
+${atk}
🛡️ DEF
+${def}
${lcMeta.skill_name || 'สกิลพิเศษประจำกรวยแสง'}
${lcMeta.desc || 'เพิ่มพลังต่อสู้และเสริมกลไกเฉพาะสายของตัวละครเมื่อติดตั้ง'}
`;
document.getElementById('btn-close-eq-inspector')?.addEventListener('click', () => {
el.eqInspectorCard.style.display = 'none';
document.querySelectorAll('.eq-tile-card.active').forEach(c => c.classList.remove('active'));
});
document.getElementById('btn-inspector-save-wearer')?.addEventListener('click', async () => {
const newAvatarId = parseInt(document.getElementById('sel-inspector-wearer').value, 10);
if (newAvatarId !== 0 && state.fullFreesrData?.lightcones) {
state.fullFreesrData.lightcones.forEach(otherLc => {
if (otherLc.equip_avatar === newAvatarId && otherLc.internal_uid !== lc.internal_uid) {
otherLc.equip_avatar = 0;
}
});
}
lc.equip_avatar = newAvatarId;
if (state.isConnected) {
await saveFreesrChangesToServer();
}
showToast(`อัปเดตผู้สวมใส่ ${lcInfo.name} เรียบร้อย`, 'success');
renderEquipment();
showLcInspector(lc);
});
document.getElementById('btn-inspector-delete')?.addEventListener('click', async () => {
if (!confirm(`ยืนยันการลบ ${lcInfo.name} ออกจากคลัง FreeSR หรือไม่?`)) return;
if (state.fullFreesrData?.lightcones) {
state.fullFreesrData.lightcones = state.fullFreesrData.lightcones.filter(l => l.internal_uid !== lc.internal_uid);
if (state.isConnected) {
await saveFreesrChangesToServer();
}
el.eqInspectorCard.style.display = 'none';
showToast(t('toastItemDeleted'), 'info');
renderEquipment();
}
});
}
// Show Relic Details Inspector
function showRelicInspector(relic) {
if (!el.eqInspectorCard) return;
const slotNum = (relic.relic_id % 10) || 1;
const setId = Math.floor((relic.relic_id % 10000) / 10);
const rInfo = getRelicSetInfo(setId);
const slotName = RELIC_SLOT_NAMES[slotNum] || `ชิ้นที่ ${slotNum}`;
let avatarOpts = ``;
const sortedAvatars = [...state.avatarsList].sort((a, b) => b.avatar_id - a.avatar_id);
sortedAvatars.forEach(av => {
const info = getAvatarInfo(av.avatar_id);
const sel = (relic.equip_avatar === av.avatar_id) ? 'selected' : '';
avatarOpts += ``;
});
const mainAffixName = getRelicMainAffixName(slotNum, relic.main_affix_id);
el.eqInspectorCard.style.display = 'flex';
el.eqInspectorCard.innerHTML = `
${rInfo.name}
★★★★★
${slotName} • +${relic.level || 15}
ID: ${relic.relic_id || setId}
สเตตัสหลัก (Main Stat)
✦ ${mainAffixName}
ออฟรอง (Substats):
${(relic.sub_affixes && relic.sub_affixes.length > 0) ? relic.sub_affixes.map(s => {
const sName = getRelicSubAffixName(s.sub_affix_id);
const rolls = s.count || s.cnt || 1;
const val = formatRelicSubstatValue(s.sub_affix_id, rolls);
return `
• ${sName}
+${val} +${rolls}
`;
}).join('') : '
ไม่มีข้อมูลออฟรอง
'}
Set Bonus (${rInfo.name})
2-Piece: ${rInfo.desc2 || 'เพิ่มประสิทธิภาพตามเซ็ตที่กำหนด'}
4-Piece: ${rInfo.desc4 || 'ปลดล็อกเอฟเฟกต์ต่อสู้ขั้นสูง'}
`;
document.getElementById('btn-inspector-edit-subs')?.addEventListener('click', () => {
openSlotPickerForRelic(relic);
});
document.getElementById('btn-close-eq-inspector')?.addEventListener('click', () => {
el.eqInspectorCard.style.display = 'none';
document.querySelectorAll('.eq-tile-card.active').forEach(c => c.classList.remove('active'));
});
document.getElementById('btn-inspector-save-wearer')?.addEventListener('click', async () => {
const newAvatarId = parseInt(document.getElementById('sel-inspector-wearer').value, 10);
if (newAvatarId !== 0 && state.fullFreesrData?.relics) {
state.fullFreesrData.relics.forEach(otherR => {
const otherSlot = (otherR.relic_id % 10) || 1;
if (otherR.equip_avatar === newAvatarId && otherSlot === slotNum && otherR.internal_uid !== relic.internal_uid) {
otherR.equip_avatar = 0;
}
});
}
relic.equip_avatar = newAvatarId;
if (state.isConnected) {
await saveFreesrChangesToServer();
}
showToast(`อัปเดตผู้สวมใส่ ${rInfo.name} (${slotName}) เรียบร้อย`, 'success');
renderEquipment();
showRelicInspector(relic);
});
document.getElementById('btn-inspector-delete')?.addEventListener('click', async () => {
if (!confirm(`ยืนยันการลบ ${rInfo.name} ออกจากคลัง FreeSR หรือไม่?`)) return;
if (state.fullFreesrData?.relics) {
state.fullFreesrData.relics = state.fullFreesrData.relics.filter(r => r.internal_uid !== relic.internal_uid);
if (state.isConnected) {
await saveFreesrChangesToServer();
}
el.eqInspectorCard.style.display = 'none';
showToast(t('toastItemDeleted'), 'info');
renderEquipment();
}
});
}
// Open Add Light Cone Modal
function openAddLcModal() {
if (!el.modalAddLc) return;
// Reset filters
const searchInput = document.getElementById('add-lc-search');
if (searchInput) searchInput.value = '';
const filterPath = document.getElementById('add-lc-filter-path');
if (filterPath) filterPath.value = 'all';
const filterRarity = document.getElementById('add-lc-filter-rarity');
if (filterRarity) filterRarity.value = 'all';
// Avatars dropdown
if (el.addLcAvatar) {
el.addLcAvatar.innerHTML = ``;
const sortedAvatars = [...state.avatarsList].sort((a, b) => b.avatar_id - a.avatar_id);
sortedAvatars.forEach(av => {
const info = getAvatarInfo(av.avatar_id);
const opt = document.createElement('option');
opt.value = av.avatar_id;
opt.textContent = `${info.name} (ID: ${av.avatar_id})`;
el.addLcAvatar.appendChild(opt);
});
}
if (el.addLcLevel) el.addLcLevel.value = 80;
if (el.addLcPromotion) el.addLcPromotion.value = 6;
if (el.addLcRank) el.addLcRank.value = 5;
const defaultId = LIGHTCONE_METADATA[23065] ? 23065 : 23001;
updateAddLcPreview(defaultId);
renderAddLcVisualGrid();
el.modalAddLc.classList.add('active');
}
// Update Add LC Selected Preview Banner
function updateAddLcPreview(lcId) {
const id = parseInt(lcId, 10);
const info = getLightconeInfo(id);
const meta = LIGHTCONE_METADATA[id] || { path: 'Unknown', rarity: 5 };
const hiddenId = document.getElementById('add-lc-selected-id');
if (hiddenId) hiddenId.value = id;
const legacySelect = document.getElementById('add-lc-select');
if (legacySelect) legacySelect.value = id;
const previewThumb = document.getElementById('add-lc-preview-thumb');
if (previewThumb) {
previewThumb.src = getLightconeArtSrc(id);
previewThumb.onerror = function() { handleLcImgError(this, id); };
}
const previewName = document.getElementById('add-lc-preview-name');
if (previewName) {
previewName.textContent = info.name || `Light Cone ${id}`;
}
const previewMeta = document.getElementById('add-lc-preview-meta');
if (previewMeta) {
previewMeta.innerHTML = `
${'★'.repeat(info.rarity || 5)}
${getPathIconHtml(meta.path, 13)} ${info.path}
ID: ${id}
`;
}
}
// Render Add LC Visual Grid with Live Search & Filtering
function renderAddLcVisualGrid() {
const grid = document.getElementById('add-lc-visual-grid');
if (!grid) return;
grid.innerHTML = '';
const search = (document.getElementById('add-lc-search')?.value || '').trim().toLowerCase();
const pathFilter = document.getElementById('add-lc-filter-path')?.value || 'all';
const rarityFilter = document.getElementById('add-lc-filter-rarity')?.value || 'all';
const selectedIdInput = document.getElementById('add-lc-selected-id');
const selectedId = parseInt(selectedIdInput?.value || '23001', 10);
const allIds = Object.keys(LIGHTCONE_METADATA)
.map(id => parseInt(id, 10))
.sort((a, b) => {
const metaA = LIGHTCONE_METADATA[a];
const metaB = LIGHTCONE_METADATA[b];
if (metaB.rarity !== metaA.rarity) return metaB.rarity - metaA.rarity;
return b - a;
});
const filtered = allIds.filter(id => {
const meta = LIGHTCONE_METADATA[id];
const info = getLightconeInfo(id);
// Path filter
if (pathFilter !== 'all' && meta.path !== pathFilter) return false;
// Rarity filter
if (rarityFilter !== 'all' && String(meta.rarity) !== String(rarityFilter)) return false;
// Search filter
if (search) {
const matchName = (info.name || '').toLowerCase().includes(search);
const matchId = String(id).includes(search);
const matchPath = (info.path || '').toLowerCase().includes(search);
if (!matchName && !matchId && !matchPath) return false;
}
return true;
});
if (filtered.length === 0) {
grid.innerHTML = `ไม่พบกรวยแสงตามเงื่อนไขที่ค้นหา
`;
return;
}
filtered.forEach(id => {
const meta = LIGHTCONE_METADATA[id];
const info = getLightconeInfo(id);
const isSelected = id === selectedId;
const card = document.createElement('div');
card.className = `add-lc-card rarity-${info.rarity || 5} ${isSelected ? 'selected' : ''}`;
card.dataset.lcId = id;
card.innerHTML = `
${info.name}
${'★'.repeat(info.rarity || 5)}
${getPathIconHtml(meta.path, 13)} ${info.path}
#${id}
`;
card.addEventListener('click', () => {
grid.querySelectorAll('.add-lc-card.selected').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
updateAddLcPreview(id);
});
grid.appendChild(card);
});
}
// Open Add Relic Modal
function openAddRelicModal() {
if (!el.modalAddRelic) return;
el.addRelicSet.innerHTML = '';
const setIds = Object.keys(RELIC_SET_METADATA).sort((a, b) => parseInt(b, 10) - parseInt(a, 10));
setIds.forEach(setId => {
const meta = getRelicSetInfo(setId);
const opt = document.createElement('option');
opt.value = setId;
opt.textContent = `${meta.name} (Set ID: ${setId})`;
el.addRelicSet.appendChild(opt);
});
el.addRelicAvatar.innerHTML = ``;
const sortedAvatars = [...state.avatarsList].sort((a, b) => b.avatar_id - a.avatar_id);
sortedAvatars.forEach(av => {
const info = getAvatarInfo(av.avatar_id);
const opt = document.createElement('option');
opt.value = av.avatar_id;
opt.textContent = `${info.name} (ID: ${av.avatar_id})`;
el.addRelicAvatar.appendChild(opt);
});
el.addRelicSlot.value = "1";
el.addRelicLevel.value = 15;
updateRelicMainAffixes("1");
el.modalAddRelic.classList.add('active');
}
function updateRelicMainAffixes(slot) {
if (!el.addRelicMainAffix) return;
el.addRelicMainAffix.innerHTML = '';
const affixesBySlot = {
"1": [{ id: 1, name: "HP (Flat)" }],
"2": [{ id: 1, name: "ATK (Flat)" }],
"3": [
{ id: 1, name: "HP%" }, { id: 2, name: "ATK%" }, { id: 3, name: "DEF%" },
{ id: 4, name: "CRIT Rate%" }, { id: 5, name: "CRIT DMG%" },
{ id: 6, name: "Outgoing Healing Boost%" }, { id: 7, name: "Effect Hit Rate%" }
],
"4": [
{ id: 1, name: "HP%" }, { id: 2, name: "ATK%" }, { id: 3, name: "DEF%" },
{ id: 4, name: "SPD" }
],
"5": [
{ id: 1, name: "HP%" }, { id: 2, name: "ATK%" }, { id: 3, name: "DEF%" },
{ id: 4, name: "Physical DMG Boost%" }, { id: 5, name: "Fire DMG Boost%" },
{ id: 6, name: "Ice DMG Boost%" }, { id: 7, name: "Lightning DMG Boost%" },
{ id: 8, name: "Wind DMG Boost%" }, { id: 9, name: "Quantum DMG Boost%" },
{ id: 10, name: "Imaginary DMG Boost%" }
],
"6": [
{ id: 1, name: "Break Effect%" }, { id: 2, name: "Energy Regeneration Rate%" },
{ id: 3, name: "HP%" }, { id: 4, name: "ATK%" }, { id: 5, name: "DEF%" }
]
};
const list = affixesBySlot[slot] || [{ id: 1, name: "Main Stat" }];
list.forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.name;
el.addRelicMainAffix.appendChild(opt);
});
}
async function handleAddLcSubmit(e) {
e.preventDefault();
const selectedIdInput = document.getElementById('add-lc-selected-id');
const itemId = parseInt(selectedIdInput?.value || el.addLcSelect?.value || '23001', 10);
const level = parseInt(el.addLcLevel?.value || '80', 10) || 80;
const promotion = parseInt(el.addLcPromotion?.value || '6', 10) || 6;
const rank = parseInt(el.addLcRank?.value || '5', 10) || 5;
const equipAvatar = parseInt(el.addLcAvatar?.value || '0', 10) || 0;
if (!state.fullFreesrData) {
state.fullFreesrData = { lightcones: [], relics: [], avatars: {} };
}
if (!state.fullFreesrData.lightcones) {
state.fullFreesrData.lightcones = [];
}
if (equipAvatar !== 0) {
state.fullFreesrData.lightcones.forEach(lc => {
if (lc.equip_avatar === equipAvatar) lc.equip_avatar = 0;
});
}
let maxUniqueId = 1000;
state.fullFreesrData.lightcones.forEach(l => {
if ((l.unique_id || 0) >= maxUniqueId) maxUniqueId = (l.unique_id || 0) + 1;
});
const maxInternalUid = state.fullFreesrData.lightcones.reduce((m, lc) => Math.max(m, lc.internal_uid || 0), 0);
const newLc = {
unique_id: maxUniqueId,
internal_uid: maxInternalUid + 1,
item_id: itemId,
level,
promotion,
rank,
equip_avatar: equipAvatar
};
state.fullFreesrData.lightcones.unshift(newLc);
await saveFreesrChangesToServer();
loadEquipment();
el.modalAddLc.classList.remove('active');
showToast(t('toastLcAdded') || 'เพิ่มกรวยแสงลง FreeSR เรียบร้อยแล้ว', 'success');
}
async function handleAddRelicSubmit(e) {
e.preventDefault();
const setId = parseInt(el.addRelicSet.value, 10);
const slot = parseInt(el.addRelicSlot.value, 10) || 1;
const level = parseInt(el.addRelicLevel.value, 10) || 15;
const mainAffixId = parseInt(el.addRelicMainAffix.value, 10) || 1;
const equipAvatar = parseInt(el.addRelicAvatar.value, 10) || 0;
const relicId = 60000 + (setId * 10) + slot;
if (!state.fullFreesrData) {
state.fullFreesrData = { lightcones: [], relics: [], avatars: {} };
}
if (!state.fullFreesrData.relics) {
state.fullFreesrData.relics = [];
}
if (equipAvatar !== 0) {
state.fullFreesrData.relics.forEach(r => {
if (r.equip_avatar === equipAvatar && (r.relic_id % 10 === slot)) {
r.equip_avatar = 0;
}
});
}
const maxUid = state.fullFreesrData.relics.reduce((m, r) => Math.max(m, r.internal_uid || 0), 0);
const newRelic = {
level,
relic_id: relicId,
main_affix_id: mainAffixId,
sub_affixes: [
{ sub_affix_id: 1, cnt: 2, count: 2, step: 1 },
{ sub_affix_id: 2, cnt: 2, count: 2, step: 1 },
{ sub_affix_id: 3, cnt: 3, count: 3, step: 2 },
{ sub_affix_id: 4, cnt: 4, count: 4, step: 2 }
],
internal_uid: maxUid + 1,
equip_avatar: equipAvatar
};
state.fullFreesrData.relics.unshift(newRelic);
await saveFreesrChangesToServer();
loadEquipment();
el.modalAddRelic.classList.remove('active');
showToast(t('toastRelicAdded'), 'success');
}
// Load Persistent / Character Path Settings (GaC persistent vs Firefly data-in-game)
async function loadPersistentSettings() {
if (state.serverMode === 'robin') return;
if (state.serverMode === 'firefly') {
// Firefly uses data-in-game.json char_path { main, march_7 }
let dg = state.dataGameData;
if (!dg) {
dg = await idbGet('gac_srtool_datagame_cache') || JSON.parse(localStorage.getItem('gac_srtool_datagame_cache') || 'null');
if (dg) state.dataGameData = dg;
}
if (dg?.char_path) {
const mcKey = FIREFLY_MC_REVERSE_MAP[dg.char_path.main];
if (mcKey && el.persistentMcSelect) el.persistentMcSelect.value = mcKey;
const marchKey = FIREFLY_MARCH_REVERSE_MAP[dg.char_path.march_7];
if (marchKey && el.persistentMarchSelect) el.persistentMarchSelect.value = marchKey;
}
return;
}
// GaC mode
try {
let data = state.persistentData;
if (state.isConnected) {
const res = await apiCall('/api/persistent');
if (res && res.data) {
data = res.data;
state.persistentData = data;
savePersistentCache(data);
}
} else if (!data) {
const cached = localStorage.getItem('gac_srtool_persistent_cache');
if (cached) {
try {
data = JSON.parse(cached);
state.persistentData = data;
} catch (e) {}
}
}
if (!data) return;
if (data.main_character && el.persistentMcSelect) {
el.persistentMcSelect.value = data.main_character;
}
if (data.march_type && el.persistentMarchSelect) {
el.persistentMarchSelect.value = data.march_type;
}
if (el.toggleSwGlobal) el.toggleSwGlobal.checked = data.enable_sw_global !== false;
if (el.toggleCastoriceGlobal) el.toggleCastoriceGlobal.checked = data.enable_castorice_global !== false;
if (el.toggleAhaGlobal) el.toggleAhaGlobal.checked = data.enable_aha_global !== false;
} catch (err) {
console.warn('Persistent settings loading error:', err);
}
}
// Render Ex-Rank Cards
function renderExRanks(exList) {
if (!el.exrankList) return;
el.exrankList.innerHTML = '';
if (!exList || exList.length === 0) {
el.exrankList.innerHTML = 'ไม่มีข้อมูล Ex-Rank
';
return;
}
exList.forEach(ex => {
const item = document.createElement('div');
item.className = 'card exrank-item';
item.style.padding = '1.25rem';
item.style.display = 'flex';
item.style.justifyContent = 'space-between';
item.style.alignItems = 'center';
item.style.flexWrap = 'wrap';
item.style.gap = '1rem';
const info = getAvatarInfo(ex.avatar_id);
item.innerHTML = `
${info.name}
${getElementIconHtml(info.element, 20)}
${getPathIconHtml(info.path, 20)}
Ex-Rank ID: ${ex.ex_rank_id}
`;
item.querySelector('.btn-toggle-ex').addEventListener('click', async (e) => {
const aid = parseInt(e.target.getAttribute('data-avatar'), 10);
const cur = e.target.getAttribute('data-unlocked') === 'true';
await toggleSingleExRank(aid, !cur);
});
el.exrankList.appendChild(item);
});
}
async function toggleSingleExRank(avatarId, unlock) {
try {
await apiCall('/api/exrank/toggle', 'POST', { avatar_id: avatarId, unlocked: unlock });
showToast(unlock ? 'ปลดล็อค Ex-Rank สำเร็จ!' : 'ปิดใช้งาน Ex-Rank เรียบร้อย', 'success');
refreshStatus();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
// Load and Switch JSON Tab Data
async function loadJsonData(mode) {
state.currentJsonMode = mode;
if (mode === 'freesr') {
el.btnSelectFreesr.className = 'btn btn-sm btn-primary active';
if (el.btnSelectPersistent) el.btnSelectPersistent.className = 'btn btn-sm btn-secondary';
if (el.btnSelectDatagame) el.btnSelectDatagame.className = 'btn btn-sm btn-secondary';
el.jsonStatusInfo.textContent = 'กำลังโหลด freesr-data.json...';
try {
if (state.fullFreesrData) {
el.jsonEditor.value = JSON.stringify(state.fullFreesrData, null, 2);
el.jsonStatusInfo.textContent = 'freesr-data.json (โหลดจากหน่วยความจำ/แคช)';
} else if (state.isConnected) {
const res = await apiCall('/api/freesr');
state.fullFreesrData = res.data;
saveFreesrCache(res.data);
el.jsonEditor.value = JSON.stringify(res.data, null, 2);
el.jsonStatusInfo.textContent = 'freesr-data.json (โหลดจากเซิร์ฟเวอร์)';
} else {
const cached = localStorage.getItem('gac_srtool_freesr_cache');
if (cached) {
const parsed = JSON.parse(cached);
state.fullFreesrData = parsed;
el.jsonEditor.value = JSON.stringify(parsed, null, 2);
el.jsonStatusInfo.textContent = 'freesr-data.json (โหลดจากแคชของเบราว์เซอร์)';
} else {
el.jsonStatusInfo.textContent = 'Offline: ไม่มีแคช สามารถอัปโหลดหรือพิมพ์ JSON เองได้';
if (!el.jsonEditor.value) {
el.jsonEditor.value = JSON.stringify({ avatars: {}, lightcones: [], relics: [], battle_config: {} }, null, 2);
}
}
}
} catch (err) {
const cached = localStorage.getItem('gac_srtool_freesr_cache');
if (cached) {
try {
const parsed = JSON.parse(cached);
state.fullFreesrData = parsed;
el.jsonEditor.value = JSON.stringify(parsed, null, 2);
el.jsonStatusInfo.textContent = 'freesr-data.json (โหลดจากแคชของเบราว์เซอร์)';
} catch (e) {}
} else {
el.jsonStatusInfo.textContent = `Offline: สามารถพิมพ์หรือวาง JSON เองได้`;
if (!el.jsonEditor.value) {
el.jsonEditor.value = JSON.stringify({ avatars: {}, lightcones: [], relics: [], battle_config: {} }, null, 2);
}
}
}
} else if (mode === 'datagame') {
el.btnSelectFreesr.className = 'btn btn-sm btn-secondary';
if (el.btnSelectPersistent) el.btnSelectPersistent.className = 'btn btn-sm btn-secondary';
if (el.btnSelectDatagame) el.btnSelectDatagame.className = 'btn btn-sm btn-primary active';
el.jsonStatusInfo.textContent = 'กำลังโหลด data-in-game.json...';
try {
if (state.dataGameData) {
el.jsonEditor.value = JSON.stringify(state.dataGameData, null, 2);
el.jsonStatusInfo.textContent = 'data-in-game.json (โหลดจากหน่วยความจำ/แคช)';
} else if (state.isConnected) {
const res = await apiCall('/api/data-in-game').catch(() => null);
if (res && (res.data || res.lineups)) {
const val = res.data || res;
state.dataGameData = val;
saveDataGameCache(val);
el.jsonEditor.value = JSON.stringify(val, null, 2);
el.jsonStatusInfo.textContent = 'data-in-game.json (โหลดจากเซิร์ฟเวอร์)';
} else {
throw new Error('Not available');
}
} else {
const cached = localStorage.getItem('gac_srtool_datagame_cache');
if (cached) {
const parsed = JSON.parse(cached);
state.dataGameData = parsed;
el.jsonEditor.value = JSON.stringify(parsed, null, 2);
el.jsonStatusInfo.textContent = 'data-in-game.json (โหลดจากแคช)';
} else {
state.dataGameData = { ...DEFAULT_DATA_GAME };
el.jsonEditor.value = JSON.stringify(DEFAULT_DATA_GAME, null, 2);
el.jsonStatusInfo.textContent = 'data-in-game.json (โครงสร้าง Firefly dump)';
}
}
} catch (err) {
const cached = localStorage.getItem('gac_srtool_datagame_cache');
if (cached) {
try {
const parsed = JSON.parse(cached);
state.dataGameData = parsed;
el.jsonEditor.value = JSON.stringify(parsed, null, 2);
el.jsonStatusInfo.textContent = 'data-in-game.json (โหลดจากแคช)';
} catch (e) {}
} else {
state.dataGameData = { ...DEFAULT_DATA_GAME };
el.jsonEditor.value = JSON.stringify(DEFAULT_DATA_GAME, null, 2);
el.jsonStatusInfo.textContent = 'data-in-game.json (โครงสร้าง Firefly dump)';
}
}
} else {
el.btnSelectFreesr.className = 'btn btn-sm btn-secondary';
if (el.btnSelectPersistent) el.btnSelectPersistent.className = 'btn btn-sm btn-primary active';
if (el.btnSelectDatagame) el.btnSelectDatagame.className = 'btn btn-sm btn-secondary';
el.jsonStatusInfo.textContent = 'กำลังโหลด persistent...';
try {
if (state.persistentData) {
el.jsonEditor.value = JSON.stringify(state.persistentData, null, 2);
el.jsonStatusInfo.textContent = 'persistent (โหลดจากหน่วยความจำ/แคช)';
} else if (state.isConnected) {
const res = await apiCall('/api/persistent');
state.persistentData = res.data;
savePersistentCache(res.data);
el.jsonEditor.value = JSON.stringify(res.data, null, 2);
el.jsonStatusInfo.textContent = 'persistent (โหลดจากเซิร์ฟเวอร์)';
} else {
const cached = localStorage.getItem('gac_srtool_persistent_cache');
if (cached) {
const parsed = JSON.parse(cached);
state.persistentData = parsed;
el.jsonEditor.value = JSON.stringify(parsed, null, 2);
el.jsonStatusInfo.textContent = 'persistent (โหลดจากแคชของเบราว์เซอร์)';
} else {
el.jsonStatusInfo.textContent = 'Offline: ไม่มีแคช สามารถอัปโหลดหรือพิมพ์ JSON เองได้';
if (!el.jsonEditor.value) {
el.jsonEditor.value = JSON.stringify({ lineups: DEFAULT_LINEUP, main_character: "FemaleHarmony", march_type: "MarchHunt" }, null, 2);
}
}
}
} catch (err) {
const cached = localStorage.getItem('gac_srtool_persistent_cache');
if (cached) {
try {
const parsed = JSON.parse(cached);
state.persistentData = parsed;
el.jsonEditor.value = JSON.stringify(parsed, null, 2);
el.jsonStatusInfo.textContent = 'persistent (โหลดจากแคชของเบราว์เซอร์)';
} catch (e) {}
} else {
el.jsonStatusInfo.textContent = `Offline: สามารถพิมพ์หรือวาง JSON เองได้`;
if (!el.jsonEditor.value) {
el.jsonEditor.value = JSON.stringify({ lineups: DEFAULT_LINEUP, main_character: "FemaleHarmony", march_type: "MarchHunt" }, null, 2);
}
}
}
}
}
// Save JSON Tab Data
async function saveJsonData() {
try {
const parsed = JSON.parse(el.jsonEditor.value);
if (state.currentJsonMode === 'freesr') {
state.fullFreesrData = parsed;
await saveFreesrCache(parsed);
loadEquipment();
let serverSaved = false;
let serverError = null;
try {
await saveFreesrChangesToServer();
serverSaved = true;
} catch (err) {
serverError = err.message;
}
// Sync loaded characters to local list
if (parsed.avatars) {
for (const aid in parsed.avatars) {
const av = parsed.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;
}
}
renderAvatarsGrid();
}
if (serverSaved) {
showToast('✅ บันทึก freesr-data.json ลงในเซิร์ฟเวอร์และแคชเรียบร้อย', 'success');
} else if (serverError) {
showToast(`⚠️ บันทึกลงแคชแล้ว แต่ส่งไปยังเซิร์ฟเวอร์ไม่สำเร็จ: ${serverError}`, 'warning');
} else {
showToast('💾 บันทึก freesr-data.json ลงในแคชแล้ว (โหมดออฟไลน์)', 'info');
}
} else if (state.currentJsonMode === 'datagame') {
state.dataGameData = parsed;
await saveDataGameCache(parsed);
if (parsed.lineups) {
state.statusData = state.statusData || {};
state.statusData.lineups = parsed.lineups;
renderLineup(parsed.lineups);
}
let serverSaved = false;
let serverError = null;
if (state.isConnected) {
try {
await apiCall('/api/data-in-game', 'POST', parsed);
serverSaved = true;
} catch (err) {
serverError = err.message;
}
}
if (serverSaved) {
showToast('✅ บันทึก data-in-game.json ลงในเซิร์ฟเวอร์และแคชเรียบร้อย', 'success');
} else if (serverError) {
showToast(`⚠️ บันทึกลงแคชแล้ว แต่ส่งไปยังเซิร์ฟเวอร์ไม่สำเร็จ: ${serverError}`, 'warning');
} else {
showToast('💾 บันทึก data-in-game.json ลงในแคชแล้ว (โหมดออฟไลน์)', 'info');
}
} else {
state.persistentData = parsed;
await savePersistentCache(parsed);
let serverSaved = false;
let serverError = null;
try {
if (state.serverMode === 'gac') {
await apiCall('/api/persistent', 'POST', parsed);
} else {
await saveFreesrChangesToServer();
}
serverSaved = true;
} catch (err) {
serverError = err.message;
}
if (parsed.lineups) {
state.statusData = state.statusData || {};
state.statusData.lineups = parsed.lineups;
renderLineup(parsed.lineups);
}
if (serverSaved) {
showToast('✅ บันทึกไฟล์ persistent ลงในเซิร์ฟเวอร์และแคชเรียบร้อย', 'success');
} else if (serverError) {
showToast(`⚠️ บันทึกลงแคชแล้ว แต่ส่งไปยังเซิร์ฟเวอร์ไม่สำเร็จ: ${serverError}`, 'warning');
} else {
showToast('💾 บันทึกไฟล์ persistent ลงในแคชแล้ว (โหมดออฟไลน์)', 'info');
}
}
updateCacheUsageDisplay();
refreshStatus();
} catch (err) {
showToast(`เกิดข้อผิดพลาด: ${err.message}`, 'error');
}
}
// Cache Usage Status for Diagnostics
function updateCacheUsageDisplay() {
if (!el.lblCacheUsage) return;
try {
let totalBytes = 0;
for (let key in localStorage) {
if (Object.prototype.hasOwnProperty.call(localStorage, key)) {
totalBytes += (localStorage[key].length + key.length) * 2;
}
}
let freesrBytes = 0;
if (state.fullFreesrData) {
try {
freesrBytes = JSON.stringify(state.fullFreesrData).length;
} catch (e) {}
}
const displayBytes = Math.max(totalBytes, freesrBytes);
const kb = (displayBytes / 1024).toFixed(1);
el.lblCacheUsage.textContent = `${kb} KB (IndexedDB + Storage)`;
} catch (err) {
el.lblCacheUsage.textContent = 'ไม่สามารถตรวจสอบได้';
}
}
// Tab Switching
function setupTabs() {
const tabs = document.querySelectorAll('.tab-btn');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(tBtn => tBtn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const targetId = tab.getAttribute('data-tab');
const targetContent = document.getElementById(targetId);
if (targetContent) {
targetContent.classList.add('active');
state.activeTab = targetId;
updateDocumentTitle(targetId);
// On-demand tab refresh
if (targetId === 'tab-avatars') {
renderAvatarsGrid();
} else if (targetId === 'tab-equipment') {
loadEquipment();
} else if (targetId === 'tab-persistent') {
loadPersistentSettings();
} else if (targetId === 'tab-json') {
loadJsonData(state.currentJsonMode);
} else if (targetId === 'tab-settings') {
updateCacheUsageDisplay();
}
}
});
});
}
// Setup Event Listeners
function setupEvents() {
// Quick Ping & Refresh
el.btnQuickPing.addEventListener('click', () => {
showToast(t('checking'), 'info');
refreshStatus();
});
// Settings Modal
el.btnOpenSettings.addEventListener('click', () => {
el.modalApiUrl.value = state.apiUrl;
el.modalSettings.classList.add('active');
});
el.btnCloseModal.addEventListener('click', () => el.modalSettings.classList.remove('active'));
el.btnModalCancel.addEventListener('click', () => el.modalSettings.classList.remove('active'));
el.btnModalSave.addEventListener('click', () => {
const newUrl = el.modalApiUrl.value.trim();
if (newUrl) {
state.apiUrl = newUrl;
localStorage.setItem('gac_srtool_api_url', newUrl);
el.modalSettings.classList.remove('active');
showToast(t('settingsSaved'), 'success');
refreshStatus();
}
});
// Character Detail Modal Controls
el.btnCloseAvatarModal.addEventListener('click', () => el.modalAvatarDetail.classList.remove('active'));
document.getElementById('btn-modal-cancel-avatar')?.addEventListener('click', () => el.modalAvatarDetail.classList.remove('active'));
el.btnModalSaveAvatar.addEventListener('click', () => saveAvatarDetails(true));
el.btnModalMaxSkills.addEventListener('click', maxSkillsForAvatar);
// E6S5 and E0S1 Preset Buttons
el.btnModalE6S5?.addEventListener('click', applyE6S5Build);
el.btnModalE0S1?.addEventListener('click', applyE0S1Build);
// Steppers for 4 Skills (Basic, Skill, Ultra, Talent)
const setupSkillStepper = (rangeEl, valEl, decBtn, incBtn, maxBtn, maxVal) => {
if (!rangeEl || !valEl) return;
rangeEl.addEventListener('input', () => { valEl.textContent = rangeEl.value; });
decBtn?.addEventListener('click', () => {
let cur = parseInt(rangeEl.value, 10);
if (cur > parseInt(rangeEl.min, 10)) {
rangeEl.value = cur - 1;
valEl.textContent = rangeEl.value;
}
});
incBtn?.addEventListener('click', () => {
let cur = parseInt(rangeEl.value, 10);
if (cur < parseInt(rangeEl.max, 10)) {
rangeEl.value = cur + 1;
valEl.textContent = rangeEl.value;
}
});
maxBtn?.addEventListener('click', () => {
rangeEl.value = maxVal;
valEl.textContent = maxVal;
});
};
setupSkillStepper(el.rangeSkillBasic, el.valSkillBasic, el.btnStepDecBasic, el.btnStepIncBasic, el.btnSkillMaxBasic, 6);
setupSkillStepper(el.rangeSkillSkill, el.valSkillSkill, el.btnStepDecSkill, el.btnStepIncSkill, el.btnSkillMaxSkill, 10);
setupSkillStepper(el.rangeSkillUltra, el.valSkillUltra, el.btnStepDecUltra, el.btnStepIncUltra, el.btnSkillMaxUltra, 10);
setupSkillStepper(el.rangeSkillTalent, el.valSkillTalent, el.btnStepDecTalent, el.btnStepIncTalent, el.btnSkillMaxTalent, 10);
// Overview Tab Quick Navigation Cards
el.overviewLcCard?.addEventListener('click', () => {
if (state.selectedAvatarDetail) openLightconePicker(state.selectedAvatarDetail.avatar_id);
});
el.overviewRelicsCard?.addEventListener('click', () => switchModalSubtab('modaltab-relics'));
// Lightcone Picker Modal Controls
document.getElementById('btn-close-lc-picker')?.addEventListener('click', () => {
document.getElementById('modal-select-lightcone')?.classList.remove('active');
});
document.getElementById('btn-lc-picker-close')?.addEventListener('click', () => {
document.getElementById('modal-select-lightcone')?.classList.remove('active');
});
document.getElementById('lc-picker-search')?.addEventListener('input', () => {
renderLightconePickerGrid();
});
document.getElementById('lc-picker-filter-path')?.addEventListener('change', () => {
renderLightconePickerGrid();
});
document.getElementById('lc-picker-rank')?.addEventListener('change', () => {
renderLightconePickerGrid();
});
document.getElementById('btn-lc-picker-unequip')?.addEventListener('click', async () => {
if (state.lcPickerAvatarId) {
await unequipLightconeFromAvatar(state.lcPickerAvatarId);
}
document.getElementById('modal-select-lightcone')?.classList.remove('active');
});
// Top Bar Characters Tab Quick Actions
document.getElementById('btn-all-avatars-e0s1')?.addEventListener('click', quickSetAllAvatarsE0S1);
// Relic Tab Controls in Modal
el.btnModalRelicsAuto?.addEventListener('click', async () => {
if (!state.selectedAvatarDetail) return;
const aid = state.selectedAvatarDetail.avatar_id;
await autoEquipRecommendedRelics(aid, true);
renderModalRelicsTab(aid);
renderModalOverviewTab(aid);
renderShowcaseCard(aid);
});
el.btnModalRelicsClear?.addEventListener('click', async () => {
if (!state.selectedAvatarDetail) return;
const aid = state.selectedAvatarDetail.avatar_id;
const msg = t('confirmClearRelics') || 'คุณแน่ใจหรือไม่ว่าต้องการถอดรีลิกส์ทั้งหมดของตัวละครนี้?';
if (!confirm(msg)) return;
const relics = state.fullFreesrData?.relics || [];
let unequippedCount = 0;
relics.forEach(r => {
if (r.equip_avatar === aid) {
r.equip_avatar = 0;
unequippedCount++;
}
});
if (unequippedCount > 0) {
if (state.isConnected) await saveFreesrChangesToServer();
showToast(t('toastRelicsCleared') || `ถอดรีลิกส์เรียบร้อย (${unequippedCount} ชิ้น)`, 'info');
renderModalRelicsTab(aid);
renderModalOverviewTab(aid);
renderShowcaseCard(aid);
loadEquipment();
} else {
showToast('ไม่มีรีลิกส์ที่ติดตั้งอยู่', 'warning');
}
});
// Relic Slot Picker Modal Controls
el.btnCloseSlotPicker?.addEventListener('click', () => {
el.modalSelectRelicSlot?.classList.remove('active');
});
el.btnSlotPickerQuickGen?.addEventListener('click', async () => {
if (state.slotPickerAvatarId && state.slotPickerSlot) {
await quickGenSlotRelic(state.slotPickerAvatarId, state.slotPickerSlot);
}
});
// Lightcone Controls in Modal
document.querySelectorAll('.lc-super-pip').forEach(btn => {
btn.addEventListener('click', () => {
const r = parseInt(btn.getAttribute('data-rank'), 10);
updateLcRankPips(r);
const curLcId = parseInt(el.modalLcId?.value, 10);
const curLvl = parseInt(el.modalLcLevel?.value, 10) || 80;
if (curLcId) updateModalLcFullView(curLcId, r, curLvl);
});
});
// Lightcone Level Slider & Input sync
el.rangeLcLevel?.addEventListener('input', () => {
const lvl = parseInt(el.rangeLcLevel.value, 10);
if (el.modalLcLevel) el.modalLcLevel.value = lvl;
if (el.modalLcLvlVal) el.modalLcLvlVal.textContent = `${lvl} / 80`;
if (el.modalLcRankBadge) el.modalLcRankBadge.textContent = `S${selectedLcRank} • Lv.${lvl}`;
});
el.modalLcLevel?.addEventListener('input', () => {
const lvl = parseInt(el.modalLcLevel.value, 10) || 1;
if (el.rangeLcLevel) el.rangeLcLevel.value = lvl;
if (el.modalLcLvlVal) el.modalLcLvlVal.textContent = `${lvl} / 80`;
if (el.modalLcRankBadge) el.modalLcRankBadge.textContent = `S${selectedLcRank} • Lv.${lvl}`;
});
// Apply change Light Cone button
el.btnModalApplyChangeLc?.addEventListener('click', () => {
if (!el.selectModalChangeLc || !state.selectedAvatarDetail) return;
const newLcId = parseInt(el.selectModalChangeLc.value, 10);
const aid = state.selectedAvatarDetail.avatar_id;
const curLvl = parseInt(el.modalLcLevel?.value, 10) || 80;
updateModalLcFullView(newLcId, selectedLcRank, curLvl);
const lcInfo = getLightconeInfo(newLcId);
showToast(`เปลี่ยน Light Cone เป็น: ${lcInfo.name} เรียบร้อยแล้ว`, 'info');
renderShowcaseCard(aid);
});
// Showcase Toolbar Controls
el.btnShowcaseCopyText?.addEventListener('click', () => {
if (state.selectedAvatarDetail) copyShowcaseText(state.selectedAvatarDetail.avatar_id);
});
el.btnShowcaseExportImg?.addEventListener('click', () => {
if (state.selectedAvatarDetail) exportShowcaseImage(state.selectedAvatarDetail.avatar_id);
});
el.btnShowcaseAutoRelics?.addEventListener('click', () => {
if (state.selectedAvatarDetail) autoEquipRecommendedRelics(state.selectedAvatarDetail.avatar_id, true);
});
// Quick Preset Buttons for Character Modal
document.querySelectorAll('.btn-lvl-preset').forEach(btn => {
btn.addEventListener('click', () => {
const lvl = parseInt(btn.getAttribute('data-lvl'), 10);
el.modalCharLevel.value = lvl;
if (lvl > 70) el.modalCharPromotion.value = 6;
else if (lvl > 60) el.modalCharPromotion.value = 5;
else if (lvl > 50) el.modalCharPromotion.value = 4;
else if (lvl > 40) el.modalCharPromotion.value = 3;
else if (lvl > 20) el.modalCharPromotion.value = 1;
else el.modalCharPromotion.value = 0;
});
});
document.querySelectorAll('.btn-prom-preset').forEach(btn => {
btn.addEventListener('click', () => {
el.modalCharPromotion.value = btn.getAttribute('data-prom');
});
});
el.btnModalQuickE0?.addEventListener('click', () => {
if (state.selectedAvatarDetail) state.selectedAvatarDetail.rank = 0;
renderModalEidolonPips(0);
});
el.btnModalQuickE6?.addEventListener('click', () => {
if (state.selectedAvatarDetail) state.selectedAvatarDetail.rank = 6;
renderModalEidolonPips(6);
});
// Lineup Picker Modal Controls
el.btnClosePickerModal.addEventListener('click', () => el.modalLineupPicker.classList.remove('active'));
el.pickerSearch.addEventListener('input', renderPickerGrid);
// Creative Mode Toggle
if (el.toggleCreativeMode) {
el.toggleCreativeMode.checked = state.creativeMode;
el.toggleCreativeMode.disabled = false;
el.toggleCreativeMode.addEventListener('change', (e) => {
state.creativeMode = e.target.checked;
localStorage.setItem('gac_srtool_creative_mode', state.creativeMode);
if (el.badgeCreativeMode) {
el.badgeCreativeMode.style.display = state.creativeMode ? 'inline-flex' : 'none';
}
renderLineup(state.statusData?.lineups);
const title = t('lblCreativeMode') || 'โหมดสร้างสรรค์';
showToast(`${title}: ${state.creativeMode ? 'ON' : 'OFF'}`, 'info');
});
}
// Quick Action Buttons on Dashboard
el.btnQuickLineupE0S1?.addEventListener('click', quickSetLineupE0S1);
el.btnQuickLineupRelics?.addEventListener('click', quickEquipLineupRelics);
el.btnQuickUnlockAllAvatars?.addEventListener('click', quickUnlockAllAvatars);
el.btnQuickAddAllLcs?.addEventListener('click', quickAddAll5StarLightcones);
el.btnQuickTeleportTrain?.addEventListener('click', quickTeleportAstralExpress);
el.btnQuickClearLineup?.addEventListener('click', quickClearLineup);
el.btnMaxAllAvatars?.addEventListener('click', maxAllAvatars);
// Persistent / Character Path Form Submit
el.formPersistentChars.addEventListener('submit', async (e) => {
e.preventDefault();
const mc = el.persistentMcSelect.value;
const march = el.persistentMarchSelect.value;
if (state.serverMode === 'firefly') {
state.dataGameData = state.dataGameData || { ...DEFAULT_DATA_GAME };
state.dataGameData.char_path = state.dataGameData.char_path || {};
state.dataGameData.char_path.main = FIREFLY_MC_MAP[mc] || 8010;
state.dataGameData.char_path.march_7 = FIREFLY_MARCH_MAP[march] || 1224;
await saveDataGameCache(state.dataGameData);
let serverUpdated = false;
if (state.isConnected) {
try {
await apiCall('/api/data-in-game', 'POST', state.dataGameData);
serverUpdated = true;
} catch (e) {
await saveFreesrChangesToServer().catch(() => {});
}
}
showToast(serverUpdated ? 'บันทึกสายของตัวละคร (Firefly data-in-game) สำเร็จ!' : 'บันทึกสายตัวละครลงในแคชแล้ว (Firefly data-in-game)', 'success');
refreshStatus();
return;
}
try {
await apiCall('/api/persistent', 'POST', {
main_character: mc,
march_type: march
});
showToast('บันทึกการตั้งค่าตัวละครสำเร็จ!', 'success');
refreshStatus();
} catch (err) {
showToast(`เกิดข้อผิดพลาด: ${err.message}`, 'error');
}
});
// Persistent Toggles
el.btnSavePersistentToggles.addEventListener('click', async () => {
const sw = el.toggleSwGlobal.checked;
const castorice = el.toggleCastoriceGlobal.checked;
const aha = el.toggleAhaGlobal ? el.toggleAhaGlobal.checked : true;
try {
await apiCall('/api/persistent', 'POST', {
enable_sw_global: sw,
enable_castorice_global: castorice,
enable_aha_global: aha
});
showToast(t('btnSaveGlobalSwitch') || 'บันทึกสวิตช์ Global เรียบร้อย!', 'success');
refreshStatus();
} catch (err) {
showToast(`เกิดข้อผิดพลาด: ${err.message}`, 'error');
}
});
// Equipment Add Modal Controls
if (el.btnOpenAddLc) {
el.btnOpenAddLc.addEventListener('click', openAddLcModal);
}
if (el.btnCloseAddLcModal) {
el.btnCloseAddLcModal.addEventListener('click', () => el.modalAddLc.classList.remove('active'));
}
if (el.btnCancelAddLc) {
el.btnCancelAddLc.addEventListener('click', () => el.modalAddLc.classList.remove('active'));
}
if (el.formAddLc) {
el.formAddLc.addEventListener('submit', handleAddLcSubmit);
}
document.getElementById('add-lc-search')?.addEventListener('input', () => renderAddLcVisualGrid());
document.getElementById('add-lc-filter-path')?.addEventListener('change', () => renderAddLcVisualGrid());
document.getElementById('add-lc-filter-rarity')?.addEventListener('change', () => renderAddLcVisualGrid());
if (el.btnOpenAddRelic) {
el.btnOpenAddRelic.addEventListener('click', openAddRelicModal);
}
if (el.btnCloseAddRelicModal) {
el.btnCloseAddRelicModal.addEventListener('click', () => el.modalAddRelic.classList.remove('active'));
}
if (el.btnCancelAddRelic) {
el.btnCancelAddRelic.addEventListener('click', () => el.modalAddRelic.classList.remove('active'));
}
if (el.addRelicSlot) {
el.addRelicSlot.addEventListener('change', (e) => updateRelicMainAffixes(e.target.value));
}
if (el.formAddRelic) {
el.formAddRelic.addEventListener('submit', handleAddRelicSubmit);
}
// Equipment Subtab Switching & Filters (Tiles View)
const updateEqFiltersExtra = () => {
if (!el.eqFilterExtra) return;
el.eqFilterExtra.innerHTML = '';
if (state.eqActiveSubtab === 'relic') {
el.eqFilterExtra.innerHTML = `
`;
} else {
el.eqFilterExtra.innerHTML = `
`;
}
};
if (el.btnEqSubtabLc) {
el.btnEqSubtabLc.addEventListener('click', () => {
state.eqActiveSubtab = 'lc';
el.btnEqSubtabLc.classList.add('active', 'btn-primary');
el.btnEqSubtabLc.classList.remove('btn-secondary');
el.btnEqSubtabRelic.classList.remove('active', 'btn-primary');
el.btnEqSubtabRelic.classList.add('btn-secondary');
if (el.lightconesGrid) el.lightconesGrid.style.display = 'grid';
if (el.relicsGrid) el.relicsGrid.style.display = 'none';
if (el.btnOpenAddLc) el.btnOpenAddLc.style.display = '';
if (el.btnOpenAddRelic) el.btnOpenAddRelic.style.display = 'none';
if (el.eqInspectorCard) el.eqInspectorCard.style.display = 'none';
updateEqFiltersExtra();
renderEquipment();
});
}
if (el.btnEqSubtabRelic) {
el.btnEqSubtabRelic.addEventListener('click', () => {
state.eqActiveSubtab = 'relic';
el.btnEqSubtabRelic.classList.add('active', 'btn-primary');
el.btnEqSubtabRelic.classList.remove('btn-secondary');
el.btnEqSubtabLc.classList.remove('active', 'btn-primary');
el.btnEqSubtabLc.classList.add('btn-secondary');
if (el.relicsGrid) el.relicsGrid.style.display = 'grid';
if (el.lightconesGrid) el.lightconesGrid.style.display = 'none';
if (el.btnOpenAddRelic) el.btnOpenAddRelic.style.display = '';
if (el.btnOpenAddLc) el.btnOpenAddLc.style.display = 'none';
if (el.eqInspectorCard) el.eqInspectorCard.style.display = 'none';
updateEqFiltersExtra();
renderEquipment();
});
}
if (el.eqSearch) el.eqSearch.addEventListener('input', renderEquipment);
if (el.eqFilterRarity) el.eqFilterRarity.addEventListener('change', renderEquipment);
if (el.eqFilterEquipped) el.eqFilterEquipped.addEventListener('change', renderEquipment);
if (el.eqFilterExtra) el.eqFilterExtra.addEventListener('change', renderEquipment);
updateEqFiltersExtra();
// Avatar Search & Filters
el.avatarSearch.addEventListener('input', renderAvatarsGrid);
el.avatarFilterRarity.addEventListener('change', renderAvatarsGrid);
el.avatarFilterElement.addEventListener('change', renderAvatarsGrid);
el.avatarFilterPath?.addEventListener('change', renderAvatarsGrid);
el.btnReloadAvatars.addEventListener('click', loadAvatars);
// Ex-Rank All Controls
el.btnExrankEnableAll.addEventListener('click', async () => {
try {
await apiCall('/api/exrank/toggle', 'POST', { all: true, unlocked: true });
showToast(t('exrankAllSuccess', { status: t('enabled') }), 'success');
refreshStatus();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
});
el.btnExrankDisableAll.addEventListener('click', async () => {
try {
await apiCall('/api/exrank/toggle', 'POST', { all: true, unlocked: false });
showToast(t('exrankAllSuccess', { status: t('disabled') }), 'success');
refreshStatus();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
});
// JSON Tab Controls
el.btnSelectFreesr?.addEventListener('click', () => loadJsonData('freesr'));
el.btnSelectPersistent?.addEventListener('click', () => loadJsonData('persistent'));
el.btnSelectDatagame?.addEventListener('click', () => loadJsonData('datagame'));
el.btnJsonBeautify.addEventListener('click', () => {
try {
const parsed = JSON.parse(el.jsonEditor.value);
el.jsonEditor.value = JSON.stringify(parsed, null, 2);
showToast('จัดรูปแบบ JSON สวยงามแล้ว', 'info');
} catch (err) {
showToast(t('jsonInvalid'), 'error');
}
});
el.btnJsonSave.addEventListener('click', saveJsonData);
el.btnJsonDownload.addEventListener('click', () => {
const text = el.jsonEditor.value;
const blob = new Blob([text], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${state.currentJsonMode}-${Date.now()}.json`;
a.click();
showToast(`ดาวน์โหลด ${a.download} เรียบร้อย`, 'success');
});
el.jsonFileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
try {
const parsed = JSON.parse(event.target.result);
el.jsonEditor.value = JSON.stringify(parsed, null, 2);
let recognized = false;
let isFreesr = false;
let isPersistent = false;
// If freesr-data.json was uploaded (has avatars, lightcones, or relics)
if (parsed.avatars || parsed.lightcones || parsed.relics) {
recognized = true;
isFreesr = true;
state.fullFreesrData = parsed;
await saveFreesrCache(parsed);
if (parsed.avatars) {
for (const aid in parsed.avatars) {
const av = parsed.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;
}
}
renderAvatarsGrid();
}
loadEquipment();
if (parsed.battle_config?.custom_battle_lineup || parsed.lineups) {
const lineups = parsed.lineups || parsed.battle_config?.custom_battle_lineup;
state.statusData = state.statusData || {};
state.statusData.lineups = lineups;
renderLineup(lineups);
}
}
// If persistent or has lineups
if (parsed.main_character || parsed.march_type || (parsed.lineups && !parsed.avatars)) {
recognized = true;
isPersistent = true;
state.persistentData = parsed;
await savePersistentCache(parsed);
if (parsed.lineups) {
state.statusData = state.statusData || {};
state.statusData.lineups = parsed.lineups;
renderLineup(parsed.lineups);
}
if (parsed.main_character && el.persistentMcSelect) {
el.persistentMcSelect.value = parsed.main_character;
}
if (parsed.march_type && el.persistentMarchSelect) {
el.persistentMarchSelect.value = parsed.march_type;
}
if (el.toggleSwGlobal && parsed.enable_sw_global !== undefined) {
el.toggleSwGlobal.checked = parsed.enable_sw_global !== false;
}
if (el.toggleCastoriceGlobal && parsed.enable_castorice_global !== undefined) {
el.toggleCastoriceGlobal.checked = parsed.enable_castorice_global !== false;
}
if (el.toggleAhaGlobal && parsed.enable_aha_global !== undefined) {
el.toggleAhaGlobal.checked = parsed.enable_aha_global !== false;
}
}
if (!recognized) {
if (state.currentJsonMode === 'freesr') {
isFreesr = true;
state.fullFreesrData = parsed;
await saveFreesrCache(parsed);
loadEquipment();
} else {
isPersistent = true;
state.persistentData = parsed;
await savePersistentCache(parsed);
}
}
// Automatically push to server upon file upload
let serverSaved = false;
let serverError = null;
try {
if (isFreesr) {
await saveFreesrChangesToServer();
serverSaved = true;
} else if (isPersistent) {
if (state.serverMode === 'gac') {
await apiCall('/api/persistent', 'POST', parsed);
} else {
await saveFreesrChangesToServer();
}
serverSaved = true;
}
} catch (sErr) {
serverError = sErr.message;
}
updateCacheUsageDisplay();
refreshStatus();
if (serverSaved) {
showToast(`✅ อัปโหลดและบันทึก ${file.name} ลงเซิร์ฟเวอร์ + แคชสำเร็จ!`, 'success');
} else if (serverError) {
showToast(`💾 บันทึก ${file.name} ลงแคชแล้ว (แต่ส่งเซิร์ฟเวอร์ไม่สำเร็จ: ${serverError})`, 'warning');
} else {
showToast(`💾 บันทึก ${file.name} ลงแคชสำเร็จ (โหมดออฟไลน์)`, 'info');
}
} catch (err) {
showToast(t('jsonInvalid') || 'ไฟล์ JSON ไม่ถูกต้อง', 'error');
}
};
reader.readAsText(file);
e.target.value = '';
});
// -------------------------------------------------------------
// Settings Tab Controls & Diagnostics
// -------------------------------------------------------------
if (el.settingApiUrl) {
el.settingApiUrl.value = state.apiUrl;
}
// Quick URL Preset Chips
document.querySelectorAll('.btn-quick-url').forEach(btn => {
btn.addEventListener('click', () => {
if (el.settingApiUrl) {
el.settingApiUrl.value = btn.dataset.url;
showToast(`เลือก Endpoint: ${btn.dataset.url}`, 'info');
}
});
});
// Ping Latency Test
if (el.btnTestConnection) {
el.btnTestConnection.addEventListener('click', async () => {
const targetUrl = (el.settingApiUrl?.value || state.apiUrl).trim().replace(/\/+$/, '');
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)';
}
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}`);
const latency = Math.round(performance.now() - t0);
if (el.pingLatencyBadge) {
el.pingLatencyBadge.textContent = `${latency} ms`;
el.pingLatencyBadge.style.backgroundColor = latency < 80 ? 'rgba(34, 197, 94, 0.2)' : 'rgba(234, 179, 8, 0.2)';
el.pingLatencyBadge.style.color = latency < 80 ? 'var(--accent-green)' : 'var(--accent-gold)';
}
showToast(t('toastPingSuccess', { ms: latency }), 'success');
setConnectionStatus(true);
} catch (err) {
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');
setConnectionStatus(false);
}
});
}
// Save Connection Settings
if (el.formSettings) {
el.formSettings.addEventListener('submit', (e) => {
e.preventDefault();
state.apiUrl = el.settingApiUrl.value.trim().replace(/\/+$/, '') || 'http://localhost:21000';
localStorage.setItem('gac_srtool_api_url', state.apiUrl);
showToast(t('settingsSaved'), 'success');
refreshStatus();
});
}
// Battle Starting Energy Presets
const curEnergy = localStorage.getItem('gac_srtool_default_energy') || '50';
document.querySelectorAll('.btn-setting-energy').forEach(btn => {
if (btn.dataset.energy === curEnergy) {
btn.classList.add('active', 'btn-primary');
btn.classList.remove('btn-secondary');
} else {
btn.classList.remove('active', 'btn-primary');
btn.classList.add('btn-secondary');
}
btn.addEventListener('click', () => {
document.querySelectorAll('.btn-setting-energy').forEach(b => {
b.classList.remove('active', 'btn-primary');
b.classList.add('btn-secondary');
});
btn.classList.add('active', 'btn-primary');
btn.classList.remove('btn-secondary');
localStorage.setItem('gac_srtool_default_energy', btn.dataset.energy);
state.defaultEnergy = parseInt(btn.dataset.energy, 10);
showToast(`ตั้งค่าพลังงานเริ่มต้น: ${btn.dataset.energy}%`, 'info');
});
});
// Auto-Sync Toggle
if (el.settingAutoSync) {
el.settingAutoSync.checked = localStorage.getItem('gac_srtool_auto_sync') !== 'false';
el.settingAutoSync.addEventListener('change', (e) => {
localStorage.setItem('gac_srtool_auto_sync', e.target.checked);
state.autoSync = e.target.checked;
});
}
// Creative Mode by Default Toggle
if (el.settingDefaultCreative) {
el.settingDefaultCreative.checked = localStorage.getItem('gac_srtool_default_creative') === 'true';
el.settingDefaultCreative.addEventListener('change', (e) => {
const val = e.target.checked;
localStorage.setItem('gac_srtool_default_creative', val);
state.defaultCreative = val;
if (val && !state.creativeMode) {
state.creativeMode = true;
localStorage.setItem('gac_srtool_creative_mode', 'true');
if (el.toggleCreativeMode) el.toggleCreativeMode.checked = true;
if (el.badgeCreativeMode) el.badgeCreativeMode.style.display = 'inline-flex';
renderLineup(state.statusData?.lineups);
}
});
}
// Showcase Sub-stat Rolls Toggle
if (el.settingShowcaseRolls) {
el.settingShowcaseRolls.checked = localStorage.getItem('gac_srtool_showcase_rolls') !== 'false';
el.settingShowcaseRolls.addEventListener('change', (e) => {
localStorage.setItem('gac_srtool_showcase_rolls', e.target.checked);
state.showcaseRolls = e.target.checked;
if (state.selectedAvatarDetail?.avatar_id) {
renderShowcaseCard(state.selectedAvatarDetail.avatar_id);
}
});
}
// Showcase Element Aura Glow Toggle
if (el.settingShowcaseAura) {
el.settingShowcaseAura.checked = localStorage.getItem('gac_srtool_showcase_aura') !== 'false';
el.settingShowcaseAura.addEventListener('change', (e) => {
localStorage.setItem('gac_srtool_showcase_aura', e.target.checked);
state.showcaseAura = e.target.checked;
if (state.selectedAvatarDetail?.avatar_id) {
renderShowcaseCard(state.selectedAvatarDetail.avatar_id);
}
});
}
// Clear Browser Cache Button
if (el.btnClearBrowserCache) {
el.btnClearBrowserCache.addEventListener('click', async () => {
const keepUrl = localStorage.getItem('gac_srtool_api_url');
const keepMode = localStorage.getItem('gac_srtool_mode');
const keepLang = localStorage.getItem('gac_srtool_lang');
localStorage.clear();
await idbClear();
if (keepUrl) localStorage.setItem('gac_srtool_api_url', keepUrl);
if (keepMode) localStorage.setItem('gac_srtool_mode', keepMode);
if (keepLang) localStorage.setItem('gac_srtool_lang', keepLang);
state.fullFreesrData = null;
state.persistentData = null;
loadAvatars();
loadEquipment();
updateCacheUsageDisplay();
showToast(t('toastCacheCleared') || 'ล้างแคชในเบราว์เซอร์เรียบร้อยแล้ว', 'success');
refreshStatus();
});
}
// Factory Reset Button
if (el.btnResetSettings) {
el.btnResetSettings.addEventListener('click', async () => {
localStorage.clear();
await idbClear();
state.fullFreesrData = null;
state.persistentData = null;
state.apiUrl = 'http://localhost:21000';
state.serverMode = 'gac';
state.creativeMode = false;
state.defaultEnergy = 50;
state.autoSync = true;
state.showcaseRolls = true;
state.showcaseAura = true;
if (el.settingApiUrl) el.settingApiUrl.value = state.apiUrl;
if (el.settingServerMode) el.settingServerMode.value = 'gac';
if (el.settingAutoSync) el.settingAutoSync.checked = true;
if (el.settingDefaultCreative) el.settingDefaultCreative.checked = false;
if (el.settingShowcaseRolls) el.settingShowcaseRolls.checked = true;
if (el.settingShowcaseAura) el.settingShowcaseAura.checked = true;
if (el.toggleCreativeMode) el.toggleCreativeMode.checked = false;
if (el.badgeCreativeMode) el.badgeCreativeMode.style.display = 'none';
loadAvatars();
loadEquipment();
updateCacheUsageDisplay();
showToast(t('settingsReset') || 'รีเซ็ตเป็นค่าเริ่มต้นเรียบร้อย', 'info');
refreshStatus();
});
}
updateCacheUsageDisplay();
// Modal Backdrop Click to close
[el.modalAvatarDetail, el.modalLineupPicker, el.modalSettings, el.modalSelectRelicSlot].forEach(modal => {
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.remove('active');
}
});
}
});
// ESC Key to close any active modal
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(m => m.classList.remove('active'));
}
});
}
// ==========================================
// Git Branch Detection & Browser Tab Title
// ==========================================
let currentGitBranch = 'test-gay';
function updateDocumentTitle(tabId = state.activeTab || 'tab-dashboard') {
const branchPrefix = currentGitBranch ? `[${currentGitBranch}] ` : '';
const tabNames = {
'tab-dashboard': 'GaC-Srtool — Ultimate Star Rail Server & Build Manager',
'tab-avatars': `👥 ${t('tabAvatars') || 'ตัวละคร'} | GaC-Srtool`,
'tab-equipment': `🗡️ ${t('tabEquipment') || 'กรวยแสง & รีลิกส์'} | GaC-Srtool`,
'tab-persistent': `💾 ${t('tabPersistent') || 'จัดการเซฟ GaC'} | GaC-Srtool`,
'tab-exrank': `⚡ ${t('tabExrank') || 'สกิลพิเศษ Ex-Rank'} | GaC-Srtool`,
'tab-json': `📝 ${t('tabJson') || 'จัดการไฟล์ JSON'} | GaC-Srtool`,
'tab-settings': `⚙️ ${t('tabSettings') || 'ตั้งค่า'} | GaC-Srtool`
};
const titleBody = tabNames[tabId] || 'GaC-Srtool — Ultimate Star Rail Server & Build Manager';
document.title = `${branchPrefix}${titleBody}`;
}
function applyBranchToUI(branch) {
state.currentBranch = branch;
currentGitBranch = branch;
// 1. Browser Tab Title ( & document.title)
updateDocumentTitle();
// 2. Header Brand Badge
const headerBadge = document.getElementById('header-branch-badge');
if (headerBadge) {
headerBadge.textContent = branch;
headerBadge.style.display = 'inline-flex';
}
// 3. Nav Tabs Branch Indicator
const navBadge = document.getElementById('nav-branch-name');
if (navBadge) {
navBadge.textContent = branch;
}
}
async function initGitBranchDisplay() {
// 1. URL Query Param ?branch=...
const urlParams = new URLSearchParams(window.location.search);
const branchFromUrl = urlParams.get('branch');
if (branchFromUrl) {
applyBranchToUI(branchFromUrl.trim());
return;
}
// 2. version.json (Generated by sync-branch or git commit)
try {
const res = await fetch('version.json?t=' + Date.now());
if (res.ok) {
const data = await res.json();
if (data && data.branch) {
applyBranchToUI(data.branch.trim());
return;
}
}
} catch (_) {}
// 3. .git/HEAD (if served locally by dev server)
try {
const headRes = await fetch('.git/HEAD?t=' + Date.now());
if (headRes.ok) {
const headText = await headRes.text();
const match = headText.match(/ref:\s*refs\/heads\/([^\r\n]+)/);
if (match && match[1]) {
applyBranchToUI(match[1].trim());
return;
}
}
} catch (_) {}
// 4. Fallback
applyBranchToUI(currentGitBranch);
}
// ==========================================
// In-Game Battle Telemetry & Damage Analyst
// ==========================================
const analystState = {
currentReport: null,
isFetching: false,
lastBattleTimestamp: 0,
autoSyncInterval: null
};
function formatNumber(num) {
if (num === null || num === undefined || isNaN(num)) return '0';
return Math.round(num).toLocaleString('en-US');
}
function formatDuration(seconds) {
if (!seconds || isNaN(seconds)) return '00:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function parseFireflySranalysisJson(json) {
if (!json || (!json.lineup && !json.skillHistory && !json.turnHistory)) return null;
const avatarsMap = {};
const teamLineup = json.lineup || [];
teamLineup.forEach(item => {
const aid = item.avatarId;
if (aid && aid > 0) {
avatarsMap[aid] = {
avatar_id: aid,
level: 80,
rank: 6,
total_damage: 0,
damage_percent: 0,
total_damage_taken: 0,
total_heal: 0,
total_shield: 0,
total_break_damage: 0,
total_turns: 0,
extra_turns: 0,
attack_type_damage: {},
attack_type_max_damage: {},
skill_times: []
};
}
});
let maxCycles = 0;
if (Array.isArray(json.turnHistory)) {
json.turnHistory.forEach(turn => {
if (turn.cycleIndex > maxCycles) maxCycles = turn.cycleIndex;
const aid = turn.avatarId;
if (aid && avatarsMap[aid]) {
avatarsMap[aid].total_turns++;
}
});
}
let teamTotalDmg = 0;
if (Array.isArray(json.skillHistory)) {
json.skillHistory.forEach(sk => {
const aid = sk.avatarId;
if (!aid || aid <= 0) return;
if (!avatarsMap[aid]) {
avatarsMap[aid] = {
avatar_id: aid,
level: 80,
rank: 6,
total_damage: 0,
damage_percent: 0,
total_damage_taken: 0,
total_heal: 0,
total_shield: 0,
total_break_damage: 0,
total_turns: 1,
extra_turns: 0,
attack_type_damage: {},
attack_type_max_damage: {},
skill_times: []
};
}
const av = avatarsMap[aid];
const dmg = sk.totalDamage || 0;
av.total_damage += dmg;
teamTotalDmg += dmg;
const skType = sk.skillType || 'Other';
av.attack_type_damage[skType] = (av.attack_type_damage[skType] || 0) + dmg;
if (Array.isArray(sk.damageDetail)) {
sk.damageDetail.forEach(d => {
const detailDmg = d.damage || 0;
const dType = d.type || skType;
if (detailDmg > (av.attack_type_max_damage[dType] || 0)) {
av.attack_type_max_damage[dType] = detailDmg;
}
});
}
});
}
const avatarsList = Object.values(avatarsMap);
avatarsList.forEach(a => {
if (teamTotalDmg > 0) {
a.damage_percent = (a.total_damage / teamTotalDmg) * 100;
}
});
return {
timestamp: Date.now(),
battle_id: Math.floor(Math.random() * 90000) + 10000,
stage_id: 1001,
end_status: 'WIN',
total_battle_turns: json.turnHistory?.length || 15,
total_auto_turns: 0,
round_cnt: maxCycles,
cost_time: 48,
challenge_score: 0,
team_total_damage: teamTotalDmg,
team_total_heal: 0,
team_total_shield: 0,
avatars: avatarsList,
monsters: []
};
}
async function fetchLatestBattleReport(silent = false) {
if (analystState.isFetching) return;
analystState.isFetching = true;
try {
const data = await apiRequest('/api/battle_report', 'GET');
if (data && data.has_report && data.report) {
const isNew = data.report.timestamp !== analystState.lastBattleTimestamp;
analystState.currentReport = data.report;
analystState.lastBattleTimestamp = data.report.timestamp;
renderDamageAnalyst();
updateDashboardBattleBanner();
if (!silent) {
showToast(t('analystToastFetched'), 'success');
} else if (isNew) {
showToast(t('analystToastFetched'), 'info');
}
} else {
if (!silent) {
showToast(data?.message || t('analystNoBattle'), 'info');
}
if (!analystState.currentReport) {
renderEmptyDamageAnalyst();
}
}
} catch (err) {
if (!silent) {
showToast(`Error: ${err.message}`, 'error');
}
} finally {
analystState.isFetching = false;
}
}
function renderEmptyDamageAnalyst() {
if (el.analystTeamDamage) el.analystTeamDamage.textContent = '0';
if (el.analystCycles) el.analystCycles.textContent = `0 ${t('analystCycles')}`;
if (el.analystTotalTurns) el.analystTotalTurns.textContent = `0 ${t('analystTurns')}`;
if (el.analystDuration) el.analystDuration.textContent = '00:00';
if (el.analystDamageAv) el.analystDamageAv.textContent = 'เฉลี่ย: -';
if (el.analystBattleMeta) el.analystBattleMeta.textContent = 'Stage: - | Battle: -';
if (el.badgeAnalystStatus) {
el.badgeAnalystStatus.textContent = 'พร้อมรับข้อมูลจากเกม';
el.badgeAnalystStatus.style.background = 'rgba(34, 197, 94, 0.15)';
el.badgeAnalystStatus.style.color = '#4ade80';
}
if (el.analystShareBarContainer) {
el.analystShareBarContainer.innerHTML = '';
}
if (el.analystShareLegend) {
el.analystShareLegend.innerHTML = '';
}
if (el.analystCharactersContainer) {
el.analystCharactersContainer.innerHTML = `
⚔️
${t('analystNoBattle')}
`;
const btnF = document.getElementById('btn-empty-analyst-fetch');
if (btnF) btnF.addEventListener('click', () => fetchLatestBattleReport(false));
const btnD = document.getElementById('btn-empty-analyst-demo');
if (btnD) btnD.addEventListener('click', () => loadDemoBattleReport());
}
if (el.analystEnemiesCard) {
el.analystEnemiesCard.style.display = 'none';
}
}
function renderDamageAnalyst() {
const report = analystState.currentReport;
if (!report) {
renderEmptyDamageAnalyst();
return;
}
const teamDmg = report.team_total_damage || 0;
if (el.analystTeamDamage) el.analystTeamDamage.textContent = formatNumber(teamDmg);
if (el.analystCycles) {
el.analystCycles.textContent = (report.round_cnt !== undefined) ? `${report.round_cnt} รอบ (${report.round_cnt}-Cycle)` : '0 รอบ';
}
if (el.analystTotalTurns) el.analystTotalTurns.textContent = `${report.total_battle_turns || 0} เทิร์น`;
if (el.analystDuration) el.analystDuration.textContent = formatDuration(report.cost_time);
if (el.analystDamageAv) {
el.analystDamageAv.textContent = (report.round_cnt > 0)
? `เฉลี่ย: ${(teamDmg / report.round_cnt).toFixed(0).toLocaleString()} / รอบ`
: `เฉลี่ย: ${(teamDmg / Math.max(1, report.total_battle_turns || 1)).toFixed(0).toLocaleString()} / เทิร์น`;
}
if (el.analystBattleMeta) {
el.analystBattleMeta.textContent = `Stage: ${report.stage_id || '-'} | Battle: #${report.battle_id || '-'}`;
}
if (el.badgeAnalystStatus) {
if (report.end_status === 'WIN') {
el.badgeAnalystStatus.textContent = t('analystStatusWin');
el.badgeAnalystStatus.style.background = 'rgba(34, 197, 94, 0.15)';
el.badgeAnalystStatus.style.color = '#4ade80';
} else if (report.end_status === 'QUIT') {
el.badgeAnalystStatus.textContent = t('analystStatusQuit');
el.badgeAnalystStatus.style.background = 'rgba(245, 158, 11, 0.15)';
el.badgeAnalystStatus.style.color = '#fbbf24';
} else {
el.badgeAnalystStatus.textContent = t('analystStatusLose');
el.badgeAnalystStatus.style.background = 'rgba(239, 68, 68, 0.15)';
el.badgeAnalystStatus.style.color = '#f87171';
}
}
const colors = ['#ef4444', '#38bdf8', '#34d399', '#fbbf24', '#c084fc', '#ec4899'];
// Team Share Bar
if (el.analystShareBarContainer) {
if (report.avatars && report.avatars.length > 0) {
el.analystShareBarContainer.innerHTML = report.avatars.map((a, idx) => {
const color = colors[idx % colors.length];
const pct = a.damage_percent || (teamDmg > 0 ? (a.total_damage / teamDmg) * 100 : 0);
const info = getAvatarInfo(a.avatar_id);
return ``;
}).join('');
} else {
el.analystShareBarContainer.innerHTML = '';
}
}
// Legend
if (el.analystShareLegend) {
if (report.avatars && report.avatars.length > 0) {
el.analystShareLegend.innerHTML = report.avatars.map((a, idx) => {
const color = colors[idx % colors.length];
const info = getAvatarInfo(a.avatar_id);
const pct = a.damage_percent || (teamDmg > 0 ? (a.total_damage / teamDmg) * 100 : 0);
return `
${info.name}
${pct.toFixed(1)}%
(${formatNumber(a.total_damage)})
`;
}).join('');
} else {
el.analystShareLegend.innerHTML = '';
}
}
// Characters Detailed Cards Grid
if (el.analystCharactersContainer) {
if (!report.avatars || report.avatars.length === 0) {
el.analystCharactersContainer.innerHTML = `
${t('analystNoBattle')}
`;
} else {
el.analystCharactersContainer.innerHTML = report.avatars.map((a, idx) => {
const color = colors[idx % colors.length];
const info = getAvatarInfo(a.avatar_id);
const pct = a.damage_percent || (teamDmg > 0 ? (a.total_damage / teamDmg) * 100 : 0);
let maxHit = 0;
if (a.attack_type_max_damage) {
for (const key in a.attack_type_max_damage) {
if (a.attack_type_max_damage[key] > maxHit) maxHit = a.attack_type_max_damage[key];
}
}
const dNormal = a.attack_type_damage?.Normal || 0;
const dSkill = a.attack_type_damage?.BPSkill || a.attack_type_damage?.Skill || 0;
const dUlt = a.attack_type_damage?.Ultra || a.attack_type_damage?.Ultimate || 0;
const dBreak = a.total_break_damage || a.attack_type_damage?.BreakDamage || 0;
const dFollowup = (a.attack_type_damage?.Insert || 0) + (a.attack_type_damage?.Pursued || 0);
const dDot = a.attack_type_damage?.DOT || 0;
return `
})
${info.element ? `
${getElementIconHtml(info.element, 14)}
` : ''}
${info.name}
Lv.${a.level || 80} E${a.rank !== undefined ? a.rank : 0}
${translatePath(info.path)} • ${translateElement(info.element)}
${pct.toFixed(1)}%
DPS SHARE
${t('analystTotalDamage')}
${formatNumber(a.total_damage)}
${t('analystMaxHit')}
${formatNumber(maxHit)}
${t('analystBreakdownTitle')}
${t('analystDmgNormal')}
${formatNumber(dNormal)}
${t('analystDmgSkill')}
${formatNumber(dSkill)}
${t('analystDmgUlt')}
${formatNumber(dUlt)}
${t('analystDmgBreak')}
${formatNumber(dBreak)}
${dFollowup > 0 ? `
${t('analystDmgFollowup')}
${formatNumber(dFollowup)}
` : ''}
${dDot > 0 ? `
${t('analystDmgDot')}
${formatNumber(dDot)}
` : ''}
⏱️ เทิร์น: ${a.total_turns || 0} ${a.extra_turns ? `(+${a.extra_turns})` : ''}
🛡️ โล่: ${formatNumber(a.total_shield)}
💚 ฮีล: ${formatNumber(a.total_heal)}
💔 โดนตี: ${formatNumber(a.total_damage_taken)}
`;
}).join('');
}
}
// Enemies Section
if (el.analystEnemiesContainer && el.analystEnemiesCard) {
if (report.monsters && report.monsters.length > 0) {
el.analystEnemiesCard.style.display = 'block';
el.analystEnemiesContainer.innerHTML = report.monsters.map(m => `
👾
Monster #${m.monster_id || m.entity_id}
Wave: ${m.wave || 1} • Entity: ${m.entity_id}
${formatNumber(m.total_damage_taken)} DMG Taken
${m.total_turns || 0} Turns (${formatNumber(m.total_damage)} Dealt)
`).join('');
} else {
el.analystEnemiesCard.style.display = 'none';
}
}
}
function updateDashboardBattleBanner() {
if (!el.dashboardBattleTitle || !el.dashboardBattleSummary) return;
const report = analystState.currentReport;
if (!report || !report.team_total_damage) {
el.dashboardBattleTitle.textContent = "Damage Analyst (จับดาเมจจากในเกม)";
el.dashboardBattleSummary.textContent = "ยังไม่มีข้อมูลการต่อสู้ล่าสุด — กดเพื่อเปิดหน้า Damage Analyst";
return;
}
const topAvatar = (report.avatars && report.avatars.length > 0)
? [...report.avatars].sort((a, b) => b.total_damage - a.total_damage)[0]
: null;
const topName = topAvatar ? getAvatarInfo(topAvatar.avatar_id).name : '';
const topDmgPct = topAvatar ? `${topAvatar.damage_percent.toFixed(0)}%` : '';
el.dashboardBattleTitle.textContent = `💥 ดาเมจรวม ${formatNumber(report.team_total_damage)} (${report.round_cnt} รอบ)`;
el.dashboardBattleSummary.textContent = topName
? `MVP: ${topName} (${topDmgPct} Share) • ${report.total_battle_turns} เทิร์น • เวลา ${formatDuration(report.cost_time)}`
: `สถานะ: ${report.end_status || 'WIN'} • ${report.total_battle_turns} เทิร์น`;
}
function exportBattleReportJson() {
if (!analystState.currentReport) {
showToast('ไม่มีข้อมูลการต่อสู้ที่จะส่งออก', 'error');
return;
}
const text = JSON.stringify(analystState.currentReport, null, 2);
const blob = new Blob([text], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `battle-report-${analystState.currentReport.battle_id || Date.now()}.json`;
a.click();
showToast(`ส่งออก ${a.download} เรียบร้อย`, 'success');
}
async function clearBattleReport() {
analystState.currentReport = null;
analystState.lastBattleTimestamp = 0;
try {
await apiRequest('/api/battle_report/reset', 'POST');
} catch (_) {}
renderEmptyDamageAnalyst();
updateDashboardBattleBanner();
showToast(t('analystToastCleared'), 'info');
}
function loadDemoBattleReport() {
const demoReport = {
timestamp: Date.now(),
battle_id: 88421,
stage_id: 3001,
end_status: "WIN",
total_battle_turns: 11,
total_auto_turns: 0,
round_cnt: 0,
cost_time: 46.8,
challenge_score: 4000,
team_total_damage: 2840000,
team_total_heal: 8400,
team_total_shield: 0,
avatars: [
{
avatar_id: 1310,
level: 80,
rank: 2,
total_damage: 2150000,
damage_percent: 75.7,
total_damage_taken: 3200,
total_heal: 0,
total_shield: 0,
total_break_damage: 1890000,
total_turns: 5,
extra_turns: 2,
attack_type_damage: {
Normal: 45000,
BPSkill: 215000,
BreakDamage: 1890000
},
attack_type_max_damage: {
BreakDamage: 620000
},
skill_times: [
{ skill_id: 131001, skill_type: "Normal", skill_use_count: 1 },
{ skill_id: 131002, skill_type: "BPSkill", skill_use_count: 4 }
]
},
{
avatar_id: 8005,
level: 80,
rank: 6,
total_damage: 410000,
damage_percent: 14.4,
total_damage_taken: 1800,
total_heal: 0,
total_shield: 0,
total_break_damage: 380000,
total_turns: 3,
extra_turns: 0,
attack_type_damage: {
BPSkill: 120000,
BreakDamage: 290000
},
attack_type_max_damage: {
BreakDamage: 195000
}
},
{
avatar_id: 1303,
level: 80,
rank: 1,
total_damage: 160000,
damage_percent: 5.6,
total_damage_taken: 1400,
total_heal: 0,
total_shield: 0,
total_break_damage: 160000,
total_turns: 2,
extra_turns: 0,
attack_type_damage: {
Normal: 20000,
BreakDamage: 140000
},
attack_type_max_damage: {
BreakDamage: 95000
}
},
{
avatar_id: 1301,
level: 80,
rank: 6,
total_damage: 120000,
damage_percent: 4.3,
total_damage_taken: 2100,
total_heal: 8400,
total_shield: 0,
total_break_damage: 95000,
total_turns: 3,
extra_turns: 1,
attack_type_damage: {
Normal: 25000,
Ultra: 35000,
BreakDamage: 60000
},
attack_type_max_damage: {
BreakDamage: 60000
}
}
],
monsters: [
{
entity_id: 101,
monster_id: 3024010,
total_turns: 4,
total_damage: 8500,
total_damage_taken: 2840000,
wave: 1
}
]
};
analystState.currentReport = demoReport;
analystState.lastBattleTimestamp = demoReport.timestamp;
renderDamageAnalyst();
updateDashboardBattleBanner();
showToast("✨ โหลดตัวอย่างการต่อสู้ 0-Cycle เรียบร้อยแล้ว", "success");
}
function setupDamageAnalystEvents() {
if (el.btnAnalystFetch) {
el.btnAnalystFetch.addEventListener('click', () => fetchLatestBattleReport(false));
}
if (el.btnAnalystClear) {
el.btnAnalystClear.addEventListener('click', clearBattleReport);
}
if (el.btnAnalystExport) {
el.btnAnalystExport.addEventListener('click', exportBattleReportJson);
}
if (el.btnAnalystImport && el.inputAnalystFile) {
el.btnAnalystImport.addEventListener('click', () => {
el.inputAnalystFile.click();
});
el.inputAnalystFile.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
try {
const parsed = JSON.parse(event.target.result);
let report = null;
if (parsed.avatars || parsed.team_total_damage !== undefined) {
report = parsed;
} else {
report = parseFireflySranalysisJson(parsed);
}
if (report && (report.avatars || report.team_total_damage !== undefined)) {
analystState.currentReport = report;
analystState.lastBattleTimestamp = report.timestamp || Date.now();
renderDamageAnalyst();
updateDashboardBattleBanner();
showToast(t('analystToastImported'), 'success');
try {
await apiRequest('/api/battle_report', 'POST', report);
} catch (_) {}
} else {
showToast('รูปแบบไฟล์ JSON ไม่ถูกต้อง (ต้องการ GaC Battle Report หรือ Firefly-Sranalysis JSON)', 'error');
}
} catch (err) {
showToast(`ไฟล์ JSON ผิดพลาด: ${err.message}`, 'error');
}
};
reader.readAsText(file);
e.target.value = '';
});
}
if (el.synergyDamageBanner) {
el.synergyDamageBanner.addEventListener('click', () => {
const btn = document.querySelector('.tab-btn[data-tab="tab-damage-analyst"]');
if (btn) btn.click();
});
}
if (!analystState.autoSyncInterval) {
analystState.autoSyncInterval = setInterval(() => {
if (el.chkAnalystAutosync && el.chkAnalystAutosync.checked && state.isConnected) {
fetchLatestBattleReport(true);
}
}, 3000);
}
}
window.fetchLatestBattleReport = fetchLatestBattleReport;
window.loadDemoBattleReport = loadDemoBattleReport;
window.exportBattleReportJson = exportBattleReportJson;
window.clearBattleReport = clearBattleReport;
// Background Ping Loop
function startPingLoop() {
setInterval(() => {
refreshStatus();
}, state.pingIntervalSec * 1000);
}
// Initialize Application
async function init() {
await initGitBranchDisplay();
initAvatarsList();
await loadCachedStorageData();
setupServerModes();
setupLanguageSelector();
setupTabs();
setupModalSubtabs();
setupEvents();
setupDamageAnalystEvents();
setupGMConsoleEvents();
applyTranslations();
renderLineup(state.statusData?.lineups || DEFAULT_LINEUP);
renderAvatarsGrid();
loadEquipment();
renderEmptyDamageAnalyst();
refreshStatus();
fetchLatestBattleReport(true);
startPingLoop();
updateCacheUsageDisplay();
}
// Run on DOM load
window.addEventListener('DOMContentLoaded', init);