From bcbf14eb81000f1ae3e4e8fd8b29650a7b589a4d Mon Sep 17 00:00:00 2001 From: Akurosia Kamo Date: Mon, 8 Jun 2026 18:12:15 +0200 Subject: [PATCH] add compare to livelog ability --- api/analysis.php | 46 +++- api/cache.php | 2 +- api/fight.php | 3 +- css/planner.css | 437 ++++++++++++++++++++++++++++++++++++++ js/planner.js | 518 ++++++++++++++++++++++++++++++++++++++++++++- templates/page.php | 45 ++++ 6 files changed, 1043 insertions(+), 8 deletions(-) diff --git a/api/analysis.php b/api/analysis.php index 3b29db0..43ce5f3 100644 --- a/api/analysis.php +++ b/api/analysis.php @@ -18,6 +18,7 @@ $startTime = (float)($_POST['start_time'] ?? 0); $endTime = (float)($_POST['end_time'] ?? 0); $language = strtolower(trim($_POST['language'] ?? 'en')); $language = in_array($language, ['en', 'de', 'fr', 'jp'], true) ? $language : 'en'; +$noCache = filter_var($_POST['no_cache'] ?? false, FILTER_VALIDATE_BOOLEAN); $translate = 'true'; if (!$reportCode || !$fightId || !$endTime) { @@ -27,7 +28,7 @@ if (!$reportCode || !$fightId || !$endTime) { } $cacheParts = [$fightId, (int)$startTime, (int)$endTime]; -$cached = read_cached_log('analysis', $reportCode, $language, $cacheParts); +$cached = $noCache ? null : read_cached_log('analysis', $reportCode, $language, $cacheParts); if ($cached !== null) { echo $cached; exit; @@ -196,8 +197,11 @@ function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourc 'buffType' => $mitigIdMap[$id]['buffType'], 'extraAbilityGameID' => $mitigIdMap[$id]['extraAbilityGameID'] ?? null, ]; - $source = findBuffSourcePlayer($buffSourceTimeline, $id, $ts, $players); - if ($source) $entry['sourcePlayerType'] = $source['type']; + $source = findBuffSourceEntry($buffSourceTimeline, $id, $ts, $players); + if ($source) { + $entry['sourcePlayerType'] = $source['player']['type']; + $entry['appliedAt'] = $source['apply']; + } $result[] = $entry; } } @@ -205,7 +209,7 @@ function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourc } // Findet den Spieler der einen Buff zum Zeitpunkt $ts gecastet hat (anhand der applybuff-Timeline). -function findBuffSourcePlayer(array $sourceTimeline, int $statusId, float $ts, array $players): ?array { +function findBuffSourceEntry(array $sourceTimeline, int $statusId, float $ts, array $players): ?array { $best = null; foreach ($sourceTimeline[$statusId] ?? [] as $entry) { if ($entry['apply'] > $ts + 200) continue; // noch nicht aktiv @@ -213,7 +217,11 @@ function findBuffSourcePlayer(array $sourceTimeline, int $statusId, float $ts, a if ($best === null || $entry['apply'] > $best['apply']) $best = $entry; } if ($best === null || empty($best['sourceId'])) return null; - return $players[$best['sourceId']] ?? null; + if (!isset($players[$best['sourceId']])) return null; + return [ + 'player' => $players[$best['sourceId']], + 'apply' => $best['apply'], + ]; } // Fallback for shields consumed by a hit: the damage event's buffs field no @@ -233,6 +241,7 @@ function shieldsActiveAt(array $shieldTimeline, int $targetId, float $ts, array 'dr' => $m['dr'], 'buffType' => $m['buffType'], 'extraAbilityGameID' => $m['extraAbilityGameID'] ?? null, + 'appliedAt' => $iv['apply'], ]; } break; @@ -653,10 +662,37 @@ foreach ($clusters as $group) { usort($bossEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']); usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']); +$mitigationCasts = []; +$castSeen = []; +foreach ($buffSourceTimeline as $statusId => $entries) { + if (!isset($mitigIdMap[$statusId])) continue; + $meta = $mitigIdMap[$statusId]; + foreach ($entries as $entry) { + $sourceId = (int)($entry['sourceId'] ?? 0); + $apply = (float)($entry['apply'] ?? 0); + if ($sourceId <= 0 || $apply <= 0 || !isset($players[$sourceId])) continue; + $dedupeKey = $statusId . ':' . $sourceId . ':' . (int)round($apply / 100); + if (isset($castSeen[$dedupeKey])) continue; + $castSeen[$dedupeKey] = true; + $mitigationCasts[] = [ + 'key' => $meta['key'] ?? $meta['name'], + 'name' => $meta['name'], + 'buffType' => $meta['buffType'], + 'sourcePlayerType' => $players[$sourceId]['type'] ?? '', + 'sourceName' => $players[$sourceId]['name'] ?? '', + 'timestamp' => $apply, + 'remove' => $entry['remove'] ?? null, + 'extraAbilityGameID' => $meta['extraAbilityGameID'] ?? null, + ]; + } +} +usort($mitigationCasts, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']); + $response = json_encode([ 'players' => array_values($players), 'boss_events' => $bossEvents, 'aoe_events' => $aoeEvents, + 'mitigation_casts' => $mitigationCasts, 'fight_start' => (int)$startTime, 'mitigation_names' => $mitigationNames, ]); diff --git a/api/cache.php b/api/cache.php index fef8666..ab3f8f8 100644 --- a/api/cache.php +++ b/api/cache.php @@ -2,7 +2,7 @@ declare(strict_types=1); const CACHED_LOG_DIR = __DIR__ . '/../cached_logs'; -const CACHED_LOG_VERSION = 'v12'; +const CACHED_LOG_VERSION = 'v14'; function cache_language(string $language): string { $language = strtolower(trim($language)); diff --git a/api/fight.php b/api/fight.php index 3abcb40..a8a172c 100644 --- a/api/fight.php +++ b/api/fight.php @@ -15,6 +15,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? ''); $language = strtolower(trim($_POST['language'] ?? 'en')); $language = in_array($language, ['en', 'de', 'fr', 'jp'], true) ? $language : 'en'; +$noCache = filter_var($_POST['no_cache'] ?? false, FILTER_VALIDATE_BOOLEAN); if (strlen($reportCode) < 1) { http_response_code(400); @@ -22,7 +23,7 @@ if (strlen($reportCode) < 1) { exit; } -$cached = read_cached_log('fight', $reportCode, $language); +$cached = $noCache ? null : read_cached_log('fight', $reportCode, $language); if ($cached !== null) { echo $cached; exit; diff --git a/css/planner.css b/css/planner.css index 48d6543..f55d9ac 100644 --- a/css/planner.css +++ b/css/planner.css @@ -143,6 +143,14 @@ color: var(--t3); } +.planner-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + /* ── Job Slots ────────────────────────────────────────────────────────────────── */ .job-slots-grid { display: flex; @@ -222,6 +230,435 @@ padding: 3px 6px; } +/* ── Compare Modal ──────────────────────────────────────────────────────────── */ +.planner-compare-modal-box { + display: flex; + flex-direction: column; + width: min(1680px, 98vw) !important; + height: 96vh !important; + max-width: none !important; + max-height: 96vh !important; + overflow: hidden; + padding: 0 !important; +} + +.compare-modal-header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 22px 26px 16px; + border-bottom: 1px solid var(--border); + background: var(--bgcard); +} + +.compare-modal-header .modal-title { + margin-bottom: 4px; +} + +.compare-modal-subtitle { + color: var(--t3); + font-size: 13px; +} + +.compare-modal-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + flex-shrink: 0; +} + +.compare-modal-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 18px 26px 14px; +} + +.compare-setup-grid { + display: grid; + grid-template-columns: minmax(360px, 0.8fr) minmax(0, 1.2fr); + gap: 14px; + align-items: end; + min-width: 0; +} + +.compare-setup-grid .modal-section { + margin: 0; + min-width: 0; +} + +.compare-setup-grid .name-import-input-row { + min-width: 0; +} + +.compare-setup-grid .name-import-input-row .btn { + flex: 0 0 auto; +} + +.compare-setup-grid input, +.compare-setup-grid select { + width: 100%; + min-width: 0; +} + +.compare-modal-footer { + flex: 0 0 auto; + position: sticky; + bottom: 0; + z-index: 8; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + margin-top: 0 !important; + padding: 14px 26px 18px; + border-top: 1px solid var(--border); + background: var(--bgcard); +} + +.planner-compare-result { margin-top: 16px; } + +.compare-show-ok-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--t2); + font-size: 12px; +} + +.compare-show-ok-toggle input { + width: auto; + margin: 0; +} + +.compare-live-header { + position: sticky; + top: 0; + z-index: 4; + display: grid; + grid-template-columns: minmax(180px, 1fr) auto; + gap: 14px; + align-items: center; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--r); + background: rgba(14,18,27,.96); +} + +.compare-live-header strong { + display: block; + color: var(--t1); + font-size: 15px; +} + +.compare-live-header span { + color: var(--t3); + font-size: 12px; +} + +.compare-summary { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 7px; +} + +.compare-stat { + display: inline-flex; + min-width: 58px; + flex-direction: column; + align-items: center; + gap: 1px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: var(--r); + background: var(--bg2); + color: var(--t2); +} + +.compare-stat strong { + color: inherit; + font-size: 15px; + line-height: 1; +} + +.compare-stat small { + color: inherit; + font-size: 10px; + text-transform: uppercase; +} + +.compare-list { + display: flex; + flex-direction: column; + gap: 7px; + margin-top: 10px; +} + +.compare-row { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + min-height: 68px; + border: 1px solid var(--border); + border-radius: var(--r); + background: rgba(255,255,255,.018); + overflow: hidden; +} + +.compare-row-status { + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; + padding: 10px; + border-right: 1px solid var(--border); + background: rgba(255,255,255,.03); + text-align: center; +} + +.compare-row-status strong { + color: var(--t1); + font-size: 12px; + text-transform: uppercase; +} + +.compare-row-status span { + color: var(--gold); + font-size: 13px; + font-weight: 700; +} + +.compare-row-body { + min-width: 0; + padding: 10px 12px; +} + +.compare-row-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + color: var(--t1); + font-size: 13px; +} + +.compare-row-title small { + color: var(--t3); + font-size: 11px; +} + +.compare-row-warning { + color: var(--red); + font-size: 12px; +} + +.compare-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.compare-column-label { + margin-bottom: 4px; + color: var(--t3); + font-size: 10px; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; +} + +.compare-chip-list { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.compare-status-line { + display: grid; + grid-template-columns: 68px minmax(0, 1fr); + align-items: start; + gap: 7px; + margin-bottom: 5px; + width: 100%; +} + +.compare-status-line:last-child { + margin-bottom: 0; +} + +.compare-status-line--no-label { + display: block; +} + +.compare-status-label { + align-self: center; + padding: 3px 5px; + border: 1px solid var(--border); + border-radius: var(--r); + background: rgba(255,255,255,.025); + color: var(--t3); + font-size: 10px; + font-weight: 700; + text-align: center; + text-transform: uppercase; +} + +.compare-status-label--missing { color: var(--red); border-color: rgba(224,92,92,.45); } +.compare-status-label--late { color: #ffb07a; border-color: rgba(255,145,75,.50); } +.compare-status-label--early { color: #ffd18a; border-color: rgba(255,190,90,.45); } +.compare-status-label--extra { color: var(--blue); border-color: rgba(74,158,255,.45); } +.compare-status-label--unknown { color: var(--t3); border-color: rgba(180,190,205,.35); } +.compare-status-label--ok { color: #8fd99e; border-color: rgba(88,180,116,.45); } + +.compare-mini-chip { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: 100%; + min-height: 25px; + padding: 3px 7px 3px 4px; + border: 1px solid var(--border); + border-radius: var(--r); + background: var(--bg2); + color: var(--t2); + font-size: 12px; +} + +.compare-mini-chip img { + width: 18px; + height: 18px; + object-fit: contain; + flex-shrink: 0; +} + +.compare-mini-chip strong { + color: var(--t1); + font-weight: 700; +} + +.compare-mini-chip span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compare-mini-chip small { + color: inherit; + font-size: 11px; + font-weight: 700; +} + +.compare-stat--ok, +.compare-mini-chip--ok, +.compare-row--ok .compare-row-status { + border-color: rgba(88,180,116,.45); + color: #8fd99e; +} + +.compare-stat--early, +.compare-mini-chip--early, +.compare-row--early .compare-row-status { + border-color: rgba(255,190,90,.50); + color: #ffd18a; +} + +.compare-stat--late, +.compare-mini-chip--late, +.compare-row--late .compare-row-status { + border-color: rgba(255,145,75,.55); + color: #ffb07a; +} + +.compare-stat--missing, +.compare-mini-chip--missing, +.compare-stat--unmatched, +.compare-row--missing .compare-row-status { + border-color: rgba(224,92,92,.55); + color: var(--red); +} + +.compare-stat--extra, +.compare-mini-chip--extra, +.compare-row--extra .compare-row-status { + border-color: rgba(74,158,255,.45); + color: var(--blue); +} + +.compare-stat--unknown, +.compare-mini-chip--unknown, +.compare-row--unknown .compare-row-status { + border-color: rgba(180,190,205,.35); + color: var(--t3); +} + +.compare-empty, +.compare-loading { + color: var(--t3); + font-size: 13px; +} + +.compare-empty--panel { + padding: 18px; + border: 1px solid var(--border); + border-radius: var(--r); + background: var(--bg2); + text-align: center; +} + +.compare-error { + padding: 10px 12px; + border: 1px solid rgba(224,92,92,.45); + border-radius: var(--r); + color: var(--red); + background: rgba(224,92,92,.08); + font-size: 13px; +} + +@media (max-width: 760px) { + .planner-compare-modal-box { + width: 98vw; + height: 94vh; + } + + .compare-modal-header, + .compare-modal-body, + .compare-modal-footer { + padding-left: 14px; + padding-right: 14px; + } + + .compare-modal-header { + align-items: flex-start; + flex-direction: column; + } + + .compare-modal-header-actions { + justify-content: flex-start; + width: 100%; + } + + .compare-setup-grid, + .compare-live-header, + .compare-columns, + .compare-row { + grid-template-columns: 1fr; + } + + .compare-row-status { + border-right: none; + border-bottom: 1px solid var(--border); + text-align: left; + } +} + /* ── Mechanic Cards ──────────────────────────────────────────────────────────── */ .mechanic-card { display: grid; diff --git a/js/planner.js b/js/planner.js index 905f35c..64640e7 100644 --- a/js/planner.js +++ b/js/planner.js @@ -431,7 +431,10 @@ function renderPlanDetail(plan) { ${escHtml(plan.name)} -
Erstellt ${fmtDate(plan.createdAt)} · ${visibleMechanics.length} Mechaniken
+
+
Erstellt ${fmtDate(plan.createdAt)} · ${visibleMechanics.length} Mechaniken
+ +
@@ -506,6 +509,9 @@ function renderPlanDetail(plan) { document.getElementById('name-import-open-btn')?.addEventListener('click', () => { showNameImportModal(plan.id); }); + document.getElementById('planner-compare-open-btn')?.addEventListener('click', () => { + showCompareModal(plan.id); + }); initTimelineOptions(plan.id); initTimeline(plan.id); initMechanicClicks(plan.id); @@ -3280,6 +3286,515 @@ function initImportModal() { }); } +// ── Plan Compare ────────────────────────────────────────────────────────────── + +const COMPARE_TIMING_TOLERANCE_MS = 3000; +let comparePlanId = null; +let compareReportCode = ''; +let compareFights = []; +let compareLastResult = null; +let compareLastPlan = null; + +function normalizeReportCodeInput(value) { + const text = String(value ?? '').trim(); + const match = text.match(/fflogs\.com\/reports\/([A-Za-z0-9]+)/i); + return (match ? match[1] : text).replace(/[^A-Za-z0-9]/g, ''); +} + +function fightOptionText(fight) { + const ms = Math.max(0, (fight.endTime ?? 0) - (fight.startTime ?? 0)); + const dur = `${Math.floor(ms / 60000)}:${String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')}`; + const hp = fight.kill ? 'Kill' : (fight.fightPercentage != null ? `${fight.fightPercentage.toFixed(2)}%` : '?'); + return `${fight.name} — ${dur} — ${hp}`; +} + +function collectActualMitigations(event) { + const result = []; + const seen = new Set(); + for (const target of event?.targets ?? []) { + for (const mitigation of target.mitigations ?? []) { + const ability = mitigation.key ?? mitigation.name; + const sourceJob = JOB_FROM_TYPE[mitigation.sourcePlayerType] ?? ''; + const key = `${ability}::${sourceJob || '*'}::${Math.round(Number(mitigation.appliedAt ?? 0))}`; + if (seen.has(key)) continue; + seen.add(key); + result.push({ + ability, + name: mitigation.name ?? ability, + sourceJob, + appliedAt: Number.isFinite(Number(mitigation.appliedAt)) ? Number(mitigation.appliedAt) : null, + buffType: mitigation.buffType ?? abilityDefinition(ability)?.buffType ?? 'buff', + }); + } + } + return result; +} + +function collectActualCasts(casts, actualFightStart) { + return (casts ?? []).map(cast => ({ + ability: cast.key ?? cast.name, + name: cast.name ?? cast.key, + sourceJob: JOB_FROM_TYPE[cast.sourcePlayerType] ?? '', + sourceName: cast.sourceName ?? '', + timestamp: Number(cast.timestamp) || 0, + rel: (Number(cast.timestamp) || 0) - actualFightStart, + buffType: cast.buffType ?? abilityDefinition(cast.key ?? cast.name)?.buffType ?? 'buff', + })).filter(cast => cast.ability && cast.timestamp > 0); +} + +function actualMatchesAssignment(actual, assignment) { + if ((actual.ability ?? actual.name) !== assignment.ability) return false; + if (!assignment.job || !actual.sourceJob) return true; + return actual.sourceJob === assignment.job; +} + +function findCastForAssignment(actualCasts, consumedCasts, assignment, plannedStart, mechanicTime) { + const durationMs = assignmentDurationSeconds(assignment) * 1000; + const earlyWindow = Math.max(15000, durationMs + 5000); + const lateWindow = Math.max(10000, mechanicTime - plannedStart + 5000); + const candidates = actualCasts + .map((actual, idx) => ({ actual, idx, delta: actual.rel - plannedStart })) + .filter(item => !consumedCasts.has(item.idx)) + .filter(item => actualMatchesAssignment(item.actual, assignment)) + .filter(item => item.delta >= -earlyWindow && item.delta <= lateWindow) + .sort((a, b) => Math.abs(a.delta) - Math.abs(b.delta)); + const match = candidates[0] ?? null; + if (match) consumedCasts.add(match.idx); + return match; +} + +function findActualMechanic(planMechanic, actualEvents, used, actualFightStart) { + const expected = Number(planMechanic.timestamp) || 0; + const abilityId = Number(planMechanic.abilityId ?? 0); + const name = String(planMechanic.name ?? '').trim().toLowerCase(); + const scored = actualEvents + .map((event, idx) => ({ event, idx, rel: (Number(event.timestamp) || 0) - actualFightStart })) + .filter(item => !used.has(item.idx)) + .map(item => { + const actualAbilityId = Number(item.event.abilityId ?? 0); + const actualName = String(item.event.abilityName ?? '').trim().toLowerCase(); + const idMatch = abilityId > 0 && actualAbilityId > 0 && abilityId === actualAbilityId; + const nameMatch = name !== '' && actualName === name; + const timeDiff = Math.abs(item.rel - expected); + const closeMatch = timeDiff <= 3000; + const score = (idMatch ? 0 : nameMatch ? 1 : closeMatch ? 2 : 99) * 100000 + + timeDiff; + return { ...item, idMatch, nameMatch, closeMatch, timeDiff, score }; + }) + .filter(item => ((item.idMatch || item.nameMatch) && item.timeDiff <= 30000) || item.closeMatch) + .sort((a, b) => a.score - b.score); + + const match = scored[0] ?? null; + if (match) used.add(match.idx); + return match?.event ?? null; +} + +function comparePlanToAnalysis(plan, actualEvents, actualFightStart, actualCastEvents = []) { + const usedMechanics = new Set(); + const actualCasts = collectActualCasts(actualCastEvents, actualFightStart); + const consumedCasts = new Set(); + const rows = []; + const summary = { ok: 0, early: 0, late: 0, missing: 0, extra: 0, unmatched: 0, unknown: 0 }; + + for (const mechanic of visiblePlanMechanics(plan)) { + const actualEvent = findActualMechanic(mechanic, actualEvents, usedMechanics, actualFightStart); + const planned = sortedAssignments(plannedAssignmentsForMechanic(plan, mechanic)); + if (!actualEvent) { + summary.unmatched += 1; + rows.push({ mechanic, status: 'unmatched', items: planned.map(assignment => ({ status: 'missing', assignment })), extras: [] }); + continue; + } + + const actualMitigations = collectActualMitigations(actualEvent); + const consumedActual = new Set(); + const items = planned.map(assignment => { + const plannedStart = Number(assignment.sourceStart ?? assignmentStartMs(mechanic, assignment)) || 0; + const castMatch = findCastForAssignment(actualCasts, consumedCasts, assignment, plannedStart, mechanic.timestamp); + if (!castMatch) { + summary.missing += 1; + return { status: 'missing', assignment }; + } + + const actualIdx = actualMitigations.findIndex((actual, idx) => + !consumedActual.has(idx) && actualMatchesAssignment(actual, assignment) + ); + if (actualIdx >= 0) consumedActual.add(actualIdx); + + const actual = actualIdx >= 0 ? actualMitigations[actualIdx] : castMatch.actual; + const delta = castMatch.delta; + const status = delta < -COMPARE_TIMING_TOLERANCE_MS + ? 'early' + : delta > COMPARE_TIMING_TOLERANCE_MS ? 'late' : 'ok'; + summary[status] += 1; + return { status, assignment, actual, delta, coveredHit: actualIdx >= 0 }; + }); + + const extras = actualMitigations + .map((actual, idx) => ({ actual, idx })) + .filter(item => !consumedActual.has(item.idx)); + summary.extra += extras.length; + rows.push({ mechanic, actualEvent, status: 'matched', items, extras }); + } + + return { summary, rows }; +} + +function compareStatusLabel(status) { + return { + ok: 'OK', + early: 'Früh', + late: 'Spät', + missing: 'Fehlt', + extra: 'Extra', + unmatched: 'Kein Hit', + unknown: 'Timing ?', + }[status] ?? status; +} + +function compareDeltaText(delta) { + if (delta == null) return ''; + const sec = Math.abs(delta / 1000).toFixed(1).replace(/\.0$/, ''); + return delta < 0 ? `${sec}s früh` : `${sec}s spät`; +} + +function compareRowIssueCount(row) { + if (row.status === 'unmatched') return 1; + return row.items.filter(item => item.status !== 'ok').length + row.extras.length; +} + +function compareRowVisible(row, showOk) { + return showOk || compareRowSeverity(row) !== 'ok'; +} + +function compareRowSeverity(row) { + if (row.status === 'unmatched') return 'missing'; + if (row.items.some(item => item.status === 'missing')) return 'missing'; + if (row.items.some(item => item.status === 'late')) return 'late'; + if (row.items.some(item => item.status === 'early')) return 'early'; + if (row.extras.length) return 'extra'; + if (row.items.some(item => item.status === 'unknown')) return 'unknown'; + return 'ok'; +} + +function compareStatusText(row) { + const severity = compareRowSeverity(row); + const count = compareRowIssueCount(row); + if (severity === 'ok') return 'OK'; + if (severity === 'missing') return row.status === 'unmatched' ? 'Kein Hit' : `${count} Problem${count === 1 ? '' : 'e'}`; + if (severity === 'extra') return `${count} Extra`; + return `${count} Check${count === 1 ? '' : 's'}`; +} + +function compareAssignmentChip(assignment, plan) { + const icon = abilityIcon(assignment.ability); + const name = assignmentAbilityName(assignment, plan); + return ` + + ${icon ? `` : ''} + ${escHtml(assignment.job || '?')} + ${escHtml(name)} + `; +} + +function compareActualChip(actual, plan, status = 'extra', delta = null, note = '') { + const icon = abilityIcon(actual.ability); + const name = localizedAbilityName(actual.ability, plan); + const deltaHtml = delta == null || status === 'ok' ? '' : `${escHtml(compareDeltaText(delta))}`; + const noteHtml = note ? `${escHtml(note)}` : ''; + return ` + + ${icon ? `` : ''} + ${escHtml(actual.sourceJob || '?')} + ${escHtml(name)} + ${deltaHtml} + ${noteHtml} + `; +} + +function compareGroupedLines(entries, emptyHtml, options = {}) { + const order = ['missing', 'late', 'early', 'extra', 'unknown', 'ok']; + const groups = new Map(); + for (const entry of entries) { + const status = entry.status || 'unknown'; + if (!groups.has(status)) groups.set(status, []); + groups.get(status).push(entry.html); + } + + const html = order + .filter(status => groups.has(status)) + .map(status => ` +
+ ${options.hideLabels ? '' : `
${escHtml(compareStatusLabel(status))}
`} +
${groups.get(status).join('')}
+
+ `).join(''); + return html || emptyHtml; +} + +function renderCompareResults(result, plan, showOk = false) { + const stat = result.summary; + const issueCount = stat.early + stat.late + stat.missing + stat.extra + stat.unmatched + stat.unknown; + const visibleRows = result.rows.filter(row => compareRowVisible(row, showOk)); + const summaryHtml = [ + ['missing', stat.missing + stat.unmatched, 'Fehlt'], + ['late', stat.late, 'Spät'], + ['early', stat.early, 'Früh'], + ['extra', stat.extra, 'Extra'], + ['unknown', stat.unknown, 'Timing ?'], + ['ok', stat.ok, 'OK'], + ].filter(([key, count]) => count > 0 && (showOk || key !== 'ok')) + .map(([key, count, label]) => `${count}${label}`) + .join(''); + + const rowsHtml = visibleRows.map(row => { + const severity = compareRowSeverity(row); + const visibleItems = showOk ? row.items : row.items.filter(item => item.status !== 'ok'); + const plannedEntries = visibleItems.map(item => ({ + status: item.status, + html: compareAssignmentChip(item.assignment, plan), + })); + const plannedHtml = compareGroupedLines( + plannedEntries, + row.extras.length ? 'OK ausgeblendet' : 'Nichts geplant', + { hideLabels: true } + ); + const actualEntries = [ + ...visibleItems.map(item => { + if (!item.actual) return { + status: 'missing', + html: ` + + ${escHtml(item.assignment.job || '?')} + ${escHtml(assignmentAbilityName(item.assignment, plan))} + `, + }; + return { + status: item.status, + html: compareActualChip(item.actual, plan, item.status, item.delta, item.coveredHit ? '' : 'nicht aktiv'), + }; + }), + ...row.extras.map(({ actual }) => ({ + status: 'extra', + html: compareActualChip(actual, plan, 'extra'), + })), + ]; + const actualHtml = compareGroupedLines(actualEntries, 'Keine erkannte Mitigation'); + + return ` +
+
+ ${escHtml(compareStatusText(row))} + ${escHtml(fmtTimestamp(row.mechanic.timestamp))} +
+
+
+ ${escHtml(row.mechanic.name)} + ${row.actualEvent?.abilityName ? `Log: ${escHtml(row.actualEvent.abilityName)}` : ''} + ${row.status === 'unmatched' ? 'Kein passender Hit im Log' : ''} +
+
+
+
Plan
+
${plannedHtml}
+
+
+
Log
+
${actualHtml}
+
+
+
+
`; + }).join(''); + + return ` +
+
+ ${issueCount ? `${issueCount} offene Checks` : 'Alles passt'} + ${showOk ? 'Alle Mechaniken sichtbar' : 'OK-Mechaniken ausgeblendet'} +
+
${summaryHtml}
+
+
${rowsHtml || '
Keine Probleme gefunden.
'}
+ `; +} + +function showCompareModal(planId) { + comparePlanId = planId; + const plan = getPlan(planId); + const modal = document.getElementById('planner-compare-modal'); + const reportInput = document.getElementById('compare-report-input'); + const fightSection = document.getElementById('compare-fight-section'); + const fightSelect = document.getElementById('compare-fight-select'); + const result = document.getElementById('compare-result'); + const runBtn = document.getElementById('compare-run-btn'); + const refreshBtn = document.getElementById('compare-refresh-btn'); + const showOkWrap = document.getElementById('compare-show-ok-wrap'); + const showOk = document.getElementById('compare-show-ok'); + if (!modal || !plan) return; + + compareReportCode = ''; + compareFights = []; + compareLastResult = null; + compareLastPlan = null; + reportInput.value = plan.source?.reportCode ?? window.App?.reportCode ?? ''; + fightSection.style.display = 'none'; + fightSelect.innerHTML = ''; + result.style.display = 'none'; + result.innerHTML = ''; + runBtn.style.display = 'none'; + refreshBtn.style.display = 'none'; + showOkWrap.style.display = 'none'; + showOk.checked = false; + modal.style.display = 'flex'; + reportInput.focus(); +} + +function hideCompareModal() { + document.getElementById('planner-compare-modal').style.display = 'none'; +} + +function initCompareModal() { + const modal = document.getElementById('planner-compare-modal'); + const reportInput = document.getElementById('compare-report-input'); + const loadBtn = document.getElementById('compare-load-btn'); + const fightSection = document.getElementById('compare-fight-section'); + const fightSelect = document.getElementById('compare-fight-select'); + const result = document.getElementById('compare-result'); + const runBtn = document.getElementById('compare-run-btn'); + const refreshBtn = document.getElementById('compare-refresh-btn'); + const showOkWrap = document.getElementById('compare-show-ok-wrap'); + const showOk = document.getElementById('compare-show-ok'); + const cancelBtn = document.getElementById('compare-cancel-btn'); + if (!modal || !reportInput || !loadBtn || !fightSelect || !runBtn || !refreshBtn || !showOkWrap || !showOk || !cancelBtn) return; + + const renderLastCompare = () => { + if (!compareLastResult || !compareLastPlan) return; + result.innerHTML = renderCompareResults(compareLastResult, compareLastPlan, showOk.checked); + result.style.display = ''; + showOkWrap.style.display = ''; + refreshBtn.style.display = ''; + }; + + const loadCompareFights = async (noCache = false) => { + const plan = getPlan(comparePlanId); + const code = normalizeReportCodeInput(reportInput.value); + if (!code) return false; + + loadBtn.disabled = true; + if (!noCache) loadBtn.textContent = 'Lädt…'; + if (!noCache) { + fightSection.style.display = 'none'; + result.style.display = 'none'; + runBtn.style.display = 'none'; + refreshBtn.style.display = 'none'; + showOkWrap.style.display = 'none'; + } + fightSelect.innerHTML = ''; + + try { + const res = await fetch('api/fight.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ report_code: code, language: plannerLanguage(), no_cache: noCache ? '1' : '0' }), + }); + const json = await res.json(); + if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return false; } + if (json.error) throw new Error(json.error); + compareReportCode = code; + compareFights = json?.data?.reportData?.report?.fights ?? []; + compareFights.forEach(fight => { + const opt = document.createElement('option'); + opt.value = fight.id; + opt.textContent = fightOptionText(fight); + fightSelect.appendChild(opt); + }); + const sourceFightId = plan?.source?.reportCode === code ? Number(plan.source?.fightId ?? 0) : 0; + if (sourceFightId && compareFights.some(f => Number(f.id) === sourceFightId)) { + fightSelect.value = String(sourceFightId); + runBtn.style.display = ''; + } + fightSection.style.display = ''; + return true; + } catch (err) { + result.innerHTML = `
${escHtml(err.message || 'Fights konnten nicht geladen werden.')}
`; + result.style.display = ''; + return false; + } finally { + loadBtn.disabled = false; + loadBtn.textContent = 'Fights laden'; + } + }; + + const runCompare = async (noCache = false) => { + const plan = getPlan(comparePlanId); + const fightId = parseInt(fightSelect.value, 10); + const fight = compareFights.find(f => Number(f.id) === fightId); + if (!plan || !fight || !compareReportCode) return; + + runBtn.disabled = true; + refreshBtn.disabled = true; + runBtn.textContent = noCache ? 'Refresh…' : 'Vergleicht…'; + result.innerHTML = `
${noCache ? 'Livelog wird aktualisiert…' : 'Log wird analysiert…'}
`; + result.style.display = ''; + + try { + const res = await fetch('api/analysis.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + report_code: compareReportCode, + fight_id: fight.id, + start_time: fight.startTime, + end_time: fight.endTime, + language: plannerLanguage(), + no_cache: noCache ? '1' : '0', + }), + }); + const json = await res.json(); + if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } + if (json.error) throw new Error(json.error); + compareLastPlan = plan; + compareLastResult = comparePlanToAnalysis(plan, json.aoe_events ?? [], Number(fight.startTime) || 0, json.mitigation_casts ?? []); + renderLastCompare(); + } catch (err) { + result.innerHTML = `
${escHtml(err.message || 'Vergleich fehlgeschlagen.')}
`; + } + + runBtn.disabled = false; + refreshBtn.disabled = false; + runBtn.textContent = 'Vergleichen'; + }; + + reportInput.addEventListener('input', () => { + const code = normalizeReportCodeInput(reportInput.value); + if (code && code !== reportInput.value.trim()) reportInput.value = code; + }); + + loadBtn.addEventListener('click', () => loadCompareFights(false)); + + fightSelect.addEventListener('change', () => { + runBtn.style.display = fightSelect.value ? '' : 'none'; + result.style.display = 'none'; + refreshBtn.style.display = 'none'; + showOkWrap.style.display = 'none'; + compareLastResult = null; + }); + + showOk.addEventListener('change', renderLastCompare); + runBtn.addEventListener('click', () => runCompare(false)); + refreshBtn.addEventListener('click', async () => { + const selectedFightId = fightSelect.value; + const loaded = await loadCompareFights(true); + if (selectedFightId && compareFights.some(f => String(f.id) === String(selectedFightId))) { + fightSelect.value = selectedFightId; + } + if (loaded) await runCompare(true); + }); + + cancelBtn.addEventListener('click', hideCompareModal); + modal.addEventListener('click', e => { if (e.target === modal) hideCompareModal(); }); +} + // ── Name Import Modal ───────────────────────────────────────────────────────── let nameImportPlanId = null; @@ -3498,6 +4013,7 @@ document.addEventListener('DOMContentLoaded', () => { initImportModal(); initAbilityModal(); initNameImportModal(); + initCompareModal(); activePlanId = localStorage.getItem(PLANNER_ACTIVE_KEY); renderPlanList(); if (activePlanId && getPlan(activePlanId)) { diff --git a/templates/page.php b/templates/page.php index 79c630e..8b6d52c 100644 --- a/templates/page.php +++ b/templates/page.php @@ -80,6 +80,51 @@ + + +