ff14-mitigator/api/analysis.php
xziino 1a91d1af0e Planer/Analyse: Personal-Mits Knight's Resolve, Nascent Glint, Stem the Flow/Tide, Aquaveil, Exaltation
- api/analysis.php: 6 neue Einträge in MITIGATION_ABILITIES
  Knight's Resolve (PLD, 1002675, 10% DR), Nascent Glint (WAR, 1001858, 10% DR),
  Stem the Flow (WAR, 1002679, 10% DR), Stem the Tide (WAR, 1002680, Shield),
  Aquaveil (WHM, 1002708, 15% DR), Exaltation (AST, 1002717, 10% DR)
- js/ffxiv-data.js: JOB_ABILITIES, ABILITY_JOB_MAP, MITIG_ICONS, ABILITY_DR aktualisiert
- js/planner.js: alle 6 in TIMELINE_PERSONAL_ABILITIES eingetragen
- assets/jsons/Action.json: Recast-Daten für Nascent Flash (25s), Aquaveil (60s),
  Exaltation (60s) ergänzt (Gantt-Balken)
- assets/icons/mitigation/: 6 neue Icons heruntergeladen (XIVAPI Status-Icons)
- api/cache.php: Version v6 -> v8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:22:12 +02:00

664 lines
32 KiB
PHP

<?php
ini_set('display_errors', '0');
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/cache.php';
session_start_safe();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? '');
$fightId = (int)($_POST['fight_id'] ?? 0);
$startTime = (float)($_POST['start_time'] ?? 0);
$endTime = (float)($_POST['end_time'] ?? 0);
$language = strtolower(trim($_POST['language'] ?? 'en'));
$language = in_array($language, ['en', 'de', 'fr', 'jp'], true) ? $language : 'en';
$translate = 'true';
if (!$reportCode || !$fightId || !$endTime) {
http_response_code(400);
echo json_encode(['error' => 'Missing required parameters']);
exit;
}
$cacheParts = [$fightId, (int)$startTime, (int)$endTime];
$cached = read_cached_log('analysis', $reportCode, $language, $cacheParts);
if ($cached !== null) {
echo $cached;
exit;
}
if (empty($_SESSION['access_token'])) {
echo json_encode(['reauth' => true]);
exit;
}
if (($_SESSION['token_expires'] ?? 0) <= time()) {
echo json_encode(['reauth' => true]);
exit;
}
$token = $_SESSION['access_token'];
function localized_graphql_uri(string $language): string {
$host = [
'de' => 'de.fflogs.com',
'fr' => 'fr.fflogs.com',
'jp' => 'ja.fflogs.com',
][$language] ?? 'www.fflogs.com';
return preg_replace('#https://[^/]+#', 'https://' . $host, GRAPHQL_URI);
}
function fflogs_gql(string $query): array {
global $token, $language;
$acceptLanguage = $language === 'jp' ? 'ja' : $language;
$ch = curl_init(localized_graphql_uri($language));
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['query' => $query]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $token,
'Accept-Language: ' . $acceptLanguage,
],
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
]);
$body = curl_exec($ch);
$err = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($err) return ['_curl_error' => $err];
if ($code === 401) return ['_reauth' => true];
return json_decode($body, true) ?? ['_parse_error' => true];
}
// ── Party-wide mitigation, shields + boss debuffs to track ─────────────────
// buffType 'buff' = damage reduction, shown as icons per player
// buffType 'shield' = barrier, shown only as tooltip on absorbed value
// buffType 'debuff' = boss debuff, shown in event header
const MITIGATION_ABILITIES = [
// ── Damage reduction buffs ──────────────────────────────────────────────
'Passage of Arms' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001175, 'extraAbilityGameID' => 7385],
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001894, 'extraAbilityGameID' => 16471],
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001839, 'extraAbilityGameID' => 16160],
'Temperance' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001873, 'extraAbilityGameID' => 16536],
'Aquaveil' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002708, 'extraAbilityGameID' => 25861], // Personal, WHM auf Ziel
'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001944, 'extraAbilityGameID' => 188],
'Expedient' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002711, 'extraAbilityGameID' => 25868], // FFLogs: "Desperate Measures"
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff', 'statusId' => 1000317, 'extraAbilityGameID' => 16538],
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1000849, 'extraAbilityGameID' => 3613],
'Exaltation' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002717, 'extraAbilityGameID' => 25873], // Personal, AST auf Ziel
'Holos' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1003003, 'extraAbilityGameID' => 24310],
'Kerachole' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002618, 'extraAbilityGameID' => 24298],
'Troubadour' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001934, 'extraAbilityGameID' => 7405],
'Tactician' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001951, 'extraAbilityGameID' => 16889],
'Shield Samba' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001826, 'extraAbilityGameID' => 16012],
'Magick Barrier' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002707, 'extraAbilityGameID' => 25857],
// ── Personal / targeted mitigation ─────────────────────────────────────
'Rampart' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 7531],
// PLD
'Hallowed Ground' => ['dr' => 100, 'buffType' => 'buff', 'extraAbilityGameID' => 30],
'Sentinel' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 17],
'Bulwark' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 22],
'Holy Sheltron' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 25746],
'Intervention' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 7382],
'Knight\'s Resolve' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002675, 'extraAbilityGameID' => 7382], // Proc von Intervention
// WAR
'Holmgang' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 43],
'Vengeance' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 44],
'Damnation' => ['dr' => 40, 'buffType' => 'buff', 'extraAbilityGameID' => 36923],
'Thrill of Battle' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 40],
'Raw Intuition' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 3551],
'Nascent Glint' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001858, 'extraAbilityGameID' => 16464], // Proc von Nascent Flash auf Ziel
'Stem the Flow' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002679, 'extraAbilityGameID' => 25751], // Proc von Bloodwhetting / Nascent Flash
// DRK
'Living Dead' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 3638],
'Shadow Wall' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 3636],
'Shadowed Vigil' => ['dr' => 40, 'buffType' => 'buff', 'extraAbilityGameID' => 36927],
'Dark Mind' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 3634],
'The Blackest Night' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 7393],
'Oblation' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 25754],
// GNB
'Superbolide' => ['dr' => 100, 'buffType' => 'buff', 'extraAbilityGameID' => 16152],
'Nebula' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 16148],
'Great Nebula' => ['dr' => 40, 'buffType' => 'buff', 'statusId' => 1003838, 'extraAbilityGameID' => 36935],
'Camouflage' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 16140],
'Heart of Stone' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 16161],
'Heart of Corundum' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002683, 'extraAbilityGameID' => 25758],
'Clarity of Corundum' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002684, 'extraAbilityGameID' => 25758], // Proc von Heart of Corundum, kann beliebiges Partymitglied treffen
// DPS
'Riddle of Earth' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 7394],
'Shade Shift' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 2241],
'Third Eye' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 7498],
'Arcane Crest' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 24404],
'Manaward' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 157],
// ── Shields ─────────────────────────────────────────────────────────────
// PLD
'Divine Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001362, 'extraAbilityGameID' => 3540],
'Guardian' => ['dr' => 40, 'buffType' => 'shield', 'statusId' => 1003830, 'extraAbilityGameID' => 36920], // FFLogs: "Guardian's Will"
// WAR
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457, 'extraAbilityGameID' => 7388],
'Bloodwhetting' => ['dr' => 10, 'buffType' => 'shield', 'statusId' => 1002678, 'extraAbilityGameID' => 25751],
'Stem the Tide' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002680, 'extraAbilityGameID' => 25751], // Proc von Bloodwhetting / Nascent Flash
// WHM
'Divine Benison' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001218, 'extraAbilityGameID' => 7432],
'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903, 'extraAbilityGameID' => 37011],
// AST
'Intersection' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001889, 'extraAbilityGameID' => 16556],
'Neutral Sect' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001921, 'extraAbilityGameID' => 16559],
'the Spire' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003892, 'extraAbilityGameID' => 37025], // FFLogs: "The Spire"
// SGE
'Panhaima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002613, 'extraAbilityGameID' => 24311],
'Holosakos' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003365, 'extraAbilityGameID' => 24310],
'Eukrasian Prognosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002609, 'extraAbilityGameID' => 24292],
'Eukrasian Prognosis II' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 37034],
'Eukrasian Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002607, 'extraAbilityGameID' => 24291],
'Differential Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002608, 'extraAbilityGameID' => 24291],
'Haima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002612, 'extraAbilityGameID' => 24305],
// SCH
'Galvanize' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1000297, 'extraAbilityGameID' => 185],
'Seraphic Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001917, 'extraAbilityGameID' => 16548],
'Catalyze' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001918, 'extraAbilityGameID' => 185],
// SMN
'Radiant Aegis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002702, 'extraAbilityGameID' => 25799],
// PCT
'Tempera Coat' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003686, 'extraAbilityGameID' => 34685],
'Tempera Grassa' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003687, 'extraAbilityGameID' => 34686],
// DNC
'Improvised Finish' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002697, 'extraAbilityGameID' => 25789],
// ── Boss debuffs ────────────────────────────────────────────────────────
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001193, 'extraAbilityGameID' => 7535],
'Feint' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001195, 'extraAbilityGameID' => 7549],
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203, 'extraAbilityGameID' => 7560],
];
function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourceTimeline = [], array $players = [], float $ts = 0): array {
if ($buffStr === '') return [];
$result = [];
$seen = [];
foreach (explode('.', $buffStr) as $idStr) {
$id = (int)$idStr;
if (isset($mitigIdMap[$id])) {
$name = $mitigIdMap[$id]['name'];
if (isset($seen[$name])) continue;
$seen[$name] = true;
$entry = [
'key' => $mitigIdMap[$id]['key'] ?? $name,
'name' => $name,
'dr' => $mitigIdMap[$id]['dr'],
'buffType' => $mitigIdMap[$id]['buffType'],
'extraAbilityGameID' => $mitigIdMap[$id]['extraAbilityGameID'] ?? null,
];
$source = findBuffSourcePlayer($buffSourceTimeline, $id, $ts, $players);
if ($source) $entry['sourcePlayerType'] = $source['type'];
$result[] = $entry;
}
}
return $result;
}
// Findet den Spieler der einen Buff zum Zeitpunkt $ts gecastet hat (anhand der applybuff-Timeline).
function findBuffSourcePlayer(array $sourceTimeline, int $statusId, float $ts, array $players): ?array {
$best = null;
foreach ($sourceTimeline[$statusId] ?? [] as $entry) {
if ($entry['apply'] > $ts + 200) continue; // noch nicht aktiv
if ($entry['remove'] !== null && $entry['remove'] < $ts - 200) continue; // schon abgelaufen
if ($best === null || $entry['apply'] > $best['apply']) $best = $entry;
}
if ($best === null || empty($best['sourceId'])) return null;
return $players[$best['sourceId']] ?? null;
}
// Fallback for shields consumed by a hit: the damage event's buffs field no
// longer contains the shield ID (already removed), but the applybuff/removebuff
// timeline shows it was active just before the hit.
function shieldsActiveAt(array $shieldTimeline, int $targetId, float $ts, array $mitigIdMap): array {
$result = [];
foreach ($shieldTimeline[$targetId] ?? [] as $statusId => $intervals) {
foreach ($intervals as $iv) {
// applied before hit, removed at or after hit (200ms buffer for event ordering)
if ($iv['apply'] <= $ts && ($iv['remove'] === null || $iv['remove'] >= $ts - 200)) {
if (isset($mitigIdMap[$statusId])) {
$m = $mitigIdMap[$statusId];
$result[] = [
'key' => $m['key'] ?? $m['name'],
'name' => $m['name'],
'dr' => $m['dr'],
'buffType' => $m['buffType'],
'extraAbilityGameID' => $m['extraAbilityGameID'] ?? null,
];
}
break;
}
}
}
return $result;
}
// ── 1. Player details + masterData (ability names) ─────────────────────────
$pdResult = fflogs_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
playerDetails(fightIDs: [$fightId])
masterData(translate: $translate) {
abilities {
gameID
name
}
}
}
}
}
GQL);
if (isset($pdResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
if (isset($pdResult['_curl_error'])) { http_response_code(502); echo json_encode(['error' => $pdResult['_curl_error']]); exit; }
// abilityGameID/statusID → display name
$abilityNames = [];
foreach ($pdResult['data']['reportData']['report']['masterData']['abilities'] ?? [] as $ab) {
$abilityNames[(int)$ab['gameID']] = $ab['name'];
}
// gameID → mitigation meta: primary from masterData, statusId fallback for
// abilities whose status ID isn't in masterData (add 'statusId' to the entry
// in MITIGATION_ABILITIES when discovered via the Event Explorer).
$mitigIdMap = [];
foreach ($abilityNames as $gameId => $name) {
if (isset(MITIGATION_ABILITIES[$name])) {
$mitigIdMap[$gameId] = array_merge(['key' => $name, 'name' => $name], MITIGATION_ABILITIES[$name]);
}
}
foreach (MITIGATION_ABILITIES as $name => $meta) {
if (isset($meta['statusId']) && !isset($mitigIdMap[$meta['statusId']])) {
$displayName = $abilityNames[(int)$meta['statusId']] ?? $name;
$mitigIdMap[$meta['statusId']] = array_merge(['key' => $name, 'name' => $displayName], $meta);
}
}
// statusId set for tracked mitigations — used to resolve localized buff names
// from Buffs events and to build the shield fallback timeline.
$trackedStatusIds = [];
foreach (MITIGATION_ABILITIES as $meta) {
if (isset($meta['statusId'])) {
$trackedStatusIds[$meta['statusId']] = true;
}
}
// player actorID → player data
$pdRaw = $pdResult['data']['reportData']['report']['playerDetails'] ?? null;
$pdParsed = is_string($pdRaw) ? json_decode($pdRaw, true) : $pdRaw;
$pdGroups = $pdParsed['data']['playerDetails'] ?? [];
$players = [];
$roleMap = ['tanks' => 'tank', 'healers' => 'healer', 'dps' => 'dps'];
foreach ($roleMap as $group => $role) {
foreach ($pdGroups[$group] ?? [] as $p) {
$players[(int)$p['id']] = [
'id' => (int)$p['id'],
'name' => $p['name'],
'type' => $p['type'] ?? '',
'role' => $role,
];
}
}
// ── 2. Damage-taken events (paginated) ─────────────────────────────────────
$allEvents = [];
$nextPage = $startTime;
for ($page = 0; $page < 10; $page++) {
$evResult = fflogs_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
events(
fightIDs: [$fightId],
dataType: DamageTaken,
includeResources: true,
startTime: $nextPage,
endTime: $endTime
) {
data
nextPageTimestamp
}
}
}
}
GQL);
if (isset($evResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
if (isset($evResult['_curl_error'])) break;
$ev = $evResult['data']['reportData']['report']['events'] ?? [];
$allEvents = array_merge($allEvents, $ev['data'] ?? []);
$nextPage = $ev['nextPageTimestamp'] ?? null;
if ($nextPage === null || $nextPage >= $endTime) break;
}
// ── 2b. Shield buff/debuff timeline ────────────────────────────────────────
// Builds applybuff/removebuff intervals per target so we can detect shields
// that were consumed by a hit (absent from the damage event's buffs snapshot).
$shieldTimeline = []; // targetId → statusId → [[apply, remove|null], ...]
$buffSourceTimeline = []; // statusId → [[apply, remove|null, sourceId], ...] — wer hat den Buff gecastet?
$statusNames = []; // statusId → localized display name from Buffs events
$statusActionIds = []; // statusId → applybuff extraAbilityGameID from FFLogs
if (!empty($trackedStatusIds)) {
$nextPage = $startTime;
for ($page = 0; $page < 10; $page++) {
$bfResult = fflogs_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
events(
fightIDs: [$fightId],
dataType: Buffs,
startTime: $nextPage,
endTime: $endTime
) {
data
nextPageTimestamp
}
}
}
}
GQL);
if (isset($bfResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
if (isset($bfResult['_curl_error'])) break;
$bfEv = $bfResult['data']['reportData']['report']['events'] ?? [];
foreach ($bfEv['data'] ?? [] as $ev) {
$abId = (int)($ev['abilityGameID'] ?? 0);
if (!isset($trackedStatusIds[$abId])) continue;
$evName = $ev['ability']['name'] ?? null;
if (is_string($evName) && $evName !== '') {
$statusNames[$abId] = $evName;
}
$extraAbilityGameID = (int)($ev['extraAbilityGameID'] ?? 0);
if ($extraAbilityGameID > 0) {
$statusActionIds[$abId] = $extraAbilityGameID;
}
$tgtId = (int)($ev['targetID'] ?? 0);
$ts = (float)($ev['timestamp'] ?? 0);
$type = $ev['type'] ?? '';
$meta = $mitigIdMap[$abId] ?? null;
// Source-Tracking für alle getrackten Abilities (unabhängig von buffType)
$srcId = (int)($ev['sourceID'] ?? 0);
if ($srcId > 0 && isset($players[$srcId])) {
if ($type === 'applybuff') {
$buffSourceTimeline[$abId][] = ['apply' => $ts, 'remove' => null, 'sourceId' => $srcId];
} elseif ($type === 'removebuff') {
for ($i = count($buffSourceTimeline[$abId] ?? []) - 1; $i >= 0; $i--) {
if ($buffSourceTimeline[$abId][$i]['remove'] === null) {
$buffSourceTimeline[$abId][$i]['remove'] = $ts;
break;
}
}
}
}
if (($meta['buffType'] ?? null) !== 'shield') continue;
if ($type === 'applybuff') {
$shieldTimeline[$tgtId][$abId][] = ['apply' => $ts, 'remove' => null];
} elseif ($type === 'removebuff') {
if (isset($shieldTimeline[$tgtId][$abId])) {
for ($i = count($shieldTimeline[$tgtId][$abId]) - 1; $i >= 0; $i--) {
if ($shieldTimeline[$tgtId][$abId][$i]['remove'] === null) {
$shieldTimeline[$tgtId][$abId][$i]['remove'] = $ts;
break;
}
}
}
}
}
$nextPage = $bfEv['nextPageTimestamp'] ?? null;
if ($nextPage === null || $nextPage >= $endTime) break;
}
}
// ── 2c. Boss-Debuff-Source via Casts ───────────────────────────────────────
// dataType: Buffs liefert nur Events auf Spieler (Friendly). Reprisal/Feint/Addle
// werden auf den Boss (Hostile) angewendet und tauchen dort nicht auf.
// Lösung: Cast-Events der drei Abilities direkt abfragen — 1 GQL-Request, 3 Aliase.
$dbReprisalActionId = (int)(MITIGATION_ABILITIES['Reprisal']['extraAbilityGameID'] ?? 0);
$dbFeintActionId = (int)(MITIGATION_ABILITIES['Feint']['extraAbilityGameID'] ?? 0);
$dbAddleActionId = (int)(MITIGATION_ABILITIES['Addle']['extraAbilityGameID'] ?? 0);
$dbReprisalStatusId = (int)(MITIGATION_ABILITIES['Reprisal']['statusId'] ?? 0);
$dbFeintStatusId = (int)(MITIGATION_ABILITIES['Feint']['statusId'] ?? 0);
$dbAddleStatusId = (int)(MITIGATION_ABILITIES['Addle']['statusId'] ?? 0);
if ($dbReprisalActionId && $dbFeintActionId && $dbAddleActionId) {
$dbResult = fflogs_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
reprisal: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbReprisalActionId, startTime: $startTime, endTime: $endTime) { data }
feint: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbFeintActionId, startTime: $startTime, endTime: $endTime) { data }
addle: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbAddleActionId, startTime: $startTime, endTime: $endTime) { data }
}
}
}
GQL);
if (isset($dbResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
foreach ([
'reprisal' => ['statusId' => $dbReprisalStatusId, 'durationMs' => 10000],
'feint' => ['statusId' => $dbFeintStatusId, 'durationMs' => 10000],
'addle' => ['statusId' => $dbAddleStatusId, 'durationMs' => 10000],
] as $alias => $info) {
foreach ($dbResult['data']['reportData']['report'][$alias]['data'] ?? [] as $ev) {
if (($ev['type'] ?? '') !== 'cast') continue;
$srcId = (int)($ev['sourceID'] ?? 0);
if ($srcId <= 0 || !isset($players[$srcId]) || !$info['statusId']) continue;
$ts = (float)($ev['timestamp'] ?? 0);
$buffSourceTimeline[$info['statusId']][] = [
'apply' => $ts,
'remove' => $ts + $info['durationMs'],
'sourceId' => $srcId,
];
}
}
}
foreach ($statusNames as $statusId => $displayName) {
if (isset($mitigIdMap[$statusId])) {
$mitigIdMap[$statusId]['name'] = $displayName;
}
}
foreach ($statusActionIds as $statusId => $extraAbilityGameID) {
if (isset($mitigIdMap[$statusId])) {
$mitigIdMap[$statusId]['extraAbilityGameID'] = $extraAbilityGameID;
}
}
$mitigationNames = [];
foreach ($mitigIdMap as $meta) {
$key = $meta['key'] ?? null;
if ($key) {
$mitigationNames[$key] = $meta['name'] ?? $key;
}
}
// ── 3. AoE detection — proximity clustering ────────────────────────────────
// Group events by abilityId, then cluster by time proximity (≤ 1000ms from
// the first event in the cluster) to avoid fixed-window boundary splits.
const CLUSTER_WINDOW_MS = 1000;
const HEAVY_TANKBUSTER_MIN_HP_RATIO = 0.33;
$byAbility = []; // abilityId → [{ts, tgtId, amount, hp, maxHp, buffs, name}]
foreach ($allEvents as $ev) {
if (!empty($ev['tick'])) continue;
if (($ev['type'] ?? '') !== 'damage') continue;
$abId = (int)($ev['abilityGameID'] ?? 0);
$tgtId = (int)($ev['targetID'] ?? 0);
if (!$abId || !$tgtId || $abId <= 7) continue;
$srcId = (int)($ev['sourceID'] ?? 0);
if ($srcId > 0 && isset($players[$srcId])) continue;
$byAbility[$abId][] = [
'ts' => (float)($ev['timestamp'] ?? 0),
'tgtId' => $tgtId,
'amount' => (int)($ev['amount'] ?? 0),
'absorbed' => (int)($ev['absorbed'] ?? 0),
'overkill' => (int)($ev['overkill'] ?? 0),
'unmitigatedAmount' => (int)($ev['unmitigatedAmount'] ?? 0),
'mitigated' => (int)($ev['mitigated'] ?? 0),
'hp' => (int)($ev['targetResources']['hitPoints'] ?? 0),
'maxHp' => (int)($ev['targetResources']['maxHitPoints'] ?? 0),
'buffs' => $ev['buffs'] ?? '',
'name' => $abilityNames[$abId] ?? $ev['ability']['name'] ?? ('Ability #' . $abId),
];
}
$clusters = [];
foreach ($byAbility as $abId => $events) {
// Events from FFLogs are already time-sorted, but sort per ability to be safe
usort($events, fn($a, $b) => $a['ts'] <=> $b['ts']);
$clusterStart = null;
$current = null;
foreach ($events as $ev) {
if ($current === null || ($ev['ts'] - $clusterStart) > CLUSTER_WINDOW_MS) {
if ($current !== null) $clusters[] = $current;
$clusterStart = $ev['ts'];
$current = [
'timestamp' => (int)$ev['ts'],
'abilityId' => $abId,
'abilityName' => $ev['name'],
'targets' => [],
];
}
$tgtId = $ev['tgtId'];
if (!isset($current['targets'][$tgtId])) {
$current['targets'][$tgtId] = [
'id' => $tgtId,
'ts' => $ev['ts'],
'amount' => 0,
'absorbed' => 0,
'unmitigatedAmount' => 0,
'mitigated' => 0,
'overkill' => 0,
'hp' => $ev['hp'],
'maxHp' => $ev['maxHp'],
'buffs' => $ev['buffs'],
];
}
$current['targets'][$tgtId]['amount'] += $ev['amount'];
$current['targets'][$tgtId]['absorbed'] += $ev['absorbed'];
$current['targets'][$tgtId]['unmitigatedAmount'] += $ev['unmitigatedAmount'];
$current['targets'][$tgtId]['mitigated'] += $ev['mitigated'];
$current['targets'][$tgtId]['overkill'] += $ev['overkill'];
}
if ($current !== null) $clusters[] = $current;
}
$bossEvents = [];
$aoeEvents = [];
foreach ($clusters as $group) {
$targetCount = count($group['targets']);
$isHeavyTankbuster = false;
if ($targetCount < 3) {
foreach ($group['targets'] as $tgtId => $tgt) {
$p = $players[$tgtId] ?? null;
if (($p['role'] ?? null) !== 'tank') continue;
$tankMaxHp = (int)($tgt['maxHp'] ?? 0);
$rawDamage = max(
(int)($tgt['unmitigatedAmount'] ?? 0),
(int)($tgt['amount'] ?? 0) + (int)($tgt['absorbed'] ?? 0) + (int)($tgt['mitigated'] ?? 0)
);
if ($tankMaxHp > 0 && $rawDamage >= $tankMaxHp * HEAVY_TANKBUSTER_MIN_HP_RATIO) {
$isHeavyTankbuster = true;
break;
}
}
}
$targets = [];
foreach ($group['targets'] as $tgtId => $tgt) {
$p = $players[$tgtId] ?? null;
$targets[] = [
'id' => $tgtId,
'name' => $p['name'] ?? '?',
'type' => $p['type'] ?? '',
'role' => $p['role'] ?? 'dps',
'amount' => $tgt['amount'],
'absorbed' => $tgt['absorbed'],
'unmitigatedAmount' => $tgt['unmitigatedAmount'],
'mitigated' => $tgt['mitigated'],
'overkill' => $tgt['overkill'],
'hp' => $tgt['hp'],
'maxHp' => $tgt['maxHp'],
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline, $buffSourceTimeline, $players) {
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap, $buffSourceTimeline, $players, $tgt['ts']);
if ($tgt['absorbed'] > 0 && !empty($shieldTimeline)) {
$existing = [];
foreach ($mitigations as $m) {
if ($m['buffType'] === 'shield') $existing[$m['name']] = true;
}
foreach (shieldsActiveAt($shieldTimeline, $tgt['id'], $tgt['ts'], $mitigIdMap) as $s) {
if (!isset($existing[$s['name']])) $mitigations[] = $s;
}
}
return $mitigations;
})(),
];
}
$roleOrder = ['healer' => 0, 'dps' => 1, 'tank' => 2];
usort($targets, function($a, $b) use ($roleOrder) {
$roleCmp = ($roleOrder[$a['role']] ?? 1) <=> ($roleOrder[$b['role']] ?? 1);
return $roleCmp !== 0 ? $roleCmp : strcmp($a['name'], $b['name']);
});
$bossEvent = [
'timestamp' => $group['timestamp'],
'abilityId' => $group['abilityId'],
'abilityName' => $group['abilityName'],
'targets' => $targets,
'totalDamage' => array_sum(array_column($targets, 'amount')),
'isHeavyTankbuster' => $isHeavyTankbuster,
];
$bossEvents[] = $bossEvent;
if ($targetCount < 3 && !$isHeavyTankbuster) continue;
$aoeEvents[] = $bossEvent;
}
usort($bossEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
$response = json_encode([
'players' => array_values($players),
'boss_events' => $bossEvents,
'aoe_events' => $aoeEvents,
'fight_start' => (int)$startTime,
'mitigation_names' => $mitigationNames,
]);
if ($response === false) {
http_response_code(500);
echo json_encode(['error' => 'Could not encode analysis response']);
exit;
}
write_cached_log('analysis', $reportCode, $language, $cacheParts, $response);
echo $response;