forked from xziino/ff14-mitigator
961 lines
39 KiB
JavaScript
961 lines
39 KiB
JavaScript
// ── Storage ───────────────────────────────────────────────────────────────────
|
||
|
||
const PLANNER_KEY = 'ff14-planner-plans';
|
||
|
||
function loadPlans() {
|
||
try { return JSON.parse(localStorage.getItem(PLANNER_KEY) || '[]'); }
|
||
catch { return []; }
|
||
}
|
||
|
||
function savePlans(plans) {
|
||
localStorage.setItem(PLANNER_KEY, JSON.stringify(plans));
|
||
}
|
||
|
||
// ── CRUD ──────────────────────────────────────────────────────────────────────
|
||
|
||
function createPlan(name) {
|
||
const plan = {
|
||
id: crypto.randomUUID(),
|
||
name: name.trim() || 'Unbenannter Plan',
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now(),
|
||
source: 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 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: orig.name + ' (Kopie)',
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now()
|
||
};
|
||
all.push(copy);
|
||
savePlans(all);
|
||
return copy;
|
||
}
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────────
|
||
|
||
let activePlanId = null;
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
function escHtml(str) {
|
||
return String(str ?? '')
|
||
.replace(/&/g, '&')
|
||
.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) {
|
||
return assignment?.abilityName ?? assignment?.ability ?? '';
|
||
}
|
||
|
||
// ── Rendering: Plan List ──────────────────────────────────────────────────────
|
||
|
||
function renderPlanList() {
|
||
const el = document.getElementById('plan-list');
|
||
if (!el) return;
|
||
|
||
const plans = loadPlans();
|
||
|
||
if (plans.length === 0) {
|
||
el.innerHTML = '<div class="plan-list-empty">Noch keine Pläne vorhanden</div>';
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = plans.map(p => `
|
||
<div class="plan-item${p.id === activePlanId ? ' active' : ''}" data-id="${escHtml(p.id)}">
|
||
<div class="plan-item-info">
|
||
<div class="plan-item-name">${escHtml(p.name)}</div>
|
||
<div class="plan-item-meta">${p.mechanics.length} Mechaniken · ${fmtDate(p.updatedAt)}</div>
|
||
</div>
|
||
<div class="plan-item-actions">
|
||
<button class="plan-btn plan-btn-copy" data-id="${escHtml(p.id)}" title="Kopieren">⎘</button>
|
||
<button class="plan-btn plan-btn-danger plan-btn-delete" data-id="${escHtml(p.id)}" title="Löschen">✕</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
el.querySelectorAll('.plan-item').forEach(row => {
|
||
row.addEventListener('click', e => {
|
||
if (e.target.closest('.plan-item-actions')) return;
|
||
openPlan(row.dataset.id);
|
||
});
|
||
});
|
||
|
||
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); }
|
||
});
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── 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';
|
||
return;
|
||
}
|
||
|
||
noplan.style.display = 'none';
|
||
content.style.display = '';
|
||
|
||
content.innerHTML = `
|
||
<div class="card section-gap">
|
||
<div class="plan-detail-header">
|
||
<div class="plan-name-wrap">
|
||
<span id="plan-name-display" class="plan-name-text">${escHtml(plan.name)}</span>
|
||
<button id="plan-name-edit-btn" class="plan-btn" title="Umbenennen">✎</button>
|
||
</div>
|
||
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} · ${plan.mechanics.length} Mechaniken</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card section-gap">
|
||
<div class="card-title">Jobaufstellung</div>
|
||
<div class="job-slots-grid" id="job-slots-grid">
|
||
${renderJobSlotsHtml(plan)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-title-row">
|
||
<div class="card-title">Mechaniken</div>
|
||
</div>
|
||
<div id="mechanic-list">
|
||
${renderMechanicListHtml(plan)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('plan-name-edit-btn')?.addEventListener('click', () => {
|
||
startRename(plan.id, plan.name);
|
||
});
|
||
initJobSlots(plan.id);
|
||
initMechanicClicks(plan.id);
|
||
}
|
||
|
||
function renderMechanicListHtml(plan) {
|
||
if (plan.mechanics.length === 0) {
|
||
return `
|
||
<div class="empty" style="padding:30px 0">
|
||
<div class="empty-icon" style="font-size:26px">📋</div>
|
||
<h3>Noch keine Mechaniken</h3>
|
||
<p style="font-size:13px;color:var(--t3);margin-top:6px">
|
||
Importiere einen Log aus dem Analyse-Tab
|
||
</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const activeJobSet = new Set(plan.jobComposition.filter(j => j));
|
||
|
||
return plan.mechanics.map(m => {
|
||
const sorted = sortedAssignments(m.assignments);
|
||
const assignHtml = sorted.length === 0
|
||
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
|
||
: 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);
|
||
const label = a.job ? `${escHtml(a.job)} · ${escHtml(ability)}` : escHtml(ability);
|
||
const title = isMissing ? `${escHtml(a.job)} nicht in Jobaufstellung` : '';
|
||
return `<span class="badge badge-assign ${cls}${isMissing ? ' badge-assign--missing-job' : ''}"${title ? ` title="${title}"` : ''}>
|
||
${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}
|
||
${label}
|
||
<button class="badge-remove" data-mechanic-id="${escHtml(m.id)}" data-ability="${escHtml(a.ability)}" title="Entfernen">×</button>
|
||
</span>`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="mechanic-card" data-mechanic-id="${escHtml(m.id)}">
|
||
<div class="mechanic-time">${escHtml(fmtTimestamp(m.timestamp))}</div>
|
||
<div class="mechanic-body">
|
||
${m.phase ? `<div class="mechanic-phase">${escHtml(m.phase)}</div>` : ''}
|
||
<div class="mechanic-name">${escHtml(m.name)}</div>
|
||
${m.unmitigatedDamage
|
||
? `<div class="mechanic-dmg">${fmtNumber(m.unmitigatedDamage)} unmitigiert</div>`
|
||
: ''
|
||
}
|
||
<div class="mechanic-assignments">${assignHtml}</div>
|
||
${m.notes ? `<div class="mechanic-notes">${escHtml(m.notes)}</div>` : ''}
|
||
<div class="mechanic-edit-hint">Klicken zum Bearbeiten</div>
|
||
</div>
|
||
<button class="mechanic-delete-btn plan-btn plan-btn-danger" data-mechanic-id="${escHtml(m.id)}" title="Mechanik löschen">✕</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ── Job Slots ─────────────────────────────────────────────────────────────────
|
||
|
||
function renderJobSlotsHtml(plan) {
|
||
return Array.from({ length: 8 }, (_, i) => {
|
||
const job = plan.jobComposition[i] ?? '';
|
||
const role = JOB_ROLE[job] ?? '';
|
||
return `
|
||
<div class="job-slot${role ? ` job-slot--${role}` : ''}">
|
||
<select class="job-slot-select" data-idx="${i}">
|
||
<option value="">—</option>
|
||
${ALL_JOBS.map(g =>
|
||
`<optgroup label="${escHtml(g.group)}">${g.jobs.map(j =>
|
||
`<option value="${j}"${j === job ? ' selected' : ''}>${j}</option>`
|
||
).join('')}</optgroup>`
|
||
).join('')}
|
||
</select>
|
||
</div>`;
|
||
}).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);
|
||
});
|
||
}
|
||
|
||
// ── Mechanic list helpers ─────────────────────────────────────────────────────
|
||
|
||
function refreshMechanicList(planId) {
|
||
const plan = getPlan(planId);
|
||
if (!plan) return;
|
||
const el = document.getElementById('mechanic-list');
|
||
if (el) el.innerHTML = renderMechanicListHtml(plan);
|
||
}
|
||
|
||
function removeAssignment(planId, mechanicId, abilityName) {
|
||
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);
|
||
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);
|
||
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);
|
||
});
|
||
}
|
||
|
||
// ── 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 = `<input id="plan-name-input" class="plan-name-input" type="text" value="${escHtml(currentName)}">`;
|
||
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 = `<span class="plan-name-text">${escHtml(currentName)}</span>`;
|
||
if (editBtn) editBtn.style.display = '';
|
||
}
|
||
});
|
||
input.addEventListener('blur', save);
|
||
}
|
||
|
||
// ── Open plan ─────────────────────────────────────────────────────────────────
|
||
|
||
function openPlan(id) {
|
||
activePlanId = id;
|
||
renderPlanList();
|
||
renderPlanDetail(getPlan(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 doCreate = () => {
|
||
const name = input.value.trim();
|
||
if (!name) { input.focus(); return; }
|
||
const plan = createPlan(name);
|
||
form.style.display = 'none';
|
||
renderPlanList();
|
||
openPlan(plan.id);
|
||
};
|
||
|
||
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 Prognosis II': '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 Prognosis II', buffType: 'shield' },
|
||
{ name: 'Eukrasian Diagnosis', buffType: 'shield' },
|
||
{ name: 'Differential Diagnosis', buffType: 'shield' },
|
||
{ name: 'Haima', buffType: 'shield' },
|
||
{ name: 'Addle', buffType: 'debuff' },
|
||
],
|
||
'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',
|
||
};
|
||
|
||
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) {
|
||
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),
|
||
job: guessJob(key, players),
|
||
buffType: m.buffType ?? '',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
id: crypto.randomUUID(),
|
||
name: ev.abilityName,
|
||
timestamp: relTs,
|
||
phase: phase?.name ?? '',
|
||
unmitigatedDamage: avgUnmit,
|
||
notes: '',
|
||
assignments,
|
||
};
|
||
});
|
||
}
|
||
|
||
// ── Merge + Create plan from import ──────────────────────────────────────────
|
||
|
||
function extractJobComp(players) {
|
||
const order = { tank: 0, healer: 1, dps: 2 };
|
||
const sorted = [...(players ?? [])]
|
||
.filter(p => JOB_FROM_TYPE[p.type])
|
||
.sort((a, b) => {
|
||
const roleCmp = (order[a.role] ?? 2) - (order[b.role] ?? 2);
|
||
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, phases, players, fightName, reportCode } = data;
|
||
const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations);
|
||
|
||
if (whereMode === 'new') {
|
||
const plan = createPlan(newName || fightName || 'Importierter Plan');
|
||
return updatePlan(plan.id, {
|
||
mechanics,
|
||
source: { reportCode, fightName },
|
||
jobComposition: extractJobComp(players),
|
||
});
|
||
}
|
||
|
||
// 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 });
|
||
}
|
||
|
||
// ── 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 = `
|
||
<p style="color:var(--t3);font-size:13px;padding:8px 0">
|
||
Bitte zuerst die Jobaufstellung konfigurieren.
|
||
</p>`;
|
||
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) : '';
|
||
const label = assignedName || ab.name;
|
||
return `<button class="ability-chip ${cls}${activeClass}${otherClass}"
|
||
data-ability="${escHtml(ab.name)}"
|
||
data-job="${escHtml(job)}"
|
||
data-buff-type="${escHtml(ab.buffType)}"
|
||
${title ? `title="${title}"` : ''}
|
||
>${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}${escHtml(label)}</button>`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="ability-job-group">
|
||
<div class="ability-job-label">
|
||
<span class="aoe-target-job role-${role}">${escHtml(job)}</span>
|
||
</div>
|
||
<div class="ability-chips">${chips}</div>
|
||
</div>`;
|
||
}).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, 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
|
||
const nameInput = document.getElementById('import-plan-name');
|
||
if (nameInput) { nameInput.value = data.fightName || ''; }
|
||
|
||
// Populate merge dropdown
|
||
const planSelect = document.getElementById('import-plan-select');
|
||
if (planSelect) {
|
||
const plans = loadPlans();
|
||
planSelect.innerHTML = '<option value="">— Plan auswählen —</option>';
|
||
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');
|
||
});
|
||
}
|
||
|
||
// ── 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();
|
||
initImportModal();
|
||
initAbilityModal();
|
||
renderPlanList();
|
||
renderPlanDetail(null);
|
||
});
|
||
|
||
window.addEventListener('ff14-language-change', () => {
|
||
if (!activePlanId) return;
|
||
renderPlanDetail(getPlan(activePlanId));
|
||
});
|