From 182f24ee93f1f96cc191b0ba0414dbb39e4badd2 Mon Sep 17 00:00:00 2001 From: xziino Date: Thu, 21 May 2026 13:33:57 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 44 ++++++++----------- api/analysis.php | 110 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 122 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eee8e28..93c5837 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,35 +87,23 @@ CSS-Variablen in `css/base.css`: ### Spielernamen statt IDs - `masterData.abilities` (gameID → Name) wird zusammen mit `playerDetails` in einem einzigen Query abgerufen -- `$mitigIdMap` (gameID → Mitigation-Meta) wird nur für Abilities gebaut, die tatsächlich im Report vorkommen +- `$mitigIdMap` (gameID → Mitigation-Meta) wird aus zwei Quellen befüllt: + 1. `masterData.abilities` — für Abilities die als Events im Report auftauchen (Name-basiertes Matching) + 2. Statische `statusId`-Felder in `MITIGATION_ABILITIES` — Fallback für Abilities die nicht in masterData stehen (z.B. Pre-Pull-Buffs) ### Mitigation-Tracking -Getrackte party-wide Buffs + Boss-Debuffs (definiert in `MITIGATION_ABILITIES` in `api/analysis.php`): +Getrackte party-wide Buffs + Schilde + Boss-Debuffs (definiert in `MITIGATION_ABILITIES` in `api/analysis.php`): -| Ability | DR | Typ | -|---|---|---| -| Passage of Arms | 15% | buff | -| Divine Veil | Barrier | buff | -| Shake It Off | Barrier | buff | -| Dark Missionary | 10% | buff | -| Heart of Light | 10% | buff | -| Temperance | 10% | buff | -| Sacred Soil | 10% | buff | -| Expedient | 10% | buff | -| Fey Illumination | 5% | buff | -| Collective Unconscious | 10% | buff | -| Holos | 10% | buff | -| Kerachole | 10% | buff | -| Panhaima | Barrier | buff | -| Troubadour | 15% | buff | -| Tactician | 15% | buff | -| Shield Samba | 15% | buff | -| Magick Barrier | 10% | buff | -| Reprisal | 10% | debuff | -| Feint | 10% | debuff | -| Addle | 10% | debuff | +Drei `buffType`-Kategorien: +- **`buff`**: Damage Reduction — Passage of Arms (15%), Troubadour/Tactician/Shield Samba (15%), Dark Missionary/Heart of Light/Temperance/Sacred Soil/Expedient/Collective Unconscious/Holos/Kerachole/Magick Barrier (10%), Fey Illumination (5%) +- **`shield`**: Barrieren — Divine Veil, Guardian, Shake It Off, Bloodwhetting, Divine Benison, Divine Caress, Intersection, Neutral Sect, the Spire, Panhaima, Holosakos, Eukrasian Prognosis, Eukrasian Diagnosis, Differential Diagnosis, Haima, Galvanize, Seraphic Veil, Catalyze, Radiant Aegis, Tempera Coat, Tempera Grassa, Improvised Finish +- **`debuff`**: Boss-Debuffs — Reprisal, Feint, Addle (je 10%) -**Implementierung via `buffs`-Feld:** Mitigations werden nicht über separate `applybuff`/`removebuff`-Events getrackt, sondern direkt aus dem `buffs`-Feld jedes `damage`-Events gelesen. Das Feld enthält einen `.`-separierten String von `abilityGameID`s der aktiven Buffs. `resolveMitigations()` mappt diese IDs auf `MITIGATION_ABILITIES`-Einträge via `$mitigIdMap` (gameID → Meta). Doppelte Namen werden dedupliziert. +**Statische Status-IDs (`statusId`-Feld):** Jeder Eintrag in `MITIGATION_ABILITIES` trägt ein `statusId`-Feld (FFLogs Status-ID = XIVAPI Status row_id + 1.000.000). Diese IDs werden als Fallback in `$mitigIdMap` eingetragen wenn masterData den Eintrag nicht enthält. Löst das Pre-Pull-Problem und Name-Mismatches (z.B. FFLogs "Guardian's Will" vs. Key 'Guardian', "Desperate Measures" vs. 'Expedient'). + +**Primär: `buffs`-Feld im `damage`-Event:** Das Feld enthält einen `.`-separierten String aktiver Status-IDs. `resolveMitigations()` mappt diese via `$mitigIdMap`. Doppelte Namen werden dedupliziert. + +**Fallback: Shield-Timeline via `applybuff`/`removebuff`:** Das `buffs`-Feld des `damage`-Events spiegelt den Zustand *nach* dem Hit — Schilde die durch den Hit konsumiert wurden fehlen darin. Daher wird in Step 2b ein separater Buffs-Query (dataType: Buffs) für alle Shield-StatusIds gefetcht und eine Timeline aufgebaut: `$shieldTimeline[targetId][statusId][] = ['apply' => ts, 'remove' => ts|null]`. Bei `absorbed > 0` wird `shieldsActiveAt()` konsultiert und alle dort gefundenen Schilde die noch nicht in der Mitigation-Liste stehen werden hinzugefügt (Name-Deduplication). Buffer: 200ms (`remove >= damageTs - 200`) um consumed-at-impact von natural-expiry zu unterscheiden. ### Mitigation-Icons - Icons lokal gespeichert in `assets/icons/mitigation/` als PNG @@ -126,6 +114,7 @@ Getrackte party-wide Buffs + Boss-Debuffs (definiert in `MITIGATION_ABILITIES` i **Darstellungsort nach `buffType`:** - **Buffs** (`buffType: 'buff'`): erscheinen per Spieler unter der Spieler-Box (`.aoe-target-buffs`) +- **Schilde** (`buffType: 'shield'`): erscheinen als Tooltip auf dem `+absorbed`-Wert (`.aoe-target-absorbed`) — Liste der aktiven Shield-Namen, consumed Schilde werden über die Timeline-Fallback ergänzt - **Debuffs** (`buffType: 'debuff'`): erscheinen einmal pro Event im Ability-Header neben "X total" bzw. in der REF-Zeile neben "REF X total ±delta" - Fehlende Mitigations aus dem Ref-Pull: Buffs → ausgegraut per Spieler; Debuffs → ausgegraut im jeweiligen Header - `.aoe-ability` nutzt `display: flex; align-items: center; gap: 6px` — identisches Spacing wie `.aoe-ref-label` @@ -188,7 +177,10 @@ Erscheint nach Fight-Auswahl als Card über dem Output-Terminal. Filteroptionen: - GraphQL Endpoint (user-scoped): `https://www.fflogs.com/api/v2/user` - Token Endpoint: `https://www.fflogs.com/oauth/token` - Kein Refresh Token für öffentliche Clients — abgelaufene Sessions starten PKCE neu -- Event-Typen: `damage` (post-Mitigation Snapshot, enthält `buffs`-Feld — den wir nutzen) vs. `calculateddamage` (Application-Zeitpunkt, ignorieren) +- Event-Typen: `damage` (post-Mitigation Snapshot, enthält `buffs`-Feld) vs. `calculateddamage` (Snapshot beim Cast-Start — buffs sind zu früh, aber `targetResources.absorb` zeigt den Schildwert zu diesem Zeitpunkt) +- `buffs`-Feld im `damage`-Event: `.`-separierter String aktiver Status-IDs. Achtung: Schilde die durch diesen Hit konsumiert wurden sind bereits entfernt → Shield-Timeline als Fallback nötig +- FFLogs Status-IDs = XIVAPI Status-Sheet `row_id` + 1.000.000 (z.B. Galvanize: row_id 297 → FFLogs 1000297) +- `dataType: Buffs` liefert `applybuff`/`removebuff`-Events (u.a. für die Shield-Timeline) - `includeResources: true` im `events()`-Query liefert `targetResources.hitPoints` / `maxHitPoints` pro Event ## XIVAPI diff --git a/api/analysis.php b/api/analysis.php index 4b6da17..5897808 100644 --- a/api/analysis.php +++ b/api/analysis.php @@ -135,6 +135,26 @@ function resolveMitigations(string $buffStr, array $mitigIdMap): array { 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(<< $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; @@ -228,6 +256,63 @@ for ($page = 0; $page < 10; $page++) { 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(<< 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. @@ -280,15 +365,16 @@ foreach ($byAbility as $abId => $events) { $tgtId = $ev['tgtId']; if (!isset($current['targets'][$tgtId])) { $current['targets'][$tgtId] = [ - 'id' => $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'], + 'overkill' => 0, + 'hp' => $ev['hp'], + 'maxHp' => $ev['maxHp'], + 'buffs' => $ev['buffs'], ]; } $current['targets'][$tgtId]['amount'] += $ev['amount']; @@ -319,7 +405,19 @@ foreach ($clusters as $group) { 'overkill' => $tgt['overkill'], 'hp' => $tgt['hp'], 'maxHp' => $tgt['maxHp'], - 'mitigations' => resolveMitigations($tgt['buffs'], $mitigIdMap), + '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];