diff --git a/js/planner.js b/js/planner.js index e3a7f3c..6d580a1 100644 --- a/js/planner.js +++ b/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; - 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) - ); + let removed = false; + if (job === null) { + const mechanic = plan.mechanics.find(m => m.id === mechanicId); + 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 = { diff --git a/scripts/update_action_json.php b/scripts/update_action_json.php index 35aae37..bdffaee 100644 --- a/scripts/update_action_json.php +++ b/scripts/update_action_json.php @@ -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);