ff14-mitigator/api/analysis.php
xziino 182f24ee93 Fix shield detection: timeline merge + static statusId map for all abilities
- Replace "only if no shield detected" fallback with always-merge approach:
  when absorbed > 0, check applybuff/removebuff timeline and add any shields
  not already resolved from the buffs field (name deduplication). Catches
  shields consumed mid-cast (absent from damage event buffs) alongside
  shields still active after the hit.
- Add static statusId fields for all tracked abilities (FFLogs ID = XIVAPI
  row_id + 1,000,000); mitigIdMap is now seeded from these as fallback.
- Update CLAUDE.md: document three buffType categories, statusId system,
  shield timeline mechanics, and FFLogs ID encoding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 13:33:57 +02:00

444 lines
19 KiB
PHP

<?php
ini_set('display_errors', '0');
require_once __DIR__ . '/../config.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;
}
if (empty($_SESSION['access_token'])) {
echo json_encode(['reauth' => true]);
exit;
}
if (($_SESSION['token_expires'] ?? 0) <= time()) {
echo json_encode(['reauth' => true]);
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);
if (!$reportCode || !$fightId || !$endTime) {
http_response_code(400);
echo json_encode(['error' => 'Missing required parameters']);
exit;
}
$token = $_SESSION['access_token'];
function fflogs_gql(string $query): array {
global $token;
$ch = curl_init(GRAPHQL_URI);
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,
],
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],
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001894],
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001839],
'Temperance' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001873],
'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001944],
'Expedient' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002711], // FFLogs: "Desperate Measures"
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff', 'statusId' => 1000317],
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1000849],
'Holos' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1003003],
'Kerachole' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002618],
'Troubadour' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001934],
'Tactician' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001951],
'Shield Samba' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001826],
'Magick Barrier' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002707],
// ── Shields ─────────────────────────────────────────────────────────────
// PLD
'Divine Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001362],
'Guardian' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003830], // FFLogs: "Guardian's Will"
// WAR
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457],
'Bloodwhetting' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002678],
// WHM
'Divine Benison' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001218],
'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903],
// AST
'Intersection' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001889],
'Neutral Sect' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001921],
'the Spire' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003892], // FFLogs: "The Spire"
// SGE
'Panhaima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002613],
'Holosakos' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003365],
'Eukrasian Prognosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002609],
'Eukrasian Prognosis II' => ['dr' => 0, 'buffType' => 'shield'], // TODO
'Eukrasian Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002607],
'Differential Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002608],
'Haima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002612],
// SCH
'Galvanize' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1000297],
'Seraphic Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001917],
'Catalyze' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001918],
// SMN
'Radiant Aegis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002702],
// PCT
'Tempera Coat' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003686],
'Tempera Grassa' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003687],
// DNC
'Improvised Finish' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002697],
// ── Boss debuffs ────────────────────────────────────────────────────────
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001193],
'Feint' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001195],
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203],
];
function resolveMitigations(string $buffStr, array $mitigIdMap): 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;
$result[] = [
'name' => $name,
'dr' => $mitigIdMap[$id]['dr'],
'buffType' => $mitigIdMap[$id]['buffType'],
];
}
}
return $result;
}
// 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[] = ['name' => $m['name'], 'dr' => $m['dr'], 'buffType' => $m['buffType']];
}
break;
}
}
}
return $result;
}
// ── 1. Player details + masterData (ability names) ─────────────────────────
$pdResult = fflogs_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
playerDetails(fightIDs: [$fightId])
masterData {
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 → 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(['name' => $name], MITIGATION_ABILITIES[$name]);
}
}
foreach (MITIGATION_ABILITIES as $name => $meta) {
if (isset($meta['statusId']) && !isset($mitigIdMap[$meta['statusId']])) {
$mitigIdMap[$meta['statusId']] = array_merge(['name' => $name], $meta);
}
}
// statusId set for shield abilities — used to filter the buff timeline query
$shieldStatusIds = [];
foreach (MITIGATION_ABILITIES as $meta) {
if ($meta['buffType'] === 'shield' && isset($meta['statusId'])) {
$shieldStatusIds[$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], ...]
if (!empty($shieldStatusIds)) {
$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($shieldStatusIds[$abId])) continue;
$tgtId = (int)($ev['targetID'] ?? 0);
$ts = (float)($ev['timestamp'] ?? 0);
$type = $ev['type'] ?? '';
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;
}
}
// ── 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;
$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;
$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;
}
$aoeEvents = [];
foreach ($clusters as $group) {
if (count($group['targets']) < 3) continue;
$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) {
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap);
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']);
});
$aoeEvents[] = [
'timestamp' => $group['timestamp'],
'abilityId' => $group['abilityId'],
'abilityName' => $group['abilityName'],
'targets' => $targets,
'totalDamage' => array_sum(array_column($targets, 'amount')),
];
}
usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
echo json_encode([
'players' => array_values($players),
'aoe_events' => $aoeEvents,
'fight_start' => (int)$startTime,
]);