ff14-mitigator/js/analysis.js
xziino e85e341b33 Move boss debuffs to AoE event header, update CLAUDE.md
Debuffs (Reprisal, Feint, Addle, etc.) now appear once next to the
ability name and REF label instead of repeating under each player card.
Flex layout on .aoe-ability matches the REF row spacing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 18:42:50 +02:00

535 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
const MITIG_ICONS = {
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
'Temperance': 'assets/icons/mitigation/temperance.png',
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
'Expedient': 'assets/icons/mitigation/expedient.png',
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
'Holos': 'assets/icons/mitigation/holos.png',
'Kerachole': 'assets/icons/mitigation/kerachole.png',
'Panhaima': 'assets/icons/mitigation/panhaima.png',
'Troubadour': 'assets/icons/mitigation/troubadour.png',
'Tactician': 'assets/icons/mitigation/tactician.png',
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
'Reprisal': 'assets/icons/mitigation/reprisal.png',
'Feint': 'assets/icons/mitigation/feint.png',
'Addle': 'assets/icons/mitigation/addle.png',
};
const JOB_ABBR = {
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
'Reaper': 'RPR', 'Viper': 'VPR',
'Bard': 'BRD', 'Machinist': 'MCH', 'Dancer': 'DNC',
'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
};
function abbr(type) {
return JOB_ABBR[type] ?? type.slice(0, 3).toUpperCase();
}
function fmtTime(ms, start) {
const rel = ms - start;
const min = Math.floor(rel / 60000);
const sec = String(Math.floor((rel % 60000) / 1000)).padStart(2, '0');
return `${min}:${sec}`;
}
function fmtDmg(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
if (n >= 1_000) return Math.round(n / 1_000) + 'k';
return String(n);
}
function fmtDur(ms) {
const min = Math.floor(ms / 60000);
const sec = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0');
return `${min}:${sec}`;
}
let hiddenPlayers = new Set();
let hiddenPlayerNames = new Set();
let lastEvents = [];
let lastFightStart = 0;
let playerFilter = '';
let phaseFilter = { startTime: 0, endTime: Infinity };
let refEvents = [];
let refFightStart = 0;
let extFights = [];
let extReportCode = '';
// ── Player grid ──────────────────────────────────────────────────────────
function renderPlayers(players) {
const grid = document.getElementById('player-grid');
const order = { healer: 0, dps: 1, tank: 2 };
players.sort((a, b) => {
const roleCmp = (order[a.role] ?? 2) - (order[b.role] ?? 2);
return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name);
});
hiddenPlayers = new Set(players.filter(p => p.role === 'tank').map(p => p.id));
hiddenPlayerNames = new Set(players.filter(p => p.role === 'tank').map(p => p.name));
grid.innerHTML = players.map(p => `
<div class="player-card ${hiddenPlayers.has(p.id) ? 'player-hidden' : ''}" data-player-id="${p.id}" data-player-name="${p.name}">
<div class="player-job-icon role-${p.role}">${abbr(p.type)}</div>
<div>
<div class="player-name">${p.name}</div>
<div class="player-type">${p.type}</div>
</div>
</div>
`).join('');
}
document.getElementById('player-grid').addEventListener('click', e => {
const card = e.target.closest('.player-card');
if (!card) return;
const id = parseInt(card.dataset.playerId, 10);
const name = card.dataset.playerName;
if (hiddenPlayers.has(id)) {
hiddenPlayers.delete(id);
hiddenPlayerNames.delete(name);
card.classList.remove('player-hidden');
} else {
hiddenPlayers.add(id);
hiddenPlayerNames.add(name);
card.classList.add('player-hidden');
}
renderTimeline(lastEvents, lastFightStart);
});
document.getElementById('player-filter').addEventListener('input', e => {
playerFilter = e.target.value.trim().toLowerCase();
renderTimeline(lastEvents, lastFightStart);
});
// ── Phase select ─────────────────────────────────────────────────────────
const phaseSelect = document.getElementById('phase-select');
phaseSelect.addEventListener('change', () => {
const phases = window.App?.phases ?? [];
const phase = phases.find(p => p.id === parseInt(phaseSelect.value, 10));
if (phase) {
phaseFilter = { startTime: phase.startTime, endTime: phase.endTime };
renderTimeline(lastEvents, lastFightStart);
}
});
function setupPhases(phases) {
if (!phases.length) {
phaseSelect.style.display = 'none';
phaseFilter = { startTime: 0, endTime: Infinity };
return;
}
phaseSelect.innerHTML = phases.map(p =>
`<option value="${p.id}">${p.name}</option>`
).join('');
phaseSelect.value = 0;
phaseFilter = { startTime: phases[0].startTime, endTime: phases[0].endTime };
phaseSelect.style.display = '';
}
// ── Reference fight select ────────────────────────────────────────────────
const refFightSelect = document.getElementById('ref-fight-select');
refFightSelect.addEventListener('change', async () => {
const refId = parseInt(refFightSelect.value, 10);
if (!refId) {
refEvents = [];
refFightStart = 0;
renderTimeline(lastEvents, lastFightStart);
return;
}
const fight = (window.App?.fights ?? []).find(f => f.id === refId);
if (!fight) return;
// Clear ext-report selection
refExtFightSelect.value = '';
refFightSelect.disabled = true;
try {
const res = await fetch('api/analysis.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
report_code: window.App.reportCode,
fight_id: refId,
start_time: fight.startTime,
end_time: fight.endTime,
}),
});
const json = await res.json();
if (!json.error && !json.reauth) {
refEvents = json.aoe_events ?? [];
refFightStart = json.fight_start ?? fight.startTime;
}
} catch { }
refFightSelect.disabled = false;
renderTimeline(lastEvents, lastFightStart);
});
function onFightsLoaded(fights) {
refFightSelect.innerHTML = '<option value="">Kein Vergleich</option>';
fights.forEach(f => {
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = `${f.name}${fmtDur(f.endTime - f.startTime)}${hp}`;
refFightSelect.appendChild(opt);
});
refFightSelect.style.display = '';
}
// ── External report comparison ────────────────────────────────────────────
const refExtToggle = document.getElementById('ref-ext-toggle');
const refExtPanel = document.getElementById('ref-ext-panel');
const refReportInput = document.getElementById('ref-report-input');
const refReportLoad = document.getElementById('ref-report-load');
const refExtFightSelect = document.getElementById('ref-ext-fight-select');
refExtToggle.addEventListener('click', () => {
const hidden = refExtPanel.style.display === 'none';
refExtPanel.style.display = hidden ? '' : 'none';
});
refReportLoad.addEventListener('click', async () => {
const code = refReportInput.value.trim();
if (!code) return;
refReportLoad.disabled = true;
refReportLoad.textContent = 'Lädt…';
try {
const res = await fetch('api/fight.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ report_code: code }),
});
const json = await res.json();
if (json.reauth) { window.location.href = 'auth/start.php'; return; }
const fights = json?.data?.reportData?.report?.fights ?? [];
extFights = fights;
extReportCode = code;
refExtFightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
fights.forEach(f => {
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = `${f.name}${fmtDur(f.endTime - f.startTime)}${hp}`;
refExtFightSelect.appendChild(opt);
});
refExtFightSelect.style.display = fights.length ? '' : 'none';
} catch { }
refReportLoad.disabled = false;
refReportLoad.textContent = 'Laden';
});
refExtFightSelect.addEventListener('change', async () => {
const refId = parseInt(refExtFightSelect.value, 10);
if (!refId) {
refEvents = [];
refFightStart = 0;
renderTimeline(lastEvents, lastFightStart);
return;
}
const fight = extFights.find(f => f.id === refId);
if (!fight) return;
// Clear same-report selection
refFightSelect.value = '';
refExtFightSelect.disabled = true;
try {
const res = await fetch('api/analysis.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
report_code: extReportCode,
fight_id: refId,
start_time: fight.startTime,
end_time: fight.endTime,
}),
});
const json = await res.json();
if (!json.error && !json.reauth) {
refEvents = json.aoe_events ?? [];
refFightStart = json.fight_start ?? fight.startTime;
}
} catch { }
refExtFightSelect.disabled = false;
renderTimeline(lastEvents, lastFightStart);
});
// ── Timeline rendering ────────────────────────────────────────────────────
function renderTimeline(events, fightStart) {
lastEvents = events;
lastFightStart = fightStart;
const el = document.getElementById('aoe-timeline');
if (!events.length) {
el.innerHTML = '<div class="empty" style="padding:30px 0"><h3>Keine AoE-Events gefunden</h3></div>';
return;
}
// Build reference index: abilityName → [events in order]
const refIndex = {};
for (const ev of refEvents) {
(refIndex[ev.abilityName] = refIndex[ev.abilityName] ?? []).push(ev);
}
const abilityOccurrence = {};
const rows = events.map(ev => {
if (ev.timestamp < phaseFilter.startTime || ev.timestamp >= phaseFilter.endTime) return '';
// Track occurrence for ref matching
const occ = abilityOccurrence[ev.abilityName] ?? 0;
abilityOccurrence[ev.abilityName] = occ + 1;
const refEv = refEvents.length ? (refIndex[ev.abilityName]?.[occ] ?? null) : null;
const visibleTargets = ev.targets.filter(t =>
!hiddenPlayers.has(t.id) &&
(!playerFilter || t.name.toLowerCase().includes(playerFilter))
);
if (!visibleTargets.length) return '';
// Collect boss debuffs (Reprisal/Feint/Addle) once at event level
const seenDebuffNames = new Set();
const eventDebuffs = [];
for (const t of visibleTargets) {
for (const m of (t.mitigations ?? [])) {
if (m.buffType === 'debuff' && !seenDebuffNames.has(m.name)) {
seenDebuffNames.add(m.name);
eventDebuffs.push(m);
}
}
}
const eventMissingDebuffs = refEv
? (refEv.targets[0]?.mitigations ?? []).filter(m => m.buffType === 'debuff' && !seenDebuffNames.has(m.name))
: [];
const debuffIconsHtml = [
...eventDebuffs.map(m => ({ ...m, missing: false })),
...eventMissingDebuffs.map(m => ({ ...m, missing: true })),
].map(m => {
const iconSrc = MITIG_ICONS[m.name];
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
return m.missing
? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr} fehlt (war im Referenz-Pull aktiv)">`
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
}).join('');
// Current targets
const targets = visibleTargets.map(t => {
const hpBar = (t.maxHp > 0) ? (() => {
const afterPct = t.hp / t.maxHp * 100;
const damagePct = t.amount / t.maxHp * 100;
const hpColor = afterPct > 50 ? 'var(--green)' : afterPct > 25 ? '#e8a020' : 'var(--red)';
return `<div class="aoe-hp-bar">
<div class="aoe-hp-remaining" style="width:${afterPct.toFixed(1)}%;background:${hpColor}"></div>
<div class="aoe-hp-damage" style="width:${damagePct.toFixed(1)}%"></div>
</div>`;
})() : '';
const currentMitigNames = new Set((t.mitigations ?? []).map(m => m.name));
const refTarget = refEv?.targets?.find(rt => rt.name === t.name);
const missingMitigs = refTarget
? (refTarget.mitigations ?? []).filter(m => m.buffType !== 'debuff' && !currentMitigNames.has(m.name))
: [];
const mitigIcons = (t.mitigations ?? []).filter(m => m.buffType !== 'debuff').map(m => {
const iconSrc = MITIG_ICONS[m.name];
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
}).join('');
const missingIcons = missingMitigs.map(m => {
const iconSrc = MITIG_ICONS[m.name];
if (!iconSrc) return '';
return `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${m.name} fehlt (war im Referenz-Pull aktiv)">`;
}).join('');
const dead = t.hp === 0 && t.maxHp > 0;
return `
<div class="aoe-target-wrap">
<div class="aoe-target${dead ? ' aoe-target--dead' : ''}">
<div class="aoe-target-left">
<span class="aoe-target-job role-${t.role}">${abbr(t.type)}</span>
${dead && t.overkill > 0 ? `<span class="aoe-target-overkill">-${fmtDmg(t.overkill)}</span>` : ''}
</div>
<div class="aoe-target-body">
<div class="aoe-target-row">
<span class="aoe-target-name">${t.name}</span>
<span class="aoe-target-dmg">${fmtDmg(t.amount)}${t.absorbed > 0 ? ` <span class="aoe-target-absorbed">+${fmtDmg(t.absorbed)}</span>` : ''}</span>
</div>
${hpBar}
</div>
</div>
${(mitigIcons || missingIcons) ? `<div class="aoe-target-buffs">${mitigIcons}${missingIcons}</div>` : ''}
</div>`;
}).join('');
// Reference row
let refHtml = '';
if (refEv) {
const refVisible = refEv.targets.filter(t =>
!hiddenPlayerNames.has(t.name) &&
(!playerFilter || t.name.toLowerCase().includes(playerFilter))
);
if (refVisible.length) {
const currentByName = {};
ev.targets.forEach(t => { currentByName[t.name] = t; });
const seenRefDebuffNames = new Set();
const refDebuffIconsHtml = refVisible.flatMap(t => (t.mitigations ?? []))
.filter(m => m.buffType === 'debuff' && !seenRefDebuffNames.has(m.name) && seenRefDebuffNames.add(m.name))
.map(m => {
const iconSrc = MITIG_ICONS[m.name];
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
}).join('');
const refCards = refVisible.map(t => {
const curr = currentByName[t.name];
const diff = curr ? curr.amount - t.amount : 0;
const dead = t.hp === 0 && t.maxHp > 0;
const deltaHtml = diff !== 0
? `<span class="${diff > 0 ? 'aoe-delta-worse' : 'aoe-delta-better'}">${diff > 0 ? '+' : '-'}${fmtDmg(Math.abs(diff))}</span>`
: '';
const refMitigIcons = (t.mitigations ?? []).filter(m => m.buffType !== 'debuff').map(m => {
const iconSrc = MITIG_ICONS[m.name];
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
}).join('');
return `
<div class="aoe-target-wrap">
<div class="aoe-ref-target${dead ? ' aoe-target--dead' : ''}">
<div class="aoe-target-left">
<span class="aoe-target-job role-${t.role}">${abbr(t.type)}</span>
${deltaHtml}
</div>
<span class="aoe-target-name">${t.name}</span>
<span class="aoe-target-dmg">${fmtDmg(t.amount)}${t.absorbed > 0 ? ` <span class="aoe-target-absorbed">+${fmtDmg(t.absorbed)}</span>` : ''}</span>
</div>
${refMitigIcons ? `<div class="aoe-target-buffs">${refMitigIcons}</div>` : ''}
</div>`;
}).join('');
const totalDiff = ev.totalDamage - refEv.totalDamage;
const totalDelta = totalDiff !== 0
? `<span class="${totalDiff > 0 ? 'aoe-delta-worse' : 'aoe-delta-better'}">${totalDiff > 0 ? '+' : ''}${fmtDmg(totalDiff)}</span>`
: '';
refHtml = `
<div class="aoe-ref-row">
<span class="aoe-ref-label">REF ${fmtDmg(refEv.totalDamage)} ${totalDelta} ${refDebuffIconsHtml}</span>
<div class="aoe-targets">${refCards}</div>
</div>`;
}
}
return `
<div class="aoe-event">
<div class="aoe-time">${fmtTime(ev.timestamp, fightStart)}</div>
<div>
<div class="aoe-ability">
${ev.abilityName}
<span class="aoe-total">— ${fmtDmg(ev.totalDamage)} total</span>
${debuffIconsHtml}
</div>
<div class="aoe-targets">${targets}</div>
${refHtml}
</div>
</div>
`;
}).join('');
el.innerHTML = rows || '<div class="empty" style="padding:30px 0"><h3>Keine sichtbaren Targets</h3></div>';
}
function setEmpty(msg) {
document.getElementById('analysis-loading').style.display = 'none';
document.getElementById('analysis-content').style.display = 'none';
document.getElementById('analysis-empty').style.display = 'block';
document.getElementById('analysis-empty-msg').textContent = msg;
}
let lastFightId = null;
async function load() {
const { reportCode, fightId, fightStart, fightEnd } = window.App ?? {};
if (!reportCode || !fightId) return;
if (lastFightId === fightId) return;
document.getElementById('analysis-loading').style.display = 'flex';
document.getElementById('analysis-empty').style.display = 'none';
document.getElementById('analysis-content').style.display = 'none';
let json;
try {
const res = await fetch('api/analysis.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ report_code: reportCode, fight_id: fightId, start_time: fightStart, end_time: fightEnd }),
});
json = await res.json();
} catch (err) {
setEmpty('Netzwerkfehler: ' + err.message);
return;
}
if (json.reauth) { window.location.href = 'auth/start.php'; return; }
if (json.error) { setEmpty('Fehler: ' + json.error); return; }
lastFightId = fightId;
setupPhases(window.App?.phases ?? []);
renderPlayers(json.players ?? []);
renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart);
document.getElementById('analysis-loading').style.display = 'none';
document.getElementById('analysis-content').style.display = 'block';
}
window.analysisTab = {
onFightSelected: load,
onTabOpen: load,
onFightsLoaded: onFightsLoaded,
reset() {
lastFightId = null;
refEvents = [];
refFightStart = 0;
extFights = [];
extReportCode = '';
refFightSelect.value = '';
refFightSelect.style.display = 'none';
refExtFightSelect.value = '';
refExtFightSelect.style.display = 'none';
refExtPanel.style.display = 'none';
},
};
})();