Merge remote-tracking branch 'Akurosia/akus_schabernack4'

This commit is contained in:
xziino 2026-05-23 12:59:51 +02:00
commit 8b00d1d2a8
6 changed files with 1285 additions and 16 deletions

View File

@ -42,6 +42,7 @@ query GetReportData($reportCode: String!) {
endTime
fights {
id
encounterID
name
startTime
endTime
@ -75,9 +76,7 @@ function localized_graphql_uri(string $language): string {
return preg_replace('#https://[^/]+#', 'https://' . $host, GRAPHQL_URI);
}
// Fight names must be stable regardless of language — always use the English endpoint.
// Localization only matters for ability/player names in analysis.php.
$ch = curl_init(GRAPHQL_URI);
$ch = curl_init(localized_graphql_uri($language));
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
@ -85,6 +84,7 @@ curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $_SESSION['access_token'],
'Accept-Language: ' . ($language === 'jp' ? 'ja' : $language),
],
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
]);

150
assets/jsons/Action.json Normal file
View File

@ -0,0 +1,150 @@
{
"185": {
"cast": 20,
"recast": 25
},
"188": {
"cast": 0,
"recast": 300
},
"3540": {
"cast": 0,
"recast": 900
},
"3613": {
"cast": 0,
"recast": 600
},
"7385": {
"cast": 0,
"recast": 1200
},
"7388": {
"cast": 0,
"recast": 900
},
"7405": {
"cast": 0,
"recast": 1200
},
"7432": {
"cast": 0,
"recast": 300
},
"7535": {
"cast": 0,
"recast": 600
},
"7549": {
"cast": 0,
"recast": 900
},
"7560": {
"cast": 0,
"recast": 900
},
"16012": {
"cast": 0,
"recast": 1200
},
"16160": {
"cast": 0,
"recast": 900
},
"16471": {
"cast": 0,
"recast": 900
},
"16536": {
"cast": 0,
"recast": 1200
},
"16538": {
"cast": 0,
"recast": 1200
},
"16548": {
"cast": 0,
"recast": 30
},
"16556": {
"cast": 0,
"recast": 300
},
"16559": {
"cast": 0,
"recast": 1200
},
"16889": {
"cast": 0,
"recast": 1200
},
"24291": {
"cast": 0,
"recast": 15
},
"24292": {
"cast": 0,
"recast": 15
},
"24298": {
"cast": 0,
"recast": 300
},
"24305": {
"cast": 0,
"recast": 1200
},
"24310": {
"cast": 0,
"recast": 1200
},
"24311": {
"cast": 0,
"recast": 1200
},
"25751": {
"cast": 0,
"recast": 250
},
"25789": {
"cast": 0,
"recast": 15
},
"25799": {
"cast": 0,
"recast": 600
},
"25857": {
"cast": 0,
"recast": 1200
},
"25868": {
"cast": 0,
"recast": 1200
},
"34685": {
"cast": 0,
"recast": 1200
},
"34686": {
"cast": 0,
"recast": 10
},
"36920": {
"cast": 0,
"recast": 1200
},
"37011": {
"cast": 0,
"recast": 10
},
"37025": {
"cast": 0,
"recast": 10
},
"37034": {
"cast": 0,
"recast": 15
}
}

View File

@ -4,6 +4,12 @@
grid-template-columns: 280px 1fr;
gap: 16px;
align-items: start;
min-width: 0;
}
#plan-detail-panel,
#plan-content {
min-width: 0;
}
/* ── Plan Sidebar ────────────────────────────────────────────────────────────── */
@ -659,3 +665,318 @@
}
.folder-picker-option:hover { background: var(--bg2); color: var(--t1); }
.folder-picker-option.active { color: var(--gold); }
/* ── Planner Timeline ───────────────────────────────────────────────────────── */
.timeline-hint {
font-size: 12px;
color: var(--t3);
}
.timeline-empty,
.timeline-settings-empty {
font-size: 13px;
color: var(--t3);
padding: 12px 0;
}
.timeline-scroll {
overflow-x: auto;
overflow-y: hidden;
border: 1px solid var(--border);
background: var(--bg1);
max-width: 100%;
width: 100%;
}
.timeline-grid {
width: calc(180px + var(--timeline-width));
display: grid;
grid-template-columns: 180px var(--timeline-width);
}
.timeline-row,
.timeline-axis {
display: grid;
grid-template-columns: 180px var(--timeline-width);
min-height: 38px;
grid-column: 1 / -1;
}
.timeline-row-label {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
background: var(--bgcard);
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
font-size: 12px;
color: var(--t2);
min-width: 0;
}
.timeline-boss-row {
min-height: var(--boss-row-height, 52px);
}
.timeline-boss-row .timeline-row-label {
color: var(--gold);
font-weight: 600;
}
.timeline-track,
.timeline-axis-track {
position: relative;
min-height: inherit;
border-bottom: 1px solid var(--border);
background:
repeating-linear-gradient(
to right,
transparent 0,
transparent 79px,
rgba(255,255,255,0.07) 80px
);
}
.timeline-hit-line {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
transform: translateX(-50%);
background: rgba(200,168,75,.38);
pointer-events: none;
z-index: 1;
}
.timeline-player-row .timeline-track {
background-color: rgba(255,255,255,0.015);
}
.timeline-job {
width: 36px;
text-align: center;
border-radius: 3px;
padding: 2px 0;
font-size: 11px;
flex-shrink: 0;
}
.timeline-player-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-boss-action {
position: absolute;
top: 8px;
transform: translateX(-50%);
max-width: 150px;
padding: 5px 8px;
border: 1px solid rgba(224,92,92,.35);
border-radius: var(--r);
background: rgba(224,92,92,.14);
color: var(--t1);
font-size: 12px;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
z-index: 3;
}
.timeline-boss-action:hover {
border-color: rgba(224,92,92,.7);
background: rgba(224,92,92,.22);
}
.timeline-mitigation {
position: absolute;
top: 6px;
width: var(--cd-width);
min-width: 28px;
height: 26px;
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px;
border: 1px solid var(--borderem);
border-radius: var(--r);
background: var(--bg2);
color: var(--t1);
font-size: 11px;
cursor: grab;
z-index: 4;
overflow: hidden;
}
.timeline-mitigation-active {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--active-width);
background: currentColor;
opacity: 0.22;
pointer-events: none;
}
.timeline-mitigation:active { cursor: grabbing; }
.timeline-mitigation.selected {
outline: 2px solid var(--gold);
outline-offset: 1px;
}
.timeline-mitigation--candidate {
border-style: dashed;
opacity: 0.78;
}
.timeline-mitigation img {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.timeline-mitigation span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
z-index: 1;
}
.timeline-mitigation .timeline-mitigation-active {
z-index: 0;
}
.timeline-mitigation--buff { border-color: rgba(200,168,75,.5); color: var(--gold); background: rgba(200,168,75,.12); }
.timeline-mitigation--debuff { border-color: rgba(224,92,92,.5); color: var(--red); background: rgba(224,92,92,.12); }
.timeline-mitigation--shield { border-color: rgba(74,158,255,.5); color: var(--blue); background: rgba(74,158,255,.12); }
.timeline-axis {
min-height: 28px;
}
.timeline-axis-track {
border-bottom: none;
}
.timeline-tick {
position: absolute;
top: 6px;
transform: translateX(-50%);
font-size: 11px;
color: var(--t3);
}
.timeline-settings-panel {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.timeline-settings-title {
font-size: 13px;
color: var(--t1);
font-weight: 600;
width: 100%;
}
.timeline-settings-meta {
font-size: 12px;
color: var(--t3);
width: 100%;
margin-top: -6px;
}
.timeline-settings-panel label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--t3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.timeline-setting-input {
width: 86px !important;
font-size: 13px !important;
padding: 5px 7px !important;
}
.timeline-context-menu {
position: fixed;
z-index: 300;
min-width: 190px;
max-width: 280px;
padding: 5px;
border: 1px solid var(--borderem);
border-radius: var(--r);
background: var(--bgcard);
box-shadow: 0 10px 24px rgba(0,0,0,0.45);
}
.timeline-menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 7px;
padding: 7px 9px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--t2);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.timeline-menu-item:hover {
background: var(--bg2);
color: var(--t1);
}
.timeline-menu-item.disabled,
.timeline-menu-item:disabled {
opacity: 0.38;
cursor: not-allowed;
}
.timeline-menu-item img {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
}
.timeline-menu-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-menu-empty {
padding: 8px 10px;
color: var(--t3);
font-size: 12px;
}
@media (max-width: 980px) {
.planner-layout {
grid-template-columns: 1fr;
}
.plan-sidebar {
position: static;
}
}

View File

@ -138,13 +138,24 @@
return String(name ?? '').trim().toLowerCase();
}
function currentFightName() {
const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId);
return normalizeFightName(fight?.name);
function fightEncounterId(fight) {
return parseInt(fight?.encounterID ?? fight?.encounterId ?? 0, 10) || 0;
}
function isSameFightName(fight) {
const name = currentFightName();
function currentFight() {
return (window.App?.fights ?? []).find(f => f.id === window.App?.fightId) ?? null;
}
function isSameEncounter(fight) {
const selectedFight = currentFight();
const selectedEncounterId = fightEncounterId(selectedFight);
const encounterId = fightEncounterId(fight);
if (selectedEncounterId && encounterId) {
return encounterId === selectedEncounterId;
}
const name = normalizeFightName(selectedFight?.name);
return name !== '' && normalizeFightName(fight?.name) === name;
}
@ -341,7 +352,7 @@
let allSameReportFights = [];
function populateRefFightSelect() {
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameFightName(f));
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameEncounter(f));
refFightSelect.innerHTML = '<option value="">Kein Vergleich</option>';
visible.forEach(f => {
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
@ -410,7 +421,7 @@
extReportCode = code;
updateRefFflogsLink();
const visibleExt = fights.filter(isSameFightName);
const visibleExt = fights.filter(isSameEncounter);
refExtFightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
visibleExt.forEach(f => {
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');

View File

@ -96,6 +96,10 @@ function copyPlan(id) {
let activePlanId = null;
let collapsedFolders = new Set();
let selectedTimelineAssignment = null;
let actionMetaPromise = null;
let actionMetaById = {};
let actionMetaByName = {};
// ── Helpers ───────────────────────────────────────────────────────────────────
@ -129,6 +133,48 @@ function plannerLanguage() {
return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en';
}
async function ensureActionMetaLoaded() {
if (actionMetaPromise) return actionMetaPromise;
actionMetaPromise = (async () => {
let compact = {};
let full = {};
try {
const res = await fetch('assets/jsons/Action.json', { cache: 'no-cache' });
if (res.ok) compact = await res.json();
} catch { }
try {
const res = await fetch('assets/mitigation-actions.json', { cache: 'no-cache' });
if (res.ok) full = await res.json();
} catch { }
const byId = {};
const byName = {};
for (const [id, action] of Object.entries(full ?? {})) {
const description = action.Description_en ?? '';
const durations = [...description.matchAll(/Duration:\s*(\d+)s/gi)].map(m => parseInt(m[1], 10));
const meta = {
id,
name: action.Name_en ?? '',
cast: (parseInt(compact?.[id]?.cast ?? action.Cast100ms ?? 0, 10) || 0) / 10,
recast: (parseInt(compact?.[id]?.recast ?? action.Recast100ms ?? 0, 10) || 0) / 10,
duration: durations.find(Number.isFinite) ?? 15,
};
byId[id] = meta;
if (meta.name) byName[meta.name] = meta;
}
for (const [id, action] of Object.entries(compact ?? {})) {
byId[id] = {
...(byId[id] ?? { id }),
cast: (parseInt(action.cast ?? 0, 10) || 0) / 10,
recast: (parseInt(action.recast ?? 0, 10) || 0) / 10,
};
}
actionMetaById = byId;
actionMetaByName = byName;
})();
return actionMetaPromise;
}
function sameMechanic(existing, incoming, source) {
const fightStart = source?.fightStart ?? 0;
const incomingRel = incoming.timestamp - fightStart;
@ -138,6 +184,12 @@ function sameMechanic(existing, incoming, source) {
return Math.abs(existing.timestamp - incomingRel) < 1500;
}
function visiblePlanMechanics(plan) {
return [...(plan?.mechanics ?? [])]
.filter(m => m?.id && String(m.name ?? '').trim() !== '' && Number.isFinite(Number(m.timestamp)))
.sort((a, b) => a.timestamp - b.timestamp);
}
// ── Rendering: Plan List ──────────────────────────────────────────────────────
function planItemHtml(p) {
@ -313,6 +365,8 @@ function renderPlanDetail(plan) {
noplan.style.display = 'none';
content.style.display = '';
const visibleMechanics = visiblePlanMechanics(plan);
content.innerHTML = `
<div class="card section-gap">
<div class="plan-detail-header">
@ -320,7 +374,7 @@ function renderPlanDetail(plan) {
<span id="plan-name-display" class="plan-name-text">${escHtml(plan.name)}</span>
<button id="plan-name-edit-btn" class="plan-btn" title="Umbenennen"></button>
</div>
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} &middot; ${plan.mechanics.length} Mechaniken</div>
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} &middot; ${visibleMechanics.length} Mechaniken</div>
</div>
</div>
@ -334,6 +388,19 @@ function renderPlanDetail(plan) {
</div>
</div>
<div class="card section-gap">
<div class="card-title-row">
<div class="card-title">Zeitstrahl</div>
<div class="timeline-hint">Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten</div>
</div>
<div id="planner-timeline">
${renderTimelineHtml(plan)}
</div>
<div id="timeline-settings">
${renderTimelineSettingsHtml(plan)}
</div>
</div>
<div class="card">
<div class="card-title-row">
<div class="card-title">Mechaniken</div>
@ -351,8 +418,10 @@ function renderPlanDetail(plan) {
document.getElementById('name-import-open-btn')?.addEventListener('click', () => {
showNameImportModal(plan.id);
});
initTimeline(plan.id);
initMechanicClicks(plan.id);
renderInfoPanel(plan);
ensureActionMetaLoaded().then(() => refreshTimeline(plan.id));
}
function avgNonTankMaxHp(plan) {
@ -367,7 +436,8 @@ function avgNonTankMaxHp(plan) {
}
function renderMechanicListHtml(plan) {
if (plan.mechanics.length === 0) {
const mechanics = visiblePlanMechanics(plan);
if (mechanics.length === 0) {
return `
<div class="empty" style="padding:30px 0">
<div class="empty-icon" style="font-size:26px">📋</div>
@ -382,7 +452,7 @@ function renderMechanicListHtml(plan) {
const activeJobSet = new Set(plan.jobComposition.filter(j => j));
const avgHp = avgNonTankMaxHp(plan);
return plan.mechanics.map(m => {
return mechanics.map(m => {
const sorted = sortedAssignments(m.assignments);
const assignHtml = sorted.length === 0
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
@ -475,13 +545,488 @@ function initJobSlots(planId) {
});
}
// ── Timeline ─────────────────────────────────────────────────────────────────
function planDurationMs(plan) {
const sourceDuration = (plan.source?.fightEnd ?? 0) - (plan.source?.fightStart ?? 0);
const mechanicEnd = Math.max(0, ...visiblePlanMechanics(plan).map(m => (m.timestamp ?? 0) + 30000));
return Math.max(sourceDuration, mechanicEnd, 60000);
}
function timelineScale(plan) {
const duration = planDurationMs(plan);
const pxPerSecond = 8;
return { duration, width: Math.max(720, Math.ceil(duration / 1000 * pxPerSecond)) };
}
function timelinePlayerRows(plan) {
const roster = plan.playerRoster ?? [];
return (plan.jobComposition ?? []).map((job, idx) => ({
idx,
job,
name: roster[idx]?.name ?? '',
role: JOB_ROLE[job] ?? '',
})).filter(row => row.job);
}
function selectedAssignmentMatches(mechanicId, assignment) {
return selectedTimelineAssignment
&& selectedTimelineAssignment.mechanicId === mechanicId
&& selectedTimelineAssignment.ability === assignment.ability
&& selectedTimelineAssignment.job === (assignment.job ?? '');
}
function jobCanUseAbility(job, ability) {
return (JOB_ABILITIES[job] ?? []).some(a => a.name === ability);
}
function timelineRowsForAssignment(rows, assignment) {
const assignedJob = assignment.job ?? '';
if (assignedJob) return rows.filter(row => row.job === assignedJob);
return rows.filter(row => jobCanUseAbility(row.job, assignment.ability));
}
function assignmentStartMs(mechanic, assignment) {
return Number.isFinite(Number(assignment?.timestamp)) ? Number(assignment.timestamp) : mechanic.timestamp;
}
function findNearestMechanic(plan, timestamp) {
const mechanics = visiblePlanMechanics(plan);
if (!mechanics.length) return null;
return mechanics.reduce((best, mechanic) =>
Math.abs(mechanic.timestamp - timestamp) < Math.abs(best.timestamp - timestamp) ? mechanic : best
, mechanics[0]);
}
function timelineTimestampFromEvent(plan, track, event) {
const rect = track.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, event.clientX - rect.left));
return (x / rect.width) * planDurationMs(plan);
}
function layoutBossActions(mechanics, duration) {
const lanes = [];
const minGapPct = 9;
return mechanics.map(mechanic => {
const left = (mechanic.timestamp / duration) * 100;
let lane = lanes.findIndex(lastLeft => left - lastLeft >= minGapPct);
if (lane === -1) {
lane = lanes.length;
lanes.push(left);
} else {
lanes[lane] = left;
}
return { mechanic, left, lane };
});
}
function assignmentOverlapsJob(plan, job, ability, timestamp, ignore = null) {
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;
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;
}
}
return false;
}
function actionMetaForAssignment(assignment) {
const id = String(assignment?.actionId ?? assignment?.extraAbilityGameID ?? '');
return (id && actionMetaById[id]) || actionMetaByName[assignment?.ability] || null;
}
function assignmentCooldownSeconds(assignment) {
const own = Number(assignment?.cooldownSeconds);
if (Number.isFinite(own) && own >= 0) return own;
return actionMetaForAssignment(assignment)?.recast ?? 0;
}
function assignmentDurationSeconds(assignment) {
const own = Number(assignment?.durationSeconds);
if (Number.isFinite(own) && own > 0) return own;
return actionMetaForAssignment(assignment)?.duration ?? (assignment?.buffType === 'shield' ? 20 : 15);
}
function renderTimelineHtml(plan) {
const mechanics = visiblePlanMechanics(plan);
if (!mechanics.length) {
return '<div class="timeline-empty">Importiere Mechaniken aus dem Analyse-Tab, um den Zeitstrahl zu nutzen.</div>';
}
const { duration, width } = timelineScale(plan);
const rows = timelinePlayerRows(plan);
const marks = [];
const tick = 10000;
for (let t = 0; t <= duration; t += tick) {
marks.push(`<span class="timeline-tick" style="left:${(t / duration) * 100}%">${fmtTimestamp(t)}</span>`);
}
const bossActions = layoutBossActions(mechanics, duration);
const laneCount = Math.max(1, ...bossActions.map(item => item.lane + 1));
const hitLines = mechanics.map(m => `
<span class="timeline-hit-line" style="left:${(m.timestamp / duration) * 100}%"></span>
`).join('');
const bossItems = bossActions.map(({ mechanic: m, left, lane }) => `
<button class="timeline-boss-action"
style="left:${left}%;top:${8 + lane * 30}px"
data-mechanic-id="${escHtml(m.id)}"
title="${escHtml(fmtTimestamp(m.timestamp))} · ${escHtml(m.name)}">
${escHtml(m.name)}
</button>
`).join('');
const playerRows = rows.map(row => {
const blocks = [];
for (const m of mechanics) {
for (const a of sortedAssignments(m.assignments ?? [])) {
if (!timelineRowsForAssignment(rows, a).some(target => target.job === row.job)) continue;
const start = Number.isFinite(Number(a.timestamp)) ? Number(a.timestamp) : m.timestamp;
const durationSec = assignmentDurationSeconds(a);
const cooldownSec = assignmentCooldownSeconds(a);
const left = Math.max(0, Math.min(100, (start / duration) * 100));
const widthPct = Math.max(1.2, Math.min(100 - left, (durationSec * 1000 / duration) * 100));
const cdWidthPct = cooldownSec > 0 ? Math.max(widthPct, Math.min(100 - left, (cooldownSec * 1000 / duration) * 100)) : widthPct;
const activeWidthPct = Math.min(100, (widthPct / cdWidthPct) * 100);
const selected = selectedAssignmentMatches(m.id, a) ? ' selected' : '';
const unresolved = a.job ? '' : ' timeline-mitigation--candidate';
const cls = a.buffType === 'debuff' ? 'timeline-mitigation--debuff'
: a.buffType === 'shield' ? 'timeline-mitigation--shield'
: 'timeline-mitigation--buff';
const icon = MITIG_ICONS[a.ability] ?? '';
const ability = assignmentAbilityName(a, plan);
blocks.push(`
<button class="timeline-mitigation ${cls}${selected}${unresolved}"
draggable="true"
style="left:${left}%;--cd-width:${cdWidthPct}%;--active-width:${activeWidthPct}%"
data-mechanic-id="${escHtml(m.id)}"
data-ability="${escHtml(a.ability)}"
data-job="${escHtml(a.job ?? '')}"
title="${escHtml(ability)} · aktiv ${durationSec}s · CD ${cooldownSec}s${a.job ? '' : ' · mögliche Zuordnung'}">
<span class="timeline-mitigation-active"></span>
${icon ? `<img src="${escHtml(icon)}" alt="">` : ''}
<span>${escHtml(ability)}</span>
</button>
`);
}
}
return `
<div class="timeline-row timeline-player-row" data-row-idx="${row.idx}" data-job="${escHtml(row.job)}">
<div class="timeline-row-label">
<span class="timeline-job role-${escHtml(row.role)}">${escHtml(row.job)}</span>
<span class="timeline-player-name">${escHtml(row.name || `Slot ${row.idx + 1}`)}</span>
</div>
<div class="timeline-track" style="width:${width}px">${hitLines}${blocks.join('')}</div>
</div>`;
}).join('');
return `
<div class="timeline-scroll">
<div class="timeline-grid" style="--timeline-width:${width}px">
<div class="timeline-row timeline-boss-row" style="--boss-row-height:${Math.max(52, 18 + laneCount * 30)}px">
<div class="timeline-row-label">Boss</div>
<div class="timeline-track" style="width:${width}px">${hitLines}${bossItems}</div>
</div>
${playerRows}
<div class="timeline-axis">
<div></div>
<div class="timeline-axis-track" style="width:${width}px">${marks.join('')}</div>
</div>
</div>
</div>`;
}
function findTimelineAssignment(plan, selected = selectedTimelineAssignment) {
if (!plan || !selected) return null;
const mechanic = (plan.mechanics ?? []).find(m => m.id === selected.mechanicId);
if (!mechanic) return null;
const assignment = (mechanic.assignments ?? []).find(a =>
a.ability === selected.ability && (a.job ?? '') === selected.job
);
return assignment ? { mechanic, assignment } : null;
}
function renderTimelineSettingsHtml(plan) {
const found = findTimelineAssignment(plan);
if (!found) {
return '<div class="timeline-settings-empty">Mitigation im Zeitstrahl auswählen, um Dauer und Cooldown anzupassen.</div>';
}
const { mechanic, assignment } = found;
const ability = assignmentAbilityName(assignment, plan);
const jobLabel = assignment.job || 'Nicht zugewiesen';
return `
<div class="timeline-settings-panel">
<div class="timeline-settings-title">${escHtml(jobLabel)} · ${escHtml(ability)}</div>
<div class="timeline-settings-meta">${escHtml(mechanic.name)} bei ${escHtml(fmtTimestamp(mechanic.timestamp))}</div>
<label>
<span>Start</span>
<input class="timeline-setting-input" data-field="timestamp" type="number" min="0" step="1" value="${Math.round(((assignment.timestamp ?? mechanic.timestamp) || 0) / 1000)}">
</label>
<label>
<span>Dauer</span>
<input class="timeline-setting-input" data-field="durationSeconds" type="number" min="1" step="1" value="${assignmentDurationSeconds(assignment)}">
</label>
<label>
<span>Cooldown</span>
<input class="timeline-setting-input" data-field="cooldownSeconds" type="number" min="0" step="1" value="${assignmentCooldownSeconds(assignment)}">
</label>
</div>`;
}
function refreshTimeline(planId) {
const plan = getPlan(planId);
if (!plan) return;
const timeline = document.getElementById('planner-timeline');
const settings = document.getElementById('timeline-settings');
if (timeline) timeline.innerHTML = renderTimelineHtml(plan);
if (settings) settings.innerHTML = renderTimelineSettingsHtml(plan);
}
function setTimelineAssignmentField(planId, mechanicId, ability, job, field, value) {
const plan = getPlan(planId);
if (!plan) return;
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job);
if (!assignment) return;
if (field === 'timestamp') assignment.timestamp = Math.max(0, Math.round(Number(value) * 1000));
if (field === 'durationSeconds') assignment.durationSeconds = Math.max(1, Math.round(Number(value)));
if (field === 'cooldownSeconds') assignment.cooldownSeconds = Math.max(0, Math.round(Number(value)));
updatePlan(planId, { mechanics: plan.mechanics });
refreshTimeline(planId);
}
function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) {
const plan = getPlan(planId);
if (!plan || !jobCanUseAbility(nextJob, ability)) return;
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
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;
assignment.job = nextJob;
selectedTimelineAssignment = { mechanicId, ability, job: nextJob };
updatePlan(planId, { mechanics: plan.mechanics });
refreshMechanicList(planId);
}
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 (selectedTimelineAssignment?.mechanicId === mechanicId && selectedTimelineAssignment?.ability === ability && selectedTimelineAssignment?.job === job) {
selectedTimelineAssignment = null;
}
updatePlan(planId, { mechanics: plan.mechanics });
refreshMechanicList(planId);
}
function addTimelineAssignment(planId, mechanicId, ability, job, buffType, timestamp) {
const plan = getPlan(planId);
if (!plan || !jobCanUseAbility(job, ability)) return;
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
if (!mechanic) return;
mechanic.assignments = mechanic.assignments ?? [];
mechanic.assignments.push({
ability,
abilityName: plan.mitigationNames?.[ability],
actionId: actionMetaByName[ability]?.id ?? null,
job,
buffType,
timestamp: Math.max(0, Math.round(timestamp)),
});
selectedTimelineAssignment = { mechanicId, ability, job };
updatePlan(planId, { mechanics: plan.mechanics });
refreshMechanicList(planId);
}
function closeTimelineMenu() {
document.getElementById('timeline-context-menu')?.remove();
}
function showTimelineMenu(x, y, items) {
closeTimelineMenu();
const menu = document.createElement('div');
menu.id = 'timeline-context-menu';
menu.className = 'timeline-context-menu';
menu.innerHTML = items.length
? items.map((item, idx) => `
<button class="timeline-menu-item${item.disabled ? ' disabled' : ''}" data-idx="${idx}"${item.disabled ? ' disabled' : ''}>
${item.icon ? `<img src="${escHtml(item.icon)}" alt="">` : ''}
<span>${escHtml(item.label)}</span>
</button>
`).join('')
: '<div class="timeline-menu-empty">Keine verfügbare Fähigkeit</div>';
document.body.appendChild(menu);
const rect = menu.getBoundingClientRect();
menu.style.left = Math.min(x, window.innerWidth - rect.width - 8) + 'px';
menu.style.top = Math.min(y, window.innerHeight - rect.height - 8) + 'px';
menu.addEventListener('click', e => {
const btn = e.target.closest('.timeline-menu-item');
if (!btn || btn.disabled) return;
items[parseInt(btn.dataset.idx, 10)]?.onClick?.();
closeTimelineMenu();
document.removeEventListener('click', closeOutside, true);
document.removeEventListener('contextmenu', closeOutside, true);
});
const closeOutside = ev => {
if (menu.contains(ev.target)) return;
closeTimelineMenu();
document.removeEventListener('click', closeOutside, true);
document.removeEventListener('contextmenu', closeOutside, true);
};
setTimeout(() => {
document.addEventListener('click', closeOutside, true);
document.addEventListener('contextmenu', closeOutside, true);
}, 0);
}
function updateTimelineAssignmentPosition(planId, mechanicId, ability, job, rowJob, timestamp) {
const plan = getPlan(planId);
if (!plan) return;
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
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));
assignment.job = rowJob;
selectedTimelineAssignment = { mechanicId, ability, job: rowJob };
updatePlan(planId, { mechanics: plan.mechanics });
refreshTimeline(planId);
refreshMechanicList(planId, false);
}
function initTimeline(planId) {
const timeline = document.getElementById('planner-timeline');
const settings = document.getElementById('timeline-settings');
if (!timeline || !settings) return;
timeline.addEventListener('click', e => {
closeTimelineMenu();
const boss = e.target.closest('.timeline-boss-action');
if (boss) {
showAbilityModal(planId, boss.dataset.mechanicId);
return;
}
const block = e.target.closest('.timeline-mitigation');
if (block) {
selectedTimelineAssignment = {
mechanicId: block.dataset.mechanicId,
ability: block.dataset.ability,
job: block.dataset.job,
};
refreshTimeline(planId);
const plan = getPlan(planId);
const rows = timelinePlayerRows(plan ?? {});
const found = findTimelineAssignment(plan, selectedTimelineAssignment);
const timestamp = found ? assignmentStartMs(found.mechanic, found.assignment) : 0;
const items = rows
.filter(row => jobCanUseAbility(row.job, block.dataset.ability))
.map(row => ({
label: `${row.job}${row.name ? ` · ${row.name}` : ''}`,
icon: MITIG_ICONS[block.dataset.ability] ?? '',
disabled: assignmentOverlapsJob(plan, row.job, block.dataset.ability, timestamp, {
mechanicId: block.dataset.mechanicId,
ability: block.dataset.ability,
job: block.dataset.job,
}),
onClick: () => setTimelineAssignmentJob(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job, row.job),
}));
showTimelineMenu(e.clientX, e.clientY, items);
return;
}
const track = e.target.closest('.timeline-player-row .timeline-track');
const row = e.target.closest('.timeline-player-row');
if (!track || !row) return;
const plan = getPlan(planId);
if (!plan) return;
const timestamp = timelineTimestampFromEvent(plan, track, e);
const mechanic = findNearestMechanic(plan, timestamp);
if (!mechanic) return;
const items = (JOB_ABILITIES[row.dataset.job] ?? [])
.filter(ab => !assignmentOverlapsJob(plan, row.dataset.job, ab.name, timestamp))
.map(ab => ({
label: `${plan.mitigationNames?.[ab.name] ?? ab.name} · ${fmtTimestamp(timestamp)}`,
icon: MITIG_ICONS[ab.name] ?? '',
onClick: () => addTimelineAssignment(planId, mechanic.id, ab.name, row.dataset.job, ab.buffType, timestamp),
}));
showTimelineMenu(e.clientX, e.clientY, items);
});
timeline.addEventListener('contextmenu', e => {
const block = e.target.closest('.timeline-mitigation');
if (!block) return;
e.preventDefault();
closeTimelineMenu();
removeTimelineAssignment(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job);
});
timeline.addEventListener('dragstart', e => {
const block = e.target.closest('.timeline-mitigation');
if (!block) return;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({
mechanicId: block.dataset.mechanicId,
ability: block.dataset.ability,
job: block.dataset.job,
}));
});
timeline.addEventListener('dragover', e => {
if (e.target.closest('.timeline-player-row .timeline-track')) e.preventDefault();
});
timeline.addEventListener('drop', e => {
const track = e.target.closest('.timeline-player-row .timeline-track');
const row = e.target.closest('.timeline-player-row');
if (!track || !row) return;
e.preventDefault();
let data;
try { data = JSON.parse(e.dataTransfer.getData('text/plain')); }
catch { return; }
const plan = getPlan(planId);
if (!plan) return;
const rect = track.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
const timestamp = (x / rect.width) * planDurationMs(plan);
updateTimelineAssignmentPosition(planId, data.mechanicId, data.ability, data.job, row.dataset.job, timestamp);
});
settings.addEventListener('change', e => {
const input = e.target.closest('.timeline-setting-input');
if (!input || !selectedTimelineAssignment) return;
setTimelineAssignmentField(
planId,
selectedTimelineAssignment.mechanicId,
selectedTimelineAssignment.ability,
selectedTimelineAssignment.job,
input.dataset.field,
input.value
);
});
}
// ── Mechanic list helpers ─────────────────────────────────────────────────────
function refreshMechanicList(planId) {
function refreshMechanicList(planId, includeTimeline = true) {
const plan = getPlan(planId);
if (!plan) return;
const el = document.getElementById('mechanic-list');
if (el) el.innerHTML = renderMechanicListHtml(plan);
if (includeTimeline) refreshTimeline(planId);
renderInfoPanel(plan);
}
@ -505,7 +1050,7 @@ function renderInfoPanel(plan) {
const noJobAbilities = new Set();
const missingJobPairs = new Set();
for (const m of plan.mechanics) {
for (const m of visiblePlanMechanics(plan)) {
for (const a of m.assignments) {
if (!a.job) {
noJobAbilities.add(a.ability);
@ -1007,6 +1552,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
assignments.push({
ability: key,
abilityName: mitigationDisplayName(m) || mitigationNames[key],
actionId: m.extraAbilityGameID ?? null,
job: guessJob(key, players),
buffType: m.buffType ?? '',
});
@ -1266,7 +1812,13 @@ function toggleAbilityAssignment(abilityName, job, buffType) {
mechanic.assignments[idx].job = job;
}
} else {
mechanic.assignments.push({ ability: abilityName, abilityName: plan.mitigationNames?.[abilityName], job, buffType });
mechanic.assignments.push({
ability: abilityName,
abilityName: plan.mitigationNames?.[abilityName],
actionId: actionMetaByName[abilityName]?.id ?? null,
job,
buffType,
});
}
updatePlan(abilityModalPlanId, { mechanics: plan.mechanics });

View File

@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
ini_set('memory_limit', '1024M');
const ACTION_SOURCE_URL = 'https://ff14.akurosiakamo.de/extras/json/xivapi_data/Action.json';
$rootDir = dirname(__DIR__);
$mitigationSource = $rootDir . '/api/analysis.php';
$outputFile = $rootDir . '/assets/jsons/Action.json';
function fail(string $message, int $code = 1): void
{
fwrite(STDERR, $message . PHP_EOL);
exit($code);
}
function extract_constant_array_literal(string $php, string $constantName): string
{
$needle = 'const ' . $constantName . ' =';
$start = strpos($php, $needle);
if ($start === false) {
fail('Could not find const ' . $constantName . ' in api/analysis.php');
}
$arrayStart = strpos($php, '[', $start);
if ($arrayStart === false) {
fail('Could not find array literal for ' . $constantName);
}
$depth = 0;
$length = strlen($php);
$inString = false;
$stringQuote = '';
$escaped = false;
for ($i = $arrayStart; $i < $length; $i++) {
$char = $php[$i];
if ($inString) {
if ($escaped) {
$escaped = false;
continue;
}
if ($char === '\\') {
$escaped = true;
continue;
}
if ($char === $stringQuote) {
$inString = false;
$stringQuote = '';
}
continue;
}
if ($char === '\'' || $char === '"') {
$inString = true;
$stringQuote = $char;
continue;
}
if ($char === '[') {
$depth++;
continue;
}
if ($char === ']') {
$depth--;
if ($depth === 0) {
return substr($php, $arrayStart, $i - $arrayStart + 1);
}
}
}
fail('Could not parse array literal for ' . $constantName);
}
function read_mitigation_action_ids(string $sourceFile): array
{
if (!is_file($sourceFile)) {
fail('Missing mitigation source file: ' . $sourceFile);
}
$php = file_get_contents($sourceFile);
if ($php === false) {
fail('Could not read mitigation source file: ' . $sourceFile);
}
$literal = extract_constant_array_literal($php, 'MITIGATION_ABILITIES');
$abilities = eval('return ' . $literal . ';');
if (!is_array($abilities)) {
fail('MITIGATION_ABILITIES did not parse as an array');
}
$ids = [];
foreach ($abilities as $name => $ability) {
$id = (int)($ability['extraAbilityGameID'] ?? 0);
if ($id <= 0) {
fwrite(STDERR, 'Skipping mitigation without extraAbilityGameID: ' . $name . PHP_EOL);
continue;
}
$ids[$id] = true;
}
if (!$ids) {
fail('No extraAbilityGameID values found in MITIGATION_ABILITIES');
}
ksort($ids, SORT_NUMERIC);
return array_keys($ids);
}
function download_url(string $url): string
{
$lastError = '';
$allowInsecureDownload = getenv('FF14_MITIGATOR_INSECURE_DOWNLOAD') === '1';
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_TIMEOUT => 120,
CURLOPT_USERAGENT => 'ff14-mitigator-action-cache/1.0',
CURLOPT_SSL_VERIFYPEER => !$allowInsecureDownload,
CURLOPT_SSL_VERIFYHOST => $allowInsecureDownload ? 0 : 2,
]);
$body = curl_exec($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body !== false && $status >= 200 && $status < 300) {
return $body;
}
$lastError = 'cURL HTTP ' . $status . ($error ? ': ' . $error : '');
}
$wrappers = stream_get_wrappers();
if (in_array('https', $wrappers, true)) {
$context = stream_context_create([
'http' => [
'timeout' => 120,
'user_agent' => 'ff14-mitigator-action-cache/1.0',
],
'ssl' => [
'verify_peer' => !$allowInsecureDownload,
'verify_peer_name' => !$allowInsecureDownload,
],
]);
$body = file_get_contents($url, false, $context);
if ($body !== false) {
return $body;
}
$lastError = trim($lastError . '; file_get_contents failed', '; ');
}
if ($lastError !== '') {
fail('Could not download Action.json. ' . $lastError);
}
fail('This PHP installation has neither cURL nor the HTTPS stream wrapper enabled.');
}
function action_field(array $action, string $field): ?int
{
if (array_key_exists($field, $action) && is_numeric($action[$field])) {
return (int)$action[$field];
}
$fields = $action['fields'] ?? null;
if (is_array($fields) && array_key_exists($field, $fields) && is_numeric($fields[$field])) {
return (int)$fields[$field];
}
return null;
}
$actionIds = read_mitigation_action_ids($mitigationSource);
$wanted = array_fill_keys(array_map('strval', $actionIds), true);
$json = download_url(ACTION_SOURCE_URL);
$actions = json_decode($json, true);
if (!is_array($actions)) {
fail('Downloaded Action.json is not valid JSON: ' . json_last_error_msg());
}
$filtered = [];
foreach ($wanted as $id => $_) {
$action = $actions[$id] ?? null;
if (!is_array($action)) {
fwrite(STDERR, 'Missing action in downloaded Action.json: ' . $id . PHP_EOL);
continue;
}
$filtered[$id] = [
'cast' => action_field($action, 'Cast100ms'),
'recast' => action_field($action, 'Recast100ms'),
];
}
if (!$filtered) {
fail('No matching mitigation actions found in downloaded Action.json');
}
ksort($filtered, SORT_NUMERIC);
$outputDir = dirname($outputFile);
if (!is_dir($outputDir) && !mkdir($outputDir, 0775, true) && !is_dir($outputDir)) {
fail('Could not create output directory: ' . $outputDir);
}
$encoded = json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($encoded === false) {
fail('Could not encode filtered Action.json: ' . json_last_error_msg());
}
if (file_put_contents($outputFile, $encoded . PHP_EOL, LOCK_EX) === false) {
fail('Could not write output file: ' . $outputFile);
}
echo 'Saved ' . count($filtered) . ' actions to ' . $outputFile . PHP_EOL;