Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89d1ac0df1 | ||
|
|
fce55c5bb6 | ||
|
|
1a91d1af0e | ||
|
|
636a65965a | ||
|
|
a9b3cc8666 | ||
|
|
7eeeb5ef56 | ||
|
|
e8863d83a5 | ||
|
|
90fcbb69a5 | ||
|
|
858a5e8f49 | ||
|
|
fc2dc513ca | ||
|
|
186d59fdc5 | ||
|
|
646a7252c8 | ||
|
|
1dfc727940 | ||
|
|
206e09a57a | ||
|
|
b5445da02a | ||
|
|
243cb0608c | ||
|
|
eecab6d76a | ||
|
|
bb4eb301e0 | ||
|
|
e40bdbea1c | ||
|
|
4345fadc1c | ||
|
|
28c045fee7 | ||
|
|
669bcd937b | ||
|
|
be65d0b228 | ||
|
|
c983ca6621 | ||
|
|
8217f68a0f | ||
|
|
fd0de86dbc | ||
|
|
0f8a90d1b4 | ||
|
|
8f00c22682 | ||
|
|
fb6d50961a | ||
|
|
3276e3bfb3 | ||
|
|
d0f54049e6 | ||
|
|
4ec929ebb7 | ||
|
|
da226d54a2 | ||
|
|
cdd594e43e | ||
|
|
b27986c0f4 | ||
|
|
e358a4e709 | ||
|
|
8b00d1d2a8 | ||
|
|
19922d79aa | ||
|
|
61fecbc576 | ||
|
|
27b9b0785e | ||
|
|
2275f0050d | ||
|
|
5dc61754f2 | ||
|
|
2e8818bb03 | ||
|
|
fb58226be8 | ||
|
|
6024560e61 | ||
|
|
565dedc568 | ||
|
|
07a140442f | ||
|
|
349645f4cb | ||
|
|
d8ff50eeef | ||
|
|
b6d44d8ae0 | ||
|
|
58745fec65 |
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
|||||||
.claude/
|
.claude/
|
||||||
debug/
|
debug/
|
||||||
|
cached_logs/
|
||||||
|
.env
|
||||||
fflogs-schema.json
|
fflogs-schema.json
|
||||||
|
|||||||
129
CLAUDE.md
@ -69,6 +69,7 @@ api/
|
|||||||
analysis.php — POST-Endpunkt: Spieler + AoE-Events + Mitigations → JSON
|
analysis.php — POST-Endpunkt: Spieler + AoE-Events + Mitigations → JSON
|
||||||
abilities.php — POST-Endpunkt: Ability- + Spielerliste für Event Explorer Dropdowns
|
abilities.php — POST-Endpunkt: Ability- + Spielerliste für Event Explorer Dropdowns
|
||||||
debug-events.php — POST-Endpunkt: Raw Events für Event Explorer (mit Filterung)
|
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/
|
assets/
|
||||||
icons/mitigation/ — Lokal gespeicherte Ability-Icons (PNG, von XIVAPI)
|
icons/mitigation/ — Lokal gespeicherte Ability-Icons (PNG, von XIVAPI)
|
||||||
data/
|
data/
|
||||||
@ -108,7 +109,7 @@ Getrackte party-wide Buffs + Schilde + Boss-Debuffs (definiert in `MITIGATION_AB
|
|||||||
Drei `buffType`-Kategorien:
|
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%)
|
- **`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
|
- **`shield`**: Barrieren — Divine Veil, Guardian, Shake It Off, Bloodwhetting, Divine Benison, Divine Caress, Intersection, Neutral Sect, the Spire, Panhaima, Holosakos, Eukrasian Prognosis, Eukrasian Diagnosis, Differential Diagnosis, Haima, Galvanize, Seraphic Veil, Catalyze, Radiant Aegis, Tempera Coat, Tempera Grassa, Improvised Finish
|
||||||
- **`debuff`**: Boss-Debuffs — Reprisal, Feint, Addle (je 10%)
|
- **`debuff`**: Boss-Debuffs — Reprisal (10%), Feint (10% phys / 5% magic), Addle (10%)
|
||||||
|
|
||||||
**Statische Status-IDs (`statusId`-Feld):** Jeder Eintrag in `MITIGATION_ABILITIES` trägt ein `statusId`-Feld (FFLogs Status-ID = XIVAPI Status row_id + 1.000.000). Diese IDs werden als Fallback in `$mitigIdMap` eingetragen wenn masterData den Eintrag nicht enthält. Löst das Pre-Pull-Problem und Name-Mismatches (z.B. FFLogs "Guardian's Will" vs. Key 'Guardian', "Desperate Measures" vs. 'Expedient').
|
**Statische Status-IDs (`statusId`-Feld):** Jeder Eintrag in `MITIGATION_ABILITIES` trägt ein `statusId`-Feld (FFLogs Status-ID = XIVAPI Status row_id + 1.000.000). Diese IDs werden als Fallback in `$mitigIdMap` eingetragen wenn masterData den Eintrag nicht enthält. Löst das Pre-Pull-Problem und Name-Mismatches (z.B. FFLogs "Guardian's Will" vs. Key 'Guardian', "Desperate Measures" vs. 'Expedient').
|
||||||
|
|
||||||
@ -160,6 +161,14 @@ Konsistentes Healer → DPS → Tank-Ordering überall: im Spieler-Grid, in jede
|
|||||||
- Schaden-Delta pro Spieler: grün wenn besser (`aoe-delta-better`), rot wenn schlechter (`aoe-delta-worse`)
|
- 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`)
|
- 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
|
### Cross-Report-Vergleich
|
||||||
- Button "+ Anderer Report" (`#ref-ext-toggle`) öffnet Panel mit Eingabefeld + Laden-Button
|
- 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
|
- `api/fight.php` wird mit dem externen Report-Code aufgerufen → Fight-Dropdown befüllt
|
||||||
@ -239,26 +248,39 @@ Raid-Cooldown-Planer: Welche Mitigation-Ability wird für welche Mechanik einges
|
|||||||
"name": "M8S – Prog Week 1",
|
"name": "M8S – Prog Week 1",
|
||||||
"createdAt": 1234567890,
|
"createdAt": 1234567890,
|
||||||
"updatedAt": 1234567890,
|
"updatedAt": 1234567890,
|
||||||
"source": { "reportCode": "abc123", "fightId": 6 },
|
"source": { "reportCode": "abc123", "fightId": 6, "fightName": "Howling Blade", "fightStart": 0, "fightEnd": 420000, "language": "en" },
|
||||||
|
"mitigationNames": { "Reprisal": "Vergeltung" },
|
||||||
|
"folderId": null,
|
||||||
"jobComposition": ["PLD", "WAR", "WHM", "SCH", "MNK", "DRG", "BRD", "SMN"],
|
"jobComposition": ["PLD", "WAR", "WHM", "SCH", "MNK", "DRG", "BRD", "SMN"],
|
||||||
|
"playerRoster": [
|
||||||
|
{ "name": "Xziino", "maxHp": 82340 },
|
||||||
|
{ "name": "Healer1", "maxHp": 75000 }
|
||||||
|
],
|
||||||
|
"shieldK": 30,
|
||||||
"mechanics": [
|
"mechanics": [
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"name": "Fourth-Wall Fusion",
|
"name": "Fourth-Wall Fusion",
|
||||||
|
"abilityId": 12345,
|
||||||
"timestamp": 83000,
|
"timestamp": 83000,
|
||||||
"phase": "Phase 1",
|
"phase": "Phase 1",
|
||||||
"unmitigatedDamage": 280000,
|
"unmitigatedDamage": 280000,
|
||||||
"notes": "",
|
"notes": "",
|
||||||
"assignments": [
|
"assignments": [
|
||||||
{ "ability": "Reprisal", "job": "PLD" },
|
{ "ability": "Reprisal", "abilityName": "Vergeltung", "job": "PLD", "buffType": "debuff" },
|
||||||
{ "ability": "Shield Samba", "job": "BRD" }
|
{ "ability": "Shield Samba", "abilityName": "Schildsamba", "job": "BRD", "buffType": "buff" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `playerRoster`: Array[8] mit `{name, maxHp}` — wird via "Namen + Job Import" befüllt (aus `api/players.php`)
|
||||||
|
- `shieldK`: SGE/SCH-Schildwert in k (×1000) — wird in der DR-Simulation als Flat-Abzug verwendet; im Info-Panel editierbar
|
||||||
|
|
||||||
Mehrere Pläne gespeichert in `localStorage` unter `ff14-planner-plans` als Array.
|
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
|
### 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.
|
Der Haupteinstieg ist der Analyse-Tab — der Nutzer hat die Daten bereits geladen und sieht die Timeline.
|
||||||
@ -271,30 +293,59 @@ Der Haupteinstieg ist der Analyse-Tab — der Nutzer hat die Daten bereits gelad
|
|||||||
4. AoE-Events werden zu Mechaniken; Phase-Information aus `phaseTransitions` wird mitübernommen
|
4. AoE-Events werden zu Mechaniken; Phase-Information aus `phaseTransitions` wird mitübernommen
|
||||||
5. Weiterarbeiten im Planer-Tab
|
5. Weiterarbeiten im Planer-Tab
|
||||||
|
|
||||||
**Merge-Logik:** Mechaniken gelten als identisch wenn `abilityName` gleich und `|timestamp_a - timestamp_b| < 5000ms`. Nur neue Mechaniken werden hinzugefügt, bestehende Assignments bleiben erhalten. Neue Mechaniken werden timestamp-sortiert eingefügt.
|
**Merge-Logik:** Mechaniken gelten als identisch wenn `|timestamp_a - timestamp_b| < 1500ms`. Nur neue Mechaniken werden hinzugefügt, bestehende Assignments bleiben erhalten. Neue Mechaniken werden timestamp-sortiert eingefügt.
|
||||||
|
|
||||||
**Warum Merge statt Überschreiben:** Progress-Szenario — erster Import enthält Phase 1, späterer Import fügt Phase 2 hinzu ohne Phase-1-Planung zu verlieren.
|
**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
|
### Implementierungs-Reihenfolge
|
||||||
|
|
||||||
| Schritt | Feature | Beschreibung |
|
| Schritt | Status | Feature |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | **Datenfundament** | Plan-Datenmodell + localStorage CRUD (create, read, update, delete, copy) |
|
| 1 | ✅ | **Datenfundament** — Plan-Datenmodell + localStorage CRUD |
|
||||||
| 2 | **Tab-Grundgerüst** | Leere Tab-Hülle wie Analyse-Tab; Plan-Liste; Mechanik-Timeline (read-only) |
|
| 2 | ✅ | **Tab-Grundgerüst** — Plan-Liste, Ordner, Mechanik-Timeline |
|
||||||
| 3 | **Import aus Analyse-Tab** | Export-Button + Dialog (s. oben); `window.plannerTab.importFromAnalysis()` |
|
| 3 | ✅ | **Import aus Analyse-Tab** — Export-Button + Dialog + Merge |
|
||||||
| 4 | **Jobaufstellung** | 8 Slots mit Job-Dropdown; bestimmt verfügbare Abilities in Schritt 5 |
|
| 4 | ✅ | **Jobaufstellung** — 8 Slots mit Job-Dropdown + Rollenfärbung + Namen+Job-Import |
|
||||||
| 5 | **Ability-Zuweisung** | Pro Mechanik Abilities per Modal-Picker hinzufügen/entfernen |
|
| 5 | ✅ | **Ability-Zuweisung** — Modal-Picker + Rechtsklick-Remove + Äquivalenz-Hints |
|
||||||
| 6 | **Recast-Konflikt** | `data/recast-times.json`; rote Warnung wenn CD zwischen zwei Mechaniken noch läuft |
|
| 6 | ✅ | **DR-Simulation + Gantt-Chart** — mitigierter Schaden in Mechanik-Cards, Gantt mit Ability-Zeilen |
|
||||||
| 7 | **DR-Simulation** | `simuliert = unmitigated × ∏(1 − dr_i)`; grün/rot ob Spieler laut Simulation überlebt |
|
| 7 | 🔜 | **Analyse-Overlay** — geplante vs. tatsächlich genutzte CDs |
|
||||||
| 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 |
|
|
||||||
|
|
||||||
Schritte 1–3 = nutzbarer MVP. Schritte 4–6 = praktisch einsetzbar. 7–9 = Power-Features.
|
### DR-Simulation (implementiert)
|
||||||
|
|
||||||
|
- `ABILITY_DR` in `planner.js`: Map Ability-Name → DR-Wert (0–1). Feint = 0.05 (5% magical, konservativ).
|
||||||
|
- `simulateDrMultiplier(mechanic)`: multipliziert `∏(1 − dr_i)` über alle Buff/Debuff-Assignments (keine Schilde)
|
||||||
|
- In `renderMechanicListHtml`:
|
||||||
|
- `→ XX mitigiert` (DR-only, farbig gegen ∅ HP): grün wenn ≤ avgHp, rot wenn darüber
|
||||||
|
- `Mitigation mit Schild XX` (DR + shieldK×1000, daneben): nur wenn `plan.shieldK > 0`
|
||||||
|
- `∅ XX HP` (Durchschnitt maxHp ohne Tanks): aus `avgNonTankMaxHp(plan)`, neben unmitigiertem Wert
|
||||||
|
- Quell-DR-Werte spiegeln `MITIGATION_ABILITIES[*]['dr']` aus `api/analysis.php` — beide Stellen müssen bei Änderungen synchron gehalten werden
|
||||||
|
|
||||||
|
### Gantt-Chart (implementiert)
|
||||||
|
|
||||||
|
**Aktueller Stand:**
|
||||||
|
- X-Achse: Kampfzeit aus `source.fightEnd - source.fightStart`
|
||||||
|
- **Eine Zeile pro (Job, Ability)** — nicht pro Job. `timelinePlayerRows(plan)` expandiert `JOB_ABILITIES` pro Job.
|
||||||
|
- Schilde in Gantt ausgeblendet, außer **Panhaima** (SGE)
|
||||||
|
- Erste Zeile jedes Jobs hat eine visuelle Trennlinie (`.timeline-player-row--job-start`)
|
||||||
|
- Balken: linker Teil (aktive Dauer, opak) + rechter Teil (Cooldown-Rest, transparent) via CSS `linear-gradient` auf `--active-width` / `--cd-width`
|
||||||
|
- Klick auf leere Zeile → fügt diese Ability direkt der nächsten Mechanik hinzu (kein Menü)
|
||||||
|
- Drag & Drop: nur zwischen Zeilen derselben Ability
|
||||||
|
- Bestehende Balken per Drag verschiebbar; Rechtsklick zum Entfernen
|
||||||
|
|
||||||
|
**Noch offen:**
|
||||||
|
- Konflikte visuell hervorheben wenn Balken eine Mechaniken-Linie überlappt
|
||||||
|
|
||||||
|
**Recast-Daten:** `data/recast-times.json` — noch zu befüllen. Enthält Cooldown-Dauer (s) und Aktiv-Dauer (s) pro Ability:
|
||||||
|
```json
|
||||||
|
{ "Reprisal": { "recast": 60, "duration": 10 }, "Feint": { "recast": 90, "duration": 10 } }
|
||||||
|
```
|
||||||
|
|
||||||
### UI-Paradigma
|
### UI-Paradigma
|
||||||
- Visuell dem Analyse-Tab ähneln (Cards, gleiche CSS-Variablen, einheitliches Look & Feel)
|
- Visuell dem Analyse-Tab ähneln (Cards, gleiche CSS-Variablen, einheitliches Look & Feel)
|
||||||
- Mechaniken als vertikale Timeline-Cards
|
- Mechaniken als vertikale Timeline-Cards — primäre Bearbeitungsfläche bleibt erhalten
|
||||||
- Ability-Picker als **Modal** (kein Inline-Dropdown)
|
- 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
|
- Nicht für mobile Geräte ausgelegt
|
||||||
|
|
||||||
### Spell-Verfügbarkeit nach Job
|
### Spell-Verfügbarkeit nach Job
|
||||||
@ -309,7 +360,7 @@ Jobaufstellung → verfügbare Abilities (Subset von `MITIGATION_ABILITIES`):
|
|||||||
| WHM | Temperance, Divine Benison, Divine Caress |
|
| WHM | Temperance, Divine Benison, Divine Caress |
|
||||||
| SCH | Sacred Soil, Expedient, Fey Illumination, Galvanize, Seraphic Veil, Catalyze, Addle |
|
| SCH | Sacred Soil, Expedient, Fey Illumination, Galvanize, Seraphic Veil, Catalyze, Addle |
|
||||||
| AST | Collective Unconscious, Neutral Sect, Intersection, the Spire |
|
| AST | Collective Unconscious, Neutral Sect, Intersection, the Spire |
|
||||||
| SGE | Kerachole, Holos, Holosakos, Panhaima, Eukrasian Prognosis, Eukrasian Diagnosis, Haima, Addle |
|
| SGE | Kerachole, Holos, Holosakos, Panhaima, Eukrasian Prognosis, Eukrasian Diagnosis, Differential Diagnosis, Haima |
|
||||||
| BRD | Troubadour |
|
| BRD | Troubadour |
|
||||||
| MCH | Tactician |
|
| MCH | Tactician |
|
||||||
| DNC | Shield Samba, Improvised Finish |
|
| DNC | Shield Samba, Improvised Finish |
|
||||||
@ -324,31 +375,39 @@ Jobaufstellung → verfügbare Abilities (Subset von `MITIGATION_ABILITIES`):
|
|||||||
| RDM | Addle, Magick Barrier |
|
| RDM | Addle, Magick Barrier |
|
||||||
| PCT | Addle, Tempera Coat, Tempera Grassa |
|
| PCT | Addle, Tempera Coat, Tempera Grassa |
|
||||||
|
|
||||||
### Job-Äquivalente (`data/ability-equivalents.json`)
|
### Job-Äquivalente (in `planner.js` als `ABILITY_EQUIVALENTS`)
|
||||||
Abilities die funktional gleich sind aber unterschiedliche Namen haben — relevant beim Job-Wechsel im Slot:
|
Abilities die funktional gleich sind aber unterschiedliche Namen haben — zeigt Hinweis bei fehlendem Job.
|
||||||
|
Kein automatischer Austausch — nur Hinweis in rot/grün unter dem ausgegrauten Badge.
|
||||||
|
|
||||||
| Gruppe | Abilities |
|
| Gruppe | Abilities |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 15% Party-Mitigation | Troubadour, Tactician, Shield Samba |
|
| 15% Party-Mitigation | Troubadour, Tactician, Shield Samba |
|
||||||
| 10% Ground-Barrier | Sacred Soil, Kerachole |
|
| 10% Ground-Barrier | Sacred Soil, Kerachole |
|
||||||
|
|
||||||
Reprisal, Feint und Addle sind identische Ability-Namen über Jobs hinweg — kein Mapping nötig, die gleiche Ability bleibt einfach bestehen.
|
Reprisal, Feint und Addle sind identische Ability-Namen über Jobs hinweg — kein Mapping nötig.
|
||||||
|
|
||||||
**Verhalten beim Job-Wechsel:** Assignment wird auto-substituiert wenn Äquivalent für neuen Job existiert (mit Hinweis "automatisch gemappt"). Kein Äquivalent → Assignment ausgegraut (nicht gelöscht, Nutzer entscheidet).
|
### `extraAbilityGameID` in MITIGATION_ABILITIES (geplant)
|
||||||
|
Jeder Eintrag in `MITIGATION_ABILITIES` soll ein `actionId`-Feld bekommen — die echte Action-Row-ID aus FFLogs (`applybuff`-Event → `extraAbilityGameID`). Diese ID entspricht direkt der XIVAPI Action-Sheet-Row-ID und ermöglicht automatischen Icon-Lookup:
|
||||||
|
- 5-stellige IDs: führende 0 ergänzen falls XIVAPI 6 Stellen erwartet
|
||||||
|
- Lookup: `https://v2.xivapi.com/api/sheet/Action/{actionId}?fields=Name,Icon`
|
||||||
|
- Ermöglicht künftig automatisches Icon-Fetching statt manueller `MITIG_ICONS`-Pflege
|
||||||
|
|
||||||
### Recast-Zeiten (`data/recast-times.json`)
|
### Recast-Zeiten (`data/recast-times.json`)
|
||||||
Wird für Konflikt-Erkennung benötigt (Schritt 6). Vollständige Liste wird beim Implementieren vervollständigt, Beispiele:
|
Format: `{ "Ability": { "recast": 60, "duration": 10 } }` — noch zu befüllen.
|
||||||
- Reprisal: 60s
|
Bekannte Werte (Beispiele):
|
||||||
- Feint / Addle: 90s
|
- Reprisal: recast 60s, duration 10s
|
||||||
- Dark Missionary / Heart of Light: 90s
|
- Feint / Addle: recast 90s, duration 10s
|
||||||
- Troubadour / Tactician / Shield Samba: 120s
|
- Dark Missionary / Heart of Light: recast 90s, duration 15s
|
||||||
- Temperance: 120s
|
- Troubadour / Tactician / Shield Samba: recast 120s, duration 15s
|
||||||
|
- Temperance: recast 120s, duration 20s
|
||||||
|
- Sacred Soil / Kerachole: recast 30s, duration 15s
|
||||||
|
|
||||||
### Technische Entscheidungen
|
### Technische Entscheidungen
|
||||||
- **Persistenz:** `localStorage` unter `ff14-planner-plans` — kein Backend nötig
|
- **Persistenz:** `localStorage` — kein Backend nötig
|
||||||
- **IDs:** `crypto.randomUUID()` für Plan- und Mechanik-IDs
|
- **IDs:** `crypto.randomUUID()` für Plan-, Mechanik- und Ordner-IDs
|
||||||
- **Keine Spielernamen:** Assignments sind Job-basiert (`{ ability, job }`), damit Pläne übertragbar sind
|
- **Eindeutige Namen:** `uniquePlanName()` verhindert Duplikate beim Erstellen und Importieren
|
||||||
- **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
|
- **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
|
||||||
- **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)
|
- **Kein Ability-Stacking:** FFXIV erlaubt keine doppelte Anwendung derselben Ability pro Mechanik
|
||||||
- **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.
|
- **Shield-Attribution:** Nicht simulierbar — `absorbed` ist Gesamtwert ohne Aufschlüsselung. Bewusst weggelassen.
|
||||||
- **Plan kopieren:** Duplicate-Funktion für Plan-Varianten ("Week 3 v2") ohne Original zu verlieren
|
- **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
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
ini_set('display_errors', '0');
|
ini_set('display_errors', '0');
|
||||||
require_once __DIR__ . '/../config.php';
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/cache.php';
|
||||||
session_start_safe();
|
session_start_safe();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
|
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'] ?? '');
|
$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? '');
|
||||||
$fightId = (int)($_POST['fight_id'] ?? 0);
|
$fightId = (int)($_POST['fight_id'] ?? 0);
|
||||||
@ -19,6 +18,16 @@ $translate = 'true';
|
|||||||
|
|
||||||
if (!$reportCode || !$fightId || !$endTime) { http_response_code(400); echo json_encode(['error' => 'Missing params']); exit; }
|
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'];
|
$token = $_SESSION['access_token'];
|
||||||
|
|
||||||
function localized_graphql_uri(string $language): string {
|
function localized_graphql_uri(string $language): string {
|
||||||
@ -123,4 +132,12 @@ foreach (array_keys($seenIds) as $id) {
|
|||||||
}
|
}
|
||||||
usort($abilities, fn($a, $b) => strcmp($a['name'], $b['name']));
|
usort($abilities, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||||
|
|
||||||
echo json_encode(['abilities' => $abilities, 'players' => $players]);
|
$response = json_encode(['abilities' => $abilities, 'players' => $players]);
|
||||||
|
if ($response === false) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Could not encode abilities response']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_cached_log('abilities', $reportCode, $language, $cacheParts, $response);
|
||||||
|
echo $response;
|
||||||
|
|||||||
274
api/analysis.php
@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
ini_set('display_errors', '0');
|
ini_set('display_errors', '0');
|
||||||
require_once __DIR__ . '/../config.php';
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/cache.php';
|
||||||
session_start_safe();
|
session_start_safe();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@ -11,15 +12,6 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||||||
exit;
|
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'] ?? '');
|
$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? '');
|
||||||
$fightId = (int)($_POST['fight_id'] ?? 0);
|
$fightId = (int)($_POST['fight_id'] ?? 0);
|
||||||
$startTime = (float)($_POST['start_time'] ?? 0);
|
$startTime = (float)($_POST['start_time'] ?? 0);
|
||||||
@ -34,6 +26,22 @@ if (!$reportCode || !$fightId || !$endTime) {
|
|||||||
exit;
|
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'];
|
$token = $_SESSION['access_token'];
|
||||||
|
|
||||||
function localized_graphql_uri(string $language): string {
|
function localized_graphql_uri(string $language): string {
|
||||||
@ -76,60 +84,101 @@ function fflogs_gql(string $query): array {
|
|||||||
// buffType 'debuff' = boss debuff, shown in event header
|
// buffType 'debuff' = boss debuff, shown in event header
|
||||||
const MITIGATION_ABILITIES = [
|
const MITIGATION_ABILITIES = [
|
||||||
// ── Damage reduction buffs ──────────────────────────────────────────────
|
// ── Damage reduction buffs ──────────────────────────────────────────────
|
||||||
'Passage of Arms' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001175],
|
'Passage of Arms' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001175, 'extraAbilityGameID' => 7385],
|
||||||
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001894],
|
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001894, 'extraAbilityGameID' => 16471],
|
||||||
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001839],
|
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001839, 'extraAbilityGameID' => 16160],
|
||||||
'Temperance' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001873],
|
'Temperance' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001873, 'extraAbilityGameID' => 16536],
|
||||||
'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001944],
|
'Aquaveil' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002708, 'extraAbilityGameID' => 25861], // Personal, WHM auf Ziel
|
||||||
'Expedient' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002711], // FFLogs: "Desperate Measures"
|
'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001944, 'extraAbilityGameID' => 188],
|
||||||
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff', 'statusId' => 1000317],
|
'Expedient' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002711, 'extraAbilityGameID' => 25868], // FFLogs: "Desperate Measures"
|
||||||
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1000849],
|
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff', 'statusId' => 1000317, 'extraAbilityGameID' => 16538],
|
||||||
'Holos' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1003003],
|
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1000849, 'extraAbilityGameID' => 3613],
|
||||||
'Kerachole' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002618],
|
'Exaltation' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002717, 'extraAbilityGameID' => 25873], // Personal, AST auf Ziel
|
||||||
'Troubadour' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001934],
|
'Holos' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1003003, 'extraAbilityGameID' => 24310],
|
||||||
'Tactician' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001951],
|
'Kerachole' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002618, 'extraAbilityGameID' => 24298],
|
||||||
'Shield Samba' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001826],
|
'Troubadour' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001934, 'extraAbilityGameID' => 7405],
|
||||||
'Magick Barrier' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002707],
|
'Tactician' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001951, 'extraAbilityGameID' => 16889],
|
||||||
|
'Shield Samba' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1001826, 'extraAbilityGameID' => 16012],
|
||||||
|
'Magick Barrier' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002707, 'extraAbilityGameID' => 25857],
|
||||||
|
// ── Personal / targeted mitigation ─────────────────────────────────────
|
||||||
|
'Rampart' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 7531],
|
||||||
|
// PLD
|
||||||
|
'Hallowed Ground' => ['dr' => 100, 'buffType' => 'buff', 'extraAbilityGameID' => 30],
|
||||||
|
'Sentinel' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 17],
|
||||||
|
'Bulwark' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 22],
|
||||||
|
'Holy Sheltron' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 25746],
|
||||||
|
'Intervention' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 7382],
|
||||||
|
'Knight\'s Resolve' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002675, 'extraAbilityGameID' => 7382], // Proc von Intervention
|
||||||
|
// WAR
|
||||||
|
'Holmgang' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 43],
|
||||||
|
'Vengeance' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 44],
|
||||||
|
'Damnation' => ['dr' => 40, 'buffType' => 'buff', 'extraAbilityGameID' => 36923],
|
||||||
|
'Thrill of Battle' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 40],
|
||||||
|
'Raw Intuition' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 3551],
|
||||||
|
'Nascent Glint' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1001858, 'extraAbilityGameID' => 16464], // Proc von Nascent Flash auf Ziel
|
||||||
|
'Stem the Flow' => ['dr' => 10, 'buffType' => 'buff', 'statusId' => 1002679, 'extraAbilityGameID' => 25751], // Proc von Bloodwhetting / Nascent Flash
|
||||||
|
// DRK
|
||||||
|
'Living Dead' => ['dr' => 0, 'buffType' => 'buff', 'extraAbilityGameID' => 3638],
|
||||||
|
'Shadow Wall' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 3636],
|
||||||
|
'Shadowed Vigil' => ['dr' => 40, 'buffType' => 'buff', 'extraAbilityGameID' => 36927],
|
||||||
|
'Dark Mind' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 3634],
|
||||||
|
'The Blackest Night' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 7393],
|
||||||
|
'Oblation' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 25754],
|
||||||
|
// GNB
|
||||||
|
'Superbolide' => ['dr' => 100, 'buffType' => 'buff', 'extraAbilityGameID' => 16152],
|
||||||
|
'Nebula' => ['dr' => 30, 'buffType' => 'buff', 'extraAbilityGameID' => 16148],
|
||||||
|
'Great Nebula' => ['dr' => 40, 'buffType' => 'buff', 'statusId' => 1003838, 'extraAbilityGameID' => 36935],
|
||||||
|
'Camouflage' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 16140],
|
||||||
|
'Heart of Stone' => ['dr' => 15, 'buffType' => 'buff', 'extraAbilityGameID' => 16161],
|
||||||
|
'Heart of Corundum' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002683, 'extraAbilityGameID' => 25758],
|
||||||
|
'Clarity of Corundum' => ['dr' => 15, 'buffType' => 'buff', 'statusId' => 1002684, 'extraAbilityGameID' => 25758], // Proc von Heart of Corundum, kann beliebiges Partymitglied treffen
|
||||||
|
// DPS
|
||||||
|
'Riddle of Earth' => ['dr' => 20, 'buffType' => 'buff', 'extraAbilityGameID' => 7394],
|
||||||
|
'Shade Shift' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 2241],
|
||||||
|
'Third Eye' => ['dr' => 10, 'buffType' => 'buff', 'extraAbilityGameID' => 7498],
|
||||||
|
'Arcane Crest' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 24404],
|
||||||
|
'Manaward' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 157],
|
||||||
// ── Shields ─────────────────────────────────────────────────────────────
|
// ── Shields ─────────────────────────────────────────────────────────────
|
||||||
// PLD
|
// PLD
|
||||||
'Divine Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001362],
|
'Divine Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001362, 'extraAbilityGameID' => 3540],
|
||||||
'Guardian' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003830], // FFLogs: "Guardian's Will"
|
'Guardian' => ['dr' => 40, 'buffType' => 'shield', 'statusId' => 1003830, 'extraAbilityGameID' => 36920], // FFLogs: "Guardian's Will"
|
||||||
// WAR
|
// WAR
|
||||||
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457],
|
'Shake It Off' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001457, 'extraAbilityGameID' => 7388],
|
||||||
'Bloodwhetting' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002678],
|
'Bloodwhetting' => ['dr' => 10, 'buffType' => 'shield', 'statusId' => 1002678, 'extraAbilityGameID' => 25751],
|
||||||
|
'Stem the Tide' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002680, 'extraAbilityGameID' => 25751], // Proc von Bloodwhetting / Nascent Flash
|
||||||
// WHM
|
// WHM
|
||||||
'Divine Benison' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001218],
|
'Divine Benison' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001218, 'extraAbilityGameID' => 7432],
|
||||||
'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903],
|
'Divine Caress' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003903, 'extraAbilityGameID' => 37011],
|
||||||
// AST
|
// AST
|
||||||
'Intersection' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001889],
|
'Intersection' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001889, 'extraAbilityGameID' => 16556],
|
||||||
'Neutral Sect' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001921],
|
'Neutral Sect' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001921, 'extraAbilityGameID' => 16559],
|
||||||
'the Spire' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003892], // FFLogs: "The Spire"
|
'the Spire' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003892, 'extraAbilityGameID' => 37025], // FFLogs: "The Spire"
|
||||||
// SGE
|
// SGE
|
||||||
'Panhaima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002613],
|
'Panhaima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002613, 'extraAbilityGameID' => 24311],
|
||||||
'Holosakos' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003365],
|
'Holosakos' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003365, 'extraAbilityGameID' => 24310],
|
||||||
'Eukrasian Prognosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002609],
|
'Eukrasian Prognosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002609, 'extraAbilityGameID' => 24292],
|
||||||
'Eukrasian Prognosis II' => ['dr' => 0, 'buffType' => 'shield'], // TODO
|
'Eukrasian Prognosis II' => ['dr' => 0, 'buffType' => 'shield', 'extraAbilityGameID' => 37034],
|
||||||
'Eukrasian Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002607],
|
'Eukrasian Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002607, 'extraAbilityGameID' => 24291],
|
||||||
'Differential Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002608],
|
'Differential Diagnosis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002608, 'extraAbilityGameID' => 24291],
|
||||||
'Haima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002612],
|
'Haima' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002612, 'extraAbilityGameID' => 24305],
|
||||||
// SCH
|
// SCH
|
||||||
'Galvanize' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1000297],
|
'Galvanize' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1000297, 'extraAbilityGameID' => 185],
|
||||||
'Seraphic Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001917],
|
'Seraphic Veil' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001917, 'extraAbilityGameID' => 16548],
|
||||||
'Catalyze' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001918],
|
'Catalyze' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1001918, 'extraAbilityGameID' => 185],
|
||||||
// SMN
|
// SMN
|
||||||
'Radiant Aegis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002702],
|
'Radiant Aegis' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002702, 'extraAbilityGameID' => 25799],
|
||||||
// PCT
|
// PCT
|
||||||
'Tempera Coat' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003686],
|
'Tempera Coat' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003686, 'extraAbilityGameID' => 34685],
|
||||||
'Tempera Grassa' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003687],
|
'Tempera Grassa' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1003687, 'extraAbilityGameID' => 34686],
|
||||||
// DNC
|
// DNC
|
||||||
'Improvised Finish' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002697],
|
'Improvised Finish' => ['dr' => 0, 'buffType' => 'shield', 'statusId' => 1002697, 'extraAbilityGameID' => 25789],
|
||||||
// ── Boss debuffs ────────────────────────────────────────────────────────
|
// ── Boss debuffs ────────────────────────────────────────────────────────
|
||||||
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001193],
|
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001193, 'extraAbilityGameID' => 7535],
|
||||||
'Feint' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001195],
|
'Feint' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001195, 'extraAbilityGameID' => 7549],
|
||||||
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203],
|
'Addle' => ['dr' => 10, 'buffType' => 'debuff', 'statusId' => 1001203, 'extraAbilityGameID' => 7560],
|
||||||
];
|
];
|
||||||
|
|
||||||
function resolveMitigations(string $buffStr, array $mitigIdMap): array {
|
function resolveMitigations(string $buffStr, array $mitigIdMap, array $buffSourceTimeline = [], array $players = [], float $ts = 0): array {
|
||||||
if ($buffStr === '') return [];
|
if ($buffStr === '') return [];
|
||||||
$result = [];
|
$result = [];
|
||||||
$seen = [];
|
$seen = [];
|
||||||
@ -139,17 +188,33 @@ function resolveMitigations(string $buffStr, array $mitigIdMap): array {
|
|||||||
$name = $mitigIdMap[$id]['name'];
|
$name = $mitigIdMap[$id]['name'];
|
||||||
if (isset($seen[$name])) continue;
|
if (isset($seen[$name])) continue;
|
||||||
$seen[$name] = true;
|
$seen[$name] = true;
|
||||||
$result[] = [
|
$entry = [
|
||||||
'key' => $mitigIdMap[$id]['key'] ?? $name,
|
'key' => $mitigIdMap[$id]['key'] ?? $name,
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'dr' => $mitigIdMap[$id]['dr'],
|
'dr' => $mitigIdMap[$id]['dr'],
|
||||||
'buffType' => $mitigIdMap[$id]['buffType'],
|
'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;
|
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
|
// 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
|
// longer contains the shield ID (already removed), but the applybuff/removebuff
|
||||||
// timeline shows it was active just before the hit.
|
// timeline shows it was active just before the hit.
|
||||||
@ -161,7 +226,13 @@ function shieldsActiveAt(array $shieldTimeline, int $targetId, float $ts, array
|
|||||||
if ($iv['apply'] <= $ts && ($iv['remove'] === null || $iv['remove'] >= $ts - 200)) {
|
if ($iv['apply'] <= $ts && ($iv['remove'] === null || $iv['remove'] >= $ts - 200)) {
|
||||||
if (isset($mitigIdMap[$statusId])) {
|
if (isset($mitigIdMap[$statusId])) {
|
||||||
$m = $mitigIdMap[$statusId];
|
$m = $mitigIdMap[$statusId];
|
||||||
$result[] = ['key' => $m['key'] ?? $m['name'], 'name' => $m['name'], 'dr' => $m['dr'], 'buffType' => $m['buffType']];
|
$result[] = [
|
||||||
|
'key' => $m['key'] ?? $m['name'],
|
||||||
|
'name' => $m['name'],
|
||||||
|
'dr' => $m['dr'],
|
||||||
|
'buffType' => $m['buffType'],
|
||||||
|
'extraAbilityGameID' => $m['extraAbilityGameID'] ?? null,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -277,7 +348,9 @@ for ($page = 0; $page < 10; $page++) {
|
|||||||
// Builds applybuff/removebuff intervals per target so we can detect shields
|
// 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).
|
// that were consumed by a hit (absent from the damage event's buffs snapshot).
|
||||||
$shieldTimeline = []; // targetId → statusId → [[apply, remove|null], ...]
|
$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
|
$statusNames = []; // statusId → localized display name from Buffs events
|
||||||
|
$statusActionIds = []; // statusId → applybuff extraAbilityGameID from FFLogs
|
||||||
|
|
||||||
if (!empty($trackedStatusIds)) {
|
if (!empty($trackedStatusIds)) {
|
||||||
$nextPage = $startTime;
|
$nextPage = $startTime;
|
||||||
@ -312,12 +385,31 @@ if (!empty($trackedStatusIds)) {
|
|||||||
if (is_string($evName) && $evName !== '') {
|
if (is_string($evName) && $evName !== '') {
|
||||||
$statusNames[$abId] = $evName;
|
$statusNames[$abId] = $evName;
|
||||||
}
|
}
|
||||||
|
$extraAbilityGameID = (int)($ev['extraAbilityGameID'] ?? 0);
|
||||||
|
if ($extraAbilityGameID > 0) {
|
||||||
|
$statusActionIds[$abId] = $extraAbilityGameID;
|
||||||
|
}
|
||||||
|
|
||||||
$tgtId = (int)($ev['targetID'] ?? 0);
|
$tgtId = (int)($ev['targetID'] ?? 0);
|
||||||
$ts = (float)($ev['timestamp'] ?? 0);
|
$ts = (float)($ev['timestamp'] ?? 0);
|
||||||
$type = $ev['type'] ?? '';
|
$type = $ev['type'] ?? '';
|
||||||
$meta = $mitigIdMap[$abId] ?? null;
|
$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 (($meta['buffType'] ?? null) !== 'shield') continue;
|
||||||
|
|
||||||
if ($type === 'applybuff') {
|
if ($type === 'applybuff') {
|
||||||
@ -339,11 +431,60 @@ 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) {
|
foreach ($statusNames as $statusId => $displayName) {
|
||||||
if (isset($mitigIdMap[$statusId])) {
|
if (isset($mitigIdMap[$statusId])) {
|
||||||
$mitigIdMap[$statusId]['name'] = $displayName;
|
$mitigIdMap[$statusId]['name'] = $displayName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
foreach ($statusActionIds as $statusId => $extraAbilityGameID) {
|
||||||
|
if (isset($mitigIdMap[$statusId])) {
|
||||||
|
$mitigIdMap[$statusId]['extraAbilityGameID'] = $extraAbilityGameID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$mitigationNames = [];
|
$mitigationNames = [];
|
||||||
foreach ($mitigIdMap as $meta) {
|
foreach ($mitigIdMap as $meta) {
|
||||||
@ -368,6 +509,9 @@ foreach ($allEvents as $ev) {
|
|||||||
$tgtId = (int)($ev['targetID'] ?? 0);
|
$tgtId = (int)($ev['targetID'] ?? 0);
|
||||||
if (!$abId || !$tgtId || $abId <= 7) continue;
|
if (!$abId || !$tgtId || $abId <= 7) continue;
|
||||||
|
|
||||||
|
$srcId = (int)($ev['sourceID'] ?? 0);
|
||||||
|
if ($srcId > 0 && isset($players[$srcId])) continue;
|
||||||
|
|
||||||
$byAbility[$abId][] = [
|
$byAbility[$abId][] = [
|
||||||
'ts' => (float)($ev['timestamp'] ?? 0),
|
'ts' => (float)($ev['timestamp'] ?? 0),
|
||||||
'tgtId' => $tgtId,
|
'tgtId' => $tgtId,
|
||||||
@ -427,6 +571,7 @@ foreach ($byAbility as $abId => $events) {
|
|||||||
if ($current !== null) $clusters[] = $current;
|
if ($current !== null) $clusters[] = $current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$bossEvents = [];
|
||||||
$aoeEvents = [];
|
$aoeEvents = [];
|
||||||
foreach ($clusters as $group) {
|
foreach ($clusters as $group) {
|
||||||
$targetCount = count($group['targets']);
|
$targetCount = count($group['targets']);
|
||||||
@ -448,8 +593,6 @@ foreach ($clusters as $group) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($targetCount < 3 && !$isHeavyTankbuster) continue;
|
|
||||||
|
|
||||||
$targets = [];
|
$targets = [];
|
||||||
foreach ($group['targets'] as $tgtId => $tgt) {
|
foreach ($group['targets'] as $tgtId => $tgt) {
|
||||||
$p = $players[$tgtId] ?? null;
|
$p = $players[$tgtId] ?? null;
|
||||||
@ -465,8 +608,8 @@ foreach ($clusters as $group) {
|
|||||||
'overkill' => $tgt['overkill'],
|
'overkill' => $tgt['overkill'],
|
||||||
'hp' => $tgt['hp'],
|
'hp' => $tgt['hp'],
|
||||||
'maxHp' => $tgt['maxHp'],
|
'maxHp' => $tgt['maxHp'],
|
||||||
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline) {
|
'mitigations' => (function() use ($tgt, $mitigIdMap, $shieldTimeline, $buffSourceTimeline, $players) {
|
||||||
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap);
|
$mitigations = resolveMitigations($tgt['buffs'], $mitigIdMap, $buffSourceTimeline, $players, $tgt['ts']);
|
||||||
if ($tgt['absorbed'] > 0 && !empty($shieldTimeline)) {
|
if ($tgt['absorbed'] > 0 && !empty($shieldTimeline)) {
|
||||||
$existing = [];
|
$existing = [];
|
||||||
foreach ($mitigations as $m) {
|
foreach ($mitigations as $m) {
|
||||||
@ -486,7 +629,7 @@ foreach ($clusters as $group) {
|
|||||||
return $roleCmp !== 0 ? $roleCmp : strcmp($a['name'], $b['name']);
|
return $roleCmp !== 0 ? $roleCmp : strcmp($a['name'], $b['name']);
|
||||||
});
|
});
|
||||||
|
|
||||||
$aoeEvents[] = [
|
$bossEvent = [
|
||||||
'timestamp' => $group['timestamp'],
|
'timestamp' => $group['timestamp'],
|
||||||
'abilityId' => $group['abilityId'],
|
'abilityId' => $group['abilityId'],
|
||||||
'abilityName' => $group['abilityName'],
|
'abilityName' => $group['abilityName'],
|
||||||
@ -494,12 +637,27 @@ foreach ($clusters as $group) {
|
|||||||
'totalDamage' => array_sum(array_column($targets, 'amount')),
|
'totalDamage' => array_sum(array_column($targets, 'amount')),
|
||||||
'isHeavyTankbuster' => $isHeavyTankbuster,
|
'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']);
|
usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
|
||||||
|
|
||||||
echo json_encode([
|
$response = json_encode([
|
||||||
'players' => array_values($players),
|
'players' => array_values($players),
|
||||||
|
'boss_events' => $bossEvents,
|
||||||
'aoe_events' => $aoeEvents,
|
'aoe_events' => $aoeEvents,
|
||||||
'fight_start' => (int)$startTime,
|
'fight_start' => (int)$startTime,
|
||||||
'mitigation_names' => $mitigationNames,
|
'mitigation_names' => $mitigationNames,
|
||||||
]);
|
]);
|
||||||
|
if ($response === false) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Could not encode analysis response']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_cached_log('analysis', $reportCode, $language, $cacheParts, $response);
|
||||||
|
echo $response;
|
||||||
|
|||||||
52
api/cache.php
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
const CACHED_LOG_DIR = __DIR__ . '/../cached_logs';
|
||||||
|
const CACHED_LOG_VERSION = 'v8';
|
||||||
|
|
||||||
|
function cache_language(string $language): string {
|
||||||
|
$language = strtolower(trim($language));
|
||||||
|
return in_array($language, ['en', 'de', 'fr', 'jp'], true) ? $language : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cache_report_code(string $reportCode): string {
|
||||||
|
return preg_replace('/[^a-zA-Z0-9]/', '', $reportCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cache_key_part(string|int|float|null $value): string {
|
||||||
|
$value = (string)($value ?? '');
|
||||||
|
$value = preg_replace('/[^a-zA-Z0-9._-]/', '_', $value);
|
||||||
|
return trim($value, '_') ?: 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cached_log_path(string $kind, string $reportCode, string $language, array $parts = []): string {
|
||||||
|
$reportCode = cache_report_code($reportCode);
|
||||||
|
$language = cache_language($language);
|
||||||
|
$kind = cache_key_part($kind);
|
||||||
|
$safeParts = array_map('cache_key_part', $parts);
|
||||||
|
$suffix = $safeParts ? '_' . implode('_', $safeParts) : '';
|
||||||
|
|
||||||
|
return CACHED_LOG_DIR . '/' . CACHED_LOG_VERSION . '/' . $reportCode . '/' . $language . '/' . $kind . $suffix . '.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
function read_cached_log(string $kind, string $reportCode, string $language, array $parts = []): ?string {
|
||||||
|
$path = cached_log_path($kind, $reportCode, $language, $parts);
|
||||||
|
if (!is_file($path)) return null;
|
||||||
|
|
||||||
|
$body = file_get_contents($path);
|
||||||
|
if ($body === false || trim($body) === '') return null;
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function write_cached_log(string $kind, string $reportCode, string $language, array $parts, string $body): void {
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
if (!is_array($decoded) || isset($decoded['error'], $decoded['errors'], $decoded['reauth'])) return;
|
||||||
|
|
||||||
|
$path = cached_log_path($kind, $reportCode, $language, $parts);
|
||||||
|
$dir = dirname($path);
|
||||||
|
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) return;
|
||||||
|
|
||||||
|
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
||||||
|
if (file_put_contents($tmp, $body, LOCK_EX) === false) return;
|
||||||
|
rename($tmp, $path);
|
||||||
|
}
|
||||||
@ -87,13 +87,20 @@ if ($playerName !== '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch events
|
// Fetch events + masterData abilities (incl. type)
|
||||||
$includeResources = in_array($dataType, ['DamageTaken', 'DamageDone']) ? 'includeResources: true,' : '';
|
$includeResources = in_array($dataType, ['DamageTaken', 'DamageDone']) ? 'includeResources: true,' : '';
|
||||||
|
|
||||||
$result = dbg_gql(<<<GQL
|
$result = dbg_gql(<<<GQL
|
||||||
{
|
{
|
||||||
reportData {
|
reportData {
|
||||||
report(code: "$reportCode") {
|
report(code: "$reportCode") {
|
||||||
|
masterData(translate: false) {
|
||||||
|
abilities {
|
||||||
|
gameID
|
||||||
|
name
|
||||||
|
type
|
||||||
|
}
|
||||||
|
}
|
||||||
events(
|
events(
|
||||||
fightIDs: [$fightId],
|
fightIDs: [$fightId],
|
||||||
dataType: $dataType,
|
dataType: $dataType,
|
||||||
@ -111,7 +118,14 @@ GQL);
|
|||||||
|
|
||||||
$events = $result['data']['reportData']['report']['events']['data'] ?? [];
|
$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
|
// Filter by raw event type, player (source OR target), then apply limit
|
||||||
|
// Enrich each event with ability meta (name + type) from masterData
|
||||||
$filtered = [];
|
$filtered = [];
|
||||||
foreach ($events as $ev) {
|
foreach ($events as $ev) {
|
||||||
if ($eventType !== '' && ($ev['type'] ?? '') !== $eventType) continue;
|
if ($eventType !== '' && ($ev['type'] ?? '') !== $eventType) continue;
|
||||||
@ -121,6 +135,11 @@ foreach ($events as $ev) {
|
|||||||
$tgtId = (int)($ev['targetID'] ?? -1);
|
$tgtId = (int)($ev['targetID'] ?? -1);
|
||||||
if (!in_array($srcId, $playerIds) && !in_array($tgtId, $playerIds)) continue;
|
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;
|
$filtered[] = $ev;
|
||||||
if (count($filtered) >= $limit) break;
|
if (count($filtered) >= $limit) break;
|
||||||
}
|
}
|
||||||
@ -131,11 +150,9 @@ echo json_encode([
|
|||||||
'ability_id' => $abilityId ?: null,
|
'ability_id' => $abilityId ?: null,
|
||||||
'player_name' => $playerName ?: null,
|
'player_name' => $playerName ?: null,
|
||||||
'player_ids' => $playerIds ?: null,
|
'player_ids' => $playerIds ?: null,
|
||||||
'time_range' => [
|
'time_range' => ['from_ms' => (int)$queryStart, 'to_ms' => (int)$queryEnd],
|
||||||
'from_ms' => (int)$queryStart,
|
|
||||||
'to_ms' => (int)$queryEnd,
|
|
||||||
],
|
|
||||||
'total_before_limit' => count($events),
|
'total_before_limit' => count($events),
|
||||||
'count' => count($filtered),
|
'count' => count($filtered),
|
||||||
|
'ability_meta' => $abilityMeta, // vollständige Lookup-Tabelle: gameID → {name, type}
|
||||||
'events' => $filtered,
|
'events' => $filtered,
|
||||||
], JSON_PRETTY_PRINT);
|
], JSON_PRETTY_PRINT);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
ini_set('display_errors', '0');
|
ini_set('display_errors', '0');
|
||||||
require_once __DIR__ . '/../config.php';
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/cache.php';
|
||||||
session_start_safe();
|
session_start_safe();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@ -21,6 +22,12 @@ if (strlen($reportCode) < 1) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cached = read_cached_log('fight', $reportCode, $language);
|
||||||
|
if ($cached !== null) {
|
||||||
|
echo $cached;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($_SESSION['access_token'])) {
|
if (empty($_SESSION['access_token'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['error' => 'Not authenticated', 'reauth' => true]);
|
echo json_encode(['error' => 'Not authenticated', 'reauth' => true]);
|
||||||
@ -42,6 +49,7 @@ query GetReportData($reportCode: String!) {
|
|||||||
endTime
|
endTime
|
||||||
fights {
|
fights {
|
||||||
id
|
id
|
||||||
|
encounterID
|
||||||
name
|
name
|
||||||
startTime
|
startTime
|
||||||
endTime
|
endTime
|
||||||
@ -75,9 +83,7 @@ function localized_graphql_uri(string $language): string {
|
|||||||
return preg_replace('#https://[^/]+#', 'https://' . $host, GRAPHQL_URI);
|
return preg_replace('#https://[^/]+#', 'https://' . $host, GRAPHQL_URI);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fight names must be stable regardless of language — always use the English endpoint.
|
$ch = curl_init(localized_graphql_uri($language));
|
||||||
// Localization only matters for ability/player names in analysis.php.
|
|
||||||
$ch = curl_init(GRAPHQL_URI);
|
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_POSTFIELDS => $payload,
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
@ -85,12 +91,14 @@ curl_setopt_array($ch, [
|
|||||||
CURLOPT_HTTPHEADER => [
|
CURLOPT_HTTPHEADER => [
|
||||||
'Content-Type: application/json',
|
'Content-Type: application/json',
|
||||||
'Authorization: Bearer ' . $_SESSION['access_token'],
|
'Authorization: Bearer ' . $_SESSION['access_token'],
|
||||||
|
'Accept-Language: ' . ($language === 'jp' ? 'ja' : $language),
|
||||||
],
|
],
|
||||||
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
|
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
|
||||||
]);
|
]);
|
||||||
$body = curl_exec($ch);
|
$body = curl_exec($ch);
|
||||||
$httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
$curlError = curl_error($ch);
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
if ($curlError) {
|
if ($curlError) {
|
||||||
http_response_code(502);
|
http_response_code(502);
|
||||||
@ -112,5 +120,8 @@ if ($httpStatus === 401) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http_response_code($httpStatus === 200 ? 200 : $httpStatus);
|
http_response_code($httpStatus === 200 ? 200 : $httpStatus);
|
||||||
|
if ($httpStatus === 200) {
|
||||||
|
write_cached_log('fight', $reportCode, $language, [], $body);
|
||||||
|
}
|
||||||
echo $body;
|
echo $body;
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
112
api/players.php
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/cache.php';
|
||||||
|
session_start_safe();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
|
||||||
|
|
||||||
|
$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? '');
|
||||||
|
$fightId = (int)($_POST['fight_id'] ?? 0);
|
||||||
|
$startTime = (float)($_POST['start_time'] ?? 0);
|
||||||
|
$endTime = (float)($_POST['end_time'] ?? 0);
|
||||||
|
|
||||||
|
if (!$reportCode || !$fightId || !$endTime) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Missing params']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheParts = [$fightId, (int)$startTime, (int)$endTime];
|
||||||
|
$cached = read_cached_log('players', $reportCode, 'en', $cacheParts);
|
||||||
|
if ($cached !== null) {
|
||||||
|
echo $cached;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($_SESSION['access_token'])) { echo json_encode(['reauth' => true]); exit; }
|
||||||
|
if (($_SESSION['token_expires'] ?? 0) <= time()) { echo json_encode(['reauth' => true]); exit; }
|
||||||
|
|
||||||
|
$token = $_SESSION['access_token'];
|
||||||
|
|
||||||
|
function pl_gql(string $query): array {
|
||||||
|
global $token;
|
||||||
|
$ch = curl_init(GRAPHQL_URI);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode(['query' => $query]),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
|
||||||
|
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($err) return ['_curl_error' => $err];
|
||||||
|
if ($code === 401) return ['_reauth' => true];
|
||||||
|
return json_decode($body, true) ?? ['_parse_error' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single query: playerDetails + first page of DamageTaken with resources to get maxHp
|
||||||
|
$result = pl_gql(<<<GQL
|
||||||
|
{
|
||||||
|
reportData {
|
||||||
|
report(code: "$reportCode") {
|
||||||
|
playerDetails(fightIDs: [$fightId])
|
||||||
|
events(
|
||||||
|
fightIDs: [$fightId],
|
||||||
|
dataType: DamageTaken,
|
||||||
|
startTime: $startTime,
|
||||||
|
endTime: $endTime,
|
||||||
|
includeResources: true
|
||||||
|
) {
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GQL);
|
||||||
|
|
||||||
|
if (isset($result['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
|
||||||
|
if (isset($result['_curl_error'])) { http_response_code(502); echo json_encode(['error' => $result['_curl_error']]); exit; }
|
||||||
|
|
||||||
|
// Parse player details: id → {name, type, role, maxHp}
|
||||||
|
$pdRaw = $result['data']['reportData']['report']['playerDetails'] ?? null;
|
||||||
|
$pdParsed = is_string($pdRaw) ? json_decode($pdRaw, true) : $pdRaw;
|
||||||
|
$pdGroups = $pdParsed['data']['playerDetails'] ?? [];
|
||||||
|
|
||||||
|
$players = [];
|
||||||
|
$roleMap = ['tanks' => 'tank', 'healers' => 'healer', 'dps' => 'dps'];
|
||||||
|
foreach ($roleMap as $group => $role) {
|
||||||
|
foreach ($pdGroups[$group] ?? [] as $p) {
|
||||||
|
$players[(int)$p['id']] = [
|
||||||
|
'name' => $p['name'],
|
||||||
|
'type' => $p['type'] ?? '',
|
||||||
|
'role' => $role,
|
||||||
|
'maxHp' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract maxHp from first damage events (skip DoT ticks — they may lack resources)
|
||||||
|
foreach ($result['data']['reportData']['report']['events']['data'] ?? [] as $ev) {
|
||||||
|
if ($ev['tick'] ?? false) continue;
|
||||||
|
$tid = (int)($ev['targetID'] ?? 0);
|
||||||
|
$maxHp = (int)($ev['targetResources']['maxHitPoints'] ?? 0);
|
||||||
|
if (isset($players[$tid]) && $players[$tid]['maxHp'] === 0 && $maxHp > 0) {
|
||||||
|
$players[$tid]['maxHp'] = $maxHp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = json_encode(['players' => array_values($players)]);
|
||||||
|
if ($response === false) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Could not encode players response']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_cached_log('players', $reportCode, 'en', $cacheParts, $response);
|
||||||
|
echo $response;
|
||||||
BIN
assets/icons/mitigation/aquaveil.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
assets/icons/mitigation/clarity-of-corundum.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
assets/icons/mitigation/exaltation.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/icons/mitigation/heart-of-corundum.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
assets/icons/mitigation/heart-of-stone.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
assets/icons/mitigation/knights-resolve.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
assets/icons/mitigation/nascent-glint.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
assets/icons/mitigation/stem-the-flow.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
assets/icons/mitigation/stem-the-tide.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
818
assets/jsons/Action.json
Normal file
@ -0,0 +1,818 @@
|
|||||||
|
{
|
||||||
|
"17": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Sentinel",
|
||||||
|
"de": "Sentinel",
|
||||||
|
"fr": "Sentinelle",
|
||||||
|
"jp": "センチネル"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/000000/000151_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"22": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 900,
|
||||||
|
"names": {
|
||||||
|
"en": "Bulwark",
|
||||||
|
"de": "Bollwerk",
|
||||||
|
"fr": "Forteresse",
|
||||||
|
"jp": "ブルワーク"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/000000/000167_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"30": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 4200,
|
||||||
|
"names": {
|
||||||
|
"en": "Hallowed Ground",
|
||||||
|
"de": "Heiliger Boden",
|
||||||
|
"fr": "Invincible",
|
||||||
|
"jp": "インビンシブル"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002502_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"40": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 900,
|
||||||
|
"names": {
|
||||||
|
"en": "Thrill of Battle",
|
||||||
|
"de": "Kampfrausch",
|
||||||
|
"fr": "Frisson de la bataille",
|
||||||
|
"jp": "スリル・オブ・バトル"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/000000/000263_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"43": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 2400,
|
||||||
|
"names": {
|
||||||
|
"en": "Holmgang",
|
||||||
|
"de": "Holmgang",
|
||||||
|
"fr": "Holmgang",
|
||||||
|
"jp": "ホルムギャング"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/000000/000266_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"44": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Vengeance",
|
||||||
|
"de": "Rachsucht",
|
||||||
|
"fr": "Représailles",
|
||||||
|
"jp": "ヴェンジェンス"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/000000/000267_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"157": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Manaward",
|
||||||
|
"de": "Mana-Schild",
|
||||||
|
"fr": "Barrière de mana",
|
||||||
|
"jp": "マバリア"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/000000/000463_hr1.png",
|
||||||
|
"shield": "30% max HP"
|
||||||
|
},
|
||||||
|
"185": {
|
||||||
|
"cast": 20,
|
||||||
|
"recast": 25,
|
||||||
|
"names": {
|
||||||
|
"en": "Adloquium",
|
||||||
|
"de": "Adloquium",
|
||||||
|
"fr": "Traité du réconfort",
|
||||||
|
"jp": "鼓舞激励の策"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002801_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"188": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 300,
|
||||||
|
"names": {
|
||||||
|
"en": "Sacred Soil",
|
||||||
|
"de": "Geweihte Erde",
|
||||||
|
"fr": "Dogme de survie",
|
||||||
|
"jp": "野戦治療の陣"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002804_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"2241": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Shade Shift",
|
||||||
|
"de": "Superkniff",
|
||||||
|
"fr": "Décalage d'ombre",
|
||||||
|
"jp": "残影"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/000000/000607_hr1.png",
|
||||||
|
"shield": "20% max HP"
|
||||||
|
},
|
||||||
|
"3540": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 900,
|
||||||
|
"names": {
|
||||||
|
"en": "Divine Veil",
|
||||||
|
"de": "Heiliger Quell",
|
||||||
|
"fr": "Voile divin",
|
||||||
|
"jp": "ディヴァインヴェール"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002508_hr1.png",
|
||||||
|
"shield": "10% max HP"
|
||||||
|
},
|
||||||
|
"3551": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 250,
|
||||||
|
"names": {
|
||||||
|
"en": "Raw Intuition",
|
||||||
|
"de": "Urinstinkt",
|
||||||
|
"fr": "Intuition pure",
|
||||||
|
"jp": "原初の直感"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002559_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"3613": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 600,
|
||||||
|
"names": {
|
||||||
|
"en": "Collective Unconscious",
|
||||||
|
"de": "Numinosum",
|
||||||
|
"fr": "Inconscient collectif",
|
||||||
|
"jp": "運命の輪"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003140_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"3634": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 600,
|
||||||
|
"names": {
|
||||||
|
"en": "Dark Mind",
|
||||||
|
"de": "Dunkler Geist",
|
||||||
|
"fr": "Esprit ténébreux",
|
||||||
|
"jp": "ダークマインド"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003076_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"3636": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Shadow Wall",
|
||||||
|
"de": "Schattenwand",
|
||||||
|
"fr": "Mur d'ombre",
|
||||||
|
"jp": "シャドウウォール"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003075_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"3638": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 3000,
|
||||||
|
"names": {
|
||||||
|
"en": "Living Dead",
|
||||||
|
"de": "Totenerweckung",
|
||||||
|
"fr": "Mort-vivant",
|
||||||
|
"jp": "リビングデッド"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003077_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"7382": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 100,
|
||||||
|
"names": {
|
||||||
|
"en": "Intervention",
|
||||||
|
"de": "Intervention",
|
||||||
|
"fr": "Intervention",
|
||||||
|
"jp": "インターベンション"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002512_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"7385": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Passage of Arms",
|
||||||
|
"de": "Waffengang",
|
||||||
|
"fr": "Passe d'armes",
|
||||||
|
"jp": "パッセージ・オブ・アームズ"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002515_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"7388": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 900,
|
||||||
|
"names": {
|
||||||
|
"en": "Shake It Off",
|
||||||
|
"de": "Abschütteln",
|
||||||
|
"fr": "Débarrassage",
|
||||||
|
"jp": "シェイクオフ"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002563_hr1.png",
|
||||||
|
"shield": "15% max HP"
|
||||||
|
},
|
||||||
|
"7393": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 150,
|
||||||
|
"names": {
|
||||||
|
"en": "The Blackest Night",
|
||||||
|
"de": "Schwärzeste Nacht",
|
||||||
|
"fr": "Nuit noirissime",
|
||||||
|
"jp": "ブラックナイト"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003081_hr1.png",
|
||||||
|
"shield": "25% max HP"
|
||||||
|
},
|
||||||
|
"7394": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Riddle of Earth",
|
||||||
|
"de": "Steinernes Enigma",
|
||||||
|
"fr": "Énigme de la terre",
|
||||||
|
"jp": "金剛の極意"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002537_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"7405": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Troubadour",
|
||||||
|
"de": "Troubadour",
|
||||||
|
"fr": "Troubadour",
|
||||||
|
"jp": "トルバドゥール"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002612_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"7432": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 300,
|
||||||
|
"names": {
|
||||||
|
"en": "Divine Benison",
|
||||||
|
"de": "Göttlicher Segen",
|
||||||
|
"fr": "Faveur divine",
|
||||||
|
"jp": "ディヴァインベニゾン"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002638_hr1.png",
|
||||||
|
"shield": "500 potency"
|
||||||
|
},
|
||||||
|
"7498": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 150,
|
||||||
|
"names": {
|
||||||
|
"en": "Third Eye",
|
||||||
|
"de": "Drittes Auge",
|
||||||
|
"fr": "Troisième œil",
|
||||||
|
"jp": "心眼"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003153_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"7531": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 900,
|
||||||
|
"names": {
|
||||||
|
"en": "Rampart",
|
||||||
|
"de": "Schutzwall",
|
||||||
|
"fr": "Rempart",
|
||||||
|
"jp": "ランパート"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/000000/000801_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"7535": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 600,
|
||||||
|
"names": {
|
||||||
|
"en": "Reprisal",
|
||||||
|
"de": "Reflexion",
|
||||||
|
"fr": "Rétorsion",
|
||||||
|
"jp": "リプライザル"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/000000/000806_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"7549": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 900,
|
||||||
|
"names": {
|
||||||
|
"en": "Feint",
|
||||||
|
"de": "Zermürben",
|
||||||
|
"fr": "Restreinte",
|
||||||
|
"jp": "牽制"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/000000/000828_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"7560": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 900,
|
||||||
|
"names": {
|
||||||
|
"en": "Addle",
|
||||||
|
"de": "Stumpfsinn",
|
||||||
|
"fr": "Embrouillement",
|
||||||
|
"jp": "アドル"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/000000/000861_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"16012": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Shield Samba",
|
||||||
|
"de": "Schildsamba",
|
||||||
|
"fr": "Samba protectrice",
|
||||||
|
"jp": "守りのサンバ"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003469_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"16140": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 900,
|
||||||
|
"names": {
|
||||||
|
"en": "Camouflage",
|
||||||
|
"de": "Camouflage",
|
||||||
|
"fr": "Camouflage",
|
||||||
|
"jp": "カモフラージュ"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003404_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"16148": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Nebula",
|
||||||
|
"de": "Nebula",
|
||||||
|
"fr": "Nébuleuse",
|
||||||
|
"jp": "ネビュラ"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003412_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"16152": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 3600,
|
||||||
|
"names": {
|
||||||
|
"en": "Superbolide",
|
||||||
|
"de": "Meteoritenfall",
|
||||||
|
"fr": "Bolide",
|
||||||
|
"jp": "ボーライド"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003416_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"16160": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 900,
|
||||||
|
"names": {
|
||||||
|
"en": "Heart of Light",
|
||||||
|
"de": "Wackeres Herz",
|
||||||
|
"fr": "Cœur de Lumière",
|
||||||
|
"jp": "ハート・オブ・ライト"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003424_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"16161": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 250,
|
||||||
|
"names": {
|
||||||
|
"en": "Heart of Stone",
|
||||||
|
"de": "Steinernes Herz",
|
||||||
|
"fr": "Cœur de pierre",
|
||||||
|
"jp": "ハート・オブ・ストーン"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003425_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"16471": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 900,
|
||||||
|
"names": {
|
||||||
|
"en": "Dark Missionary",
|
||||||
|
"de": "Dunkler Bote",
|
||||||
|
"fr": "Missionnaire des Ténèbres",
|
||||||
|
"jp": "ダークミッショナリー"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003087_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"16536": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Temperance",
|
||||||
|
"de": "Linderung",
|
||||||
|
"fr": "Tempérance",
|
||||||
|
"jp": "テンパランス"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002645_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"16538": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Fey Illumination",
|
||||||
|
"de": "Illumination",
|
||||||
|
"fr": "Illumination féérique",
|
||||||
|
"jp": "フェイイルミネーション"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002853_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"16548": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 30,
|
||||||
|
"names": {
|
||||||
|
"en": "Seraphic Veil",
|
||||||
|
"de": "Schleier der Seraphim",
|
||||||
|
"fr": "Voile séraphique",
|
||||||
|
"jp": "セラフィックヴェール"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002847_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"16556": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 300,
|
||||||
|
"names": {
|
||||||
|
"en": "Celestial Intersection",
|
||||||
|
"de": "Kongruenz",
|
||||||
|
"fr": "Rencontre céleste",
|
||||||
|
"jp": "星天交差"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003556_hr1.png",
|
||||||
|
"shield": "200% of HP restored"
|
||||||
|
},
|
||||||
|
"16559": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Neutral Sect",
|
||||||
|
"de": "Neutral",
|
||||||
|
"fr": "Adepte de la neutralité",
|
||||||
|
"jp": "ニュートラルセクト"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003552_hr1.png",
|
||||||
|
"shield": "250% of HP restored"
|
||||||
|
},
|
||||||
|
"16889": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Tactician",
|
||||||
|
"de": "Taktiker",
|
||||||
|
"fr": "Tacticien",
|
||||||
|
"jp": "タクティシャン"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003040_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"24291": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 15,
|
||||||
|
"names": {
|
||||||
|
"en": "Eukrasian Diagnosis",
|
||||||
|
"de": "Eukratische Diagnose",
|
||||||
|
"fr": "Diagnosis eucrasique",
|
||||||
|
"jp": "エウクラシア・ディアグノシス"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003659_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"24292": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 15,
|
||||||
|
"names": {
|
||||||
|
"en": "Eukrasian Prognosis",
|
||||||
|
"de": "Eukratische Prognose",
|
||||||
|
"fr": "Prognosis eucrasique",
|
||||||
|
"jp": "エウクラシア・プログノシス"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003660_hr1.png",
|
||||||
|
"shield": "320% of HP restored"
|
||||||
|
},
|
||||||
|
"24298": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 300,
|
||||||
|
"names": {
|
||||||
|
"en": "Kerachole",
|
||||||
|
"de": "Kerachole",
|
||||||
|
"fr": "Kerachole",
|
||||||
|
"jp": "ケーラコレ"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003666_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"24305": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Haima",
|
||||||
|
"de": "Haima",
|
||||||
|
"fr": "Haima",
|
||||||
|
"jp": "ハイマ"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003673_hr1.png",
|
||||||
|
"shield": "300 potency"
|
||||||
|
},
|
||||||
|
"24310": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Holos",
|
||||||
|
"de": "Holos",
|
||||||
|
"fr": "Holos",
|
||||||
|
"jp": "ホーリズム"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003678_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"24311": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Panhaima",
|
||||||
|
"de": "Panhaima",
|
||||||
|
"fr": "Panhaima",
|
||||||
|
"jp": "パンハイマ"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003679_hr1.png",
|
||||||
|
"shield": "200 potency"
|
||||||
|
},
|
||||||
|
"24404": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 300,
|
||||||
|
"names": {
|
||||||
|
"en": "Arcane Crest",
|
||||||
|
"de": "Arkanes Wappen",
|
||||||
|
"fr": "Blason arcanique",
|
||||||
|
"jp": "アルケインクレスト"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003632_hr1.png",
|
||||||
|
"shield": "10% max HP"
|
||||||
|
},
|
||||||
|
"25746": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 50,
|
||||||
|
"names": {
|
||||||
|
"en": "Holy Sheltron",
|
||||||
|
"de": "Heiliges Schiltron",
|
||||||
|
"fr": "Schiltron sacré",
|
||||||
|
"jp": "ホーリーシェルトロン"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002950_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"25751": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 250,
|
||||||
|
"names": {
|
||||||
|
"en": "Bloodwhetting",
|
||||||
|
"de": "Urimpuls",
|
||||||
|
"fr": "Intuition fougueuse",
|
||||||
|
"jp": "原初の血気"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002569_hr1.png",
|
||||||
|
"shield": "400 potency"
|
||||||
|
},
|
||||||
|
"25754": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 600,
|
||||||
|
"names": {
|
||||||
|
"en": "Oblation",
|
||||||
|
"de": "Opfergabe",
|
||||||
|
"fr": "Oblation",
|
||||||
|
"jp": "オブレーション"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003089_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"25758": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 250,
|
||||||
|
"names": {
|
||||||
|
"en": "Heart of Corundum",
|
||||||
|
"de": "Herz des Korunds",
|
||||||
|
"fr": "Cœur de corindon",
|
||||||
|
"jp": "ハート・オブ・コランダム"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003430_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"25789": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 15,
|
||||||
|
"names": {
|
||||||
|
"en": "Improvised Finish",
|
||||||
|
"de": "Improvisiertes Finale",
|
||||||
|
"fr": "Final improvisé",
|
||||||
|
"jp": "インプロビゼーション・フィニッシュ"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003479_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"25799": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 600,
|
||||||
|
"names": {
|
||||||
|
"en": "Radiant Aegis",
|
||||||
|
"de": "Schimmerschild",
|
||||||
|
"fr": "Égide rayonnante",
|
||||||
|
"jp": "守りの光"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002750_hr1.png",
|
||||||
|
"shield": "20% max HP"
|
||||||
|
},
|
||||||
|
"25857": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Magick Barrier",
|
||||||
|
"de": "Magiebarriere",
|
||||||
|
"fr": "Barrière anti-magie",
|
||||||
|
"jp": "バマジク"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003237_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"25868": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Expedient",
|
||||||
|
"de": "Sturm und Drang",
|
||||||
|
"fr": "Thèse fluidique",
|
||||||
|
"jp": "疾風怒濤の計"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002878_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"34685": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Tempera Coat",
|
||||||
|
"de": "Tempera-Schicht",
|
||||||
|
"fr": "Enduit a tempera",
|
||||||
|
"jp": "テンペラコート"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003835_hr1.png",
|
||||||
|
"shield": "20% max HP"
|
||||||
|
},
|
||||||
|
"34686": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 10,
|
||||||
|
"names": {
|
||||||
|
"en": "Tempera Grassa",
|
||||||
|
"de": "Fette Tempera",
|
||||||
|
"fr": "Tempera grassa",
|
||||||
|
"jp": "テンペラグラッサ"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003836_hr1.png",
|
||||||
|
"shield": "10% max HP"
|
||||||
|
},
|
||||||
|
"36920": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Guardian",
|
||||||
|
"de": "Heilige Wacht",
|
||||||
|
"fr": "Garde extrême",
|
||||||
|
"jp": "エクストリームガード"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002524_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"36923": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Damnation",
|
||||||
|
"de": "Verdammnis",
|
||||||
|
"fr": "Damnation",
|
||||||
|
"jp": "ダムネーション"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002573_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"36927": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Shadowed Vigil",
|
||||||
|
"de": "Schattenwacht",
|
||||||
|
"fr": "Vigile ténébreux",
|
||||||
|
"jp": "シャドウヴィジル"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003094_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"36935": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 1200,
|
||||||
|
"names": {
|
||||||
|
"en": "Great Nebula",
|
||||||
|
"de": "Große Nebula",
|
||||||
|
"fr": "Grande nébuleuse",
|
||||||
|
"jp": "グレートネビュラ"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003435_hr1.png",
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"37011": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 10,
|
||||||
|
"names": {
|
||||||
|
"en": "Divine Caress",
|
||||||
|
"de": "Göttliche Umarmung",
|
||||||
|
"fr": "Caresse divine",
|
||||||
|
"jp": "ディヴァインカレス"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/002000/002128_hr1.png",
|
||||||
|
"shield": "400 potency"
|
||||||
|
},
|
||||||
|
"37025": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 10,
|
||||||
|
"names": {
|
||||||
|
"en": "the Spire",
|
||||||
|
"de": "Turm",
|
||||||
|
"fr": "La Tour",
|
||||||
|
"jp": "ビエルゴの塔"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003115_hr1.png",
|
||||||
|
"shield": "400 potency"
|
||||||
|
},
|
||||||
|
"37034": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 15,
|
||||||
|
"names": {
|
||||||
|
"en": "Eukrasian Prognosis II",
|
||||||
|
"de": "Eukratische Prognose II",
|
||||||
|
"fr": "Prognosis eucrasique II",
|
||||||
|
"jp": "エウクラシア・プログノシスII"
|
||||||
|
},
|
||||||
|
"icon": "https://xivapi.com/i/003000/003689_hr1.png",
|
||||||
|
"shield": "360% of HP restored"
|
||||||
|
},
|
||||||
|
"16464": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 250,
|
||||||
|
"names": {
|
||||||
|
"en": "Nascent Flash",
|
||||||
|
"de": "Urflackern",
|
||||||
|
"fr": "Exaltation naissante",
|
||||||
|
"jp": "原初の猛り"
|
||||||
|
},
|
||||||
|
"icon": null,
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"25861": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 600,
|
||||||
|
"names": {
|
||||||
|
"en": "Aquaveil",
|
||||||
|
"de": "Wasserschleier",
|
||||||
|
"fr": "Aquavoile",
|
||||||
|
"jp": "アクアヴェール"
|
||||||
|
},
|
||||||
|
"icon": null,
|
||||||
|
"shield": null
|
||||||
|
},
|
||||||
|
"25873": {
|
||||||
|
"cast": 0,
|
||||||
|
"recast": 600,
|
||||||
|
"names": {
|
||||||
|
"en": "Exaltation",
|
||||||
|
"de": "Exaltation",
|
||||||
|
"fr": "Exaltation",
|
||||||
|
"jp": "エクザルテーション"
|
||||||
|
},
|
||||||
|
"icon": null,
|
||||||
|
"shield": null
|
||||||
|
}
|
||||||
|
}
|
||||||
3481
assets/mitigation-actions.json
Normal file
@ -8,7 +8,7 @@ $state = bin2hex(random_bytes(16));
|
|||||||
|
|
||||||
$_SESSION['pkce_verifier'] = $verifier;
|
$_SESSION['pkce_verifier'] = $verifier;
|
||||||
$_SESSION['oauth_state'] = $state;
|
$_SESSION['oauth_state'] = $state;
|
||||||
$_SESSION['oauth_return'] = null;
|
$_SESSION['oauth_return'] = safe_return_path($_GET['return'] ?? null);
|
||||||
|
|
||||||
$params = http_build_query([
|
$params = http_build_query([
|
||||||
'response_type' => 'code',
|
'response_type' => 'code',
|
||||||
|
|||||||
87
config.php
@ -1,11 +1,51 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('DEV_MODE', true); // set to false in production
|
function load_env_file(string $path): void {
|
||||||
define('CLIENT_ID', 'a1d27cba-b7f8-48dd-aefd-4697b457cc67');
|
if (!is_file($path)) return;
|
||||||
define('REDIRECT_URI', 'http://localhost:8080/auth/callback.php');
|
|
||||||
define('AUTHORIZE_URI','https://www.fflogs.com/oauth/authorize');
|
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||||
define('TOKEN_URI', 'https://www.fflogs.com/oauth/token');
|
$line = trim($line);
|
||||||
define('GRAPHQL_URI', 'https://www.fflogs.com/api/v2/user');
|
if ($line === '' || str_starts_with($line, '#')) continue;
|
||||||
|
|
||||||
|
[$key, $value] = array_pad(explode('=', $line, 2), 2, '');
|
||||||
|
$key = trim($key);
|
||||||
|
if ($key === '') continue;
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
if (
|
||||||
|
strlen($value) >= 2
|
||||||
|
&& (($value[0] === '"' && $value[-1] === '"') || ($value[0] === "'" && $value[-1] === "'"))
|
||||||
|
) {
|
||||||
|
$value = substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
putenv($key . '=' . $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function env_value(string $key, ?string $default = null): string {
|
||||||
|
$value = $_ENV[$key] ?? getenv($key);
|
||||||
|
if ($value === false || $value === null || $value === '') {
|
||||||
|
if ($default !== null) return $default;
|
||||||
|
throw new RuntimeException('Missing required environment value: ' . $key);
|
||||||
|
}
|
||||||
|
return (string)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function env_bool(string $key, bool $default = false): bool {
|
||||||
|
$value = strtolower(env_value($key, $default ? 'true' : 'false'));
|
||||||
|
return in_array($value, ['1', 'true', 'yes', 'on'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env_file(__DIR__ . '/.env');
|
||||||
|
|
||||||
|
define('DEV_MODE', env_bool('DEV_MODE'));
|
||||||
|
define('CLIENT_ID', env_value('CLIENT_ID'));
|
||||||
|
define('REDIRECT_URI', env_value('REDIRECT_URI'));
|
||||||
|
define('AUTHORIZE_URI', env_value('AUTHORIZE_URI'));
|
||||||
|
define('TOKEN_URI', env_value('TOKEN_URI'));
|
||||||
|
define('GRAPHQL_URI', env_value('GRAPHQL_URI'));
|
||||||
|
|
||||||
function session_start_safe(): void {
|
function session_start_safe(): void {
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
@ -19,3 +59,38 @@ function session_start_safe(): void {
|
|||||||
session_start();
|
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());
|
||||||
|
}
|
||||||
|
|||||||
@ -80,6 +80,30 @@ select option { background: var(--bg2); }
|
|||||||
|
|
||||||
.btn-sm { padding: 5px 13px; font-size: 13px; }
|
.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 ──────────────────────────────────────────────────────────────── */
|
||||||
.stats-row {
|
.stats-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
756
css/planner.css
@ -4,6 +4,12 @@
|
|||||||
grid-template-columns: 280px 1fr;
|
grid-template-columns: 280px 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#plan-detail-panel,
|
||||||
|
#plan-content {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Plan Sidebar ────────────────────────────────────────────────────────────── */
|
/* ── Plan Sidebar ────────────────────────────────────────────────────────────── */
|
||||||
@ -159,6 +165,63 @@
|
|||||||
.job-slot--healer select { border-left-color: var(--green); }
|
.job-slot--healer select { border-left-color: var(--green); }
|
||||||
.job-slot--dps select { border-left-color: rgba(200,168,75,.5); }
|
.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 Cards ──────────────────────────────────────────────────────────── */
|
||||||
.mechanic-card {
|
.mechanic-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -222,6 +285,16 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--t2);
|
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 {
|
.mechanic-assignments {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -250,6 +323,7 @@
|
|||||||
.badge-assign-buff { background: rgba(200,168,75,.08); border-color: rgba(200,168,75,.4); color: var(--gold); }
|
.badge-assign-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-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-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 {
|
.badge-assign--missing-job {
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
@ -415,6 +489,7 @@
|
|||||||
.ability-chip.badge-assign-buff.ability-chip--active { background: rgba(200,168,75,.18); border-color: rgba(200,168,75,.6); color: var(--gold); }
|
.ability-chip.badge-assign-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-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-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; }
|
.ability-chip--other-job { opacity: 0.45; }
|
||||||
|
|
||||||
@ -453,9 +528,9 @@
|
|||||||
|
|
||||||
/* ── Info Panel ──────────────────────────────────────────────────────────────── */
|
/* ── Info Panel ──────────────────────────────────────────────────────────────── */
|
||||||
.planner-info-panel {
|
.planner-info-panel {
|
||||||
margin-top: 14px;
|
padding-bottom: 16px;
|
||||||
padding-top: 14px;
|
margin-bottom: 16px;
|
||||||
border-top: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-section { margin-bottom: 12px; }
|
.info-section { margin-bottom: 12px; }
|
||||||
@ -490,6 +565,7 @@
|
|||||||
.info-legend-dot--buff { background: rgba(200,168,75,.8); }
|
.info-legend-dot--buff { background: rgba(200,168,75,.8); }
|
||||||
.info-legend-dot--debuff { background: var(--red); }
|
.info-legend-dot--debuff { background: var(--red); }
|
||||||
.info-legend-dot--shield { background: var(--blue); }
|
.info-legend-dot--shield { background: var(--blue); }
|
||||||
|
.info-legend-dot--personal { background: rgba(177,112,255,.9); }
|
||||||
|
|
||||||
.info-legend-label {
|
.info-legend-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -505,6 +581,38 @@
|
|||||||
.info-warning--job { color: var(--t2); }
|
.info-warning--job { color: var(--t2); }
|
||||||
.info-warning--missing { color: var(--red); }
|
.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 Sidebar ──────────────────────────────────────────────────────────── */
|
||||||
.folder-section { margin-bottom: 2px; }
|
.folder-section { margin-bottom: 2px; }
|
||||||
|
|
||||||
@ -597,3 +705,645 @@
|
|||||||
}
|
}
|
||||||
.folder-picker-option:hover { background: var(--bg2); color: var(--t1); }
|
.folder-picker-option:hover { background: var(--bg2); color: var(--t1); }
|
||||||
.folder-picker-option.active { color: var(--gold); }
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
389
js/analysis.js
@ -1,58 +1,18 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const MITIG_ICONS = {
|
const { MITIG_ICONS, JOB_ABBR, ABILITY_JOBS, JOB_ROLE } = window.FF14_DATA;
|
||||||
// DR buffs
|
|
||||||
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
|
|
||||||
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
|
|
||||||
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
|
|
||||||
'Temperance': 'assets/icons/mitigation/temperance.png',
|
|
||||||
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
|
|
||||||
'Expedient': 'assets/icons/mitigation/expedient.png',
|
|
||||||
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
|
|
||||||
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
|
|
||||||
'Holos': 'assets/icons/mitigation/holos.png',
|
|
||||||
'Kerachole': 'assets/icons/mitigation/kerachole.png',
|
|
||||||
'Troubadour': 'assets/icons/mitigation/troubadour.png',
|
|
||||||
'Tactician': 'assets/icons/mitigation/tactician.png',
|
|
||||||
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
|
|
||||||
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
|
|
||||||
// Debuffs
|
|
||||||
'Reprisal': 'assets/icons/mitigation/reprisal.png',
|
|
||||||
'Feint': 'assets/icons/mitigation/feint.png',
|
|
||||||
'Addle': 'assets/icons/mitigation/addle.png',
|
|
||||||
// Shields
|
|
||||||
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
|
|
||||||
'Guardian': 'assets/icons/mitigation/guardian.png',
|
|
||||||
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
|
|
||||||
'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png',
|
|
||||||
'Divine Benison': 'assets/icons/mitigation/divine-benison.png',
|
|
||||||
'Divine Caress': 'assets/icons/mitigation/divine-caress.png',
|
|
||||||
'Intersection': 'assets/icons/mitigation/intersection.png',
|
|
||||||
'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png',
|
|
||||||
'the Spire': 'assets/icons/mitigation/the-spire.png',
|
|
||||||
'Panhaima': 'assets/icons/mitigation/panhaima.png',
|
|
||||||
'Holosakos': 'assets/icons/mitigation/holos.png',
|
|
||||||
'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png',
|
|
||||||
'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png',
|
|
||||||
'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
|
||||||
'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
|
||||||
'Haima': 'assets/icons/mitigation/haima.png',
|
|
||||||
'Galvanize': 'assets/icons/mitigation/galvanize.png',
|
|
||||||
'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png',
|
|
||||||
'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png',
|
|
||||||
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
|
|
||||||
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
|
|
||||||
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
|
|
||||||
};
|
|
||||||
|
|
||||||
const JOB_ABBR = {
|
// Deduplicated list of all mitigations across all targets of a ref event
|
||||||
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
|
function collectRefMitigs(refEvent) {
|
||||||
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
|
if (!refEvent) return [];
|
||||||
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
|
const seen = new Set(), result = [];
|
||||||
'Reaper': 'RPR', 'Viper': 'VPR',
|
for (const t of refEvent.targets ?? []) {
|
||||||
'Bard': 'BRD', 'Machinist': 'MCH', 'Dancer': 'DNC',
|
for (const m of (t.mitigations ?? [])) {
|
||||||
'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
|
const k = m.key ?? m.name;
|
||||||
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
|
if (!seen.has(k)) { seen.add(k); result.push(m); }
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function abbr(type) {
|
function abbr(type) {
|
||||||
return JOB_ABBR[type] ?? type.slice(0, 3).toUpperCase();
|
return JOB_ABBR[type] ?? type.slice(0, 3).toUpperCase();
|
||||||
@ -81,13 +41,24 @@
|
|||||||
return String(name ?? '').trim().toLowerCase();
|
return String(name ?? '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentFightName() {
|
function fightEncounterId(fight) {
|
||||||
const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId);
|
return parseInt(fight?.encounterID ?? fight?.encounterId ?? 0, 10) || 0;
|
||||||
return normalizeFightName(fight?.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameFightName(fight) {
|
function currentFight() {
|
||||||
const name = currentFightName();
|
return (window.App?.fights ?? []).find(f => f.id === window.App?.fightId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameEncounter(fight) {
|
||||||
|
const selectedFight = currentFight();
|
||||||
|
const selectedEncounterId = fightEncounterId(selectedFight);
|
||||||
|
const encounterId = fightEncounterId(fight);
|
||||||
|
|
||||||
|
if (selectedEncounterId && encounterId) {
|
||||||
|
return encounterId === selectedEncounterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = normalizeFightName(selectedFight?.name);
|
||||||
return name !== '' && normalizeFightName(fight?.name) === name;
|
return name !== '' && normalizeFightName(fight?.name) === name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,6 +75,28 @@
|
|||||||
let extFights = [];
|
let extFights = [];
|
||||||
let extReportCode = '';
|
let extReportCode = '';
|
||||||
let mitigationNames = {};
|
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 ──────────────────────────────────────────────────────────
|
// ── Player grid ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -244,8 +237,11 @@
|
|||||||
const fight = (window.App?.fights ?? []).find(f => f.id === refId);
|
const fight = (window.App?.fights ?? []).find(f => f.id === refId);
|
||||||
if (!fight) return;
|
if (!fight) return;
|
||||||
|
|
||||||
// Clear ext-report selection
|
// Clear ext-report and plan selections
|
||||||
refExtFightSelect.value = '';
|
refExtFightSelect.value = '';
|
||||||
|
planRefId = '';
|
||||||
|
refPlanSelect.value = '';
|
||||||
|
refPlanPanel.style.display = 'none';
|
||||||
|
|
||||||
refFightSelect.disabled = true;
|
refFightSelect.disabled = true;
|
||||||
try {
|
try {
|
||||||
@ -264,7 +260,7 @@
|
|||||||
if (!json.error && !json.reauth) {
|
if (!json.error && !json.reauth) {
|
||||||
refEvents = json.aoe_events ?? [];
|
refEvents = json.aoe_events ?? [];
|
||||||
refFightStart = json.fight_start ?? fight.startTime;
|
refFightStart = json.fight_start ?? fight.startTime;
|
||||||
refPlayers = [];
|
refPlayers = json.players ?? [];
|
||||||
window.App.setUrlState?.({
|
window.App.setUrlState?.({
|
||||||
compareReportCode: '',
|
compareReportCode: '',
|
||||||
compareFightId: refId,
|
compareFightId: refId,
|
||||||
@ -280,7 +276,7 @@
|
|||||||
let allSameReportFights = [];
|
let allSameReportFights = [];
|
||||||
|
|
||||||
function populateRefFightSelect() {
|
function populateRefFightSelect() {
|
||||||
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameFightName(f));
|
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameEncounter(f));
|
||||||
refFightSelect.innerHTML = '<option value="">Kein Vergleich</option>';
|
refFightSelect.innerHTML = '<option value="">Kein Vergleich</option>';
|
||||||
visible.forEach(f => {
|
visible.forEach(f => {
|
||||||
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
||||||
@ -349,7 +345,7 @@
|
|||||||
extReportCode = code;
|
extReportCode = code;
|
||||||
updateRefFflogsLink();
|
updateRefFflogsLink();
|
||||||
|
|
||||||
const visibleExt = fights.filter(isSameFightName);
|
const visibleExt = fights.filter(isSameEncounter);
|
||||||
refExtFightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
refExtFightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
||||||
visibleExt.forEach(f => {
|
visibleExt.forEach(f => {
|
||||||
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
||||||
@ -389,8 +385,11 @@
|
|||||||
const fight = extFights.find(f => f.id === refId);
|
const fight = extFights.find(f => f.id === refId);
|
||||||
if (!fight) return;
|
if (!fight) return;
|
||||||
|
|
||||||
// Clear same-report selection
|
// Clear same-report and plan selections
|
||||||
refFightSelect.value = '';
|
refFightSelect.value = '';
|
||||||
|
planRefId = '';
|
||||||
|
refPlanSelect.value = '';
|
||||||
|
refPlanPanel.style.display = 'none';
|
||||||
|
|
||||||
refExtFightSelect.disabled = true;
|
refExtFightSelect.disabled = true;
|
||||||
try {
|
try {
|
||||||
@ -410,6 +409,7 @@
|
|||||||
refEvents = json.aoe_events ?? [];
|
refEvents = json.aoe_events ?? [];
|
||||||
refFightStart = json.fight_start ?? fight.startTime;
|
refFightStart = json.fight_start ?? fight.startTime;
|
||||||
refPlayers = json.players ?? [];
|
refPlayers = json.players ?? [];
|
||||||
|
await ensureActionIconCache();
|
||||||
window.App.setUrlState?.({
|
window.App.setUrlState?.({
|
||||||
compareReportCode: extReportCode,
|
compareReportCode: extReportCode,
|
||||||
compareFightId: refId,
|
compareFightId: refId,
|
||||||
@ -428,6 +428,116 @@
|
|||||||
await loadExternalCompare(refId);
|
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 ────────────────────────────────────────────────────
|
// ── Timeline rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function renderTimeline(events, fightStart) {
|
function renderTimeline(events, fightStart) {
|
||||||
@ -441,6 +551,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentFightJobSet = new Set(currentPlayers.map(p => JOB_ABBR[p.type]).filter(Boolean));
|
||||||
|
|
||||||
// Build reference index: abilityName → [events in order]
|
// Build reference index: abilityName → [events in order]
|
||||||
const refIndex = {};
|
const refIndex = {};
|
||||||
for (const ev of refEvents) {
|
for (const ev of refEvents) {
|
||||||
@ -455,6 +567,11 @@
|
|||||||
const occ = abilityOccurrence[ev.abilityName] ?? 0;
|
const occ = abilityOccurrence[ev.abilityName] ?? 0;
|
||||||
abilityOccurrence[ev.abilityName] = occ + 1;
|
abilityOccurrence[ev.abilityName] = occ + 1;
|
||||||
const refEv = refEvents.length ? (refIndex[ev.abilityName]?.[occ] ?? null) : null;
|
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 =>
|
const visibleTargets = ev.targets.filter(t =>
|
||||||
!hiddenPlayers.has(t.id) &&
|
!hiddenPlayers.has(t.id) &&
|
||||||
@ -476,18 +593,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const eventMissingDebuffs = refEv
|
const eventMissingDebuffs = refEv
|
||||||
? (refEv.targets[0]?.mitigations ?? []).filter(m => m.buffType === 'debuff' && !seenDebuffKeys.has(m.key ?? m.name))
|
? allRefMitigs.filter(m => {
|
||||||
|
if (m.buffType !== 'debuff' || seenDebuffKeys.has(m.key ?? m.name)) return false;
|
||||||
|
const jobs = ABILITY_JOBS[m.key] ?? ABILITY_JOBS[m.name];
|
||||||
|
return jobs ? jobs.some(j => currentFightJobSet.has(j)) : false;
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
const debuffIconsHtml = [
|
const debuffIconsHtml = [
|
||||||
...eventDebuffs.map(m => ({ ...m, missing: false })),
|
...eventDebuffs.map(m => ({ ...m, missing: false })),
|
||||||
...eventMissingDebuffs.map(m => ({ ...m, missing: true })),
|
...eventMissingDebuffs.map(m => ({ ...m, missing: true })),
|
||||||
].map(m => {
|
].map(m => {
|
||||||
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
|
const iconSrc = mitigationIcon(m);
|
||||||
if (!iconSrc) return '';
|
if (!iconSrc) return '';
|
||||||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
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
|
return m.missing
|
||||||
? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr} fehlt (war im Referenz-Pull aktiv)">`
|
? `<img class="aoe-target-buff-icon 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="${m.name}${dr}">`;
|
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${label}${dr}">`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Current targets
|
// Current targets
|
||||||
@ -507,15 +630,9 @@
|
|||||||
</div>`;
|
</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)
|
// DR buff icons (shown below player box)
|
||||||
const mitigIcons = (t.mitigations ?? []).filter(m => m.buffType === 'buff').map(m => {
|
const mitigIcons = (t.mitigations ?? []).filter(m => m.buffType === 'buff').map(m => {
|
||||||
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
|
const iconSrc = mitigationIcon(m);
|
||||||
if (!iconSrc) return '';
|
if (!iconSrc) return '';
|
||||||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
||||||
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
|
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
|
||||||
@ -523,13 +640,7 @@
|
|||||||
|
|
||||||
// Shield tooltip on absorbed value
|
// Shield tooltip on absorbed value
|
||||||
const activeShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield');
|
const activeShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield');
|
||||||
const missingShields = refTarget
|
const shieldLines = activeShields.map(s => s.name);
|
||||||
? (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 shieldTitle = shieldLines.length ? shieldLines.join('\n') : null;
|
||||||
|
|
||||||
const dead = t.hp === 0 && t.maxHp > 0;
|
const dead = t.hp === 0 && t.maxHp > 0;
|
||||||
@ -568,28 +679,31 @@
|
|||||||
const refDebuffIconsHtml = refVisible.flatMap(t => (t.mitigations ?? []))
|
const refDebuffIconsHtml = refVisible.flatMap(t => (t.mitigations ?? []))
|
||||||
.filter(m => m.buffType === 'debuff' && !seenRefDebuffKeys.has(m.key ?? m.name) && seenRefDebuffKeys.add(m.key ?? m.name))
|
.filter(m => m.buffType === 'debuff' && !seenRefDebuffKeys.has(m.key ?? m.name) && seenRefDebuffKeys.add(m.key ?? m.name))
|
||||||
.map(m => {
|
.map(m => {
|
||||||
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
|
const iconSrc = mitigationIcon(m);
|
||||||
if (!iconSrc) return '';
|
if (!iconSrc) return '';
|
||||||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
||||||
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
|
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
const isPlanRef = !!refEv.isPlanRef;
|
||||||
|
|
||||||
const refCards = refVisible.map(t => {
|
const refCards = refVisible.map(t => {
|
||||||
const curr = currentByName[t.name];
|
const curr = currentByName[t.name];
|
||||||
const diff = curr ? curr.amount - t.amount : 0;
|
const diff = (!isPlanRef && curr) ? curr.amount - t.amount : 0;
|
||||||
const dead = t.hp === 0 && t.maxHp > 0;
|
const dead = !isPlanRef && t.hp === 0 && t.maxHp > 0;
|
||||||
|
|
||||||
const deltaHtml = diff !== 0
|
const deltaHtml = diff !== 0
|
||||||
? `<span class="${diff > 0 ? 'aoe-delta-worse' : 'aoe-delta-better'}">${diff > 0 ? '+' : '-'}${fmtDmg(Math.abs(diff))}</span>`
|
? `<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 refMitigIcons = (t.mitigations ?? []).filter(m => m.buffType === 'buff').map(m => {
|
||||||
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
|
const iconSrc = mitigationIcon(m);
|
||||||
if (!iconSrc) return '';
|
if (!iconSrc) return '';
|
||||||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
||||||
const missing = !currMitigKeys.has(m.key ?? m.name);
|
const k = m.key ?? m.name;
|
||||||
|
const jobs = ABILITY_JOBS[k] ?? ABILITY_JOBS[m.name];
|
||||||
|
const currentGroupHasJob = jobs ? jobs.some(j => currentFightJobSet.has(j)) : false;
|
||||||
|
const missing = currentGroupHasJob && !currentEventMitigKeys.has(k);
|
||||||
const cls = missing ? ' aoe-buff-ref-unique' : '';
|
const cls = missing ? ' aoe-buff-ref-unique' : '';
|
||||||
const titleSufx = missing ? ' (fehlt im aktuellen Pull)' : '';
|
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}">`;
|
return `<img class="aoe-target-buff-icon${cls}" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}${titleSufx}">`;
|
||||||
@ -597,9 +711,19 @@
|
|||||||
|
|
||||||
const refShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield');
|
const refShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield');
|
||||||
const refShieldTitle = refShields.length
|
const refShieldTitle = refShields.length
|
||||||
? refShields.map(s => currMitigKeys.has(s.key ?? s.name) ? s.name : `${s.name} [fehlt im aktuellen Pull]`).join('\n')
|
? refShields.map(s => {
|
||||||
|
const k = s.key ?? s.name;
|
||||||
|
const jobs = ABILITY_JOBS[k];
|
||||||
|
const currentGroupHasJob = jobs ? jobs.some(j => currentFightJobSet.has(j)) : false;
|
||||||
|
const isMissing = !isPlanRef && currentGroupHasJob && !currentEventMitigKeys.has(k);
|
||||||
|
return isMissing ? `${s.name} [fehlt im aktuellen Pull]` : s.name;
|
||||||
|
}).join('\n')
|
||||||
: null;
|
: 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 `
|
return `
|
||||||
<div class="aoe-target-wrap">
|
<div class="aoe-target-wrap">
|
||||||
<div class="aoe-ref-target${dead ? ' aoe-target--dead' : ''}">
|
<div class="aoe-ref-target${dead ? ' aoe-target--dead' : ''}">
|
||||||
@ -608,20 +732,21 @@
|
|||||||
${deltaHtml}
|
${deltaHtml}
|
||||||
</div>
|
</div>
|
||||||
<span class="aoe-target-name">${t.name}</span>
|
<span class="aoe-target-name">${t.name}</span>
|
||||||
<span class="aoe-target-dmg">${fmtDmg(t.amount)}${t.absorbed > 0 ? ` <span class="aoe-target-absorbed" title="${refShieldTitle ?? 'Keine erkannten Schilde'}">+${fmtDmg(t.absorbed)}</span>` : ''}</span>
|
<span class="aoe-target-dmg">${isPlanRef ? '' : fmtDmg(t.amount)}${absorbedHtml}</span>
|
||||||
</div>
|
</div>
|
||||||
${refMitigIcons ? `<div class="aoe-target-buffs">${refMitigIcons}</div>` : ''}
|
${refMitigIcons ? `<div class="aoe-target-buffs">${refMitigIcons}</div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
const totalDiff = ev.totalDamage - refEv.totalDamage;
|
const totalDiff = ev.totalDamage - refEv.totalDamage;
|
||||||
const totalDelta = totalDiff !== 0
|
const totalDelta = (!isPlanRef && totalDiff !== 0)
|
||||||
? `<span class="${totalDiff > 0 ? 'aoe-delta-worse' : 'aoe-delta-better'}">${totalDiff > 0 ? '+' : ''}${fmtDmg(totalDiff)}</span>`
|
? `<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 = `
|
refHtml = `
|
||||||
<div class="aoe-ref-row">
|
<div class="aoe-ref-row">
|
||||||
<span class="aoe-ref-label">REF ${fmtDmg(refEv.totalDamage)} ${totalDelta} ${refDebuffIconsHtml}</span>
|
<span class="aoe-ref-label">${refLabel} ${refDebuffIconsHtml}</span>
|
||||||
<div class="aoe-targets">${refCards}</div>
|
<div class="aoe-targets">${refCards}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@ -685,6 +810,7 @@
|
|||||||
setupPhases(window.App?.phases ?? []);
|
setupPhases(window.App?.phases ?? []);
|
||||||
renderPlayers(json.players ?? []);
|
renderPlayers(json.players ?? []);
|
||||||
mitigationNames = json.mitigation_names ?? {};
|
mitigationNames = json.mitigation_names ?? {};
|
||||||
|
await ensureActionIconCache();
|
||||||
renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart);
|
renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart);
|
||||||
|
|
||||||
document.getElementById('analysis-loading').style.display = 'none';
|
document.getElementById('analysis-loading').style.display = 'none';
|
||||||
@ -724,6 +850,42 @@
|
|||||||
mitigationNames,
|
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() {
|
reset() {
|
||||||
lastFightId = null;
|
lastFightId = null;
|
||||||
refEvents = [];
|
refEvents = [];
|
||||||
@ -732,6 +894,7 @@
|
|||||||
extFights = [];
|
extFights = [];
|
||||||
extReportCode = '';
|
extReportCode = '';
|
||||||
mitigationNames = {};
|
mitigationNames = {};
|
||||||
|
planRefId = '';
|
||||||
document.getElementById('ref-player-section').style.display = 'none';
|
document.getElementById('ref-player-section').style.display = 'none';
|
||||||
refFightSelect.value = '';
|
refFightSelect.value = '';
|
||||||
refFightSelect.style.display = 'none';
|
refFightSelect.style.display = 'none';
|
||||||
@ -740,12 +903,52 @@
|
|||||||
refFflogsLink.style.display = 'none';
|
refFflogsLink.style.display = 'none';
|
||||||
refFflogsLink.href = '#';
|
refFflogsLink.href = '#';
|
||||||
refExtPanel.style.display = 'none';
|
refExtPanel.style.display = 'none';
|
||||||
|
refPlanPanel.style.display = 'none';
|
||||||
|
refPlanSelect.value = '';
|
||||||
const exportBtn = document.getElementById('export-to-planner-btn');
|
const exportBtn = document.getElementById('export-to-planner-btn');
|
||||||
if (exportBtn) exportBtn.style.display = 'none';
|
if (exportBtn) exportBtn.style.display = 'none';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('export-to-planner-btn')?.addEventListener('click', () => {
|
document.getElementById('export-to-planner-btn')?.addEventListener('click', (e) => {
|
||||||
|
if (!refEvents.length) {
|
||||||
window.plannerTab?.showImportModal(window.analysisTab.exportForPlanner());
|
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);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
327
js/ffxiv-data.js
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
(function () {
|
||||||
|
// FFLogs ability type bitmask values for FFXIV
|
||||||
|
// Verified via XIVAPI AttackType cross-reference:
|
||||||
|
// 128 = Physical (all subtypes: Slashing/Piercing/Blunt/Shot)
|
||||||
|
// 1024 = Magical
|
||||||
|
// Used for Feint/Addle DR calculation (Feint: 10% phys / 5% mag, Addle: 10% mag / 5% phys)
|
||||||
|
const ABILITY_TYPE_PHYSICAL = 128;
|
||||||
|
const ABILITY_TYPE_MAGICAL = 1024;
|
||||||
|
|
||||||
|
function abilityTypeIsPhysical(type) { return (parseInt(type) & ABILITY_TYPE_PHYSICAL) !== 0; }
|
||||||
|
function abilityTypeIsMagical(type) { return (parseInt(type) & ABILITY_TYPE_MAGICAL) !== 0; }
|
||||||
|
|
||||||
|
const JOB_FROM_TYPE = {
|
||||||
|
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
|
||||||
|
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
|
||||||
|
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
|
||||||
|
'Reaper': 'RPR', 'Viper': 'VPR', 'Bard': 'BRD', 'Machinist': 'MCH',
|
||||||
|
'Dancer': 'DNC', 'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
|
||||||
|
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
|
||||||
|
};
|
||||||
|
|
||||||
|
const JOB_ROLE = {
|
||||||
|
'PLD': 'tank', 'WAR': 'tank', 'DRK': 'tank', 'GNB': 'tank',
|
||||||
|
'WHM': 'healer', 'SCH': 'healer', 'AST': 'healer', 'SGE': 'healer',
|
||||||
|
'MNK': 'dps', 'DRG': 'dps', 'NIN': 'dps', 'SAM': 'dps',
|
||||||
|
'RPR': 'dps', 'VPR': 'dps', 'BRD': 'dps', 'MCH': 'dps',
|
||||||
|
'DNC': 'dps', 'BLM': 'dps', 'SMN': 'dps', 'RDM': 'dps', 'PCT': 'dps',
|
||||||
|
'BLU': 'dps',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_JOBS = [
|
||||||
|
{ group: 'Tank', jobs: ['PLD', 'WAR', 'DRK', 'GNB'] },
|
||||||
|
{ group: 'Healer', jobs: ['WHM', 'SCH', 'AST', 'SGE'] },
|
||||||
|
{ group: 'Melee', jobs: ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'] },
|
||||||
|
{ group: 'Ranged', jobs: ['BRD', 'MCH', 'DNC'] },
|
||||||
|
{ group: 'Caster', jobs: ['BLM', 'SMN', 'RDM', 'PCT'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const JOB_ABILITIES = {
|
||||||
|
'PLD': [
|
||||||
|
{ name: 'Passage of Arms', buffType: 'buff' },
|
||||||
|
{ name: 'Divine Veil', buffType: 'shield' },
|
||||||
|
{ name: 'Rampart', buffType: 'buff', extraAbilityGameID: 7531, duration: 20 },
|
||||||
|
{ name: 'Hallowed Ground', buffType: 'buff', extraAbilityGameID: 30, duration: 10 },
|
||||||
|
{ name: 'Sentinel', buffType: 'buff', extraAbilityGameID: 17, duration: 15 },
|
||||||
|
{ name: 'Guardian', buffType: 'shield', extraAbilityGameID: 36920, duration: 15 },
|
||||||
|
{ name: 'Bulwark', buffType: 'buff', extraAbilityGameID: 22, duration: 10 },
|
||||||
|
{ name: 'Holy Sheltron', buffType: 'buff', extraAbilityGameID: 25746, duration: 8 },
|
||||||
|
{ name: 'Intervention', buffType: 'buff', extraAbilityGameID: 7382, duration: 8 },
|
||||||
|
{ name: 'Knight\'s Resolve', buffType: 'buff', extraAbilityGameID: 7382, duration: 4 }, // Proc von Intervention
|
||||||
|
{ name: 'Reprisal', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'WAR': [
|
||||||
|
{ name: 'Shake It Off', buffType: 'shield' },
|
||||||
|
{ name: 'Rampart', buffType: 'buff', extraAbilityGameID: 7531, duration: 20 },
|
||||||
|
{ name: 'Holmgang', buffType: 'buff', extraAbilityGameID: 43, duration: 10 },
|
||||||
|
{ name: 'Vengeance', buffType: 'buff', extraAbilityGameID: 44, duration: 15 },
|
||||||
|
{ name: 'Damnation', buffType: 'buff', extraAbilityGameID: 36923, duration: 15 },
|
||||||
|
{ name: 'Thrill of Battle', buffType: 'buff', extraAbilityGameID: 40, duration: 10 },
|
||||||
|
{ name: 'Raw Intuition', buffType: 'buff', extraAbilityGameID: 3551, duration: 6 },
|
||||||
|
{ name: 'Bloodwhetting', buffType: 'shield', extraAbilityGameID: 25751, duration: 8 },
|
||||||
|
{ name: 'Nascent Glint', buffType: 'buff', extraAbilityGameID: 16464, duration: 8 }, // Proc von Nascent Flash auf Ziel
|
||||||
|
{ name: 'Stem the Flow', buffType: 'buff', extraAbilityGameID: 25751, duration: 4 }, // Proc von Bloodwhetting / Nascent Flash
|
||||||
|
{ name: 'Stem the Tide', buffType: 'shield', extraAbilityGameID: 25751, duration: 4 }, // Proc von Bloodwhetting / Nascent Flash
|
||||||
|
{ name: 'Reprisal', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'DRK': [
|
||||||
|
{ name: 'Dark Missionary', buffType: 'buff' },
|
||||||
|
{ name: 'Rampart', buffType: 'buff', extraAbilityGameID: 7531, duration: 20 },
|
||||||
|
{ name: 'Living Dead', buffType: 'buff', extraAbilityGameID: 3638, duration: 10 },
|
||||||
|
{ name: 'Shadow Wall', buffType: 'buff', extraAbilityGameID: 3636, duration: 15 },
|
||||||
|
{ name: 'Shadowed Vigil', buffType: 'buff', extraAbilityGameID: 36927, duration: 15 },
|
||||||
|
{ name: 'Dark Mind', buffType: 'buff', extraAbilityGameID: 3634, duration: 10 },
|
||||||
|
{ name: 'The Blackest Night', buffType: 'shield', extraAbilityGameID: 7393, duration: 7 },
|
||||||
|
{ name: 'Oblation', buffType: 'buff', extraAbilityGameID: 25754, duration: 10 },
|
||||||
|
{ name: 'Reprisal', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'GNB': [
|
||||||
|
{ name: 'Heart of Light', buffType: 'buff' },
|
||||||
|
{ name: 'Rampart', buffType: 'buff', extraAbilityGameID: 7531, duration: 20 },
|
||||||
|
{ name: 'Superbolide', buffType: 'buff', extraAbilityGameID: 16152, duration: 10 },
|
||||||
|
{ name: 'Nebula', buffType: 'buff', extraAbilityGameID: 16148, duration: 15 },
|
||||||
|
{ name: 'Great Nebula', buffType: 'buff', extraAbilityGameID: 36935, duration: 15 },
|
||||||
|
{ name: 'Camouflage', buffType: 'buff', extraAbilityGameID: 16140, duration: 20 },
|
||||||
|
{ name: 'Heart of Stone', buffType: 'buff', extraAbilityGameID: 16161, duration: 7 },
|
||||||
|
{ name: 'Heart of Corundum', buffType: 'buff', extraAbilityGameID: 25758, duration: 8 },
|
||||||
|
{ name: 'Clarity of Corundum', buffType: 'buff', extraAbilityGameID: 25758, duration: 4 }, // Proc von HoC, geht auf beliebiges Partymitglied
|
||||||
|
{ name: 'Reprisal', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'WHM': [
|
||||||
|
{ name: 'Temperance', buffType: 'buff' },
|
||||||
|
{ name: 'Aquaveil', buffType: 'buff', extraAbilityGameID: 25861, duration: 8 }, // Personal, WHM auf Ziel
|
||||||
|
{ name: 'Divine Benison', buffType: 'shield', extraAbilityGameID: 7432, duration: 15 },
|
||||||
|
{ name: 'Divine Caress', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
'SCH': [
|
||||||
|
{ name: 'Sacred Soil', buffType: 'buff' },
|
||||||
|
{ name: 'Expedient', buffType: 'buff' },
|
||||||
|
{ name: 'Fey Illumination', buffType: 'buff' },
|
||||||
|
{ name: 'Galvanize', buffType: 'shield' },
|
||||||
|
{ name: 'Seraphic Veil', buffType: 'shield', extraAbilityGameID: 16548, duration: 30 },
|
||||||
|
{ name: 'Catalyze', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
'AST': [
|
||||||
|
{ name: 'Collective Unconscious', buffType: 'buff' },
|
||||||
|
{ name: 'Exaltation', buffType: 'buff', extraAbilityGameID: 25873, duration: 8 }, // Personal, AST auf Ziel
|
||||||
|
{ name: 'Neutral Sect', buffType: 'shield' },
|
||||||
|
{ name: 'Intersection', buffType: 'shield', extraAbilityGameID: 16556, duration: 30 },
|
||||||
|
{ name: 'the Spire', buffType: 'shield', extraAbilityGameID: 37025, duration: 30 },
|
||||||
|
],
|
||||||
|
'SGE': [
|
||||||
|
{ name: 'Kerachole', buffType: 'buff' },
|
||||||
|
{ name: 'Holos', buffType: 'buff' },
|
||||||
|
{ name: 'Holosakos', buffType: 'shield' },
|
||||||
|
{ name: 'Panhaima', buffType: 'shield' },
|
||||||
|
{ name: 'Eukrasian Prognosis', buffType: 'shield' },
|
||||||
|
{ name: 'Eukrasian Prognosis II', buffType: 'shield' },
|
||||||
|
{ name: 'Eukrasian Diagnosis', buffType: 'shield', extraAbilityGameID: 24291, duration: 30 },
|
||||||
|
{ name: 'Differential Diagnosis', buffType: 'shield', extraAbilityGameID: 24291, duration: 30 },
|
||||||
|
{ name: 'Haima', buffType: 'shield', extraAbilityGameID: 24305, duration: 15 },
|
||||||
|
],
|
||||||
|
'BRD': [{ name: 'Troubadour', buffType: 'buff' }],
|
||||||
|
'MCH': [{ name: 'Tactician', buffType: 'buff' }],
|
||||||
|
'DNC': [
|
||||||
|
{ name: 'Shield Samba', buffType: 'buff' },
|
||||||
|
{ name: 'Improvised Finish', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
'MNK': [
|
||||||
|
{ name: 'Riddle of Earth', buffType: 'buff', extraAbilityGameID: 7394, duration: 10 },
|
||||||
|
{ name: 'Feint', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'DRG': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'NIN': [
|
||||||
|
{ name: 'Shade Shift', buffType: 'shield', extraAbilityGameID: 2241, duration: 20 },
|
||||||
|
{ name: 'Feint', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'SAM': [
|
||||||
|
{ name: 'Third Eye', buffType: 'buff', extraAbilityGameID: 7498, duration: 4 },
|
||||||
|
{ name: 'Feint', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'RPR': [
|
||||||
|
{ name: 'Arcane Crest', buffType: 'shield', extraAbilityGameID: 24404, duration: 5 },
|
||||||
|
{ name: 'Feint', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'VPR': [{ name: 'Feint', buffType: 'debuff' }],
|
||||||
|
'BLM': [
|
||||||
|
{ name: 'Manaward', buffType: 'shield', extraAbilityGameID: 157, duration: 20 },
|
||||||
|
{ name: 'Addle', buffType: 'debuff' },
|
||||||
|
],
|
||||||
|
'SMN': [
|
||||||
|
{ name: 'Addle', buffType: 'debuff' },
|
||||||
|
{ name: 'Radiant Aegis', buffType: 'shield', extraAbilityGameID: 25799, duration: 30 },
|
||||||
|
],
|
||||||
|
'RDM': [
|
||||||
|
{ name: 'Addle', buffType: 'debuff' },
|
||||||
|
{ name: 'Magick Barrier', buffType: 'buff' },
|
||||||
|
],
|
||||||
|
'PCT': [
|
||||||
|
{ name: 'Addle', buffType: 'debuff' },
|
||||||
|
{ name: 'Tempera Coat', buffType: 'shield', extraAbilityGameID: 34685, duration: 10 },
|
||||||
|
{ name: 'Tempera Grassa', buffType: 'shield' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ABILITY_JOB_MAP = {
|
||||||
|
'Passage of Arms': 'PLD', 'Divine Veil': 'PLD',
|
||||||
|
'Hallowed Ground': 'PLD', 'Sentinel': 'PLD', 'Guardian': 'PLD',
|
||||||
|
'Bulwark': 'PLD', 'Holy Sheltron': 'PLD', 'Intervention': 'PLD',
|
||||||
|
'Knight\'s Resolve': 'PLD',
|
||||||
|
'Shake It Off': 'WAR', 'Holmgang': 'WAR', 'Vengeance': 'WAR',
|
||||||
|
'Damnation': 'WAR', 'Thrill of Battle': 'WAR', 'Raw Intuition': 'WAR',
|
||||||
|
'Bloodwhetting': 'WAR', 'Nascent Glint': 'WAR',
|
||||||
|
'Stem the Flow': 'WAR', 'Stem the Tide': 'WAR',
|
||||||
|
'Dark Missionary': 'DRK', 'Living Dead': 'DRK', 'Shadow Wall': 'DRK',
|
||||||
|
'Shadowed Vigil': 'DRK', 'Dark Mind': 'DRK', 'The Blackest Night': 'DRK',
|
||||||
|
'Oblation': 'DRK',
|
||||||
|
'Heart of Light': 'GNB', 'Superbolide': 'GNB', 'Nebula': 'GNB',
|
||||||
|
'Great Nebula': 'GNB', 'Camouflage': 'GNB', 'Heart of Stone': 'GNB',
|
||||||
|
'Heart of Corundum': 'GNB', 'Clarity of Corundum': 'GNB',
|
||||||
|
'Temperance': 'WHM', 'Aquaveil': 'WHM', 'Divine Benison': 'WHM', 'Divine Caress': 'WHM',
|
||||||
|
'Sacred Soil': 'SCH', 'Expedient': 'SCH', 'Fey Illumination': 'SCH',
|
||||||
|
'Galvanize': 'SCH', 'Seraphic Veil': 'SCH', 'Catalyze': 'SCH',
|
||||||
|
'Collective Unconscious': 'AST', 'Exaltation': 'AST', 'Neutral Sect': 'AST',
|
||||||
|
'Intersection': 'AST', 'the Spire': 'AST',
|
||||||
|
'Kerachole': 'SGE', 'Holos': 'SGE', 'Holosakos': 'SGE',
|
||||||
|
'Panhaima': 'SGE', 'Haima': 'SGE',
|
||||||
|
'Eukrasian Prognosis': 'SGE', 'Eukrasian Prognosis II': 'SGE',
|
||||||
|
'Eukrasian Diagnosis': 'SGE', 'Differential Diagnosis': 'SGE',
|
||||||
|
'Troubadour': 'BRD',
|
||||||
|
'Tactician': 'MCH',
|
||||||
|
'Shield Samba': 'DNC', 'Improvised Finish': 'DNC',
|
||||||
|
'Riddle of Earth': 'MNK',
|
||||||
|
'Shade Shift': 'NIN',
|
||||||
|
'Third Eye': 'SAM',
|
||||||
|
'Arcane Crest': 'RPR',
|
||||||
|
'Manaward': 'BLM',
|
||||||
|
'Radiant Aegis': 'SMN',
|
||||||
|
'Magick Barrier': 'RDM',
|
||||||
|
'Tempera Coat': 'PCT', 'Tempera Grassa': 'PCT',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MITIG_ICONS = {
|
||||||
|
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
|
||||||
|
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
|
||||||
|
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
|
||||||
|
'Temperance': 'assets/icons/mitigation/temperance.png',
|
||||||
|
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
|
||||||
|
'Expedient': 'assets/icons/mitigation/expedient.png',
|
||||||
|
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
|
||||||
|
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
|
||||||
|
'Holos': 'assets/icons/mitigation/holos.png',
|
||||||
|
'Kerachole': 'assets/icons/mitigation/kerachole.png',
|
||||||
|
'Troubadour': 'assets/icons/mitigation/troubadour.png',
|
||||||
|
'Tactician': 'assets/icons/mitigation/tactician.png',
|
||||||
|
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
|
||||||
|
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
|
||||||
|
'Reprisal': 'assets/icons/mitigation/reprisal.png',
|
||||||
|
'Feint': 'assets/icons/mitigation/feint.png',
|
||||||
|
'Addle': 'assets/icons/mitigation/addle.png',
|
||||||
|
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
|
||||||
|
'Guardian': 'assets/icons/mitigation/guardian.png',
|
||||||
|
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
|
||||||
|
'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png',
|
||||||
|
'Divine Benison': 'assets/icons/mitigation/divine-benison.png',
|
||||||
|
'Divine Caress': 'assets/icons/mitigation/divine-caress.png',
|
||||||
|
'Intersection': 'assets/icons/mitigation/intersection.png',
|
||||||
|
'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png',
|
||||||
|
'the Spire': 'assets/icons/mitigation/the-spire.png',
|
||||||
|
'Panhaima': 'assets/icons/mitigation/panhaima.png',
|
||||||
|
'Holosakos': 'assets/icons/mitigation/holos.png',
|
||||||
|
'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png',
|
||||||
|
'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png',
|
||||||
|
'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
||||||
|
'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
||||||
|
'Haima': 'assets/icons/mitigation/haima.png',
|
||||||
|
'Galvanize': 'assets/icons/mitigation/galvanize.png',
|
||||||
|
'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png',
|
||||||
|
'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png',
|
||||||
|
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
|
||||||
|
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
|
||||||
|
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
|
||||||
|
'Aquaveil': 'assets/icons/mitigation/aquaveil.png',
|
||||||
|
'Exaltation': 'assets/icons/mitigation/exaltation.png',
|
||||||
|
'Heart of Corundum': 'assets/icons/mitigation/heart-of-corundum.png',
|
||||||
|
'Clarity of Corundum': 'assets/icons/mitigation/clarity-of-corundum.png',
|
||||||
|
'Heart of Stone': 'assets/icons/mitigation/heart-of-stone.png',
|
||||||
|
'Knight\'s Resolve': 'assets/icons/mitigation/knights-resolve.png',
|
||||||
|
'Nascent Glint': 'assets/icons/mitigation/nascent-glint.png',
|
||||||
|
'Stem the Flow': 'assets/icons/mitigation/stem-the-flow.png',
|
||||||
|
'Stem the Tide': 'assets/icons/mitigation/stem-the-tide.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ABILITY_DR = {
|
||||||
|
'Passage of Arms': 0.15,
|
||||||
|
'Troubadour': 0.15,
|
||||||
|
'Tactician': 0.15,
|
||||||
|
'Shield Samba': 0.15,
|
||||||
|
'Dark Missionary': 0.10,
|
||||||
|
'Heart of Light': 0.10,
|
||||||
|
'Temperance': 0.10,
|
||||||
|
'Sacred Soil': 0.10,
|
||||||
|
'Expedient': 0.10,
|
||||||
|
'Collective Unconscious': 0.10,
|
||||||
|
'Holos': 0.10,
|
||||||
|
'Kerachole': 0.10,
|
||||||
|
'Magick Barrier': 0.10,
|
||||||
|
'Fey Illumination': 0.05,
|
||||||
|
'Reprisal': 0.10,
|
||||||
|
'Feint': 0.05,
|
||||||
|
'Addle': 0.10,
|
||||||
|
'Rampart': 0.20,
|
||||||
|
'Hallowed Ground': 1.00,
|
||||||
|
'Sentinel': 0.30,
|
||||||
|
'Guardian': 0.40,
|
||||||
|
'Holy Sheltron': 0.15,
|
||||||
|
'Intervention': 0.10,
|
||||||
|
'Knight\'s Resolve': 0.10,
|
||||||
|
'Aquaveil': 0.15,
|
||||||
|
'Exaltation': 0.10,
|
||||||
|
'Vengeance': 0.30,
|
||||||
|
'Damnation': 0.40,
|
||||||
|
'Raw Intuition': 0.10,
|
||||||
|
'Bloodwhetting': 0.10,
|
||||||
|
'Nascent Glint': 0.10,
|
||||||
|
'Stem the Flow': 0.10,
|
||||||
|
'Shadow Wall': 0.30,
|
||||||
|
'Shadowed Vigil': 0.40,
|
||||||
|
'Dark Mind': 0.20,
|
||||||
|
'Oblation': 0.10,
|
||||||
|
'Superbolide': 1.00,
|
||||||
|
'Nebula': 0.30,
|
||||||
|
'Great Nebula': 0.40,
|
||||||
|
'Camouflage': 0.10,
|
||||||
|
'Heart of Stone': 0.15,
|
||||||
|
'Heart of Corundum': 0.15,
|
||||||
|
'Clarity of Corundum': 0.15,
|
||||||
|
'Riddle of Earth': 0.20,
|
||||||
|
'Third Eye': 0.10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ABILITY_JOBS = {};
|
||||||
|
Object.entries(JOB_ABILITIES).forEach(([job, abilities]) => {
|
||||||
|
abilities.forEach(ability => {
|
||||||
|
if (!ABILITY_JOBS[ability.name]) ABILITY_JOBS[ability.name] = [];
|
||||||
|
ABILITY_JOBS[ability.name].push(job);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.FF14_DATA = {
|
||||||
|
JOB_ABBR: JOB_FROM_TYPE,
|
||||||
|
JOB_FROM_TYPE,
|
||||||
|
JOB_ROLE,
|
||||||
|
ALL_JOBS,
|
||||||
|
JOB_ABILITIES,
|
||||||
|
ABILITY_JOB_MAP,
|
||||||
|
ABILITY_JOBS,
|
||||||
|
ABILITY_DR,
|
||||||
|
MITIG_ICONS,
|
||||||
|
TANK_JOBS: new Set(['PLD', 'WAR', 'DRK', 'GNB']),
|
||||||
|
MELEE_JOBS: new Set(['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR']),
|
||||||
|
CASTER_JOBS: new Set(['BLM', 'SMN', 'RDM', 'PCT']),
|
||||||
|
ABILITY_TYPE_PHYSICAL,
|
||||||
|
ABILITY_TYPE_MAGICAL,
|
||||||
|
abilityTypeIsPhysical,
|
||||||
|
abilityTypeIsMagical,
|
||||||
|
};
|
||||||
|
})();
|
||||||
2100
js/planner.js
1
run_Server.bat
Normal file
@ -0,0 +1 @@
|
|||||||
|
php -S localhost:8080
|
||||||
416
scripts/update_action_json.php
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
ini_set('memory_limit', '1024M');
|
||||||
|
|
||||||
|
const ACTION_SOURCE_URL = 'https://ff14.akurosiakamo.de/extras/json/xivapi_data/Action.json';
|
||||||
|
|
||||||
|
$rootDir = dirname(__DIR__);
|
||||||
|
$mitigationSource = $rootDir . '/api/analysis.php';
|
||||||
|
$plannerDataSource = $rootDir . '/js/ffxiv-data.js';
|
||||||
|
$outputFile = $rootDir . '/assets/jsons/Action.json';
|
||||||
|
|
||||||
|
function fail(string $message, int $code = 1): void
|
||||||
|
{
|
||||||
|
fwrite(STDERR, $message . PHP_EOL);
|
||||||
|
exit($code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extract_constant_array_literal(string $php, string $constantName): string
|
||||||
|
{
|
||||||
|
$needle = 'const ' . $constantName . ' =';
|
||||||
|
$start = strpos($php, $needle);
|
||||||
|
|
||||||
|
if ($start === false) {
|
||||||
|
fail('Could not find const ' . $constantName . ' in api/analysis.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
$arrayStart = strpos($php, '[', $start);
|
||||||
|
if ($arrayStart === false) {
|
||||||
|
fail('Could not find array literal for ' . $constantName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$depth = 0;
|
||||||
|
$length = strlen($php);
|
||||||
|
$inString = false;
|
||||||
|
$stringQuote = '';
|
||||||
|
$escaped = false;
|
||||||
|
|
||||||
|
for ($i = $arrayStart; $i < $length; $i++) {
|
||||||
|
$char = $php[$i];
|
||||||
|
|
||||||
|
if ($inString) {
|
||||||
|
if ($escaped) {
|
||||||
|
$escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '\\') {
|
||||||
|
$escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === $stringQuote) {
|
||||||
|
$inString = false;
|
||||||
|
$stringQuote = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '\'' || $char === '"') {
|
||||||
|
$inString = true;
|
||||||
|
$stringQuote = $char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '[') {
|
||||||
|
$depth++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === ']') {
|
||||||
|
$depth--;
|
||||||
|
|
||||||
|
if ($depth === 0) {
|
||||||
|
return substr($php, $arrayStart, $i - $arrayStart + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fail('Could not parse array literal for ' . $constantName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extract_js_const_object_literal(string $js, string $constantName): string
|
||||||
|
{
|
||||||
|
$needle = 'const ' . $constantName . ' =';
|
||||||
|
$start = strpos($js, $needle);
|
||||||
|
|
||||||
|
if ($start === false) {
|
||||||
|
fail('Could not find const ' . $constantName . ' in js/ffxiv-data.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
$objectStart = strpos($js, '{', $start);
|
||||||
|
if ($objectStart === false) {
|
||||||
|
fail('Could not find object literal for ' . $constantName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$depth = 0;
|
||||||
|
$length = strlen($js);
|
||||||
|
$inString = false;
|
||||||
|
$stringQuote = '';
|
||||||
|
$escaped = false;
|
||||||
|
|
||||||
|
for ($i = $objectStart; $i < $length; $i++) {
|
||||||
|
$char = $js[$i];
|
||||||
|
|
||||||
|
if ($inString) {
|
||||||
|
if ($escaped) {
|
||||||
|
$escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '\\') {
|
||||||
|
$escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === $stringQuote) {
|
||||||
|
$inString = false;
|
||||||
|
$stringQuote = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '\'' || $char === '"' || $char === '`') {
|
||||||
|
$inString = true;
|
||||||
|
$stringQuote = $char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '{') {
|
||||||
|
$depth++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '}') {
|
||||||
|
$depth--;
|
||||||
|
|
||||||
|
if ($depth === 0) {
|
||||||
|
return substr($js, $objectStart, $i - $objectStart + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fail('Could not parse object literal for ' . $constantName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function read_planner_ability_names(string $sourceFile): array
|
||||||
|
{
|
||||||
|
if (!is_file($sourceFile)) {
|
||||||
|
fail('Missing planner data source file: ' . $sourceFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$js = file_get_contents($sourceFile);
|
||||||
|
if ($js === false) {
|
||||||
|
fail('Could not read planner data source file: ' . $sourceFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$literal = extract_js_const_object_literal($js, 'JOB_ABILITIES');
|
||||||
|
if (!preg_match_all('/\bname\s*:\s*([\'"])((?:\\\\.|(?!\1).)*)\1/s', $literal, $matches)) {
|
||||||
|
fail('No abilities found in JOB_ABILITIES');
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = [];
|
||||||
|
foreach ($matches[2] as $rawName) {
|
||||||
|
$name = stripcslashes($rawName);
|
||||||
|
if ($name !== '') {
|
||||||
|
$names[$name] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($names, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
return array_keys($names);
|
||||||
|
}
|
||||||
|
|
||||||
|
function read_mitigation_action_ids(string $sourceFile, array $abilityNames): array
|
||||||
|
{
|
||||||
|
if (!is_file($sourceFile)) {
|
||||||
|
fail('Missing mitigation source file: ' . $sourceFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$php = file_get_contents($sourceFile);
|
||||||
|
if ($php === false) {
|
||||||
|
fail('Could not read mitigation source file: ' . $sourceFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$literal = extract_constant_array_literal($php, 'MITIGATION_ABILITIES');
|
||||||
|
$abilities = eval('return ' . $literal . ';');
|
||||||
|
|
||||||
|
if (!is_array($abilities)) {
|
||||||
|
fail('MITIGATION_ABILITIES did not parse as an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
$wantedNames = array_fill_keys($abilityNames, true);
|
||||||
|
$ids = [];
|
||||||
|
foreach ($wantedNames as $name => $_) {
|
||||||
|
if (!isset($abilities[$name])) {
|
||||||
|
fwrite(STDERR, 'Planner ability missing in MITIGATION_ABILITIES: ' . $name . PHP_EOL);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ability = $abilities[$name];
|
||||||
|
$id = (int)($ability['extraAbilityGameID'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
fwrite(STDERR, 'Skipping mitigation without extraAbilityGameID: ' . $name . PHP_EOL);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids[$id] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$ids) {
|
||||||
|
fail('No extraAbilityGameID values found for abilities from js/ffxiv-data.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($ids, SORT_NUMERIC);
|
||||||
|
return array_keys($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
function download_url(string $url): string
|
||||||
|
{
|
||||||
|
$lastError = '';
|
||||||
|
$allowInsecureDownload = getenv('FF14_MITIGATOR_INSECURE_DOWNLOAD') === '1';
|
||||||
|
|
||||||
|
if (function_exists('curl_init')) {
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 15,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
CURLOPT_USERAGENT => 'ff14-mitigator-action-cache/1.0',
|
||||||
|
CURLOPT_SSL_VERIFYPEER => !$allowInsecureDownload,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => $allowInsecureDownload ? 0 : 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($body !== false && $status >= 200 && $status < 300) {
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastError = 'cURL HTTP ' . $status . ($error ? ': ' . $error : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$wrappers = stream_get_wrappers();
|
||||||
|
if (in_array('https', $wrappers, true)) {
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'timeout' => 120,
|
||||||
|
'user_agent' => 'ff14-mitigator-action-cache/1.0',
|
||||||
|
],
|
||||||
|
'ssl' => [
|
||||||
|
'verify_peer' => !$allowInsecureDownload,
|
||||||
|
'verify_peer_name' => !$allowInsecureDownload,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = file_get_contents($url, false, $context);
|
||||||
|
if ($body !== false) {
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastError = trim($lastError . '; file_get_contents failed', '; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lastError !== '') {
|
||||||
|
fail('Could not download Action.json. ' . $lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
fail('This PHP installation has neither cURL nor the HTTPS stream wrapper enabled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function action_field(array $action, string $field): ?int
|
||||||
|
{
|
||||||
|
if (array_key_exists($field, $action) && is_numeric($action[$field])) {
|
||||||
|
return (int)$action[$field];
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = $action['fields'] ?? null;
|
||||||
|
if (is_array($fields) && array_key_exists($field, $fields) && is_numeric($fields[$field])) {
|
||||||
|
return (int)$fields[$field];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function action_text_field(array $action, string $field): ?string
|
||||||
|
{
|
||||||
|
if (array_key_exists($field, $action) && is_scalar($action[$field])) {
|
||||||
|
$value = trim((string)$action[$field]);
|
||||||
|
return $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = $action['fields'] ?? null;
|
||||||
|
if (is_array($fields) && array_key_exists($field, $fields) && is_scalar($fields[$field])) {
|
||||||
|
$value = trim((string)$fields[$field]);
|
||||||
|
return $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function action_icon_url(array $action): ?string
|
||||||
|
{
|
||||||
|
$icon = $action['Icon'] ?? $action['fields']['Icon'] ?? null;
|
||||||
|
if (!is_array($icon)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $icon['path_hr1'] ?? $icon['path'] ?? null;
|
||||||
|
if (!is_string($path) || $path === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('#ui/icon/([^/]+)/([^/]+)\.tex$#', $path, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'https://xivapi.com/i/' . $matches[1] . '/' . $matches[2] . '.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
function plain_action_text(?string $text): string
|
||||||
|
{
|
||||||
|
if ($text === null || $text === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
return trim((string)preg_replace('/\s+/u', ' ', $text));
|
||||||
|
}
|
||||||
|
|
||||||
|
function action_shield_text(array $action): ?string
|
||||||
|
{
|
||||||
|
$description = plain_action_text(action_text_field($action, 'Description_en'));
|
||||||
|
if ($description === '' || !preg_match('/barrier|absorbs|absorbed|nullif(?:y|ies)/i', $description)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/(?:absorbs|absorb|nullifies|nullify)[^.]*?(?:totaling|up to)?\s*(\d+)%\s*(?:of\s*)?(?:your\s+|target\'s\s+)?maximum HP/i', $description, $m)) {
|
||||||
|
return $m[1] . '% max HP';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/barrier[^.]*?(?:absorbs|absorb|nullifies|nullify)[^.]*?(\d+)%\s*(?:of\s*)?(?:your\s+|target\'s\s+)?maximum HP/i', $description, $m)) {
|
||||||
|
return $m[1] . '% max HP';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/barrier[^.]*?(\d+)%\s+of\s+the\s+amount\s+of\s+HP\s+restored/i', $description, $m)) {
|
||||||
|
return $m[1] . '% of HP restored';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/barrier[^.]*?(?:heal of|Cure Potency:)\s*(\d+)\s*potency/i', $description, $m)) {
|
||||||
|
return $m[1] . ' potency';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$plannerAbilityNames = read_planner_ability_names($plannerDataSource);
|
||||||
|
$actionIds = read_mitigation_action_ids($mitigationSource, $plannerAbilityNames);
|
||||||
|
$wanted = array_fill_keys(array_map('strval', $actionIds), true);
|
||||||
|
|
||||||
|
$json = download_url(ACTION_SOURCE_URL);
|
||||||
|
$actions = json_decode($json, true);
|
||||||
|
|
||||||
|
if (!is_array($actions)) {
|
||||||
|
fail('Downloaded Action.json is not valid JSON: ' . json_last_error_msg());
|
||||||
|
}
|
||||||
|
|
||||||
|
$filtered = [];
|
||||||
|
foreach ($wanted as $id => $_) {
|
||||||
|
$action = $actions[$id] ?? null;
|
||||||
|
if (!is_array($action)) {
|
||||||
|
fwrite(STDERR, 'Missing action in downloaded Action.json: ' . $id . PHP_EOL);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filtered[$id] = [
|
||||||
|
'cast' => action_field($action, 'Cast100ms'),
|
||||||
|
'recast' => action_field($action, 'Recast100ms'),
|
||||||
|
'names' => [
|
||||||
|
'en' => action_text_field($action, 'Name_en'),
|
||||||
|
'de' => action_text_field($action, 'Name_de'),
|
||||||
|
'fr' => action_text_field($action, 'Name_fr'),
|
||||||
|
'jp' => action_text_field($action, 'Name_ja'),
|
||||||
|
],
|
||||||
|
'icon' => action_icon_url($action),
|
||||||
|
'shield' => action_shield_text($action),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$filtered) {
|
||||||
|
fail('No matching mitigation actions found in downloaded Action.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($filtered, SORT_NUMERIC);
|
||||||
|
|
||||||
|
$outputDir = dirname($outputFile);
|
||||||
|
if (!is_dir($outputDir) && !mkdir($outputDir, 0775, true) && !is_dir($outputDir)) {
|
||||||
|
fail('Could not create output directory: ' . $outputDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($encoded === false) {
|
||||||
|
fail('Could not encode filtered Action.json: ' . json_last_error_msg());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_put_contents($outputFile, $encoded . PHP_EOL, LOCK_EX) === false) {
|
||||||
|
fail('Could not write output file: ' . $outputFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo 'Saved ' . count($filtered) . ' actions to ' . $outputFile . PHP_EOL;
|
||||||
@ -21,7 +21,7 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a href="auth/start.php" class="btn btn-gold btn-login">
|
<a href="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" class="btn btn-gold btn-login">
|
||||||
<?= $tokenExpired ? 'Reconnect to FFLogs' : 'Connect to FFLogs' ?>
|
<?= $tokenExpired ? 'Reconnect to FFLogs' : 'Connect to FFLogs' ?>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@ -80,6 +80,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Ability Assignment Modal -->
|
||||||
<div id="planner-ability-modal" class="modal-overlay" style="display:none">
|
<div id="planner-ability-modal" class="modal-overlay" style="display:none">
|
||||||
<div class="modal-box ability-modal-box">
|
<div class="modal-box ability-modal-box">
|
||||||
@ -93,6 +122,7 @@
|
|||||||
|
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
<script src="js/tabs.js"></script>
|
<script src="js/tabs.js"></script>
|
||||||
|
<script src="js/ffxiv-data.js"></script>
|
||||||
<script src="js/analysis.js"></script>
|
<script src="js/analysis.js"></script>
|
||||||
<script src="js/planner.js"></script>
|
<script src="js/planner.js"></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@ -23,7 +23,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-gold" type="submit" style="align-self:flex-end">Fetch</button>
|
<button class="btn btn-gold" type="submit" style="align-self:flex-end">Fetch</button>
|
||||||
<a class="btn" href="auth/start.php" style="align-self:flex-end;text-decoration:none">Reconnect</a>
|
<a class="btn" href="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" style="align-self:flex-end;text-decoration:none">Reconnect</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,6 +22,15 @@
|
|||||||
<div class="ref-player-label">REF Spieler</div>
|
<div class="ref-player-label">REF Spieler</div>
|
||||||
<div id="ref-player-grid" class="player-grid"></div>
|
<div id="ref-player-grid" class="player-grid"></div>
|
||||||
</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">
|
<div class="ref-ext-row">
|
||||||
<button id="ref-ext-toggle" class="btn btn-sm">+ Anderer Report</button>
|
<button id="ref-ext-toggle" class="btn btn-sm">+ Anderer Report</button>
|
||||||
<div id="ref-ext-panel" style="display:none">
|
<div id="ref-ext-panel" style="display:none">
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
<!-- Left: Plan list sidebar -->
|
<!-- Left: Plan list sidebar -->
|
||||||
<div class="plan-sidebar">
|
<div class="plan-sidebar">
|
||||||
|
|
||||||
|
<div id="planner-info-panel" class="planner-info-panel"></div>
|
||||||
|
|
||||||
<div class="plan-sidebar-header">
|
<div class="plan-sidebar-header">
|
||||||
<div class="card-title">Pläne</div>
|
<div class="card-title">Pläne</div>
|
||||||
<button id="planner-new-folder-btn" class="btn btn-sm" title="Neuer Ordner">+ Ordner</button>
|
<button id="planner-new-folder-btn" class="btn btn-sm" title="Neuer Ordner">+ Ordner</button>
|
||||||
@ -26,7 +29,6 @@
|
|||||||
|
|
||||||
<div id="plan-list"></div>
|
<div id="plan-list"></div>
|
||||||
|
|
||||||
<div id="planner-info-panel" class="planner-info-panel"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Plan detail -->
|
<!-- Right: Plan detail -->
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<button class="tab active" data-tab="report">⚔ Report</button>
|
<button class="tab active" data-tab="report">⚔ Report</button>
|
||||||
<button class="tab" data-tab="analysis">⚖ Analyse</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>
|
</nav>
|
||||||
<div class="topbar-user">Token gültig bis: <?= date('Y-m-d H:i:s', $_SESSION['token_expires']) ?></div>
|
<div class="topbar-user">Token gültig bis: <?= date('Y-m-d H:i:s', $_SESSION['token_expires']) ?></div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||