// ── Storage ─────────────────────────────────────────────────────────────────── const PLANNER_KEY = 'ff14-planner-plans'; const PLANNER_ACTIVE_KEY = 'ff14-planner-active-plan'; const FOLDERS_KEY = 'ff14-planner-folders'; function loadPlans() { try { return JSON.parse(localStorage.getItem(PLANNER_KEY) || '[]'); } catch { return []; } } function savePlans(plans) { localStorage.setItem(PLANNER_KEY, JSON.stringify(plans)); } function loadFolders() { try { return JSON.parse(localStorage.getItem(FOLDERS_KEY) || '[]'); } catch { return []; } } function saveFolders(f) { localStorage.setItem(FOLDERS_KEY, JSON.stringify(f)); } function createFolder(name) { const folder = { id: crypto.randomUUID(), name: name.trim() || 'Neuer Ordner', createdAt: Date.now(), updatedAt: Date.now() }; const all = loadFolders(); all.push(folder); saveFolders(all); return folder; } function updateFolder(id, changes) { const all = loadFolders(), idx = all.findIndex(f => f.id === id); if (idx === -1) return; all[idx] = { ...all[idx], ...changes, updatedAt: Date.now() }; saveFolders(all); } function deleteFolder(id) { saveFolders(loadFolders().filter(f => f.id !== id)); savePlans(loadPlans().map(p => p.folderId === id ? { ...p, folderId: null, updatedAt: Date.now() } : p)); } // ── CRUD ────────────────────────────────────────────────────────────────────── function createPlan(name) { const plan = { id: crypto.randomUUID(), name: name.trim() || 'Unbenannter Plan', createdAt: Date.now(), updatedAt: Date.now(), source: null, mitigationNames: {}, folderId: null, jobComposition: Array(8).fill(''), mechanics: [] }; const all = loadPlans(); all.push(plan); savePlans(all); return plan; } function getPlan(id) { return loadPlans().find(p => p.id === id) ?? null; } function updatePlan(id, changes) { const all = loadPlans(); const idx = all.findIndex(p => p.id === id); if (idx === -1) return null; all[idx] = { ...all[idx], ...changes, updatedAt: Date.now() }; savePlans(all); return all[idx]; } function deletePlan(id) { savePlans(loadPlans().filter(p => p.id !== id)); } function uniquePlanName(baseName) { const names = new Set(loadPlans().map(p => p.name)); if (!names.has(baseName)) return baseName; let i = 2; while (names.has(`${baseName} ${i}`)) i++; return `${baseName} ${i}`; } function copyPlan(id) { const all = loadPlans(); const orig = all.find(p => p.id === id); if (!orig) return null; const copy = { ...JSON.parse(JSON.stringify(orig)), id: crypto.randomUUID(), name: uniquePlanName(orig.name + ' (Kopie)'), createdAt: Date.now(), updatedAt: Date.now() }; all.push(copy); savePlans(all); return copy; } // ── State ───────────────────────────────────────────────────────────────────── let activePlanId = null; let collapsedFolders = new Set(); let selectedTimelineAssignment = null; let actionMetaPromise = null; let actionMetaById = {}; let actionMetaByName = {}; // ── Helpers ─────────────────────────────────────────────────────────────────── function escHtml(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function fmtDate(ts) { return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); } function fmtTimestamp(ms) { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; } function fmtNumber(n) { return Number(n).toLocaleString('de-DE'); } function assignmentAbilityName(assignment, plan = null) { const key = assignment?.ability ?? ''; return assignment?.abilityName ?? plan?.mitigationNames?.[key] ?? key; } function plannerLanguage() { return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en'; } async function ensureActionMetaLoaded() { if (actionMetaPromise) return actionMetaPromise; actionMetaPromise = (async () => { let compact = {}; let full = {}; try { const res = await fetch('assets/jsons/Action.json', { cache: 'no-cache' }); if (res.ok) compact = await res.json(); } catch { } try { const res = await fetch('assets/mitigation-actions.json', { cache: 'no-cache' }); if (res.ok) full = await res.json(); } catch { } const byId = {}; const byName = {}; for (const [id, action] of Object.entries(full ?? {})) { const description = action.Description_en ?? ''; const durations = [...description.matchAll(/Duration:\s*(\d+)s/gi)].map(m => parseInt(m[1], 10)); const meta = { id, name: action.Name_en ?? '', cast: (parseInt(compact?.[id]?.cast ?? action.Cast100ms ?? 0, 10) || 0) / 10, recast: (parseInt(compact?.[id]?.recast ?? action.Recast100ms ?? 0, 10) || 0) / 10, duration: durations.find(Number.isFinite) ?? 15, }; byId[id] = meta; if (meta.name) byName[meta.name] = meta; } for (const [id, action] of Object.entries(compact ?? {})) { byId[id] = { ...(byId[id] ?? { id }), cast: (parseInt(action.cast ?? 0, 10) || 0) / 10, recast: (parseInt(action.recast ?? 0, 10) || 0) / 10, }; } actionMetaById = byId; actionMetaByName = byName; })(); return actionMetaPromise; } function sameMechanic(existing, incoming, source) { const fightStart = source?.fightStart ?? 0; const incomingRel = incoming.timestamp - fightStart; if (existing.abilityId && incoming.abilityId && existing.abilityId === incoming.abilityId) { return Math.abs(existing.timestamp - incomingRel) < 1500; } return Math.abs(existing.timestamp - incomingRel) < 1500; } function visiblePlanMechanics(plan) { return [...(plan?.mechanics ?? [])] .filter(m => m?.id && String(m.name ?? '').trim() !== '' && Number.isFinite(Number(m.timestamp))) .sort((a, b) => a.timestamp - b.timestamp); } // ── Rendering: Plan List ────────────────────────────────────────────────────── function planItemHtml(p) { return `
${escHtml(p.name)}
${p.mechanics.length} Mechaniken · ${fmtDate(p.updatedAt)}
`; } function renderPlanList() { const el = document.getElementById('plan-list'); if (!el) return; const plans = loadPlans(); const folders = loadFolders(); if (plans.length === 0 && folders.length === 0) { el.innerHTML = '
Noch keine Pläne vorhanden
'; return; } const byFolder = new Map(); const ungrouped = []; for (const p of plans) { const fid = p.folderId ?? null; if (fid && folders.some(f => f.id === fid)) { if (!byFolder.has(fid)) byFolder.set(fid, []); byFolder.get(fid).push(p); } else { ungrouped.push(p); } } let html = folders.map(folder => { const folderPlans = byFolder.get(folder.id) ?? []; const collapsed = collapsedFolders.has(folder.id); return `
${escHtml(folder.name)} ${folderPlans.length}
${folderPlans.map(p => planItemHtml(p)).join('') || '
Leer
'}
`; }).join('') + ungrouped.map(p => planItemHtml(p)).join(''); el.innerHTML = html || '
Noch keine Pläne vorhanden
'; // Plan item: open el.querySelectorAll('.plan-item').forEach(row => { row.addEventListener('click', e => { if (e.target.closest('.plan-item-actions')) return; openPlan(row.dataset.id); }); }); // Plan item: move to folder el.querySelectorAll('.plan-btn-move').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); showFolderPicker(btn.dataset.id, btn); }); }); // Plan item: copy el.querySelectorAll('.plan-btn-copy').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const copy = copyPlan(btn.dataset.id); if (copy) { renderPlanList(); openPlan(copy.id); } }); }); // Plan item: delete el.querySelectorAll('.plan-btn-delete').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const plan = getPlan(btn.dataset.id); if (!plan) return; if (!confirm(`Plan "${plan.name}" löschen?`)) return; deletePlan(btn.dataset.id); if (activePlanId === btn.dataset.id) { activePlanId = null; renderPlanDetail(null); } renderPlanList(); }); }); // Folder row: toggle collapse el.querySelectorAll('.folder-row').forEach(row => { row.addEventListener('click', e => { if (e.target.closest('.folder-actions')) return; const fid = row.dataset.folderId; if (collapsedFolders.has(fid)) collapsedFolders.delete(fid); else collapsedFolders.add(fid); renderPlanList(); }); }); // Folder: rename el.querySelectorAll('.folder-rename-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const fid = btn.dataset.id; const folder = loadFolders().find(f => f.id === fid); if (!folder) return; const span = el.querySelector(`.folder-row[data-folder-id="${fid}"] .folder-name-text`); if (!span) return; const input = document.createElement('input'); input.className = 'folder-name-input'; input.value = folder.name; span.replaceWith(input); input.focus(); input.select(); let saved = false; const save = () => { if (saved) return; saved = true; const name = input.value.trim(); if (name) updateFolder(fid, { name }); renderPlanList(); }; input.addEventListener('blur', save); input.addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); save(); } if (ev.key === 'Escape') { saved = true; renderPlanList(); } }); }); }); // Folder: delete el.querySelectorAll('.folder-delete-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const folder = loadFolders().find(f => f.id === btn.dataset.id); if (!folder) return; const count = (byFolder.get(btn.dataset.id) ?? []).length; const msg = count > 0 ? `Ordner "${folder.name}" löschen? Die ${count} enthaltenen Pläne werden nicht gelöscht.` : `Ordner "${folder.name}" löschen?`; if (!confirm(msg)) return; deleteFolder(btn.dataset.id); renderPlanList(); }); }); } // ── Rendering: Plan Detail ──────────────────────────────────────────────────── function renderPlanDetail(plan) { const noplan = document.getElementById('planner-no-plan'); const content = document.getElementById('plan-content'); if (!noplan || !content) return; if (!plan) { noplan.style.display = ''; content.style.display = 'none'; renderInfoPanel(null); return; } noplan.style.display = 'none'; content.style.display = ''; const visibleMechanics = visiblePlanMechanics(plan); content.innerHTML = `
${escHtml(plan.name)}
Erstellt ${fmtDate(plan.createdAt)} · ${visibleMechanics.length} Mechaniken
Jobaufstellung
${renderJobSlotsHtml(plan)}
Zeitstrahl
Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten
${renderTimelineHtml(plan)}
${renderTimelineSettingsHtml(plan)}
Mechaniken
${renderMechanicListHtml(plan)}
`; document.getElementById('plan-name-edit-btn')?.addEventListener('click', () => { startRename(plan.id, plan.name); }); initJobSlots(plan.id); document.getElementById('name-import-open-btn')?.addEventListener('click', () => { showNameImportModal(plan.id); }); initTimeline(plan.id); initMechanicClicks(plan.id); renderInfoPanel(plan); ensureActionMetaLoaded().then(() => refreshTimeline(plan.id)); } function avgNonTankMaxHp(plan) { const roster = plan.playerRoster ?? []; const jobComp = plan.jobComposition ?? []; const hps = jobComp .map((job, i) => ({ job, maxHp: roster[i]?.maxHp ?? 0 })) .filter(p => p.job && JOB_ROLE[p.job] !== 'tank' && p.maxHp > 0) .map(p => p.maxHp); if (!hps.length) return 0; return Math.round(hps.reduce((s, v) => s + v, 0) / hps.length); } function simulateDrMultiplier(mechanic, assignments = mechanic.assignments ?? []) { let mult = 1; for (const a of assignments) { if (a.buffType === 'shield') continue; mult *= (1 - (ABILITY_DR[a.ability] ?? 0)); } return mult; } function effectiveAssignmentsForMechanic(plan, targetMechanic) { const result = []; const seen = new Set(); for (const entry of canonicalAssignmentActivations(plan)) { if (targetMechanic.timestamp < entry.start || targetMechanic.timestamp > entry.end) continue; const assignment = entry.assignment; const key = `${assignment.ability}::${assignment.job ?? ''}`; if (seen.has(key)) continue; seen.add(key); result.push({ ...assignment, sourceMechanicId: entry.mechanic.id, sourceStart: entry.start, }); } return result; } function renderMechanicListHtml(plan) { const mechanics = visiblePlanMechanics(plan); if (mechanics.length === 0) { return `
📋

Noch keine Mechaniken

Importiere einen Log aus dem Analyse-Tab

`; } const activeJobSet = new Set(plan.jobComposition.filter(j => j)); const avgHp = avgNonTankMaxHp(plan); return mechanics.map(m => { const effective = effectiveAssignmentsForMechanic(plan, m); const sorted = sortedAssignments(effective); const assignHtml = sorted.length === 0 ? 'Keine Zuweisung' : sorted.map(a => { const cls = a.buffType === 'debuff' ? 'badge-assign-debuff' : a.buffType === 'shield' ? 'badge-assign-shield' : 'badge-assign-buff'; const isMissing = !!a.job && !activeJobSet.has(a.job); const icon = MITIG_ICONS[a.ability] ?? ''; const ability = assignmentAbilityName(a, plan); const label = a.job ? `${escHtml(a.job)} · ${escHtml(ability)}` : escHtml(ability); const title = isMissing ? `${escHtml(a.job)} nicht in Jobaufstellung` : ''; const suggestions = isMissing ? findEquivSuggestions(a.ability, activeJobSet) : []; const badgeHtml = ` ${icon ? `` : ''} ${label} `; const hintHtml = suggestions.map(s => `→ ${escHtml(s.ability)} (${escHtml(s.job)})?` ).join(''); const noEquivHint = isMissing && suggestions.length === 0 ? `→ Kein Äquivalent!` : ''; const jobHint = !a.job ? `→ Job zuordnen` : ''; const needsWrap = suggestions.length > 0 || !!noEquivHint || !!jobHint; return needsWrap ? `${badgeHtml}${hintHtml}${noEquivHint}${jobHint}` : badgeHtml; }).join(''); const drOnly = m.unmitigatedDamage ? Math.round(m.unmitigatedDamage * simulateDrMultiplier(m, effective)) : 0; const shieldVal = (plan.shieldK ?? 0) * 1000; const mitigFull = Math.max(0, drOnly - shieldVal); const hasDrAssign = effective.some(a => a.buffType !== 'shield' && (ABILITY_DR[a.ability] ?? 0) > 0); const hasShield = shieldVal > 0; const drOnlyCls = avgHp ? (drOnly <= avgHp ? 'mechanic-mitig--ok' : 'mechanic-mitig--risk') : ''; const fullCls = avgHp ? (mitigFull <= avgHp ? 'mechanic-mitig--ok' : 'mechanic-mitig--risk') : ''; return `
${escHtml(fmtTimestamp(m.timestamp))}
${m.phase ? `
${escHtml(m.phase)}
` : ''}
${escHtml(m.name)}
${m.unmitigatedDamage ? `
${fmtNumber(m.unmitigatedDamage)} unmitigiert${avgHp ? ` ∅ ${fmtNumber(avgHp)} HP` : ''}
` : '' } ${hasDrAssign || hasShield ? `
${hasDrAssign ? `→ ${fmtNumber(drOnly)} mitigiert` : ''} ${hasShield ? `Mitigation mit Schild ${fmtNumber(mitigFull)}` : ''}
` : ''}
${assignHtml}
${m.notes ? `
${escHtml(m.notes)}
` : ''}
Klicken zum Bearbeiten
`; }).join(''); } // ── Job Slots ───────────────────────────────────────────────────────────────── function renderJobSlotsHtml(plan) { const roster = plan.playerRoster ?? []; return Array.from({ length: 8 }, (_, i) => { const job = plan.jobComposition[i] ?? ''; const role = JOB_ROLE[job] ?? ''; const playerName = roster[i]?.name ?? ''; return `
${playerName ? `
${escHtml(playerName)}
` : ''}
`; }).join(''); } function initJobSlots(planId) { const grid = document.getElementById('job-slots-grid'); if (!grid) return; grid.addEventListener('change', e => { const sel = e.target.closest('.job-slot-select'); if (!sel) return; const plan = getPlan(planId); if (!plan) return; const comp = [...plan.jobComposition]; comp[parseInt(sel.dataset.idx, 10)] = sel.value; updatePlan(planId, { jobComposition: comp }); const slot = sel.closest('.job-slot'); if (slot) { const role = JOB_ROLE[sel.value] ?? ''; slot.className = 'job-slot' + (role ? ` job-slot--${role}` : ''); } refreshMechanicList(planId); }); } // ── Timeline ───────────────────────────────────────────────────────────────── function planDurationMs(plan) { const sourceDuration = (plan.source?.fightEnd ?? 0) - (plan.source?.fightStart ?? 0); const mechanicEnd = Math.max(0, ...visiblePlanMechanics(plan).map(m => (m.timestamp ?? 0) + 30000)); return Math.max(sourceDuration, mechanicEnd, 60000); } function timelineScale(plan) { const duration = planDurationMs(plan); const pxPerSecond = 8; return { duration, width: Math.max(720, Math.ceil(duration / 1000 * pxPerSecond)) }; } function timelinePlayerRows(plan) { const roster = plan.playerRoster ?? []; const rows = []; (plan.jobComposition ?? []).forEach((job, idx) => { if (!job) return; const name = roster[idx]?.name ?? ''; const role = JOB_ROLE[job] ?? ''; const abilities = (JOB_ABILITIES[job] ?? []) .filter(ab => ab.buffType !== 'shield' || ab.name === 'Panhaima'); abilities.forEach((ab, abilityIdx) => { rows.push({ idx, job, ability: ab.name, buffType: ab.buffType, name, role, firstForJob: abilityIdx === 0 }); }); }); return rows; } function selectedAssignmentMatches(mechanicId, assignment) { return selectedTimelineAssignment && selectedTimelineAssignment.mechanicId === mechanicId && selectedTimelineAssignment.ability === assignment.ability && selectedTimelineAssignment.job === (assignment.job ?? ''); } function jobCanUseAbility(job, ability) { return (JOB_ABILITIES[job] ?? []).some(a => a.name === ability); } function timelineRowsForAssignment(rows, assignment) { const assignedJob = assignment.job ?? ''; return rows.filter(row => { if (row.ability !== assignment.ability) return false; if (assignedJob) return row.job === assignedJob; return true; // unresolved: show in all rows for this ability }); } function assignmentStartMs(mechanic, assignment) { return Number.isFinite(Number(assignment?.timestamp)) ? Number(assignment.timestamp) : mechanic.timestamp; } function canonicalAssignmentActivations(plan) { const entries = []; for (const mechanic of visiblePlanMechanics(plan)) { for (const assignment of mechanic.assignments ?? []) { const start = assignmentStartMs(mechanic, assignment); const durationSec = assignmentDurationSeconds(assignment); entries.push({ mechanic, assignment, start, durationSec, end: start + durationSec * 1000, }); } } entries.sort((a, b) => a.start - b.start); const activeUntilBySkill = new Map(); return entries.filter(entry => { const key = `${entry.assignment.ability}::${entry.assignment.job ?? ''}`; const activeUntil = activeUntilBySkill.get(key) ?? -Infinity; if (entry.start < activeUntil) return false; activeUntilBySkill.set(key, entry.end); return true; }); } function findNearestMechanic(plan, timestamp) { const mechanics = visiblePlanMechanics(plan); if (!mechanics.length) return null; return mechanics.reduce((best, mechanic) => Math.abs(mechanic.timestamp - timestamp) < Math.abs(best.timestamp - timestamp) ? mechanic : best , mechanics[0]); } function timelineTimestampFromEvent(plan, track, event) { const rect = track.getBoundingClientRect(); const x = Math.max(0, Math.min(rect.width, event.clientX - rect.left)); return (x / rect.width) * planDurationMs(plan); } function layoutBossActions(mechanics, duration) { const lanes = []; const minGapPct = 9; return mechanics.map(mechanic => { const left = (mechanic.timestamp / duration) * 100; let lane = lanes.findIndex(lastLeft => left - lastLeft >= minGapPct); if (lane === -1) { lane = lanes.length; lanes.push(left); } else { lanes[lane] = left; } return { mechanic, left, lane }; }); } function assignmentOverlapsJob(plan, job, ability, timestamp, ignore = null) { for (const mechanic of visiblePlanMechanics(plan)) { for (const assignment of mechanic.assignments ?? []) { if (assignment.ability !== ability) continue; if ((assignment.job ?? '') !== job && !(!(assignment.job ?? '') && jobCanUseAbility(job, ability))) continue; if (ignore && ignore.mechanicId === mechanic.id && ignore.ability === ability && ignore.job === (assignment.job ?? '')) continue; const start = assignmentStartMs(mechanic, assignment); const cooldownMs = Math.max(assignmentCooldownSeconds(assignment), assignmentDurationSeconds(assignment)) * 1000; const end = start + cooldownMs; if (timestamp >= start && timestamp < end) return true; } } return false; } function actionMetaForAssignment(assignment) { const id = String(assignment?.actionId ?? assignment?.extraAbilityGameID ?? ''); return (id && actionMetaById[id]) || actionMetaByName[assignment?.ability] || null; } function assignmentCooldownSeconds(assignment) { const own = Number(assignment?.cooldownSeconds); if (Number.isFinite(own) && own >= 0) return own; return actionMetaForAssignment(assignment)?.recast ?? 0; } function assignmentDurationSeconds(assignment) { const own = Number(assignment?.durationSeconds); if (Number.isFinite(own) && own > 0) return own; return actionMetaForAssignment(assignment)?.duration ?? (assignment?.buffType === 'shield' ? 20 : 15); } function renderTimelineHtml(plan) { const mechanics = visiblePlanMechanics(plan); if (!mechanics.length) { return '
Importiere Mechaniken aus dem Analyse-Tab, um den Zeitstrahl zu nutzen.
'; } const { duration, width } = timelineScale(plan); const rows = timelinePlayerRows(plan); const marks = []; const tick = 10000; for (let t = 0; t <= duration; t += tick) { marks.push(`${fmtTimestamp(t)}`); } const bossActions = layoutBossActions(mechanics, duration); const laneCount = Math.max(1, ...bossActions.map(item => item.lane + 1)); const hitLines = mechanics.map(m => ` `).join(''); const bossItems = bossActions.map(({ mechanic: m, left, lane }) => ` `).join(''); const playerRows = rows.map(row => { const blocks = []; const rowAssignments = canonicalAssignmentActivations(plan).filter(entry => { const assignment = entry.assignment; if (assignment.ability !== row.ability) return false; const assignedJob = assignment.job ?? ''; return !assignedJob || assignedJob === row.job; }); for (const item of rowAssignments) { const m = item.mechanic; const a = item.assignment; const start = item.start; const durationSec = item.durationSec; const cooldownSec = assignmentCooldownSeconds(a); const left = Math.max(0, Math.min(100, (start / duration) * 100)); const widthPct = Math.max(1.2, Math.min(100 - left, (durationSec * 1000 / duration) * 100)); const cdWidthPct = cooldownSec > 0 ? Math.max(widthPct, Math.min(100 - left, (cooldownSec * 1000 / duration) * 100)) : widthPct; const activeWidthPct = Math.min(100, (widthPct / cdWidthPct) * 100); const selected = selectedAssignmentMatches(m.id, a) ? ' selected' : ''; const unresolved = a.job ? '' : ' timeline-mitigation--candidate'; const cls = a.buffType === 'debuff' ? 'timeline-mitigation--debuff' : a.buffType === 'shield' ? 'timeline-mitigation--shield' : 'timeline-mitigation--buff'; const icon = MITIG_ICONS[a.ability] ?? ''; const abilityLabel = assignmentAbilityName(a, plan); blocks.push(` `); } const icon = MITIG_ICONS[row.ability] ?? ''; const abilityDisplayName = plan.mitigationNames?.[row.ability] ?? row.ability; const jobStartCls = row.firstForJob ? ' timeline-player-row--job-start' : ''; return `
${escHtml(row.job)} ${icon ? `` : ''} ${escHtml(abilityDisplayName)}
${hitLines}${blocks.join('')}
`; }).join(''); return `
Boss
${hitLines}${bossItems}
${playerRows}
${marks.join('')}
`; } function findTimelineAssignment(plan, selected = selectedTimelineAssignment) { if (!plan || !selected) return null; const mechanic = (plan.mechanics ?? []).find(m => m.id === selected.mechanicId); if (!mechanic) return null; const assignment = (mechanic.assignments ?? []).find(a => a.ability === selected.ability && (a.job ?? '') === selected.job ); return assignment ? { mechanic, assignment } : null; } function renderTimelineSettingsHtml(plan) { const found = findTimelineAssignment(plan); if (!found) { return '
Mitigation im Zeitstrahl auswählen, um Dauer und Cooldown anzupassen.
'; } const { mechanic, assignment } = found; const ability = assignmentAbilityName(assignment, plan); const jobLabel = assignment.job || 'Nicht zugewiesen'; return `
${escHtml(jobLabel)} · ${escHtml(ability)}
${escHtml(mechanic.name)} bei ${escHtml(fmtTimestamp(mechanic.timestamp))}
`; } function refreshTimeline(planId) { const plan = getPlan(planId); if (!plan) return; const timeline = document.getElementById('planner-timeline'); const settings = document.getElementById('timeline-settings'); if (timeline) timeline.innerHTML = renderTimelineHtml(plan); if (settings) settings.innerHTML = renderTimelineSettingsHtml(plan); } function setTimelineAssignmentField(planId, mechanicId, ability, job, field, value) { const plan = getPlan(planId); if (!plan) return; const mechanic = plan.mechanics.find(m => m.id === mechanicId); const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job); if (!assignment) return; if (field === 'timestamp') assignment.timestamp = Math.max(0, Math.round(Number(value) * 1000)); if (field === 'durationSeconds') assignment.durationSeconds = Math.max(1, Math.round(Number(value))); if (field === 'cooldownSeconds') assignment.cooldownSeconds = Math.max(0, Math.round(Number(value))); updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); } function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) { const plan = getPlan(planId); if (!plan || !jobCanUseAbility(nextJob, ability)) return; const mechanic = plan.mechanics.find(m => m.id === mechanicId); const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job); if (!assignment) return; const timestamp = assignmentStartMs(mechanic, assignment); if (assignmentOverlapsJob(plan, nextJob, ability, timestamp, { mechanicId, ability, job })) return; assignment.job = nextJob; selectedTimelineAssignment = { mechanicId, ability, job: nextJob }; updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); } function removeTimelineAssignment(planId, mechanicId, ability, job) { const plan = getPlan(planId); if (!plan) return; const mechanic = plan.mechanics.find(m => m.id === mechanicId); if (!mechanic) return; mechanic.assignments = (mechanic.assignments ?? []).filter(a => !(a.ability === ability && (a.job ?? '') === job)); if (selectedTimelineAssignment?.mechanicId === mechanicId && selectedTimelineAssignment?.ability === ability && selectedTimelineAssignment?.job === job) { selectedTimelineAssignment = null; } updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); } function addTimelineAssignment(planId, mechanicId, ability, job, buffType, timestamp) { const plan = getPlan(planId); if (!plan || !jobCanUseAbility(job, ability)) return; const mechanic = plan.mechanics.find(m => m.id === mechanicId); if (!mechanic) return; mechanic.assignments = mechanic.assignments ?? []; mechanic.assignments.push({ ability, abilityName: plan.mitigationNames?.[ability], actionId: actionMetaByName[ability]?.id ?? null, job, buffType, timestamp: Math.max(0, Math.round(timestamp)), }); selectedTimelineAssignment = { mechanicId, ability, job }; updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); } function closeTimelineMenu() { document.getElementById('timeline-context-menu')?.remove(); } function showTimelineMenu(x, y, items) { closeTimelineMenu(); const menu = document.createElement('div'); menu.id = 'timeline-context-menu'; menu.className = 'timeline-context-menu'; menu.innerHTML = items.length ? items.map((item, idx) => ` `).join('') : '
Keine verfügbare Fähigkeit
'; document.body.appendChild(menu); const rect = menu.getBoundingClientRect(); menu.style.left = Math.min(x, window.innerWidth - rect.width - 8) + 'px'; menu.style.top = Math.min(y, window.innerHeight - rect.height - 8) + 'px'; menu.addEventListener('click', e => { const btn = e.target.closest('.timeline-menu-item'); if (!btn || btn.disabled) return; items[parseInt(btn.dataset.idx, 10)]?.onClick?.(); closeTimelineMenu(); document.removeEventListener('click', closeOutside, true); document.removeEventListener('contextmenu', closeOutside, true); }); const closeOutside = ev => { if (menu.contains(ev.target)) return; closeTimelineMenu(); document.removeEventListener('click', closeOutside, true); document.removeEventListener('contextmenu', closeOutside, true); }; setTimeout(() => { document.addEventListener('click', closeOutside, true); document.addEventListener('contextmenu', closeOutside, true); }, 0); } function updateTimelineAssignmentPosition(planId, mechanicId, ability, job, rowJob, timestamp) { const plan = getPlan(planId); if (!plan) return; const mechanic = plan.mechanics.find(m => m.id === mechanicId); const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job); if (!assignment) return; if (!jobCanUseAbility(rowJob, ability)) return; assignment.timestamp = Math.max(0, Math.round(timestamp)); assignment.job = rowJob; selectedTimelineAssignment = { mechanicId, ability, job: rowJob }; updatePlan(planId, { mechanics: plan.mechanics }); refreshTimeline(planId); refreshMechanicList(planId, false); } function initTimeline(planId) { const timeline = document.getElementById('planner-timeline'); const settings = document.getElementById('timeline-settings'); if (!timeline || !settings) return; let timelinePan = null; let suppressNextTimelineClick = false; timeline.addEventListener('pointerdown', e => { if (e.button !== 0) return; if (!e.target.closest('.timeline-scroll')) return; if (e.target.closest('.timeline-mitigation, .timeline-boss-action, .timeline-context-menu')) return; const scroll = e.target.closest('.timeline-scroll'); timelinePan = { scroll, pointerId: e.pointerId, startX: e.clientX, startScrollLeft: scroll.scrollLeft, moved: false, }; scroll.setPointerCapture?.(e.pointerId); }); timeline.addEventListener('pointermove', e => { if (!timelinePan || timelinePan.pointerId !== e.pointerId) return; const dx = e.clientX - timelinePan.startX; if (Math.abs(dx) > 3) { timelinePan.moved = true; timelinePan.scroll.classList.add('timeline-scroll--dragging'); timelinePan.scroll.scrollLeft = timelinePan.startScrollLeft - dx; e.preventDefault(); } }); timeline.addEventListener('pointerup', e => { if (!timelinePan || timelinePan.pointerId !== e.pointerId) return; timelinePan.scroll.releasePointerCapture?.(e.pointerId); timelinePan.scroll.classList.remove('timeline-scroll--dragging'); suppressNextTimelineClick = timelinePan.moved; timelinePan = null; if (suppressNextTimelineClick) setTimeout(() => { suppressNextTimelineClick = false; }, 0); }); timeline.addEventListener('pointercancel', e => { if (!timelinePan || timelinePan.pointerId !== e.pointerId) return; timelinePan.scroll.releasePointerCapture?.(e.pointerId); timelinePan.scroll.classList.remove('timeline-scroll--dragging'); timelinePan = null; }); timeline.addEventListener('click', e => { if (suppressNextTimelineClick) { e.preventDefault(); e.stopPropagation(); suppressNextTimelineClick = false; return; } closeTimelineMenu(); const boss = e.target.closest('.timeline-boss-action'); if (boss) { showAbilityModal(planId, boss.dataset.mechanicId); return; } const block = e.target.closest('.timeline-mitigation'); if (block) { selectedTimelineAssignment = { mechanicId: block.dataset.mechanicId, ability: block.dataset.ability, job: block.dataset.job, }; refreshTimeline(planId); const plan = getPlan(planId); const rows = timelinePlayerRows(plan ?? {}); const found = findTimelineAssignment(plan, selectedTimelineAssignment); const timestamp = found ? assignmentStartMs(found.mechanic, found.assignment) : 0; const items = rows .filter(row => jobCanUseAbility(row.job, block.dataset.ability)) .map(row => ({ label: `${row.job}${row.name ? ` · ${row.name}` : ''}`, icon: MITIG_ICONS[block.dataset.ability] ?? '', disabled: assignmentOverlapsJob(plan, row.job, block.dataset.ability, timestamp, { mechanicId: block.dataset.mechanicId, ability: block.dataset.ability, job: block.dataset.job, }), onClick: () => setTimelineAssignmentJob(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job, row.job), })); showTimelineMenu(e.clientX, e.clientY, items); return; } const track = e.target.closest('.timeline-player-row .timeline-track'); const row = e.target.closest('.timeline-player-row'); if (!track || !row) return; const plan = getPlan(planId); if (!plan) return; const timestamp = timelineTimestampFromEvent(plan, track, e); const mechanic = findNearestMechanic(plan, timestamp); if (!mechanic) return; const rowAbility = row.dataset.ability; const rowJob = row.dataset.job; const ab = (JOB_ABILITIES[rowJob] ?? []).find(a => a.name === rowAbility); if (!ab || assignmentOverlapsJob(plan, rowJob, rowAbility, timestamp)) return; addTimelineAssignment(planId, mechanic.id, rowAbility, rowJob, ab.buffType, timestamp); }); timeline.addEventListener('contextmenu', e => { const block = e.target.closest('.timeline-mitigation'); if (!block) return; e.preventDefault(); closeTimelineMenu(); removeTimelineAssignment(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job); }); timeline.addEventListener('dragstart', e => { const block = e.target.closest('.timeline-mitigation'); if (!block) return; const plan = getPlan(planId); const found = findTimelineAssignment(plan, { mechanicId: block.dataset.mechanicId, ability: block.dataset.ability, job: block.dataset.job, }); if (!plan || !found) return; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', JSON.stringify({ mechanicId: block.dataset.mechanicId, ability: block.dataset.ability, job: block.dataset.job, startClientX: e.clientX, startTimestamp: assignmentStartMs(found.mechanic, found.assignment), })); }); timeline.addEventListener('dragover', e => { if (e.target.closest('.timeline-player-row .timeline-track')) e.preventDefault(); }); timeline.addEventListener('drop', e => { const track = e.target.closest('.timeline-player-row .timeline-track'); const row = e.target.closest('.timeline-player-row'); if (!track || !row) return; e.preventDefault(); let data; try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; } const plan = getPlan(planId); if (!plan) return; const rect = track.getBoundingClientRect(); const deltaPx = e.clientX - (Number(data.startClientX) || e.clientX); const timestampDelta = (deltaPx / rect.width) * planDurationMs(plan); const timestamp = (Number(data.startTimestamp) || 0) + timestampDelta; if (row.dataset.ability && data.ability !== row.dataset.ability) return; updateTimelineAssignmentPosition(planId, data.mechanicId, data.ability, data.job, row.dataset.job, timestamp); }); settings.addEventListener('change', e => { const input = e.target.closest('.timeline-setting-input'); if (!input || !selectedTimelineAssignment) return; setTimelineAssignmentField( planId, selectedTimelineAssignment.mechanicId, selectedTimelineAssignment.ability, selectedTimelineAssignment.job, input.dataset.field, input.value ); }); } // ── Mechanic list helpers ───────────────────────────────────────────────────── function refreshMechanicList(planId, includeTimeline = true) { const plan = getPlan(planId); if (!plan) return; const el = document.getElementById('mechanic-list'); if (el) el.innerHTML = renderMechanicListHtml(plan); if (includeTimeline) refreshTimeline(planId); renderInfoPanel(plan); } function renderInfoPanel(plan) { const el = document.getElementById('planner-info-panel'); if (!el) return; const legendHtml = `
Farbschema
Mitigation
Debuff
Schild
`; if (!plan) { el.innerHTML = legendHtml; return; } const activeJobSet = new Set(plan.jobComposition.filter(j => j)); const noJobAbilities = new Set(); const missingJobPairs = new Set(); for (const m of visiblePlanMechanics(plan)) { for (const a of m.assignments) { if (!a.job) { noJobAbilities.add(a.ability); } else if (!activeJobSet.has(a.job)) { missingJobPairs.add(`${a.job} · ${a.ability}`); } } } let warningsHtml = ''; if (noJobAbilities.size > 0 || missingJobPairs.size > 0) { warningsHtml = `
Hinweise
`; if (noJobAbilities.size > 0) { warningsHtml += `
Fähigkeiten ohne Job-Zuordnung
`; } if (missingJobPairs.size > 0) { warningsHtml += `
Fähigkeiten mit fehlendem Job
`; } warningsHtml += '
'; } const extraInfoHtml = `
Zusätzliche Informationen
k
`; el.innerHTML = legendHtml + warningsHtml + extraInfoHtml; const shieldInput = el.querySelector('#plan-shield-k-input'); if (shieldInput) { shieldInput.addEventListener('change', () => { const val = parseFloat(shieldInput.value) || 0; updatePlan(plan.id, { shieldK: val > 0 ? val : null }); refreshMechanicList(plan.id); }); } } function removeAssignment(planId, mechanicId, abilityName, job = null) { const plan = getPlan(planId); if (!plan) return; const mechanic = plan.mechanics.find(m => m.id === mechanicId); if (!mechanic) return; mechanic.assignments = mechanic.assignments.filter(a => a.ability !== abilityName || (job !== null && (a.job ?? '') !== job) ); updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); if (abilityModalMechanicId === mechanicId) renderAbilityModalContent(); } function deleteMechanic(planId, mechanicId) { const plan = getPlan(planId); if (!plan) return; plan.mechanics = plan.mechanics.filter(m => m.id !== mechanicId); updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); renderPlanList(); } function initMechanicClicks(planId) { const list = document.getElementById('mechanic-list'); if (!list) return; list.addEventListener('click', e => { const removeBtn = e.target.closest('.badge-remove'); if (removeBtn) { e.stopPropagation(); removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability, removeBtn.dataset.job ?? null); return; } const deleteBtn = e.target.closest('.mechanic-delete-btn'); if (deleteBtn) { e.stopPropagation(); deleteMechanic(planId, deleteBtn.dataset.mechanicId); return; } const card = e.target.closest('.mechanic-card'); if (!card) return; showAbilityModal(planId, card.dataset.mechanicId); }); list.addEventListener('contextmenu', e => { const badge = e.target.closest('.badge-assign'); if (!badge) return; e.preventDefault(); e.stopPropagation(); const removeBtn = badge.querySelector('.badge-remove'); if (removeBtn) removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability, removeBtn.dataset.job ?? null); }); } // ── Rename ──────────────────────────────────────────────────────────────────── function startRename(id, currentName) { const display = document.getElementById('plan-name-display'); const editBtn = document.getElementById('plan-name-edit-btn'); if (!display) return; display.innerHTML = ``; const input = document.getElementById('plan-name-input'); input.focus(); input.select(); if (editBtn) editBtn.style.display = 'none'; let saved = false; const save = () => { if (saved) return; saved = true; const newName = input.value.trim() || currentName; updatePlan(id, { name: newName }); renderPlanList(); openPlan(id); }; input.addEventListener('keydown', e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { saved = true; display.innerHTML = `${escHtml(currentName)}`; if (editBtn) editBtn.style.display = ''; } }); input.addEventListener('blur', save); } // ── Open plan ───────────────────────────────────────────────────────────────── function openPlan(id) { activePlanId = id; localStorage.setItem(PLANNER_ACTIVE_KEY, id); renderPlanList(); renderPlanDetail(getPlan(id)); refreshPlanLanguage(id); } // ── New plan form ───────────────────────────────────────────────────────────── function initNewPlanForm() { const btn = document.getElementById('planner-new-btn'); const form = document.getElementById('planner-new-form'); const input = document.getElementById('planner-new-name'); const save = document.getElementById('planner-new-save'); const cancel = document.getElementById('planner-new-cancel'); if (!btn) return; btn.addEventListener('click', () => { form.style.display = ''; input.value = ''; input.focus(); }); cancel.addEventListener('click', () => { form.style.display = 'none'; }); const clearErr = () => { input.style.borderColor = ''; document.getElementById('planner-new-name-err')?.remove(); }; const doCreate = () => { const name = input.value.trim(); if (!name) { input.focus(); return; } if (loadPlans().some(p => p.name === name)) { input.style.borderColor = 'var(--red)'; let err = document.getElementById('planner-new-name-err'); if (!err) { err = document.createElement('div'); err.id = 'planner-new-name-err'; err.style.cssText = 'font-size:11px;color:var(--red);margin-top:3px'; input.insertAdjacentElement('afterend', err); } err.textContent = 'Name bereits vergeben'; input.focus(); return; } clearErr(); const plan = createPlan(name); form.style.display = 'none'; renderPlanList(); openPlan(plan.id); }; input.addEventListener('input', clearErr); save.addEventListener('click', doCreate); input.addEventListener('keydown', e => { if (e.key === 'Enter') doCreate(); if (e.key === 'Escape') { clearErr(); form.style.display = 'none'; } }); } // ── Folder Picker ───────────────────────────────────────────────────────────── let folderPickerCleanup = null; function closeFolderPicker() { document.getElementById('folder-picker')?.remove(); if (folderPickerCleanup) { folderPickerCleanup(); folderPickerCleanup = null; } } function showFolderPicker(planId, anchorEl) { closeFolderPicker(); const plan = getPlan(planId); const folders = loadFolders(); if (!plan) return; if (folders.length === 0) return; const rect = anchorEl.getBoundingClientRect(); const picker = document.createElement('div'); picker.id = 'folder-picker'; picker.className = 'folder-picker'; picker.style.cssText = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;z-index:9999`; picker.innerHTML = [{ id: null, name: 'Kein Ordner' }, ...folders].map(f => `
${f.id === null ? '— ' : '📁 '}${escHtml(f.name)}
`).join(''); document.body.appendChild(picker); picker.querySelectorAll('.folder-picker-option').forEach(opt => { opt.addEventListener('click', e => { e.stopPropagation(); updatePlan(planId, { folderId: opt.dataset.fid || null }); closeFolderPicker(); renderPlanList(); }); }); const onOutside = e => { if (!picker.contains(e.target)) closeFolderPicker(); }; setTimeout(() => document.addEventListener('click', onOutside), 0); folderPickerCleanup = () => document.removeEventListener('click', onOutside); } // ── New Folder Form ─────────────────────────────────────────────────────────── function initNewFolderForm() { const btn = document.getElementById('planner-new-folder-btn'); const form = document.getElementById('planner-new-folder-form'); const input = document.getElementById('planner-new-folder-name'); const save = document.getElementById('planner-new-folder-save'); const cancel = document.getElementById('planner-new-folder-cancel'); if (!btn) return; btn.addEventListener('click', () => { form.style.display = ''; input.value = ''; input.focus(); }); cancel.addEventListener('click', () => { form.style.display = 'none'; }); const doCreate = () => { const name = input.value.trim(); if (!name) { input.focus(); return; } createFolder(name); form.style.display = 'none'; renderPlanList(); }; save.addEventListener('click', doCreate); input.addEventListener('keydown', e => { if (e.key === 'Enter') doCreate(); if (e.key === 'Escape') form.style.display = 'none'; }); } // ── Ability → Job mapping ───────────────────────────────────────────────────── const ABILITY_JOB_MAP = { 'Passage of Arms': 'PLD', 'Divine Veil': 'PLD', 'Guardian': 'PLD', 'Shake It Off': 'WAR', 'Bloodwhetting': 'WAR', 'Dark Missionary': 'DRK', 'Heart of Light': 'GNB', 'Temperance': 'WHM', 'Divine Benison': 'WHM', 'Divine Caress': 'WHM', 'Sacred Soil': 'SCH', 'Expedient': 'SCH', 'Fey Illumination': 'SCH', 'Galvanize': 'SCH', 'Seraphic Veil': 'SCH', 'Catalyze': 'SCH', 'Collective Unconscious': 'AST', 'Neutral Sect': 'AST', 'Intersection': 'AST', 'the Spire': 'AST', 'Kerachole': 'SGE', 'Holos': 'SGE', 'Holosakos': 'SGE', 'Panhaima': 'SGE', 'Haima': 'SGE', 'Eukrasian Prognosis': 'SGE', 'Eukrasian Diagnosis': 'SGE', 'Differential Diagnosis': 'SGE', 'Troubadour': 'BRD', 'Tactician': 'MCH', 'Shield Samba': 'DNC', 'Improvised Finish': 'DNC', 'Radiant Aegis': 'SMN', 'Magick Barrier': 'RDM', 'Tempera Coat': 'PCT', 'Tempera Grassa': 'PCT', }; const JOB_FROM_TYPE = { 'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB', 'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE', 'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM', 'Reaper': 'RPR', 'Viper': 'VPR', 'Bard': 'BRD', 'Machinist': 'MCH', 'Dancer': 'DNC', 'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM', 'Pictomancer': 'PCT', }; const TANK_JOBS = new Set(['PLD', 'WAR', 'DRK', 'GNB']); const MELEE_JOBS = new Set(['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR']); const CASTER_JOBS = new Set(['BLM', 'SMN', 'RDM', 'PCT']); const JOB_ROLE = { 'PLD': 'tank', 'WAR': 'tank', 'DRK': 'tank', 'GNB': 'tank', 'WHM': 'healer', 'SCH': 'healer', 'AST': 'healer', 'SGE': 'healer', 'MNK': 'dps', 'DRG': 'dps', 'NIN': 'dps', 'SAM': 'dps', 'RPR': 'dps', 'VPR': 'dps', 'BRD': 'dps', 'MCH': 'dps', 'DNC': 'dps', 'BLM': 'dps', 'SMN': 'dps', 'RDM': 'dps', 'PCT': 'dps', }; const ALL_JOBS = [ { group: 'Tank', jobs: ['PLD', 'WAR', 'DRK', 'GNB'] }, { group: 'Healer', jobs: ['WHM', 'SCH', 'AST', 'SGE'] }, { group: 'Melee', jobs: ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'] }, { group: 'Ranged', jobs: ['BRD', 'MCH', 'DNC'] }, { group: 'Caster', jobs: ['BLM', 'SMN', 'RDM', 'PCT'] }, ]; const JOB_ABILITIES = { 'PLD': [ { name: 'Passage of Arms', buffType: 'buff' }, { name: 'Divine Veil', buffType: 'shield' }, { name: 'Guardian', buffType: 'shield' }, { name: 'Reprisal', buffType: 'debuff' }, ], 'WAR': [ { name: 'Shake It Off', buffType: 'shield' }, { name: 'Bloodwhetting', buffType: 'shield' }, { name: 'Reprisal', buffType: 'debuff' }, ], 'DRK': [ { name: 'Dark Missionary', buffType: 'buff' }, { name: 'Reprisal', buffType: 'debuff' }, ], 'GNB': [ { name: 'Heart of Light', buffType: 'buff' }, { name: 'Reprisal', buffType: 'debuff' }, ], 'WHM': [ { name: 'Temperance', buffType: 'buff' }, { name: 'Divine Benison', buffType: 'shield' }, { name: 'Divine Caress', buffType: 'shield' }, ], 'SCH': [ { name: 'Sacred Soil', buffType: 'buff' }, { name: 'Expedient', buffType: 'buff' }, { name: 'Fey Illumination', buffType: 'buff' }, { name: 'Galvanize', buffType: 'shield' }, { name: 'Seraphic Veil', buffType: 'shield' }, { name: 'Catalyze', buffType: 'shield' }, { name: 'Addle', buffType: 'debuff' }, ], 'AST': [ { name: 'Collective Unconscious', buffType: 'buff' }, { name: 'Neutral Sect', buffType: 'shield' }, { name: 'Intersection', buffType: 'shield' }, { name: 'the Spire', buffType: 'shield' }, ], 'SGE': [ { name: 'Kerachole', buffType: 'buff' }, { name: 'Holos', buffType: 'buff' }, { name: 'Holosakos', buffType: 'shield' }, { name: 'Panhaima', buffType: 'shield' }, { name: 'Eukrasian Prognosis', buffType: 'shield' }, { name: 'Eukrasian Diagnosis', buffType: 'shield' }, { name: 'Differential Diagnosis', buffType: 'shield' }, { name: 'Haima', buffType: 'shield' }, ], 'BRD': [{ name: 'Troubadour', buffType: 'buff' }], 'MCH': [{ name: 'Tactician', buffType: 'buff' }], 'DNC': [ { name: 'Shield Samba', buffType: 'buff' }, { name: 'Improvised Finish', buffType: 'shield' }, ], 'MNK': [{ name: 'Feint', buffType: 'debuff' }], 'DRG': [{ name: 'Feint', buffType: 'debuff' }], 'NIN': [{ name: 'Feint', buffType: 'debuff' }], 'SAM': [{ name: 'Feint', buffType: 'debuff' }], 'RPR': [{ name: 'Feint', buffType: 'debuff' }], 'VPR': [{ name: 'Feint', buffType: 'debuff' }], 'BLM': [{ name: 'Addle', buffType: 'debuff' }], 'SMN': [ { name: 'Addle', buffType: 'debuff' }, { name: 'Radiant Aegis', buffType: 'shield' }, ], 'RDM': [ { name: 'Addle', buffType: 'debuff' }, { name: 'Magick Barrier', buffType: 'buff' }, ], 'PCT': [ { name: 'Addle', buffType: 'debuff' }, { name: 'Tempera Coat', buffType: 'shield' }, { name: 'Tempera Grassa', buffType: 'shield' }, ], }; const MITIG_ICONS = { 'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png', 'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png', 'Heart of Light': 'assets/icons/mitigation/heart-of-light.png', 'Temperance': 'assets/icons/mitigation/temperance.png', 'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png', 'Expedient': 'assets/icons/mitigation/expedient.png', 'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png', 'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png', 'Holos': 'assets/icons/mitigation/holos.png', 'Kerachole': 'assets/icons/mitigation/kerachole.png', 'Troubadour': 'assets/icons/mitigation/troubadour.png', 'Tactician': 'assets/icons/mitigation/tactician.png', 'Shield Samba': 'assets/icons/mitigation/shield-samba.png', 'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png', 'Reprisal': 'assets/icons/mitigation/reprisal.png', 'Feint': 'assets/icons/mitigation/feint.png', 'Addle': 'assets/icons/mitigation/addle.png', 'Divine Veil': 'assets/icons/mitigation/divine-veil.png', 'Guardian': 'assets/icons/mitigation/guardian.png', 'Shake It Off': 'assets/icons/mitigation/shake-it-off.png', 'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png', 'Divine Benison': 'assets/icons/mitigation/divine-benison.png', 'Divine Caress': 'assets/icons/mitigation/divine-caress.png', 'Intersection': 'assets/icons/mitigation/intersection.png', 'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png', 'the Spire': 'assets/icons/mitigation/the-spire.png', 'Panhaima': 'assets/icons/mitigation/panhaima.png', 'Holosakos': 'assets/icons/mitigation/holos.png', 'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png', 'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png', 'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png', 'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png', 'Haima': 'assets/icons/mitigation/haima.png', 'Galvanize': 'assets/icons/mitigation/galvanize.png', 'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png', 'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png', 'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png', 'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png', 'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png', }; // DR values (0–1) for buff/debuff mitigations — shields excluded (no reliable sim). const ABILITY_DR = { 'Passage of Arms': 0.15, 'Troubadour': 0.15, 'Tactician': 0.15, 'Shield Samba': 0.15, 'Dark Missionary': 0.10, 'Heart of Light': 0.10, 'Temperance': 0.10, 'Sacred Soil': 0.10, 'Expedient': 0.10, 'Collective Unconscious': 0.10, 'Holos': 0.10, 'Kerachole': 0.10, 'Magick Barrier': 0.10, 'Fey Illumination': 0.05, 'Reprisal': 0.10, 'Feint': 0.05, // 10% phys / 5% magic — use magic (conservative) 'Addle': 0.10, }; // Groups of abilities that are functionally equivalent across different jobs. // Used to suggest replacements when a job is missing from the composition. const ABILITY_EQUIVALENTS = [ ['Troubadour', 'Tactician', 'Shield Samba'], ['Sacred Soil', 'Kerachole'], ]; function findEquivSuggestions(abilityName, activeJobSet) { const group = ABILITY_EQUIVALENTS.find(g => g.includes(abilityName)); if (!group) return []; const suggestions = []; for (const equiv of group) { if (equiv === abilityName) continue; for (const job of activeJobSet) { if ((JOB_ABILITIES[job] ?? []).some(a => a.name === equiv)) { suggestions.push({ ability: equiv, job }); break; } } } return suggestions; } const ASSIGN_ORDER = { debuff: 0, buff: 1, shield: 2 }; function sortedAssignments(assignments) { return [...assignments].sort((a, b) => (ASSIGN_ORDER[a.buffType] ?? 1) - (ASSIGN_ORDER[b.buffType] ?? 1) ); } function guessJob(abilityName, players) { if (ABILITY_JOB_MAP[abilityName]) return ABILITY_JOB_MAP[abilityName]; const jobs = (players ?? []).map(p => JOB_FROM_TYPE[p.type] ?? ''); if (abilityName === 'Reprisal') { const tanks = jobs.filter(j => TANK_JOBS.has(j)); return tanks.length === 1 ? tanks[0] : ''; } if (abilityName === 'Feint') { const melees = jobs.filter(j => MELEE_JOBS.has(j)); return melees.length === 1 ? melees[0] : ''; } if (abilityName === 'Addle') { const casters = jobs.filter(j => CASTER_JOBS.has(j)); return casters.length === 1 ? casters[0] : ''; } return ''; } function mitigationDisplayName(mitigation) { return mitigation?.name ?? mitigation?.key ?? ''; } function mitigationKey(mitigation) { return mitigation?.key ?? mitigation?.name ?? ''; } // ── AoE Events → Plan Mechanics ─────────────────────────────────────────────── function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations, mitigationNames = {}) { return aoeEvents.map(ev => { const relTs = ev.timestamp - fightStart; const phase = (phases ?? []).filter(p => p.id !== 0).find(p => ev.timestamp >= p.startTime && ev.timestamp < p.endTime ); const nonTankTargets = ev.targets.filter(t => t.role !== 'tank' && (t.unmitigatedAmount ?? 0) > 0); const fallbackTargets = ev.targets.filter(t => (t.unmitigatedAmount ?? 0) > 0); const relevantTargets = nonTankTargets.length > 0 ? nonTankTargets : fallbackTargets; const avgUnmit = relevantTargets.length > 0 ? Math.round(relevantTargets.reduce((s, t) => s + t.unmitigatedAmount, 0) / relevantTargets.length) : 0; let assignments = []; if (withMitigations) { const seen = new Set(); for (const t of ev.targets) { for (const m of (t.mitigations ?? [])) { const key = mitigationKey(m); if (!seen.has(key)) { seen.add(key); assignments.push({ ability: key, abilityName: mitigationDisplayName(m) || mitigationNames[key], actionId: m.extraAbilityGameID ?? null, job: guessJob(key, players), buffType: m.buffType ?? '', }); } } } } return { id: crypto.randomUUID(), name: ev.abilityName, abilityId: ev.abilityId, timestamp: relTs, phase: phase?.name ?? '', unmitigatedDamage: avgUnmit, notes: '', assignments, }; }); } // ── Merge + Create plan from import ────────────────────────────────────────── function buildPlayerRoster(players, aoeEvents) { const maxHpByName = {}; for (const ev of aoeEvents ?? []) { for (const t of ev.targets ?? []) { if (t.name && t.maxHp > 0 && !(t.name in maxHpByName)) { maxHpByName[t.name] = t.maxHp; } } } const order = { healer: 0, dps: 1, tank: 2 }; const sorted = [...(players ?? [])] .filter(p => JOB_FROM_TYPE[p.type]) .sort((a, b) => { const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1); return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); }); const roster = sorted.slice(0, 8).map(p => ({ name: p.name, maxHp: maxHpByName[p.name] ?? 0, })); while (roster.length < 8) roster.push({ name: '', maxHp: 0 }); return roster; } function extractJobComp(players) { const order = { healer: 0, dps: 1, tank: 2 }; const sorted = [...(players ?? [])] .filter(p => JOB_FROM_TYPE[p.type]) .sort((a, b) => { const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1); return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); }); const comp = sorted.map(p => JOB_FROM_TYPE[p.type] ?? '').slice(0, 8); while (comp.length < 8) comp.push(''); return comp; } function doImport(data, withMitigations, whereMode, mergeId, newName) { const { aoeEvents, fightStart, fightEnd, phases, players, fightName, reportCode, fightId, mitigationNames = {} } = data; const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations, mitigationNames); const source = { reportCode, fightId, fightName, fightStart, fightEnd, language: plannerLanguage() }; if (whereMode === 'new') { const plan = createPlan(uniquePlanName(newName || fightName || 'Importierter Plan')); return updatePlan(plan.id, { mechanics, source, mitigationNames, jobComposition: extractJobComp(players), playerRoster: buildPlayerRoster(players, aoeEvents), }); } // Merge into existing plan const plan = getPlan(mergeId); if (!plan) return null; const merged = [...plan.mechanics]; for (const newM of mechanics) { const exists = plan.mechanics.some(m => m.name === newM.name && Math.abs(m.timestamp - newM.timestamp) < 5000 ); if (!exists) merged.push(newM); } merged.sort((a, b) => a.timestamp - b.timestamp); return updatePlan(mergeId, { mechanics: merged, source: { ...(plan.source ?? {}), ...source }, mitigationNames: { ...(plan.mitigationNames ?? {}), ...mitigationNames }, }); } const refreshingPlans = new Set(); async function refreshPlanLanguage(planId) { const plan = getPlan(planId); const source = plan?.source ?? {}; const language = plannerLanguage(); if (!plan || refreshingPlans.has(planId)) return; if (!source.reportCode || !source.fightId || !source.fightStart || !source.fightEnd) return; if (source.language === language && plan.mitigationNames && Object.keys(plan.mitigationNames).length) return; refreshingPlans.add(planId); try { const params = new URLSearchParams({ report_code: source.reportCode, fight_id: source.fightId, start_time: source.fightStart, end_time: source.fightEnd, language, }); const res = await fetch('api/analysis.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const json = await res.json(); if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } if (json.error) return; const refreshed = (json.aoe_events ?? []); const mechanics = plan.mechanics.map(mechanic => { const match = refreshed.find(ev => sameMechanic(mechanic, ev, source)); if (!match) return mechanic; const assignments = (mechanic.assignments ?? []).map(a => ({ ...a, abilityName: json.mitigation_names?.[a.ability] ?? a.abilityName, })); return { ...mechanic, name: match.abilityName ?? mechanic.name, abilityId: match.abilityId ?? mechanic.abilityId, assignments, }; }); let fightName = source.fightName; try { const fightRes = await fetch('api/fight.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ report_code: source.reportCode, language }), }); const fightJson = await fightRes.json(); const fight = (fightJson?.data?.reportData?.report?.fights ?? []).find(f => f.id === source.fightId); if (fight?.name) fightName = fight.name; } catch { } const nextSource = { ...source, fightName, language }; const nextName = plan.name === source.fightName && fightName ? fightName : plan.name; const updated = updatePlan(planId, { name: nextName, mechanics, source: nextSource, mitigationNames: json.mitigation_names ?? plan.mitigationNames ?? {}, }); if (updated && activePlanId === planId) { renderPlanList(); renderPlanDetail(updated); } } catch { } finally { refreshingPlans.delete(planId); } } // ── Ability Assignment Modal ────────────────────────────────────────────────── let abilityModalPlanId = null; let abilityModalMechanicId = null; function showAbilityModal(planId, mechanicId) { abilityModalPlanId = planId; abilityModalMechanicId = mechanicId; renderAbilityModalContent(); document.getElementById('planner-ability-modal').style.display = 'flex'; } function hideAbilityModal() { document.getElementById('planner-ability-modal').style.display = 'none'; abilityModalPlanId = null; abilityModalMechanicId = null; } function renderAbilityModalContent() { const plan = getPlan(abilityModalPlanId); if (!plan) return; const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId); if (!mechanic) return; document.getElementById('ability-modal-title').textContent = mechanic.name; const activeJobs = [...new Set(plan.jobComposition.filter(j => j))]; const content = document.getElementById('ability-modal-content'); if (activeJobs.length === 0) { content.innerHTML = `

Bitte zuerst die Jobaufstellung konfigurieren.

`; return; } content.innerHTML = activeJobs.map(job => { const abilities = JOB_ABILITIES[job] ?? []; if (!abilities.length) return ''; const role = JOB_ROLE[job] ?? 'dps'; const chips = abilities.map(ab => { const assigned = mechanic.assignments.find(a => a.ability === ab.name); const isActive = !!assigned; const byOtherJob = isActive && assigned.job !== job; const cls = ab.buffType === 'debuff' ? 'badge-assign-debuff' : ab.buffType === 'shield' ? 'badge-assign-shield' : 'badge-assign-buff'; const activeClass = isActive ? ' ability-chip--active' : ''; const otherClass = byOtherJob ? ' ability-chip--other-job' : ''; const title = byOtherJob ? `Bereits von ${escHtml(assigned.job)} zugewiesen` : ''; const icon = MITIG_ICONS[ab.name] ?? ''; const assignedName = assigned ? assignmentAbilityName(assigned, plan) : ''; const label = assignedName || plan.mitigationNames?.[ab.name] || ab.name; return ``; }).join(''); return `
${escHtml(job)}
${chips}
`; }).join(''); } function toggleAbilityAssignment(abilityName, job, buffType) { const plan = getPlan(abilityModalPlanId); if (!plan) return; const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId); if (!mechanic) return; const idx = mechanic.assignments.findIndex(a => a.ability === abilityName); if (idx !== -1) { if (mechanic.assignments[idx].job === job) { mechanic.assignments.splice(idx, 1); } else { mechanic.assignments[idx].job = job; } } else { mechanic.assignments.push({ ability: abilityName, abilityName: plan.mitigationNames?.[abilityName], actionId: actionMetaByName[abilityName]?.id ?? null, job, buffType, }); } updatePlan(abilityModalPlanId, { mechanics: plan.mechanics }); refreshMechanicList(abilityModalPlanId); renderAbilityModalContent(); } function initAbilityModal() { const overlay = document.getElementById('planner-ability-modal'); if (!overlay) return; document.getElementById('ability-modal-close')?.addEventListener('click', hideAbilityModal); overlay.addEventListener('click', e => { if (e.target === overlay) hideAbilityModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && abilityModalPlanId) hideAbilityModal(); }); document.getElementById('ability-modal-content')?.addEventListener('click', e => { const btn = e.target.closest('.ability-chip'); if (!btn) return; toggleAbilityAssignment(btn.dataset.ability, btn.dataset.job, btn.dataset.buffType); }); } // ── Import Modal ────────────────────────────────────────────────────────────── let pendingImportData = null; function showImportModal(data) { pendingImportData = data; // Pre-fill name — auto-unique so the user sees immediately what name will be used const nameInput = document.getElementById('import-plan-name'); if (nameInput) { nameInput.value = uniquePlanName(data.fightName || 'Importierter Plan'); } // Populate merge dropdown const planSelect = document.getElementById('import-plan-select'); if (planSelect) { const plans = loadPlans(); planSelect.innerHTML = ''; plans.forEach(p => { const opt = document.createElement('option'); opt.value = p.id; opt.textContent = p.name; planSelect.appendChild(opt); }); const mergeLabel = document.getElementById('import-merge-label'); if (mergeLabel) mergeLabel.style.opacity = plans.length === 0 ? '0.4' : ''; const mergeRadio = document.querySelector('input[name="import-where"][value="merge"]'); if (mergeRadio) mergeRadio.disabled = plans.length === 0; } // Reset to defaults document.querySelectorAll('input[name="import-what"]').forEach(r => { r.checked = r.value === 'with-mitigations'; }); document.querySelectorAll('input[name="import-where"]').forEach(r => { r.checked = r.value === 'new'; }); document.getElementById('import-new-section').style.display = ''; document.getElementById('import-merge-section').style.display = 'none'; document.getElementById('planner-import-modal').style.display = 'flex'; nameInput?.focus(); nameInput?.select(); } function hideImportModal() { document.getElementById('planner-import-modal').style.display = 'none'; pendingImportData = null; } function initImportModal() { document.querySelectorAll('input[name="import-where"]').forEach(radio => { radio.addEventListener('change', () => { document.getElementById('import-new-section').style.display = radio.value === 'new' ? '' : 'none'; document.getElementById('import-merge-section').style.display = radio.value === 'merge' ? '' : 'none'; }); }); document.getElementById('import-cancel-btn')?.addEventListener('click', hideImportModal); document.getElementById('planner-import-modal')?.addEventListener('click', e => { if (e.target === e.currentTarget) hideImportModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && pendingImportData) hideImportModal(); }); document.getElementById('import-confirm-btn')?.addEventListener('click', () => { if (!pendingImportData) return; const withMitigations = document.querySelector('input[name="import-what"]:checked')?.value === 'with-mitigations'; const whereMode = document.querySelector('input[name="import-where"]:checked')?.value ?? 'new'; const newName = document.getElementById('import-plan-name')?.value.trim(); const mergeId = document.getElementById('import-plan-select')?.value; if (whereMode === 'new' && !newName) { document.getElementById('import-plan-name')?.focus(); return; } if (whereMode === 'merge' && !mergeId) { document.getElementById('import-plan-select')?.focus(); return; } const plan = doImport(pendingImportData, withMitigations, whereMode, mergeId, newName); if (!plan) return; hideImportModal(); renderPlanList(); openPlan(plan.id); window.showTab?.('planner'); }); } // ── Name Import Modal ───────────────────────────────────────────────────────── let nameImportPlanId = null; let nameImportFights = []; let nameImportReportCode = ''; let nameImportMatchData = []; // length-8 array: null | {job, matched:[{name,type,role,maxHp}], selected:0} function showNameImportModal(planId) { nameImportPlanId = planId; nameImportFights = []; nameImportReportCode = ''; nameImportMatchData = []; document.getElementById('name-import-report-input').value = ''; document.getElementById('name-import-fight-section').style.display = 'none'; document.getElementById('name-import-fight-select').innerHTML = ''; document.getElementById('name-import-preview').style.display = 'none'; document.getElementById('name-import-preview').innerHTML = ''; document.getElementById('name-import-confirm-btn').style.display = 'none'; document.getElementById('planner-name-import-modal').style.display = 'flex'; document.getElementById('name-import-report-input').focus(); } function hideNameImportModal() { document.getElementById('planner-name-import-modal').style.display = 'none'; nameImportPlanId = null; } function refreshJobSlots(planId) { const plan = getPlan(planId); if (!plan) return; const grid = document.getElementById('job-slots-grid'); if (grid) grid.innerHTML = renderJobSlotsHtml(plan); } function renderNameImportPreview(_plan, fetchedPlayers) { const order = { healer: 0, dps: 1, tank: 2 }; const sorted = [...fetchedPlayers] .filter(p => JOB_FROM_TYPE[p.type]) .sort((a, b) => { const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1); return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); }); nameImportMatchData = sorted.slice(0, 8); const preview = document.getElementById('name-import-preview'); const rows = Array.from({ length: 8 }, (_, i) => { const p = sorted[i]; if (!p) { return `
Leer
`; } const job = JOB_FROM_TYPE[p.type] ?? p.type; const role = JOB_ROLE[job] ?? 'dps'; return `
${escHtml(job)} ${escHtml(p.name)}
`; }).join(''); preview.innerHTML = rows; preview.style.display = ''; } function initNameImportModal() { const modal = document.getElementById('planner-name-import-modal'); const reportInput = document.getElementById('name-import-report-input'); const loadBtn = document.getElementById('name-import-load-btn'); const fightSection = document.getElementById('name-import-fight-section'); const fightSelect = document.getElementById('name-import-fight-select'); const preview = document.getElementById('name-import-preview'); const confirmBtn = document.getElementById('name-import-confirm-btn'); const cancelBtn = document.getElementById('name-import-cancel-btn'); if (!modal) return; cancelBtn.addEventListener('click', hideNameImportModal); modal.addEventListener('click', e => { if (e.target === modal) hideNameImportModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && nameImportPlanId) hideNameImportModal(); }); reportInput.addEventListener('input', () => { const match = reportInput.value.match(/fflogs\.com\/reports\/([A-Za-z0-9]+)/); if (match) reportInput.value = match[1]; }); loadBtn.addEventListener('click', async () => { const code = reportInput.value.trim(); if (!code) return; loadBtn.disabled = true; loadBtn.textContent = 'Lädt…'; fightSection.style.display = 'none'; preview.style.display = 'none'; confirmBtn.style.display = 'none'; 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: 'en' }), }); const json = await res.json(); if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } nameImportFights = json?.data?.reportData?.report?.fights ?? []; nameImportReportCode = code; fightSelect.innerHTML = ''; nameImportFights.forEach(f => { const ms = f.endTime - f.startTime; const dur = `${Math.floor(ms / 60000)}:${String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')}`; const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?'); const opt = document.createElement('option'); opt.value = f.id; opt.textContent = `${f.name} — ${dur} — ${hp}`; fightSelect.appendChild(opt); }); fightSection.style.display = ''; } catch { } loadBtn.disabled = false; loadBtn.textContent = 'Laden'; }); fightSelect.addEventListener('change', async () => { const fightId = parseInt(fightSelect.value, 10); if (!fightId) { preview.style.display = 'none'; confirmBtn.style.display = 'none'; return; } const fight = nameImportFights.find(f => f.id === fightId); if (!fight) return; fightSelect.disabled = true; preview.innerHTML = '
Lädt…
'; preview.style.display = ''; confirmBtn.style.display = 'none'; try { const res = await fetch('api/players.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ report_code: nameImportReportCode, fight_id: fightId, start_time: fight.startTime, end_time: fight.endTime, }), }); const json = await res.json(); if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } if (json.error) { preview.innerHTML = `
${escHtml(json.error)}
`; return; } const plan = getPlan(nameImportPlanId); if (!plan) return; renderNameImportPreview(plan, json.players ?? []); confirmBtn.style.display = ''; } catch { } fightSelect.disabled = false; }); confirmBtn.addEventListener('click', () => { if (!nameImportPlanId) return; const jobComposition = Array.from({ length: 8 }, (_, i) => { const p = nameImportMatchData[i]; return p ? (JOB_FROM_TYPE[p.type] ?? '') : ''; }); const playerRoster = Array.from({ length: 8 }, (_, i) => { const p = nameImportMatchData[i]; return p ? { name: p.name, maxHp: p.maxHp ?? 0 } : { name: '', maxHp: 0 }; }); updatePlan(nameImportPlanId, { jobComposition, playerRoster }); openPlan(nameImportPlanId); hideNameImportModal(); }); } // ── window.plannerTab (hooks for other tabs) ────────────────────────────────── window.plannerTab = { onTabOpen() { renderPlanList(); if (activePlanId) { openPlan(activePlanId); } else { renderPlanDetail(null); } }, showImportModal(data) { showImportModal(data); }, importFromAnalysis(aoeEvents, _refEvents, _options) { console.log('[Planner] importFromAnalysis — use showImportModal instead', aoeEvents); }, }; // ── Init ────────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { initNewPlanForm(); initNewFolderForm(); initImportModal(); initAbilityModal(); initNameImportModal(); activePlanId = localStorage.getItem(PLANNER_ACTIVE_KEY); renderPlanList(); if (activePlanId && getPlan(activePlanId)) { renderPlanDetail(getPlan(activePlanId)); } else { activePlanId = null; renderPlanDetail(null); } }); window.addEventListener('ff14-language-change', () => { if (!activePlanId) return; refreshPlanLanguage(activePlanId); });