forked from xziino/ff14-mitigator
Two-tab app: report viewer + analysis tab with AoE timeline, per-player mitigation icons (local XIVAPI PNGs), and fight-wide buff/debuff window tracking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
343 lines
12 KiB
PHP
343 lines
12 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 + boss debuffs to track ─────────────────────────
|
|
// Barriers (dr = 0) are shown without a percentage.
|
|
const MITIGATION_ABILITIES = [
|
|
'Passage of Arms' => ['dr' => 15, 'buffType' => 'buff'],
|
|
'Divine Veil' => ['dr' => 0, 'buffType' => 'buff'],
|
|
'Shake It Off' => ['dr' => 0, 'buffType' => 'buff'],
|
|
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff'],
|
|
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff'],
|
|
'Temperance' => ['dr' => 10, 'buffType' => 'buff'],
|
|
'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff'],
|
|
'Expedient' => ['dr' => 10, 'buffType' => 'buff'],
|
|
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff'],
|
|
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff'],
|
|
'Holos' => ['dr' => 10, 'buffType' => 'buff'],
|
|
'Kerachole' => ['dr' => 10, 'buffType' => 'buff'],
|
|
'Panhaima' => ['dr' => 0, 'buffType' => 'buff'],
|
|
'Troubadour' => ['dr' => 15, 'buffType' => 'buff'],
|
|
'Tactician' => ['dr' => 15, 'buffType' => 'buff'],
|
|
'Shield Samba' => ['dr' => 15, 'buffType' => 'buff'],
|
|
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff'],
|
|
'Feint' => ['dr' => 10, 'buffType' => 'debuff'],
|
|
'Addle' => ['dr' => 10, 'buffType' => 'debuff'],
|
|
];
|
|
|
|
// ── 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 (only for abilities present in this report)
|
|
$mitigIdMap = [];
|
|
foreach ($abilityNames as $gameId => $name) {
|
|
if (isset(MITIGATION_ABILITIES[$name])) {
|
|
$mitigIdMap[$gameId] = array_merge(['name' => $name], MITIGATION_ABILITIES[$name]);
|
|
}
|
|
}
|
|
|
|
// 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. Buff / debuff events for mitigation tracking ────────────────────────
|
|
$mitigWindows = []; // [{name, dr, buffType, casterName, start, end}]
|
|
|
|
if (!empty($mitigIdMap)) {
|
|
$idsStr = implode(', ', array_keys($mitigIdMap));
|
|
$filterRaw = 'type in ("applybuff","removebuff","applydebuff","removedebuff","refreshbuff") and ability.id in (' . $idsStr . ')';
|
|
$filterEsc = str_replace('"', '\\"', $filterRaw);
|
|
|
|
$buffEvents = [];
|
|
$nextPage = $startTime;
|
|
|
|
for ($page = 0; $page < 5; $page++) {
|
|
$bResult = fflogs_gql(<<<GQL
|
|
{
|
|
reportData {
|
|
report(code: "$reportCode") {
|
|
events(
|
|
fightIDs: [$fightId],
|
|
startTime: $nextPage,
|
|
endTime: $endTime,
|
|
filterExpression: "$filterEsc"
|
|
) {
|
|
data
|
|
nextPageTimestamp
|
|
}
|
|
}
|
|
}
|
|
}
|
|
GQL);
|
|
|
|
if (isset($bResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
|
|
if (isset($bResult['_curl_error'])) break;
|
|
|
|
$bEv = $bResult['data']['reportData']['report']['events'] ?? [];
|
|
$buffEvents = array_merge($buffEvents, $bEv['data'] ?? []);
|
|
$nextPage = $bEv['nextPageTimestamp'] ?? null;
|
|
if ($nextPage === null || $nextPage >= $endTime) break;
|
|
}
|
|
|
|
// Build time windows per (abilityId, sourceId).
|
|
// Party-wide buffs fire one applybuff per party member — we only open the
|
|
// window on the FIRST applybuff for a given (ability, caster) pair.
|
|
$openWindows = []; // "$abId_$srcId" => [start, abilityId, sourceId]
|
|
|
|
foreach ($buffEvents as $ev) {
|
|
$type = $ev['type'] ?? '';
|
|
$abId = (int)($ev['abilityGameID'] ?? 0);
|
|
$srcId = (int)($ev['sourceID'] ?? 0);
|
|
$ts = (float)($ev['timestamp'] ?? 0);
|
|
|
|
if (!isset($mitigIdMap[$abId])) continue;
|
|
|
|
$key = $abId . '_' . $srcId;
|
|
|
|
if (in_array($type, ['applybuff', 'applydebuff'])) {
|
|
if (!isset($openWindows[$key])) {
|
|
$openWindows[$key] = ['start' => $ts, 'abilityId' => $abId, 'sourceId' => $srcId];
|
|
}
|
|
} elseif ($type === 'refreshbuff') {
|
|
// Buff was refreshed — keep window open (don't reset start)
|
|
if (!isset($openWindows[$key])) {
|
|
$openWindows[$key] = ['start' => $ts, 'abilityId' => $abId, 'sourceId' => $srcId];
|
|
}
|
|
} elseif (in_array($type, ['removebuff', 'removedebuff'])) {
|
|
if (isset($openWindows[$key])) {
|
|
$info = $mitigIdMap[$abId];
|
|
$mitigWindows[] = [
|
|
'name' => $info['name'],
|
|
'dr' => $info['dr'],
|
|
'buffType' => $info['buffType'],
|
|
'casterName' => $players[$srcId]['name'] ?? '?',
|
|
'abilityId' => $abId,
|
|
'start' => $openWindows[$key]['start'],
|
|
'end' => $ts,
|
|
];
|
|
unset($openWindows[$key]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Buffs still active at fight end
|
|
foreach ($openWindows as $win) {
|
|
$info = $mitigIdMap[$win['abilityId']];
|
|
$mitigWindows[] = [
|
|
'name' => $info['name'],
|
|
'dr' => $info['dr'],
|
|
'buffType' => $info['buffType'],
|
|
'casterName' => $players[$win['sourceId']]['name'] ?? '?',
|
|
'abilityId' => $win['abilityId'],
|
|
'start' => $win['start'],
|
|
'end' => $endTime,
|
|
];
|
|
}
|
|
}
|
|
|
|
// ── 3. 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,
|
|
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;
|
|
}
|
|
|
|
// ── 4. AoE detection ───────────────────────────────────────────────────────
|
|
$buckets = [];
|
|
foreach ($allEvents as $ev) {
|
|
if (!empty($ev['tick'])) continue;
|
|
if (($ev['type'] ?? '') !== 'calculateddamage') continue;
|
|
|
|
$abId = (int)($ev['abilityGameID'] ?? 0);
|
|
$tgtId = (int)($ev['targetID'] ?? 0);
|
|
if (!$abId || !$tgtId || $abId <= 7) continue;
|
|
|
|
$ts = (float)($ev['timestamp'] ?? 0);
|
|
$bucket = (int)floor($ts / 300) * 300;
|
|
$key = $bucket . '_' . $abId;
|
|
|
|
if (!isset($buckets[$key])) {
|
|
$buckets[$key] = [
|
|
'timestamp' => (int)$ts,
|
|
'abilityId' => $abId,
|
|
'abilityName' => $abilityNames[$abId] ?? $ev['ability']['name'] ?? ('Ability #' . $abId),
|
|
'targets' => [],
|
|
];
|
|
}
|
|
|
|
if (!isset($buckets[$key]['targets'][$tgtId])) {
|
|
$buckets[$key]['targets'][$tgtId] = ['id' => $tgtId, 'amount' => 0];
|
|
}
|
|
$buckets[$key]['targets'][$tgtId]['amount'] += (int)($ev['amount'] ?? 0);
|
|
}
|
|
|
|
$aoeEvents = [];
|
|
foreach ($buckets 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'],
|
|
];
|
|
}
|
|
usort($targets, fn($a, $b) => $b['amount'] <=> $a['amount']);
|
|
|
|
// Collect active mitigations at this timestamp
|
|
$evTs = $group['timestamp'];
|
|
$activeMitig = [];
|
|
$seen = [];
|
|
foreach ($mitigWindows as $win) {
|
|
if ($win['start'] <= $evTs && $win['end'] > $evTs) {
|
|
$dedupeKey = $win['abilityId'] . '_' . $win['casterName'];
|
|
if (!isset($seen[$dedupeKey])) {
|
|
$seen[$dedupeKey] = true;
|
|
$activeMitig[] = [
|
|
'name' => $win['name'],
|
|
'dr' => $win['dr'],
|
|
'buffType' => $win['buffType'],
|
|
'casterName' => $win['casterName'],
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
$aoeEvents[] = [
|
|
'timestamp' => $evTs,
|
|
'abilityId' => $group['abilityId'],
|
|
'abilityName' => $group['abilityName'],
|
|
'targets' => $targets,
|
|
'totalDamage' => array_sum(array_column($targets, 'amount')),
|
|
'mitigations' => $activeMitig,
|
|
];
|
|
}
|
|
usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
|
|
|
echo json_encode([
|
|
'players' => array_values($players),
|
|
'aoe_events' => $aoeEvents,
|
|
'fight_start' => (int)$startTime,
|
|
]);
|