add missing boss skills by event or by time

add a recheck if delete of boss action is really wanted
toogle boss action to be a tankbuster or not
This commit is contained in:
Akurosia Kamo 2026-05-24 10:23:59 +02:00
parent b5445da02a
commit 1dfc727940
4 changed files with 275 additions and 15 deletions

View File

@ -455,6 +455,7 @@ foreach ($byAbility as $abId => $events) {
if ($current !== null) $clusters[] = $current; if ($current !== null) $clusters[] = $current;
} }
$bossEvents = [];
$aoeEvents = []; $aoeEvents = [];
foreach ($clusters as $group) { foreach ($clusters as $group) {
$targetCount = count($group['targets']); $targetCount = count($group['targets']);
@ -476,8 +477,6 @@ foreach ($clusters as $group) {
} }
} }
if ($targetCount < 3 && !$isHeavyTankbuster) continue;
$targets = []; $targets = [];
foreach ($group['targets'] as $tgtId => $tgt) { foreach ($group['targets'] as $tgtId => $tgt) {
$p = $players[$tgtId] ?? null; $p = $players[$tgtId] ?? null;
@ -514,7 +513,7 @@ foreach ($clusters as $group) {
return $roleCmp !== 0 ? $roleCmp : strcmp($a['name'], $b['name']); return $roleCmp !== 0 ? $roleCmp : strcmp($a['name'], $b['name']);
}); });
$aoeEvents[] = [ $bossEvent = [
'timestamp' => $group['timestamp'], 'timestamp' => $group['timestamp'],
'abilityId' => $group['abilityId'], 'abilityId' => $group['abilityId'],
'abilityName' => $group['abilityName'], 'abilityName' => $group['abilityName'],
@ -522,11 +521,18 @@ foreach ($clusters as $group) {
'totalDamage' => array_sum(array_column($targets, 'amount')), 'totalDamage' => array_sum(array_column($targets, 'amount')),
'isHeavyTankbuster' => $isHeavyTankbuster, 'isHeavyTankbuster' => $isHeavyTankbuster,
]; ];
$bossEvents[] = $bossEvent;
if ($targetCount < 3 && !$isHeavyTankbuster) continue;
$aoeEvents[] = $bossEvent;
} }
usort($bossEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']); usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
$response = json_encode([ $response = json_encode([
'players' => array_values($players), 'players' => array_values($players),
'boss_events' => $bossEvents,
'aoe_events' => $aoeEvents, 'aoe_events' => $aoeEvents,
'fight_start' => (int)$startTime, 'fight_start' => (int)$startTime,
'mitigation_names' => $mitigationNames, 'mitigation_names' => $mitigationNames,

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
const CACHED_LOG_DIR = __DIR__ . '/../cached_logs'; const CACHED_LOG_DIR = __DIR__ . '/../cached_logs';
const CACHED_LOG_VERSION = 'v2'; const CACHED_LOG_VERSION = 'v3';
function cache_language(string $language): string { function cache_language(string $language): string {
$language = strtolower(trim($language)); $language = strtolower(trim($language));

View File

@ -772,6 +772,20 @@
z-index: 9; z-index: 9;
} }
.timeline-boss-label {
border-top: none;
border-left: none;
border-radius: 0;
cursor: pointer;
font: inherit;
text-align: left;
width: 100%;
}
.timeline-boss-label:hover {
background: rgba(200,168,75,.08);
}
.timeline-track, .timeline-track,
.timeline-axis-track { .timeline-axis-track {
position: relative; position: relative;
@ -1092,6 +1106,8 @@
z-index: 300; z-index: 300;
min-width: 190px; min-width: 190px;
max-width: 280px; max-width: 280px;
max-height: min(520px, calc(100vh - 24px));
overflow-y: auto;
padding: 5px; padding: 5px;
border: 1px solid var(--borderem); border: 1px solid var(--borderem);
border-radius: var(--r); border-radius: var(--r);
@ -1125,6 +1141,23 @@
cursor: not-allowed; cursor: not-allowed;
} }
.timeline-menu-header:disabled {
opacity: 1;
padding-top: 9px;
padding-bottom: 4px;
color: var(--gold);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
cursor: default;
}
.timeline-menu-header:hover {
background: transparent;
color: var(--gold);
}
.timeline-menu-item img { .timeline-menu-item img {
width: 18px; width: 18px;
height: 18px; height: 18px;

View File

@ -177,7 +177,8 @@ async function ensureActionMetaLoaded() {
function sameMechanic(existing, incoming, source) { function sameMechanic(existing, incoming, source) {
const fightStart = source?.fightStart ?? 0; const fightStart = source?.fightStart ?? 0;
const incomingRel = incoming.timestamp - fightStart; const incomingTs = Number(incoming.timestamp);
const incomingRel = incomingTs >= fightStart ? incomingTs - fightStart : incomingTs;
if (existing.abilityId && incoming.abilityId && existing.abilityId === incoming.abilityId) { if (existing.abilityId && incoming.abilityId && existing.abilityId === incoming.abilityId) {
return Math.abs(existing.timestamp - incomingRel) < 1500; return Math.abs(existing.timestamp - incomingRel) < 1500;
} }
@ -994,7 +995,7 @@ function renderTimelineHtml(plan) {
<div class="timeline-scroll"> <div class="timeline-scroll">
<div class="timeline-grid" style="--timeline-width:${width}px"> <div class="timeline-grid" style="--timeline-width:${width}px">
<div class="timeline-row timeline-boss-row" style="--boss-row-height:${Math.max(52, 18 + laneCount * 30)}px"> <div class="timeline-row timeline-boss-row" style="--boss-row-height:${Math.max(52, 18 + laneCount * 30)}px">
<div class="timeline-row-label">Boss</div> <button class="timeline-row-label timeline-boss-label" type="button">Boss</button>
<div class="timeline-track" style="width:${width}px">${hitLines}${bossItems}</div> <div class="timeline-track" style="width:${width}px">${hitLines}${bossItems}</div>
</div> </div>
${playerRows} ${playerRows}
@ -1120,7 +1121,13 @@ function addTimelineAssignment(planId, mechanicId, ability, job, buffType, times
refreshMechanicList(planId); refreshMechanicList(planId);
} }
let timelineMenuCleanup = null;
function closeTimelineMenu() { function closeTimelineMenu() {
if (timelineMenuCleanup) {
timelineMenuCleanup();
timelineMenuCleanup = null;
}
document.getElementById('timeline-context-menu')?.remove(); document.getElementById('timeline-context-menu')?.remove();
} }
@ -1131,7 +1138,7 @@ function showTimelineMenu(x, y, items) {
menu.className = 'timeline-context-menu'; menu.className = 'timeline-context-menu';
menu.innerHTML = items.length menu.innerHTML = items.length
? items.map((item, idx) => ` ? items.map((item, idx) => `
<button class="timeline-menu-item${item.disabled ? ' disabled' : ''}" data-idx="${idx}"${item.disabled ? ' disabled' : ''}> <button class="timeline-menu-item${item.disabled ? ' disabled' : ''}${item.header ? ' timeline-menu-header' : ''}" data-idx="${idx}"${item.disabled ? ' disabled' : ''}>
${item.icon ? `<img src="${escHtml(item.icon)}" alt="">` : ''} ${item.icon ? `<img src="${escHtml(item.icon)}" alt="">` : ''}
<span>${escHtml(item.label)}</span> <span>${escHtml(item.label)}</span>
</button> </button>
@ -1146,15 +1153,16 @@ function showTimelineMenu(x, y, items) {
menu.addEventListener('click', e => { menu.addEventListener('click', e => {
const btn = e.target.closest('.timeline-menu-item'); const btn = e.target.closest('.timeline-menu-item');
if (!btn || btn.disabled) return; if (!btn || btn.disabled) return;
items[parseInt(btn.dataset.idx, 10)]?.onClick?.(); const item = items[parseInt(btn.dataset.idx, 10)];
closeTimelineMenu(); item?.onClick?.();
document.removeEventListener('click', closeOutside, true); if (!item?.keepOpen) closeTimelineMenu();
document.removeEventListener('contextmenu', closeOutside, true);
}); });
const closeOutside = ev => { const closeOutside = ev => {
if (menu.contains(ev.target)) return; if (menu.contains(ev.target)) return;
closeTimelineMenu(); closeTimelineMenu();
};
timelineMenuCleanup = () => {
document.removeEventListener('click', closeOutside, true); document.removeEventListener('click', closeOutside, true);
document.removeEventListener('contextmenu', closeOutside, true); document.removeEventListener('contextmenu', closeOutside, true);
}; };
@ -1164,6 +1172,188 @@ function showTimelineMenu(x, y, items) {
}, 0); }, 0);
} }
function showTimelineSectionMenu(x, y, sections) {
let openSection = null;
const render = () => {
const items = [];
sections.forEach((section, idx) => {
const isOpen = openSection === idx;
items.push({
label: `${isOpen ? '▾' : '▸'} ${section.label}`,
keepOpen: true,
onClick: () => {
openSection = isOpen ? null : idx;
render();
},
});
if (isOpen) {
items.push(...section.items.map(item => ({
...item,
label: ` ${item.label}`,
})));
}
});
showTimelineMenu(x, y, items.length ? items : [{ label: 'Keine Eintraege', disabled: true }]);
};
render();
}
function setBossMechanicType(planId, mechanicId, isTankbuster) {
const plan = getPlan(planId);
if (!plan) return;
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
if (!mechanic) return;
mechanic.isHeavyTankbuster = !!isTankbuster;
mechanic.mechanicTypeManual = true;
updatePlan(planId, { mechanics: plan.mechanics });
refreshMechanicList(planId);
}
function confirmRemoveMechanic(mechanic) {
const name = mechanic?.name ? `"${mechanic.name}"` : 'diesen Angriff';
return window.confirm(`${name} wirklich aus dem Plan entfernen?`);
}
function removeBossMechanic(planId, mechanicId, ask = true) {
const plan = getPlan(planId);
if (!plan) return;
const mechanic = (plan.mechanics ?? []).find(m => m.id === mechanicId);
if (ask && !confirmRemoveMechanic(mechanic)) return;
const mechanics = (plan.mechanics ?? []).filter(m => m.id !== mechanicId);
if (mechanics.length === (plan.mechanics ?? []).length) return;
if (selectedTimelineAssignment?.mechanicId === mechanicId) {
selectedTimelineAssignment = null;
}
updatePlan(planId, { mechanics });
refreshMechanicList(planId);
}
async function fetchPlanAnalysisSource(plan) {
const source = plan?.source ?? {};
const language = plannerLanguage();
if (!source.reportCode || !source.fightId || !Number.isFinite(Number(source.fightStart)) || !Number.isFinite(Number(source.fightEnd))) {
return { error: 'Keine Report-Quelle fuer diesen Plan gefunden' };
}
const res = await fetch('api/analysis.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
report_code: source.reportCode,
fight_id: source.fightId,
start_time: source.fightStart,
end_time: source.fightEnd,
language,
}),
});
const json = await res.json();
if (json.reauth) {
window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php';
return null;
}
return json;
}
function mechanicExistsInPlan(plan, event) {
return (plan.mechanics ?? []).some(mechanic => sameMechanic(mechanic, event, plan.source ?? {}));
}
function addMechanicsFromEvents(planId, events, analysisJson) {
const plan = getPlan(planId);
if (!plan || !events.length) return;
const source = plan.source ?? {};
const mechanics = aoeEventsToMechanics(
events,
source.fightStart ?? analysisJson?.fight_start ?? 0,
[],
analysisJson?.players ?? [],
false,
analysisJson?.mitigation_names ?? plan.mitigationNames ?? {}
);
const merged = [...(plan.mechanics ?? [])];
for (const mechanic of mechanics) {
if (!merged.some(existing => sameMechanic(existing, {
timestamp: mechanic.timestamp + (source.fightStart ?? 0),
abilityId: mechanic.abilityId,
abilityName: mechanic.name,
}, source))) {
merged.push(mechanic);
}
}
merged.sort((a, b) => a.timestamp - b.timestamp);
updatePlan(planId, {
mechanics: merged,
mitigationNames: { ...(plan.mitigationNames ?? {}), ...(analysisJson?.mitigation_names ?? {}) },
});
refreshMechanicList(planId);
}
async function showMissingBossActionsMenu(planId, x, y) {
const plan = getPlan(planId);
if (!plan) return;
showTimelineMenu(x, y, [{ label: 'Boss-Angriffe werden geladen...', disabled: true }]);
let json = null;
try {
json = await fetchPlanAnalysisSource(plan);
} catch {
showTimelineMenu(x, y, [{ label: 'Boss-Angriffe konnten nicht geladen werden', disabled: true }]);
return;
}
if (!json) return;
if (json.error) {
showTimelineMenu(x, y, [{ label: json.error, disabled: true }]);
return;
}
const isGenericAttack = event => String(event?.abilityName ?? '').trim().toLowerCase() === 'attack';
const missing = (json.boss_events ?? json.aoe_events ?? [])
.filter(event => !isGenericAttack(event))
.filter(event => !mechanicExistsInPlan(plan, event))
.sort((a, b) => a.timestamp - b.timestamp);
const sourceStart = plan.source?.fightStart ?? json.fight_start ?? 0;
const byAttack = new Map();
for (const event of missing) {
const key = `${event.abilityId || event.abilityName}::${event.abilityName}`;
if (!byAttack.has(key)) byAttack.set(key, []);
byAttack.get(key).push(event);
}
const attackItems = [];
const timeItems = [];
if (byAttack.size) {
for (const events of [...byAttack.values()].sort((a, b) => a[0].abilityName.localeCompare(b[0].abilityName))) {
const first = events[0];
const count = events.length > 1 ? ` (${events.length}x)` : '';
attackItems.push({
label: `${first.abilityName}${count} · ${first.isHeavyTankbuster ? 'Tankbuster' : 'AoE'}`,
onClick: () => addMechanicsFromEvents(planId, events, json),
});
}
for (const event of missing) {
timeItems.push({
label: `${fmtTimestamp(event.timestamp - sourceStart)} · ${event.abilityName} · ${event.isHeavyTankbuster ? 'Tankbuster' : 'AoE'}`,
onClick: () => addMechanicsFromEvents(planId, [event], json),
});
}
} else {
showTimelineMenu(x, y, [{ label: 'Keine fehlenden Boss-Angriffe', disabled: true }]);
return;
}
if (missing.length > 1) {
attackItems.unshift({
label: `Alle ${missing.length} fehlenden hinzufügen`,
onClick: () => addMechanicsFromEvents(planId, missing, json),
});
}
showTimelineSectionMenu(x, y, [
{ label: 'By Attack', items: attackItems },
{ label: 'By Time', items: timeItems },
]);
}
function updateTimelineAssignmentPosition(planId, mechanicId, ability, job, rowJob, timestamp) { function updateTimelineAssignmentPosition(planId, mechanicId, ability, job, rowJob, timestamp) {
const plan = getPlan(planId); const plan = getPlan(planId);
if (!plan) return; if (!plan) return;
@ -1247,7 +1437,7 @@ function initTimeline(planId) {
timeline.addEventListener('pointerdown', e => { timeline.addEventListener('pointerdown', e => {
if (e.button !== 0) return; if (e.button !== 0) return;
if (!e.target.closest('.timeline-scroll')) return; if (!e.target.closest('.timeline-scroll')) return;
if (e.target.closest('.timeline-mitigation, .timeline-boss-action, .timeline-context-menu')) return; if (e.target.closest('.timeline-mitigation, .timeline-boss-action, .timeline-boss-label, .timeline-context-menu')) return;
const scroll = e.target.closest('.timeline-scroll'); const scroll = e.target.closest('.timeline-scroll');
timelinePan = { timelinePan = {
@ -1295,9 +1485,35 @@ function initTimeline(planId) {
return; return;
} }
closeTimelineMenu(); closeTimelineMenu();
const bossLabel = e.target.closest('.timeline-boss-label');
if (bossLabel) {
showMissingBossActionsMenu(planId, e.clientX, e.clientY);
return;
}
const boss = e.target.closest('.timeline-boss-action'); const boss = e.target.closest('.timeline-boss-action');
if (boss) { if (boss) {
showAbilityModal(planId, boss.dataset.mechanicId); const plan = getPlan(planId);
const mechanic = plan?.mechanics?.find(m => m.id === boss.dataset.mechanicId);
showTimelineMenu(e.clientX, e.clientY, [
{
label: 'Als AoE markieren',
disabled: mechanic && !mechanic.isHeavyTankbuster,
onClick: () => setBossMechanicType(planId, boss.dataset.mechanicId, false),
},
{
label: 'Als Tankbuster markieren',
disabled: mechanic && !!mechanic.isHeavyTankbuster,
onClick: () => setBossMechanicType(planId, boss.dataset.mechanicId, true),
},
{
label: 'Mitigation zuweisen',
onClick: () => showAbilityModal(planId, boss.dataset.mechanicId),
},
{
label: 'Angriff entfernen',
onClick: () => removeBossMechanic(planId, boss.dataset.mechanicId),
},
]);
return; return;
} }
const block = e.target.closest('.timeline-mitigation'); const block = e.target.closest('.timeline-mitigation');
@ -1549,7 +1765,12 @@ function removeAssignment(planId, mechanicId, abilityName, job = null) {
function deleteMechanic(planId, mechanicId) { function deleteMechanic(planId, mechanicId) {
const plan = getPlan(planId); const plan = getPlan(planId);
if (!plan) return; if (!plan) return;
plan.mechanics = plan.mechanics.filter(m => m.id !== mechanicId); const mechanic = (plan.mechanics ?? []).find(m => m.id === mechanicId);
if (!confirmRemoveMechanic(mechanic)) return;
plan.mechanics = (plan.mechanics ?? []).filter(m => m.id !== mechanicId);
if (selectedTimelineAssignment?.mechanicId === mechanicId) {
selectedTimelineAssignment = null;
}
updatePlan(planId, { mechanics: plan.mechanics }); updatePlan(planId, { mechanics: plan.mechanics });
refreshMechanicList(planId); refreshMechanicList(planId);
renderPlanList(); renderPlanList();
@ -1992,7 +2213,7 @@ async function refreshPlanLanguage(planId) {
...mechanic, ...mechanic,
name: match.abilityName ?? mechanic.name, name: match.abilityName ?? mechanic.name,
abilityId: match.abilityId ?? mechanic.abilityId, abilityId: match.abilityId ?? mechanic.abilityId,
isHeavyTankbuster: !!match.isHeavyTankbuster, isHeavyTankbuster: mechanic.mechanicTypeManual ? !!mechanic.isHeavyTankbuster : !!match.isHeavyTankbuster,
assignments, assignments,
}; };
}); });