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
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user