forked from xziino/ff14-mitigator
add a recheck if delete of boss action is really wanted toogle boss action to be a tankbuster or not
548 lines
24 KiB
PHP
548 lines
24 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],
|
|
'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],
|
|
'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],
|
|
// ── Shields ─────────────────────────────────────────────────────────────
|
|
// PLD
|
|
'Divine Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001362, 'extraAbilityGameID' => 3540],
|
|
'Guardian' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003830, 'extraAbilityGameID' => 36920], // FFLogs: "Guardian's Will"
|
|
// WAR
|
|
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457, 'extraAbilityGameID' => 7388],
|
|
'Bloodwhetting' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002678, 'extraAbilityGameID' => 25751],
|
|
// 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 {
|
|
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[] = [
|
|
'key' => $mitigIdMap[$id]['key'] ?? $name,
|
|
'name' => $name,
|
|
'dr' => $mitigIdMap[$id]['dr'],
|
|
'buffType' => $mitigIdMap[$id]['buffType'],
|
|
'extraAbilityGameID' => $mitigIdMap[$id]['extraAbilityGameID'] ?? null,
|
|
];
|
|
}
|
|
}
|
|
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[] = [
|
|
'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], ...]
|
|
$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;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
$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']);
|
|
});
|
|
|
|
$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;
|