Compare commits
7 Commits
4ec929ebb7
...
c983ca6621
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c983ca6621 | ||
|
|
fd0de86dbc | ||
|
|
0f8a90d1b4 | ||
|
|
8f00c22682 | ||
|
|
fb6d50961a | ||
|
|
3276e3bfb3 | ||
|
|
d0f54049e6 |
@ -723,6 +723,12 @@
|
|||||||
background: var(--bg1);
|
background: var(--bg1);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-scroll--dragging {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-grid {
|
.timeline-grid {
|
||||||
@ -750,6 +756,10 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--t2);
|
color: var(--t2);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 8;
|
||||||
|
box-shadow: 8px 0 12px rgba(0,0,0,.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-boss-row {
|
.timeline-boss-row {
|
||||||
@ -759,6 +769,7 @@
|
|||||||
.timeline-boss-row .timeline-row-label {
|
.timeline-boss-row .timeline-row-label {
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
z-index: 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-track,
|
.timeline-track,
|
||||||
@ -832,6 +843,14 @@
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-player-row--drop-ok .timeline-track {
|
||||||
|
background-color: rgba(88,180,116,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-player-row--drop-bad .timeline-track {
|
||||||
|
background-color: rgba(224,92,92,.08);
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-boss-action {
|
.timeline-boss-action {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
@ -925,6 +944,77 @@
|
|||||||
background: linear-gradient(to right, rgba(74,158,255,.30) 0%, rgba(74,158,255,.30) var(--active-width), rgba(74,158,255,.06) var(--active-width), rgba(74,158,255,.06) 100%);
|
background: linear-gradient(to right, rgba(74,158,255,.30) 0%, rgba(74,158,255,.30) var(--active-width), rgba(74,158,255,.06) var(--active-width), rgba(74,158,255,.06) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-drag-preview {
|
||||||
|
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 rgba(200,168,75,.85);
|
||||||
|
border-radius: var(--r);
|
||||||
|
background: linear-gradient(to right, rgba(200,168,75,.42) 0%, rgba(200,168,75,.42) var(--active-width), rgba(200,168,75,.14) var(--active-width), rgba(200,168,75,.14) 100%);
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 11px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 6;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0,0,0,.25), 0 0 16px rgba(200,168,75,.18);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-drag-preview::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: -6px;
|
||||||
|
bottom: -6px;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--gold);
|
||||||
|
box-shadow: 0 0 10px rgba(200,168,75,.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-drag-preview--bad {
|
||||||
|
border-color: rgba(224,92,92,.85);
|
||||||
|
background: rgba(224,92,92,.18);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-drag-preview--bad::before {
|
||||||
|
background: var(--red);
|
||||||
|
box-shadow: 0 0 10px rgba(224,92,92,.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-drag-preview img {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-drag-preview span {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-drag-preview-active {
|
||||||
|
position: absolute !important;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--active-width);
|
||||||
|
background: currentColor;
|
||||||
|
opacity: .20;
|
||||||
|
z-index: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-axis {
|
.timeline-axis {
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
}
|
}
|
||||||
|
|||||||
108
js/analysis.js
108
js/analysis.js
@ -1,102 +1,5 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const MITIG_ICONS = {
|
const { MITIG_ICONS, JOB_ABBR, ABILITY_JOBS, JOB_ROLE } = window.FF14_DATA;
|
||||||
// DR buffs
|
|
||||||
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
|
|
||||||
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
|
|
||||||
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
|
|
||||||
'Temperance': 'assets/icons/mitigation/temperance.png',
|
|
||||||
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
|
|
||||||
'Expedient': 'assets/icons/mitigation/expedient.png',
|
|
||||||
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
|
|
||||||
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
|
|
||||||
'Holos': 'assets/icons/mitigation/holos.png',
|
|
||||||
'Kerachole': 'assets/icons/mitigation/kerachole.png',
|
|
||||||
'Troubadour': 'assets/icons/mitigation/troubadour.png',
|
|
||||||
'Tactician': 'assets/icons/mitigation/tactician.png',
|
|
||||||
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
|
|
||||||
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
|
|
||||||
// Debuffs
|
|
||||||
'Reprisal': 'assets/icons/mitigation/reprisal.png',
|
|
||||||
'Feint': 'assets/icons/mitigation/feint.png',
|
|
||||||
'Addle': 'assets/icons/mitigation/addle.png',
|
|
||||||
// Shields
|
|
||||||
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
|
|
||||||
'Guardian': 'assets/icons/mitigation/guardian.png',
|
|
||||||
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
|
|
||||||
'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png',
|
|
||||||
'Divine Benison': 'assets/icons/mitigation/divine-benison.png',
|
|
||||||
'Divine Caress': 'assets/icons/mitigation/divine-caress.png',
|
|
||||||
'Intersection': 'assets/icons/mitigation/intersection.png',
|
|
||||||
'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png',
|
|
||||||
'the Spire': 'assets/icons/mitigation/the-spire.png',
|
|
||||||
'Panhaima': 'assets/icons/mitigation/panhaima.png',
|
|
||||||
'Holosakos': 'assets/icons/mitigation/holos.png',
|
|
||||||
'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png',
|
|
||||||
'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png',
|
|
||||||
'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
|
||||||
'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
|
||||||
'Haima': 'assets/icons/mitigation/haima.png',
|
|
||||||
'Galvanize': 'assets/icons/mitigation/galvanize.png',
|
|
||||||
'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png',
|
|
||||||
'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png',
|
|
||||||
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
|
|
||||||
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
|
|
||||||
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
|
|
||||||
};
|
|
||||||
|
|
||||||
const JOB_ABBR = {
|
|
||||||
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
|
|
||||||
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
|
|
||||||
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
|
|
||||||
'Reaper': 'RPR', 'Viper': 'VPR',
|
|
||||||
'Bard': 'BRD', 'Machinist': 'MCH', 'Dancer': 'DNC',
|
|
||||||
'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
|
|
||||||
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ability name → jobs that can provide it (for job-based ref comparison)
|
|
||||||
const ABILITY_JOBS = {
|
|
||||||
'Passage of Arms': ['PLD'],
|
|
||||||
'Divine Veil': ['PLD'],
|
|
||||||
'Guardian': ['PLD'],
|
|
||||||
'Reprisal': ['PLD', 'WAR', 'DRK', 'GNB'],
|
|
||||||
'Shake It Off': ['WAR'],
|
|
||||||
'Bloodwhetting': ['WAR'],
|
|
||||||
'Dark Missionary': ['DRK'],
|
|
||||||
'Heart of Light': ['GNB'],
|
|
||||||
'Temperance': ['WHM'],
|
|
||||||
'Divine Benison': ['WHM'],
|
|
||||||
'Divine Caress': ['WHM'],
|
|
||||||
'Sacred Soil': ['SCH'],
|
|
||||||
'Expedient': ['SCH'],
|
|
||||||
'Fey Illumination': ['SCH'],
|
|
||||||
'Galvanize': ['SCH'],
|
|
||||||
'Seraphic Veil': ['SCH'],
|
|
||||||
'Catalyze': ['SCH'],
|
|
||||||
'Collective Unconscious': ['AST'],
|
|
||||||
'Neutral Sect': ['AST'],
|
|
||||||
'Intersection': ['AST'],
|
|
||||||
'the Spire': ['AST'],
|
|
||||||
'Kerachole': ['SGE'],
|
|
||||||
'Holos': ['SGE'],
|
|
||||||
'Holosakos': ['SGE'],
|
|
||||||
'Panhaima': ['SGE'],
|
|
||||||
'Eukrasian Prognosis': ['SGE'],
|
|
||||||
'Eukrasian Prognosis II': ['SGE'],
|
|
||||||
'Eukrasian Diagnosis': ['SGE'],
|
|
||||||
'Differential Diagnosis': ['SGE'],
|
|
||||||
'Haima': ['SGE'],
|
|
||||||
'Troubadour': ['BRD'],
|
|
||||||
'Tactician': ['MCH'],
|
|
||||||
'Shield Samba': ['DNC'],
|
|
||||||
'Improvised Finish': ['DNC'],
|
|
||||||
'Feint': ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'],
|
|
||||||
'Addle': ['SCH', 'SGE', 'BLM', 'SMN', 'RDM', 'PCT'],
|
|
||||||
'Radiant Aegis': ['SMN'],
|
|
||||||
'Magick Barrier': ['RDM'],
|
|
||||||
'Tempera Coat': ['PCT'],
|
|
||||||
'Tempera Grassa': ['PCT'],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Deduplicated list of all mitigations across all targets of a ref event
|
// Deduplicated list of all mitigations across all targets of a ref event
|
||||||
function collectRefMitigs(refEvent) {
|
function collectRefMitigs(refEvent) {
|
||||||
@ -509,11 +412,6 @@
|
|||||||
const refPlanPanel = document.getElementById('ref-plan-panel');
|
const refPlanPanel = document.getElementById('ref-plan-panel');
|
||||||
const refPlanSelect = document.getElementById('ref-plan-select');
|
const refPlanSelect = document.getElementById('ref-plan-select');
|
||||||
|
|
||||||
const PLAN_JOB_ROLE = {
|
|
||||||
'PLD': 'tank', 'WAR': 'tank', 'DRK': 'tank', 'GNB': 'tank',
|
|
||||||
'WHM': 'healer', 'SCH': 'healer', 'AST': 'healer', 'SGE': 'healer',
|
|
||||||
};
|
|
||||||
|
|
||||||
function loadPlansForRef() {
|
function loadPlansForRef() {
|
||||||
try { return JSON.parse(localStorage.getItem('ff14-planner-plans') || '[]'); }
|
try { return JSON.parse(localStorage.getItem('ff14-planner-plans') || '[]'); }
|
||||||
catch { return []; }
|
catch { return []; }
|
||||||
@ -528,7 +426,7 @@
|
|||||||
const players = jobComp.map((job, i) => ({
|
const players = jobComp.map((job, i) => ({
|
||||||
job,
|
job,
|
||||||
name: roster[i]?.name ?? '',
|
name: roster[i]?.name ?? '',
|
||||||
role: PLAN_JOB_ROLE[job] ?? 'dps',
|
role: JOB_ROLE[job] ?? 'dps',
|
||||||
})).filter(p => p.name && p.job);
|
})).filter(p => p.name && p.job);
|
||||||
|
|
||||||
return plan.mechanics.map(m => {
|
return plan.mechanics.map(m => {
|
||||||
@ -610,7 +508,7 @@
|
|||||||
.map((job, i) => {
|
.map((job, i) => {
|
||||||
const name = plan.playerRoster?.[i]?.name ?? '';
|
const name = plan.playerRoster?.[i]?.name ?? '';
|
||||||
if (!name || !job) return null;
|
if (!name || !job) return null;
|
||||||
return { name, type: job, role: PLAN_JOB_ROLE[job] ?? 'dps' };
|
return { name, type: job, role: JOB_ROLE[job] ?? 'dps' };
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
|||||||
212
js/ffxiv-data.js
Normal file
212
js/ffxiv-data.js
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
(function () {
|
||||||
|
const JOB_FROM_TYPE = {
|
||||||
|
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
|
||||||
|
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
|
||||||
|
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
|
||||||
|
'Reaper': 'RPR', 'Viper': 'VPR', 'Bard': 'BRD', 'Machinist': 'MCH',
|
||||||
|
'Dancer': 'DNC', 'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
|
||||||
|
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
|
||||||
|
};
|
||||||
|
|
||||||
|
const JOB_ROLE = {
|
||||||
|
'PLD': 'tank', 'WAR': 'tank', 'DRK': 'tank', 'GNB': 'tank',
|
||||||
|
'WHM': 'healer', 'SCH': 'healer', 'AST': 'healer', 'SGE': 'healer',
|
||||||
|
'MNK': 'dps', 'DRG': 'dps', 'NIN': 'dps', 'SAM': 'dps',
|
||||||
|
'RPR': 'dps', 'VPR': 'dps', 'BRD': 'dps', 'MCH': 'dps',
|
||||||
|
'DNC': 'dps', 'BLM': 'dps', 'SMN': 'dps', 'RDM': 'dps', 'PCT': 'dps',
|
||||||
|
'BLU': 'dps',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_JOBS = [
|
||||||
|
{ group: 'Tank', jobs: ['PLD', 'WAR', 'DRK', 'GNB'] },
|
||||||
|
{ group: 'Healer', jobs: ['WHM', 'SCH', 'AST', 'SGE'] },
|
||||||
|
{ group: 'Melee', jobs: ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'] },
|
||||||
|
{ group: 'Ranged', jobs: ['BRD', 'MCH', 'DNC'] },
|
||||||
|
{ group: 'Caster', jobs: ['BLM', 'SMN', 'RDM', 'PCT'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const JOB_ABILITIES = {
|
||||||
|
'PLD': [
|
||||||
|
{ name: 'Passage of Arms', buffType: 'buff' },
|
||||||
|
{ name: 'Divine Veil', buffType: 'shield' },
|
||||||
|
{ name: 'Guardian', buffType: 'shield' },
|
||||||
|
{ name: 'Reprisal', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'WAR': [
|
||||||
|
{ name: 'Shake It Off', buffType: 'shield' },
|
||||||
|
{ name: 'Bloodwhetting', buffType: 'shield' },
|
||||||
|
{ name: 'Reprisal', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'DRK': [
|
||||||
|
{ name: 'Dark Missionary', buffType: 'buff' },
|
||||||
|
{ name: 'Reprisal', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'GNB': [
|
||||||
|
{ name: 'Heart of Light', buffType: 'buff' },
|
||||||
|
{ name: 'Reprisal', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'WHM': [
|
||||||
|
{ name: 'Temperance', buffType: 'buff' },
|
||||||
|
{ name: 'Divine Benison', buffType: 'shield' },
|
||||||
|
{ name: 'Divine Caress', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
'SCH': [
|
||||||
|
{ name: 'Sacred Soil', buffType: 'buff' },
|
||||||
|
{ name: 'Expedient', buffType: 'buff' },
|
||||||
|
{ name: 'Fey Illumination', buffType: 'buff' },
|
||||||
|
{ name: 'Galvanize', buffType: 'shield' },
|
||||||
|
{ name: 'Seraphic Veil', buffType: 'shield' },
|
||||||
|
{ name: 'Catalyze', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
'AST': [
|
||||||
|
{ name: 'Collective Unconscious', buffType: 'buff' },
|
||||||
|
{ name: 'Neutral Sect', buffType: 'shield' },
|
||||||
|
{ name: 'Intersection', buffType: 'shield' },
|
||||||
|
{ name: 'the Spire', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
'SGE': [
|
||||||
|
{ name: 'Kerachole', buffType: 'buff' },
|
||||||
|
{ name: 'Holos', buffType: 'buff' },
|
||||||
|
{ name: 'Holosakos', buffType: 'shield' },
|
||||||
|
{ name: 'Panhaima', buffType: 'shield' },
|
||||||
|
{ name: 'Eukrasian Prognosis', buffType: 'shield' },
|
||||||
|
{ name: 'Eukrasian Prognosis II', buffType: 'shield' },
|
||||||
|
{ name: 'Eukrasian Diagnosis', buffType: 'shield' },
|
||||||
|
{ name: 'Differential Diagnosis', buffType: 'shield' },
|
||||||
|
{ name: 'Haima', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
'BRD': [{ name: 'Troubadour', buffType: 'buff' }],
|
||||||
|
'MCH': [{ name: 'Tactician', buffType: 'buff' }],
|
||||||
|
'DNC': [
|
||||||
|
{ name: 'Shield Samba', buffType: 'buff' },
|
||||||
|
{ name: 'Improvised Finish', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
'MNK': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'DRG': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'NIN': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'SAM': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'RPR': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'VPR': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'BLM': [{ name: 'Addle', buffType: 'debuff' }],
|
||||||
|
'SMN': [
|
||||||
|
{ name: 'Addle', buffType: 'debuff' },
|
||||||
|
{ name: 'Radiant Aegis', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
'RDM': [
|
||||||
|
{ name: 'Addle', buffType: 'debuff' },
|
||||||
|
{ name: 'Magick Barrier', buffType: 'buff' },
|
||||||
|
],
|
||||||
|
'PCT': [
|
||||||
|
{ name: 'Addle', buffType: 'debuff' },
|
||||||
|
{ name: 'Tempera Coat', buffType: 'shield' },
|
||||||
|
{ name: 'Tempera Grassa', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ABILITY_JOB_MAP = {
|
||||||
|
'Passage of Arms': 'PLD', 'Divine Veil': 'PLD', 'Guardian': 'PLD',
|
||||||
|
'Shake It Off': 'WAR', 'Bloodwhetting': 'WAR',
|
||||||
|
'Dark Missionary': 'DRK',
|
||||||
|
'Heart of Light': 'GNB',
|
||||||
|
'Temperance': 'WHM', 'Divine Benison': 'WHM', 'Divine Caress': 'WHM',
|
||||||
|
'Sacred Soil': 'SCH', 'Expedient': 'SCH', 'Fey Illumination': 'SCH',
|
||||||
|
'Galvanize': 'SCH', 'Seraphic Veil': 'SCH', 'Catalyze': 'SCH',
|
||||||
|
'Collective Unconscious': 'AST', 'Neutral Sect': 'AST',
|
||||||
|
'Intersection': 'AST', 'the Spire': 'AST',
|
||||||
|
'Kerachole': 'SGE', 'Holos': 'SGE', 'Holosakos': 'SGE',
|
||||||
|
'Panhaima': 'SGE', 'Haima': 'SGE',
|
||||||
|
'Eukrasian Prognosis': 'SGE', 'Eukrasian Prognosis II': 'SGE',
|
||||||
|
'Eukrasian Diagnosis': 'SGE', 'Differential Diagnosis': 'SGE',
|
||||||
|
'Troubadour': 'BRD',
|
||||||
|
'Tactician': 'MCH',
|
||||||
|
'Shield Samba': 'DNC', 'Improvised Finish': 'DNC',
|
||||||
|
'Radiant Aegis': 'SMN',
|
||||||
|
'Magick Barrier': 'RDM',
|
||||||
|
'Tempera Coat': 'PCT', 'Tempera Grassa': 'PCT',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MITIG_ICONS = {
|
||||||
|
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
|
||||||
|
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
|
||||||
|
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
|
||||||
|
'Temperance': 'assets/icons/mitigation/temperance.png',
|
||||||
|
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
|
||||||
|
'Expedient': 'assets/icons/mitigation/expedient.png',
|
||||||
|
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
|
||||||
|
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
|
||||||
|
'Holos': 'assets/icons/mitigation/holos.png',
|
||||||
|
'Kerachole': 'assets/icons/mitigation/kerachole.png',
|
||||||
|
'Troubadour': 'assets/icons/mitigation/troubadour.png',
|
||||||
|
'Tactician': 'assets/icons/mitigation/tactician.png',
|
||||||
|
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
|
||||||
|
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
|
||||||
|
'Reprisal': 'assets/icons/mitigation/reprisal.png',
|
||||||
|
'Feint': 'assets/icons/mitigation/feint.png',
|
||||||
|
'Addle': 'assets/icons/mitigation/addle.png',
|
||||||
|
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
|
||||||
|
'Guardian': 'assets/icons/mitigation/guardian.png',
|
||||||
|
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
|
||||||
|
'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png',
|
||||||
|
'Divine Benison': 'assets/icons/mitigation/divine-benison.png',
|
||||||
|
'Divine Caress': 'assets/icons/mitigation/divine-caress.png',
|
||||||
|
'Intersection': 'assets/icons/mitigation/intersection.png',
|
||||||
|
'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png',
|
||||||
|
'the Spire': 'assets/icons/mitigation/the-spire.png',
|
||||||
|
'Panhaima': 'assets/icons/mitigation/panhaima.png',
|
||||||
|
'Holosakos': 'assets/icons/mitigation/holos.png',
|
||||||
|
'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png',
|
||||||
|
'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png',
|
||||||
|
'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
||||||
|
'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
||||||
|
'Haima': 'assets/icons/mitigation/haima.png',
|
||||||
|
'Galvanize': 'assets/icons/mitigation/galvanize.png',
|
||||||
|
'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png',
|
||||||
|
'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png',
|
||||||
|
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
|
||||||
|
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
|
||||||
|
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ABILITY_DR = {
|
||||||
|
'Passage of Arms': 0.15,
|
||||||
|
'Troubadour': 0.15,
|
||||||
|
'Tactician': 0.15,
|
||||||
|
'Shield Samba': 0.15,
|
||||||
|
'Dark Missionary': 0.10,
|
||||||
|
'Heart of Light': 0.10,
|
||||||
|
'Temperance': 0.10,
|
||||||
|
'Sacred Soil': 0.10,
|
||||||
|
'Expedient': 0.10,
|
||||||
|
'Collective Unconscious': 0.10,
|
||||||
|
'Holos': 0.10,
|
||||||
|
'Kerachole': 0.10,
|
||||||
|
'Magick Barrier': 0.10,
|
||||||
|
'Fey Illumination': 0.05,
|
||||||
|
'Reprisal': 0.10,
|
||||||
|
'Feint': 0.05,
|
||||||
|
'Addle': 0.10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ABILITY_JOBS = {};
|
||||||
|
Object.entries(JOB_ABILITIES).forEach(([job, abilities]) => {
|
||||||
|
abilities.forEach(ability => {
|
||||||
|
if (!ABILITY_JOBS[ability.name]) ABILITY_JOBS[ability.name] = [];
|
||||||
|
ABILITY_JOBS[ability.name].push(job);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.FF14_DATA = {
|
||||||
|
JOB_ABBR: JOB_FROM_TYPE,
|
||||||
|
JOB_FROM_TYPE,
|
||||||
|
JOB_ROLE,
|
||||||
|
ALL_JOBS,
|
||||||
|
JOB_ABILITIES,
|
||||||
|
ABILITY_JOB_MAP,
|
||||||
|
ABILITY_JOBS,
|
||||||
|
ABILITY_DR,
|
||||||
|
MITIG_ICONS,
|
||||||
|
TANK_JOBS: new Set(['PLD', 'WAR', 'DRK', 'GNB']),
|
||||||
|
MELEE_JOBS: new Set(['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR']),
|
||||||
|
CASTER_JOBS: new Set(['BLM', 'SMN', 'RDM', 'PCT']),
|
||||||
|
};
|
||||||
|
})();
|
||||||
693
js/planner.js
693
js/planner.js
@ -435,15 +435,33 @@ function avgNonTankMaxHp(plan) {
|
|||||||
return Math.round(hps.reduce((s, v) => s + v, 0) / hps.length);
|
return Math.round(hps.reduce((s, v) => s + v, 0) / hps.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
function simulateDrMultiplier(mechanic) {
|
function simulateDrMultiplier(mechanic, assignments = mechanic.assignments ?? []) {
|
||||||
let mult = 1;
|
let mult = 1;
|
||||||
for (const a of mechanic.assignments ?? []) {
|
for (const a of assignments) {
|
||||||
if (a.buffType === 'shield') continue;
|
if (a.buffType === 'shield') continue;
|
||||||
mult *= (1 - (ABILITY_DR[a.ability] ?? 0));
|
mult *= (1 - (ABILITY_DR[a.ability] ?? 0));
|
||||||
}
|
}
|
||||||
return mult;
|
return mult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function plannedAssignmentsForMechanic(plan, targetMechanic) {
|
||||||
|
const result = [];
|
||||||
|
const targetTime = Number(targetMechanic.timestamp);
|
||||||
|
const tolerance = 50;
|
||||||
|
|
||||||
|
for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: canonicalMechanicKey })) {
|
||||||
|
if (targetTime < entry.start - tolerance || targetTime > entry.end + tolerance) continue;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
...entry.assignment,
|
||||||
|
sourceMechanicId: entry.mechanic.id,
|
||||||
|
sourceStart: entry.start,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function renderMechanicListHtml(plan) {
|
function renderMechanicListHtml(plan) {
|
||||||
const mechanics = visiblePlanMechanics(plan);
|
const mechanics = visiblePlanMechanics(plan);
|
||||||
if (mechanics.length === 0) {
|
if (mechanics.length === 0) {
|
||||||
@ -462,7 +480,8 @@ function renderMechanicListHtml(plan) {
|
|||||||
const avgHp = avgNonTankMaxHp(plan);
|
const avgHp = avgNonTankMaxHp(plan);
|
||||||
|
|
||||||
return mechanics.map(m => {
|
return mechanics.map(m => {
|
||||||
const sorted = sortedAssignments(m.assignments);
|
const planned = plannedAssignmentsForMechanic(plan, m);
|
||||||
|
const sorted = sortedAssignments(planned);
|
||||||
const assignHtml = sorted.length === 0
|
const assignHtml = sorted.length === 0
|
||||||
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
|
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
|
||||||
: sorted.map(a => {
|
: sorted.map(a => {
|
||||||
@ -478,7 +497,7 @@ function renderMechanicListHtml(plan) {
|
|||||||
const badgeHtml = `<span class="badge badge-assign ${cls}${isMissing ? ' badge-assign--missing-job' : ''}"${title ? ` title="${title}"` : ''}>
|
const badgeHtml = `<span class="badge badge-assign ${cls}${isMissing ? ' badge-assign--missing-job' : ''}"${title ? ` title="${title}"` : ''}>
|
||||||
${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}
|
${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}
|
||||||
${label}
|
${label}
|
||||||
<button class="badge-remove" data-mechanic-id="${escHtml(m.id)}" data-ability="${escHtml(a.ability)}" title="Entfernen">×</button>
|
<button class="badge-remove" data-mechanic-id="${escHtml(a.sourceMechanicId ?? m.id)}" data-ability="${escHtml(a.ability)}" data-job="${escHtml(a.job ?? '')}" title="Entfernen">×</button>
|
||||||
</span>`;
|
</span>`;
|
||||||
const hintHtml = suggestions.map(s =>
|
const hintHtml = suggestions.map(s =>
|
||||||
`<span class="badge-equiv-hint">→ ${escHtml(s.ability)} (${escHtml(s.job)})?</span>`
|
`<span class="badge-equiv-hint">→ ${escHtml(s.ability)} (${escHtml(s.job)})?</span>`
|
||||||
@ -492,10 +511,10 @@ function renderMechanicListHtml(plan) {
|
|||||||
: badgeHtml;
|
: badgeHtml;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
const drOnly = m.unmitigatedDamage ? Math.round(m.unmitigatedDamage * simulateDrMultiplier(m)) : 0;
|
const drOnly = m.unmitigatedDamage ? Math.round(m.unmitigatedDamage * simulateDrMultiplier(m, planned)) : 0;
|
||||||
const shieldVal = (plan.shieldK ?? 0) * 1000;
|
const shieldVal = (plan.shieldK ?? 0) * 1000;
|
||||||
const mitigFull = Math.max(0, drOnly - shieldVal);
|
const mitigFull = Math.max(0, drOnly - shieldVal);
|
||||||
const hasDrAssign = (m.assignments ?? []).some(a => a.buffType !== 'shield' && (ABILITY_DR[a.ability] ?? 0) > 0);
|
const hasDrAssign = planned.some(a => a.buffType !== 'shield' && (ABILITY_DR[a.ability] ?? 0) > 0);
|
||||||
const hasShield = shieldVal > 0;
|
const hasShield = shieldVal > 0;
|
||||||
const drOnlyCls = avgHp ? (drOnly <= avgHp ? 'mechanic-mitig--ok' : 'mechanic-mitig--risk') : '';
|
const drOnlyCls = avgHp ? (drOnly <= avgHp ? 'mechanic-mitig--ok' : 'mechanic-mitig--risk') : '';
|
||||||
const fullCls = avgHp ? (mitigFull <= avgHp ? 'mechanic-mitig--ok' : 'mechanic-mitig--risk') : '';
|
const fullCls = avgHp ? (mitigFull <= avgHp ? 'mechanic-mitig--ok' : 'mechanic-mitig--risk') : '';
|
||||||
@ -620,6 +639,172 @@ function assignmentStartMs(mechanic, assignment) {
|
|||||||
return Number.isFinite(Number(assignment?.timestamp)) ? Number(assignment.timestamp) : mechanic.timestamp;
|
return Number.isFinite(Number(assignment?.timestamp)) ? Number(assignment.timestamp) : mechanic.timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ?? []) {
|
||||||
|
const start = assignmentStartMs(mechanic, assignment);
|
||||||
|
const durationSec = assignmentDurationSeconds(assignment);
|
||||||
|
entries.push({
|
||||||
|
mechanic,
|
||||||
|
assignment,
|
||||||
|
start,
|
||||||
|
durationSec,
|
||||||
|
end: start + durationSec * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => a.start - b.start);
|
||||||
|
const activeUntilBySkill = new Map();
|
||||||
|
return entries.filter(entry => {
|
||||||
|
if (includeEntry && !includeEntry(entry)) return false;
|
||||||
|
const key = dedupeKey(entry);
|
||||||
|
const activeUntil = activeUntilBySkill.get(key) ?? -Infinity;
|
||||||
|
if (entry.start <= activeUntil) return false;
|
||||||
|
activeUntilBySkill.set(key, entry.end);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@ -650,17 +835,36 @@ function layoutBossActions(mechanics, duration) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignmentOverlapsJob(plan, job, ability, timestamp, ignore = null) {
|
function assignmentWindowMs(assignment) {
|
||||||
|
return Math.max(1, 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 ? 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 mechanic of visiblePlanMechanics(plan)) {
|
||||||
for (const assignment of mechanic.assignments ?? []) {
|
for (const assignment of mechanic.assignments ?? []) {
|
||||||
if (assignment.ability !== ability) continue;
|
if (assignment.ability !== ability) continue;
|
||||||
if ((assignment.job ?? '') !== job && !(!(assignment.job ?? '') && jobCanUseAbility(job, 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 start = assignmentStartMs(mechanic, assignment);
|
||||||
const cooldownMs = Math.max(assignmentCooldownSeconds(assignment), assignmentDurationSeconds(assignment)) * 1000;
|
const end = start + assignmentWindowMs(assignment);
|
||||||
const end = start + cooldownMs;
|
if (ignoredActivation) {
|
||||||
if (timestamp >= start && timestamp < end) return true;
|
const entry = { mechanic, assignment, start, end };
|
||||||
|
if (assignmentsOverlapActiveFrame(plan, ignoredActivation, entry)) continue;
|
||||||
|
}
|
||||||
|
if (candidateStart < end && candidateEnd > start) return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@ -714,13 +918,20 @@ function renderTimelineHtml(plan) {
|
|||||||
|
|
||||||
const playerRows = rows.map(row => {
|
const playerRows = rows.map(row => {
|
||||||
const blocks = [];
|
const blocks = [];
|
||||||
for (const m of mechanics) {
|
for (const entry of canonicalAssignmentActivations(plan, {
|
||||||
for (const a of sortedAssignments(m.assignments ?? [])) {
|
dedupeKey: item => canonicalTimelineKey(item, row),
|
||||||
if (a.ability !== row.ability) continue;
|
includeEntry: item => {
|
||||||
const assignedJob = a.job ?? '';
|
const assignment = item.assignment;
|
||||||
if (assignedJob && assignedJob !== row.job) continue;
|
if (assignment.ability !== row.ability) return false;
|
||||||
const start = Number.isFinite(Number(a.timestamp)) ? Number(a.timestamp) : m.timestamp;
|
const assignedJob = assignment.job ?? '';
|
||||||
const durationSec = assignmentDurationSeconds(a);
|
return !assignedJob || assignedJob === row.job;
|
||||||
|
},
|
||||||
|
})) {
|
||||||
|
const item = entry;
|
||||||
|
const m = item.mechanic;
|
||||||
|
const a = item.assignment;
|
||||||
|
const start = item.start;
|
||||||
|
const durationSec = item.durationSec;
|
||||||
const cooldownSec = assignmentCooldownSeconds(a);
|
const cooldownSec = assignmentCooldownSeconds(a);
|
||||||
const left = Math.max(0, Math.min(100, (start / duration) * 100));
|
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 widthPct = Math.max(1.2, Math.min(100 - left, (durationSec * 1000 / duration) * 100));
|
||||||
@ -746,7 +957,6 @@ function renderTimelineHtml(plan) {
|
|||||||
<span>${escHtml(abilityLabel)}</span>
|
<span>${escHtml(abilityLabel)}</span>
|
||||||
</button>
|
</button>
|
||||||
`);
|
`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const icon = MITIG_ICONS[row.ability] ?? '';
|
const icon = MITIG_ICONS[row.ability] ?? '';
|
||||||
const abilityDisplayName = plan.mitigationNames?.[row.ability] ?? row.ability;
|
const abilityDisplayName = plan.mitigationNames?.[row.ability] ?? row.ability;
|
||||||
@ -821,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);
|
||||||
@ -838,7 +1049,7 @@ function setTimelineAssignmentField(planId, mechanicId, ability, job, field, val
|
|||||||
if (field === 'durationSeconds') assignment.durationSeconds = Math.max(1, Math.round(Number(value)));
|
if (field === 'durationSeconds') assignment.durationSeconds = Math.max(1, Math.round(Number(value)));
|
||||||
if (field === 'cooldownSeconds') assignment.cooldownSeconds = Math.max(0, Math.round(Number(value)));
|
if (field === 'cooldownSeconds') assignment.cooldownSeconds = Math.max(0, Math.round(Number(value)));
|
||||||
updatePlan(planId, { mechanics: plan.mechanics });
|
updatePlan(planId, { mechanics: plan.mechanics });
|
||||||
refreshTimeline(planId);
|
refreshMechanicList(planId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) {
|
function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) {
|
||||||
@ -847,8 +1058,9 @@ 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 })) return;
|
if (assignmentOverlapsJob(plan, nextJob, ability, timestamp, { mechanicId, ability, job }, assignment)) return;
|
||||||
assignment.job = nextJob;
|
assignment.job = nextJob;
|
||||||
selectedTimelineAssignment = { mechanicId, ability, job: nextJob };
|
selectedTimelineAssignment = { mechanicId, ability, job: nextJob };
|
||||||
updatePlan(planId, { mechanics: plan.mechanics });
|
updatePlan(planId, { mechanics: plan.mechanics });
|
||||||
@ -858,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;
|
||||||
}
|
}
|
||||||
@ -874,14 +1084,16 @@ function addTimelineAssignment(planId, mechanicId, ability, job, buffType, times
|
|||||||
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
|
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
|
||||||
if (!mechanic) return;
|
if (!mechanic) return;
|
||||||
mechanic.assignments = mechanic.assignments ?? [];
|
mechanic.assignments = mechanic.assignments ?? [];
|
||||||
mechanic.assignments.push({
|
const assignment = {
|
||||||
ability,
|
ability,
|
||||||
abilityName: plan.mitigationNames?.[ability],
|
abilityName: plan.mitigationNames?.[ability],
|
||||||
actionId: actionMetaByName[ability]?.id ?? null,
|
actionId: actionMetaByName[ability]?.id ?? null,
|
||||||
job,
|
job,
|
||||||
buffType,
|
buffType,
|
||||||
timestamp: Math.max(0, Math.round(timestamp)),
|
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 };
|
selectedTimelineAssignment = { mechanicId, ability, job };
|
||||||
updatePlan(planId, { mechanics: plan.mechanics });
|
updatePlan(planId, { mechanics: plan.mechanics });
|
||||||
refreshMechanicList(planId);
|
refreshMechanicList(planId);
|
||||||
@ -938,7 +1150,10 @@ 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;
|
||||||
assignment.timestamp = Math.max(0, Math.round(timestamp));
|
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;
|
||||||
assignment.job = rowJob;
|
assignment.job = rowJob;
|
||||||
selectedTimelineAssignment = { mechanicId, ability, job: rowJob };
|
selectedTimelineAssignment = { mechanicId, ability, job: rowJob };
|
||||||
updatePlan(planId, { mechanics: plan.mechanics });
|
updatePlan(planId, { mechanics: plan.mechanics });
|
||||||
@ -951,7 +1166,113 @@ function initTimeline(planId) {
|
|||||||
const settings = document.getElementById('timeline-settings');
|
const settings = document.getElementById('timeline-settings');
|
||||||
if (!timeline || !settings) return;
|
if (!timeline || !settings) return;
|
||||||
|
|
||||||
|
let timelinePan = null;
|
||||||
|
let suppressNextTimelineClick = false;
|
||||||
|
let timelineDrag = null;
|
||||||
|
|
||||||
|
function removeDragPreview() {
|
||||||
|
document.getElementById('timeline-drag-preview')?.remove();
|
||||||
|
timeline.querySelectorAll('.timeline-player-row--drop-ok, .timeline-player-row--drop-bad')
|
||||||
|
.forEach(row => row.classList.remove('timeline-player-row--drop-ok', 'timeline-player-row--drop-bad'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDragPreview(event) {
|
||||||
|
if (!timelineDrag) return;
|
||||||
|
removeDragPreview();
|
||||||
|
|
||||||
|
const track = event.target.closest('.timeline-player-row .timeline-track');
|
||||||
|
const row = event.target.closest('.timeline-player-row');
|
||||||
|
if (!track || !row) return;
|
||||||
|
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
if (!plan) return;
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
const duration = planDurationMs(plan);
|
||||||
|
const deltaPx = event.clientX - timelineDrag.startClientX;
|
||||||
|
const timestamp = Math.max(0, timelineDrag.startTimestamp + (deltaPx / rect.width) * duration);
|
||||||
|
const left = Math.max(0, Math.min(100, (timestamp / duration) * 100));
|
||||||
|
const durationPct = Math.max(1.2, Math.min(100 - left, (timelineDrag.durationSec * 1000 / duration) * 100));
|
||||||
|
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)
|
||||||
|
&& !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');
|
||||||
|
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.id = 'timeline-drag-preview';
|
||||||
|
preview.className = `timeline-drag-preview ${valid ? '' : 'timeline-drag-preview--bad'}`;
|
||||||
|
preview.style.left = `${left}%`;
|
||||||
|
preview.style.setProperty('--cd-width', `${cooldownPct}%`);
|
||||||
|
preview.style.setProperty('--active-width', `${activePct}%`);
|
||||||
|
preview.innerHTML = `
|
||||||
|
<span class="timeline-drag-preview-active"></span>
|
||||||
|
${timelineDrag.icon ? `<img src="${escHtml(timelineDrag.icon)}" alt="">` : ''}
|
||||||
|
<span>${escHtml(timelineDrag.label)}</span>
|
||||||
|
`;
|
||||||
|
track.appendChild(preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeline.addEventListener('pointerdown', e => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
if (!e.target.closest('.timeline-scroll')) return;
|
||||||
|
if (e.target.closest('.timeline-mitigation, .timeline-boss-action, .timeline-context-menu')) return;
|
||||||
|
|
||||||
|
const scroll = e.target.closest('.timeline-scroll');
|
||||||
|
timelinePan = {
|
||||||
|
scroll,
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
startX: e.clientX,
|
||||||
|
startScrollLeft: scroll.scrollLeft,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
scroll.setPointerCapture?.(e.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.addEventListener('pointermove', e => {
|
||||||
|
if (!timelinePan || timelinePan.pointerId !== e.pointerId) return;
|
||||||
|
const dx = e.clientX - timelinePan.startX;
|
||||||
|
if (Math.abs(dx) > 8) {
|
||||||
|
timelinePan.moved = true;
|
||||||
|
timelinePan.scroll.classList.add('timeline-scroll--dragging');
|
||||||
|
timelinePan.scroll.scrollLeft = timelinePan.startScrollLeft - dx;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.addEventListener('pointerup', e => {
|
||||||
|
if (!timelinePan || timelinePan.pointerId !== e.pointerId) return;
|
||||||
|
timelinePan.scroll.releasePointerCapture?.(e.pointerId);
|
||||||
|
timelinePan.scroll.classList.remove('timeline-scroll--dragging');
|
||||||
|
suppressNextTimelineClick = timelinePan.moved;
|
||||||
|
timelinePan = null;
|
||||||
|
if (suppressNextTimelineClick) setTimeout(() => { suppressNextTimelineClick = false; }, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.addEventListener('pointercancel', e => {
|
||||||
|
if (!timelinePan || timelinePan.pointerId !== e.pointerId) return;
|
||||||
|
timelinePan.scroll.releasePointerCapture?.(e.pointerId);
|
||||||
|
timelinePan.scroll.classList.remove('timeline-scroll--dragging');
|
||||||
|
timelinePan = null;
|
||||||
|
});
|
||||||
|
|
||||||
timeline.addEventListener('click', e => {
|
timeline.addEventListener('click', e => {
|
||||||
|
if (suppressNextTimelineClick) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
suppressNextTimelineClick = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
closeTimelineMenu();
|
closeTimelineMenu();
|
||||||
const boss = e.target.closest('.timeline-boss-action');
|
const boss = e.target.closest('.timeline-boss-action');
|
||||||
if (boss) {
|
if (boss) {
|
||||||
@ -979,15 +1300,18 @@ function initTimeline(planId) {
|
|||||||
mechanicId: block.dataset.mechanicId,
|
mechanicId: block.dataset.mechanicId,
|
||||||
ability: block.dataset.ability,
|
ability: block.dataset.ability,
|
||||||
job: block.dataset.job,
|
job: block.dataset.job,
|
||||||
}),
|
}, found?.assignment ?? null),
|
||||||
onClick: () => setTimelineAssignmentJob(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job, row.job),
|
onClick: () => setTimelineAssignmentJob(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job, row.job),
|
||||||
}));
|
}));
|
||||||
showTimelineMenu(e.clientX, e.clientY, items);
|
showTimelineMenu(e.clientX, e.clientY, items);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const track = e.target.closest('.timeline-player-row .timeline-track');
|
// setPointerCapture() leitet compatibility mouse events (inkl. click) an das
|
||||||
const row = e.target.closest('.timeline-player-row');
|
// capturing element um, daher e.target nicht verlässlich — echtes Element per Hit-Test:
|
||||||
|
const actualTarget = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
const track = actualTarget?.closest('.timeline-player-row .timeline-track');
|
||||||
|
const row = actualTarget?.closest('.timeline-player-row');
|
||||||
if (!track || !row) return;
|
if (!track || !row) return;
|
||||||
const plan = getPlan(planId);
|
const plan = getPlan(planId);
|
||||||
if (!plan) return;
|
if (!plan) return;
|
||||||
@ -997,7 +1321,13 @@ function initTimeline(planId) {
|
|||||||
const rowAbility = row.dataset.ability;
|
const rowAbility = row.dataset.ability;
|
||||||
const rowJob = row.dataset.job;
|
const rowJob = row.dataset.job;
|
||||||
const ab = (JOB_ABILITIES[rowJob] ?? []).find(a => a.name === rowAbility);
|
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);
|
addTimelineAssignment(planId, mechanic.id, rowAbility, rowJob, ab.buffType, timestamp);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1012,19 +1342,51 @@ function initTimeline(planId) {
|
|||||||
timeline.addEventListener('dragstart', e => {
|
timeline.addEventListener('dragstart', e => {
|
||||||
const block = e.target.closest('.timeline-mitigation');
|
const block = e.target.closest('.timeline-mitigation');
|
||||||
if (!block) return;
|
if (!block) return;
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
const found = findTimelineAssignment(plan, {
|
||||||
|
mechanicId: block.dataset.mechanicId,
|
||||||
|
ability: block.dataset.ability,
|
||||||
|
job: block.dataset.job,
|
||||||
|
});
|
||||||
|
if (!plan || !found) return;
|
||||||
|
const transparent = document.createElement('canvas');
|
||||||
|
transparent.width = 1;
|
||||||
|
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,
|
||||||
|
icon: block.querySelector('img')?.getAttribute('src') ?? '',
|
||||||
|
startClientX: e.clientX,
|
||||||
|
startTimestamp: assignmentStartMs(found.mechanic, found.assignment),
|
||||||
|
durationSec: assignmentDurationSeconds(found.assignment),
|
||||||
|
cooldownSec: assignmentCooldownSeconds(found.assignment),
|
||||||
|
};
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
e.dataTransfer.setData('text/plain', JSON.stringify({
|
e.dataTransfer.setData('text/plain', JSON.stringify({
|
||||||
mechanicId: block.dataset.mechanicId,
|
mechanicId: block.dataset.mechanicId,
|
||||||
ability: block.dataset.ability,
|
ability: block.dataset.ability,
|
||||||
job: block.dataset.job,
|
job: block.dataset.job,
|
||||||
|
startClientX: e.clientX,
|
||||||
|
startTimestamp: assignmentStartMs(found.mechanic, found.assignment),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
timeline.addEventListener('dragover', e => {
|
timeline.addEventListener('dragover', e => {
|
||||||
if (e.target.closest('.timeline-player-row .timeline-track')) e.preventDefault();
|
if (e.target.closest('.timeline-player-row .timeline-track')) {
|
||||||
|
e.preventDefault();
|
||||||
|
updateDragPreview(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.addEventListener('dragleave', e => {
|
||||||
|
if (!timeline.contains(e.relatedTarget)) removeDragPreview();
|
||||||
});
|
});
|
||||||
|
|
||||||
timeline.addEventListener('drop', e => {
|
timeline.addEventListener('drop', e => {
|
||||||
|
removeDragPreview();
|
||||||
const track = e.target.closest('.timeline-player-row .timeline-track');
|
const track = e.target.closest('.timeline-player-row .timeline-track');
|
||||||
const row = e.target.closest('.timeline-player-row');
|
const row = e.target.closest('.timeline-player-row');
|
||||||
if (!track || !row) return;
|
if (!track || !row) return;
|
||||||
@ -1035,10 +1397,17 @@ function initTimeline(planId) {
|
|||||||
const plan = getPlan(planId);
|
const plan = getPlan(planId);
|
||||||
if (!plan) return;
|
if (!plan) return;
|
||||||
const rect = track.getBoundingClientRect();
|
const rect = track.getBoundingClientRect();
|
||||||
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
|
const deltaPx = e.clientX - (Number(data.startClientX) || e.clientX);
|
||||||
const timestamp = (x / rect.width) * planDurationMs(plan);
|
const timestampDelta = (deltaPx / rect.width) * planDurationMs(plan);
|
||||||
|
const timestamp = (Number(data.startTimestamp) || 0) + timestampDelta;
|
||||||
if (row.dataset.ability && data.ability !== row.dataset.ability) return;
|
if (row.dataset.ability && data.ability !== row.dataset.ability) return;
|
||||||
updateTimelineAssignmentPosition(planId, data.mechanicId, data.ability, data.job, row.dataset.job, timestamp);
|
updateTimelineAssignmentPosition(planId, data.mechanicId, data.ability, data.job, row.dataset.job, timestamp);
|
||||||
|
timelineDrag = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.addEventListener('dragend', () => {
|
||||||
|
timelineDrag = null;
|
||||||
|
removeDragPreview();
|
||||||
});
|
});
|
||||||
|
|
||||||
settings.addEventListener('change', e => {
|
settings.addEventListener('change', e => {
|
||||||
@ -1060,6 +1429,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);
|
||||||
@ -1133,12 +1503,22 @@ function renderInfoPanel(plan) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAssignment(planId, mechanicId, abilityName) {
|
function removeAssignment(planId, mechanicId, abilityName, job = null) {
|
||||||
const plan = getPlan(planId);
|
const plan = getPlan(planId);
|
||||||
if (!plan) return;
|
if (!plan) return;
|
||||||
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
|
let removed = false;
|
||||||
if (!mechanic) return;
|
if (job === null) {
|
||||||
mechanic.assignments = mechanic.assignments.filter(a => a.ability !== abilityName);
|
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 });
|
updatePlan(planId, { mechanics: plan.mechanics });
|
||||||
refreshMechanicList(planId);
|
refreshMechanicList(planId);
|
||||||
if (abilityModalMechanicId === mechanicId) renderAbilityModalContent();
|
if (abilityModalMechanicId === mechanicId) renderAbilityModalContent();
|
||||||
@ -1160,7 +1540,7 @@ function initMechanicClicks(planId) {
|
|||||||
const removeBtn = e.target.closest('.badge-remove');
|
const removeBtn = e.target.closest('.badge-remove');
|
||||||
if (removeBtn) {
|
if (removeBtn) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability);
|
removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability, removeBtn.dataset.job ?? null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const deleteBtn = e.target.closest('.mechanic-delete-btn');
|
const deleteBtn = e.target.closest('.mechanic-delete-btn');
|
||||||
@ -1179,7 +1559,7 @@ function initMechanicClicks(planId) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const removeBtn = badge.querySelector('.badge-remove');
|
const removeBtn = badge.querySelector('.badge-remove');
|
||||||
if (removeBtn) removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability);
|
if (removeBtn) removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability, removeBtn.dataset.job ?? null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1351,200 +1731,20 @@ function initNewFolderForm() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ability → Job mapping ─────────────────────────────────────────────────────
|
// ── Shared FFXIV metadata ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ABILITY_JOB_MAP = {
|
|
||||||
'Passage of Arms': 'PLD', 'Divine Veil': 'PLD', 'Guardian': 'PLD',
|
|
||||||
'Shake It Off': 'WAR', 'Bloodwhetting': 'WAR',
|
|
||||||
'Dark Missionary': 'DRK',
|
|
||||||
'Heart of Light': 'GNB',
|
|
||||||
'Temperance': 'WHM', 'Divine Benison': 'WHM', 'Divine Caress': 'WHM',
|
|
||||||
'Sacred Soil': 'SCH', 'Expedient': 'SCH', 'Fey Illumination': 'SCH',
|
|
||||||
'Galvanize': 'SCH', 'Seraphic Veil': 'SCH', 'Catalyze': 'SCH',
|
|
||||||
'Collective Unconscious': 'AST', 'Neutral Sect': 'AST',
|
|
||||||
'Intersection': 'AST', 'the Spire': 'AST',
|
|
||||||
'Kerachole': 'SGE', 'Holos': 'SGE', 'Holosakos': 'SGE',
|
|
||||||
'Panhaima': 'SGE', 'Haima': 'SGE',
|
|
||||||
'Eukrasian Prognosis': 'SGE',
|
|
||||||
'Eukrasian Diagnosis': 'SGE', 'Differential Diagnosis': 'SGE',
|
|
||||||
'Troubadour': 'BRD',
|
|
||||||
'Tactician': 'MCH',
|
|
||||||
'Shield Samba': 'DNC', 'Improvised Finish': 'DNC',
|
|
||||||
'Radiant Aegis': 'SMN',
|
|
||||||
'Magick Barrier': 'RDM',
|
|
||||||
'Tempera Coat': 'PCT', 'Tempera Grassa': 'PCT',
|
|
||||||
};
|
|
||||||
|
|
||||||
const JOB_FROM_TYPE = {
|
|
||||||
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
|
|
||||||
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
|
|
||||||
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
|
|
||||||
'Reaper': 'RPR', 'Viper': 'VPR', 'Bard': 'BRD', 'Machinist': 'MCH',
|
|
||||||
'Dancer': 'DNC', 'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
|
|
||||||
'Pictomancer': 'PCT',
|
|
||||||
};
|
|
||||||
|
|
||||||
const TANK_JOBS = new Set(['PLD', 'WAR', 'DRK', 'GNB']);
|
|
||||||
const MELEE_JOBS = new Set(['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR']);
|
|
||||||
const CASTER_JOBS = new Set(['BLM', 'SMN', 'RDM', 'PCT']);
|
|
||||||
|
|
||||||
const JOB_ROLE = {
|
|
||||||
'PLD': 'tank', 'WAR': 'tank', 'DRK': 'tank', 'GNB': 'tank',
|
|
||||||
'WHM': 'healer', 'SCH': 'healer', 'AST': 'healer', 'SGE': 'healer',
|
|
||||||
'MNK': 'dps', 'DRG': 'dps', 'NIN': 'dps', 'SAM': 'dps',
|
|
||||||
'RPR': 'dps', 'VPR': 'dps', 'BRD': 'dps', 'MCH': 'dps',
|
|
||||||
'DNC': 'dps', 'BLM': 'dps', 'SMN': 'dps', 'RDM': 'dps', 'PCT': 'dps',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALL_JOBS = [
|
|
||||||
{ group: 'Tank', jobs: ['PLD', 'WAR', 'DRK', 'GNB'] },
|
|
||||||
{ group: 'Healer', jobs: ['WHM', 'SCH', 'AST', 'SGE'] },
|
|
||||||
{ group: 'Melee', jobs: ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'] },
|
|
||||||
{ group: 'Ranged', jobs: ['BRD', 'MCH', 'DNC'] },
|
|
||||||
{ group: 'Caster', jobs: ['BLM', 'SMN', 'RDM', 'PCT'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const JOB_ABILITIES = {
|
|
||||||
'PLD': [
|
|
||||||
{ name: 'Passage of Arms', buffType: 'buff' },
|
|
||||||
{ name: 'Divine Veil', buffType: 'shield' },
|
|
||||||
{ name: 'Guardian', buffType: 'shield' },
|
|
||||||
{ name: 'Reprisal', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'WAR': [
|
|
||||||
{ name: 'Shake It Off', buffType: 'shield' },
|
|
||||||
{ name: 'Bloodwhetting', buffType: 'shield' },
|
|
||||||
{ name: 'Reprisal', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'DRK': [
|
|
||||||
{ name: 'Dark Missionary', buffType: 'buff' },
|
|
||||||
{ name: 'Reprisal', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'GNB': [
|
|
||||||
{ name: 'Heart of Light', buffType: 'buff' },
|
|
||||||
{ name: 'Reprisal', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'WHM': [
|
|
||||||
{ name: 'Temperance', buffType: 'buff' },
|
|
||||||
{ name: 'Divine Benison', buffType: 'shield' },
|
|
||||||
{ name: 'Divine Caress', buffType: 'shield' },
|
|
||||||
],
|
|
||||||
'SCH': [
|
|
||||||
{ name: 'Sacred Soil', buffType: 'buff' },
|
|
||||||
{ name: 'Expedient', buffType: 'buff' },
|
|
||||||
{ name: 'Fey Illumination', buffType: 'buff' },
|
|
||||||
{ name: 'Galvanize', buffType: 'shield' },
|
|
||||||
{ name: 'Seraphic Veil', buffType: 'shield' },
|
|
||||||
{ name: 'Catalyze', buffType: 'shield' },
|
|
||||||
{ name: 'Addle', buffType: 'debuff' },
|
|
||||||
],
|
|
||||||
'AST': [
|
|
||||||
{ name: 'Collective Unconscious', buffType: 'buff' },
|
|
||||||
{ name: 'Neutral Sect', buffType: 'shield' },
|
|
||||||
{ name: 'Intersection', buffType: 'shield' },
|
|
||||||
{ name: 'the Spire', buffType: 'shield' },
|
|
||||||
],
|
|
||||||
'SGE': [
|
|
||||||
{ name: 'Kerachole', buffType: 'buff' },
|
|
||||||
{ name: 'Holos', buffType: 'buff' },
|
|
||||||
{ name: 'Holosakos', buffType: 'shield' },
|
|
||||||
{ name: 'Panhaima', buffType: 'shield' },
|
|
||||||
{ name: 'Eukrasian Prognosis', buffType: 'shield' },
|
|
||||||
{ name: 'Eukrasian Diagnosis', buffType: 'shield' },
|
|
||||||
{ name: 'Differential Diagnosis', buffType: 'shield' },
|
|
||||||
{ name: 'Haima', buffType: 'shield' },
|
|
||||||
],
|
|
||||||
'BRD': [{ name: 'Troubadour', buffType: 'buff' }],
|
|
||||||
'MCH': [{ name: 'Tactician', buffType: 'buff' }],
|
|
||||||
'DNC': [
|
|
||||||
{ name: 'Shield Samba', buffType: 'buff' },
|
|
||||||
{ name: 'Improvised Finish', buffType: 'shield' },
|
|
||||||
],
|
|
||||||
'MNK': [{ name: 'Feint', buffType: 'debuff' }],
|
|
||||||
'DRG': [{ name: 'Feint', buffType: 'debuff' }],
|
|
||||||
'NIN': [{ name: 'Feint', buffType: 'debuff' }],
|
|
||||||
'SAM': [{ name: 'Feint', buffType: 'debuff' }],
|
|
||||||
'RPR': [{ name: 'Feint', buffType: 'debuff' }],
|
|
||||||
'VPR': [{ name: 'Feint', buffType: 'debuff' }],
|
|
||||||
'BLM': [{ name: 'Addle', buffType: 'debuff' }],
|
|
||||||
'SMN': [
|
|
||||||
{ name: 'Addle', buffType: 'debuff' },
|
|
||||||
{ name: 'Radiant Aegis', buffType: 'shield' },
|
|
||||||
],
|
|
||||||
'RDM': [
|
|
||||||
{ name: 'Addle', buffType: 'debuff' },
|
|
||||||
{ name: 'Magick Barrier', buffType: 'buff' },
|
|
||||||
],
|
|
||||||
'PCT': [
|
|
||||||
{ name: 'Addle', buffType: 'debuff' },
|
|
||||||
{ name: 'Tempera Coat', buffType: 'shield' },
|
|
||||||
{ name: 'Tempera Grassa', buffType: 'shield' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const MITIG_ICONS = {
|
|
||||||
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
|
|
||||||
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
|
|
||||||
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
|
|
||||||
'Temperance': 'assets/icons/mitigation/temperance.png',
|
|
||||||
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
|
|
||||||
'Expedient': 'assets/icons/mitigation/expedient.png',
|
|
||||||
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
|
|
||||||
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
|
|
||||||
'Holos': 'assets/icons/mitigation/holos.png',
|
|
||||||
'Kerachole': 'assets/icons/mitigation/kerachole.png',
|
|
||||||
'Troubadour': 'assets/icons/mitigation/troubadour.png',
|
|
||||||
'Tactician': 'assets/icons/mitigation/tactician.png',
|
|
||||||
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
|
|
||||||
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
|
|
||||||
'Reprisal': 'assets/icons/mitigation/reprisal.png',
|
|
||||||
'Feint': 'assets/icons/mitigation/feint.png',
|
|
||||||
'Addle': 'assets/icons/mitigation/addle.png',
|
|
||||||
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
|
|
||||||
'Guardian': 'assets/icons/mitigation/guardian.png',
|
|
||||||
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
|
|
||||||
'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png',
|
|
||||||
'Divine Benison': 'assets/icons/mitigation/divine-benison.png',
|
|
||||||
'Divine Caress': 'assets/icons/mitigation/divine-caress.png',
|
|
||||||
'Intersection': 'assets/icons/mitigation/intersection.png',
|
|
||||||
'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png',
|
|
||||||
'the Spire': 'assets/icons/mitigation/the-spire.png',
|
|
||||||
'Panhaima': 'assets/icons/mitigation/panhaima.png',
|
|
||||||
'Holosakos': 'assets/icons/mitigation/holos.png',
|
|
||||||
'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png',
|
|
||||||
'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png',
|
|
||||||
'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
|
||||||
'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
|
||||||
'Haima': 'assets/icons/mitigation/haima.png',
|
|
||||||
'Galvanize': 'assets/icons/mitigation/galvanize.png',
|
|
||||||
'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png',
|
|
||||||
'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png',
|
|
||||||
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
|
|
||||||
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
|
|
||||||
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
|
|
||||||
};
|
|
||||||
|
|
||||||
// DR values (0–1) for buff/debuff mitigations — shields excluded (no reliable sim).
|
|
||||||
const ABILITY_DR = {
|
|
||||||
'Passage of Arms': 0.15,
|
|
||||||
'Troubadour': 0.15,
|
|
||||||
'Tactician': 0.15,
|
|
||||||
'Shield Samba': 0.15,
|
|
||||||
'Dark Missionary': 0.10,
|
|
||||||
'Heart of Light': 0.10,
|
|
||||||
'Temperance': 0.10,
|
|
||||||
'Sacred Soil': 0.10,
|
|
||||||
'Expedient': 0.10,
|
|
||||||
'Collective Unconscious': 0.10,
|
|
||||||
'Holos': 0.10,
|
|
||||||
'Kerachole': 0.10,
|
|
||||||
'Magick Barrier': 0.10,
|
|
||||||
'Fey Illumination': 0.05,
|
|
||||||
'Reprisal': 0.10,
|
|
||||||
'Feint': 0.05, // 10% phys / 5% magic — use magic (conservative)
|
|
||||||
'Addle': 0.10,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const {
|
||||||
|
ABILITY_DR,
|
||||||
|
ABILITY_JOB_MAP,
|
||||||
|
ALL_JOBS,
|
||||||
|
CASTER_JOBS,
|
||||||
|
JOB_ABILITIES,
|
||||||
|
JOB_FROM_TYPE,
|
||||||
|
JOB_ROLE,
|
||||||
|
MELEE_JOBS,
|
||||||
|
MITIG_ICONS,
|
||||||
|
TANK_JOBS,
|
||||||
|
} = window.FF14_DATA;
|
||||||
// Groups of abilities that are functionally equivalent across different jobs.
|
// Groups of abilities that are functionally equivalent across different jobs.
|
||||||
// Used to suggest replacements when a job is missing from the composition.
|
// Used to suggest replacements when a job is missing from the composition.
|
||||||
const ABILITY_EQUIVALENTS = [
|
const ABILITY_EQUIVALENTS = [
|
||||||
@ -1883,19 +2083,36 @@ 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) {
|
||||||
|
removeActivationGroup(plan, {
|
||||||
|
mechanicId: mechanic.id,
|
||||||
|
ability: abilityName,
|
||||||
|
job: assignment.job ?? '',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
mechanic.assignments[idx].job = job;
|
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 {
|
||||||
mechanic.assignments.push({
|
const assignment = {
|
||||||
ability: abilityName,
|
ability: abilityName,
|
||||||
abilityName: plan.mitigationNames?.[abilityName],
|
abilityName: plan.mitigationNames?.[abilityName],
|
||||||
actionId: actionMetaByName[abilityName]?.id ?? null,
|
actionId: actionMetaByName[abilityName]?.id ?? null,
|
||||||
job,
|
job,
|
||||||
buffType,
|
buffType,
|
||||||
});
|
};
|
||||||
|
if (assignmentOverlapsJob(plan, job, abilityName, mechanic.timestamp, null, assignment)) return;
|
||||||
|
mechanic.assignments.push(assignment);
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePlan(abilityModalPlanId, { mechanics: plan.mechanics });
|
updatePlan(abilityModalPlanId, { mechanics: plan.mechanics });
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -122,6 +122,7 @@
|
|||||||
|
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
<script src="js/tabs.js"></script>
|
<script src="js/tabs.js"></script>
|
||||||
|
<script src="js/ffxiv-data.js"></script>
|
||||||
<script src="js/analysis.js"></script>
|
<script src="js/analysis.js"></script>
|
||||||
<script src="js/planner.js"></script>
|
<script src="js/planner.js"></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user