// FireflyGo endgame (Monster Setting) helpers. // // The catalog is a compact extract of the Firefly SrTools client tables: // modes..groups[] -> levels[] (floors) -> sides[] // modes.peak.groups[] -> stages[] // monsters[id] -> { name, rank, stance, weak, icon } // // Every helper here is pure so it can be unit tested with node:test. export const FIREFLY_ENDGAME_CATALOG_URL = './assets/firefly-endgame.json'; export const ENDGAME_MODES = ['moc', 'pf', 'as', 'peak', 'custom']; export const ENDGAME_BATTLE_TYPES = { moc: 'MOC', pf: 'PF', as: 'AS', peak: 'PEAK', custom: 'CE' }; export function battleTypeForMode(mode) { return ENDGAME_BATTLE_TYPES[mode] || 'DEFAULT'; } function cloneJson(value) { return JSON.parse(JSON.stringify(value)); } function num(value, fallback = 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } export function groupList(catalog, mode) { return Array.isArray(catalog?.modes?.[mode]?.groups) ? catalog.modes[mode].groups : []; } export function findGroup(catalog, mode, groupId) { const groups = groupList(catalog, mode); return groups.find((group) => Number(group.id) === Number(groupId)) || null; } export function findLevel(group, levelId) { const levels = Array.isArray(group?.levels) ? group.levels : []; return levels.find((level) => Number(level.id) === Number(levelId)) || null; } function newestGroup(catalog, mode) { const groups = groupList(catalog, mode); return groups.slice().sort((a, b) => Number(b.id) - Number(a.id))[0] || null; } function highestLevel(group) { const levels = Array.isArray(group?.levels) ? group.levels : []; return levels.slice().sort((a, b) => Number(b.floor) - Number(a.floor))[0] || null; } /** * Picks the sensible default for a mode: newest season, highest floor and the * first side. Peak defaults to the boss stage because that is the room players * usually want to test. */ export function defaultEndgameSelection(catalog, mode) { if (mode === 'custom') { return { mode, customWaves: [[{ monster_id: 0, level: 80 }]], customCycle: 0 }; } const group = newestGroup(catalog, mode); if (!group) { return { mode, groupId: null, levelId: null, stageIndex: 0, side: 'firstNode', useCycle: true, useBuff: true }; } if (mode === 'peak') { const stages = Array.isArray(group.stages) ? group.stages : []; const bossIndex = stages.findIndex((stage) => stage.kind === 'boss'); return { mode, groupId: Number(group.id), stageIndex: bossIndex >= 0 ? bossIndex : Math.max(0, stages.length - 1), useCycle: true, useBuff: true }; } const level = highestLevel(group); const sides = Array.isArray(level?.sides) ? level.sides : []; const side = sides.some((entry) => entry.side === 'firstNode') ? 'firstNode' : (sides[0]?.side || 'firstNode'); return { mode, groupId: Number(group.id), levelId: level ? Number(level.id) : null, side, useCycle: true, useBuff: true }; } /** * Resolves a selection against the catalog. Returns null when the catalog has * no group/stage for the requested mode. */ export function resolveEndgameSelection(catalog, selection) { const mode = selection?.mode; if (!ENDGAME_MODES.includes(mode)) return null; if (mode === 'custom') return { mode: 'custom', custom: true }; const groups = groupList(catalog, mode); if (!groups.length) return null; const group = findGroup(catalog, mode, selection.groupId) || newestGroup(catalog, mode); if (!group) return null; if (mode === 'peak') { const stages = Array.isArray(group.stages) ? group.stages : []; if (!stages.length) return null; const rawIndex = num(selection.stageIndex, 0); const index = Math.min(Math.max(rawIndex, 0), stages.length - 1); return { mode, group, stage: stages[index], stageIndex: index }; } const level = findLevel(group, selection.levelId) || highestLevel(group); if (!level) return null; const sides = Array.isArray(level.sides) ? level.sides : []; if (!sides.length) return null; const stage = sides.find((entry) => entry.side === selection.side) || sides[0]; return { mode, group, level, sides, stage }; } export function monsterInfo(catalog, monsterId) { return catalog?.monsters?.[String(monsterId)] || null; } /** * Rebuilds a selection from an existing battle_config.stage_id so the picker * opens on the room the save file is already using. Peak seasons are looked up * first in the group referenced by extra_data.challenge when given. */ export function findSelectionByStageId(catalog, mode, stageId, groupId = 0) { if (!ENDGAME_MODES.includes(mode) || mode === 'custom') return null; const groups = groupList(catalog, mode); if (!groups.length) return null; const preferred = Number(groupId) > 0 ? findGroup(catalog, mode, groupId) : null; const ordered = preferred ? [preferred, ...groups.filter((group) => group !== preferred)] : groups; const target = num(stageId); for (const group of ordered) { if (mode === 'peak') { const stages = Array.isArray(group.stages) ? group.stages : []; const index = stages.findIndex((stage) => Number(stage.stageId) === target); if (index >= 0) { return { mode, groupId: Number(group.id), stageIndex: index, useCycle: true, useBuff: true }; } continue; } const levels = Array.isArray(group.levels) ? group.levels : []; for (const level of levels) { const sides = Array.isArray(level.sides) ? level.sides : []; const side = sides.find((entry) => Number(entry.stageId) === target); if (side) { return { mode, groupId: Number(group.id), levelId: Number(level.id), side: side.side, useCycle: true, useBuff: true }; } } } return null; } function normalizeCustomWaves(waves) { if (!Array.isArray(waves)) return []; return waves .map((wave) => (Array.isArray(wave) ? wave : []) .map((entry) => { const id = num(entry?.monster_id ?? entry?.id ?? entry); if (!id) return null; return { monster_id: id, level: num(entry?.level, 80) || 80, amount: num(entry?.amount, 1) || 1 }; }) .filter(Boolean)) .filter((wave) => wave.length > 0); } /** * Builds the exact battle_config FireflyGo validates: * blessings: [{ id, level }] * monsters: [[{ monster_id, level, amount }]] */ export function buildFireflyBattleConfig(catalog, selection) { const resolved = resolveEndgameSelection(catalog, selection); if (!resolved) return null; const type = battleTypeForMode(selection.mode); if (resolved.custom) { const waves = normalizeCustomWaves(selection.customWaves); if (!waves.length) return null; return { battle_type: type, blessings: [], custom_stats: [], cycle_count: num(selection.customCycle), stage_id: 0, path_resonance_id: 0, monsters: waves }; } const { stage } = resolved; const waves = (Array.isArray(stage.waves) ? stage.waves : []) .map((wave) => wave .map((id) => Number(id)) .filter((id) => id > 0) .map((id) => ({ monster_id: id, level: Number(stage.level) || 1, amount: 1 }))) .filter((wave) => wave.length > 0); const blessings = []; if (selection.useBuff !== false) { const buffs = selection.mode === 'peak' ? stage.buffs : resolved.level?.buffs; for (const buff of Array.isArray(buffs) ? buffs : []) { const id = num(buff?.id); if (id > 0) blessings.push({ id, level: 1 }); } } if (stage.invasionBuffId) { blessings.push({ id: Number(stage.invasionBuffId), level: 1 }); } return { battle_type: type, blessings, custom_stats: [], cycle_count: selection.useCycle ? (num(stage.turnLimit) || num(resolved.level?.turnLimit)) : 0, stage_id: num(stage.stageId), path_resonance_id: 0, monsters: waves }; } /** * Anomaly Arbitration is selected through extra_data.challenge, so the chosen * Peak season has to travel next to battle_config. */ export function buildFireflyChallenge(selection, current) { const challenge = current && typeof current === 'object' ? cloneJson(current) : {}; challenge.skip_node = num(challenge.skip_node); challenge.challenge_peak_group_id = num(challenge.challenge_peak_group_id); challenge.challenge_peak_group_id_list = Array.isArray(challenge.challenge_peak_group_id_list) ? challenge.challenge_peak_group_id_list.map((id) => num(id)).filter((id) => id > 0) : []; if (selection?.mode === 'peak') { const groupId = num(selection.groupId); if (groupId > 0) { challenge.challenge_peak_group_id = groupId; challenge.challenge_peak_group_id_list = [groupId]; } } return challenge; } export function summarizeEndgameSelection(catalog, selection) { const resolved = resolveEndgameSelection(catalog, selection); if (!resolved) return null; if (resolved.custom) { const waves = normalizeCustomWaves(selection.customWaves); return { mode: 'custom', battleType: battleTypeForMode('custom'), stageId: 0, waveCount: waves.length, monsterCount: waves.reduce((sum, wave) => sum + wave.length, 0), blessings: 0, cycleCount: num(selection.customCycle) }; } const { stage } = resolved; const waves = Array.isArray(stage.waves) ? stage.waves : []; const buffs = (selection.useBuff !== false ? (selection.mode === 'peak' ? stage.buffs : resolved.level?.buffs) : []) || []; return { mode: selection.mode, battleType: battleTypeForMode(selection.mode), stageId: num(stage.stageId), stageLevel: num(stage.level), waveCount: waves.length, monsterCount: waves.reduce((sum, wave) => sum + (wave?.length || 0), 0), blessings: (Array.isArray(buffs) ? buffs.length : 0) + (stage.invasionBuffId ? 1 : 0), cycleCount: selection.useCycle ? (num(stage.turnLimit) || num(resolved.level?.turnLimit)) : 0, groupId: num(resolved.group?.id), groupName: resolved.group?.name || null, floor: resolved.level ? num(resolved.level.floor) : 0, side: stage.side || '' }; }