- 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
246 lines
7.6 KiB
JavaScript
246 lines
7.6 KiB
JavaScript
// 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
|
|
};
|
|
}
|