Compare commits

..

58 Commits

Author SHA1 Message Date
xziino
89d1ac0df1 Event Explorer: masterData.abilities.type + ABILITY_TYPE_PHYSICAL/MAGICAL Konstanten
- debug-events.php: masterData { abilities { gameID name type } } mitabfragen,
  Ergebnis als ability_meta-Tabelle + _ability-Feld pro Event im Output
- ffxiv-data.js: ABILITY_TYPE_PHYSICAL (128) / ABILITY_TYPE_MAGICAL (1024)
  als verifizierte Bitmask-Konstanten + abilityTypeIsPhysical/abilityTypeIsMagical
  Hilfsfunktionen -- Grundlage fuer dynamische Feint/Addle DR-Berechnung

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 12:39:36 +02:00
xziino
fce55c5bb6 Planer: Meine-Spells-View + Lila Personal-Farbe + Sidebar-Layout + Gantt-Scrollbar
- js/planner.js:
  - Mechaniken-Card: View-Toggle 'Mechaniken' / 'Meine Spells'
  - renderMySpellsHtml(): Assignments pro Mechanik gruppiert (1 Zeile statt 1 Zeile/Ability)
  - initMySpells(): Toggle-Logik, Job-Dropdown persistent (localStorage), Kopieren-Button
  - Personal-Mits lila (myspells-type--personal / badge-assign-personal) in beiden Views
  - Legende: Personal-Eintrag ergänzt
- css/planner.css:
  - .view-toggle-btns / .view-toggle-btn: Toggle-Button-Styles
  - .myspells-*: Cheatsheet-Layout (Grid, Badges, Farbkodierung)
  - .badge-assign-personal: Lila für Personal-Mits in Mechaniken-Übersicht
  - .info-legend-dot--personal: Lila Dot in Legende
  - .planner-info-panel: border-bottom statt border-top (jetzt oben in Sidebar)
  - .timeline-scroll: dunkler Custom-Scrollbar (webkit + scrollbar-color)
- templates/tab-planner.php: Info-Panel über Pläne-Sektion verschoben
- templates/topbar.php: Planer-Icon 📋 -> ☰

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:12:28 +02:00
xziino
1a91d1af0e Planer/Analyse: Personal-Mits Knight's Resolve, Nascent Glint, Stem the Flow/Tide, Aquaveil, Exaltation
- api/analysis.php: 6 neue Einträge in MITIGATION_ABILITIES
  Knight's Resolve (PLD, 1002675, 10% DR), Nascent Glint (WAR, 1001858, 10% DR),
  Stem the Flow (WAR, 1002679, 10% DR), Stem the Tide (WAR, 1002680, Shield),
  Aquaveil (WHM, 1002708, 15% DR), Exaltation (AST, 1002717, 10% DR)
- js/ffxiv-data.js: JOB_ABILITIES, ABILITY_JOB_MAP, MITIG_ICONS, ABILITY_DR aktualisiert
- js/planner.js: alle 6 in TIMELINE_PERSONAL_ABILITIES eingetragen
- assets/jsons/Action.json: Recast-Daten für Nascent Flash (25s), Aquaveil (60s),
  Exaltation (60s) ergänzt (Gantt-Balken)
- assets/icons/mitigation/: 6 neue Icons heruntergeladen (XIVAPI Status-Icons)
- api/cache.php: Version v6 -> v8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:22:12 +02:00
xziino
636a65965a Analyse+Planer: sourceID fuer Reprisal/Feint/Addle via Cast-Events
- Neuer Abschnitt 2c in analysis.php: Cast-Events fuer Reprisal/Feint/Addle
  per 1 GQL-Request (3 Aliase) abfragen, da dataType:Buffs nur Friendly-
  Targets liefert (Boss-Debuffs fehlen dort)
- buffSourceTimeline mit Cast-Timestamps + 10s Dauer befuellt
- findBuffSourcePlayer(): sucht aktiven Caster zum Schadens-Zeitpunkt
- resolveMitigations(): gibt sourcePlayerType aus buffSourceTimeline zurueck
- guessJob() in planner.js: sourcePlayerType als erste Prioritaet vor
  job-basiertem Fallback -> DRK Reprisal, MNK Feint etc. korrekt zugeordnet
- analysis.js: Debuff-Icons im Header zeigen Job im Tooltip (z.B. DRK - Reprisal)
- Cache v5 -> v6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:22:55 +02:00
xziino
a9b3cc8666 Planer: Clarity of Corundum + separate Tankbuster-Fixes
- Clarity of Corundum (GNB Proc, 15% DR, statusId 1002684) neu in
  MITIGATION_ABILITIES, ABILITY_DR, JOB_ABILITIES, TIMELINE_PERSONAL_ABILITIES
- statusId fuer Heart of Corundum (1002683) und Great Nebula (1003838)
  nachgetragen (stabilere Erkennung via buffs-Feld)
- Icons heruntergeladen: heart-of-corundum.png, heart-of-stone.png,
  clarity-of-corundum.png (teilt HoC-Action-Icon vorlaeufig)
- Cache-Version v3 -> v4 (erzwingt Neu-Fetch nach Erkennungs-Aenderungen)

Separate Tankbuster:
- tankMaxHpFromEvent(): maxHP direkt aus dem aoe_event des getroffenen
  Tanks statt Roster-Durchschnitt (ein Tank getroffen = sein MaxHP)
- renderMechanicListHtml: m.tankMaxHp hat Vorrang vor avgTankMaxHp(plan)
- plannedAssignmentsForMechanic: persoenliche Mitigation erscheint nur
  auf der direkt zugewiesenen Mechanik (DRK-CDs nicht mehr auf GNB-TB)
- DR-Label: 'mitigiert' -> 'nach DR' (verbleibender Schaden, nicht
  reduzierter Betrag)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:04:21 +02:00
Akurosia Kamo
7eeeb5ef56 Merge remote-tracking branch 'origin/main' into akus_schabernack4 2026-05-24 11:55:11 +02:00
Akurosia Kamo
e8863d83a5 more personals 2026-05-24 11:45:46 +02:00
Akurosia Kamo
90fcbb69a5 more personals 2026-05-24 11:39:51 +02:00
xziino
858a5e8f49 Merge remote-tracking branch 'Akurosia/akus_schabernack4' 2026-05-24 11:24:14 +02:00
Akurosia Kamo
fc2dc513ca personal mitigation 2026-05-24 11:23:01 +02:00
xziino
186d59fdc5 Merge remote-tracking branch 'Akurosia/akus_schabernack4' 2026-05-24 10:43:22 +02:00
Akurosia Kamo
646a7252c8 i forgot what i have added so... JUST TRUST BRO 2026-05-24 10:42:25 +02:00
Akurosia Kamo
1dfc727940 add missing boss skills by event or by time
add a recheck if delete of boss action is really wanted
toogle boss action to be a tankbuster or not
2026-05-24 10:23:59 +02:00
xziino
206e09a57a config: .env.example entfernt, .env in .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:10:51 +02:00
Akurosia Kamo
b5445da02a superbolide fix 2026-05-24 09:41:04 +02:00
Akurosia Kamo
243cb0608c mark tankbuster as purple (superbolide is broken)
move stuff to env file and recommit config.php
2026-05-24 09:31:57 +02:00
xziino
eecab6d76a Planer: Gantt-Zeilenreihenfolge auf Healer → DPS → Tank geändert
Innerhalb DPS: Phys. Range (BRD/MCH/DNC) → Caster (BLM/SMN/RDM/PCT)
→ Melee (MNK/DRG/NIN/SAM/RPR/VPR). Sortierung unabhängig von der
Reihenfolge der Job-Slots im Info-Panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:53:37 +02:00
xziino
bb4eb301e0 Planer: Importierte Mitigations um 3s vor die Mechanik verschieben
Beim Report-Import landen alle erkannten Ability-Assignments standardmäßig
3 Sekunden vor dem Mechanik-Timestamp (IMPORT_OFFSET_MS = -3000ms).
Linksklick im Gantt und manuelle Zuweisungen bleiben unverändert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:50:11 +02:00
xziino
e40bdbea1c config: auth_start_href() in index.php definiert (fehlende Funktion nach Merge)
Commit 8217f68 rief auth_start_href() in report-form.php und login.php
auf, ohne die Funktion zu definieren. Generiert auth/start.php?return=
mit dem aktuellen Request-URI als Return-Pfad.
In index.php statt config.php da config.php skip-worktree hat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:43:30 +02:00
Akurosia Kamo
4345fadc1c Merge remote-tracking branch 'origin/main' into akus_schabernack4 2026-05-24 08:22:40 +02:00
Akurosia Kamo
28c045fee7 add caching of logs 2026-05-24 08:22:27 +02:00
xziino
669bcd937b Planer: Scroll-Position im Gantt nach Re-Render beibehalten
refreshTimeline() speichert scrollLeft vor dem innerHTML-Reset und
stellt ihn danach wieder her – die Ansicht springt beim Verschieben
von Abilities nicht mehr an den Anfang.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:21:51 +02:00
xziino
be65d0b228 Planer: Gantt-Overlap-Check auf vollen Cooldown erweitert
assignmentWindowMs und candidateWindow nutzen jetzt max(duration, cooldown)
statt nur die aktive Dauer – Abilities konnten bisher erneut platziert werden
bevor ihr Recast abgelaufen war.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:17:22 +02:00
xziino
c983ca6621 Planer: Gantt-Klick zum Hinzufügen repariert (Pointer-Capture-Bug)
setPointerCapture() leitet Compatibility Mouse Events (inkl. click) an das
capturing Element um – e.target im click-Handler war immer .timeline-scroll,
nie das angeklickte .timeline-track. Fix: document.elementFromPoint() für
zuverlässigen Hit-Test unabhängig von Pointer Capture.
Pan-Threshold zusätzlich von 3px auf 8px erhöht.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:12:09 +02:00
Akurosia Kamo
8217f68a0f fix login to keep url (again) 2026-05-24 08:02:32 +02:00
Akurosia Kamo
fd0de86dbc fix timeline skills vocer multiple skills 2026-05-23 21:42:16 +02:00
Akurosia Kamo
0f8a90d1b4 move all skill/player things into a single file 2026-05-23 21:28:36 +02:00
Akurosia Kamo
8f00c22682 more timeline fixes and addle fix 2026-05-23 21:22:10 +02:00
Akurosia Kamo
fb6d50961a better moving of skills on the timeline 2026-05-23 21:10:19 +02:00
Akurosia Kamo
3276e3bfb3 timeline and skill dragin improvments 2026-05-23 21:03:13 +02:00
Akurosia Kamo
d0f54049e6 adjustments to skill behaviour 2026-05-23 21:00:19 +02:00
xziino
4ec929ebb7 CLAUDE.md: Dokumentation auf aktuellen Stand gebracht
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:00:42 +02:00
xziino
da226d54a2 Planer: Gantt-Zeilen pro Ability + aktive Dauer im Balken sichtbar
- Eine Timeline-Zeile pro Ability statt pro Job
- Schilde ausgeblendet (außer Panhaima SGE)
- Balken zeigt aktive Dauer vs. Cooldown via Gradient
- Klick auf leere Zeile fügt Ability direkt hinzu
- Drop nur auf passende Ability-Zeile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:15:58 +02:00
xziino
cdd594e43e Planer: SGE-Abilities bereinigt (Eukrasian Prognosis II + Addle entfernt)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:50:43 +02:00
xziino
b27986c0f4 Planer: Schild-Input auf change-Event umgestellt (kein Fokus-Verlust beim Tippen)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:46:34 +02:00
xziino
e358a4e709 Planer: DR-Simulation + Schild-Eingabe in Mechanik-Cards
- ABILITY_DR-Map mit DR-Werten pro Ability (Feint = 5% magic)
- simulateDrMultiplier() berechnet multiplikativen DR-Faktor
- Mechanik-Cards zeigen mitigierten Wert (DR-only) + optional mit Schild
- SGE/SCH Schild-Feld (in k) im Info-Panel, wird pro Plan gespeichert
- Farbige Anzeige: grün = unter ∅ HP, rot = darüber

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:43:37 +02:00
xziino
8b00d1d2a8 Merge remote-tracking branch 'Akurosia/akus_schabernack4' 2026-05-23 12:59:51 +02:00
xziino
19922d79aa Planer: Durchschnittliche MaxHP (ohne Tanks) neben unmitigiertem Schaden anzeigen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 12:59:16 +02:00
Akurosia Kamo
61fecbc576 add timeline feature 2026-05-23 12:48:08 +02:00
Akurosia Kamo
27b9b0785e fix fight selection 2026-05-23 12:11:18 +02:00
Akurosia Kamo
2275f0050d Merge remote-tracking branch 'origin/main' into akus_schabernack4 2026-05-23 12:04:07 +02:00
xziino
5dc61754f2 Add mitigation-actions.json; ignore config.php
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:28:30 +02:00
xziino
2e8818bb03 Analyse-Tab: Plan-Ref zeigt Spieler des Plans im Spielerfeld
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:54:49 +02:00
xziino
fb58226be8 Analyse-Tab: Plan als Referenz-Quelle
- Pläne aus localStorage als Ref-Quelle auswählbar ("+Plan als Referenz")
- planToRefEvents() konvertiert Plan-Mechaniken ins refEvents-Format
- PLAN-Label statt REF, kein Delta, kein Schadenswert
- Buff-Icons mit "fehlt"-Markierung, Debuffs im Header
- Shield-Assignments als "Schild"-Text mit Tooltip
- Schließt sich mit anderen Ref-Quellen gegenseitig aus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 08:30:52 +02:00
xziino
6024560e61 Planner: Namen + Job Import aus beliebigem Report
- Neues api/players.php: playerDetails + maxHp aus DamageTaken in einem GQL-Query
- Import-Modal übernimmt Jobs UND Namen (Heiler → DPS → Tank → Alphabet)
- buildPlayerRoster/extractJobComp: einheitliche Sortierreihenfolge
- Ref-Fight Export überträgt jetzt auch die Jobaufstellung
- Job-Slots zeigen importierte Spielernamen an

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 08:08:22 +02:00
xziino
565dedc568 Analyse-Tab: Job-basierter Mitigation-Vergleich statt Namens-Match
Ref-Vergleich prüft jetzt ob die aktuelle Gruppe den passenden Job hat
(via ABILITY_JOBS-Map), statt Spielernamen zu matchen. Fehlende
Mitigations werden nur noch in der REF-Zeile hervorgehoben — der
aktive Pull zeigt ausschließlich tatsächlich genutzte Mitigations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:53:01 +02:00
xziino
07a140442f Analyse-Tab: Ref-Vergleich nach Job statt Spielername
Fehlende Mitigations werden nun anhand des Job-Typs (z.B. SGE, PLD)
zugeordnet statt per Spielername — funktioniert auch wenn der Referenz-
Fight mit einer anderen Gruppe gespielt wurde.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:24:44 +02:00
xziino
349645f4cb Analyse-Tab: Export-Auswahl zwischen aktuellem und Referenz-Fight
Wenn eine Referenz aktiv ist, öffnet der Export-Button ein kleines
Dropdown mit den Optionen "Aktueller Fight" und "Referenz-Fight".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:20:37 +02:00
Akurosia Kamo
d8ff50eeef add cron 2026-05-22 17:24:31 +02:00
Akurosia Kamo
b6d44d8ae0 add skill ids to map 2026-05-22 16:49:03 +02:00
xziino
58745fec65 CLAUDE.md: Planer-Dokumentation auf aktuellen Stand gebracht
Datenmodell, Roadmap, Gantt-Entscheidungen, Äquivalente-Ort und
Recast-Format aktualisiert; veraltete Punkte entfernt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 16:33:39 +02:00
xziino
4801148a8c Merge akus_schabernack3: Lokalisierung + Tab-Persistenz
- Lokalisierte Ability-Namen in Badges und Modal
- Aktiver Plan wird per localStorage über Reload hinweg gespeichert
- Aktiver Tab wird per localStorage gespeichert
- refreshPlanLanguage() aktualisiert Namen beim Sprachwechsel
- api/analysis.php gibt mitigation_names zurück
- ff14-language-change Event in app.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:58:32 +02:00
Akurosia Kamo
15754fefda Merge remote-tracking branch 'origin/master' into akus_schabernack3
# Conflicts:
#	js/planner.js
2026-05-22 12:48:22 +02:00
Akurosia Kamo
c3f18b38f5 small fix, stay on playner page on refresh and translate planner 2026-05-22 12:35:05 +02:00
Akurosia Kamo
c67b08737e small fix, stay on playner page on refresh and translate planner 2026-05-22 12:32:50 +02:00
Akurosia Kamo
14674d2842 small fix 2026-05-22 11:17:11 +02:00
Akurosia Kamo
7c6f443a53 Merge remote-tracking branch 'origin/master' into akus_schabernack3
# Conflicts:
#	js/planner.js
2026-05-22 10:52:41 +02:00
Akurosia Kamo
89b4849ed4 aku test2 2026-05-22 10:49:49 +02:00
36 changed files with 8890 additions and 461 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.claude/
debug/
cached_logs/
.env
fflogs-schema.json

129
CLAUDE.md
View File

@ -69,6 +69,7 @@ api/
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)
players.php — POST-Endpunkt: Spieler + maxHp aus einem beliebigen Fight (für Namen+Job-Import im Planer)
assets/
icons/mitigation/ — Lokal gespeicherte Ability-Icons (PNG, von XIVAPI)
data/
@ -108,7 +109,7 @@ Getrackte party-wide Buffs + Schilde + Boss-Debuffs (definiert in `MITIGATION_AB
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%)
- **`debuff`**: Boss-Debuffs — Reprisal (10%), Feint (10% phys / 5% magic), Addle (10%)
**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').
@ -160,6 +161,14 @@ Konsistentes Healer → DPS → Tank-Ordering überall: im Spieler-Grid, in jede
- Schaden-Delta pro Spieler: grün wenn besser (`aoe-delta-better`), rot wenn schlechter (`aoe-delta-worse`)
- Gesamt-Delta + Ref-Debuff-Icons in der REF-Headerzeile (`aoe-ref-label`)
### Plan als Referenz (Analyse-Tab)
- Button "+ Plan als Referenz" (`#ref-plan-toggle`) öffnet Dropdown mit gespeicherten Plänen
- Bei Auswahl: Plan-Mechaniken werden zu `refEvents[]` konvertiert (`isPlanRef: true` Flag)
- `planToRefEvents(plan)`: wandelt Plan-Assignments in `refEvents` um — nur Abilities mit bekanntem `buffType`
- In `renderTimeline()`: `isPlanRef` steuert Darstellung — kein Schaden-Delta, kein Dead-State, "Schild"-Text statt absorbed-Wert, Label "PLAN" statt "REF"
- Spieler des Plans werden im Spieler-Grid angezeigt wenn sie sich vom aktuellen Fight unterscheiden (via `refPlayers`)
- Same-Report, Cross-Report und Plan-Ref schließen sich gegenseitig aus
### 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
@ -239,26 +248,39 @@ Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik einges
"name": "M8S Prog Week 1",
"createdAt": 1234567890,
"updatedAt": 1234567890,
"source": { "reportCode": "abc123", "fightId": 6 },
"source": { "reportCode": "abc123", "fightId": 6, "fightName": "Howling Blade", "fightStart": 0, "fightEnd": 420000, "language": "en" },
"mitigationNames": { "Reprisal": "Vergeltung" },
"folderId": null,
"jobComposition": ["PLD", "WAR", "WHM", "SCH", "MNK", "DRG", "BRD", "SMN"],
"playerRoster": [
{ "name": "Xziino", "maxHp": 82340 },
{ "name": "Healer1", "maxHp": 75000 }
],
"shieldK": 30,
"mechanics": [
{
"id": "uuid",
"name": "Fourth-Wall Fusion",
"abilityId": 12345,
"timestamp": 83000,
"phase": "Phase 1",
"unmitigatedDamage": 280000,
"notes": "",
"assignments": [
{ "ability": "Reprisal", "job": "PLD" },
{ "ability": "Shield Samba", "job": "BRD" }
{ "ability": "Reprisal", "abilityName": "Vergeltung", "job": "PLD", "buffType": "debuff" },
{ "ability": "Shield Samba", "abilityName": "Schildsamba", "job": "BRD", "buffType": "buff" }
]
}
]
}
```
- `playerRoster`: Array[8] mit `{name, maxHp}` — wird via "Namen + Job Import" befüllt (aus `api/players.php`)
- `shieldK`: SGE/SCH-Schildwert in k (×1000) — wird in der DR-Simulation als Flat-Abzug verwendet; im Info-Panel editierbar
Mehrere Pläne gespeichert in `localStorage` unter `ff14-planner-plans` als Array.
Ordner gespeichert unter `ff14-planner-folders`. Pläne referenzieren Ordner per `folderId`.
Aktiver Plan per `ff14-planner-active-plan` (ID) — wird beim Tab-Öffnen wiederhergestellt.
### Primärer Import-Flow: Export aus dem Analyse-Tab
Der Haupteinstieg ist der Analyse-Tab — der Nutzer hat die Daten bereits geladen und sieht die Timeline.
@ -271,30 +293,59 @@ Der Haupteinstieg ist der Analyse-Tab — der Nutzer hat die Daten bereits gelad
4. AoE-Events werden zu Mechaniken; Phase-Information aus `phaseTransitions` wird mitübernommen
5. Weiterarbeiten im Planer-Tab
**Merge-Logik:** Mechaniken gelten als identisch wenn `abilityName` gleich und `|timestamp_a - timestamp_b| < 5000ms`. Nur neue Mechaniken werden hinzugefügt, bestehende Assignments bleiben erhalten. Neue Mechaniken werden timestamp-sortiert eingefügt.
**Merge-Logik:** Mechaniken gelten als identisch wenn `|timestamp_a - timestamp_b| < 1500ms`. Nur neue Mechaniken werden hinzugefügt, bestehende Assignments bleiben erhalten. Neue Mechaniken werden timestamp-sortiert eingefügt.
**Warum Merge statt Überschreiben:** Progress-Szenario — erster Import enthält Phase 1, späterer Import fügt Phase 2 hinzu ohne Phase-1-Planung zu verlieren.
**Sprachlokalisierung:** `api/analysis.php` gibt `mitigation_names` (key → lokalisierter Name) zurück. Plan speichert diese in `mitigationNames`. `refreshPlanLanguage()` in `planner.js` aktualisiert Namen wenn Sprache wechselt (Event `ff14-language-change`). Anzeigename via `assignmentAbilityName(assignment, plan)`.
### Implementierungs-Reihenfolge
| Schritt | Feature | Beschreibung |
| Schritt | Status | Feature |
|---|---|---|
| 1 | **Datenfundament** | Plan-Datenmodell + localStorage CRUD (create, read, update, delete, copy) |
| 2 | **Tab-Grundgerüst** | Leere Tab-Hülle wie Analyse-Tab; Plan-Liste; Mechanik-Timeline (read-only) |
| 3 | **Import aus Analyse-Tab** | Export-Button + Dialog (s. oben); `window.plannerTab.importFromAnalysis()` |
| 4 | **Jobaufstellung** | 8 Slots mit Job-Dropdown; bestimmt verfügbare Abilities in Schritt 5 |
| 5 | **Ability-Zuweisung** | Pro Mechanik Abilities per Modal-Picker hinzufügen/entfernen |
| 6 | **Recast-Konflikt** | `data/recast-times.json`; rote Warnung wenn CD zwischen zwei Mechaniken noch läuft |
| 7 | **DR-Simulation** | `simuliert = unmitigated × ∏(1 dr_i)`; grün/rot ob Spieler laut Simulation überlebt |
| 8 | **Job-Äquivalente** | `data/ability-equivalents.json`; auto-substituieren beim Job-Wechsel |
| 9 | **Analyse-Overlay** | Vergleich geplanter vs. tatsächlich genutzter CDs im Analyse-Tab |
| 1 | ✅ | **Datenfundament** — Plan-Datenmodell + localStorage CRUD |
| 2 | ✅ | **Tab-Grundgerüst** — Plan-Liste, Ordner, Mechanik-Timeline |
| 3 | ✅ | **Import aus Analyse-Tab** — Export-Button + Dialog + Merge |
| 4 | ✅ | **Jobaufstellung** — 8 Slots mit Job-Dropdown + Rollenfärbung + Namen+Job-Import |
| 5 | ✅ | **Ability-Zuweisung** — Modal-Picker + Rechtsklick-Remove + Äquivalenz-Hints |
| 6 | ✅ | **DR-Simulation + Gantt-Chart** — mitigierter Schaden in Mechanik-Cards, Gantt mit Ability-Zeilen |
| 7 | 🔜 | **Analyse-Overlay** — geplante vs. tatsächlich genutzte CDs |
Schritte 13 = nutzbarer MVP. Schritte 46 = praktisch einsetzbar. 79 = Power-Features.
### DR-Simulation (implementiert)
- `ABILITY_DR` in `planner.js`: Map Ability-Name → DR-Wert (01). Feint = 0.05 (5% magical, konservativ).
- `simulateDrMultiplier(mechanic)`: multipliziert `∏(1 dr_i)` über alle Buff/Debuff-Assignments (keine Schilde)
- In `renderMechanicListHtml`:
- `→ XX mitigiert` (DR-only, farbig gegen ∅ HP): grün wenn ≤ avgHp, rot wenn darüber
- `Mitigation mit Schild XX` (DR + shieldK×1000, daneben): nur wenn `plan.shieldK > 0`
- `∅ XX HP` (Durchschnitt maxHp ohne Tanks): aus `avgNonTankMaxHp(plan)`, neben unmitigiertem Wert
- Quell-DR-Werte spiegeln `MITIGATION_ABILITIES[*]['dr']` aus `api/analysis.php` — beide Stellen müssen bei Änderungen synchron gehalten werden
### Gantt-Chart (implementiert)
**Aktueller Stand:**
- X-Achse: Kampfzeit aus `source.fightEnd - source.fightStart`
- **Eine Zeile pro (Job, Ability)** — nicht pro Job. `timelinePlayerRows(plan)` expandiert `JOB_ABILITIES` pro Job.
- Schilde in Gantt ausgeblendet, außer **Panhaima** (SGE)
- Erste Zeile jedes Jobs hat eine visuelle Trennlinie (`.timeline-player-row--job-start`)
- Balken: linker Teil (aktive Dauer, opak) + rechter Teil (Cooldown-Rest, transparent) via CSS `linear-gradient` auf `--active-width` / `--cd-width`
- Klick auf leere Zeile → fügt diese Ability direkt der nächsten Mechanik hinzu (kein Menü)
- Drag & Drop: nur zwischen Zeilen derselben Ability
- Bestehende Balken per Drag verschiebbar; Rechtsklick zum Entfernen
**Noch offen:**
- Konflikte visuell hervorheben wenn Balken eine Mechaniken-Linie überlappt
**Recast-Daten:** `data/recast-times.json` — noch zu befüllen. Enthält Cooldown-Dauer (s) und Aktiv-Dauer (s) pro Ability:
```json
{ "Reprisal": { "recast": 60, "duration": 10 }, "Feint": { "recast": 90, "duration": 10 } }
```
### UI-Paradigma
- Visuell dem Analyse-Tab ähneln (Cards, gleiche CSS-Variablen, einheitliches Look & Feel)
- Mechaniken als vertikale Timeline-Cards
- Mechaniken als vertikale Timeline-Cards — primäre Bearbeitungsfläche bleibt erhalten
- Ability-Picker als **Modal** (kein Inline-Dropdown)
- Gantt als zusätzliche Ansicht für den Planer (derjenige der die CDs koordiniert)
- Nicht für mobile Geräte ausgelegt
### Spell-Verfügbarkeit nach Job
@ -309,7 +360,7 @@ Jobaufstellung → verfügbare Abilities (Subset von `MITIGATION_ABILITIES`):
| WHM | Temperance, Divine Benison, Divine Caress |
| SCH | Sacred Soil, Expedient, Fey Illumination, Galvanize, Seraphic Veil, Catalyze, Addle |
| AST | Collective Unconscious, Neutral Sect, Intersection, the Spire |
| SGE | Kerachole, Holos, Holosakos, Panhaima, Eukrasian Prognosis, Eukrasian Diagnosis, Haima, Addle |
| SGE | Kerachole, Holos, Holosakos, Panhaima, Eukrasian Prognosis, Eukrasian Diagnosis, Differential Diagnosis, Haima |
| BRD | Troubadour |
| MCH | Tactician |
| DNC | Shield Samba, Improvised Finish |
@ -324,31 +375,39 @@ Jobaufstellung → verfügbare Abilities (Subset von `MITIGATION_ABILITIES`):
| RDM | Addle, Magick Barrier |
| PCT | Addle, Tempera Coat, Tempera Grassa |
### Job-Äquivalente (`data/ability-equivalents.json`)
Abilities die funktional gleich sind aber unterschiedliche Namen haben — relevant beim Job-Wechsel im Slot:
### Job-Äquivalente (in `planner.js` als `ABILITY_EQUIVALENTS`)
Abilities die funktional gleich sind aber unterschiedliche Namen haben — zeigt Hinweis bei fehlendem Job.
Kein automatischer Austausch — nur Hinweis in rot/grün unter dem ausgegrauten Badge.
| Gruppe | Abilities |
|---|---|
| 15% Party-Mitigation | Troubadour, Tactician, Shield Samba |
| 10% Ground-Barrier | Sacred Soil, Kerachole |
Reprisal, Feint und Addle sind identische Ability-Namen über Jobs hinweg — kein Mapping nötig, die gleiche Ability bleibt einfach bestehen.
Reprisal, Feint und Addle sind identische Ability-Namen über Jobs hinweg — kein Mapping nötig.
**Verhalten beim Job-Wechsel:** Assignment wird auto-substituiert wenn Äquivalent für neuen Job existiert (mit Hinweis "automatisch gemappt"). Kein Äquivalent → Assignment ausgegraut (nicht gelöscht, Nutzer entscheidet).
### `extraAbilityGameID` in MITIGATION_ABILITIES (geplant)
Jeder Eintrag in `MITIGATION_ABILITIES` soll ein `actionId`-Feld bekommen — die echte Action-Row-ID aus FFLogs (`applybuff`-Event → `extraAbilityGameID`). Diese ID entspricht direkt der XIVAPI Action-Sheet-Row-ID und ermöglicht automatischen Icon-Lookup:
- 5-stellige IDs: führende 0 ergänzen falls XIVAPI 6 Stellen erwartet
- Lookup: `https://v2.xivapi.com/api/sheet/Action/{actionId}?fields=Name,Icon`
- Ermöglicht künftig automatisches Icon-Fetching statt manueller `MITIG_ICONS`-Pflege
### Recast-Zeiten (`data/recast-times.json`)
Wird für Konflikt-Erkennung benötigt (Schritt 6). Vollständige Liste wird beim Implementieren vervollständigt, Beispiele:
- Reprisal: 60s
- Feint / Addle: 90s
- Dark Missionary / Heart of Light: 90s
- Troubadour / Tactician / Shield Samba: 120s
- Temperance: 120s
Format: `{ "Ability": { "recast": 60, "duration": 10 } }` — noch zu befüllen.
Bekannte Werte (Beispiele):
- Reprisal: recast 60s, duration 10s
- Feint / Addle: recast 90s, duration 10s
- Dark Missionary / Heart of Light: recast 90s, duration 15s
- Troubadour / Tactician / Shield Samba: recast 120s, duration 15s
- Temperance: recast 120s, duration 20s
- Sacred Soil / Kerachole: recast 30s, duration 15s
### Technische Entscheidungen
- **Persistenz:** `localStorage` unter `ff14-planner-plans` — kein Backend nötig
- **IDs:** `crypto.randomUUID()` für Plan- und Mechanik-IDs
- **Keine Spielernamen:** Assignments sind Job-basiert (`{ ability, job }`), damit Pläne übertragbar sind
- **Kein Ability-Stacking:** FFXIV erlaubt keine doppelte Anwendung derselben Ability — jede Ability kommt pro Mechanik maximal einmal vor, doppelte Instanzen desselben Jobs im Static sind daher kein Sonderfall
- **Analyse-Overlay (Schritt 9):** Job aus tatsächlichem Pull → lookup welche Ability dieser Job im Plan hatte → Soll/Ist-Vergleich (kein Spielername-Matching nötig)
- **Shield-Attribution:** Aktuell nicht lösbar — `absorbed` im `damage`-Event ist ein Gesamtwert ohne Aufschlüsselung per Shield. Zu untersuchen: ob `calculatedheal`-Events die Shield-Kapazität beim Auftragen mitliefern. Vorerst zurückgestellt.
- **Plan kopieren:** Duplicate-Funktion für Plan-Varianten ("Week 3 v2") ohne Original zu verlieren
- **Persistenz:** `localStorage` — kein Backend nötig
- **IDs:** `crypto.randomUUID()` für Plan-, Mechanik- und Ordner-IDs
- **Eindeutige Namen:** `uniquePlanName()` verhindert Duplikate beim Erstellen und Importieren
- **Spielernamen in `playerRoster`:** Assignments bleiben Job-basiert (`{ ability, job }`). Namen + maxHp werden separat in `playerRoster[8]` gespeichert — für ∅-HP-Anzeige und Analyse-Tab-Vergleich, nicht für Assignments
- **Kein Ability-Stacking:** FFXIV erlaubt keine doppelte Anwendung derselben Ability pro Mechanik
- **Shield-Attribution:** Nicht simulierbar — `absorbed` ist Gesamtwert ohne Aufschlüsselung. Bewusst weggelassen.
- **DR-Simulation:** Nur Buffs/Debuffs mit bekanntem `dr`-Wert aus `MITIGATION_ABILITIES`. Ergebnis als Zahlenwert, kein Pass/Fail.
- **fight.php:** Immer englischer Endpoint (`www.fflogs.com`) für stabile Fight-Namen unabhängig von Spracheinstellung

View File

@ -1,13 +1,12 @@
<?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; }
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);
@ -19,6 +18,16 @@ $translate = 'true';
if (!$reportCode || !$fightId || !$endTime) { http_response_code(400); echo json_encode(['error' => 'Missing params']); exit; }
$cacheParts = [$fightId, (int)$startTime, (int)$endTime];
$cached = read_cached_log('abilities', $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 {
@ -123,4 +132,12 @@ foreach (array_keys($seenIds) as $id) {
}
usort($abilities, fn($a, $b) => strcmp($a['name'], $b['name']));
echo json_encode(['abilities' => $abilities, 'players' => $players]);
$response = json_encode(['abilities' => $abilities, 'players' => $players]);
if ($response === false) {
http_response_code(500);
echo json_encode(['error' => 'Could not encode abilities response']);
exit;
}
write_cached_log('abilities', $reportCode, $language, $cacheParts, $response);
echo $response;

View File

@ -1,6 +1,7 @@
<?php
ini_set('display_errors', '0');
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/cache.php';
session_start_safe();
header('Content-Type: application/json');
@ -11,15 +12,6 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
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);
@ -34,6 +26,22 @@ if (!$reportCode || !$fightId || !$endTime) {
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 {
@ -76,60 +84,101 @@ function fflogs_gql(string $query): array {
// buffType 'debuff' = boss debuff, shown in event header
const MITIGATION_ABILITIES = [
// ── Damage reduction buffs ──────────────────────────────────────────────
'Passage of Arms' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001175],
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001894],
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001839],
'Temperance' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001873],
'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001944],
'Expedient' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002711], // FFLogs: "Desperate Measures"
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff', 'statusId' => 1000317],
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1000849],
'Holos' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1003003],
'Kerachole' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002618],
'Troubadour' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001934],
'Tactician' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001951],
'Shield Samba' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001826],
'Magick Barrier' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002707],
'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],
'Aquaveil' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002708, 'extraAbilityGameID' => 25861], // Personal, WHM auf Ziel
'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],
'Exaltation' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002717, 'extraAbilityGameID' => 25873], // Personal, AST auf Ziel
'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],
// ── Personal / targeted mitigation ─────────────────────────────────────
'Rampart' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 7531],
// PLD
'Hallowed Ground' => ['dr' => 100, 'buffType' => 'buff', 'extraAbilityGameID' => 30],
'Sentinel' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 17],
'Bulwark' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 22],
'Holy Sheltron' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 25746],
'Intervention' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 7382],
'Knight\'s Resolve' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002675, 'extraAbilityGameID' => 7382], // Proc von Intervention
// WAR
'Holmgang' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 43],
'Vengeance' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 44],
'Damnation' => ['dr' => 40, 'buffType' => 'buff', 'extraAbilityGameID' => 36923],
'Thrill of Battle' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 40],
'Raw Intuition' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 3551],
'Nascent Glint' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001858, 'extraAbilityGameID' => 16464], // Proc von Nascent Flash auf Ziel
'Stem the Flow' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002679, 'extraAbilityGameID' => 25751], // Proc von Bloodwhetting / Nascent Flash
// DRK
'Living Dead' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 3638],
'Shadow Wall' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 3636],
'Shadowed Vigil' => ['dr' => 40, 'buffType' => 'buff', 'extraAbilityGameID' => 36927],
'Dark Mind' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 3634],
'The Blackest Night' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 7393],
'Oblation' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 25754],
// GNB
'Superbolide' => ['dr' => 100, 'buffType' => 'buff', 'extraAbilityGameID' => 16152],
'Nebula' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 16148],
'Great Nebula' => ['dr' => 40, 'buffType' => 'buff', 'statusId' => 1003838, 'extraAbilityGameID' => 36935],
'Camouflage' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 16140],
'Heart of Stone' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 16161],
'Heart of Corundum' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002683, 'extraAbilityGameID' => 25758],
'Clarity of Corundum' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002684, 'extraAbilityGameID' => 25758], // Proc von Heart of Corundum, kann beliebiges Partymitglied treffen
// DPS
'Riddle of Earth' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 7394],
'Shade Shift' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 2241],
'Third Eye' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 7498],
'Arcane Crest' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 24404],
'Manaward' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 157],
// ── Shields ─────────────────────────────────────────────────────────────
// PLD
'Divine Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001362],
'Guardian' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003830], // FFLogs: "Guardian's Will"
'Divine Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001362, 'extraAbilityGameID' => 3540],
'Guardian' => ['dr' => 40, 'buffType' => 'shield', 'statusId' => 1003830, 'extraAbilityGameID' => 36920], // FFLogs: "Guardian's Will"
// WAR
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457],
'Bloodwhetting' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002678],
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457, 'extraAbilityGameID' => 7388],
'Bloodwhetting' => ['dr' => 10, 'buffType' => 'shield', 'statusId' => 1002678, 'extraAbilityGameID' => 25751],
'Stem the Tide' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002680, 'extraAbilityGameID' => 25751], // Proc von Bloodwhetting / Nascent Flash
// WHM
'Divine Benison' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001218],
'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903],
'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],
'Neutral Sect' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001921],
'the Spire' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003892], // FFLogs: "The Spire"
'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],
'Holosakos' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003365],
'Eukrasian Prognosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002609],
'Eukrasian Prognosis II' => ['dr' => 0, 'buffType' => 'shield'], // TODO
'Eukrasian Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002607],
'Differential Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002608],
'Haima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002612],
'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],
'Seraphic Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001917],
'Catalyze' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001918],
'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],
'Radiant Aegis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002702, 'extraAbilityGameID' => 25799],
// PCT
'Tempera Coat' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003686],
'Tempera Grassa' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003687],
'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],
'Improvised Finish' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002697, 'extraAbilityGameID' => 25789],
// ── Boss debuffs ────────────────────────────────────────────────────────
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001193],
'Feint' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001195],
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203],
'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 {
function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourceTimeline = [], array $players = [], float $ts = 0): array {
if ($buffStr === '') return [];
$result = [];
$seen = [];
@ -139,17 +188,33 @@ function resolveMitigations(string $buffStr, array $mitigIdMap): array {
$name = $mitigIdMap[$id]['name'];
if (isset($seen[$name])) continue;
$seen[$name] = true;
$result[] = [
$entry = [
'key' => $mitigIdMap[$id]['key'] ?? $name,
'name' => $name,
'dr' => $mitigIdMap[$id]['dr'],
'buffType' => $mitigIdMap[$id]['buffType'],
'extraAbilityGameID' => $mitigIdMap[$id]['extraAbilityGameID'] ?? null,
];
$source = findBuffSourcePlayer($buffSourceTimeline, $id, $ts, $players);
if ($source) $entry['sourcePlayerType'] = $source['type'];
$result[] = $entry;
}
}
return $result;
}
// Findet den Spieler der einen Buff zum Zeitpunkt $ts gecastet hat (anhand der applybuff-Timeline).
function findBuffSourcePlayer(array $sourceTimeline, int $statusId, float $ts, array $players): ?array {
$best = null;
foreach ($sourceTimeline[$statusId] ?? [] as $entry) {
if ($entry['apply'] > $ts + 200) continue; // noch nicht aktiv
if ($entry['remove'] !== null && $entry['remove'] < $ts - 200) continue; // schon abgelaufen
if ($best === null || $entry['apply'] > $best['apply']) $best = $entry;
}
if ($best === null || empty($best['sourceId'])) return null;
return $players[$best['sourceId']] ?? null;
}
// 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.
@ -161,7 +226,13 @@ function shieldsActiveAt(array $shieldTimeline, int $targetId, float $ts, array
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']];
$result[] = [
'key' => $m['key'] ?? $m['name'],
'name' => $m['name'],
'dr' => $m['dr'],
'buffType' => $m['buffType'],
'extraAbilityGameID' => $m['extraAbilityGameID'] ?? null,
];
}
break;
}
@ -212,11 +283,12 @@ foreach (MITIGATION_ABILITIES as $name => $meta) {
}
}
// statusId set for shield abilities — used to filter the buff timeline query
$shieldStatusIds = [];
// 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 ($meta['buffType'] === 'shield' && isset($meta['statusId'])) {
$shieldStatusIds[$meta['statusId']] = true;
if (isset($meta['statusId'])) {
$trackedStatusIds[$meta['statusId']] = true;
}
}
@ -276,8 +348,11 @@ for ($page = 0; $page < 10; $page++) {
// 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], ...]
$buffSourceTimeline = []; // statusId → [[apply, remove|null, sourceId], ...] — wer hat den Buff gecastet?
$statusNames = []; // statusId → localized display name from Buffs events
$statusActionIds = []; // statusId → applybuff extraAbilityGameID from FFLogs
if (!empty($shieldStatusIds)) {
if (!empty($trackedStatusIds)) {
$nextPage = $startTime;
for ($page = 0; $page < 10; $page++) {
$bfResult = fflogs_gql(<<<GQL
@ -304,11 +379,38 @@ if (!empty($shieldStatusIds)) {
$bfEv = $bfResult['data']['reportData']['report']['events'] ?? [];
foreach ($bfEv['data'] ?? [] as $ev) {
$abId = (int)($ev['abilityGameID'] ?? 0);
if (!isset($shieldStatusIds[$abId])) continue;
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;
// Source-Tracking für alle getrackten Abilities (unabhängig von buffType)
$srcId = (int)($ev['sourceID'] ?? 0);
if ($srcId > 0 && isset($players[$srcId])) {
if ($type === 'applybuff') {
$buffSourceTimeline[$abId][] = ['apply' => $ts, 'remove' => null, 'sourceId' => $srcId];
} elseif ($type === 'removebuff') {
for ($i = count($buffSourceTimeline[$abId] ?? []) - 1; $i >= 0; $i--) {
if ($buffSourceTimeline[$abId][$i]['remove'] === null) {
$buffSourceTimeline[$abId][$i]['remove'] = $ts;
break;
}
}
}
}
if (($meta['buffType'] ?? null) !== 'shield') continue;
if ($type === 'applybuff') {
$shieldTimeline[$tgtId][$abId][] = ['apply' => $ts, 'remove' => null];
@ -329,6 +431,69 @@ if (!empty($shieldStatusIds)) {
}
}
// ── 2c. Boss-Debuff-Source via Casts ───────────────────────────────────────
// dataType: Buffs liefert nur Events auf Spieler (Friendly). Reprisal/Feint/Addle
// werden auf den Boss (Hostile) angewendet und tauchen dort nicht auf.
// Lösung: Cast-Events der drei Abilities direkt abfragen — 1 GQL-Request, 3 Aliase.
$dbReprisalActionId = (int)(MITIGATION_ABILITIES['Reprisal']['extraAbilityGameID'] ?? 0);
$dbFeintActionId = (int)(MITIGATION_ABILITIES['Feint']['extraAbilityGameID'] ?? 0);
$dbAddleActionId = (int)(MITIGATION_ABILITIES['Addle']['extraAbilityGameID'] ?? 0);
$dbReprisalStatusId = (int)(MITIGATION_ABILITIES['Reprisal']['statusId'] ?? 0);
$dbFeintStatusId = (int)(MITIGATION_ABILITIES['Feint']['statusId'] ?? 0);
$dbAddleStatusId = (int)(MITIGATION_ABILITIES['Addle']['statusId'] ?? 0);
if ($dbReprisalActionId && $dbFeintActionId && $dbAddleActionId) {
$dbResult = fflogs_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
reprisal: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbReprisalActionId, startTime: $startTime, endTime: $endTime) { data }
feint: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbFeintActionId, startTime: $startTime, endTime: $endTime) { data }
addle: events(fightIDs: [$fightId], dataType: Casts, abilityID: $dbAddleActionId, startTime: $startTime, endTime: $endTime) { data }
}
}
}
GQL);
if (isset($dbResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
foreach ([
'reprisal' => ['statusId' => $dbReprisalStatusId, 'durationMs' => 10000],
'feint' => ['statusId' => $dbFeintStatusId, 'durationMs' => 10000],
'addle' => ['statusId' => $dbAddleStatusId, 'durationMs' => 10000],
] as $alias => $info) {
foreach ($dbResult['data']['reportData']['report'][$alias]['data'] ?? [] as $ev) {
if (($ev['type'] ?? '') !== 'cast') continue;
$srcId = (int)($ev['sourceID'] ?? 0);
if ($srcId <= 0 || !isset($players[$srcId]) || !$info['statusId']) continue;
$ts = (float)($ev['timestamp'] ?? 0);
$buffSourceTimeline[$info['statusId']][] = [
'apply' => $ts,
'remove' => $ts + $info['durationMs'],
'sourceId' => $srcId,
];
}
}
}
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.
@ -344,6 +509,9 @@ foreach ($allEvents as $ev) {
$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,
@ -403,6 +571,7 @@ foreach ($byAbility as $abId => $events) {
if ($current !== null) $clusters[] = $current;
}
$bossEvents = [];
$aoeEvents = [];
foreach ($clusters as $group) {
$targetCount = count($group['targets']);
@ -424,8 +593,6 @@ foreach ($clusters as $group) {
}
}
if ($targetCount < 3 && !$isHeavyTankbuster) continue;
$targets = [];
foreach ($group['targets'] as $tgtId => $tgt) {
$p = $players[$tgtId] ?? null;
@ -441,8 +608,8 @@ foreach ($clusters as $group) {
'overkill' => $tgt['overkill'],
'hp' => $tgt['hp'],
'maxHp' => $tgt['maxHp'],
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline) {
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap);
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline, $buffSourceTimeline, $players) {
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap, $buffSourceTimeline, $players, $tgt['ts']);
if ($tgt['absorbed'] > 0 && !empty($shieldTimeline)) {
$existing = [];
foreach ($mitigations as $m) {
@ -462,7 +629,7 @@ foreach ($clusters as $group) {
return $roleCmp !== 0 ? $roleCmp : strcmp($a['name'], $b['name']);
});
$aoeEvents[] = [
$bossEvent = [
'timestamp' => $group['timestamp'],
'abilityId' => $group['abilityId'],
'abilityName' => $group['abilityName'],
@ -470,11 +637,27 @@ foreach ($clusters as $group) {
'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']);
echo json_encode([
$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;

52
api/cache.php Normal file
View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
const CACHED_LOG_DIR = __DIR__ . '/../cached_logs';
const CACHED_LOG_VERSION = 'v8';
function cache_language(string $language): string {
$language = strtolower(trim($language));
return in_array($language, ['en', 'de', 'fr', 'jp'], true) ? $language : 'en';
}
function cache_report_code(string $reportCode): string {
return preg_replace('/[^a-zA-Z0-9]/', '', $reportCode);
}
function cache_key_part(string|int|float|null $value): string {
$value = (string)($value ?? '');
$value = preg_replace('/[^a-zA-Z0-9._-]/', '_', $value);
return trim($value, '_') ?: 'all';
}
function cached_log_path(string $kind, string $reportCode, string $language, array $parts = []): string {
$reportCode = cache_report_code($reportCode);
$language = cache_language($language);
$kind = cache_key_part($kind);
$safeParts = array_map('cache_key_part', $parts);
$suffix = $safeParts ? '_' . implode('_', $safeParts) : '';
return CACHED_LOG_DIR . '/' . CACHED_LOG_VERSION . '/' . $reportCode . '/' . $language . '/' . $kind . $suffix . '.json';
}
function read_cached_log(string $kind, string $reportCode, string $language, array $parts = []): ?string {
$path = cached_log_path($kind, $reportCode, $language, $parts);
if (!is_file($path)) return null;
$body = file_get_contents($path);
if ($body === false || trim($body) === '') return null;
return $body;
}
function write_cached_log(string $kind, string $reportCode, string $language, array $parts, string $body): void {
$decoded = json_decode($body, true);
if (!is_array($decoded) || isset($decoded['error'], $decoded['errors'], $decoded['reauth'])) return;
$path = cached_log_path($kind, $reportCode, $language, $parts);
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) return;
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $body, LOCK_EX) === false) return;
rename($tmp, $path);
}

View File

@ -87,13 +87,20 @@ if ($playerName !== '') {
}
}
// Fetch events
// Fetch events + masterData abilities (incl. type)
$includeResources = in_array($dataType, ['DamageTaken', 'DamageDone']) ? 'includeResources: true,' : '';
$result = dbg_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
masterData(translate: false) {
abilities {
gameID
name
type
}
}
events(
fightIDs: [$fightId],
dataType: $dataType,
@ -111,7 +118,14 @@ GQL);
$events = $result['data']['reportData']['report']['events']['data'] ?? [];
// Build abilityGameID → { name, type } lookup
$abilityMeta = [];
foreach ($result['data']['reportData']['report']['masterData']['abilities'] ?? [] as $ab) {
$abilityMeta[(int)$ab['gameID']] = ['name' => $ab['name'], 'type' => $ab['type'] ?? null];
}
// Filter by raw event type, player (source OR target), then apply limit
// Enrich each event with ability meta (name + type) from masterData
$filtered = [];
foreach ($events as $ev) {
if ($eventType !== '' && ($ev['type'] ?? '') !== $eventType) continue;
@ -121,6 +135,11 @@ foreach ($events as $ev) {
$tgtId = (int)($ev['targetID'] ?? -1);
if (!in_array($srcId, $playerIds) && !in_array($tgtId, $playerIds)) continue;
}
// Inject ability meta so ability.type is visible directly on the event
$gid = (int)($ev['abilityGameID'] ?? 0);
if ($gid && isset($abilityMeta[$gid])) {
$ev['_ability'] = $abilityMeta[$gid];
}
$filtered[] = $ev;
if (count($filtered) >= $limit) break;
}
@ -131,11 +150,9 @@ echo json_encode([
'ability_id' => $abilityId ?: null,
'player_name' => $playerName ?: null,
'player_ids' => $playerIds ?: null,
'time_range' => [
'from_ms' => (int)$queryStart,
'to_ms' => (int)$queryEnd,
],
'time_range' => ['from_ms' => (int)$queryStart, 'to_ms' => (int)$queryEnd],
'total_before_limit' => count($events),
'count' => count($filtered),
'ability_meta' => $abilityMeta, // vollständige Lookup-Tabelle: gameID → {name, type}
'events' => $filtered,
], JSON_PRETTY_PRINT);

View File

@ -1,6 +1,7 @@
<?php
ini_set('display_errors', '0');
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/cache.php';
session_start_safe();
header('Content-Type: application/json');
@ -21,6 +22,12 @@ if (strlen($reportCode) < 1) {
exit;
}
$cached = read_cached_log('fight', $reportCode, $language);
if ($cached !== null) {
echo $cached;
exit;
}
if (empty($_SESSION['access_token'])) {
http_response_code(401);
echo json_encode(['error' => 'Not authenticated', 'reauth' => true]);
@ -42,6 +49,7 @@ query GetReportData($reportCode: String!) {
endTime
fights {
id
encounterID
name
startTime
endTime
@ -75,9 +83,7 @@ function localized_graphql_uri(string $language): string {
return preg_replace('#https://[^/]+#', 'https://' . $host, GRAPHQL_URI);
}
// Fight names must be stable regardless of language — always use the English endpoint.
// Localization only matters for ability/player names in analysis.php.
$ch = curl_init(GRAPHQL_URI);
$ch = curl_init(localized_graphql_uri($language));
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
@ -85,12 +91,14 @@ curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $_SESSION['access_token'],
'Accept-Language: ' . ($language === 'jp' ? 'ja' : $language),
],
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
]);
$body = curl_exec($ch);
$httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
http_response_code(502);
@ -112,5 +120,8 @@ if ($httpStatus === 401) {
}
http_response_code($httpStatus === 200 ? 200 : $httpStatus);
if ($httpStatus === 200) {
write_cached_log('fight', $reportCode, $language, [], $body);
}
echo $body;
exit;

112
api/players.php Normal file
View File

@ -0,0 +1,112 @@
<?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);
if (!$reportCode || !$fightId || !$endTime) {
http_response_code(400);
echo json_encode(['error' => 'Missing params']);
exit;
}
$cacheParts = [$fightId, (int)$startTime, (int)$endTime];
$cached = read_cached_log('players', $reportCode, 'en', $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 pl_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];
}
// Single query: playerDetails + first page of DamageTaken with resources to get maxHp
$result = pl_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
playerDetails(fightIDs: [$fightId])
events(
fightIDs: [$fightId],
dataType: DamageTaken,
startTime: $startTime,
endTime: $endTime,
includeResources: true
) {
data
}
}
}
}
GQL);
if (isset($result['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
if (isset($result['_curl_error'])) { http_response_code(502); echo json_encode(['error' => $result['_curl_error']]); exit; }
// Parse player details: id → {name, type, role, maxHp}
$pdRaw = $result['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']] = [
'name' => $p['name'],
'type' => $p['type'] ?? '',
'role' => $role,
'maxHp' => 0,
];
}
}
// Extract maxHp from first damage events (skip DoT ticks — they may lack resources)
foreach ($result['data']['reportData']['report']['events']['data'] ?? [] as $ev) {
if ($ev['tick'] ?? false) continue;
$tid = (int)($ev['targetID'] ?? 0);
$maxHp = (int)($ev['targetResources']['maxHitPoints'] ?? 0);
if (isset($players[$tid]) && $players[$tid]['maxHp'] === 0 && $maxHp > 0) {
$players[$tid]['maxHp'] = $maxHp;
}
}
$response = json_encode(['players' => array_values($players)]);
if ($response === false) {
http_response_code(500);
echo json_encode(['error' => 'Could not encode players response']);
exit;
}
write_cached_log('players', $reportCode, 'en', $cacheParts, $response);
echo $response;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

818
assets/jsons/Action.json Normal file
View File

@ -0,0 +1,818 @@
{
"17": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Sentinel",
"de": "Sentinel",
"fr": "Sentinelle",
"jp": "センチネル"
},
"icon": "https://xivapi.com/i/000000/000151_hr1.png",
"shield": null
},
"22": {
"cast": 0,
"recast": 900,
"names": {
"en": "Bulwark",
"de": "Bollwerk",
"fr": "Forteresse",
"jp": "ブルワーク"
},
"icon": "https://xivapi.com/i/000000/000167_hr1.png",
"shield": null
},
"30": {
"cast": 0,
"recast": 4200,
"names": {
"en": "Hallowed Ground",
"de": "Heiliger Boden",
"fr": "Invincible",
"jp": "インビンシブル"
},
"icon": "https://xivapi.com/i/002000/002502_hr1.png",
"shield": null
},
"40": {
"cast": 0,
"recast": 900,
"names": {
"en": "Thrill of Battle",
"de": "Kampfrausch",
"fr": "Frisson de la bataille",
"jp": "スリル・オブ・バトル"
},
"icon": "https://xivapi.com/i/000000/000263_hr1.png",
"shield": null
},
"43": {
"cast": 0,
"recast": 2400,
"names": {
"en": "Holmgang",
"de": "Holmgang",
"fr": "Holmgang",
"jp": "ホルムギャング"
},
"icon": "https://xivapi.com/i/000000/000266_hr1.png",
"shield": null
},
"44": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Vengeance",
"de": "Rachsucht",
"fr": "Représailles",
"jp": "ヴェンジェンス"
},
"icon": "https://xivapi.com/i/000000/000267_hr1.png",
"shield": null
},
"157": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Manaward",
"de": "Mana-Schild",
"fr": "Barrière de mana",
"jp": "マバリア"
},
"icon": "https://xivapi.com/i/000000/000463_hr1.png",
"shield": "30% max HP"
},
"185": {
"cast": 20,
"recast": 25,
"names": {
"en": "Adloquium",
"de": "Adloquium",
"fr": "Traité du réconfort",
"jp": "鼓舞激励の策"
},
"icon": "https://xivapi.com/i/002000/002801_hr1.png",
"shield": null
},
"188": {
"cast": 0,
"recast": 300,
"names": {
"en": "Sacred Soil",
"de": "Geweihte Erde",
"fr": "Dogme de survie",
"jp": "野戦治療の陣"
},
"icon": "https://xivapi.com/i/002000/002804_hr1.png",
"shield": null
},
"2241": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Shade Shift",
"de": "Superkniff",
"fr": "Décalage d'ombre",
"jp": "残影"
},
"icon": "https://xivapi.com/i/000000/000607_hr1.png",
"shield": "20% max HP"
},
"3540": {
"cast": 0,
"recast": 900,
"names": {
"en": "Divine Veil",
"de": "Heiliger Quell",
"fr": "Voile divin",
"jp": "ディヴァインヴェール"
},
"icon": "https://xivapi.com/i/002000/002508_hr1.png",
"shield": "10% max HP"
},
"3551": {
"cast": 0,
"recast": 250,
"names": {
"en": "Raw Intuition",
"de": "Urinstinkt",
"fr": "Intuition pure",
"jp": "原初の直感"
},
"icon": "https://xivapi.com/i/002000/002559_hr1.png",
"shield": null
},
"3613": {
"cast": 0,
"recast": 600,
"names": {
"en": "Collective Unconscious",
"de": "Numinosum",
"fr": "Inconscient collectif",
"jp": "運命の輪"
},
"icon": "https://xivapi.com/i/003000/003140_hr1.png",
"shield": null
},
"3634": {
"cast": 0,
"recast": 600,
"names": {
"en": "Dark Mind",
"de": "Dunkler Geist",
"fr": "Esprit ténébreux",
"jp": "ダークマインド"
},
"icon": "https://xivapi.com/i/003000/003076_hr1.png",
"shield": null
},
"3636": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Shadow Wall",
"de": "Schattenwand",
"fr": "Mur d'ombre",
"jp": "シャドウウォール"
},
"icon": "https://xivapi.com/i/003000/003075_hr1.png",
"shield": null
},
"3638": {
"cast": 0,
"recast": 3000,
"names": {
"en": "Living Dead",
"de": "Totenerweckung",
"fr": "Mort-vivant",
"jp": "リビングデッド"
},
"icon": "https://xivapi.com/i/003000/003077_hr1.png",
"shield": null
},
"7382": {
"cast": 0,
"recast": 100,
"names": {
"en": "Intervention",
"de": "Intervention",
"fr": "Intervention",
"jp": "インターベンション"
},
"icon": "https://xivapi.com/i/002000/002512_hr1.png",
"shield": null
},
"7385": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Passage of Arms",
"de": "Waffengang",
"fr": "Passe d'armes",
"jp": "パッセージ・オブ・アームズ"
},
"icon": "https://xivapi.com/i/002000/002515_hr1.png",
"shield": null
},
"7388": {
"cast": 0,
"recast": 900,
"names": {
"en": "Shake It Off",
"de": "Abschütteln",
"fr": "Débarrassage",
"jp": "シェイクオフ"
},
"icon": "https://xivapi.com/i/002000/002563_hr1.png",
"shield": "15% max HP"
},
"7393": {
"cast": 0,
"recast": 150,
"names": {
"en": "The Blackest Night",
"de": "Schwärzeste Nacht",
"fr": "Nuit noirissime",
"jp": "ブラックナイト"
},
"icon": "https://xivapi.com/i/003000/003081_hr1.png",
"shield": "25% max HP"
},
"7394": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Riddle of Earth",
"de": "Steinernes Enigma",
"fr": "Énigme de la terre",
"jp": "金剛の極意"
},
"icon": "https://xivapi.com/i/002000/002537_hr1.png",
"shield": null
},
"7405": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Troubadour",
"de": "Troubadour",
"fr": "Troubadour",
"jp": "トルバドゥール"
},
"icon": "https://xivapi.com/i/002000/002612_hr1.png",
"shield": null
},
"7432": {
"cast": 0,
"recast": 300,
"names": {
"en": "Divine Benison",
"de": "Göttlicher Segen",
"fr": "Faveur divine",
"jp": "ディヴァインベニゾン"
},
"icon": "https://xivapi.com/i/002000/002638_hr1.png",
"shield": "500 potency"
},
"7498": {
"cast": 0,
"recast": 150,
"names": {
"en": "Third Eye",
"de": "Drittes Auge",
"fr": "Troisième œil",
"jp": "心眼"
},
"icon": "https://xivapi.com/i/003000/003153_hr1.png",
"shield": null
},
"7531": {
"cast": 0,
"recast": 900,
"names": {
"en": "Rampart",
"de": "Schutzwall",
"fr": "Rempart",
"jp": "ランパート"
},
"icon": "https://xivapi.com/i/000000/000801_hr1.png",
"shield": null
},
"7535": {
"cast": 0,
"recast": 600,
"names": {
"en": "Reprisal",
"de": "Reflexion",
"fr": "Rétorsion",
"jp": "リプライザル"
},
"icon": "https://xivapi.com/i/000000/000806_hr1.png",
"shield": null
},
"7549": {
"cast": 0,
"recast": 900,
"names": {
"en": "Feint",
"de": "Zermürben",
"fr": "Restreinte",
"jp": "牽制"
},
"icon": "https://xivapi.com/i/000000/000828_hr1.png",
"shield": null
},
"7560": {
"cast": 0,
"recast": 900,
"names": {
"en": "Addle",
"de": "Stumpfsinn",
"fr": "Embrouillement",
"jp": "アドル"
},
"icon": "https://xivapi.com/i/000000/000861_hr1.png",
"shield": null
},
"16012": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Shield Samba",
"de": "Schildsamba",
"fr": "Samba protectrice",
"jp": "守りのサンバ"
},
"icon": "https://xivapi.com/i/003000/003469_hr1.png",
"shield": null
},
"16140": {
"cast": 0,
"recast": 900,
"names": {
"en": "Camouflage",
"de": "Camouflage",
"fr": "Camouflage",
"jp": "カモフラージュ"
},
"icon": "https://xivapi.com/i/003000/003404_hr1.png",
"shield": null
},
"16148": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Nebula",
"de": "Nebula",
"fr": "Nébuleuse",
"jp": "ネビュラ"
},
"icon": "https://xivapi.com/i/003000/003412_hr1.png",
"shield": null
},
"16152": {
"cast": 0,
"recast": 3600,
"names": {
"en": "Superbolide",
"de": "Meteoritenfall",
"fr": "Bolide",
"jp": "ボーライド"
},
"icon": "https://xivapi.com/i/003000/003416_hr1.png",
"shield": null
},
"16160": {
"cast": 0,
"recast": 900,
"names": {
"en": "Heart of Light",
"de": "Wackeres Herz",
"fr": "Cœur de Lumière",
"jp": "ハート・オブ・ライト"
},
"icon": "https://xivapi.com/i/003000/003424_hr1.png",
"shield": null
},
"16161": {
"cast": 0,
"recast": 250,
"names": {
"en": "Heart of Stone",
"de": "Steinernes Herz",
"fr": "Cœur de pierre",
"jp": "ハート・オブ・ストーン"
},
"icon": "https://xivapi.com/i/003000/003425_hr1.png",
"shield": null
},
"16471": {
"cast": 0,
"recast": 900,
"names": {
"en": "Dark Missionary",
"de": "Dunkler Bote",
"fr": "Missionnaire des Ténèbres",
"jp": "ダークミッショナリー"
},
"icon": "https://xivapi.com/i/003000/003087_hr1.png",
"shield": null
},
"16536": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Temperance",
"de": "Linderung",
"fr": "Tempérance",
"jp": "テンパランス"
},
"icon": "https://xivapi.com/i/002000/002645_hr1.png",
"shield": null
},
"16538": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Fey Illumination",
"de": "Illumination",
"fr": "Illumination féérique",
"jp": "フェイイルミネーション"
},
"icon": "https://xivapi.com/i/002000/002853_hr1.png",
"shield": null
},
"16548": {
"cast": 0,
"recast": 30,
"names": {
"en": "Seraphic Veil",
"de": "Schleier der Seraphim",
"fr": "Voile séraphique",
"jp": "セラフィックヴェール"
},
"icon": "https://xivapi.com/i/002000/002847_hr1.png",
"shield": null
},
"16556": {
"cast": 0,
"recast": 300,
"names": {
"en": "Celestial Intersection",
"de": "Kongruenz",
"fr": "Rencontre céleste",
"jp": "星天交差"
},
"icon": "https://xivapi.com/i/003000/003556_hr1.png",
"shield": "200% of HP restored"
},
"16559": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Neutral Sect",
"de": "Neutral",
"fr": "Adepte de la neutralité",
"jp": "ニュートラルセクト"
},
"icon": "https://xivapi.com/i/003000/003552_hr1.png",
"shield": "250% of HP restored"
},
"16889": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Tactician",
"de": "Taktiker",
"fr": "Tacticien",
"jp": "タクティシャン"
},
"icon": "https://xivapi.com/i/003000/003040_hr1.png",
"shield": null
},
"24291": {
"cast": 0,
"recast": 15,
"names": {
"en": "Eukrasian Diagnosis",
"de": "Eukratische Diagnose",
"fr": "Diagnosis eucrasique",
"jp": "エウクラシア・ディアグノシス"
},
"icon": "https://xivapi.com/i/003000/003659_hr1.png",
"shield": null
},
"24292": {
"cast": 0,
"recast": 15,
"names": {
"en": "Eukrasian Prognosis",
"de": "Eukratische Prognose",
"fr": "Prognosis eucrasique",
"jp": "エウクラシア・プログノシス"
},
"icon": "https://xivapi.com/i/003000/003660_hr1.png",
"shield": "320% of HP restored"
},
"24298": {
"cast": 0,
"recast": 300,
"names": {
"en": "Kerachole",
"de": "Kerachole",
"fr": "Kerachole",
"jp": "ケーラコレ"
},
"icon": "https://xivapi.com/i/003000/003666_hr1.png",
"shield": null
},
"24305": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Haima",
"de": "Haima",
"fr": "Haima",
"jp": "ハイマ"
},
"icon": "https://xivapi.com/i/003000/003673_hr1.png",
"shield": "300 potency"
},
"24310": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Holos",
"de": "Holos",
"fr": "Holos",
"jp": "ホーリズム"
},
"icon": "https://xivapi.com/i/003000/003678_hr1.png",
"shield": null
},
"24311": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Panhaima",
"de": "Panhaima",
"fr": "Panhaima",
"jp": "パンハイマ"
},
"icon": "https://xivapi.com/i/003000/003679_hr1.png",
"shield": "200 potency"
},
"24404": {
"cast": 0,
"recast": 300,
"names": {
"en": "Arcane Crest",
"de": "Arkanes Wappen",
"fr": "Blason arcanique",
"jp": "アルケインクレスト"
},
"icon": "https://xivapi.com/i/003000/003632_hr1.png",
"shield": "10% max HP"
},
"25746": {
"cast": 0,
"recast": 50,
"names": {
"en": "Holy Sheltron",
"de": "Heiliges Schiltron",
"fr": "Schiltron sacré",
"jp": "ホーリーシェルトロン"
},
"icon": "https://xivapi.com/i/002000/002950_hr1.png",
"shield": null
},
"25751": {
"cast": 0,
"recast": 250,
"names": {
"en": "Bloodwhetting",
"de": "Urimpuls",
"fr": "Intuition fougueuse",
"jp": "原初の血気"
},
"icon": "https://xivapi.com/i/002000/002569_hr1.png",
"shield": "400 potency"
},
"25754": {
"cast": 0,
"recast": 600,
"names": {
"en": "Oblation",
"de": "Opfergabe",
"fr": "Oblation",
"jp": "オブレーション"
},
"icon": "https://xivapi.com/i/003000/003089_hr1.png",
"shield": null
},
"25758": {
"cast": 0,
"recast": 250,
"names": {
"en": "Heart of Corundum",
"de": "Herz des Korunds",
"fr": "Cœur de corindon",
"jp": "ハート・オブ・コランダム"
},
"icon": "https://xivapi.com/i/003000/003430_hr1.png",
"shield": null
},
"25789": {
"cast": 0,
"recast": 15,
"names": {
"en": "Improvised Finish",
"de": "Improvisiertes Finale",
"fr": "Final improvisé",
"jp": "インプロビゼーション・フィニッシュ"
},
"icon": "https://xivapi.com/i/003000/003479_hr1.png",
"shield": null
},
"25799": {
"cast": 0,
"recast": 600,
"names": {
"en": "Radiant Aegis",
"de": "Schimmerschild",
"fr": "Égide rayonnante",
"jp": "守りの光"
},
"icon": "https://xivapi.com/i/002000/002750_hr1.png",
"shield": "20% max HP"
},
"25857": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Magick Barrier",
"de": "Magiebarriere",
"fr": "Barrière anti-magie",
"jp": "バマジク"
},
"icon": "https://xivapi.com/i/003000/003237_hr1.png",
"shield": null
},
"25868": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Expedient",
"de": "Sturm und Drang",
"fr": "Thèse fluidique",
"jp": "疾風怒濤の計"
},
"icon": "https://xivapi.com/i/002000/002878_hr1.png",
"shield": null
},
"34685": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Tempera Coat",
"de": "Tempera-Schicht",
"fr": "Enduit a tempera",
"jp": "テンペラコート"
},
"icon": "https://xivapi.com/i/003000/003835_hr1.png",
"shield": "20% max HP"
},
"34686": {
"cast": 0,
"recast": 10,
"names": {
"en": "Tempera Grassa",
"de": "Fette Tempera",
"fr": "Tempera grassa",
"jp": "テンペラグラッサ"
},
"icon": "https://xivapi.com/i/003000/003836_hr1.png",
"shield": "10% max HP"
},
"36920": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Guardian",
"de": "Heilige Wacht",
"fr": "Garde extrême",
"jp": "エクストリームガード"
},
"icon": "https://xivapi.com/i/002000/002524_hr1.png",
"shield": null
},
"36923": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Damnation",
"de": "Verdammnis",
"fr": "Damnation",
"jp": "ダムネーション"
},
"icon": "https://xivapi.com/i/002000/002573_hr1.png",
"shield": null
},
"36927": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Shadowed Vigil",
"de": "Schattenwacht",
"fr": "Vigile ténébreux",
"jp": "シャドウヴィジル"
},
"icon": "https://xivapi.com/i/003000/003094_hr1.png",
"shield": null
},
"36935": {
"cast": 0,
"recast": 1200,
"names": {
"en": "Great Nebula",
"de": "Große Nebula",
"fr": "Grande nébuleuse",
"jp": "グレートネビュラ"
},
"icon": "https://xivapi.com/i/003000/003435_hr1.png",
"shield": null
},
"37011": {
"cast": 0,
"recast": 10,
"names": {
"en": "Divine Caress",
"de": "Göttliche Umarmung",
"fr": "Caresse divine",
"jp": "ディヴァインカレス"
},
"icon": "https://xivapi.com/i/002000/002128_hr1.png",
"shield": "400 potency"
},
"37025": {
"cast": 0,
"recast": 10,
"names": {
"en": "the Spire",
"de": "Turm",
"fr": "La Tour",
"jp": "ビエルゴの塔"
},
"icon": "https://xivapi.com/i/003000/003115_hr1.png",
"shield": "400 potency"
},
"37034": {
"cast": 0,
"recast": 15,
"names": {
"en": "Eukrasian Prognosis II",
"de": "Eukratische Prognose II",
"fr": "Prognosis eucrasique II",
"jp": "エウクラシア・プログシスII"
},
"icon": "https://xivapi.com/i/003000/003689_hr1.png",
"shield": "360% of HP restored"
},
"16464": {
"cast": 0,
"recast": 250,
"names": {
"en": "Nascent Flash",
"de": "Urflackern",
"fr": "Exaltation naissante",
"jp": "原初の猛り"
},
"icon": null,
"shield": null
},
"25861": {
"cast": 0,
"recast": 600,
"names": {
"en": "Aquaveil",
"de": "Wasserschleier",
"fr": "Aquavoile",
"jp": "アクアヴェール"
},
"icon": null,
"shield": null
},
"25873": {
"cast": 0,
"recast": 600,
"names": {
"en": "Exaltation",
"de": "Exaltation",
"fr": "Exaltation",
"jp": "エクザルテーション"
},
"icon": null,
"shield": null
}
}

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ $state = bin2hex(random_bytes(16));
$_SESSION['pkce_verifier'] = $verifier;
$_SESSION['oauth_state'] = $state;
$_SESSION['oauth_return'] = null;
$_SESSION['oauth_return'] = safe_return_path($_GET['return'] ?? null);
$params = http_build_query([
'response_type' => 'code',

View File

@ -1,11 +1,51 @@
<?php
define('DEV_MODE', true); // set to false in production
define('CLIENT_ID', 'a1d27cba-b7f8-48dd-aefd-4697b457cc67');
define('REDIRECT_URI', 'http://localhost:8080/auth/callback.php');
define('AUTHORIZE_URI','https://www.fflogs.com/oauth/authorize');
define('TOKEN_URI', 'https://www.fflogs.com/oauth/token');
define('GRAPHQL_URI', 'https://www.fflogs.com/api/v2/user');
function load_env_file(string $path): void {
if (!is_file($path)) return;
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) continue;
[$key, $value] = array_pad(explode('=', $line, 2), 2, '');
$key = trim($key);
if ($key === '') continue;
$value = trim($value);
if (
strlen($value) >= 2
&& (($value[0] === '"' && $value[-1] === '"') || ($value[0] === "'" && $value[-1] === "'"))
) {
$value = substr($value, 1, -1);
}
$_ENV[$key] = $value;
putenv($key . '=' . $value);
}
}
function env_value(string $key, ?string $default = null): string {
$value = $_ENV[$key] ?? getenv($key);
if ($value === false || $value === null || $value === '') {
if ($default !== null) return $default;
throw new RuntimeException('Missing required environment value: ' . $key);
}
return (string)$value;
}
function env_bool(string $key, bool $default = false): bool {
$value = strtolower(env_value($key, $default ? 'true' : 'false'));
return in_array($value, ['1', 'true', 'yes', 'on'], true);
}
load_env_file(__DIR__ . '/.env');
define('DEV_MODE', env_bool('DEV_MODE'));
define('CLIENT_ID', env_value('CLIENT_ID'));
define('REDIRECT_URI', env_value('REDIRECT_URI'));
define('AUTHORIZE_URI', env_value('AUTHORIZE_URI'));
define('TOKEN_URI', env_value('TOKEN_URI'));
define('GRAPHQL_URI', env_value('GRAPHQL_URI'));
function session_start_safe(): void {
if (session_status() === PHP_SESSION_NONE) {
@ -19,3 +59,38 @@ function session_start_safe(): void {
session_start();
}
}
function default_return_path(): string {
$script = str_replace('\\', '/', $_SERVER['SCRIPT_NAME'] ?? '/index.php');
$base = rtrim(dirname(dirname($script)), '/');
return ($base === '' ? '' : $base) . '/index.php';
}
function safe_return_path(?string $value): string {
$value = trim((string)$value);
if ($value === '') return default_return_path();
$parts = parse_url($value);
if ($parts === false) return default_return_path();
if (isset($parts['host'])) {
$currentHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
if (strtolower($parts['host']) !== $currentHost) return default_return_path();
} elseif (str_starts_with($value, '//')) {
return default_return_path();
}
$path = $parts['path'] ?? '';
if ($path === '') $path = default_return_path();
if ($path[0] !== '/') $path = '/' . ltrim($path, '/');
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
return $path . $query;
}
function current_return_path(): string {
return safe_return_path($_SERVER['REQUEST_URI'] ?? null);
}
function auth_start_href(?string $returnPath = null): string {
return 'auth/start.php?return=' . rawurlencode($returnPath ?? current_return_path());
}

View File

@ -80,6 +80,30 @@ select option { background: var(--bg2); }
.btn-sm { padding: 5px 13px; font-size: 13px; }
/* ── Export choice dropdown ─────────────────────────────────────────────────── */
.export-choice-menu {
position: fixed;
z-index: 1000;
background: var(--bg2);
border: 1px solid var(--bg3);
border-radius: var(--r);
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
.export-choice-item {
display: block;
width: 100%;
padding: 9px 18px;
background: transparent;
border: none;
color: var(--t1);
font-size: 13px;
text-align: left;
cursor: pointer;
white-space: nowrap;
}
.export-choice-item:hover { background: var(--bg3); color: var(--gold); }
/* ── Stats row ──────────────────────────────────────────────────────────────── */
.stats-row {
display: flex;

View File

@ -4,6 +4,12 @@
grid-template-columns: 280px 1fr;
gap: 16px;
align-items: start;
min-width: 0;
}
#plan-detail-panel,
#plan-content {
min-width: 0;
}
/* ── Plan Sidebar ────────────────────────────────────────────────────────────── */
@ -159,6 +165,63 @@
.job-slot--healer select { border-left-color: var(--green); }
.job-slot--dps select { border-left-color: rgba(200,168,75,.5); }
/* ── Job Slot Player Names ───────────────────────────────────────────────────── */
.job-slot-name {
font-size: 11px;
color: var(--t2);
text-align: center;
margin-top: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* ── Name Import Modal ───────────────────────────────────────────────────────── */
.name-import-input-row {
display: flex;
gap: 8px;
}
.name-import-input-row input { flex: 1; }
.name-import-preview {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--r);
overflow: hidden;
margin-bottom: 4px;
max-height: 300px;
overflow-y: auto;
}
.name-import-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-bottom: 1px solid var(--border);
}
.name-import-row:last-child { border-bottom: none; }
.name-import-name {
font-size: 14px;
color: var(--t1);
flex: 1;
}
.name-import-name--none {
font-size: 13px;
color: var(--t3);
font-style: italic;
flex: 1;
}
.name-import-disambig {
flex: 1;
font-size: 13px;
padding: 3px 6px;
}
/* ── Mechanic Cards ──────────────────────────────────────────────────────────── */
.mechanic-card {
display: grid;
@ -222,6 +285,16 @@
font-size: 13px;
color: var(--t2);
}
.mechanic-avg-hp {
font-size: 12px;
color: var(--t3);
margin-left: 6px;
}
.mechanic-mitig-row { margin-top: -2px; display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; }
.mechanic-mitig-shield { font-size: 12px; color: var(--t3); }
.mechanic-mitig-val { font-weight: 500; }
.mechanic-mitig--ok { color: var(--green); }
.mechanic-mitig--risk { color: var(--red); }
.mechanic-assignments {
display: flex;
@ -250,6 +323,7 @@
.badge-assign-buff { background: rgba(200,168,75,.08); border-color: rgba(200,168,75,.4); color: var(--gold); }
.badge-assign-debuff { background: rgba(224,92,92,.08); border-color: rgba(224,92,92,.4); color: var(--red); }
.badge-assign-shield { background: rgba(74,158,255,.08); border-color: rgba(74,158,255,.4); color: var(--blue); }
.badge-assign-personal { background: rgba(177,112,255,.08); border-color: rgba(177,112,255,.4); color: #dbc7ff; }
.badge-assign--missing-job {
border-style: dashed;
@ -415,6 +489,7 @@
.ability-chip.badge-assign-buff.ability-chip--active { background: rgba(200,168,75,.18); border-color: rgba(200,168,75,.6); color: var(--gold); }
.ability-chip.badge-assign-debuff.ability-chip--active { background: rgba(224,92,92,.18); border-color: rgba(224,92,92,.6); color: var(--red); }
.ability-chip.badge-assign-shield.ability-chip--active { background: rgba(74,158,255,.18); border-color: rgba(74,158,255,.6); color: var(--blue); }
.ability-chip.badge-assign-personal.ability-chip--active { background: rgba(177,112,255,.18); border-color: rgba(177,112,255,.6); color: #dbc7ff; }
.ability-chip--other-job { opacity: 0.45; }
@ -453,9 +528,9 @@
/* ── Info Panel ──────────────────────────────────────────────────────────────── */
.planner-info-panel {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border);
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.info-section { margin-bottom: 12px; }
@ -490,6 +565,7 @@
.info-legend-dot--buff { background: rgba(200,168,75,.8); }
.info-legend-dot--debuff { background: var(--red); }
.info-legend-dot--shield { background: var(--blue); }
.info-legend-dot--personal { background: rgba(177,112,255,.9); }
.info-legend-label {
font-size: 12px;
@ -505,6 +581,38 @@
.info-warning--job { color: var(--t2); }
.info-warning--missing { color: var(--red); }
.info-section--extra { margin-top: 12px; }
.info-extra-row {
display: flex;
align-items: center;
gap: 8px;
}
.info-extra-label {
font-size: 12px;
color: var(--t2);
flex: 1;
}
.info-extra-input-wrap {
display: flex;
align-items: center;
gap: 4px;
}
.info-extra-input {
width: 52px;
font-size: 13px;
padding: 3px 6px;
text-align: right;
}
.info-extra-unit {
font-size: 12px;
color: var(--t3);
}
/* ── Folder Sidebar ──────────────────────────────────────────────────────────── */
.folder-section { margin-bottom: 2px; }
@ -597,3 +705,645 @@
}
.folder-picker-option:hover { background: var(--bg2); color: var(--t1); }
.folder-picker-option.active { color: var(--gold); }
/* ── Planner Timeline ───────────────────────────────────────────────────────── */
.timeline-hint {
font-size: 12px;
color: var(--t3);
margin-bottom: 8px;
}
.timeline-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.timeline-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: var(--r);
background: rgba(255,255,255,.025);
color: var(--t2);
font-size: 12px;
cursor: pointer;
user-select: none;
}
.timeline-toggle:hover {
border-color: var(--borderem);
color: var(--t1);
}
.timeline-toggle input {
width: auto !important;
min-width: 0 !important;
margin: 0;
}
.timeline-empty,
.timeline-settings-empty {
font-size: 13px;
color: var(--t3);
padding: 12px 0;
}
.timeline-scroll {
overflow-x: auto;
overflow-y: hidden;
border: 1px solid var(--border);
background: var(--bg1);
scrollbar-color: var(--border) var(--bg1);
scrollbar-width: thin;
max-width: 100%;
width: 100%;
cursor: grab;
user-select: none;
}
.timeline-scroll--dragging {
cursor: grabbing;
}
.timeline-scroll::-webkit-scrollbar { height: 6px; }
.timeline-scroll::-webkit-scrollbar-track { background: var(--bg1); }
.timeline-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.timeline-scroll::-webkit-scrollbar-thumb:hover { background: var(--t3); }
.timeline-grid {
width: calc(180px + var(--timeline-width));
display: grid;
grid-template-columns: 180px var(--timeline-width);
}
.timeline-row,
.timeline-axis {
display: grid;
grid-template-columns: 180px var(--timeline-width);
min-height: 38px;
grid-column: 1 / -1;
}
.timeline-row-label {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
background: var(--bgcard);
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
font-size: 12px;
color: var(--t2);
min-width: 0;
position: sticky;
left: 0;
z-index: 8;
box-shadow: 8px 0 12px rgba(0,0,0,.18);
}
.timeline-boss-row {
min-height: var(--boss-row-height, 52px);
}
.timeline-boss-row .timeline-row-label {
color: var(--gold);
font-weight: 600;
z-index: 9;
}
.timeline-boss-label {
border-top: none;
border-left: none;
border-radius: 0;
cursor: pointer;
font: inherit;
text-align: left;
width: 100%;
}
.timeline-boss-label:hover {
background: rgba(200,168,75,.08);
}
.timeline-track,
.timeline-axis-track {
position: relative;
min-height: inherit;
border-bottom: 1px solid var(--border);
background:
repeating-linear-gradient(
to right,
transparent 0,
transparent 79px,
rgba(255,255,255,0.07) 80px
);
}
.timeline-hit-line {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
transform: translateX(-50%);
background: rgba(200,168,75,.38);
pointer-events: none;
z-index: 1;
}
.timeline-hit-line--tankbuster {
width: 2px;
background: rgba(177,112,255,.62);
}
.timeline-player-row .timeline-track {
background-color: rgba(255,255,255,0.015);
}
.timeline-job {
width: 36px;
text-align: center;
border-radius: 3px;
padding: 2px 0;
font-size: 11px;
flex-shrink: 0;
}
.timeline-player-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-row-ability {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.timeline-row-ability-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
border-radius: 2px;
}
.timeline-row-ability-name {
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-player-row--job-start .timeline-row-label,
.timeline-player-row--job-start .timeline-track {
border-top: 1px solid var(--border);
}
.timeline-player-row--drop-ok .timeline-track {
background-color: rgba(88,180,116,.08);
}
.timeline-player-row--drop-bad .timeline-track {
background-color: rgba(224,92,92,.08);
}
.timeline-boss-action {
position: absolute;
top: 8px;
transform: translateX(-50%);
max-width: 150px;
padding: 5px 8px;
border: 1px solid rgba(224,92,92,.35);
border-radius: var(--r);
background: rgba(224,92,92,.14);
color: var(--t1);
font-size: 12px;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
z-index: 3;
}
.timeline-boss-action:hover {
border-color: rgba(224,92,92,.7);
background: rgba(224,92,92,.22);
}
.timeline-boss-action--tankbuster {
border-color: rgba(177,112,255,.55);
background: rgba(177,112,255,.18);
color: #dbc7ff;
}
.timeline-boss-action--tankbuster:hover {
border-color: rgba(177,112,255,.85);
background: rgba(177,112,255,.28);
}
.timeline-mitigation {
position: absolute;
top: 6px;
width: var(--cd-width);
min-width: 28px;
height: 26px;
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px;
border: 1px solid var(--borderem);
border-radius: var(--r);
background: var(--bg2);
color: var(--t1);
font-size: 11px;
cursor: grab;
z-index: 4;
overflow: hidden;
}
.timeline-mitigation-active { display: none; }
.timeline-mitigation:active { cursor: grabbing; }
.timeline-mitigation.selected {
outline: 2px solid var(--gold);
outline-offset: 1px;
}
.timeline-mitigation--candidate {
border-style: dashed;
opacity: 0.78;
}
.timeline-mitigation img {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.timeline-mitigation span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
z-index: 1;
}
.timeline-mitigation .timeline-mitigation-active {
z-index: 0;
}
.timeline-mitigation--buff {
border-color: rgba(200,168,75,.5);
color: var(--gold);
background: linear-gradient(to right, rgba(200,168,75,.30) 0%, rgba(200,168,75,.30) var(--active-width), rgba(200,168,75,.06) var(--active-width), rgba(200,168,75,.06) 100%);
}
.timeline-mitigation--debuff {
border-color: rgba(224,92,92,.5);
color: var(--red);
background: linear-gradient(to right, rgba(224,92,92,.30) 0%, rgba(224,92,92,.30) var(--active-width), rgba(224,92,92,.06) var(--active-width), rgba(224,92,92,.06) 100%);
}
.timeline-mitigation--shield {
border-color: rgba(74,158,255,.5);
color: var(--blue);
background: linear-gradient(to right, rgba(74,158,255,.30) 0%, rgba(74,158,255,.30) var(--active-width), rgba(74,158,255,.06) var(--active-width), rgba(74,158,255,.06) 100%);
}
.timeline-drag-preview {
position: absolute;
top: 6px;
width: var(--cd-width);
min-width: 28px;
height: 26px;
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px;
border: 1px solid rgba(200,168,75,.85);
border-radius: var(--r);
background: linear-gradient(to right, rgba(200,168,75,.42) 0%, rgba(200,168,75,.42) var(--active-width), rgba(200,168,75,.14) var(--active-width), rgba(200,168,75,.14) 100%);
color: var(--gold);
font-size: 11px;
pointer-events: none;
z-index: 6;
box-shadow: 0 0 0 1px rgba(0,0,0,.25), 0 0 16px rgba(200,168,75,.18);
overflow: hidden;
}
.timeline-drag-preview::before {
content: "";
position: absolute;
left: 0;
top: -6px;
bottom: -6px;
width: 1px;
background: var(--gold);
box-shadow: 0 0 10px rgba(200,168,75,.55);
}
.timeline-drag-preview--bad {
border-color: rgba(224,92,92,.85);
background: rgba(224,92,92,.18);
color: var(--red);
}
.timeline-drag-preview--bad::before {
background: var(--red);
box-shadow: 0 0 10px rgba(224,92,92,.55);
}
.timeline-drag-preview img {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.timeline-drag-preview span {
position: relative;
z-index: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-drag-preview-active {
position: absolute !important;
left: 0;
top: 0;
bottom: 0;
width: var(--active-width);
background: currentColor;
opacity: .20;
z-index: 0 !important;
}
.timeline-axis {
min-height: 28px;
}
.timeline-axis-track {
border-bottom: none;
}
.timeline-tick {
position: absolute;
top: 6px;
transform: translateX(-50%);
font-size: 11px;
color: var(--t3);
}
.timeline-settings-panel {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.timeline-settings-title {
font-size: 13px;
color: var(--t1);
font-weight: 600;
width: 100%;
}
.timeline-settings-meta {
font-size: 12px;
color: var(--t3);
width: 100%;
margin-top: -6px;
}
.timeline-settings-panel label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--t3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.timeline-setting-input {
width: 86px !important;
font-size: 13px !important;
padding: 5px 7px !important;
}
.timeline-context-menu {
position: fixed;
z-index: 300;
min-width: 190px;
max-width: 280px;
max-height: min(520px, calc(100vh - 24px));
overflow-y: auto;
padding: 5px;
border: 1px solid var(--borderem);
border-radius: var(--r);
background: var(--bgcard);
box-shadow: 0 10px 24px rgba(0,0,0,0.45);
}
.timeline-menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 7px;
padding: 7px 9px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--t2);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.timeline-menu-item:hover {
background: var(--bg2);
color: var(--t1);
}
.timeline-menu-item.disabled,
.timeline-menu-item:disabled {
opacity: 0.38;
cursor: not-allowed;
}
.timeline-menu-header:disabled {
opacity: 1;
padding-top: 9px;
padding-bottom: 4px;
color: var(--gold);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
cursor: default;
}
.timeline-menu-header:hover {
background: transparent;
color: var(--gold);
}
.timeline-menu-item img {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
}
.timeline-menu-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-menu-empty {
padding: 8px 10px;
color: var(--t3);
font-size: 12px;
}
/* ── View Toggle ─────────────────────────────────────────────────────────── */
.view-toggle-btns {
display: flex;
gap: 4px;
}
.view-toggle-btn {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: var(--r);
background: transparent;
color: var(--t2);
font-size: 12px;
font-family: var(--font-b);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.view-toggle-btn:hover {
color: var(--t1);
border-color: var(--t3);
}
.view-toggle-btn.active {
background: var(--gold);
border-color: var(--gold);
color: #000;
font-weight: 600;
}
/* ── Meine Spells ────────────────────────────────────────────────────────── */
.myspells-controls {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0 14px;
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.myspells-controls select {
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: var(--r);
background: var(--bg2);
color: var(--t1);
font-size: 13px;
cursor: pointer;
}
.myspells-row {
display: grid;
grid-template-columns: 48px 180px 1fr;
align-items: center;
gap: 10px;
padding: 7px 4px;
border-bottom: 1px solid rgba(255,255,255,0.04);
font-size: 13px;
}
.myspells-abilities {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.myspells-row:last-child {
border-bottom: none;
}
.myspells-time {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--gold);
font-size: 12px;
}
.myspells-mechanic {
color: var(--t2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.myspells-ability {
display: flex;
align-items: center;
gap: 5px;
font-weight: 500;
font-size: 12px;
color: var(--t1);
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 3px 8px 3px 4px;
white-space: nowrap;
}
.myspells-ability.myspells-type--debuff { color: var(--orange); border-color: rgba(255,140,0,0.3); }
.myspells-ability.myspells-type--shield { color: var(--blue); border-color: rgba(88,166,255,0.3); }
.myspells-ability.myspells-type--personal { color: #dbc7ff; border-color: rgba(177,112,255,0.4); }
.myspells-icon {
width: 20px;
height: 20px;
object-fit: contain;
flex-shrink: 0;
}
.myspells-empty {
padding: 24px 0;
text-align: center;
color: var(--t3);
font-size: 13px;
}
@media (max-width: 980px) {
.planner-layout {
grid-template-columns: 1fr;
}
.plan-sidebar {
position: static;
}
}

View File

@ -1,58 +1,18 @@
(function () {
const MITIG_ICONS = {
// DR buffs
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
'Temperance': 'assets/icons/mitigation/temperance.png',
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
'Expedient': 'assets/icons/mitigation/expedient.png',
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
'Holos': 'assets/icons/mitigation/holos.png',
'Kerachole': 'assets/icons/mitigation/kerachole.png',
'Troubadour': 'assets/icons/mitigation/troubadour.png',
'Tactician': 'assets/icons/mitigation/tactician.png',
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
// Debuffs
'Reprisal': 'assets/icons/mitigation/reprisal.png',
'Feint': 'assets/icons/mitigation/feint.png',
'Addle': 'assets/icons/mitigation/addle.png',
// Shields
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
'Guardian': 'assets/icons/mitigation/guardian.png',
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png',
'Divine Benison': 'assets/icons/mitigation/divine-benison.png',
'Divine Caress': 'assets/icons/mitigation/divine-caress.png',
'Intersection': 'assets/icons/mitigation/intersection.png',
'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png',
'the Spire': 'assets/icons/mitigation/the-spire.png',
'Panhaima': 'assets/icons/mitigation/panhaima.png',
'Holosakos': 'assets/icons/mitigation/holos.png',
'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png',
'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png',
'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
'Haima': 'assets/icons/mitigation/haima.png',
'Galvanize': 'assets/icons/mitigation/galvanize.png',
'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png',
'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png',
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
};
const { MITIG_ICONS, JOB_ABBR, ABILITY_JOBS, JOB_ROLE } = window.FF14_DATA;
const JOB_ABBR = {
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
'Reaper': 'RPR', 'Viper': 'VPR',
'Bard': 'BRD', 'Machinist': 'MCH', 'Dancer': 'DNC',
'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
};
// Deduplicated list of all mitigations across all targets of a ref event
function collectRefMitigs(refEvent) {
if (!refEvent) return [];
const seen = new Set(), result = [];
for (const t of refEvent.targets ?? []) {
for (const m of (t.mitigations ?? [])) {
const k = m.key ?? m.name;
if (!seen.has(k)) { seen.add(k); result.push(m); }
}
}
return result;
}
function abbr(type) {
return JOB_ABBR[type] ?? type.slice(0, 3).toUpperCase();
@ -81,13 +41,24 @@
return String(name ?? '').trim().toLowerCase();
}
function currentFightName() {
const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId);
return normalizeFightName(fight?.name);
function fightEncounterId(fight) {
return parseInt(fight?.encounterID ?? fight?.encounterId ?? 0, 10) || 0;
}
function isSameFightName(fight) {
const name = currentFightName();
function currentFight() {
return (window.App?.fights ?? []).find(f => f.id === window.App?.fightId) ?? null;
}
function isSameEncounter(fight) {
const selectedFight = currentFight();
const selectedEncounterId = fightEncounterId(selectedFight);
const encounterId = fightEncounterId(fight);
if (selectedEncounterId && encounterId) {
return encounterId === selectedEncounterId;
}
const name = normalizeFightName(selectedFight?.name);
return name !== '' && normalizeFightName(fight?.name) === name;
}
@ -103,6 +74,29 @@
let currentPlayers = [];
let extFights = [];
let extReportCode = '';
let mitigationNames = {};
let planRefId = '';
let actionIconPromise = null;
const actionIconsByName = {};
function mitigationIcon(m) {
return MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name] ?? actionIconsByName[m.key] ?? actionIconsByName[m.name] ?? '';
}
async function ensureActionIconCache() {
if (actionIconPromise) return actionIconPromise;
actionIconPromise = (async () => {
try {
const res = await fetch('assets/jsons/Action.json', { cache: 'no-cache' });
if (!res.ok) return;
const actions = await res.json();
for (const action of Object.values(actions ?? {})) {
if (action?.names?.en && action?.icon) actionIconsByName[action.names.en] = action.icon;
}
} catch { }
})();
return actionIconPromise;
}
// ── Player grid ──────────────────────────────────────────────────────────
@ -243,8 +237,11 @@
const fight = (window.App?.fights ?? []).find(f => f.id === refId);
if (!fight) return;
// Clear ext-report selection
// Clear ext-report and plan selections
refExtFightSelect.value = '';
planRefId = '';
refPlanSelect.value = '';
refPlanPanel.style.display = 'none';
refFightSelect.disabled = true;
try {
@ -263,7 +260,7 @@
if (!json.error && !json.reauth) {
refEvents = json.aoe_events ?? [];
refFightStart = json.fight_start ?? fight.startTime;
refPlayers = [];
refPlayers = json.players ?? [];
window.App.setUrlState?.({
compareReportCode: '',
compareFightId: refId,
@ -279,7 +276,7 @@
let allSameReportFights = [];
function populateRefFightSelect() {
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameFightName(f));
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameEncounter(f));
refFightSelect.innerHTML = '<option value="">Kein Vergleich</option>';
visible.forEach(f => {
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
@ -348,7 +345,7 @@
extReportCode = code;
updateRefFflogsLink();
const visibleExt = fights.filter(isSameFightName);
const visibleExt = fights.filter(isSameEncounter);
refExtFightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
visibleExt.forEach(f => {
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
@ -388,8 +385,11 @@
const fight = extFights.find(f => f.id === refId);
if (!fight) return;
// Clear same-report selection
// Clear same-report and plan selections
refFightSelect.value = '';
planRefId = '';
refPlanSelect.value = '';
refPlanPanel.style.display = 'none';
refExtFightSelect.disabled = true;
try {
@ -409,6 +409,7 @@
refEvents = json.aoe_events ?? [];
refFightStart = json.fight_start ?? fight.startTime;
refPlayers = json.players ?? [];
await ensureActionIconCache();
window.App.setUrlState?.({
compareReportCode: extReportCode,
compareFightId: refId,
@ -427,6 +428,116 @@
await loadExternalCompare(refId);
});
// ── Plan as reference ─────────────────────────────────────────────────────
const refPlanToggle = document.getElementById('ref-plan-toggle');
const refPlanPanel = document.getElementById('ref-plan-panel');
const refPlanSelect = document.getElementById('ref-plan-select');
function loadPlansForRef() {
try { return JSON.parse(localStorage.getItem('ff14-planner-plans') || '[]'); }
catch { return []; }
}
function planToRefEvents(plan) {
const roster = plan.playerRoster ?? [];
const jobComp = plan.jobComposition ?? [];
const fightStart = plan.source?.fightStart ?? 0;
const mitigNames = plan.mitigationNames ?? {};
const players = jobComp.map((job, i) => ({
job,
name: roster[i]?.name ?? '',
role: JOB_ROLE[job] ?? 'dps',
})).filter(p => p.name && p.job);
return plan.mechanics.map(m => {
const mitigations = (m.assignments ?? []).map(a => ({
key: a.ability,
name: a.abilityName || mitigNames[a.ability] || a.ability,
buffType: a.buffType,
dr: 0,
}));
const targets = players.map(p => ({
id: 0,
name: p.name,
type: p.job,
role: p.role,
amount: 0,
absorbed: 0,
overkill: 0,
hp: 0,
maxHp: 0,
unmitigatedAmount: 0,
mitigations,
}));
return {
abilityName: m.name,
abilityId: m.abilityId ?? 0,
timestamp: fightStart + m.timestamp,
totalDamage: 0,
targets,
isPlanRef: true,
};
});
}
function populateRefPlanSelect() {
const plans = loadPlansForRef();
refPlanSelect.innerHTML = '<option value="">— Plan auswählen —</option>';
plans.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = `${p.name} (${p.mechanics.length} Mechaniken)`;
refPlanSelect.appendChild(opt);
});
refPlanSelect.value = planRefId || '';
}
refPlanToggle.addEventListener('click', () => {
const hidden = refPlanPanel.style.display === 'none';
refPlanPanel.style.display = hidden ? '' : 'none';
if (hidden) populateRefPlanSelect();
});
refPlanSelect.addEventListener('change', () => {
const id = refPlanSelect.value;
// Clear other ref sources
refFightSelect.value = '';
refExtFightSelect.value = '';
updateRefFflogsLink(0);
if (!id) {
planRefId = '';
refEvents = [];
refFightStart = 0;
refPlayers = [];
renderRefPlayers();
renderTimeline(lastEvents, lastFightStart);
return;
}
const plan = loadPlansForRef().find(p => p.id === id);
if (!plan) return;
planRefId = id;
refEvents = planToRefEvents(plan);
refFightStart = plan.source?.fightStart ?? 0;
refPlayers = (plan.jobComposition ?? [])
.map((job, i) => {
const name = plan.playerRoster?.[i]?.name ?? '';
if (!name || !job) return null;
return { name, type: job, role: JOB_ROLE[job] ?? 'dps' };
})
.filter(Boolean);
renderRefPlayers();
renderTimeline(lastEvents, lastFightStart);
});
// ── Timeline rendering ────────────────────────────────────────────────────
function renderTimeline(events, fightStart) {
@ -440,6 +551,8 @@
return;
}
const currentFightJobSet = new Set(currentPlayers.map(p => JOB_ABBR[p.type]).filter(Boolean));
// Build reference index: abilityName → [events in order]
const refIndex = {};
for (const ev of refEvents) {
@ -454,6 +567,11 @@
const occ = abilityOccurrence[ev.abilityName] ?? 0;
abilityOccurrence[ev.abilityName] = occ + 1;
const refEv = refEvents.length ? (refIndex[ev.abilityName]?.[occ] ?? null) : null;
const allRefMitigs = collectRefMitigs(refEv);
const currentEventMitigKeys = new Set();
for (const t of ev.targets) {
for (const m of (t.mitigations ?? [])) currentEventMitigKeys.add(m.key ?? m.name);
}
const visibleTargets = ev.targets.filter(t =>
!hiddenPlayers.has(t.id) &&
@ -475,18 +593,24 @@
}
}
const eventMissingDebuffs = refEv
? (refEv.targets[0]?.mitigations ?? []).filter(m => m.buffType === 'debuff' && !seenDebuffKeys.has(m.key ?? m.name))
? allRefMitigs.filter(m => {
if (m.buffType !== 'debuff' || seenDebuffKeys.has(m.key ?? m.name)) return false;
const jobs = ABILITY_JOBS[m.key] ?? ABILITY_JOBS[m.name];
return jobs ? jobs.some(j => currentFightJobSet.has(j)) : false;
})
: [];
const debuffIconsHtml = [
...eventDebuffs.map(m => ({ ...m, missing: false })),
...eventMissingDebuffs.map(m => ({ ...m, missing: true })),
].map(m => {
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
const iconSrc = mitigationIcon(m);
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
const jobAbbr = m.sourcePlayerType ? (JOB_ABBR[m.sourcePlayerType] ?? '') : '';
const label = jobAbbr ? `${jobAbbr} · ${m.name}` : m.name;
return m.missing
? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr} fehlt (war im Referenz-Pull aktiv)">`
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${label}${dr} fehlt (war im Referenz-Pull aktiv)">`
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${label}${dr}">`;
}).join('');
// Current targets
@ -506,15 +630,9 @@
</div>`;
})() : '';
const currentMitigKeys = new Set((t.mitigations ?? []).map(m => m.key ?? m.name));
const refTarget = refEv?.targets?.find(rt => rt.name === t.name);
const missingMitigs = refTarget
? (refTarget.mitigations ?? []).filter(m => m.buffType === 'buff' && !currentMitigKeys.has(m.key ?? m.name))
: [];
// DR buff icons (shown below player box)
const mitigIcons = (t.mitigations ?? []).filter(m => m.buffType === 'buff').map(m => {
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
const iconSrc = mitigationIcon(m);
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
@ -522,13 +640,7 @@
// Shield tooltip on absorbed value
const activeShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield');
const missingShields = refTarget
? (refTarget.mitigations ?? []).filter(m => m.buffType === 'shield' && !currentMitigKeys.has(m.key ?? m.name))
: [];
const shieldLines = [
...activeShields.map(s => s.name),
...missingShields.map(s => `[fehlt: ${s.name}]`),
];
const shieldLines = activeShields.map(s => s.name);
const shieldTitle = shieldLines.length ? shieldLines.join('\n') : null;
const dead = t.hp === 0 && t.maxHp > 0;
@ -567,28 +679,31 @@
const refDebuffIconsHtml = refVisible.flatMap(t => (t.mitigations ?? []))
.filter(m => m.buffType === 'debuff' && !seenRefDebuffKeys.has(m.key ?? m.name) && seenRefDebuffKeys.add(m.key ?? m.name))
.map(m => {
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
const iconSrc = mitigationIcon(m);
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
}).join('');
const isPlanRef = !!refEv.isPlanRef;
const refCards = refVisible.map(t => {
const curr = currentByName[t.name];
const diff = curr ? curr.amount - t.amount : 0;
const dead = t.hp === 0 && t.maxHp > 0;
const diff = (!isPlanRef && curr) ? curr.amount - t.amount : 0;
const dead = !isPlanRef && t.hp === 0 && t.maxHp > 0;
const deltaHtml = diff !== 0
? `<span class="${diff > 0 ? 'aoe-delta-worse' : 'aoe-delta-better'}">${diff > 0 ? '+' : '-'}${fmtDmg(Math.abs(diff))}</span>`
: '';
const currMitigKeys = new Set((curr?.mitigations ?? []).map(m => m.key ?? m.name));
const refMitigIcons = (t.mitigations ?? []).filter(m => m.buffType === 'buff').map(m => {
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
const iconSrc = mitigationIcon(m);
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
const missing = !currMitigKeys.has(m.key ?? m.name);
const k = m.key ?? m.name;
const jobs = ABILITY_JOBS[k] ?? ABILITY_JOBS[m.name];
const currentGroupHasJob = jobs ? jobs.some(j => currentFightJobSet.has(j)) : false;
const missing = currentGroupHasJob && !currentEventMitigKeys.has(k);
const cls = missing ? ' aoe-buff-ref-unique' : '';
const titleSufx = missing ? ' (fehlt im aktuellen Pull)' : '';
return `<img class="aoe-target-buff-icon${cls}" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}${titleSufx}">`;
@ -596,9 +711,19 @@
const refShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield');
const refShieldTitle = refShields.length
? refShields.map(s => currMitigKeys.has(s.key ?? s.name) ? s.name : `${s.name} [fehlt im aktuellen Pull]`).join('\n')
? refShields.map(s => {
const k = s.key ?? s.name;
const jobs = ABILITY_JOBS[k];
const currentGroupHasJob = jobs ? jobs.some(j => currentFightJobSet.has(j)) : false;
const isMissing = !isPlanRef && currentGroupHasJob && !currentEventMitigKeys.has(k);
return isMissing ? `${s.name} [fehlt im aktuellen Pull]` : s.name;
}).join('\n')
: null;
const absorbedHtml = isPlanRef
? (refShields.length ? ` <span class="aoe-target-absorbed" title="${refShieldTitle ?? ''}">Schild</span>` : '')
: (t.absorbed > 0 ? ` <span class="aoe-target-absorbed" title="${refShieldTitle ?? 'Keine erkannten Schilde'}">+${fmtDmg(t.absorbed)}</span>` : '');
return `
<div class="aoe-target-wrap">
<div class="aoe-ref-target${dead ? ' aoe-target--dead' : ''}">
@ -607,20 +732,21 @@
${deltaHtml}
</div>
<span class="aoe-target-name">${t.name}</span>
<span class="aoe-target-dmg">${fmtDmg(t.amount)}${t.absorbed > 0 ? ` <span class="aoe-target-absorbed" title="${refShieldTitle ?? 'Keine erkannten Schilde'}">+${fmtDmg(t.absorbed)}</span>` : ''}</span>
<span class="aoe-target-dmg">${isPlanRef ? '' : fmtDmg(t.amount)}${absorbedHtml}</span>
</div>
${refMitigIcons ? `<div class="aoe-target-buffs">${refMitigIcons}</div>` : ''}
</div>`;
}).join('');
const totalDiff = ev.totalDamage - refEv.totalDamage;
const totalDelta = totalDiff !== 0
const totalDelta = (!isPlanRef && totalDiff !== 0)
? `<span class="${totalDiff > 0 ? 'aoe-delta-worse' : 'aoe-delta-better'}">${totalDiff > 0 ? '+' : ''}${fmtDmg(totalDiff)}</span>`
: '';
const refLabel = isPlanRef ? 'PLAN' : `REF ${fmtDmg(refEv.totalDamage)} ${totalDelta}`;
refHtml = `
<div class="aoe-ref-row">
<span class="aoe-ref-label">REF ${fmtDmg(refEv.totalDamage)} ${totalDelta} ${refDebuffIconsHtml}</span>
<span class="aoe-ref-label">${refLabel} ${refDebuffIconsHtml}</span>
<div class="aoe-targets">${refCards}</div>
</div>`;
}
@ -683,6 +809,8 @@
populateRefFightSelect();
setupPhases(window.App?.phases ?? []);
renderPlayers(json.players ?? []);
mitigationNames = json.mitigation_names ?? {};
await ensureActionIconCache();
renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart);
document.getElementById('analysis-loading').style.display = 'none';
@ -717,8 +845,47 @@
players: currentPlayers,
fightName: fight?.name ?? `Fight ${window.App?.fightId ?? '?'}`,
reportCode: window.App?.reportCode ?? '',
fightId: window.App?.fightId ?? 0,
fightEnd: window.App?.fightEnd ?? 0,
mitigationNames,
};
},
exportRefForPlanner() {
const sameReportId = parseInt(refFightSelect.value, 10);
const extId = parseInt(refExtFightSelect.value, 10);
let fight = null, reportCode = '', fightId = 0;
if (sameReportId) {
fight = allSameReportFights.find(f => f.id === sameReportId);
reportCode = window.App?.reportCode ?? '';
fightId = sameReportId;
} else if (extId) {
fight = extFights.find(f => f.id === extId);
reportCode = extReportCode;
fightId = extId;
}
const transitions = fight?.phaseTransitions ?? [];
const phases = transitions.length === 0 ? [] : [
{ id: 0, name: 'Ganzer Fight', startTime: fight.startTime, endTime: fight.endTime },
...transitions.map((t, i) => ({
id: t.id,
name: `Phase ${t.id}`,
startTime: t.startTime,
endTime: transitions[i + 1]?.startTime ?? fight.endTime,
})),
];
return {
aoeEvents: refEvents,
fightStart: refFightStart,
phases,
players: refPlayers,
fightName: fight?.name ?? 'Referenz-Fight',
reportCode,
fightId,
fightEnd: fight?.endTime ?? 0,
mitigationNames,
};
},
hasRefExport() { return refEvents.length > 0 && !planRefId; },
reset() {
lastFightId = null;
refEvents = [];
@ -726,6 +893,8 @@
refPlayers = [];
extFights = [];
extReportCode = '';
mitigationNames = {};
planRefId = '';
document.getElementById('ref-player-section').style.display = 'none';
refFightSelect.value = '';
refFightSelect.style.display = 'none';
@ -734,12 +903,52 @@
refFflogsLink.style.display = 'none';
refFflogsLink.href = '#';
refExtPanel.style.display = 'none';
refPlanPanel.style.display = 'none';
refPlanSelect.value = '';
const exportBtn = document.getElementById('export-to-planner-btn');
if (exportBtn) exportBtn.style.display = 'none';
},
};
document.getElementById('export-to-planner-btn')?.addEventListener('click', () => {
document.getElementById('export-to-planner-btn')?.addEventListener('click', (e) => {
if (!refEvents.length) {
window.plannerTab?.showImportModal(window.analysisTab.exportForPlanner());
return;
}
showExportChoiceMenu(e.currentTarget);
});
function showExportChoiceMenu(anchor) {
document.getElementById('export-choice-menu')?.remove();
const menu = document.createElement('div');
menu.id = 'export-choice-menu';
menu.className = 'export-choice-menu';
[
{ label: 'Aktueller Fight', fn: () => window.analysisTab.exportForPlanner() },
{ label: 'Referenz-Fight', fn: () => window.analysisTab.exportRefForPlanner() },
].forEach(({ label, fn }) => {
const btn = document.createElement('button');
btn.className = 'export-choice-item';
btn.textContent = label;
btn.addEventListener('click', () => {
menu.remove();
window.plannerTab?.showImportModal(fn());
});
menu.appendChild(btn);
});
document.body.appendChild(menu);
const rect = anchor.getBoundingClientRect();
menu.style.top = (rect.bottom + 4) + 'px';
menu.style.right = (window.innerWidth - rect.right) + 'px';
const close = (ev) => {
if (!menu.contains(ev.target) && ev.target !== anchor) {
menu.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 0);
}
})();

View File

@ -103,6 +103,7 @@ document.addEventListener('DOMContentLoaded', () => {
window.App.language = normalizeLanguage(languageSelect.value);
localStorage.setItem('ff14-mitigator-language', window.App.language);
setUrlState({ language: window.App.language });
window.dispatchEvent(new CustomEvent('ff14-language-change', { detail: { language: window.App.language } }));
if (window.App.reportCode) {
loadReport(window.App.reportCode, window.App.fightId);
}
@ -136,6 +137,12 @@ document.addEventListener('DOMContentLoaded', () => {
document.querySelector('.tabs .tab[data-tab="analysis"]')?.click();
}
function shouldAutoOpenAnalysis() {
const params = new URLSearchParams(window.location.search);
const requestedTab = params.get('tab') || localStorage.getItem('ff14-mitigator-active-tab');
return requestedTab !== 'planner';
}
function selectFight(id, updateUrl = true) {
const fight = allFights.find(f => f.id === id);
if (!fight) return false;
@ -340,7 +347,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (initialUrlState.reportCode) {
form.elements['report_code'].value = initialUrlState.reportCode;
loadReport(initialUrlState.reportCode, initialUrlState.fightId).then(() => {
if (initialUrlState.fightId) {
if (initialUrlState.fightId && shouldAutoOpenAnalysis()) {
openAnalysisTab();
}
if (initialUrlState.compareFightId) {

327
js/ffxiv-data.js Normal file
View File

@ -0,0 +1,327 @@
(function () {
// FFLogs ability type bitmask values for FFXIV
// Verified via XIVAPI AttackType cross-reference:
// 128 = Physical (all subtypes: Slashing/Piercing/Blunt/Shot)
// 1024 = Magical
// Used for Feint/Addle DR calculation (Feint: 10% phys / 5% mag, Addle: 10% mag / 5% phys)
const ABILITY_TYPE_PHYSICAL = 128;
const ABILITY_TYPE_MAGICAL = 1024;
function abilityTypeIsPhysical(type) { return (parseInt(type) & ABILITY_TYPE_PHYSICAL) !== 0; }
function abilityTypeIsMagical(type) { return (parseInt(type) & ABILITY_TYPE_MAGICAL) !== 0; }
const JOB_FROM_TYPE = {
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
'Reaper': 'RPR', 'Viper': 'VPR', 'Bard': 'BRD', 'Machinist': 'MCH',
'Dancer': 'DNC', 'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
};
const JOB_ROLE = {
'PLD': 'tank', 'WAR': 'tank', 'DRK': 'tank', 'GNB': 'tank',
'WHM': 'healer', 'SCH': 'healer', 'AST': 'healer', 'SGE': 'healer',
'MNK': 'dps', 'DRG': 'dps', 'NIN': 'dps', 'SAM': 'dps',
'RPR': 'dps', 'VPR': 'dps', 'BRD': 'dps', 'MCH': 'dps',
'DNC': 'dps', 'BLM': 'dps', 'SMN': 'dps', 'RDM': 'dps', 'PCT': 'dps',
'BLU': 'dps',
};
const ALL_JOBS = [
{ group: 'Tank', jobs: ['PLD', 'WAR', 'DRK', 'GNB'] },
{ group: 'Healer', jobs: ['WHM', 'SCH', 'AST', 'SGE'] },
{ group: 'Melee', jobs: ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'] },
{ group: 'Ranged', jobs: ['BRD', 'MCH', 'DNC'] },
{ group: 'Caster', jobs: ['BLM', 'SMN', 'RDM', 'PCT'] },
];
const JOB_ABILITIES = {
'PLD': [
{ name: 'Passage of Arms', buffType: 'buff' },
{ name: 'Divine Veil', buffType: 'shield' },
{ name: 'Rampart', buffType: 'buff', extraAbilityGameID: 7531, duration: 20 },
{ name: 'Hallowed Ground', buffType: 'buff', extraAbilityGameID: 30, duration: 10 },
{ name: 'Sentinel', buffType: 'buff', extraAbilityGameID: 17, duration: 15 },
{ name: 'Guardian', buffType: 'shield', extraAbilityGameID: 36920, duration: 15 },
{ name: 'Bulwark', buffType: 'buff', extraAbilityGameID: 22, duration: 10 },
{ name: 'Holy Sheltron', buffType: 'buff', extraAbilityGameID: 25746, duration: 8 },
{ name: 'Intervention', buffType: 'buff', extraAbilityGameID: 7382, duration: 8 },
{ name: 'Knight\'s Resolve', buffType: 'buff', extraAbilityGameID: 7382, duration: 4 }, // Proc von Intervention
{ name: 'Reprisal', buffType: 'debuff' },
],
'WAR': [
{ name: 'Shake It Off', buffType: 'shield' },
{ name: 'Rampart', buffType: 'buff', extraAbilityGameID: 7531, duration: 20 },
{ name: 'Holmgang', buffType: 'buff', extraAbilityGameID: 43, duration: 10 },
{ name: 'Vengeance', buffType: 'buff', extraAbilityGameID: 44, duration: 15 },
{ name: 'Damnation', buffType: 'buff', extraAbilityGameID: 36923, duration: 15 },
{ name: 'Thrill of Battle', buffType: 'buff', extraAbilityGameID: 40, duration: 10 },
{ name: 'Raw Intuition', buffType: 'buff', extraAbilityGameID: 3551, duration: 6 },
{ name: 'Bloodwhetting', buffType: 'shield', extraAbilityGameID: 25751, duration: 8 },
{ name: 'Nascent Glint', buffType: 'buff', extraAbilityGameID: 16464, duration: 8 }, // Proc von Nascent Flash auf Ziel
{ name: 'Stem the Flow', buffType: 'buff', extraAbilityGameID: 25751, duration: 4 }, // Proc von Bloodwhetting / Nascent Flash
{ name: 'Stem the Tide', buffType: 'shield', extraAbilityGameID: 25751, duration: 4 }, // Proc von Bloodwhetting / Nascent Flash
{ name: 'Reprisal', buffType: 'debuff' },
],
'DRK': [
{ name: 'Dark Missionary', buffType: 'buff' },
{ name: 'Rampart', buffType: 'buff', extraAbilityGameID: 7531, duration: 20 },
{ name: 'Living Dead', buffType: 'buff', extraAbilityGameID: 3638, duration: 10 },
{ name: 'Shadow Wall', buffType: 'buff', extraAbilityGameID: 3636, duration: 15 },
{ name: 'Shadowed Vigil', buffType: 'buff', extraAbilityGameID: 36927, duration: 15 },
{ name: 'Dark Mind', buffType: 'buff', extraAbilityGameID: 3634, duration: 10 },
{ name: 'The Blackest Night', buffType: 'shield', extraAbilityGameID: 7393, duration: 7 },
{ name: 'Oblation', buffType: 'buff', extraAbilityGameID: 25754, duration: 10 },
{ name: 'Reprisal', buffType: 'debuff' },
],
'GNB': [
{ name: 'Heart of Light', buffType: 'buff' },
{ name: 'Rampart', buffType: 'buff', extraAbilityGameID: 7531, duration: 20 },
{ name: 'Superbolide', buffType: 'buff', extraAbilityGameID: 16152, duration: 10 },
{ name: 'Nebula', buffType: 'buff', extraAbilityGameID: 16148, duration: 15 },
{ name: 'Great Nebula', buffType: 'buff', extraAbilityGameID: 36935, duration: 15 },
{ name: 'Camouflage', buffType: 'buff', extraAbilityGameID: 16140, duration: 20 },
{ name: 'Heart of Stone', buffType: 'buff', extraAbilityGameID: 16161, duration: 7 },
{ name: 'Heart of Corundum', buffType: 'buff', extraAbilityGameID: 25758, duration: 8 },
{ name: 'Clarity of Corundum', buffType: 'buff', extraAbilityGameID: 25758, duration: 4 }, // Proc von HoC, geht auf beliebiges Partymitglied
{ name: 'Reprisal', buffType: 'debuff' },
],
'WHM': [
{ name: 'Temperance', buffType: 'buff' },
{ name: 'Aquaveil', buffType: 'buff', extraAbilityGameID: 25861, duration: 8 }, // Personal, WHM auf Ziel
{ name: 'Divine Benison', buffType: 'shield', extraAbilityGameID: 7432, duration: 15 },
{ name: 'Divine Caress', buffType: 'shield' },
],
'SCH': [
{ name: 'Sacred Soil', buffType: 'buff' },
{ name: 'Expedient', buffType: 'buff' },
{ name: 'Fey Illumination', buffType: 'buff' },
{ name: 'Galvanize', buffType: 'shield' },
{ name: 'Seraphic Veil', buffType: 'shield', extraAbilityGameID: 16548, duration: 30 },
{ name: 'Catalyze', buffType: 'shield' },
],
'AST': [
{ name: 'Collective Unconscious', buffType: 'buff' },
{ name: 'Exaltation', buffType: 'buff', extraAbilityGameID: 25873, duration: 8 }, // Personal, AST auf Ziel
{ name: 'Neutral Sect', buffType: 'shield' },
{ name: 'Intersection', buffType: 'shield', extraAbilityGameID: 16556, duration: 30 },
{ name: 'the Spire', buffType: 'shield', extraAbilityGameID: 37025, duration: 30 },
],
'SGE': [
{ name: 'Kerachole', buffType: 'buff' },
{ name: 'Holos', buffType: 'buff' },
{ name: 'Holosakos', buffType: 'shield' },
{ name: 'Panhaima', buffType: 'shield' },
{ name: 'Eukrasian Prognosis', buffType: 'shield' },
{ name: 'Eukrasian Prognosis II', buffType: 'shield' },
{ name: 'Eukrasian Diagnosis', buffType: 'shield', extraAbilityGameID: 24291, duration: 30 },
{ name: 'Differential Diagnosis', buffType: 'shield', extraAbilityGameID: 24291, duration: 30 },
{ name: 'Haima', buffType: 'shield', extraAbilityGameID: 24305, duration: 15 },
],
'BRD': [{ name: 'Troubadour', buffType: 'buff' }],
'MCH': [{ name: 'Tactician', buffType: 'buff' }],
'DNC': [
{ name: 'Shield Samba', buffType: 'buff' },
{ name: 'Improvised Finish', buffType: 'shield' },
],
'MNK': [
{ name: 'Riddle of Earth', buffType: 'buff', extraAbilityGameID: 7394, duration: 10 },
{ name: 'Feint', buffType: 'debuff' },
],
'DRG': [{ name: 'Feint', buffType: 'debuff' }],
'NIN': [
{ name: 'Shade Shift', buffType: 'shield', extraAbilityGameID: 2241, duration: 20 },
{ name: 'Feint', buffType: 'debuff' },
],
'SAM': [
{ name: 'Third Eye', buffType: 'buff', extraAbilityGameID: 7498, duration: 4 },
{ name: 'Feint', buffType: 'debuff' },
],
'RPR': [
{ name: 'Arcane Crest', buffType: 'shield', extraAbilityGameID: 24404, duration: 5 },
{ name: 'Feint', buffType: 'debuff' },
],
'VPR': [{ name: 'Feint', buffType: 'debuff' }],
'BLM': [
{ name: 'Manaward', buffType: 'shield', extraAbilityGameID: 157, duration: 20 },
{ name: 'Addle', buffType: 'debuff' },
],
'SMN': [
{ name: 'Addle', buffType: 'debuff' },
{ name: 'Radiant Aegis', buffType: 'shield', extraAbilityGameID: 25799, duration: 30 },
],
'RDM': [
{ name: 'Addle', buffType: 'debuff' },
{ name: 'Magick Barrier', buffType: 'buff' },
],
'PCT': [
{ name: 'Addle', buffType: 'debuff' },
{ name: 'Tempera Coat', buffType: 'shield', extraAbilityGameID: 34685, duration: 10 },
{ name: 'Tempera Grassa', buffType: 'shield' },
],
};
const ABILITY_JOB_MAP = {
'Passage of Arms': 'PLD', 'Divine Veil': 'PLD',
'Hallowed Ground': 'PLD', 'Sentinel': 'PLD', 'Guardian': 'PLD',
'Bulwark': 'PLD', 'Holy Sheltron': 'PLD', 'Intervention': 'PLD',
'Knight\'s Resolve': 'PLD',
'Shake It Off': 'WAR', 'Holmgang': 'WAR', 'Vengeance': 'WAR',
'Damnation': 'WAR', 'Thrill of Battle': 'WAR', 'Raw Intuition': 'WAR',
'Bloodwhetting': 'WAR', 'Nascent Glint': 'WAR',
'Stem the Flow': 'WAR', 'Stem the Tide': 'WAR',
'Dark Missionary': 'DRK', 'Living Dead': 'DRK', 'Shadow Wall': 'DRK',
'Shadowed Vigil': 'DRK', 'Dark Mind': 'DRK', 'The Blackest Night': 'DRK',
'Oblation': 'DRK',
'Heart of Light': 'GNB', 'Superbolide': 'GNB', 'Nebula': 'GNB',
'Great Nebula': 'GNB', 'Camouflage': 'GNB', 'Heart of Stone': 'GNB',
'Heart of Corundum': 'GNB', 'Clarity of Corundum': 'GNB',
'Temperance': 'WHM', 'Aquaveil': 'WHM', 'Divine Benison': 'WHM', 'Divine Caress': 'WHM',
'Sacred Soil': 'SCH', 'Expedient': 'SCH', 'Fey Illumination': 'SCH',
'Galvanize': 'SCH', 'Seraphic Veil': 'SCH', 'Catalyze': 'SCH',
'Collective Unconscious': 'AST', 'Exaltation': 'AST', 'Neutral Sect': 'AST',
'Intersection': 'AST', 'the Spire': 'AST',
'Kerachole': 'SGE', 'Holos': 'SGE', 'Holosakos': 'SGE',
'Panhaima': 'SGE', 'Haima': 'SGE',
'Eukrasian Prognosis': 'SGE', 'Eukrasian Prognosis II': 'SGE',
'Eukrasian Diagnosis': 'SGE', 'Differential Diagnosis': 'SGE',
'Troubadour': 'BRD',
'Tactician': 'MCH',
'Shield Samba': 'DNC', 'Improvised Finish': 'DNC',
'Riddle of Earth': 'MNK',
'Shade Shift': 'NIN',
'Third Eye': 'SAM',
'Arcane Crest': 'RPR',
'Manaward': 'BLM',
'Radiant Aegis': 'SMN',
'Magick Barrier': 'RDM',
'Tempera Coat': 'PCT', 'Tempera Grassa': 'PCT',
};
const MITIG_ICONS = {
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
'Temperance': 'assets/icons/mitigation/temperance.png',
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
'Expedient': 'assets/icons/mitigation/expedient.png',
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
'Holos': 'assets/icons/mitigation/holos.png',
'Kerachole': 'assets/icons/mitigation/kerachole.png',
'Troubadour': 'assets/icons/mitigation/troubadour.png',
'Tactician': 'assets/icons/mitigation/tactician.png',
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
'Reprisal': 'assets/icons/mitigation/reprisal.png',
'Feint': 'assets/icons/mitigation/feint.png',
'Addle': 'assets/icons/mitigation/addle.png',
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
'Guardian': 'assets/icons/mitigation/guardian.png',
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png',
'Divine Benison': 'assets/icons/mitigation/divine-benison.png',
'Divine Caress': 'assets/icons/mitigation/divine-caress.png',
'Intersection': 'assets/icons/mitigation/intersection.png',
'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png',
'the Spire': 'assets/icons/mitigation/the-spire.png',
'Panhaima': 'assets/icons/mitigation/panhaima.png',
'Holosakos': 'assets/icons/mitigation/holos.png',
'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png',
'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png',
'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
'Haima': 'assets/icons/mitigation/haima.png',
'Galvanize': 'assets/icons/mitigation/galvanize.png',
'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png',
'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png',
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
'Aquaveil': 'assets/icons/mitigation/aquaveil.png',
'Exaltation': 'assets/icons/mitigation/exaltation.png',
'Heart of Corundum': 'assets/icons/mitigation/heart-of-corundum.png',
'Clarity of Corundum': 'assets/icons/mitigation/clarity-of-corundum.png',
'Heart of Stone': 'assets/icons/mitigation/heart-of-stone.png',
'Knight\'s Resolve': 'assets/icons/mitigation/knights-resolve.png',
'Nascent Glint': 'assets/icons/mitigation/nascent-glint.png',
'Stem the Flow': 'assets/icons/mitigation/stem-the-flow.png',
'Stem the Tide': 'assets/icons/mitigation/stem-the-tide.png',
};
const ABILITY_DR = {
'Passage of Arms': 0.15,
'Troubadour': 0.15,
'Tactician': 0.15,
'Shield Samba': 0.15,
'Dark Missionary': 0.10,
'Heart of Light': 0.10,
'Temperance': 0.10,
'Sacred Soil': 0.10,
'Expedient': 0.10,
'Collective Unconscious': 0.10,
'Holos': 0.10,
'Kerachole': 0.10,
'Magick Barrier': 0.10,
'Fey Illumination': 0.05,
'Reprisal': 0.10,
'Feint': 0.05,
'Addle': 0.10,
'Rampart': 0.20,
'Hallowed Ground': 1.00,
'Sentinel': 0.30,
'Guardian': 0.40,
'Holy Sheltron': 0.15,
'Intervention': 0.10,
'Knight\'s Resolve': 0.10,
'Aquaveil': 0.15,
'Exaltation': 0.10,
'Vengeance': 0.30,
'Damnation': 0.40,
'Raw Intuition': 0.10,
'Bloodwhetting': 0.10,
'Nascent Glint': 0.10,
'Stem the Flow': 0.10,
'Shadow Wall': 0.30,
'Shadowed Vigil': 0.40,
'Dark Mind': 0.20,
'Oblation': 0.10,
'Superbolide': 1.00,
'Nebula': 0.30,
'Great Nebula': 0.40,
'Camouflage': 0.10,
'Heart of Stone': 0.15,
'Heart of Corundum': 0.15,
'Clarity of Corundum': 0.15,
'Riddle of Earth': 0.20,
'Third Eye': 0.10,
};
const ABILITY_JOBS = {};
Object.entries(JOB_ABILITIES).forEach(([job, abilities]) => {
abilities.forEach(ability => {
if (!ABILITY_JOBS[ability.name]) ABILITY_JOBS[ability.name] = [];
ABILITY_JOBS[ability.name].push(job);
});
});
window.FF14_DATA = {
JOB_ABBR: JOB_FROM_TYPE,
JOB_FROM_TYPE,
JOB_ROLE,
ALL_JOBS,
JOB_ABILITIES,
ABILITY_JOB_MAP,
ABILITY_JOBS,
ABILITY_DR,
MITIG_ICONS,
TANK_JOBS: new Set(['PLD', 'WAR', 'DRK', 'GNB']),
MELEE_JOBS: new Set(['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR']),
CASTER_JOBS: new Set(['BLM', 'SMN', 'RDM', 'PCT']),
ABILITY_TYPE_PHYSICAL,
ABILITY_TYPE_MAGICAL,
abilityTypeIsPhysical,
abilityTypeIsMagical,
};
})();

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,10 @@
document.addEventListener('DOMContentLoaded', () => {
const tabs = document.querySelectorAll('.tabs .tab');
const contents = document.querySelectorAll('.tab-content');
const validTabs = new Set([...tabs].map(btn => btn.dataset.tab));
function showTab(name) {
if (!validTabs.has(name)) name = 'report';
contents.forEach(el => el.style.display = 'none');
tabs.forEach(btn => btn.classList.remove('active'));
@ -14,8 +16,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (name === 'analysis') window.analysisTab?.onTabOpen?.();
if (name === 'planner') window.plannerTab?.onTabOpen?.();
localStorage.setItem('ff14-mitigator-active-tab', name);
}
tabs.forEach(btn => btn.addEventListener('click', () => showTab(btn.dataset.tab)));
window.showTab = showTab;
const params = new URLSearchParams(window.location.search);
showTab(params.get('tab') || localStorage.getItem('ff14-mitigator-active-tab') || 'report');
});

1
run_Server.bat Normal file
View File

@ -0,0 +1 @@
php -S localhost:8080

View File

@ -0,0 +1,416 @@
<?php
declare(strict_types=1);
ini_set('memory_limit', '1024M');
const ACTION_SOURCE_URL = 'https://ff14.akurosiakamo.de/extras/json/xivapi_data/Action.json';
$rootDir = dirname(__DIR__);
$mitigationSource = $rootDir . '/api/analysis.php';
$plannerDataSource = $rootDir . '/js/ffxiv-data.js';
$outputFile = $rootDir . '/assets/jsons/Action.json';
function fail(string $message, int $code = 1): void
{
fwrite(STDERR, $message . PHP_EOL);
exit($code);
}
function extract_constant_array_literal(string $php, string $constantName): string
{
$needle = 'const ' . $constantName . ' =';
$start = strpos($php, $needle);
if ($start === false) {
fail('Could not find const ' . $constantName . ' in api/analysis.php');
}
$arrayStart = strpos($php, '[', $start);
if ($arrayStart === false) {
fail('Could not find array literal for ' . $constantName);
}
$depth = 0;
$length = strlen($php);
$inString = false;
$stringQuote = '';
$escaped = false;
for ($i = $arrayStart; $i < $length; $i++) {
$char = $php[$i];
if ($inString) {
if ($escaped) {
$escaped = false;
continue;
}
if ($char === '\\') {
$escaped = true;
continue;
}
if ($char === $stringQuote) {
$inString = false;
$stringQuote = '';
}
continue;
}
if ($char === '\'' || $char === '"') {
$inString = true;
$stringQuote = $char;
continue;
}
if ($char === '[') {
$depth++;
continue;
}
if ($char === ']') {
$depth--;
if ($depth === 0) {
return substr($php, $arrayStart, $i - $arrayStart + 1);
}
}
}
fail('Could not parse array literal for ' . $constantName);
}
function extract_js_const_object_literal(string $js, string $constantName): string
{
$needle = 'const ' . $constantName . ' =';
$start = strpos($js, $needle);
if ($start === false) {
fail('Could not find const ' . $constantName . ' in js/ffxiv-data.js');
}
$objectStart = strpos($js, '{', $start);
if ($objectStart === false) {
fail('Could not find object literal for ' . $constantName);
}
$depth = 0;
$length = strlen($js);
$inString = false;
$stringQuote = '';
$escaped = false;
for ($i = $objectStart; $i < $length; $i++) {
$char = $js[$i];
if ($inString) {
if ($escaped) {
$escaped = false;
continue;
}
if ($char === '\\') {
$escaped = true;
continue;
}
if ($char === $stringQuote) {
$inString = false;
$stringQuote = '';
}
continue;
}
if ($char === '\'' || $char === '"' || $char === '`') {
$inString = true;
$stringQuote = $char;
continue;
}
if ($char === '{') {
$depth++;
continue;
}
if ($char === '}') {
$depth--;
if ($depth === 0) {
return substr($js, $objectStart, $i - $objectStart + 1);
}
}
}
fail('Could not parse object literal for ' . $constantName);
}
function read_planner_ability_names(string $sourceFile): array
{
if (!is_file($sourceFile)) {
fail('Missing planner data source file: ' . $sourceFile);
}
$js = file_get_contents($sourceFile);
if ($js === false) {
fail('Could not read planner data source file: ' . $sourceFile);
}
$literal = extract_js_const_object_literal($js, 'JOB_ABILITIES');
if (!preg_match_all('/\bname\s*:\s*([\'"])((?:\\\\.|(?!\1).)*)\1/s', $literal, $matches)) {
fail('No abilities found in JOB_ABILITIES');
}
$names = [];
foreach ($matches[2] as $rawName) {
$name = stripcslashes($rawName);
if ($name !== '') {
$names[$name] = true;
}
}
ksort($names, SORT_NATURAL | SORT_FLAG_CASE);
return array_keys($names);
}
function read_mitigation_action_ids(string $sourceFile, array $abilityNames): array
{
if (!is_file($sourceFile)) {
fail('Missing mitigation source file: ' . $sourceFile);
}
$php = file_get_contents($sourceFile);
if ($php === false) {
fail('Could not read mitigation source file: ' . $sourceFile);
}
$literal = extract_constant_array_literal($php, 'MITIGATION_ABILITIES');
$abilities = eval('return ' . $literal . ';');
if (!is_array($abilities)) {
fail('MITIGATION_ABILITIES did not parse as an array');
}
$wantedNames = array_fill_keys($abilityNames, true);
$ids = [];
foreach ($wantedNames as $name => $_) {
if (!isset($abilities[$name])) {
fwrite(STDERR, 'Planner ability missing in MITIGATION_ABILITIES: ' . $name . PHP_EOL);
continue;
}
$ability = $abilities[$name];
$id = (int)($ability['extraAbilityGameID'] ?? 0);
if ($id <= 0) {
fwrite(STDERR, 'Skipping mitigation without extraAbilityGameID: ' . $name . PHP_EOL);
continue;
}
$ids[$id] = true;
}
if (!$ids) {
fail('No extraAbilityGameID values found for abilities from js/ffxiv-data.js');
}
ksort($ids, SORT_NUMERIC);
return array_keys($ids);
}
function download_url(string $url): string
{
$lastError = '';
$allowInsecureDownload = getenv('FF14_MITIGATOR_INSECURE_DOWNLOAD') === '1';
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_TIMEOUT => 120,
CURLOPT_USERAGENT => 'ff14-mitigator-action-cache/1.0',
CURLOPT_SSL_VERIFYPEER => !$allowInsecureDownload,
CURLOPT_SSL_VERIFYHOST => $allowInsecureDownload ? 0 : 2,
]);
$body = curl_exec($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body !== false && $status >= 200 && $status < 300) {
return $body;
}
$lastError = 'cURL HTTP ' . $status . ($error ? ': ' . $error : '');
}
$wrappers = stream_get_wrappers();
if (in_array('https', $wrappers, true)) {
$context = stream_context_create([
'http' => [
'timeout' => 120,
'user_agent' => 'ff14-mitigator-action-cache/1.0',
],
'ssl' => [
'verify_peer' => !$allowInsecureDownload,
'verify_peer_name' => !$allowInsecureDownload,
],
]);
$body = file_get_contents($url, false, $context);
if ($body !== false) {
return $body;
}
$lastError = trim($lastError . '; file_get_contents failed', '; ');
}
if ($lastError !== '') {
fail('Could not download Action.json. ' . $lastError);
}
fail('This PHP installation has neither cURL nor the HTTPS stream wrapper enabled.');
}
function action_field(array $action, string $field): ?int
{
if (array_key_exists($field, $action) && is_numeric($action[$field])) {
return (int)$action[$field];
}
$fields = $action['fields'] ?? null;
if (is_array($fields) && array_key_exists($field, $fields) && is_numeric($fields[$field])) {
return (int)$fields[$field];
}
return null;
}
function action_text_field(array $action, string $field): ?string
{
if (array_key_exists($field, $action) && is_scalar($action[$field])) {
$value = trim((string)$action[$field]);
return $value !== '' ? $value : null;
}
$fields = $action['fields'] ?? null;
if (is_array($fields) && array_key_exists($field, $fields) && is_scalar($fields[$field])) {
$value = trim((string)$fields[$field]);
return $value !== '' ? $value : null;
}
return null;
}
function action_icon_url(array $action): ?string
{
$icon = $action['Icon'] ?? $action['fields']['Icon'] ?? null;
if (!is_array($icon)) {
return null;
}
$path = $icon['path_hr1'] ?? $icon['path'] ?? null;
if (!is_string($path) || $path === '') {
return null;
}
if (!preg_match('#ui/icon/([^/]+)/([^/]+)\.tex$#', $path, $matches)) {
return null;
}
return 'https://xivapi.com/i/' . $matches[1] . '/' . $matches[2] . '.png';
}
function plain_action_text(?string $text): string
{
if ($text === null || $text === '') {
return '';
}
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string)preg_replace('/\s+/u', ' ', $text));
}
function action_shield_text(array $action): ?string
{
$description = plain_action_text(action_text_field($action, 'Description_en'));
if ($description === '' || !preg_match('/barrier|absorbs|absorbed|nullif(?:y|ies)/i', $description)) {
return null;
}
if (preg_match('/(?:absorbs|absorb|nullifies|nullify)[^.]*?(?:totaling|up to)?\s*(\d+)%\s*(?:of\s*)?(?:your\s+|target\'s\s+)?maximum HP/i', $description, $m)) {
return $m[1] . '% max HP';
}
if (preg_match('/barrier[^.]*?(?:absorbs|absorb|nullifies|nullify)[^.]*?(\d+)%\s*(?:of\s*)?(?:your\s+|target\'s\s+)?maximum HP/i', $description, $m)) {
return $m[1] . '% max HP';
}
if (preg_match('/barrier[^.]*?(\d+)%\s+of\s+the\s+amount\s+of\s+HP\s+restored/i', $description, $m)) {
return $m[1] . '% of HP restored';
}
if (preg_match('/barrier[^.]*?(?:heal of|Cure Potency:)\s*(\d+)\s*potency/i', $description, $m)) {
return $m[1] . ' potency';
}
return null;
}
$plannerAbilityNames = read_planner_ability_names($plannerDataSource);
$actionIds = read_mitigation_action_ids($mitigationSource, $plannerAbilityNames);
$wanted = array_fill_keys(array_map('strval', $actionIds), true);
$json = download_url(ACTION_SOURCE_URL);
$actions = json_decode($json, true);
if (!is_array($actions)) {
fail('Downloaded Action.json is not valid JSON: ' . json_last_error_msg());
}
$filtered = [];
foreach ($wanted as $id => $_) {
$action = $actions[$id] ?? null;
if (!is_array($action)) {
fwrite(STDERR, 'Missing action in downloaded Action.json: ' . $id . PHP_EOL);
continue;
}
$filtered[$id] = [
'cast' => action_field($action, 'Cast100ms'),
'recast' => action_field($action, 'Recast100ms'),
'names' => [
'en' => action_text_field($action, 'Name_en'),
'de' => action_text_field($action, 'Name_de'),
'fr' => action_text_field($action, 'Name_fr'),
'jp' => action_text_field($action, 'Name_ja'),
],
'icon' => action_icon_url($action),
'shield' => action_shield_text($action),
];
}
if (!$filtered) {
fail('No matching mitigation actions found in downloaded Action.json');
}
ksort($filtered, SORT_NUMERIC);
$outputDir = dirname($outputFile);
if (!is_dir($outputDir) && !mkdir($outputDir, 0775, true) && !is_dir($outputDir)) {
fail('Could not create output directory: ' . $outputDir);
}
$encoded = json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($encoded === false) {
fail('Could not encode filtered Action.json: ' . json_last_error_msg());
}
if (file_put_contents($outputFile, $encoded . PHP_EOL, LOCK_EX) === false) {
fail('Could not write output file: ' . $outputFile);
}
echo 'Saved ' . count($filtered) . ' actions to ' . $outputFile . PHP_EOL;

View File

@ -21,7 +21,7 @@
<?php endif; ?>
</p>
<a href="auth/start.php" class="btn btn-gold btn-login">
<a href="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" class="btn btn-gold btn-login">
<?= $tokenExpired ? 'Reconnect to FFLogs' : 'Connect to FFLogs' ?>
</a>

View File

@ -80,6 +80,35 @@
</div>
</div>
<!-- Name Import Modal (Planner) -->
<div id="planner-name-import-modal" class="modal-overlay" style="display:none">
<div class="modal-box">
<div class="modal-title">Namen importieren</div>
<div class="modal-section">
<div class="modal-label">Report-Code</div>
<div class="name-import-input-row">
<input type="text" id="name-import-report-input" placeholder="Report-Code oder URL…">
<button id="name-import-load-btn" class="btn btn-sm">Laden</button>
</div>
</div>
<div class="modal-section" id="name-import-fight-section" style="display:none">
<div class="modal-label">Fight</div>
<select id="name-import-fight-select">
<option value=""> Fight auswählen </option>
</select>
</div>
<div id="name-import-preview" class="name-import-preview" style="display:none"></div>
<div class="modal-actions" style="margin-top:16px">
<button id="name-import-confirm-btn" class="btn btn-gold" style="display:none">Übernehmen</button>
<button id="name-import-cancel-btn" class="btn">Abbrechen</button>
</div>
</div>
</div>
<!-- Ability Assignment Modal -->
<div id="planner-ability-modal" class="modal-overlay" style="display:none">
<div class="modal-box ability-modal-box">
@ -93,6 +122,7 @@
<script src="js/app.js"></script>
<script src="js/tabs.js"></script>
<script src="js/ffxiv-data.js"></script>
<script src="js/analysis.js"></script>
<script src="js/planner.js"></script>
<?php endif; ?>

View File

@ -23,7 +23,7 @@
</select>
</div>
<button class="btn btn-gold" type="submit" style="align-self:flex-end">Fetch</button>
<a class="btn" href="auth/start.php" style="align-self:flex-end;text-decoration:none">Reconnect</a>
<a class="btn" href="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" style="align-self:flex-end;text-decoration:none">Reconnect</a>
</div>
</form>
</div>

View File

@ -22,6 +22,15 @@
<div class="ref-player-label">REF Spieler</div>
<div id="ref-player-grid" class="player-grid"></div>
</div>
<div class="ref-ext-row">
<button id="ref-plan-toggle" class="btn btn-sm">+ Plan als Referenz</button>
<div id="ref-plan-panel" style="display:none">
<select id="ref-plan-select" class="filter-input">
<option value=""> Plan auswählen </option>
</select>
</div>
</div>
<div class="ref-ext-row">
<button id="ref-ext-toggle" class="btn btn-sm">+ Anderer Report</button>
<div id="ref-ext-panel" style="display:none">

View File

@ -2,6 +2,9 @@
<!-- Left: Plan list sidebar -->
<div class="plan-sidebar">
<div id="planner-info-panel" class="planner-info-panel"></div>
<div class="plan-sidebar-header">
<div class="card-title">Pläne</div>
<button id="planner-new-folder-btn" class="btn btn-sm" title="Neuer Ordner">+ Ordner</button>
@ -26,7 +29,6 @@
<div id="plan-list"></div>
<div id="planner-info-panel" class="planner-info-panel"></div>
</div>
<!-- Right: Plan detail -->

View File

@ -3,7 +3,7 @@
<nav class="tabs">
<button class="tab active" data-tab="report"> Report</button>
<button class="tab" data-tab="analysis"> Analyse</button>
<button class="tab" data-tab="planner">📋 Planer</button>
<button class="tab" data-tab="planner"> Planer</button>
</nav>
<div class="topbar-user">Token gültig bis: <?= date('Y-m-d H:i:s', $_SESSION['token_expires']) ?></div>
</header>