fix timeline skills vocer multiple skills

This commit is contained in:
Akurosia Kamo 2026-05-23 21:42:16 +02:00
parent 0f8a90d1b4
commit fd0de86dbc
2 changed files with 281 additions and 30 deletions

View File

@ -445,14 +445,12 @@ function simulateDrMultiplier(mechanic, assignments = mechanic.assignments ?? []
} }
function plannedAssignmentsForMechanic(plan, targetMechanic) { function plannedAssignmentsForMechanic(plan, targetMechanic) {
const mechanics = visiblePlanMechanics(plan);
const result = []; const result = [];
const targetTime = Number(targetMechanic.timestamp);
const tolerance = 50;
for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: canonicalMechanicKey })) { for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: canonicalMechanicKey })) {
const displayMechanic = mechanics.reduce((best, mechanic) => if (targetTime < entry.start - tolerance || targetTime > entry.end + tolerance) continue;
Math.abs(mechanic.timestamp - entry.start) < Math.abs(best.timestamp - entry.start) ? mechanic : best
, mechanics[0]);
if (displayMechanic?.id !== targetMechanic.id) continue;
result.push({ result.push({
...entry.assignment, ...entry.assignment,
@ -677,6 +675,136 @@ function canonicalAssignmentActivations(plan, { dedupeKey = canonicalMechanicKey
}); });
} }
function assignmentEntryForRef(plan, ref) {
if (!plan || !ref) return null;
const mechanic = (plan.mechanics ?? []).find(m => m.id === ref.mechanicId);
if (!mechanic) return null;
const assignment = (mechanic.assignments ?? []).find(a =>
a.ability === ref.ability && (a.job ?? '') === ref.job
);
if (!assignment) return null;
const start = assignmentStartMs(mechanic, assignment);
return {
mechanic,
assignment,
start,
end: start + assignmentWindowMs(assignment),
};
}
function assignmentRowKeys(plan, assignment) {
const assignedJob = assignment.job ?? '';
if (assignedJob) return new Set([`${assignedJob}::${assignment.ability}`]);
const jobs = (plan.jobComposition ?? []).filter(job => jobCanUseAbility(job, assignment.ability));
return new Set(jobs.map(job => `${job}::${assignment.ability}`));
}
function assignmentEntriesShareRow(plan, left, right) {
const leftRows = assignmentRowKeys(plan, left.assignment);
const rightRows = assignmentRowKeys(plan, right.assignment);
for (const row of leftRows) {
if (rightRows.has(row)) return true;
}
return false;
}
function assignmentsOverlapActiveFrame(plan, left, right) {
return left.assignment.ability === right.assignment.ability
&& assignmentEntriesShareRow(plan, left, right)
&& left.start < right.end
&& left.end > right.start;
}
function compactActivationCopies(plan, keeperRef) {
const keeper = assignmentEntryForRef(plan, keeperRef);
if (!keeper) return false;
let changed = false;
for (const mechanic of visiblePlanMechanics(plan)) {
const assignments = mechanic.assignments ?? [];
const next = assignments.filter(assignment => {
if (assignment === keeper.assignment) return true;
if (assignment.ability !== keeper.assignment.ability) return true;
const start = assignmentStartMs(mechanic, assignment);
const entry = {
mechanic,
assignment,
start,
end: start + assignmentWindowMs(assignment),
};
const remove = assignmentsOverlapActiveFrame(plan, keeper, entry);
if (remove) changed = true;
return !remove;
});
if (next.length !== assignments.length) mechanic.assignments = next;
}
return changed;
}
function removeActivationGroup(plan, ref) {
const keeper = assignmentEntryForRef(plan, ref);
if (!keeper) return false;
let changed = false;
for (const mechanic of visiblePlanMechanics(plan)) {
const assignments = mechanic.assignments ?? [];
const next = assignments.filter(assignment => {
if (assignment.ability !== keeper.assignment.ability) return true;
const start = assignmentStartMs(mechanic, assignment);
const entry = {
mechanic,
assignment,
start,
end: start + assignmentWindowMs(assignment),
};
const remove = assignment === keeper.assignment || assignmentsOverlapActiveFrame(plan, keeper, entry);
if (remove) changed = true;
return !remove;
});
if (next.length !== assignments.length) mechanic.assignments = next;
}
return changed;
}
function normalizeActivationCopies(plan) {
const entries = [];
for (const mechanic of visiblePlanMechanics(plan)) {
for (const assignment of mechanic.assignments ?? []) {
const start = assignmentStartMs(mechanic, assignment);
entries.push({
mechanic,
assignment,
start,
end: start + assignmentWindowMs(assignment),
});
}
}
entries.sort((a, b) => a.start - b.start);
const keepers = [];
const removals = new Set();
for (const entry of entries) {
if (keepers.some(keeper => assignmentsOverlapActiveFrame(plan, keeper, entry))) {
removals.add(entry.assignment);
} else {
keepers.push(entry);
}
}
if (!removals.size) return false;
for (const mechanic of visiblePlanMechanics(plan)) {
const assignments = mechanic.assignments ?? [];
const next = assignments.filter(assignment => !removals.has(assignment));
if (next.length !== assignments.length) mechanic.assignments = next;
}
return true;
}
function findNearestMechanic(plan, timestamp) { function findNearestMechanic(plan, timestamp) {
const mechanics = visiblePlanMechanics(plan); const mechanics = visiblePlanMechanics(plan);
if (!mechanics.length) return null; if (!mechanics.length) return null;
@ -708,7 +836,7 @@ function layoutBossActions(mechanics, duration) {
} }
function assignmentWindowMs(assignment) { function assignmentWindowMs(assignment) {
return Math.max(assignmentCooldownSeconds(assignment), assignmentDurationSeconds(assignment)) * 1000; return Math.max(1, assignmentDurationSeconds(assignment)) * 1000;
} }
function sameAssignmentRef(mechanic, assignment, ref) { function sameAssignmentRef(mechanic, assignment, ref) {
@ -719,13 +847,10 @@ function sameAssignmentRef(mechanic, assignment, ref) {
} }
function assignmentOverlapsJob(plan, job, ability, timestamp, ignore = null, candidate = null) { function assignmentOverlapsJob(plan, job, ability, timestamp, ignore = null, candidate = null) {
const candidateWindow = Math.max( const candidateWindow = Math.max(candidate ? assignmentDurationSeconds(candidate) : 0, 1) * 1000;
candidate ? assignmentCooldownSeconds(candidate) : 0,
candidate ? assignmentDurationSeconds(candidate) : 0,
1
) * 1000;
const candidateStart = Math.max(0, timestamp); const candidateStart = Math.max(0, timestamp);
const candidateEnd = candidateStart + candidateWindow; const candidateEnd = candidateStart + candidateWindow;
const ignoredActivation = assignmentEntryForRef(plan, ignore);
for (const mechanic of visiblePlanMechanics(plan)) { for (const mechanic of visiblePlanMechanics(plan)) {
for (const assignment of mechanic.assignments ?? []) { for (const assignment of mechanic.assignments ?? []) {
@ -735,6 +860,10 @@ function assignmentOverlapsJob(plan, job, ability, timestamp, ignore = null, can
const start = assignmentStartMs(mechanic, assignment); const start = assignmentStartMs(mechanic, assignment);
const end = start + assignmentWindowMs(assignment); const end = start + assignmentWindowMs(assignment);
if (ignoredActivation) {
const entry = { mechanic, assignment, start, end };
if (assignmentsOverlapActiveFrame(plan, ignoredActivation, entry)) continue;
}
if (candidateStart < end && candidateEnd > start) return true; if (candidateStart < end && candidateEnd > start) return true;
} }
} }
@ -902,6 +1031,7 @@ function renderTimelineSettingsHtml(plan) {
function refreshTimeline(planId) { function refreshTimeline(planId) {
const plan = getPlan(planId); const plan = getPlan(planId);
if (!plan) return; if (!plan) return;
if (normalizeActivationCopies(plan)) updatePlan(planId, { mechanics: plan.mechanics });
const timeline = document.getElementById('planner-timeline'); const timeline = document.getElementById('planner-timeline');
const settings = document.getElementById('timeline-settings'); const settings = document.getElementById('timeline-settings');
if (timeline) timeline.innerHTML = renderTimelineHtml(plan); if (timeline) timeline.innerHTML = renderTimelineHtml(plan);
@ -928,6 +1058,7 @@ function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) {
const mechanic = plan.mechanics.find(m => m.id === mechanicId); const mechanic = plan.mechanics.find(m => m.id === mechanicId);
const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job); const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job);
if (!assignment) return; if (!assignment) return;
compactActivationCopies(plan, { mechanicId, ability, job });
const timestamp = assignmentStartMs(mechanic, assignment); const timestamp = assignmentStartMs(mechanic, assignment);
if (assignmentOverlapsJob(plan, nextJob, ability, timestamp, { mechanicId, ability, job }, assignment)) return; if (assignmentOverlapsJob(plan, nextJob, ability, timestamp, { mechanicId, ability, job }, assignment)) return;
assignment.job = nextJob; assignment.job = nextJob;
@ -939,9 +1070,7 @@ function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) {
function removeTimelineAssignment(planId, mechanicId, ability, job) { function removeTimelineAssignment(planId, mechanicId, ability, job) {
const plan = getPlan(planId); const plan = getPlan(planId);
if (!plan) return; if (!plan) return;
const mechanic = plan.mechanics.find(m => m.id === mechanicId); if (!removeActivationGroup(plan, { mechanicId, ability, job })) return;
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) { if (selectedTimelineAssignment?.mechanicId === mechanicId && selectedTimelineAssignment?.ability === ability && selectedTimelineAssignment?.job === job) {
selectedTimelineAssignment = null; selectedTimelineAssignment = null;
} }
@ -1021,6 +1150,7 @@ function updateTimelineAssignmentPosition(planId, mechanicId, ability, job, rowJ
const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job); const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job);
if (!assignment) return; if (!assignment) return;
if (!jobCanUseAbility(rowJob, ability)) return; if (!jobCanUseAbility(rowJob, ability)) return;
compactActivationCopies(plan, { mechanicId, ability, job });
const nextTimestamp = 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; if (assignmentOverlapsJob(plan, rowJob, ability, nextTimestamp, { mechanicId, ability, job }, assignment)) return;
assignment.timestamp = nextTimestamp; assignment.timestamp = nextTimestamp;
@ -1296,6 +1426,7 @@ function initTimeline(planId) {
function refreshMechanicList(planId, includeTimeline = true) { function refreshMechanicList(planId, includeTimeline = true) {
const plan = getPlan(planId); const plan = getPlan(planId);
if (!plan) return; if (!plan) return;
if (normalizeActivationCopies(plan)) updatePlan(planId, { mechanics: plan.mechanics });
const el = document.getElementById('mechanic-list'); const el = document.getElementById('mechanic-list');
if (el) el.innerHTML = renderMechanicListHtml(plan); if (el) el.innerHTML = renderMechanicListHtml(plan);
if (includeTimeline) refreshTimeline(planId); if (includeTimeline) refreshTimeline(planId);
@ -1372,11 +1503,19 @@ function renderInfoPanel(plan) {
function removeAssignment(planId, mechanicId, abilityName, job = null) { function removeAssignment(planId, mechanicId, abilityName, job = null) {
const plan = getPlan(planId); const plan = getPlan(planId);
if (!plan) return; if (!plan) return;
let removed = false;
if (job === null) {
const mechanic = plan.mechanics.find(m => m.id === mechanicId); const mechanic = plan.mechanics.find(m => m.id === mechanicId);
if (!mechanic) return; const jobs = [...new Set((mechanic?.assignments ?? [])
mechanic.assignments = mechanic.assignments.filter(a => .filter(a => a.ability === abilityName)
a.ability !== abilityName || (job !== null && (a.job ?? '') !== job) .map(a => a.job ?? ''))];
); jobs.forEach(assignmentJob => {
removed = removeActivationGroup(plan, { mechanicId, ability: abilityName, job: assignmentJob }) || removed;
});
} else {
removed = removeActivationGroup(plan, { mechanicId, ability: abilityName, job });
}
if (!removed) return;
updatePlan(planId, { mechanics: plan.mechanics }); updatePlan(planId, { mechanics: plan.mechanics });
refreshMechanicList(planId); refreshMechanicList(planId);
if (abilityModalMechanicId === mechanicId) renderAbilityModalContent(); if (abilityModalMechanicId === mechanicId) renderAbilityModalContent();
@ -1941,15 +2080,25 @@ function toggleAbilityAssignment(abilityName, job, buffType) {
const idx = mechanic.assignments.findIndex(a => a.ability === abilityName); const idx = mechanic.assignments.findIndex(a => a.ability === abilityName);
if (idx !== -1) { if (idx !== -1) {
if (mechanic.assignments[idx].job === job) { const assignment = mechanic.assignments[idx];
mechanic.assignments.splice(idx, 1); if (assignment.job === job) {
} else { removeActivationGroup(plan, {
if (assignmentOverlapsJob(plan, job, abilityName, assignmentStartMs(mechanic, mechanic.assignments[idx]), {
mechanicId: mechanic.id, mechanicId: mechanic.id,
ability: abilityName, ability: abilityName,
job: mechanic.assignments[idx].job ?? '', job: assignment.job ?? '',
}, mechanic.assignments[idx])) return; });
mechanic.assignments[idx].job = job; } else {
compactActivationCopies(plan, {
mechanicId: mechanic.id,
ability: abilityName,
job: assignment.job ?? '',
});
if (assignmentOverlapsJob(plan, job, abilityName, assignmentStartMs(mechanic, assignment), {
mechanicId: mechanic.id,
ability: abilityName,
job: assignment.job ?? '',
}, assignment)) return;
assignment.job = job;
} }
} else { } else {
const assignment = { const assignment = {

View File

@ -7,6 +7,7 @@ const ACTION_SOURCE_URL = 'https://ff14.akurosiakamo.de/extras/json/xivapi_data/
$rootDir = dirname(__DIR__); $rootDir = dirname(__DIR__);
$mitigationSource = $rootDir . '/api/analysis.php'; $mitigationSource = $rootDir . '/api/analysis.php';
$plannerDataSource = $rootDir . '/js/ffxiv-data.js';
$outputFile = $rootDir . '/assets/jsons/Action.json'; $outputFile = $rootDir . '/assets/jsons/Action.json';
function fail(string $message, int $code = 1): void function fail(string $message, int $code = 1): void
@ -80,7 +81,100 @@ function extract_constant_array_literal(string $php, string $constantName): stri
fail('Could not parse array literal for ' . $constantName); fail('Could not parse array literal for ' . $constantName);
} }
function read_mitigation_action_ids(string $sourceFile): array function extract_js_const_object_literal(string $js, string $constantName): string
{
$needle = 'const ' . $constantName . ' =';
$start = strpos($js, $needle);
if ($start === false) {
fail('Could not find const ' . $constantName . ' in js/ffxiv-data.js');
}
$objectStart = strpos($js, '{', $start);
if ($objectStart === false) {
fail('Could not find object literal for ' . $constantName);
}
$depth = 0;
$length = strlen($js);
$inString = false;
$stringQuote = '';
$escaped = false;
for ($i = $objectStart; $i < $length; $i++) {
$char = $js[$i];
if ($inString) {
if ($escaped) {
$escaped = false;
continue;
}
if ($char === '\\') {
$escaped = true;
continue;
}
if ($char === $stringQuote) {
$inString = false;
$stringQuote = '';
}
continue;
}
if ($char === '\'' || $char === '"' || $char === '`') {
$inString = true;
$stringQuote = $char;
continue;
}
if ($char === '{') {
$depth++;
continue;
}
if ($char === '}') {
$depth--;
if ($depth === 0) {
return substr($js, $objectStart, $i - $objectStart + 1);
}
}
}
fail('Could not parse object literal for ' . $constantName);
}
function read_planner_ability_names(string $sourceFile): array
{
if (!is_file($sourceFile)) {
fail('Missing planner data source file: ' . $sourceFile);
}
$js = file_get_contents($sourceFile);
if ($js === false) {
fail('Could not read planner data source file: ' . $sourceFile);
}
$literal = extract_js_const_object_literal($js, 'JOB_ABILITIES');
if (!preg_match_all('/\bname\s*:\s*([\'"])((?:\\\\.|(?!\1).)*)\1/s', $literal, $matches)) {
fail('No abilities found in JOB_ABILITIES');
}
$names = [];
foreach ($matches[2] as $rawName) {
$name = stripcslashes($rawName);
if ($name !== '') {
$names[$name] = true;
}
}
ksort($names, SORT_NATURAL | SORT_FLAG_CASE);
return array_keys($names);
}
function read_mitigation_action_ids(string $sourceFile, array $abilityNames): array
{ {
if (!is_file($sourceFile)) { if (!is_file($sourceFile)) {
fail('Missing mitigation source file: ' . $sourceFile); fail('Missing mitigation source file: ' . $sourceFile);
@ -98,8 +192,15 @@ function read_mitigation_action_ids(string $sourceFile): array
fail('MITIGATION_ABILITIES did not parse as an array'); fail('MITIGATION_ABILITIES did not parse as an array');
} }
$wantedNames = array_fill_keys($abilityNames, true);
$ids = []; $ids = [];
foreach ($abilities as $name => $ability) { foreach ($wantedNames as $name => $_) {
if (!isset($abilities[$name])) {
fwrite(STDERR, 'Planner ability missing in MITIGATION_ABILITIES: ' . $name . PHP_EOL);
continue;
}
$ability = $abilities[$name];
$id = (int)($ability['extraAbilityGameID'] ?? 0); $id = (int)($ability['extraAbilityGameID'] ?? 0);
if ($id <= 0) { if ($id <= 0) {
fwrite(STDERR, 'Skipping mitigation without extraAbilityGameID: ' . $name . PHP_EOL); fwrite(STDERR, 'Skipping mitigation without extraAbilityGameID: ' . $name . PHP_EOL);
@ -110,7 +211,7 @@ function read_mitigation_action_ids(string $sourceFile): array
} }
if (!$ids) { if (!$ids) {
fail('No extraAbilityGameID values found in MITIGATION_ABILITIES'); fail('No extraAbilityGameID values found for abilities from js/ffxiv-data.js');
} }
ksort($ids, SORT_NUMERIC); ksort($ids, SORT_NUMERIC);
@ -188,7 +289,8 @@ function action_field(array $action, string $field): ?int
return null; return null;
} }
$actionIds = read_mitigation_action_ids($mitigationSource); $plannerAbilityNames = read_planner_ability_names($plannerDataSource);
$actionIds = read_mitigation_action_ids($mitigationSource, $plannerAbilityNames);
$wanted = array_fill_keys(array_map('strval', $actionIds), true); $wanted = array_fill_keys(array_map('strval', $actionIds), true);
$json = download_url(ACTION_SOURCE_URL); $json = download_url(ACTION_SOURCE_URL);