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