Compare commits

..

No commits in common. "main" and "master" have entirely different histories.
main ... master

34 changed files with 446 additions and 8698 deletions

2
.gitignore vendored
View File

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

129
CLAUDE.md
View File

@ -69,7 +69,6 @@ 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/
@ -109,7 +108,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 (10%), Feint (10% phys / 5% magic), Addle (10%)
- **`debuff`**: Boss-Debuffs — Reprisal, Feint, Addle (je 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').
@ -161,14 +160,6 @@ 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
@ -248,39 +239,26 @@ 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, "fightName": "Howling Blade", "fightStart": 0, "fightEnd": 420000, "language": "en" },
"mitigationNames": { "Reprisal": "Vergeltung" },
"folderId": null,
"source": { "reportCode": "abc123", "fightId": 6 },
"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", "abilityName": "Vergeltung", "job": "PLD", "buffType": "debuff" },
{ "ability": "Shield Samba", "abilityName": "Schildsamba", "job": "BRD", "buffType": "buff" }
{ "ability": "Reprisal", "job": "PLD" },
{ "ability": "Shield Samba", "job": "BRD" }
]
}
]
}
```
- `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.
@ -293,59 +271,30 @@ 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 `|timestamp_a - timestamp_b| < 1500ms`. Nur neue Mechaniken werden hinzugefügt, bestehende Assignments bleiben erhalten. Neue Mechaniken werden timestamp-sortiert eingefügt.
**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.
**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 | Status | Feature |
| Schritt | Feature | Beschreibung |
|---|---|---|
| 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 |
| 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 |
### 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 } }
```
Schritte 13 = nutzbarer MVP. Schritte 46 = praktisch einsetzbar. 79 = Power-Features.
### UI-Paradigma
- Visuell dem Analyse-Tab ähneln (Cards, gleiche CSS-Variablen, einheitliches Look & Feel)
- Mechaniken als vertikale Timeline-Cards — primäre Bearbeitungsfläche bleibt erhalten
- Mechaniken als vertikale Timeline-Cards
- 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
@ -360,7 +309,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, Differential Diagnosis, Haima |
| SGE | Kerachole, Holos, Holosakos, Panhaima, Eukrasian Prognosis, Eukrasian Diagnosis, Haima, Addle |
| BRD | Troubadour |
| MCH | Tactician |
| DNC | Shield Samba, Improvised Finish |
@ -375,39 +324,31 @@ Jobaufstellung → verfügbare Abilities (Subset von `MITIGATION_ABILITIES`):
| RDM | Addle, Magick Barrier |
| PCT | Addle, Tempera Coat, Tempera Grassa |
### 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.
### Job-Äquivalente (`data/ability-equivalents.json`)
Abilities die funktional gleich sind aber unterschiedliche Namen haben — relevant beim Job-Wechsel im Slot:
| 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.
Reprisal, Feint und Addle sind identische Ability-Namen über Jobs hinweg — kein Mapping nötig, die gleiche Ability bleibt einfach bestehen.
### `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
**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).
### Recast-Zeiten (`data/recast-times.json`)
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
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
### Technische Entscheidungen
- **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
- **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

View File

@ -1,12 +1,13 @@
<?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);
@ -18,16 +19,6 @@ $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 {
@ -132,12 +123,4 @@ foreach (array_keys($seenIds) as $id) {
}
usort($abilities, fn($a, $b) => strcmp($a['name'], $b['name']));
$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;
echo json_encode(['abilities' => $abilities, 'players' => $players]);

View File

@ -1,7 +1,6 @@
<?php
ini_set('display_errors', '0');
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/cache.php';
session_start_safe();
header('Content-Type: application/json');
@ -12,6 +11,15 @@ 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);
@ -26,22 +34,6 @@ 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 {
@ -84,101 +76,60 @@ 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, '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],
'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],
// ── Shields ─────────────────────────────────────────────────────────────
// PLD
'Divine Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001362, 'extraAbilityGameID' => 3540],
'Guardian' => ['dr' => 40, 'buffType' => 'shield', 'statusId' => 1003830, 'extraAbilityGameID' => 36920], // FFLogs: "Guardian's Will"
'Divine Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001362],
'Guardian' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003830], // FFLogs: "Guardian's Will"
// WAR
'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
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457],
'Bloodwhetting' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002678],
// WHM
'Divine Benison' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001218, 'extraAbilityGameID' => 7432],
'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903, 'extraAbilityGameID' => 37011],
'Divine Benison' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001218],
'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903],
// AST
'Intersection' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001889, 'extraAbilityGameID' => 16556],
'Neutral Sect' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001921, 'extraAbilityGameID' => 16559],
'the Spire' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003892, 'extraAbilityGameID' => 37025], // FFLogs: "The Spire"
'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"
// SGE
'Panhaima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002613, 'extraAbilityGameID' => 24311],
'Holosakos' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003365, 'extraAbilityGameID' => 24310],
'Eukrasian Prognosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002609, 'extraAbilityGameID' => 24292],
'Eukrasian Prognosis II' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 37034],
'Eukrasian Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002607, 'extraAbilityGameID' => 24291],
'Differential Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002608, 'extraAbilityGameID' => 24291],
'Haima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002612, 'extraAbilityGameID' => 24305],
'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],
// SCH
'Galvanize' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1000297, 'extraAbilityGameID' => 185],
'Seraphic Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001917, 'extraAbilityGameID' => 16548],
'Catalyze' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001918, 'extraAbilityGameID' => 185],
'Galvanize' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1000297],
'Seraphic Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001917],
'Catalyze' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001918],
// SMN
'Radiant Aegis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002702, 'extraAbilityGameID' => 25799],
'Radiant Aegis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002702],
// PCT
'Tempera Coat' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003686, 'extraAbilityGameID' => 34685],
'Tempera Grassa' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003687, 'extraAbilityGameID' => 34686],
'Tempera Coat' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003686],
'Tempera Grassa' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003687],
// DNC
'Improvised Finish' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002697, 'extraAbilityGameID' => 25789],
'Improvised Finish' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002697],
// ── Boss debuffs ────────────────────────────────────────────────────────
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001193, 'extraAbilityGameID' => 7535],
'Feint' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001195, 'extraAbilityGameID' => 7549],
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203, 'extraAbilityGameID' => 7560],
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001193],
'Feint' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001195],
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203],
];
function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourceTimeline = [], array $players = [], float $ts = 0): array {
function resolveMitigations(string $buffStr, array $mitigIdMap): array {
if ($buffStr === '') return [];
$result = [];
$seen = [];
@ -188,33 +139,17 @@ function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourc
$name = $mitigIdMap[$id]['name'];
if (isset($seen[$name])) continue;
$seen[$name] = true;
$entry = [
$result[] = [
'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.
@ -226,13 +161,7 @@ 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'],
'extraAbilityGameID' => $m['extraAbilityGameID'] ?? null,
];
$result[] = ['key' => $m['key'] ?? $m['name'], 'name' => $m['name'], 'dr' => $m['dr'], 'buffType' => $m['buffType']];
}
break;
}
@ -348,9 +277,7 @@ 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($trackedStatusIds)) {
$nextPage = $startTime;
@ -385,31 +312,12 @@ if (!empty($trackedStatusIds)) {
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') {
@ -431,60 +339,11 @@ if (!empty($trackedStatusIds)) {
}
}
// ── 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) {
@ -509,9 +368,6 @@ 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,
@ -571,7 +427,6 @@ foreach ($byAbility as $abId => $events) {
if ($current !== null) $clusters[] = $current;
}
$bossEvents = [];
$aoeEvents = [];
foreach ($clusters as $group) {
$targetCount = count($group['targets']);
@ -593,6 +448,8 @@ foreach ($clusters as $group) {
}
}
if ($targetCount < 3 && !$isHeavyTankbuster) continue;
$targets = [];
foreach ($group['targets'] as $tgtId => $tgt) {
$p = $players[$tgtId] ?? null;
@ -608,8 +465,8 @@ foreach ($clusters as $group) {
'overkill' => $tgt['overkill'],
'hp' => $tgt['hp'],
'maxHp' => $tgt['maxHp'],
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline, $buffSourceTimeline, $players) {
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap, $buffSourceTimeline, $players, $tgt['ts']);
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline) {
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap);
if ($tgt['absorbed'] > 0 && !empty($shieldTimeline)) {
$existing = [];
foreach ($mitigations as $m) {
@ -629,7 +486,7 @@ foreach ($clusters as $group) {
return $roleCmp !== 0 ? $roleCmp : strcmp($a['name'], $b['name']);
});
$bossEvent = [
$aoeEvents[] = [
'timestamp' => $group['timestamp'],
'abilityId' => $group['abilityId'],
'abilityName' => $group['abilityName'],
@ -637,27 +494,12 @@ 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']);
$response = json_encode([
echo 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;

View File

@ -1,52 +0,0 @@
<?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,20 +87,13 @@ if ($playerName !== '') {
}
}
// Fetch events + masterData abilities (incl. type)
// Fetch events
$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,
@ -118,14 +111,7 @@ 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;
@ -135,11 +121,6 @@ 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;
}
@ -150,9 +131,11 @@ 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,7 +1,6 @@
<?php
ini_set('display_errors', '0');
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/cache.php';
session_start_safe();
header('Content-Type: application/json');
@ -22,12 +21,6 @@ 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]);
@ -49,7 +42,6 @@ query GetReportData($reportCode: String!) {
endTime
fights {
id
encounterID
name
startTime
endTime
@ -83,7 +75,9 @@ function localized_graphql_uri(string $language): string {
return preg_replace('#https://[^/]+#', 'https://' . $host, GRAPHQL_URI);
}
$ch = curl_init(localized_graphql_uri($language));
// 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);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
@ -91,14 +85,12 @@ 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);
@ -120,8 +112,5 @@ if ($httpStatus === 401) {
}
http_response_code($httpStatus === 200 ? 200 : $httpStatus);
if ($httpStatus === 200) {
write_cached_log('fight', $reportCode, $language, [], $body);
}
echo $body;
exit;

View File

@ -1,112 +0,0 @@
<?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.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -1,818 +0,0 @@
{
"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'] = safe_return_path($_GET['return'] ?? null);
$_SESSION['oauth_return'] = null;
$params = http_build_query([
'response_type' => 'code',

View File

@ -1,51 +1,11 @@
<?php
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'));
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 session_start_safe(): void {
if (session_status() === PHP_SESSION_NONE) {
@ -59,38 +19,3 @@ 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,30 +80,6 @@ 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,12 +4,6 @@
grid-template-columns: 280px 1fr;
gap: 16px;
align-items: start;
min-width: 0;
}
#plan-detail-panel,
#plan-content {
min-width: 0;
}
/* ── Plan Sidebar ────────────────────────────────────────────────────────────── */
@ -165,63 +159,6 @@
.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;
@ -285,16 +222,6 @@
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;
@ -323,7 +250,6 @@
.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;
@ -489,7 +415,6 @@
.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; }
@ -528,9 +453,9 @@
/* ── Info Panel ──────────────────────────────────────────────────────────────── */
.planner-info-panel {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.info-section { margin-bottom: 12px; }
@ -565,7 +490,6 @@
.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;
@ -581,38 +505,6 @@
.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; }
@ -705,645 +597,3 @@
}
.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,18 +1,58 @@
(function () {
const { MITIG_ICONS, JOB_ABBR, ABILITY_JOBS, JOB_ROLE } = window.FF14_DATA;
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',
};
// 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;
}
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',
};
function abbr(type) {
return JOB_ABBR[type] ?? type.slice(0, 3).toUpperCase();
@ -41,24 +81,13 @@
return String(name ?? '').trim().toLowerCase();
}
function fightEncounterId(fight) {
return parseInt(fight?.encounterID ?? fight?.encounterId ?? 0, 10) || 0;
function currentFightName() {
const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId);
return normalizeFightName(fight?.name);
}
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);
function isSameFightName(fight) {
const name = currentFightName();
return name !== '' && normalizeFightName(fight?.name) === name;
}
@ -75,28 +104,6 @@
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 ──────────────────────────────────────────────────────────
@ -237,11 +244,8 @@
const fight = (window.App?.fights ?? []).find(f => f.id === refId);
if (!fight) return;
// Clear ext-report and plan selections
// Clear ext-report selection
refExtFightSelect.value = '';
planRefId = '';
refPlanSelect.value = '';
refPlanPanel.style.display = 'none';
refFightSelect.disabled = true;
try {
@ -260,7 +264,7 @@
if (!json.error && !json.reauth) {
refEvents = json.aoe_events ?? [];
refFightStart = json.fight_start ?? fight.startTime;
refPlayers = json.players ?? [];
refPlayers = [];
window.App.setUrlState?.({
compareReportCode: '',
compareFightId: refId,
@ -276,7 +280,7 @@
let allSameReportFights = [];
function populateRefFightSelect() {
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameEncounter(f));
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameFightName(f));
refFightSelect.innerHTML = '<option value="">Kein Vergleich</option>';
visible.forEach(f => {
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
@ -345,7 +349,7 @@
extReportCode = code;
updateRefFflogsLink();
const visibleExt = fights.filter(isSameEncounter);
const visibleExt = fights.filter(isSameFightName);
refExtFightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
visibleExt.forEach(f => {
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
@ -385,11 +389,8 @@
const fight = extFights.find(f => f.id === refId);
if (!fight) return;
// Clear same-report and plan selections
// Clear same-report selection
refFightSelect.value = '';
planRefId = '';
refPlanSelect.value = '';
refPlanPanel.style.display = 'none';
refExtFightSelect.disabled = true;
try {
@ -409,7 +410,6 @@
refEvents = json.aoe_events ?? [];
refFightStart = json.fight_start ?? fight.startTime;
refPlayers = json.players ?? [];
await ensureActionIconCache();
window.App.setUrlState?.({
compareReportCode: extReportCode,
compareFightId: refId,
@ -428,116 +428,6 @@
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) {
@ -551,8 +441,6 @@
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) {
@ -567,11 +455,6 @@
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) &&
@ -593,24 +476,18 @@
}
}
const eventMissingDebuffs = refEv
? 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;
})
? (refEv.targets[0]?.mitigations ?? []).filter(m => m.buffType === 'debuff' && !seenDebuffKeys.has(m.key ?? m.name))
: [];
const debuffIconsHtml = [
...eventDebuffs.map(m => ({ ...m, missing: false })),
...eventMissingDebuffs.map(m => ({ ...m, missing: true })),
].map(m => {
const iconSrc = mitigationIcon(m);
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
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="${label}${dr} fehlt (war im Referenz-Pull aktiv)">`
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${label}${dr}">`;
? `<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}">`;
}).join('');
// Current targets
@ -630,9 +507,15 @@
</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 = mitigationIcon(m);
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
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}">`;
@ -640,7 +523,13 @@
// Shield tooltip on absorbed value
const activeShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield');
const shieldLines = activeShields.map(s => s.name);
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 shieldTitle = shieldLines.length ? shieldLines.join('\n') : null;
const dead = t.hp === 0 && t.maxHp > 0;
@ -679,31 +568,28 @@
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 = mitigationIcon(m);
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
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 = (!isPlanRef && curr) ? curr.amount - t.amount : 0;
const dead = !isPlanRef && t.hp === 0 && t.maxHp > 0;
const diff = curr ? curr.amount - t.amount : 0;
const dead = 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 = mitigationIcon(m);
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
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 missing = !currMitigKeys.has(m.key ?? m.name);
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}">`;
@ -711,19 +597,9 @@
const refShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield');
const refShieldTitle = refShields.length
? 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')
? refShields.map(s => currMitigKeys.has(s.key ?? s.name) ? s.name : `${s.name} [fehlt im aktuellen Pull]`).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' : ''}">
@ -732,21 +608,20 @@
${deltaHtml}
</div>
<span class="aoe-target-name">${t.name}</span>
<span class="aoe-target-dmg">${isPlanRef ? '' : fmtDmg(t.amount)}${absorbedHtml}</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>
</div>
${refMitigIcons ? `<div class="aoe-target-buffs">${refMitigIcons}</div>` : ''}
</div>`;
}).join('');
const totalDiff = ev.totalDamage - refEv.totalDamage;
const totalDelta = (!isPlanRef && totalDiff !== 0)
const totalDelta = 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">${refLabel} ${refDebuffIconsHtml}</span>
<span class="aoe-ref-label">REF ${fmtDmg(refEv.totalDamage)} ${totalDelta} ${refDebuffIconsHtml}</span>
<div class="aoe-targets">${refCards}</div>
</div>`;
}
@ -810,7 +685,6 @@
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';
@ -850,42 +724,6 @@
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 = [];
@ -894,7 +732,6 @@
extFights = [];
extReportCode = '';
mitigationNames = {};
planRefId = '';
document.getElementById('ref-player-section').style.display = 'none';
refFightSelect.value = '';
refFightSelect.style.display = 'none';
@ -903,52 +740,12 @@
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', (e) => {
if (!refEvents.length) {
document.getElementById('export-to-planner-btn')?.addEventListener('click', () => {
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

@ -1,327 +0,0 @@
(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 +0,0 @@
php -S localhost:8080

View File

@ -1,416 +0,0 @@
<?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="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" class="btn btn-gold btn-login">
<a href="auth/start.php" class="btn btn-gold btn-login">
<?= $tokenExpired ? 'Reconnect to FFLogs' : 'Connect to FFLogs' ?>
</a>

View File

@ -80,35 +80,6 @@
</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">
@ -122,7 +93,6 @@
<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="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" style="align-self:flex-end;text-decoration:none">Reconnect</a>
<a class="btn" href="auth/start.php" style="align-self:flex-end;text-decoration:none">Reconnect</a>
</div>
</form>
</div>

View File

@ -22,15 +22,6 @@
<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,9 +2,6 @@
<!-- 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>
@ -29,6 +26,7 @@
<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>