more timeline fixes and addle fix

This commit is contained in:
Akurosia Kamo 2026-05-23 21:22:10 +02:00
parent fb6d50961a
commit 8f00c22682
3 changed files with 90 additions and 28 deletions

View File

@ -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,

View File

@ -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'],

View File

@ -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 });