diff --git a/api/analysis.php b/api/analysis.php index 3fcefbc..e04f0e3 100644 --- a/api/analysis.php +++ b/api/analysis.php @@ -212,11 +212,12 @@ foreach (MITIGATION_ABILITIES as $name => $meta) { } } -// statusId set for shield abilities — used to filter the buff timeline query -$shieldStatusIds = []; +// statusId set for tracked mitigations — used to resolve localized buff names +// from Buffs events and to build the shield fallback timeline. +$trackedStatusIds = []; foreach (MITIGATION_ABILITIES as $meta) { - if ($meta['buffType'] === 'shield' && isset($meta['statusId'])) { - $shieldStatusIds[$meta['statusId']] = true; + if (isset($meta['statusId'])) { + $trackedStatusIds[$meta['statusId']] = true; } } @@ -276,8 +277,9 @@ for ($page = 0; $page < 10; $page++) { // Builds applybuff/removebuff intervals per target so we can detect shields // that were consumed by a hit (absent from the damage event's buffs snapshot). $shieldTimeline = []; // targetId → statusId → [[apply, remove|null], ...] +$statusNames = []; // statusId → localized display name from Buffs events -if (!empty($shieldStatusIds)) { +if (!empty($trackedStatusIds)) { $nextPage = $startTime; for ($page = 0; $page < 10; $page++) { $bfResult = fflogs_gql(<< $ts, 'remove' => null]; @@ -329,6 +339,20 @@ if (!empty($shieldStatusIds)) { } } +foreach ($statusNames as $statusId => $displayName) { + if (isset($mitigIdMap[$statusId])) { + $mitigIdMap[$statusId]['name'] = $displayName; + } +} + +$mitigationNames = []; +foreach ($mitigIdMap as $meta) { + $key = $meta['key'] ?? null; + if ($key) { + $mitigationNames[$key] = $meta['name'] ?? $key; + } +} + // ── 3. AoE detection — proximity clustering ──────────────────────────────── // Group events by abilityId, then cluster by time proximity (≤ 1000ms from // the first event in the cluster) to avoid fixed-window boundary splits. @@ -474,7 +498,8 @@ foreach ($clusters as $group) { usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']); echo json_encode([ - 'players' => array_values($players), - 'aoe_events' => $aoeEvents, - 'fight_start' => (int)$startTime, + 'players' => array_values($players), + 'aoe_events' => $aoeEvents, + 'fight_start' => (int)$startTime, + 'mitigation_names' => $mitigationNames, ]); diff --git a/js/analysis.js b/js/analysis.js index a252c0e..5f160eb 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -103,6 +103,7 @@ let currentPlayers = []; let extFights = []; let extReportCode = ''; + let mitigationNames = {}; // ── Player grid ────────────────────────────────────────────────────────── @@ -683,6 +684,7 @@ populateRefFightSelect(); setupPhases(window.App?.phases ?? []); renderPlayers(json.players ?? []); + mitigationNames = json.mitigation_names ?? {}; renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart); document.getElementById('analysis-loading').style.display = 'none'; @@ -717,6 +719,9 @@ players: currentPlayers, fightName: fight?.name ?? `Fight ${window.App?.fightId ?? '?'}`, reportCode: window.App?.reportCode ?? '', + fightId: window.App?.fightId ?? 0, + fightEnd: window.App?.fightEnd ?? 0, + mitigationNames, }; }, reset() { @@ -726,6 +731,7 @@ refPlayers = []; extFights = []; extReportCode = ''; + mitigationNames = {}; document.getElementById('ref-player-section').style.display = 'none'; refFightSelect.value = ''; refFightSelect.style.display = 'none'; diff --git a/js/app.js b/js/app.js index b3ed4a1..db982bf 100644 --- a/js/app.js +++ b/js/app.js @@ -137,6 +137,12 @@ document.addEventListener('DOMContentLoaded', () => { document.querySelector('.tabs .tab[data-tab="analysis"]')?.click(); } + function shouldAutoOpenAnalysis() { + const params = new URLSearchParams(window.location.search); + const requestedTab = params.get('tab') || localStorage.getItem('ff14-mitigator-active-tab'); + return requestedTab !== 'planner'; + } + function selectFight(id, updateUrl = true) { const fight = allFights.find(f => f.id === id); if (!fight) return false; @@ -341,7 +347,7 @@ document.addEventListener('DOMContentLoaded', () => { if (initialUrlState.reportCode) { form.elements['report_code'].value = initialUrlState.reportCode; loadReport(initialUrlState.reportCode, initialUrlState.fightId).then(() => { - if (initialUrlState.fightId) { + if (initialUrlState.fightId && shouldAutoOpenAnalysis()) { openAnalysisTab(); } if (initialUrlState.compareFightId) { diff --git a/js/planner.js b/js/planner.js index f0441b1..b35ea75 100644 --- a/js/planner.js +++ b/js/planner.js @@ -1,6 +1,7 @@ // ── Storage ─────────────────────────────────────────────────────────────────── const PLANNER_KEY = 'ff14-planner-plans'; +const PLANNER_ACTIVE_KEY = 'ff14-planner-active-plan'; function loadPlans() { try { return JSON.parse(localStorage.getItem(PLANNER_KEY) || '[]'); } @@ -20,6 +21,7 @@ function createPlan(name) { createdAt: Date.now(), updatedAt: Date.now(), source: null, + mitigationNames: {}, jobComposition: Array(8).fill(''), mechanics: [] }; @@ -89,8 +91,22 @@ function fmtNumber(n) { return Number(n).toLocaleString('de-DE'); } -function assignmentAbilityName(assignment) { - return assignment?.abilityName ?? assignment?.ability ?? ''; +function assignmentAbilityName(assignment, plan = null) { + const key = assignment?.ability ?? ''; + return assignment?.abilityName ?? plan?.mitigationNames?.[key] ?? key; +} + +function plannerLanguage() { + return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en'; +} + +function sameMechanic(existing, incoming, source) { + const fightStart = source?.fightStart ?? 0; + const incomingRel = incoming.timestamp - fightStart; + if (existing.abilityId && incoming.abilityId && existing.abilityId === incoming.abilityId) { + return Math.abs(existing.timestamp - incomingRel) < 1500; + } + return Math.abs(existing.timestamp - incomingRel) < 1500; } // ── Rendering: Plan List ────────────────────────────────────────────────────── @@ -226,7 +242,7 @@ function renderMechanicListHtml(plan) { : 'badge-assign-buff'; const isMissing = !!a.job && !activeJobSet.has(a.job); const icon = MITIG_ICONS[a.ability] ?? ''; - const ability = assignmentAbilityName(a); + const ability = assignmentAbilityName(a, plan); const label = a.job ? `${escHtml(a.job)} · ${escHtml(ability)}` : escHtml(ability); const title = isMissing ? `${escHtml(a.job)} nicht in Jobaufstellung` : ''; return ` @@ -384,8 +400,10 @@ function startRename(id, currentName) { function openPlan(id) { activePlanId = id; + localStorage.setItem(PLANNER_ACTIVE_KEY, id); renderPlanList(); renderPlanDetail(getPlan(id)); + refreshPlanLanguage(id); } // ── New plan form ───────────────────────────────────────────────────────────── @@ -633,7 +651,7 @@ function mitigationKey(mitigation) { // ── AoE Events → Plan Mechanics ─────────────────────────────────────────────── -function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations) { +function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations, mitigationNames = {}) { return aoeEvents.map(ev => { const relTs = ev.timestamp - fightStart; const phase = (phases ?? []).filter(p => p.id !== 0).find(p => @@ -657,7 +675,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga seen.add(key); assignments.push({ ability: key, - abilityName: mitigationDisplayName(m), + abilityName: mitigationDisplayName(m) || mitigationNames[key], job: guessJob(key, players), buffType: m.buffType ?? '', }); @@ -669,6 +687,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga return { id: crypto.randomUUID(), name: ev.abilityName, + abilityId: ev.abilityId, timestamp: relTs, phase: phase?.name ?? '', unmitigatedDamage: avgUnmit, @@ -694,14 +713,16 @@ function extractJobComp(players) { } function doImport(data, withMitigations, whereMode, mergeId, newName) { - const { aoeEvents, fightStart, phases, players, fightName, reportCode } = data; - const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations); + const { aoeEvents, fightStart, fightEnd, phases, players, fightName, reportCode, fightId, mitigationNames = {} } = data; + const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations, mitigationNames); + const source = { reportCode, fightId, fightName, fightStart, fightEnd, language: plannerLanguage() }; if (whereMode === 'new') { const plan = createPlan(newName || fightName || 'Importierter Plan'); return updatePlan(plan.id, { mechanics, - source: { reportCode, fightName }, + source, + mitigationNames, jobComposition: extractJobComp(players), }); } @@ -719,7 +740,87 @@ function doImport(data, withMitigations, whereMode, mergeId, newName) { } merged.sort((a, b) => a.timestamp - b.timestamp); - return updatePlan(mergeId, { mechanics: merged }); + return updatePlan(mergeId, { + mechanics: merged, + source: { ...(plan.source ?? {}), ...source }, + mitigationNames: { ...(plan.mitigationNames ?? {}), ...mitigationNames }, + }); +} + +const refreshingPlans = new Set(); + +async function refreshPlanLanguage(planId) { + const plan = getPlan(planId); + const source = plan?.source ?? {}; + const language = plannerLanguage(); + if (!plan || refreshingPlans.has(planId)) return; + if (!source.reportCode || !source.fightId || !source.fightStart || !source.fightEnd) return; + if (source.language === language && plan.mitigationNames && Object.keys(plan.mitigationNames).length) return; + + refreshingPlans.add(planId); + try { + const params = new URLSearchParams({ + report_code: source.reportCode, + fight_id: source.fightId, + start_time: source.fightStart, + end_time: source.fightEnd, + language, + }); + + const res = await fetch('api/analysis.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params, + }); + const json = await res.json(); + if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } + if (json.error) return; + + const refreshed = (json.aoe_events ?? []); + const mechanics = plan.mechanics.map(mechanic => { + const match = refreshed.find(ev => sameMechanic(mechanic, ev, source)); + if (!match) return mechanic; + const assignments = (mechanic.assignments ?? []).map(a => ({ + ...a, + abilityName: json.mitigation_names?.[a.ability] ?? a.abilityName, + })); + return { + ...mechanic, + name: match.abilityName ?? mechanic.name, + abilityId: match.abilityId ?? mechanic.abilityId, + assignments, + }; + }); + + let fightName = source.fightName; + try { + const fightRes = await fetch('api/fight.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ report_code: source.reportCode, language }), + }); + const fightJson = await fightRes.json(); + const fight = (fightJson?.data?.reportData?.report?.fights ?? []).find(f => f.id === source.fightId); + if (fight?.name) fightName = fight.name; + } catch { } + + const nextSource = { ...source, fightName, language }; + const nextName = plan.name === source.fightName && fightName ? fightName : plan.name; + const updated = updatePlan(planId, { + name: nextName, + mechanics, + source: nextSource, + mitigationNames: json.mitigation_names ?? plan.mitigationNames ?? {}, + }); + + if (updated && activePlanId === planId) { + renderPlanList(); + renderPlanDetail(updated); + } + } catch { } + finally { + refreshingPlans.delete(planId); + } } // ── Ability Assignment Modal ────────────────────────────────────────────────── @@ -775,8 +876,8 @@ function renderAbilityModalContent() { const otherClass = byOtherJob ? ' ability-chip--other-job' : ''; const title = byOtherJob ? `Bereits von ${escHtml(assigned.job)} zugewiesen` : ''; const icon = MITIG_ICONS[ab.name] ?? ''; - const assignedName = assigned ? assignmentAbilityName(assigned) : ''; - const label = assignedName || ab.name; + const assignedName = assigned ? assignmentAbilityName(assigned, plan) : ''; + const label = assignedName || plan.mitigationNames?.[ab.name] || ab.name; return `