From 8f00c22682f0de3cfe138e4f96fc19efe856a388 Mon Sep 17 00:00:00 2001 From: Akurosia Kamo Date: Sat, 23 May 2026 21:22:10 +0200 Subject: [PATCH] more timeline fixes and addle fix --- css/planner.css | 5 +++ js/analysis.js | 2 +- js/planner.js | 111 ++++++++++++++++++++++++++++++++++++------------ 3 files changed, 90 insertions(+), 28 deletions(-) diff --git a/css/planner.css b/css/planner.css index acc205d..00627e9 100644 --- a/css/planner.css +++ b/css/planner.css @@ -756,6 +756,10 @@ font-size: 12px; color: var(--t2); min-width: 0; + position: sticky; + left: 0; + z-index: 8; + box-shadow: 8px 0 12px rgba(0,0,0,.18); } .timeline-boss-row { @@ -765,6 +769,7 @@ .timeline-boss-row .timeline-row-label { color: var(--gold); font-weight: 600; + z-index: 9; } .timeline-track, diff --git a/js/analysis.js b/js/analysis.js index 1ab74c9..7a71b95 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -91,7 +91,7 @@ 'Shield Samba': ['DNC'], 'Improvised Finish': ['DNC'], 'Feint': ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'], - 'Addle': ['SCH', 'SGE', 'BLM', 'SMN', 'RDM', 'PCT'], + 'Addle': ['BLM', 'SMN', 'RDM', 'PCT'], 'Radiant Aegis': ['SMN'], 'Magick Barrier': ['RDM'], 'Tempera Coat': ['PCT'], diff --git a/js/planner.js b/js/planner.js index a106d5c..0ee2cae 100644 --- a/js/planner.js +++ b/js/planner.js @@ -448,10 +448,10 @@ function effectiveAssignmentsForMechanic(plan, targetMechanic) { const result = []; const seen = new Set(); - for (const entry of canonicalAssignmentActivations(plan)) { + for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: canonicalMechanicKey })) { if (targetMechanic.timestamp < entry.start || targetMechanic.timestamp > entry.end) continue; const assignment = entry.assignment; - const key = `${assignment.ability}::${assignment.job ?? ''}`; + const key = canonicalMechanicKey(entry); if (seen.has(key)) continue; seen.add(key); result.push({ @@ -641,7 +641,15 @@ function assignmentStartMs(mechanic, assignment) { return Number.isFinite(Number(assignment?.timestamp)) ? Number(assignment.timestamp) : mechanic.timestamp; } -function canonicalAssignmentActivations(plan) { +function canonicalMechanicKey(entry) { + return `${entry.assignment.ability}::${entry.assignment.job || '*'}`; +} + +function canonicalTimelineKey(entry, row) { + return `${row.job}::${row.ability}`; +} + +function canonicalAssignmentActivations(plan, { dedupeKey = canonicalMechanicKey, includeEntry = null } = {}) { const entries = []; for (const mechanic of visiblePlanMechanics(plan)) { for (const assignment of mechanic.assignments ?? []) { @@ -660,9 +668,10 @@ function canonicalAssignmentActivations(plan) { entries.sort((a, b) => a.start - b.start); const activeUntilBySkill = new Map(); return entries.filter(entry => { - const key = `${entry.assignment.ability}::${entry.assignment.job ?? ''}`; + if (includeEntry && !includeEntry(entry)) return false; + const key = dedupeKey(entry); const activeUntil = activeUntilBySkill.get(key) ?? -Infinity; - if (entry.start < activeUntil) return false; + if (entry.start <= activeUntil) return false; activeUntilBySkill.set(key, entry.end); return true; }); @@ -698,17 +707,35 @@ function layoutBossActions(mechanics, duration) { }); } -function assignmentOverlapsJob(plan, job, ability, timestamp, ignore = null) { +function assignmentWindowMs(assignment) { + return Math.max(assignmentCooldownSeconds(assignment), assignmentDurationSeconds(assignment)) * 1000; +} + +function sameAssignmentRef(mechanic, assignment, ref) { + return !!ref + && ref.mechanicId === mechanic.id + && ref.ability === assignment.ability + && ref.job === (assignment.job ?? ''); +} + +function assignmentOverlapsJob(plan, job, ability, timestamp, ignore = null, candidate = null) { + const candidateWindow = Math.max( + candidate ? assignmentCooldownSeconds(candidate) : 0, + candidate ? assignmentDurationSeconds(candidate) : 0, + 1 + ) * 1000; + const candidateStart = Math.max(0, timestamp); + const candidateEnd = candidateStart + candidateWindow; + 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; + if (sameAssignmentRef(mechanic, assignment, ignore)) 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; + const end = start + assignmentWindowMs(assignment); + if (candidateStart < end && candidateEnd > start) return true; } } return false; @@ -762,13 +789,16 @@ function renderTimelineHtml(plan) { 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) { + for (const entry of canonicalAssignmentActivations(plan, { + dedupeKey: item => canonicalTimelineKey(item, row), + includeEntry: item => { + const assignment = item.assignment; + if (assignment.ability !== row.ability) return false; + const assignedJob = assignment.job ?? ''; + return !assignedJob || assignedJob === row.job; + }, + })) { + const item = entry; const m = item.mechanic; const a = item.assignment; const start = item.start; @@ -899,7 +929,7 @@ function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) { 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; + if (assignmentOverlapsJob(plan, nextJob, ability, timestamp, { mechanicId, ability, job }, assignment)) return; assignment.job = nextJob; selectedTimelineAssignment = { mechanicId, ability, job: nextJob }; updatePlan(planId, { mechanics: plan.mechanics }); @@ -925,14 +955,16 @@ function addTimelineAssignment(planId, mechanicId, ability, job, buffType, times const mechanic = plan.mechanics.find(m => m.id === mechanicId); if (!mechanic) return; mechanic.assignments = mechanic.assignments ?? []; - mechanic.assignments.push({ + const assignment = { ability, abilityName: plan.mitigationNames?.[ability], actionId: actionMetaByName[ability]?.id ?? null, job, buffType, timestamp: Math.max(0, Math.round(timestamp)), - }); + }; + if (assignmentOverlapsJob(plan, job, ability, assignment.timestamp, null, assignment)) return; + mechanic.assignments.push(assignment); selectedTimelineAssignment = { mechanicId, ability, job }; updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); @@ -989,7 +1021,9 @@ function updateTimelineAssignmentPosition(planId, mechanicId, ability, job, rowJ 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)); + const nextTimestamp = Math.max(0, Math.round(timestamp)); + if (assignmentOverlapsJob(plan, rowJob, ability, nextTimestamp, { mechanicId, ability, job }, assignment)) return; + assignment.timestamp = nextTimestamp; assignment.job = rowJob; selectedTimelineAssignment = { mechanicId, ability, job: rowJob }; updatePlan(planId, { mechanics: plan.mechanics }); @@ -1031,7 +1065,17 @@ function initTimeline(planId) { const cooldownPct = Math.max(durationPct, Math.min(100 - left, (timelineDrag.cooldownSec * 1000 / duration) * 100)); const activePct = Math.min(100, (durationPct / cooldownPct) * 100); const valid = row.dataset.ability === timelineDrag.ability - && jobCanUseAbility(row.dataset.job, timelineDrag.ability); + && jobCanUseAbility(row.dataset.job, timelineDrag.ability) + && !assignmentOverlapsJob(plan, row.dataset.job, timelineDrag.ability, timestamp, { + mechanicId: timelineDrag.mechanicId, + ability: timelineDrag.ability, + job: timelineDrag.job, + }, { + ability: timelineDrag.ability, + job: row.dataset.job, + durationSeconds: timelineDrag.durationSec, + cooldownSeconds: timelineDrag.cooldownSec, + }); row.classList.add(valid ? 'timeline-player-row--drop-ok' : 'timeline-player-row--drop-bad'); @@ -1126,7 +1170,7 @@ function initTimeline(planId) { mechanicId: block.dataset.mechanicId, ability: block.dataset.ability, job: block.dataset.job, - }), + }, found?.assignment ?? null), onClick: () => setTimelineAssignmentJob(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job, row.job), })); showTimelineMenu(e.clientX, e.clientY, items); @@ -1144,7 +1188,13 @@ function initTimeline(planId) { 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; + const candidate = ab ? { + ability: rowAbility, + actionId: actionMetaByName[rowAbility]?.id ?? null, + job: rowJob, + buffType: ab.buffType, + } : null; + if (!ab || assignmentOverlapsJob(plan, rowJob, rowAbility, timestamp, null, candidate)) return; addTimelineAssignment(planId, mechanic.id, rowAbility, rowJob, ab.buffType, timestamp); }); @@ -1171,6 +1221,7 @@ function initTimeline(planId) { transparent.height = 1; e.dataTransfer.setDragImage(transparent, 0, 0); timelineDrag = { + mechanicId: block.dataset.mechanicId, ability: block.dataset.ability, job: block.dataset.job, label: block.querySelector('span:last-child')?.textContent ?? block.dataset.ability, @@ -1623,7 +1674,6 @@ const JOB_ABILITIES = { { name: 'Galvanize', buffType: 'shield' }, { name: 'Seraphic Veil', buffType: 'shield' }, { name: 'Catalyze', buffType: 'shield' }, - { name: 'Addle', buffType: 'debuff' }, ], 'AST': [ { name: 'Collective Unconscious', buffType: 'buff' }, @@ -2073,16 +2123,23 @@ function toggleAbilityAssignment(abilityName, job, buffType) { if (mechanic.assignments[idx].job === job) { mechanic.assignments.splice(idx, 1); } else { + if (assignmentOverlapsJob(plan, job, abilityName, assignmentStartMs(mechanic, mechanic.assignments[idx]), { + mechanicId: mechanic.id, + ability: abilityName, + job: mechanic.assignments[idx].job ?? '', + }, mechanic.assignments[idx])) return; mechanic.assignments[idx].job = job; } } else { - mechanic.assignments.push({ + const assignment = { ability: abilityName, abilityName: plan.mitigationNames?.[abilityName], actionId: actionMetaByName[abilityName]?.id ?? null, job, buffType, - }); + }; + if (assignmentOverlapsJob(plan, job, abilityName, mechanic.timestamp, null, assignment)) return; + mechanic.assignments.push(assignment); } updatePlan(abilityModalPlanId, { mechanics: plan.mechanics });