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
+
@@ -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}
+
+
+
+
+
`;
+ }).join('');
+
+ return `
+
+ ${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 @@
+
+
+
+
+
+
+
+
+
Report-Code
+
+
+
+
+
+
+
+
Fight
+
+
+
+
+
+
+
+
+
+
+