238 lines
8.3 KiB
JavaScript
238 lines
8.3 KiB
JavaScript
// 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;
|
||
|
|
}
|