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 + ? `${m.name}` + : `${m.name}`; + }).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 `${m.name}`; }).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 `${name}`; + return `${m.name}`; }).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 `${m.name}`; + }).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 = `
- REF ${fmtDmg(refEv.totalDamage)} ${totalDelta} + REF ${fmtDmg(refEv.totalDamage)} ${totalDelta} ${refDebuffIconsHtml}
${refCards}
`; } @@ -423,6 +459,7 @@
${ev.abilityName} — ${fmtDmg(ev.totalDamage)} total + ${debuffIconsHtml}
${targets}
${refHtml}