add compare to livelog ability

This commit is contained in:
Akurosia Kamo 2026-06-08 18:12:15 +02:00
parent f6078542ef
commit bcbf14eb81
6 changed files with 1043 additions and 8 deletions

View File

@ -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,
]);

View File

@ -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));

View File

@ -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;

View File

@ -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;

View File

@ -431,7 +431,10 @@ function renderPlanDetail(plan) {
<span id="plan-name-display" class="plan-name-text">${escHtml(plan.name)}</span>
<button id="plan-name-edit-btn" class="plan-btn" title="Umbenennen"></button>
</div>
<div class="planner-header-actions">
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} &middot; ${visibleMechanics.length} Mechaniken</div>
<button id="planner-compare-open-btn" class="btn btn-sm">Compare Log</button>
</div>
</div>
</div>
@ -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 `
<span class="compare-mini-chip">
${icon ? `<img src="${escHtml(icon)}" alt="">` : ''}
<strong>${escHtml(assignment.job || '?')}</strong>
<span>${escHtml(name)}</span>
</span>`;
}
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' ? '' : `<small>${escHtml(compareDeltaText(delta))}</small>`;
const noteHtml = note ? `<small>${escHtml(note)}</small>` : '';
return `
<span class="compare-mini-chip compare-mini-chip--${escHtml(status)}">
${icon ? `<img src="${escHtml(icon)}" alt="">` : ''}
<strong>${escHtml(actual.sourceJob || '?')}</strong>
<span>${escHtml(name)}</span>
${deltaHtml}
${noteHtml}
</span>`;
}
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 => `
<div class="compare-status-line compare-status-line--${escHtml(status)}${options.hideLabels ? ' compare-status-line--no-label' : ''}">
${options.hideLabels ? '' : `<div class="compare-status-label compare-status-label--${escHtml(status)}">${escHtml(compareStatusLabel(status))}</div>`}
<div class="compare-chip-list">${groups.get(status).join('')}</div>
</div>
`).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]) => `<span class="compare-stat compare-stat--${key}"><strong>${count}</strong><small>${label}</small></span>`)
.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 ? '<span class="compare-empty">OK ausgeblendet</span>' : '<span class="compare-empty">Nichts geplant</span>',
{ hideLabels: true }
);
const actualEntries = [
...visibleItems.map(item => {
if (!item.actual) return {
status: 'missing',
html: `
<span class="compare-mini-chip compare-mini-chip--missing">
<strong>${escHtml(item.assignment.job || '?')}</strong>
<span>${escHtml(assignmentAbilityName(item.assignment, plan))}</span>
</span>`,
};
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, '<span class="compare-empty">Keine erkannte Mitigation</span>');
return `
<div class="compare-row compare-row--${escHtml(severity)}">
<div class="compare-row-status">
<strong>${escHtml(compareStatusText(row))}</strong>
<span>${escHtml(fmtTimestamp(row.mechanic.timestamp))}</span>
</div>
<div class="compare-row-body">
<div class="compare-row-title">
<strong>${escHtml(row.mechanic.name)}</strong>
${row.actualEvent?.abilityName ? `<small>Log: ${escHtml(row.actualEvent.abilityName)}</small>` : ''}
${row.status === 'unmatched' ? '<span class="compare-row-warning">Kein passender Hit im Log</span>' : ''}
</div>
<div class="compare-columns">
<div>
<div class="compare-column-label">Plan</div>
<div class="compare-chip-list">${plannedHtml}</div>
</div>
<div>
<div class="compare-column-label">Log</div>
<div class="compare-chip-list">${actualHtml}</div>
</div>
</div>
</div>
</div>`;
}).join('');
return `
<div class="compare-live-header">
<div>
<strong>${issueCount ? `${issueCount} offene Checks` : 'Alles passt'}</strong>
<span>${showOk ? 'Alle Mechaniken sichtbar' : 'OK-Mechaniken ausgeblendet'}</span>
</div>
<div class="compare-summary">${summaryHtml}</div>
</div>
<div class="compare-list">${rowsHtml || '<div class="compare-empty compare-empty--panel">Keine Probleme gefunden.</div>'}</div>
`;
}
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 = '<option value="">— Fight auswählen —</option>';
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 = '<option value="">— Fight auswählen —</option>';
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 = `<div class="compare-error">${escHtml(err.message || 'Fights konnten nicht geladen werden.')}</div>`;
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 = `<div class="compare-loading">${noCache ? 'Livelog wird aktualisiert…' : 'Log wird analysiert…'}</div>`;
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 = `<div class="compare-error">${escHtml(err.message || 'Vergleich fehlgeschlagen.')}</div>`;
}
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)) {

View File

@ -80,6 +80,51 @@
</div>
</div>
<!-- Planner Compare Modal -->
<div id="planner-compare-modal" class="modal-overlay" style="display:none">
<div class="modal-box planner-compare-modal-box">
<div class="compare-modal-header">
<div>
<div class="modal-title">Plan mit FFLogs vergleichen</div>
<div class="compare-modal-subtitle">Schnellcheck für live Logs: fehlende, frühe, späte und extra Mitigations.</div>
</div>
<div class="compare-modal-header-actions">
<label class="compare-show-ok-toggle" id="compare-show-ok-wrap" style="display:none">
<input type="checkbox" id="compare-show-ok">
<span>OK anzeigen</span>
</label>
<button id="compare-run-btn" class="btn btn-gold" style="display:none">Vergleichen</button>
<button id="compare-refresh-btn" class="btn" style="display:none">Refresh live log</button>
</div>
</div>
<div class="compare-modal-body">
<div class="compare-setup-grid">
<div class="modal-section">
<div class="modal-label">Report-Code</div>
<div class="name-import-input-row">
<input type="text" id="compare-report-input" placeholder="Report-Code oder URL…">
<button id="compare-load-btn" class="btn btn-sm">Fights laden</button>
</div>
</div>
<div class="modal-section" id="compare-fight-section" style="display:none">
<div class="modal-label">Fight</div>
<select id="compare-fight-select">
<option value=""> Fight auswählen </option>
</select>
</div>
</div>
<div id="compare-result" class="planner-compare-result" style="display:none"></div>
</div>
<div class="modal-actions compare-modal-footer">
<button id="compare-cancel-btn" class="btn">Schließen</button>
</div>
</div>
</div>
<!-- Name Import Modal (Planner) -->
<div id="planner-name-import-modal" class="modal-overlay" style="display:none">
<div class="modal-box">