ff14-mitigator/api/analysis.php
xziino 8fe057e15b Add pull comparison feature and consistent player sorting
- Reference fight dropdown in Spieler card for same-report pull comparison
- Ref row per AoE event: ref damage + delta, absorbed, mitigation icons
- Missing mitigations (active in ref but not current) shown with red border
- Delta moved below job abbreviation in ref targets to reduce card width
- Players and targets sorted alphabetically within role for consistent ordering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:01:57 +02:00

292 lines
10 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'],
'Magick Barrier' => ['dr' => 10, 'buffType' => 'buff'],
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff'],
'Feint' => ['dr' => 10, 'buffType' => 'debuff'],
'Addle' => ['dr' => 10, 'buffType' => 'debuff'],
];
function resolveMitigations(string $buffStr, array $mitigIdMap): array {
if ($buffStr === '') return [];
$result = [];
foreach (explode('.', $buffStr) as $idStr) {
$id = (int)$idStr;
if (isset($mitigIdMap[$id])) {
$result[] = [
'name' => $mitigIdMap[$id]['name'],
'dr' => $mitigIdMap[$id]['dr'],
'buffType' => $mitigIdMap[$id]['buffType'],
];
}
}
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 (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. 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;
}
// ── 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),
'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,
'amount' => 0,
'absorbed' => 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]['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'],
'overkill' => $tgt['overkill'],
'hp' => $tgt['hp'],
'maxHp' => $tgt['maxHp'],
'mitigations' => resolveMitigations($tgt['buffs'], $mitigIdMap),
];
}
$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,
]);