diff --git a/api/analysis.php b/api/analysis.php index 282d02f..3fcefbc 100644 --- a/api/analysis.php +++ b/api/analysis.php @@ -333,6 +333,7 @@ if (!empty($shieldStatusIds)) { // Group events by abilityId, then cluster by time proximity (≤ 1000ms from // the first event in the cluster) to avoid fixed-window boundary splits. const CLUSTER_WINDOW_MS = 1000; +const HEAVY_TANKBUSTER_MIN_HP_RATIO = 0.33; $byAbility = []; // abilityId → [{ts, tgtId, amount, hp, maxHp, buffs, name}] foreach ($allEvents as $ev) { @@ -404,7 +405,26 @@ foreach ($byAbility as $abId => $events) { $aoeEvents = []; foreach ($clusters as $group) { - if (count($group['targets']) < 3) continue; + $targetCount = count($group['targets']); + $isHeavyTankbuster = false; + if ($targetCount < 3) { + foreach ($group['targets'] as $tgtId => $tgt) { + $p = $players[$tgtId] ?? null; + if (($p['role'] ?? null) !== 'tank') continue; + + $tankMaxHp = (int)($tgt['maxHp'] ?? 0); + $rawDamage = max( + (int)($tgt['unmitigatedAmount'] ?? 0), + (int)($tgt['amount'] ?? 0) + (int)($tgt['absorbed'] ?? 0) + (int)($tgt['mitigated'] ?? 0) + ); + if ($tankMaxHp > 0 && $rawDamage >= $tankMaxHp * HEAVY_TANKBUSTER_MIN_HP_RATIO) { + $isHeavyTankbuster = true; + break; + } + } + } + + if ($targetCount < 3 && !$isHeavyTankbuster) continue; $targets = []; foreach ($group['targets'] as $tgtId => $tgt) { @@ -443,11 +463,12 @@ foreach ($clusters as $group) { }); $aoeEvents[] = [ - 'timestamp' => $group['timestamp'], - 'abilityId' => $group['abilityId'], - 'abilityName' => $group['abilityName'], - 'targets' => $targets, - 'totalDamage' => array_sum(array_column($targets, 'amount')), + 'timestamp' => $group['timestamp'], + 'abilityId' => $group['abilityId'], + 'abilityName' => $group['abilityName'], + 'targets' => $targets, + 'totalDamage' => array_sum(array_column($targets, 'amount')), + 'isHeavyTankbuster' => $isHeavyTankbuster, ]; } usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']); diff --git a/auth/callback.php b/auth/callback.php index 795a115..b96897d 100644 --- a/auth/callback.php +++ b/auth/callback.php @@ -2,10 +2,17 @@ require_once __DIR__ . '/../config.php'; session_start_safe(); +function redirect_with_error(string $returnPath, string $error): void { + $separator = str_contains($returnPath, '?') ? '&' : '?'; + header('Location: ' . $returnPath . $separator . 'error=' . rawurlencode($error)); + exit; +} + +$returnPath = safe_return_path($_SESSION['oauth_return'] ?? null); + // user denied access if (isset($_GET['error'])) { - header('Location: ../index.php?error=' . urlencode($_GET['error'])); - exit; + redirect_with_error($returnPath, $_GET['error']); } // CSRF check @@ -21,12 +28,11 @@ if ( } if (empty($_GET['code'])) { - header('Location: ../index.php?error=missing_code'); - exit; + redirect_with_error($returnPath, 'missing_code'); } $verifier = $_SESSION['pkce_verifier']; -unset($_SESSION['pkce_verifier'], $_SESSION['oauth_state']); +unset($_SESSION['pkce_verifier'], $_SESSION['oauth_state'], $_SESSION['oauth_return']); $post = http_build_query([ 'grant_type' => 'authorization_code', @@ -56,12 +62,11 @@ if ($curlError || $status !== 200 || empty($data['access_token'])) { 'http_status' => $status, 'response_body' => $body, ]; - header('Location: ../index.php?error=token_failed'); - exit; + redirect_with_error($returnPath, 'token_failed'); } $_SESSION['access_token'] = $data['access_token']; $_SESSION['token_expires'] = time() + ($data['expires_in'] ?? 3600); -header('Location: ../index.php'); +header('Location: ' . $returnPath); exit; diff --git a/auth/start.php b/auth/start.php index fa39fda..a3f96da 100644 --- a/auth/start.php +++ b/auth/start.php @@ -8,6 +8,7 @@ $state = bin2hex(random_bytes(16)); $_SESSION['pkce_verifier'] = $verifier; $_SESSION['oauth_state'] = $state; +$_SESSION['oauth_return'] = safe_return_path($_GET['return'] ?? ($_SERVER['HTTP_REFERER'] ?? null)); $params = http_build_query([ 'response_type' => 'code', diff --git a/js/analysis.js b/js/analysis.js index 73ac170..a252c0e 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -77,6 +77,20 @@ return `${min}:${sec}`; } + function normalizeFightName(name) { + return String(name ?? '').trim().toLowerCase(); + } + + function currentFightName() { + const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId); + return normalizeFightName(fight?.name); + } + + function isSameFightName(fight) { + const name = currentFightName(); + return name !== '' && normalizeFightName(fight?.name) === name; + } + let hiddenPlayers = new Set(); let hiddenPlayerNames = new Set(); let lastEvents = []; @@ -265,7 +279,7 @@ let allSameReportFights = []; function populateRefFightSelect() { - const visible = allSameReportFights.filter(f => f.id !== window.App.fightId); + const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameFightName(f)); refFightSelect.innerHTML = ''; visible.forEach(f => { const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?'); @@ -288,8 +302,22 @@ const refExtPanel = document.getElementById('ref-ext-panel'); const refReportInput = document.getElementById('ref-report-input'); const refReportLoad = document.getElementById('ref-report-load'); + const refFflogsLink = document.getElementById('ref-fflogs-report-link'); const refExtFightSelect = document.getElementById('ref-ext-fight-select'); + function updateRefFflogsLink(fightId = 0) { + if (!extReportCode) { + refFflogsLink.style.display = 'none'; + refFflogsLink.href = '#'; + return; + } + + refFflogsLink.href = window.App?.fflogsReportUrl + ? window.App.fflogsReportUrl(extReportCode, fightId) + : `https://www.fflogs.com/reports/${encodeURIComponent(extReportCode)}${fightId ? `#fight=${fightId}` : ''}`; + refFflogsLink.style.display = ''; + } + refReportInput.addEventListener('input', () => { const match = refReportInput.value.match(/fflogs\.com\/reports\/([A-Za-z0-9]+)/); if (match) refReportInput.value = match[1]; @@ -313,13 +341,14 @@ body: new URLSearchParams({ report_code: code, language: window.App.language }), }); const json = await res.json(); - if (json.reauth) { window.location.href = 'auth/start.php'; return; } + if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } const fights = json?.data?.reportData?.report?.fights ?? []; extFights = fights; extReportCode = code; + updateRefFflogsLink(); - const visibleExt = fights; + const visibleExt = fights.filter(isSameFightName); refExtFightSelect.innerHTML = ''; visibleExt.forEach(f => { const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?'); @@ -330,8 +359,9 @@ }); refExtFightSelect.style.display = visibleExt.length ? '' : 'none'; refExtPanel.style.display = ''; - if (preferredFightId) { + if (preferredFightId && visibleExt.some(f => f.id === preferredFightId)) { refExtFightSelect.value = String(preferredFightId); + updateRefFflogsLink(preferredFightId); await loadExternalCompare(preferredFightId); } } catch { } @@ -392,7 +422,9 @@ } refExtFightSelect.addEventListener('change', async () => { - await loadExternalCompare(parseInt(refExtFightSelect.value, 10)); + const refId = parseInt(refExtFightSelect.value, 10) || 0; + updateRefFflogsLink(refId); + await loadExternalCompare(refId); }); // ── Timeline rendering ──────────────────────────────────────────────────── @@ -427,6 +459,7 @@ !hiddenPlayers.has(t.id) && (!playerFilter || t.name.toLowerCase().includes(playerFilter)) ); + if (ev.isHeavyTankbuster && !visibleTargets.some(t => t.role === 'tank')) return ''; if (!visibleTargets.length) return ''; // Collect boss debuffs (Reprisal/Feint/Addle) once at event level @@ -643,7 +676,7 @@ return; } - if (json.reauth) { window.location.href = 'auth/start.php'; return; } + if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } if (json.error) { setEmpty('Fehler: ' + json.error); return; } lastFightId = fightId; @@ -698,6 +731,8 @@ refFightSelect.style.display = 'none'; refExtFightSelect.value = ''; refExtFightSelect.style.display = 'none'; + refFflogsLink.style.display = 'none'; + refFflogsLink.href = '#'; refExtPanel.style.display = 'none'; const exportBtn = document.getElementById('export-to-planner-btn'); if (exportBtn) exportBtn.style.display = 'none'; diff --git a/js/app.js b/js/app.js index 3389b1a..0c7b0f7 100644 --- a/js/app.js +++ b/js/app.js @@ -7,6 +7,7 @@ document.addEventListener('DOMContentLoaded', () => { const initialHint = document.getElementById('initial-hint'); const fightSelectCard = document.getElementById('fight-select-card'); const fightSelect = document.getElementById('fight-select'); + const fflogsReportLink = document.getElementById('fflogs-report-link'); const languageSelect = document.getElementById('language-select'); const explorerCard = document.getElementById('event-explorer-card'); const exLoadBtn = document.getElementById('ex-load-btn'); @@ -59,6 +60,36 @@ document.addEventListener('DOMContentLoaded', () => { } window.App.setUrlState = setUrlState; + function authStartUrl() { + return 'auth/start.php?return=' + encodeURIComponent(window.location.pathname + window.location.search); + } + window.App.authStartUrl = authStartUrl; + + function fflogsReportUrl(reportCode, fightId = 0) { + const code = String(reportCode || '').trim(); + if (!code) return '#'; + + const host = { + de: 'de.fflogs.com', + fr: 'fr.fflogs.com', + jp: 'ja.fflogs.com', + }[window.App.language] ?? 'www.fflogs.com'; + const fight = parseInt(fightId, 10) || 0; + return `https://${host}/reports/${encodeURIComponent(code)}${fight ? `#fight=${fight}` : ''}`; + } + window.App.fflogsReportUrl = fflogsReportUrl; + + function updateFflogsReportLink() { + if (!window.App.reportCode) { + fflogsReportLink.style.display = 'none'; + fflogsReportLink.href = '#'; + return; + } + + fflogsReportLink.href = fflogsReportUrl(window.App.reportCode, window.App.fightId); + fflogsReportLink.style.display = ''; + } + const initialUrlState = getUrlState(); const storedLanguage = localStorage.getItem('ff14-mitigator-language'); const legacyTranslateLanguage = initialUrlState.translate === '1' ? 'de' : 'en'; @@ -114,6 +145,7 @@ document.addEventListener('DOMContentLoaded', () => { window.App.fightStart = fight.startTime; window.App.fightEnd = fight.endTime; window.App.phases = buildPhases(fight); + updateFflogsReportLink(); displayFight(fight); explorerCard.style.display = 'block'; @@ -210,7 +242,7 @@ document.addEventListener('DOMContentLoaded', () => { body: new URLSearchParams(params), }); const json = await res.json(); - if (json.reauth) { window.location.href = 'auth/start.php'; return; } + if (json.reauth) { window.location.href = authStartUrl(); return; } output.textContent = JSON.stringify(json, null, 2); } catch (err) { output.textContent = '// Fehler: ' + err.message; @@ -224,6 +256,8 @@ document.addEventListener('DOMContentLoaded', () => { fightSelectCard.style.display = 'none'; explorerCard.style.display = 'none'; fightSelect.innerHTML = ''; + fflogsReportLink.style.display = 'none'; + fflogsReportLink.href = '#'; allFights = []; window.App.reportCode = reportCode; @@ -258,7 +292,7 @@ document.addEventListener('DOMContentLoaded', () => { if (json.reauth) { output.textContent = '// session expired — redirecting...'; - setTimeout(() => { window.location.href = 'auth/start.php'; }, 1500); + setTimeout(() => { window.location.href = authStartUrl(); }, 1500); return; } @@ -291,6 +325,7 @@ document.addEventListener('DOMContentLoaded', () => { window.App.fights = allFights; fightSelectCard.style.display = 'block'; + updateFflogsReportLink(); window.analysisTab?.onFightsLoaded?.(allFights); if (preferredFightId && selectFight(preferredFightId, true)) return; diff --git a/templates/fight-select.php b/templates/fight-select.php index da53a17..37db7ae 100644 --- a/templates/fight-select.php +++ b/templates/fight-select.php @@ -1,5 +1,8 @@ - Reconnect + Reconnect diff --git a/templates/tab-analysis.php b/templates/tab-analysis.php index dfb6d96..e1ad5ff 100644 --- a/templates/tab-analysis.php +++ b/templates/tab-analysis.php @@ -27,6 +27,7 @@