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:
CAtlas0784 committed 2026-11-03 16:42:02 +07:00
1 parent 03fc1b82bb
commit 8eb519023a
17 files changed
+2174 -162

No files matched your search

+182
View File
@@ -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: []
});
});
+194
View File
@@ -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, {});
});
+110
View File
@@ -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}`);
}
});