@@ -230,34 +234,136 @@ function renderMechanicList(plan) {
`;
}
- return plan.mechanics.map(m => `
-
-
${escHtml(fmtTimestamp(m.timestamp))}
-
- ${m.phase ? `
${escHtml(m.phase)}
` : ''}
-
${escHtml(m.name)}
- ${m.unmitigatedDamage
- ? `
${fmtNumber(m.unmitigatedDamage)} unmitigiert
`
- : ''
- }
-
- ${m.assignments.length === 0
- ? '
Keine Zuweisung'
- : m.assignments.map(a => {
- const cls = a.buffType === 'debuff' ? 'badge-assign-debuff'
- : a.buffType === 'shield' ? 'badge-assign-shield'
- : a.buffType === 'buff' ? 'badge-assign-buff'
- : '';
- const ability = localizedAbilityName(a.ability, a.abilityName ?? a.ability);
- const label = a.job ? `${escHtml(a.job)} · ${escHtml(ability)}` : escHtml(ability);
- return `
${label}`;
- }).join('')
+ const activeJobSet = new Set(plan.jobComposition.filter(j => j));
+
+ return plan.mechanics.map(m => {
+ const sorted = sortedAssignments(m.assignments);
+ 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 = localizedAbilityName(a.ability, a.abilityName ?? a.ability);
+ const label = a.job ? `${escHtml(a.job)} · ${escHtml(ability)}` : escHtml(ability);
+ const title = isMissing ? `${escHtml(a.job)} nicht in Jobaufstellung` : '';
+ return `
+ ${icon ? `
` : ''}
+ ${label}
+
+ `;
+ }).join('');
+
+ return `
+
+
${escHtml(fmtTimestamp(m.timestamp))}
+
+ ${m.phase ? `
${escHtml(m.phase)}
` : ''}
+
${escHtml(m.name)}
+ ${m.unmitigatedDamage
+ ? `
${fmtNumber(m.unmitigatedDamage)} unmitigiert
`
+ : ''
}
+
${assignHtml}
+ ${m.notes ? `
${escHtml(m.notes)}
` : ''}
+
Klicken zum Bearbeiten
- ${m.notes ? `
${escHtml(m.notes)}
` : ''}
-
-
- `).join('');
+
+
`;
+ }).join('');
+}
+
+// ── Job Slots ─────────────────────────────────────────────────────────────────
+
+function renderJobSlotsHtml(plan) {
+ return Array.from({ length: 8 }, (_, i) => {
+ const job = plan.jobComposition[i] ?? '';
+ const role = JOB_ROLE[job] ?? '';
+ return `
+
+
+
`;
+ }).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 ────────────────────────────────────────────────────────────────────
@@ -373,6 +479,152 @@ 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] ?? '');
@@ -448,6 +700,19 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
// ── 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);
@@ -456,7 +721,8 @@ function doImport(data, withMitigations, whereMode, mergeId, newName) {
const plan = createPlan(newName || fightName || 'Importierter Plan');
return updatePlan(plan.id, {
mechanics,
- source: { reportCode, fightName },
+ source: { reportCode, fightName },
+ jobComposition: extractJobComp(players),
});
}
@@ -476,6 +742,117 @@ function doImport(data, withMitigations, whereMode, mergeId, newName) {
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 = `
+
+ 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 label = localizedAbilityName(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: localizedAbilityName(abilityName, 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;
@@ -591,6 +968,7 @@ window.plannerTab = {
document.addEventListener('DOMContentLoaded', () => {
initNewPlanForm();
initImportModal();
+ initAbilityModal();
renderPlanList();
renderPlanDetail(null);
});
diff --git a/templates/page.php b/templates/page.php
index 1f2eadb..17e3bc9 100644
--- a/templates/page.php
+++ b/templates/page.php
@@ -80,6 +80,17 @@