Merge remote-tracking branch 'Akurosia/akus_schabernack4'
This commit is contained in:
commit
8b00d1d2a8
@ -42,6 +42,7 @@ query GetReportData($reportCode: String!) {
|
|||||||
endTime
|
endTime
|
||||||
fights {
|
fights {
|
||||||
id
|
id
|
||||||
|
encounterID
|
||||||
name
|
name
|
||||||
startTime
|
startTime
|
||||||
endTime
|
endTime
|
||||||
@ -75,9 +76,7 @@ function localized_graphql_uri(string $language): string {
|
|||||||
return preg_replace('#https://[^/]+#', 'https://' . $host, GRAPHQL_URI);
|
return preg_replace('#https://[^/]+#', 'https://' . $host, GRAPHQL_URI);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fight names must be stable regardless of language — always use the English endpoint.
|
$ch = curl_init(localized_graphql_uri($language));
|
||||||
// Localization only matters for ability/player names in analysis.php.
|
|
||||||
$ch = curl_init(GRAPHQL_URI);
|
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_POSTFIELDS => $payload,
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
@ -85,6 +84,7 @@ curl_setopt_array($ch, [
|
|||||||
CURLOPT_HTTPHEADER => [
|
CURLOPT_HTTPHEADER => [
|
||||||
'Content-Type: application/json',
|
'Content-Type: application/json',
|
||||||
'Authorization: Bearer ' . $_SESSION['access_token'],
|
'Authorization: Bearer ' . $_SESSION['access_token'],
|
||||||
|
'Accept-Language: ' . ($language === 'jp' ? 'ja' : $language),
|
||||||
],
|
],
|
||||||
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
|
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
|
||||||
]);
|
]);
|
||||||
|
|||||||
150
assets/jsons/Action.json
Normal file
150
assets/jsons/Action.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
321
css/planner.css
321
css/planner.css
@ -4,6 +4,12 @@
|
|||||||
grid-template-columns: 280px 1fr;
|
grid-template-columns: 280px 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#plan-detail-panel,
|
||||||
|
#plan-content {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Plan Sidebar ────────────────────────────────────────────────────────────── */
|
/* ── Plan Sidebar ────────────────────────────────────────────────────────────── */
|
||||||
@ -659,3 +665,318 @@
|
|||||||
}
|
}
|
||||||
.folder-picker-option:hover { background: var(--bg2); color: var(--t1); }
|
.folder-picker-option:hover { background: var(--bg2); color: var(--t1); }
|
||||||
.folder-picker-option.active { color: var(--gold); }
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -138,13 +138,24 @@
|
|||||||
return String(name ?? '').trim().toLowerCase();
|
return String(name ?? '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentFightName() {
|
function fightEncounterId(fight) {
|
||||||
const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId);
|
return parseInt(fight?.encounterID ?? fight?.encounterId ?? 0, 10) || 0;
|
||||||
return normalizeFightName(fight?.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameFightName(fight) {
|
function currentFight() {
|
||||||
const name = currentFightName();
|
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;
|
return name !== '' && normalizeFightName(fight?.name) === name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -341,7 +352,7 @@
|
|||||||
let allSameReportFights = [];
|
let allSameReportFights = [];
|
||||||
|
|
||||||
function populateRefFightSelect() {
|
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>';
|
refFightSelect.innerHTML = '<option value="">Kein Vergleich</option>';
|
||||||
visible.forEach(f => {
|
visible.forEach(f => {
|
||||||
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
||||||
@ -410,7 +421,7 @@
|
|||||||
extReportCode = code;
|
extReportCode = code;
|
||||||
updateRefFflogsLink();
|
updateRefFflogsLink();
|
||||||
|
|
||||||
const visibleExt = fights.filter(isSameFightName);
|
const visibleExt = fights.filter(isSameEncounter);
|
||||||
refExtFightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
refExtFightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
||||||
visibleExt.forEach(f => {
|
visibleExt.forEach(f => {
|
||||||
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
||||||
|
|||||||
564
js/planner.js
564
js/planner.js
@ -96,6 +96,10 @@ function copyPlan(id) {
|
|||||||
|
|
||||||
let activePlanId = null;
|
let activePlanId = null;
|
||||||
let collapsedFolders = new Set();
|
let collapsedFolders = new Set();
|
||||||
|
let selectedTimelineAssignment = null;
|
||||||
|
let actionMetaPromise = null;
|
||||||
|
let actionMetaById = {};
|
||||||
|
let actionMetaByName = {};
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -129,6 +133,48 @@ function plannerLanguage() {
|
|||||||
return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en';
|
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) {
|
function sameMechanic(existing, incoming, source) {
|
||||||
const fightStart = source?.fightStart ?? 0;
|
const fightStart = source?.fightStart ?? 0;
|
||||||
const incomingRel = incoming.timestamp - fightStart;
|
const incomingRel = incoming.timestamp - fightStart;
|
||||||
@ -138,6 +184,12 @@ function sameMechanic(existing, incoming, source) {
|
|||||||
return Math.abs(existing.timestamp - incomingRel) < 1500;
|
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 ──────────────────────────────────────────────────────
|
// ── Rendering: Plan List ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function planItemHtml(p) {
|
function planItemHtml(p) {
|
||||||
@ -313,6 +365,8 @@ function renderPlanDetail(plan) {
|
|||||||
noplan.style.display = 'none';
|
noplan.style.display = 'none';
|
||||||
content.style.display = '';
|
content.style.display = '';
|
||||||
|
|
||||||
|
const visibleMechanics = visiblePlanMechanics(plan);
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="card section-gap">
|
<div class="card section-gap">
|
||||||
<div class="plan-detail-header">
|
<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>
|
<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>
|
<button id="plan-name-edit-btn" class="plan-btn" title="Umbenennen">✎</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} · ${plan.mechanics.length} Mechaniken</div>
|
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} · ${visibleMechanics.length} Mechaniken</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -334,6 +388,19 @@ function renderPlanDetail(plan) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<div class="card-title">Mechaniken</div>
|
<div class="card-title">Mechaniken</div>
|
||||||
@ -351,8 +418,10 @@ function renderPlanDetail(plan) {
|
|||||||
document.getElementById('name-import-open-btn')?.addEventListener('click', () => {
|
document.getElementById('name-import-open-btn')?.addEventListener('click', () => {
|
||||||
showNameImportModal(plan.id);
|
showNameImportModal(plan.id);
|
||||||
});
|
});
|
||||||
|
initTimeline(plan.id);
|
||||||
initMechanicClicks(plan.id);
|
initMechanicClicks(plan.id);
|
||||||
renderInfoPanel(plan);
|
renderInfoPanel(plan);
|
||||||
|
ensureActionMetaLoaded().then(() => refreshTimeline(plan.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function avgNonTankMaxHp(plan) {
|
function avgNonTankMaxHp(plan) {
|
||||||
@ -367,7 +436,8 @@ function avgNonTankMaxHp(plan) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderMechanicListHtml(plan) {
|
function renderMechanicListHtml(plan) {
|
||||||
if (plan.mechanics.length === 0) {
|
const mechanics = visiblePlanMechanics(plan);
|
||||||
|
if (mechanics.length === 0) {
|
||||||
return `
|
return `
|
||||||
<div class="empty" style="padding:30px 0">
|
<div class="empty" style="padding:30px 0">
|
||||||
<div class="empty-icon" style="font-size:26px">📋</div>
|
<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 activeJobSet = new Set(plan.jobComposition.filter(j => j));
|
||||||
const avgHp = avgNonTankMaxHp(plan);
|
const avgHp = avgNonTankMaxHp(plan);
|
||||||
|
|
||||||
return plan.mechanics.map(m => {
|
return mechanics.map(m => {
|
||||||
const sorted = sortedAssignments(m.assignments);
|
const sorted = sortedAssignments(m.assignments);
|
||||||
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>'
|
||||||
@ -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 ─────────────────────────────────────────────────────
|
// ── Mechanic list helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function refreshMechanicList(planId) {
|
function refreshMechanicList(planId, includeTimeline = true) {
|
||||||
const plan = getPlan(planId);
|
const plan = getPlan(planId);
|
||||||
if (!plan) return;
|
if (!plan) return;
|
||||||
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);
|
||||||
renderInfoPanel(plan);
|
renderInfoPanel(plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -505,7 +1050,7 @@ function renderInfoPanel(plan) {
|
|||||||
const noJobAbilities = new Set();
|
const noJobAbilities = new Set();
|
||||||
const missingJobPairs = new Set();
|
const missingJobPairs = new Set();
|
||||||
|
|
||||||
for (const m of plan.mechanics) {
|
for (const m of visiblePlanMechanics(plan)) {
|
||||||
for (const a of m.assignments) {
|
for (const a of m.assignments) {
|
||||||
if (!a.job) {
|
if (!a.job) {
|
||||||
noJobAbilities.add(a.ability);
|
noJobAbilities.add(a.ability);
|
||||||
@ -1007,6 +1552,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
|
|||||||
assignments.push({
|
assignments.push({
|
||||||
ability: key,
|
ability: key,
|
||||||
abilityName: mitigationDisplayName(m) || mitigationNames[key],
|
abilityName: mitigationDisplayName(m) || mitigationNames[key],
|
||||||
|
actionId: m.extraAbilityGameID ?? null,
|
||||||
job: guessJob(key, players),
|
job: guessJob(key, players),
|
||||||
buffType: m.buffType ?? '',
|
buffType: m.buffType ?? '',
|
||||||
});
|
});
|
||||||
@ -1266,7 +1812,13 @@ function toggleAbilityAssignment(abilityName, job, buffType) {
|
|||||||
mechanic.assignments[idx].job = job;
|
mechanic.assignments[idx].job = job;
|
||||||
}
|
}
|
||||||
} else {
|
} 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 });
|
updatePlan(abilityModalPlanId, { mechanics: plan.mechanics });
|
||||||
|
|||||||
235
scripts/update_action_json.php
Normal file
235
scripts/update_action_json.php
Normal 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;
|
||||||
Loading…
x
Reference in New Issue
Block a user