diff --git a/CLAUDE.md b/CLAUDE.md
index 7217756..340a3b0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,8 +5,8 @@ PHP/HTML/JS-Tool zum Analysieren von FFXIV-Raidlogs via FFLogs OAuth2 PKCE + Gra
Kein Framework, kein Composer, kein npm — Plain PHP für Shared Hosting.
Zwei Tabs:
-- **Report-Tab**: Report-Code eingeben, Fight auswählen, Raw-JSON-Ausgabe
-- **Analyse-Tab**: Spielerübersicht + AoE-Timeline mit Mitigation-Tracking
+- **Report-Tab**: Report-Code eingeben, Fight auswählen → Fight-JSON-Ausgabe + Event Explorer
+- **Analyse-Tab**: Spielerübersicht + AoE-Timeline mit Mitigation-Tracking, Pull-Vergleich, Phase-Filter
## Architektur & Konventionen
@@ -22,11 +22,12 @@ Zwei Tabs:
### Geteilter JS-State
`window.App` in `app.js` hält den gemeinsamen State für alle Tabs:
```js
-window.App = { reportCode, fightId, fightStart, fightEnd }
+window.App = { reportCode, fightId, fightStart, fightEnd, phases: [], fights: [] }
```
`window.analysisTab` (definiert in `analysis.js`) stellt Hooks bereit:
- `onFightSelected()` — wird von `app.js` aufgerufen wenn ein Fight gewählt wird
- `onTabOpen()` — wird von `tabs.js` aufgerufen wenn der Analyse-Tab geöffnet wird
+- `onFightsLoaded(fights)` — wird von `app.js` aufgerufen nach Report-Load (befüllt Vergleichs-Dropdown)
- `reset()` — wird von `app.js` aufgerufen wenn ein neuer Report geladen wird
## Dateistruktur
@@ -37,26 +38,29 @@ templates/
page.php — HTML-Skeleton (head, body), routet zu login oder app
login.php — Login-Overlay (nicht authentifiziert / Token abgelaufen)
topbar.php — Topbar mit Logo + Tab-Navigation + Token-Ablaufzeit
- tab-report.php — Report-Tab: includes report-form, fight-select, output-card
- tab-analysis.php — Analyse-Tab: Spieler-Grid + AoE-Timeline HTML
+ tab-report.php — Report-Tab: includes report-form, fight-select, event-explorer, output-card
+ tab-analysis.php — Analyse-Tab: Spieler-Grid + Pull-Vergleich + AoE-Timeline HTML
report-form.php — Report-Code-Eingabe Card
fight-select.php — Fight-Auswahl Dropdown Card
+ event-explorer.php — Event Explorer Card (Ability/DataType/EventType/Spieler-Filter)
output-card.php — Terminal-Ausgabe Card + Initial-Hint
css/
base.css — CSS-Variablen, Reset, Basis-Styles, Feedback-Klassen
layout.css — App-Shell, Topbar, Tabs, Login-Overlay, Form-Helpers
components.css — Cards, Inputs, Buttons, Badges, Terminal
- analysis.css — Spieler-Grid, AoE-Timeline, Mitigation-Icons
+ analysis.css — Spieler-Grid, AoE-Timeline, Mitigation-Icons, HP-Bar, Ref-Row
js/
- app.js — Formular, Fight-Dropdown, Fetch, window.App State
+ app.js — Formular, Fight-Dropdown, Fetch, window.App State, Event Explorer
tabs.js — Tab-Switching, ruft window.analysisTab.onTabOpen() auf
- analysis.js — Analyse-Tab: Daten laden, Spieler rendern, Timeline rendern
+ analysis.js — Analyse-Tab: Daten laden, Spieler rendern, Timeline rendern, Pull-Vergleich
auth/
start.php — PKCE generieren, Session speichern, Redirect zu FFLogs
callback.php — Code gegen Token tauschen, Token in Session speichern
api/
fight.php — POST-Endpunkt: Fight-Liste via GraphQL → JSON
analysis.php — POST-Endpunkt: Spieler + AoE-Events + Mitigations → JSON
+ abilities.php — POST-Endpunkt: Ability- + Spielerliste für Event Explorer Dropdowns
+ debug-events.php — POST-Endpunkt: Raw Events für Event Explorer (mit Filterung)
assets/
icons/mitigation/ — Lokal gespeicherte Ability-Icons (PNG, von XIVAPI)
debug/
@@ -75,11 +79,11 @@ CSS-Variablen in `css/base.css`:
## Analyse-Tab — Konzepte & Entscheidungen
### AoE-Erkennung
-- Nur `calculateddamage` Events (post-Mitigation Snapshot) — **nicht** `damage` (Application), da sonst doppelte Events
-- Gruppierung: 300ms-Zeitfenster × `abilityGameID` → Bucket
-- AoE = Bucket mit ≥ 3 unterschiedlichen `targetID`s
-- Auto-Attacks und Fähigkeiten mit `abilityGameID ≤ 7` werden gefiltert
-- Tick-Schaden (`ev['tick'] = true`) wird ignoriert
+- Verwendet `damage` Events mit `includeResources: true` (enthält `buffs`-Feld für Mitigations)
+- Tick-Schaden (`ev['tick'] = true`) und `abilityGameID ≤ 7` (Auto-Attacks) werden gefiltert
+- **Proximity Clustering** statt fixer Zeitfenster: Events werden pro `abilityGameID` gesammelt, dann in Cluster eingeteilt — ein neuer Cluster beginnt wenn der Abstand zum ersten Event im aktuellen Cluster `> CLUSTER_WINDOW_MS` (1000ms) beträgt
+- AoE = Cluster mit ≥ 3 unterschiedlichen `targetID`s
+- Pro Target werden `amount`, `absorbed`, `overkill`, `hp` (nach Treffer), `maxHp` und `buffs` gespeichert; bei mehreren Hits desselben Targets pro Cluster werden `amount`/`absorbed`/`overkill` summiert
### Spielernamen statt IDs
- `masterData.abilities` (gameID → Name) wird zusammen mit `playerDetails` in einem einzigen Query abgerufen
@@ -106,11 +110,12 @@ Getrackte party-wide Buffs + Boss-Debuffs (definiert in `MITIGATION_ABILITIES` i
| Troubadour | 15% | buff |
| Tactician | 15% | buff |
| Shield Samba | 15% | buff |
+| Magick Barrier | 10% | buff |
| Reprisal | 10% | debuff |
| Feint | 10% | debuff |
| Addle | 10% | debuff |
-**Fenster-Tracking:** `applybuff`/`applydebuff` öffnet Fenster (nur erstes pro `abilityId_sourceId`-Key, da party-wide Buffs einmal pro Partymitglied feuern). `removebuff`/`removedebuff` schließt das Fenster. Noch offene Fenster am Fight-Ende werden mit `endTime` geschlossen.
+**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.
### Mitigation-Icons
- Icons lokal gespeichert in `assets/icons/mitigation/` als PNG
@@ -120,25 +125,52 @@ Getrackte party-wide Buffs + Boss-Debuffs (definiert in `MITIGATION_ABILITIES` i
- Mapping in `analysis.js`: `MITIG_ICONS` Objekt (Ability-Name → lokaler Pfad)
- Darstellung: 16×16px Icon unter jedem Spieler-Target, kein Text, `title`-Tooltip mit Name + DR% + Caster
-## Geplante Features
+## Implementierte Features (Übersicht neuerer Commits)
-### HP-Balken pro Spieler (nächstes Feature)
-Für jeden Spieler in der AoE-Timeline einen 3-Segment-Balken anzeigen, der den HP-Stand im Kontext des Treffers zeigt:
+### HP-Balken pro Spieler
+2-Segment-Balken direkt unter Name+Schaden in der Spieler-Box:
+- **Grün** (links): HP nach dem Treffer — Farbton dynamisch: >50% grün, 25–50% amber, <25% rot
+- **Rot** (Mitte): erlittener Schaden (als % von MaxHP)
+- Nur gerendert wenn `t.maxHp > 0`; Daten kommen aus `targetResources.hitPoints` / `maxHitPoints` via `includeResources: true`
-```
-[████████████▓▓▓░░░░░░░░]
- ^verbleibend ^Schaden ^vorher schon fehlend
-```
+### Death-Highlight + Absorbed/Overkill
+- Spieler-Box bekommt Klasse `.aoe-target--dead` wenn `hp === 0 && maxHp > 0`
+- Overkill-Betrag wird links neben dem Job-Badge angezeigt (`.aoe-target-overkill`)
+- Absorbed-Betrag erscheint als gedimmter Zusatz neben dem Schaden (`.aoe-target-absorbed`)
-- **Grün** (links): HP nach dem Treffer (als % von MaxHP)
-- **Rot/Orange** (Mitte): erlittener Schaden (als % von MaxHP)
-- **Dunkelgrau** (rechts): HP die bereits vor dem Treffer fehlten
-- Grünton dynamisch: >50% grün, 25–50% gelb/amber, <25% rot
-- Balken sitzt direkt unter Name+Schaden-Zeile in der Spieler-Box
+### Phase-Filter
+- Liest `phaseTransitions` aus dem gewählten Fight-Objekt (`window.App.phases`)
+- Dropdown `#phase-select` erscheint nur wenn Phasen vorhanden sind
+- Option "Ganzer Fight" (id=0) ist immer dabei; individuelle Phasen ab id=1
+- `phaseFilter = { startTime, endTime }` filtert die Timeline in `renderTimeline()`
-**Datenbedarf:** FFLogs `calculateddamage` Events enthalten `hitPoints` (HP vor dem Treffer) und `maxHitPoints`. Diese müssen in `api/analysis.php` pro Target mitgegeben werden.
+### Spieler-Sortierung
+Konsistentes Healer → DPS → Tank-Ordering überall: im Spieler-Grid, in jedem AoE-Event, und in der Referenz-Zeile. Innerhalb gleicher Rolle alphabetisch nach Name.
-**Backend-Änderung:** In `$buckets[$key]['targets'][$tgtId]` zusätzlich `hp` und `maxHp` aus dem letzten Event speichern und in der Response durchreichen.
+### Pull-Vergleich (innerhalb desselben Reports)
+- Dropdown `#ref-fight-select` wird nach Report-Load mit allen Fights befüllt (via `onFightsLoaded`)
+- Bei Auswahl: separater `api/analysis.php`-Call für den Ref-Fight → `refEvents[]`
+- In `renderTimeline()`: per `abilityName` und Occurrence-Index gematchter Ref-Event wird als `REF`-Zeile unterhalb der aktuellen Targets gerendert
+- Fehlende Mitigations (vorhanden im Ref, nicht im aktuellen Pull) werden als ausgegrautem Icon mit Klasse `.aoe-buff-missing` gezeigt
+- Schaden-Delta pro Spieler: grün wenn besser (`aoe-delta-better`), rot wenn schlechter (`aoe-delta-worse`)
+- Gesamt-Delta in der REF-Headerzeile
+
+### Cross-Report-Vergleich
+- Button "+ Anderer Report" (`#ref-ext-toggle`) öffnet Panel mit Eingabefeld + Laden-Button
+- `api/fight.php` wird mit dem externen Report-Code aufgerufen → Fight-Dropdown befüllt
+- Auswahl eines externen Fights → `api/analysis.php`-Call mit externem Code → `refEvents[]`
+- Same-Report- und Cross-Report-Selektion schließen sich gegenseitig aus (jeweils Reset des anderen Dropdowns)
+
+### Event Explorer (im Report-Tab)
+Erscheint nach Fight-Auswahl als Card über dem Output-Terminal. Filteroptionen:
+- **Ability**: Dropdown mit allen NPC-Fähigkeiten des Fights (via `api/abilities.php`)
+- **DataType**: DamageTaken / DamageDone / Healing / Casts / Buffs / Deaths
+- **Event-Typ (raw)**: damage, calculateddamage, applybuff, removebuff, etc.
+- **Spieler**: Dropdown mit allen Spielern des Fights (via `api/abilities.php`)
+- **Limit**: 1–500 Events
+- **Von/Bis**: Zeitoffsets in Sekunden vom Fight-Start
+- Output landet im gleichen `#output`-Terminal wie der Fight-JSON
+- Backend `api/debug-events.php`: filtert Events nach Typ, Ability-ID und Spieler (Source oder Target); gibt Metadaten + gefilterte Events zurück
## Konfiguration
- `config.php`: `CLIENT_ID`, `REDIRECT_URI`, `DEV_MODE` anpassen
@@ -151,7 +183,8 @@ Für jeden Spieler in der AoE-Timeline einen 3-Segment-Balken anzeigen, der den
- 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: `calculateddamage` (Snapshot nach Mitigation, den wir nutzen) vs. `damage` (Application, ignorieren)
+- Event-Typen: `damage` (post-Mitigation Snapshot, enthält `buffs`-Feld — den wir nutzen) vs. `calculateddamage` (Application-Zeitpunkt, ignorieren)
+- `includeResources: true` im `events()`-Query liefert `targetResources.hitPoints` / `maxHitPoints` pro Event
## XIVAPI
- Basis-URL: `https://v2.xivapi.com`
@@ -174,7 +207,8 @@ Redirect URI in FFLogs App und config.php: `http://localhost:8080/auth/callback.
## Bekannte Schema-Infos (ReportFight)
Verfügbare aber noch nicht genutzte Felder: `friendlyPlayers`, `enemyNPCs`,
-`lastPhase`, `standardComposition`, `hasEcho`, `combatTime`, `phaseTransitions`
+`lastPhase`, `standardComposition`, `hasEcho`, `combatTime`
+Genutzt: `phaseTransitions` (für Phase-Filter), `fightPercentage`, `kill`, `startTime`, `endTime`
Vollständiges Schema: siehe `debug/schema.php` oder `fflogs-schema.json`
## Deployment
diff --git a/css/analysis.css b/css/analysis.css
index 27912f4..6985894 100644
--- a/css/analysis.css
+++ b/css/analysis.css
@@ -106,6 +106,9 @@
}
.aoe-ability {
+ display: flex;
+ align-items: center;
+ gap: 6px;
font-size: 13px;
color: var(--t1);
margin-bottom: 7px;
diff --git a/js/analysis.js b/js/analysis.js
index 3e18091..0b75574 100644
--- a/js/analysis.js
+++ b/js/analysis.js
@@ -310,6 +310,32 @@
);
if (!visibleTargets.length) return '';
+ // Collect boss debuffs (Reprisal/Feint/Addle) once at event level
+ const seenDebuffNames = new Set();
+ const eventDebuffs = [];
+ for (const t of visibleTargets) {
+ for (const m of (t.mitigations ?? [])) {
+ if (m.buffType === 'debuff' && !seenDebuffNames.has(m.name)) {
+ seenDebuffNames.add(m.name);
+ eventDebuffs.push(m);
+ }
+ }
+ }
+ const eventMissingDebuffs = refEv
+ ? (refEv.targets[0]?.mitigations ?? []).filter(m => m.buffType === 'debuff' && !seenDebuffNames.has(m.name))
+ : [];
+ const debuffIconsHtml = [
+ ...eventDebuffs.map(m => ({ ...m, missing: false })),
+ ...eventMissingDebuffs.map(m => ({ ...m, missing: true })),
+ ].map(m => {
+ const iconSrc = MITIG_ICONS[m.name];
+ if (!iconSrc) return '';
+ const dr = m.dr > 0 ? ` −${m.dr}%` : '';
+ return m.missing
+ ? ``
+ : `
`;
+ }).join('');
+
// Current targets
const targets = visibleTargets.map(t => {
const hpBar = (t.maxHp > 0) ? (() => {
@@ -324,21 +350,21 @@
const currentMitigNames = new Set((t.mitigations ?? []).map(m => m.name));
const refTarget = refEv?.targets?.find(rt => rt.name === t.name);
- const missingMitigNames = refTarget
- ? new Set((refTarget.mitigations ?? []).filter(m => !currentMitigNames.has(m.name)).map(m => m.name))
- : new Set();
+ const missingMitigs = refTarget
+ ? (refTarget.mitigations ?? []).filter(m => m.buffType !== 'debuff' && !currentMitigNames.has(m.name))
+ : [];
- const mitigIcons = (t.mitigations ?? []).map(m => {
+ const mitigIcons = (t.mitigations ?? []).filter(m => m.buffType !== 'debuff').map(m => {
const iconSrc = MITIG_ICONS[m.name];
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
return `
`;
}).join('');
- const missingIcons = [...missingMitigNames].map(name => {
- const iconSrc = MITIG_ICONS[name];
+ const missingIcons = missingMitigs.map(m => {
+ const iconSrc = MITIG_ICONS[m.name];
if (!iconSrc) return '';
- return `
`;
+ return `
`;
}).join('');
const dead = t.hp === 0 && t.maxHp > 0;
@@ -373,6 +399,16 @@
const currentByName = {};
ev.targets.forEach(t => { currentByName[t.name] = t; });
+ const seenRefDebuffNames = new Set();
+ const refDebuffIconsHtml = refVisible.flatMap(t => (t.mitigations ?? []))
+ .filter(m => m.buffType === 'debuff' && !seenRefDebuffNames.has(m.name) && seenRefDebuffNames.add(m.name))
+ .map(m => {
+ const iconSrc = MITIG_ICONS[m.name];
+ if (!iconSrc) return '';
+ const dr = m.dr > 0 ? ` −${m.dr}%` : '';
+ return `
`;
+ }).join('');
+
const refCards = refVisible.map(t => {
const curr = currentByName[t.name];
const diff = curr ? curr.amount - t.amount : 0;
@@ -382,7 +418,7 @@
? `${diff > 0 ? '+' : '-'}${fmtDmg(Math.abs(diff))}`
: '';
- const refMitigIcons = (t.mitigations ?? []).map(m => {
+ const refMitigIcons = (t.mitigations ?? []).filter(m => m.buffType !== 'debuff').map(m => {
const iconSrc = MITIG_ICONS[m.name];
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
@@ -410,7 +446,7 @@
refHtml = `