Merge remote-tracking branch 'Akurosia/akus_schabernack4'
This commit is contained in:
commit
186d59fdc5
@ -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,
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -707,6 +707,39 @@
|
|||||||
.timeline-hint {
|
.timeline-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--t3);
|
color: var(--t3);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
background: rgba(255,255,255,.025);
|
||||||
|
color: var(--t2);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-toggle:hover {
|
||||||
|
border-color: var(--borderem);
|
||||||
|
color: var(--t1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-toggle input {
|
||||||
|
width: auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-empty,
|
.timeline-empty,
|
||||||
@ -772,6 +805,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 +1139,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 +1174,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;
|
||||||
|
|||||||
327
js/planner.js
327
js/planner.js
@ -41,6 +41,7 @@ function createPlan(name) {
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
source: null,
|
source: null,
|
||||||
mitigationNames: {},
|
mitigationNames: {},
|
||||||
|
timelineOptions: { includeShields: false, includePersonal: false },
|
||||||
folderId: null,
|
folderId: null,
|
||||||
jobComposition: Array(8).fill(''),
|
jobComposition: Array(8).fill(''),
|
||||||
mechanics: []
|
mechanics: []
|
||||||
@ -177,7 +178,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;
|
||||||
}
|
}
|
||||||
@ -391,8 +393,18 @@ function renderPlanDetail(plan) {
|
|||||||
<div class="card section-gap">
|
<div class="card section-gap">
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<div class="card-title">Zeitstrahl</div>
|
<div class="card-title">Zeitstrahl</div>
|
||||||
<div class="timeline-hint">Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten</div>
|
<div class="timeline-controls">
|
||||||
|
<label class="timeline-toggle">
|
||||||
|
<input type="checkbox" id="timeline-include-shields"${timelineOptions(plan).includeShields ? ' checked' : ''}>
|
||||||
|
<span>Include Shields</span>
|
||||||
|
</label>
|
||||||
|
<label class="timeline-toggle">
|
||||||
|
<input type="checkbox" id="timeline-include-personal"${timelineOptions(plan).includePersonal ? ' checked' : ''}>
|
||||||
|
<span>Include Personal</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-hint">Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten</div>
|
||||||
<div id="planner-timeline">
|
<div id="planner-timeline">
|
||||||
${renderTimelineHtml(plan)}
|
${renderTimelineHtml(plan)}
|
||||||
</div>
|
</div>
|
||||||
@ -418,6 +430,7 @@ function renderPlanDetail(plan) {
|
|||||||
document.getElementById('name-import-open-btn')?.addEventListener('click', () => {
|
document.getElementById('name-import-open-btn')?.addEventListener('click', () => {
|
||||||
showNameImportModal(plan.id);
|
showNameImportModal(plan.id);
|
||||||
});
|
});
|
||||||
|
initTimelineOptions(plan.id);
|
||||||
initTimeline(plan.id);
|
initTimeline(plan.id);
|
||||||
initMechanicClicks(plan.id);
|
initMechanicClicks(plan.id);
|
||||||
renderInfoPanel(plan);
|
renderInfoPanel(plan);
|
||||||
@ -585,6 +598,25 @@ function initJobSlots(planId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initTimelineOptions(planId) {
|
||||||
|
const shields = document.getElementById('timeline-include-shields');
|
||||||
|
const personal = document.getElementById('timeline-include-personal');
|
||||||
|
const update = () => {
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
if (!plan) return;
|
||||||
|
updatePlan(planId, {
|
||||||
|
timelineOptions: {
|
||||||
|
...(plan.timelineOptions ?? {}),
|
||||||
|
includeShields: !!shields?.checked,
|
||||||
|
includePersonal: !!personal?.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
refreshMechanicList(planId);
|
||||||
|
};
|
||||||
|
shields?.addEventListener('change', update);
|
||||||
|
personal?.addEventListener('change', update);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Timeline ─────────────────────────────────────────────────────────────────
|
// ── Timeline ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function planDurationMs(plan) {
|
function planDurationMs(plan) {
|
||||||
@ -608,9 +640,39 @@ const JOB_GANTT_ORDER = {
|
|||||||
'PLD': 40, 'WAR': 41, 'DRK': 42, 'GNB': 43,
|
'PLD': 40, 'WAR': 41, 'DRK': 42, 'GNB': 43,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TIMELINE_PERSONAL_ABILITIES = new Set([
|
||||||
|
'Guardian',
|
||||||
|
'Bloodwhetting',
|
||||||
|
'Divine Benison',
|
||||||
|
'Intersection',
|
||||||
|
'the Spire',
|
||||||
|
'Haima',
|
||||||
|
'Eukrasian Diagnosis',
|
||||||
|
'Differential Diagnosis',
|
||||||
|
'Seraphic Veil',
|
||||||
|
'Radiant Aegis',
|
||||||
|
'Tempera Coat',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function timelineOptions(plan) {
|
||||||
|
return {
|
||||||
|
includeShields: !!plan?.timelineOptions?.includeShields,
|
||||||
|
includePersonal: !!plan?.timelineOptions?.includePersonal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineAbilityVisible(ability, options) {
|
||||||
|
const isPersonal = TIMELINE_PERSONAL_ABILITIES.has(ability.name);
|
||||||
|
const isShield = ability.buffType === 'shield';
|
||||||
|
if (isPersonal && !options.includePersonal) return false;
|
||||||
|
if (isShield && !options.includeShields && ability.name !== 'Panhaima') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function timelinePlayerRows(plan) {
|
function timelinePlayerRows(plan) {
|
||||||
const roster = plan.playerRoster ?? [];
|
const roster = plan.playerRoster ?? [];
|
||||||
const rows = [];
|
const rows = [];
|
||||||
|
const options = timelineOptions(plan);
|
||||||
const jobEntries = (plan.jobComposition ?? [])
|
const jobEntries = (plan.jobComposition ?? [])
|
||||||
.map((job, idx) => ({ job, idx }))
|
.map((job, idx) => ({ job, idx }))
|
||||||
.filter(e => !!e.job)
|
.filter(e => !!e.job)
|
||||||
@ -619,7 +681,7 @@ function timelinePlayerRows(plan) {
|
|||||||
const name = roster[idx]?.name ?? '';
|
const name = roster[idx]?.name ?? '';
|
||||||
const role = JOB_ROLE[job] ?? '';
|
const role = JOB_ROLE[job] ?? '';
|
||||||
const abilities = (JOB_ABILITIES[job] ?? [])
|
const abilities = (JOB_ABILITIES[job] ?? [])
|
||||||
.filter(ab => ab.buffType !== 'shield' || ab.name === 'Panhaima');
|
.filter(ab => timelineAbilityVisible(ab, options));
|
||||||
abilities.forEach((ab, abilityIdx) => {
|
abilities.forEach((ab, abilityIdx) => {
|
||||||
rows.push({ idx, job, ability: ab.name, buffType: ab.buffType, name, role, firstForJob: abilityIdx === 0 });
|
rows.push({ idx, job, ability: ab.name, buffType: ab.buffType, name, role, firstForJob: abilityIdx === 0 });
|
||||||
});
|
});
|
||||||
@ -994,7 +1056,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 +1182,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 +1199,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 +1214,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 +1233,207 @@ 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 genericAttackNames = new Set(['attack', 'attacke', 'auto attack', 'auto-attack', 'angriff', 'attaque', '攻撃']);
|
||||||
|
const isGenericAttack = event => {
|
||||||
|
const name = String(event?.abilityName ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '');
|
||||||
|
const id = parseInt(event?.abilityId ?? 0, 10) || 0;
|
||||||
|
return id > 0 && id <= 7 || genericAttackNames.has(name);
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
const idsByNameType = new Map();
|
||||||
|
for (const event of missing) {
|
||||||
|
const nameKey = String(event.abilityName ?? '').trim().toLowerCase();
|
||||||
|
const typeKey = event.isHeavyTankbuster ? 'tankbuster' : 'aoe';
|
||||||
|
const idKey = String(event.abilityId ?? '');
|
||||||
|
const nameTypeKey = `${nameKey}::${typeKey}`;
|
||||||
|
if (!idsByNameType.has(nameTypeKey)) idsByNameType.set(nameTypeKey, new Set());
|
||||||
|
if (idKey) idsByNameType.get(nameTypeKey).add(idKey);
|
||||||
|
const key = `${idKey || nameKey}::${nameTypeKey}`;
|
||||||
|
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)` : '';
|
||||||
|
const nameTypeKey = `${String(first.abilityName ?? '').trim().toLowerCase()}::${first.isHeavyTankbuster ? 'tankbuster' : 'aoe'}`;
|
||||||
|
const duplicateName = (idsByNameType.get(nameTypeKey)?.size ?? 0) > 1;
|
||||||
|
const idLabel = duplicateName && first.abilityId ? ` [${first.abilityId}]` : '';
|
||||||
|
attackItems.push({
|
||||||
|
label: `${first.abilityName}${idLabel}${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 +1517,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 +1565,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 +1845,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 +2293,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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user