forked from xziino/ff14-mitigator
fix timeline skills vocer multiple skills
This commit is contained in:
parent
0f8a90d1b4
commit
fd0de86dbc
199
js/planner.js
199
js/planner.js
@ -445,14 +445,12 @@ function simulateDrMultiplier(mechanic, assignments = mechanic.assignments ?? []
|
||||
}
|
||||
|
||||
function plannedAssignmentsForMechanic(plan, targetMechanic) {
|
||||
const mechanics = visiblePlanMechanics(plan);
|
||||
const result = [];
|
||||
const targetTime = Number(targetMechanic.timestamp);
|
||||
const tolerance = 50;
|
||||
|
||||
for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: canonicalMechanicKey })) {
|
||||
const displayMechanic = mechanics.reduce((best, mechanic) =>
|
||||
Math.abs(mechanic.timestamp - entry.start) < Math.abs(best.timestamp - entry.start) ? mechanic : best
|
||||
, mechanics[0]);
|
||||
if (displayMechanic?.id !== targetMechanic.id) continue;
|
||||
if (targetTime < entry.start - tolerance || targetTime > entry.end + tolerance) continue;
|
||||
|
||||
result.push({
|
||||
...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) {
|
||||
const mechanics = visiblePlanMechanics(plan);
|
||||
if (!mechanics.length) return null;
|
||||
@ -708,7 +836,7 @@ function layoutBossActions(mechanics, duration) {
|
||||
}
|
||||
|
||||
function assignmentWindowMs(assignment) {
|
||||
return Math.max(assignmentCooldownSeconds(assignment), assignmentDurationSeconds(assignment)) * 1000;
|
||||
return Math.max(1, assignmentDurationSeconds(assignment)) * 1000;
|
||||
}
|
||||
|
||||
function sameAssignmentRef(mechanic, assignment, ref) {
|
||||
@ -719,13 +847,10 @@ function sameAssignmentRef(mechanic, assignment, ref) {
|
||||
}
|
||||
|
||||
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 candidateWindow = Math.max(candidate ? assignmentDurationSeconds(candidate) : 0, 1) * 1000;
|
||||
const candidateStart = Math.max(0, timestamp);
|
||||
const candidateEnd = candidateStart + candidateWindow;
|
||||
const ignoredActivation = assignmentEntryForRef(plan, ignore);
|
||||
|
||||
for (const mechanic of visiblePlanMechanics(plan)) {
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
@ -902,6 +1031,7 @@ function renderTimelineSettingsHtml(plan) {
|
||||
function refreshTimeline(planId) {
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) return;
|
||||
if (normalizeActivationCopies(plan)) updatePlan(planId, { mechanics: plan.mechanics });
|
||||
const timeline = document.getElementById('planner-timeline');
|
||||
const settings = document.getElementById('timeline-settings');
|
||||
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 assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job);
|
||||
if (!assignment) return;
|
||||
compactActivationCopies(plan, { mechanicId, ability, job });
|
||||
const timestamp = assignmentStartMs(mechanic, assignment);
|
||||
if (assignmentOverlapsJob(plan, nextJob, ability, timestamp, { mechanicId, ability, job }, assignment)) return;
|
||||
assignment.job = nextJob;
|
||||
@ -939,9 +1070,7 @@ function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) {
|
||||
function removeTimelineAssignment(planId, mechanicId, ability, job) {
|
||||
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 === ability && (a.job ?? '') === job));
|
||||
if (!removeActivationGroup(plan, { mechanicId, ability, job })) return;
|
||||
if (selectedTimelineAssignment?.mechanicId === mechanicId && selectedTimelineAssignment?.ability === ability && selectedTimelineAssignment?.job === job) {
|
||||
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);
|
||||
if (!assignment) return;
|
||||
if (!jobCanUseAbility(rowJob, ability)) return;
|
||||
compactActivationCopies(plan, { mechanicId, ability, job });
|
||||
const nextTimestamp = Math.max(0, Math.round(timestamp));
|
||||
if (assignmentOverlapsJob(plan, rowJob, ability, nextTimestamp, { mechanicId, ability, job }, assignment)) return;
|
||||
assignment.timestamp = nextTimestamp;
|
||||
@ -1296,6 +1426,7 @@ function initTimeline(planId) {
|
||||
function refreshMechanicList(planId, includeTimeline = true) {
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) return;
|
||||
if (normalizeActivationCopies(plan)) updatePlan(planId, { mechanics: plan.mechanics });
|
||||
const el = document.getElementById('mechanic-list');
|
||||
if (el) el.innerHTML = renderMechanicListHtml(plan);
|
||||
if (includeTimeline) refreshTimeline(planId);
|
||||
@ -1372,11 +1503,19 @@ function renderInfoPanel(plan) {
|
||||
function removeAssignment(planId, mechanicId, abilityName, job = null) {
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) return;
|
||||
let removed = false;
|
||||
if (job === null) {
|
||||
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
|
||||
if (!mechanic) return;
|
||||
mechanic.assignments = mechanic.assignments.filter(a =>
|
||||
a.ability !== abilityName || (job !== null && (a.job ?? '') !== job)
|
||||
);
|
||||
const jobs = [...new Set((mechanic?.assignments ?? [])
|
||||
.filter(a => a.ability === abilityName)
|
||||
.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 });
|
||||
refreshMechanicList(planId);
|
||||
if (abilityModalMechanicId === mechanicId) renderAbilityModalContent();
|
||||
@ -1941,15 +2080,25 @@ function toggleAbilityAssignment(abilityName, job, buffType) {
|
||||
|
||||
const idx = mechanic.assignments.findIndex(a => a.ability === abilityName);
|
||||
if (idx !== -1) {
|
||||
if (mechanic.assignments[idx].job === job) {
|
||||
mechanic.assignments.splice(idx, 1);
|
||||
} else {
|
||||
if (assignmentOverlapsJob(plan, job, abilityName, assignmentStartMs(mechanic, mechanic.assignments[idx]), {
|
||||
const assignment = mechanic.assignments[idx];
|
||||
if (assignment.job === job) {
|
||||
removeActivationGroup(plan, {
|
||||
mechanicId: mechanic.id,
|
||||
ability: abilityName,
|
||||
job: mechanic.assignments[idx].job ?? '',
|
||||
}, mechanic.assignments[idx])) return;
|
||||
mechanic.assignments[idx].job = job;
|
||||
job: assignment.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 {
|
||||
const assignment = {
|
||||
|
||||
@ -7,6 +7,7 @@ const ACTION_SOURCE_URL = 'https://ff14.akurosiakamo.de/extras/json/xivapi_data/
|
||||
|
||||
$rootDir = dirname(__DIR__);
|
||||
$mitigationSource = $rootDir . '/api/analysis.php';
|
||||
$plannerDataSource = $rootDir . '/js/ffxiv-data.js';
|
||||
$outputFile = $rootDir . '/assets/jsons/Action.json';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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)) {
|
||||
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');
|
||||
}
|
||||
|
||||
$wantedNames = array_fill_keys($abilityNames, true);
|
||||
$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);
|
||||
if ($id <= 0) {
|
||||
fwrite(STDERR, 'Skipping mitigation without extraAbilityGameID: ' . $name . PHP_EOL);
|
||||
@ -110,7 +211,7 @@ function read_mitigation_action_ids(string $sourceFile): array
|
||||
}
|
||||
|
||||
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);
|
||||
@ -188,7 +289,8 @@ function action_field(array $action, string $field): ?int
|
||||
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);
|
||||
|
||||
$json = download_url(ACTION_SOURCE_URL);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user